Skip to content

Synchronization and provisioning decisions

Manifest/lockfile synchronization and provider-neutral artifact provisioning. Current behavior: ../sync.md.

These records explain why choices were made and may describe superseded behavior. For the supported interface, follow the current-state page linked above.

D27 — Client synchronization: manifest+revision, lockfile, layered triggers

Decision. Client realignment (skill/hook/agent) is based on three objects: a server-side provisioning manifest (revision = aggregate hash), a client-side lockfile (applied state + managed[]), and a ProvisionedArtifact abstraction with an extensible kind. Layered triggers (SessionStart hook, sync_check/sync_apply tools, optional MCP push), default notify + signature gate, managed-only prune. Rationale. A pull-based model with fingerprint is O(1) and cross-provider on a heterogeneous request/response transport; the kind abstraction avoids redesigning for hooks/subagents. Skills are executable code: the signature gate aligns sync with the supply-chain invariants. (Superseded state: Layer 3 and codex/kiro providers → D34; cartographer-configure removed in favor of cartographer connect/status/sync → D37/D40.) Details: docs/sync.md.


D34 — Synchronization: Layer 3 push over stdio + codex/kiro materialization

Decision. Two pieces of the sync model (D27) completed: - codex/kiro providers: skillDestDir (internal/provisioning) now also maps codex.codex/skills/<nome>/ and kiro.kiro/skills/<nome>/ — they move out of needs_approval to direct materialization like claude/opencode. Everything else (manifest, lockfile, diff, prune) was already provider-generic. - Layer 3 (push): Server.Notify(method, params) emits a JSON-RPC notification on the shared stdio encoder (serialized by writeMu); the notifyWrap helper wraps skill_install (outside gitWrap, fires after the commit, only on success) and emits notifications/skills/list_changed. Capability skills.listChanged: true announced in initialize.

Rationale. On the hand-rolled HTTP transport (D16: request/response, no SSE) the server has no channel for unsolicited pushes → Server.Notify is a no-op when not inside Run (enc == nil), and Layer 3 applies only to stdio, degrading gracefully to Layers 1–2 over HTTP (consistent with the "additive" design of sync.md). The crucial choice is the lock discipline: writeMu protects each individual Encode but is never held during dispatch (where Notify is called, same goroutine) → no nested locks and no deadlock; verified with go test -race. The practical value over stdio is limited (the agent calling skill_install is also the consumer), but the Notify infrastructure is the hook-in point for future emitters (server-side git pull, bundle hot-reload) with no further changes to the loop.


D40 — sync_pull and client-side trust; anti path-traversal guard in provisioning.Apply

Decision. New MCP tool sync_pull() (read-only): returns the manifest with each artifact's contents embedded in base64, so a remote HTTP client without a shared filesystem can materialize locally. Unlike sync_apply, sync_pull does not accept auto_trust: the trust decision is entirely client-side. Multi-provider lockfile v2 (LockFile{Providers: map[string]Lock}, automatic migration from v1). provisioning.Apply gains an explicit guard: artifact name and file path must be filepath.IsLocal. Rationale. The server must not decide trust on behalf of the remote client, which may have a different policy — hence sync_apply (local, trust = tool parameter) vs sync_pull (remote, trust decided by whoever materializes). The path-traversal guard is necessary because with sync_pull the paths arrive as network data (JSON), not from an already trusted filesystem. Details: docs/sync.md, docs/control-plane.md §Client synchronization ↔ provisioning.


D48 — Provisioning extended to kind: agent/hook; hooks without auto-merge into settings.json; per-kind counts

Decision. Extends internal/provisioning (already kind-generic on Manifest/Lock/Diff, D27/D34/ D40) to two new kinds. kb.Init creates agents//hooks/ (optional/backward-compat); BuildManifest scans agents/<nome>.md (one file = one agent artifact) and hooks/<nome>/ (one directory = one hook artifact). destDir(kind, name, provider) generalizes skillDestDir: for agent/hook only claude has a known destination (.claude/agents/<nome>.md single file, .claude/hooks/<nome>/ directory) — other providers remain needs_approval. Hooks with no automatic merge into settings.json: that file is user-owned with nested arrays and no safe merge/prune — Cartographer materializes only the folder and prints a manual instruction (superseded by D57: registration is now automatic). New KindCounts aggregates installed/total per kind, shown by status/TUI. Rationale. The model was already kind-generic by design (D27/D34): the extension only required new emitters and a destination branch. The non-merge of hooks follows the same least-surprise philosophy as prune (D40): a user file with nested structure is not a safe target for silent automatic writes. The single file for agents mirrors the native format of a Claude subagent, instead of forcing them into the skills' directory convention. Details: docs/sync.md §Agents and hooks, docs/interoperability.md §Provider capability matrix.


D50 — Honest per-provider sync state: unsupportedneeds_approval, InSync requires zero differences

Context. After D48, an OpenCode sync showed "in-sync" and at the same time "8 needs approval": unsupported agents/ hooks ended up in NeedsApproval (but --auto-trust would never have unblocked them) and Diff.InSync ignored artifacts left out.

Decision. AppliedResult.Unsupported (new field): destRel == ""Unsupported, no longer NeedsApproval. provisioning.FilterForProvider filters the manifest per provider before Apply/diff/counts. Diff.InSync now requires identical revision and zero Added/Updated/ Removed.

Discarded alternatives. A dedicated badge for unsupported kinds in the counts; marking the revision as not applied when NeedsApproval remain (it would have made every sync re-attempt the apply with no progress). Details: docs/sync.md §Agents and hooks.


D54 — Per-server trust persisted at connect (replaces the recurring --auto-trust gate)

Context. Artifacts from the KB arrive signed:false (D40): without --auto-trust, every sync/connect leaves them in needs_approval. The TUI did not expose --auto-trust at all, so from there they stayed pending forever — in a single-user context, "re-approving" at every sync is pure friction that verifies nothing.

Decision. Trust becomes an explicit choice made once at connect, persisted: clientconfig.Config.Trust bool (default true), toggle in the connect form shared CLI/TUI. sync/status/TUI use cfg.Trust as default; --auto-trust remains a one-off override (cfg.Trust || --auto-trust) at materialization time. status/TUI never mutate Signed: they report a separate trust state per artifact (snapshotArtifacts, D115) that reads cfg.Trust read-only to report trusted instead of needs_approval, so as not to show a false pending approval without pretending the artifact was cryptographically verified. No symmetric --no-trust: revoking is rare, manually setting trust: false in .cartographer.yaml is enough. Rationale. The signature placeholder (D40) stays identical server-side; this decision concerns only who/when decides to trust (the user, at connect, persisted — not relitigated at every sync). Discarded alternatives. A symmetric CLI --no-trust: no real use case, it would only have doubled the flag surface to maintain. Details: docs/configurator.md §.cartographer.yaml, §cartographer connect.


D55 — Agents also materialized on OpenCode, via frontmatter translation

Context. D48 materializes kind: agent only on claude — but OpenCode has its own native subagents (.opencode/agent/<nome>.md, singular dir, incompatible frontmatter: description + mode: subagent, name from the filename).

Decision. destDir maps agent×opencode.opencode/agent/<nome>.md (codex/kiro remain unsupported). The content is not copied verbatim: translateAgentForProvider (a pure function, called by Apply before writing) extracts description from the source Claude frontmatter and emits a minimal OpenCode frontmatter + verbatim body. Claude-only fields that cannot be mapped reliably (tools, model, name) are dropped, not guessed. ContentHash remains that of the source (unchanged): translation happens only at write time, never in the hash computation — drift detection stays agnostic to the destination provider. Rationale. The translation lives in Apply (client-side), not in the manifest, the same architectural choice as D48. Dropping unmappable fields avoids silently wrong behavior on an artifact that drives an agent's behavior. Discarded alternatives. Mapping tools/model with a best-effort heuristic: syntax and names diverge enough to make any automatic mapping fragile and silently incorrect. Details: docs/sync.md §Agents and hooks, docs/interoperability.md §Provider capability matrix.


D56 — instructions kind: KB imprinting via managed block in the global instruction files

Context. LLM agents connected via MCP have no way to discover that a KB exists: an "imprinting" is needed in the global instructions file that every provider always reads at each session (CLAUDE.md, AGENTS.md, Kiro's steering).

Decision. New instructions kind, one per KB. Unlike skill/agent/hook, the content does not live on disk: it is generated by generateKBInstructions(kbName, kbRoot), a pure, deterministic function that lists the KB's top-level archives and gives operational instructions (search/kb_overview/concept_read/concept_write/log_append); ContentHash is the sha256 of the generated content. Materialization as a marker-delimited block (<!-- cartographer:instructions:begin/end -->) inside the user's file — never a dedicated file (except Kiro, .kiro/steering/cartographer.md, which has no pre-existing generic file) — so everything outside the markers is never touched. Managed as a group (applyInstructionsGroup): a single file per provider contains the concatenation of all current KB instructions, rebuilt in full when anything in the kind changes. Non-destructive prune: removes only the block, deletes the file only if it ends up empty. Signature gate unchanged: unsigned instructions artifacts stay in NeedsApproval like every other kind. Rationale. The "never touch unmanaged content" rule must be applied inside a file, not at the whole-file level, because the destination is by construction a file that may already belong to the user. The marker block guarantees idempotence and non-destructiveness without semantic merge. Discarded alternatives. A dedicated file for all providers (it would not be read automatically unless already referenced by the main file); a runtime hook injecting the context (requires session-hook support, not universal, and starts from scratch every time). Details: docs/sync.md §Instructions.


D57 — Claude Code hooks: automatic registration in settings.json

Context. D48 materialized a hook (hooks/<nome>/.claude/hooks/<nome>/) but stopped there: settings.json was never touched, for fear of duplicating entries in hooks.<Event>[] at every sync (nested arrays, no ownership marker) and of not being able to prune safely — a materialized but dead hook, to be registered by hand.

Decision. For the claude provider only, Apply now automatically registers/updates the entry in <targetDir>/.claude/settings.json right after materializing the hook's files (internal/provisioning/hooksettings.go). Ownership criterion: an entry is "Cartographer's for hook <nome>" if and only if its command contains the substring .claude/hooks/<nome>/ (hookOwnershipMarker) — the materialized path is the signature, no extra field to invent. upsertHookEntry removes every entry with that marker and inserts a fresh one → idempotent. The file is decoded into a map[string]interface{}, not a fixed struct: unknown keys survive, only the key order is not preserved (acceptable: the invariant is "no user data lost"). Prune removes the entry via the same marker when the hook disappears. Rationale. A generic JSON deep-merge has no "obvious" criterion for an array without an identifying key; the per-path marker is targeted and does not require extending the entry format (discarded: a dedicated _cartographer field, risk of breakage with future Claude Code schema checks). Details: docs/sync.md §Agents and hooks.

Update (bare-command bug). resolveHookCommand joined any non-absolute first token (and non-$VAR) to the hook's dir — correct for ./notify.sh, broken for a shell one-liner like jq -e ...: it produced .claude/hooks/<nome>/jq, a nonexistent binary, and the command's || true masked the failure at every invocation. Now the join happens only if the token contains / (relative path); bare names stay verbatim, resolved via PATH as in a shell. Since the ownership marker is the path in the command, a bare command would never contain it (entry neither idempotent nor prunable): for the claude provider only, registerHookSettings appends the marker as an inert shell comment (# cartographer-hook: .claude/hooks/<nome>/) — syntactically neutral, preserves D57's substring criterion without extra fields. Codex/OpenCode do not need it (per-block/per-file ownership).


D58 — Real Codex CLI integration: managed-block config.toml, TOML agents, hook engine

The bug. Since D23, emitCodex wrote .codex/config.json Claude Code-style — but Codex CLI never reads that file: the only configuration it consults is ~/.codex/config.toml, section [mcp_servers.<id>]. The MCP integration with Codex never worked in practice.

Decision. Three extensions, verified against the official Codex docs: - MCP → managed-block config.toml. config.toml is hand-curated (comments, order): never parsed/reserialized as generic TOML. emitCodex generates only the body of the [mcp_servers.cartographer] block; Apply/Remove materialize it with the new generic internal/blocktext package (Write/Remove/ReplaceBetween on text markers) inside dedicated markers. Remove also cleans up any legacy pre-D58 .codex/config.json. - Agent → .codex/agents/<nome>.toml. destDir maps agent×codex to this path; translateAgentForCodex (parallel to D55) extracts description and puts the body in developer_instructions. Fields with no reliable equivalent (tools, model) omitted, same policy as D55: never guess a value. - Hook → materialization + hooks engine. destDir maps hook×codex to .codex/hooks/<nome>/; registration goes inside the managed block of config.toml as an array of TOML tables, with a per-hook marker (# cartographer:hook:<nome>:begin/end) — same ownership-per-block principle as D57, here per-marker in text instead of per-substring in JSON. Rationale. internal/blocktext factors out a problem that now recurs twice in config.toml (MCP entry + N hooks); the pre-existing version for instructions (D56, HTML markers) remains unrefactored — stable code, no reason to touch it alongside an unrelated change. Open question. The hook.json matcher is written for Claude Code (free substring); Codex interprets it as a regex — no automatic conversion planned. Details: docs/sync.md §Agents and hooks. Follow-up (July 2026). The TUI dashboard (mcpConfigStatus in cmd/cartographer/tui.go) checked for the MCP entry's presence with a single json.Unmarshal, still calibrated on the old config.json: for Codex the file is TOML, so the parse always failed and the mcp-config badge showed missing even with a correct config. Now, for providers with a FilePath ending in .toml, the check looks for the [mcp_servers.<name>] table instead of parsing JSON.


D59 — OpenCode hooks: generated JS plugin

Context. After D57/D58, kind: hook remained Unsupported on OpenCode: no declarative hooks to register in a file — OpenCode instead loads JS/TS plugins from ~/.config/opencode/plugins/ (auto-loaded, no entry in opencode.json).

Decision. destDir("hook", _, opencode) materializes the files in .opencode/hooks/<nome>/; Apply generates — if the KB event is mappable (PreToolUsetool.execute.before, PostToolUsetool.execute.after, SessionStart/Stop on the generic pub/sub bus filtered by event.type, other events → no plugin) — an entire deterministic plugin file cartographer-<nome>.js that runs the materialized script. Per-file ownership, not per-block: unlike D57/D58, the generated file belongs entirely to Cartographer — rewritten in full on update, deleted on prune, no block parsing. Unmappable event → AppliedResult.Warnings, not an error. The matcher (only PreToolUse/PostToolUse) uses a bidirectional case-insensitive substring comparison (a heuristic, not a guaranteed tool-name table). Rationale. One file per hook is consistent with the existing skill/agent scheme and makes prune trivial (os.Remove), discarding a single "router" plugin that would be harder to maintain incrementally. Open question. The substring matcher is a heuristic: a hook with a very specific matcher might not behave identically on the two providers. Details: docs/sync.md §Agents and hooks.


D60 — Client-side bootstrap hook: auto-sync at session start (WP4)

Context. docs/sync.md §Layer 1 described, since before D57, a SessionStart hook that invokes cartographer sync/status at session start — never implemented: D57/D58/D59 built the registration mechanism for KB hooks, but this one never comes from a KB. An artifact generated entirely by the client was needed.

Decision. internal/provisioning/bootstrap.go introduces EnsureBootstrapHook: it materializes a deterministic script (bootstrap.sh, calls cartographer sync --auto-trust) and reuses verbatim registerHookSettings/registerHookConfigTOML/registerOpenCodePlugin (D57/D58/D59) for native registration — zero new code. Reserved name cartographer-bootstrap. Called by doConnect/cmdSync before the manifest fetch, independent of server reachability. "Orphan" protection: the server manifest will never contain this artifact — ComputeDiff explicitly excludes Kind=="hook" && Name==BootstrapHookName from the Removed computation, so the bootstrap is not deleted and recreated at every sync (same principle already used for kind: instructions). Name collision with a same-named KB hook → ignored with a warning, never overwritten. kiro (no native hook mechanism) remains a no-op, degraded to Layer 2. Rationale. Protecting the bootstrap from the diff (instead of having it reappear at every sync) avoids noise (files/config rewritten every round) and a window in which the hook does not exist between prune and rematerialization. Details: docs/sync.md §Layer 1.


D61 — Instructions: auto-generated agents section + curated instructions.md (WP5)

Context. D56 generates the instructions block with archives only + generic operational instructions. It says nothing about the agents provisioned by the same KB (D48/D55): the main agent does not know local subagents exist nor when to delegate. There is also no place where the operator can write domain-specific orchestration directives.

Decision. generateKBInstructions (signature and materialization mechanism unchanged) adds two optional trailing sections, each omitted if empty: (1) an auto-generated agents section (name + description from the frontmatter of agents/<nome>.md, sorted by name); (2) an optional curated file <kbRoot>/instructions.md, free text included verbatim. The hash remains sha256(generateKBInstructions(...)): no change to BuildManifest, drift is still detected because the function now also reads these files. instructions.md is not a concept: it lives outside data/, never indexed nor materialized as a standalone file. Discarded alternatives. instructions.md as an additional skill/agent (it would require a new kind and its own destination, instead of enriching the already injected block); keeping it outside the KB in server config (it would lose the git versioning shared with the rest of the KB). Details: docs/sync.md §Instructions.


D69 — kind: mcp provisioning: third-party MCP servers distributed by the KBs

Status: active. HTTP transport only (per the starting decision). WP1–WP4 and WP6 implemented as planned; WP5 (trust) implemented with a more restrictive security choice than the plan implied (see below); the server-side allow-list (optional in the plan) not implemented, deferred to Phase 3.

Context. KBs already distribute skills, agents, hooks, and instructions to clients via the manifest→lockfile→apply flow (D27/D48/D56). A kind for third-party MCP servers is missing: today the only MCP the client configures in the agents is Cartographer itself (internal/configurator, cartographer:mcp:* blocks). All the necessary infrastructure already exists: the configurator can emit MCP config for the 4 providers (for itself), hooksettings.go has the idempotent merge + prune patterns on the providers' config files. The feature is "generalize MCP emission and hook it into the artifact flow". N.B.: the KB mcp/ folder introduced here has no relation to the mcp/ removed by D28 (that was something else, June 2026).

Starting decision. HTTP transport only in this iteration. stdio implies referencing a command/binary present on the client — more useful but thornier (distribution, paths, security); it will be added later (the type field is already in the schema for that day).

WP1 — Source format in the KB. mcp/ folder in the KB, one JSON file per server: mcp/<nome>.json (single-file like agents, not a directory like skills). Provider-neutral schema: {"type": "http", "url", "headers", "env"}. Security constraint: no secrets in the file — the values of headers/env support only ${VAR} references resolved from the client's environment (token_env pattern, D64); parseMCPServerSpec rejects a value with no ${VAR} reference at all (it looks like a literal secret), a type other than "http", or a missing url. env is validated with the same rule for future stdio compatibility, but it is not yet emitted by any provider (see WP3): none of the 4 currently exposes a verified channel for generic env vars on an "http" server — only the Authorization header can be represented reliably.

WP2 — BuildManifest. New step in BuildManifest (provisioning.go): scan of mcp/*.json for each KB → an Artifact{Kind: "mcp", Source: "kb:<nome>"} with contentHashFile (like agents). Missing folder → zero artifacts (backward compat). Schema parse+validation happens here, so a malformed file or one with a literal secret fails the build, not the apply.

WP3 — Apply per provider. Unlike skills/hooks, an MCP server does not materialize its own files: it merges into the provider's native config (internal/provisioning/mcpsettings.go, registerMCPServer/removeMCPServer): - claude: key mcpServers.<nome> in ~/.claude.json; - codex: block [mcp_servers.<nome>] in .codex/config.toml with markers # cartographer:mcp:<nome>:begin/end (pattern from registerHookConfigTOML, distinct from the unnamed cartographer:mcp:begin/end block that internal/configurator writes for the Cartographer entry itself via connect — no collision); - opencode: key mcp.<nome> in opencode.json; - kiro: mcpServers.<nome> in .kiro/settings/mcp.json.

Key refactor: internal/configurator.EmitServer(name, spec ServerSpec, provider) extracted from Emit/ServerConfig (which is now a thin wrapper over EmitServer(cfg.Name, cfg.toSpec(), provider)), used both by connect (for the Cartographer entry) and by provisioning.Apply (for the KBs' servers). Each ${VAR} reference is translated per provider: claude/kiro/codex leave it verbatim, OpenCode translates it to {env:VAR}. Native limits not worked around with heuristics: Codex exposes only bearer_token_env_var (an Authorization: Bearer ${VAR} header translates to it, every other header is dropped with a warning in EmitResult.Warnings); Kiro never had a header field for MCP servers (pre-existing limit, not introduced here) — a KB server with headers generates a warning in AppliedResult.Warnings, not an error. Invariant preserved: only own keys/blocks are managed, never the rest of the file (existing connect goldens unchanged).

WP4 — Prune and disconnect. ManagedFile{Kind: "mcp"} in the lock for each written server; PruneManaged removes the single key/block (removeMCPServer, analogous to removeHookEntries/removeHookConfigTOML), never the whole file — with the same empty-shell cleanup as configurator.Remove (D63) for kiro/opencode. Round-trip test in provisioning_disconnect_test.go extended: connect with a KB carrying an MCP server → disconnect → clean provider configs (TestRoundTrip_ConnectDisconnect_NessunResiduo).

WP5 — Trust and remote sync. An MCP server is an endpoint that receives the agent's data: Before D114/D115, BuildManifest marked the mcp kind always Signed:false, regardless of autoTrust — unlike skill/agent/hook/instructions, which autoTrust signs. The remote client likewise excluded mcp from its generic upgrade via cfg.Trust/--auto-trust: a stricter policy than the other kinds, NeedsApproval at first appearance and at every hash change, even with AutoTrust active. Deviation from the plan, since resolved: this iteration had no mechanism to mark a single mcp artifact as approved — the only generic gate was cfg.Trust/--auto-trust, which mcp ignores by construction. The persisted point approval (of what exactly, for which hash) arrived with D115 as cfg.MCPApprovals; the client-side upgrade hook it describes no longer exists, since D114 made Signed an exclusively cryptographic result. sync_pull/tools_sync.go and the HTTP client do not filter by kind (verified): an mcp artifact travels as a single ArtifactFile, same schema as an agent.

Server-side allow-list: not implemented (optional in the plan, tied to the Phase 3 "MCP registry allow-list" item below).

WP6 — Documentation and closure. docs/sync.md §MCP servers, docs/configurator.md, this entry and the release tracking state then in use. Tests: internal/provisioning/mcpspec_test.go (schema validation), internal/configurator/configurator_mcpserver_test.go (EmitServer goldens for the 4 providers), internal/provisioning/provisioning_mcp_test.go (BuildManifest/Apply/Prune), extended round-trip in provisioning_disconnect_test.go.


D75 — Path portability across machines: placeholders auto-resolved via git remote

Status: implemented (2026-07-10). WP1–WP6 complete: internal/repoindex (Scan/Resolve, cache ~/.config/cartographer/repos.json), search_roots/paths in .cartographer.yaml, client-side expansion in provisioning.Apply with hash on the expanded content, "Local paths" table in the instructions block, cartographer resolve, machine_path lint. Details → docs/sync.md §Path portability placeholders, docs/configurator.md (resolve, .cartographer.yaml).

Deviations/clarifications versus the original spec:

  • ApplyOptions gains an explicit ExpandPlaceholders bool field (besides SearchRoots/ Paths), not mentioned in the spec: it makes "the server never expands anything" a structural guarantee (the field stays false by construction in internal/mcpserver, never set there) instead of depending on the accidental absence of SearchRoots/Paths — it also prevents a server-side Resolve from attempting a LoadCache/Scan on the server's filesystem anyway.
  • Intentional consequence of hash-on-expanded-content: for an artifact containing at least one placeholder, ManagedFile.ContentHash (client, expanded) never converges with Artifact.ContentHash (server, raw) — that artifact is therefore re-materialized at every sync, not only when the KB content really changes. It is not a bug: it guarantees that a repo re-cloned elsewhere (or an updated search_roots/paths) is reflected in the expanded path at the next sync, instead of staying frozen in the lockfile. No impact on artifacts without placeholders (zero drift, tested).

Decision. In shared content (concepts and provisioning artifacts) never absolute paths. Two placeholders:

  • {{repo:<name>}} (short form) / {{repo:<host>/<owner>/<name>}} (full, canonical form) — resolved automatically: the key is the normalized git remote, identical on every team machine; the local path is discovered by scanning the filesystem. Zero manual mapping for the common case.
  • {{path:<nome>}} — manual paths: mapping in .cartographer.yaml, fallback for directories that are not git repos.

No server-side substitution: it would break content_hash/if_match (hash on the raw, served content different per user) and the server does not know the clients' filesystems. All resolution is client-side.

WP1 — internal/repoindex. Scan(roots []string): walk with depth cap (default 4) and skip of heavy dirs (node_modules, inner .git, caches); for each repo it reads remote.origin.url and normalizes it to host/owner/name (handles scp-like ssh, https, .git suffix). Cache in ~/.config/cartographer/repos.json with refresh on-miss. Resolve(key): paths: map → cache → rescan → not found. Ambiguity: short name with multiple distinct remotes → error asking for the full form; multiple clones of the same remote → deterministic root order, first match + warning.

WP2 — clientconfig. search_roots: fields (default ["~/Documents"]) and paths: (name→path map) in Config/yamlConfig (internal/clientconfig/clientconfig.go:20). Never on git: the file is already per-machine.

WP3 — expansion at materialization. provisioning.Apply expands the placeholders in the textual contents before writing to disk (write points: provisioning.go:1017, :1779, instructions block :1283). The lockfile hash is computed on the expanded content — drift detection compares the disk, it must compare like with like. Unresolved placeholder → warning on stderr and text left as-is (the sync does not block).

WP4 — "Local paths" table in the instructions block. At materialization the client collects the placeholders present in the KB's artifacts, resolves them and appends to the instructions block a key→local path table, plus the instruction for the agent: placeholder encountered in a concept and absent from the table → cartographer resolve <key>.

WP5 — cartographer resolve subcommand. cartographer resolve repo:<...>|path:<...> prints the resolved path: a runtime fallback for the agent (the binary is already on every connected machine) and a debugging tool.

WP6 — machine_path lint (warning, server-side). Flags home-anchored paths in concept bodies: /Users/, /home/, ~/, C:\Users\. Deliberately narrow pattern: absolute container/cluster paths (/etc/..., /var/...) are legitimate and identical everywhere.

Rationale. The git remote is the only identifier of a repo that is already shared and stable across the team's machines — using it as the key eliminates the one-to-one manual mapping that does not scale. Resolution lives in the client (scan+cache) and in the two channels the agent already has: the materialized imprinting (table) and the local binary (resolve). Discarded alternatives: pure manual mapping (does not scale, it was the v0 of this decision); server-side per-user profiles (identity and filesystem on the server side, hash breakage); resolution via env vars (explodes into N envs for N repos).


D116 — Trusted stdio MCP descriptors with environment references

Decision. MCP descriptors support a strictly disjoint stdio transport with a command, ordered arguments and a reference-only environment map. Commands are bare executable names or clean absolute paths; Cartographer emits command and arguments as separate native fields, never runs a shell, and preflights the executable before changing any provider file or lockfile. The server allow-list binds stdio descriptors to the exact command; D114 signatures and D115 hash-bound approvals continue to bind the entire descriptor. Claude Code, Codex, Kiro and OpenCode receive their respective native local-MCP representation; OpenCode translates ${VAR} to {env:VAR}. No environment value is resolved, persisted in a manifest/status/log, or sent back to the server.

Rationale. A local MCP server has a materially stronger capability than a remote endpoint: it causes an agent runtime to execute a binary on the client. Keeping validation, server policy, cryptographic identity, local approval and executable preflight as independent gates preserves the fail-closed boundary while allowing portable KB descriptors. Retaining a bare command instead of its resolved path preserves the client operator's intended PATH semantics.

Discarded alternatives. A single command string split by Cartographer: it would have reintroduced shell-quoting ambiguity between the approved descriptor and the executed process. Resolving ${VAR} client-side before emission: it would have written secret values into provider configuration files that are not designed to hold them.

D115 — MCP allow-list and hash-bound local approval

Decision. A KB-provided HTTP MCP descriptor is exposed only when an exact per-KB operator allow-list entry matches its name, transport and normalized absolute target URL. Empty policy denies all. A client then materializes an unsigned descriptor only when a local record binds its source KB, artifact name and full content hash; generic trust never applies. Cryptographic verification remains independent and is sufficient on the client, but cannot bypass the server allow-list.

Rationale. The server controls which endpoints a KB may advertise; the client controls consent to execute one exact descriptor. Hash-binding covers headers and environment references as well as URLs, and keeps either grant from silently authorizing a changed endpoint.


D114 — Verify provisioning artifacts cryptographically

Decision. A KB may configure an operator-controlled 32-byte Ed25519 seed. The server signs a deterministic, domain-separated binary envelope containing format version, KB source, kind, name, version and canonical content hash. The client pins public keys out of band per KB and verifies both reconstructed file content (including paths and executable bits) and detached signature before materialization. Signed is verification output only; bundle trust is the separate BuiltIn origin.

Failure semantics. An absent signature is unsigned and remains eligible for the existing explicit trust policy. A malformed, invalid, source-mismatched or unknown-key signature, a duplicate conflicting signature, or a content-hash mismatch is tampering: remote sync fails before provider files or lockfiles change. Multiple client pins support rotation: pin the new key, switch the server seed, then retire the old pin after clients have synchronized.

Rationale. A policy boolean sent by a server cannot authenticate an artifact received over a compromised transport. Canonical length-prefixed bytes avoid making JSON serialization the signed protocol and bind signatures to a precise KB artifact identity, preventing replay across KBs, kinds or names.


D105 — Binary-safe provisioning artifacts and executable KB scripts

Decision. Provisioning artifact content is raw bytes; MCP read/write uses explicit text/base64 encoding and retains raw-byte sha256 for if_match. Executability is derived from the KB filesystem, not a descriptor. Hooks impose an effective mode: every file except hook.json is executable, while hook.json is always non-executable. The versioned, domain-separated artifact hash includes that effective mode.

Rationale. Git already versions the executable bit, so the KB remains the sole registry rather than requiring a parallel metadata file. Including the effective mode in the hash makes chmod change the manifest revision and realign existing installations; normalizing the hook floor avoids drift from raw mode changes that cannot affect the materialized result. Base64 prevents byte corruption while preserving the existing text API and if_match contract.