Concurrency, commits and git synchronization¶
Writer boundary¶
Each mounted KB has one in-process mutex. Write tools acquire it for the filesystem change and the associated git operation, so one server process is the sole writer of that working copy.
Do not mount the same local checkout into multiple writer processes. Separate clones can synchronize through the same remote, but high-contention multi-writer operation is not a supported scaling model; partition KBs across instances instead.
concept_batch (D125) runs its whole multi-concept transaction under this
same single per-KB lock — it does not acquire a second one. Every operation
is validated and its content materialized in memory before the first byte is
written; if a write or the summary log.md entry then fails partway, every file the call already wrote (including any
implicit expanded-index stub a new directory triggered) and log.md are
restored to their exact pre-call bytes and mode before the error is
returned. Unlike concept_move — which documents that a rewrite_links
failure leaves the already-applied moves in place — concept_batch never
leaves a partially-applied batch on disk (and the search indexes follow the
files, D245, so they never see one either): callers
observe either the complete batch or the exact pre-call KB state. The curated
index entries a batch asks for (index_entry, D714) are part of that: they
are written last, and a failure restores the indexes already patched before
the files and log are rolled back. It does
not open a nested git commit; a rolled-back call also leaves no commit,
since gitWrap only commits after the handler returns success.
concept_transfer (D760) and artifact_move (D782) are the two writes that hold two KB locks, and with
git_conflict_resolve (one KB: it takes the KB lock itself and finalizes merge, commit and push of the
conflicts it settles) the only write tools that do not go through gitWrap; the first two run the one transaction in twokbmove.go: it takes the git lock of the
source and of the target itself, in one global order (the KBs' root paths, whichever
side is the source), so A → B and B → A running together cannot wait on each
other. Inside both locks it syncs both KBs, writes and commits the target, then
writes and commits the source, then pushes both. A failure of the second step
leaves a revert commit on the target, never a reset. artifact_move is the same
for skills, hooks, agents, mcp servers and templates.
The advisory KB lock¶
Inside one server process, git operations are serialised by a per-KB mutex. That mutex is blind to
any other process, and cartographer import is exactly that: a separate process writing into the
same directory the sync loop manages. Two processes interleaving git operations on one index corrupt
it, so both take an advisory lock file (D155), .cartographer.lock in the KB
root (dot-prefixed, so every walker already skips it, and excluded via .git/info/exclude so it can
never dirty the working tree).
Advisory, not mandatory, on purpose:
- a lock whose recorded pid no longer exists is reclaimed with a note on stderr — a crash must not block a KB forever;
- the server waits for a bounded window and then proceeds under its mutex alone, reporting the condition: a lock held by something else must not stop the server from writing;
importfails fast, naming the holder and how to stop it. An import is an operator action at a keyboard, and a silent ten-minute block is worse than an error.--dry-runwrites nothing and never takes the lock.- a holder never waits on itself.
importkeeps the lock for its whole run and commits through the same path the server uses; a KB that already holds the lock goes straight to the write instead of polling its own lock until the timeout. - contention waits, a failure does not. Only "someone else holds it" is polled until the timeout; any other error from the lock call comes back immediately, naming itself, instead of costing a full wait and then reporting a holder that does not exist.
The exclusive lock is flock(2) on Unix and LockFileEx on Windows
(internal/kb/lockfile_unix.go, internal/kb/lockfile_windows.go). Windows byte-range locks are
mandatory rather than advisory, so the Windows side locks a single byte far past the end of the
metadata: the contending process must still be able to read the pid, timestamp and command line to
name the holder. processAlive follows the same asymmetry — on Windows a pid that exists but cannot
be opened counts as alive, because honouring a stale lock only costs a message while reclaiming a
live one is the corruption this section exists to prevent.
An in-progress rebase makes sync refuse. When .git/rebase-merge or .git/rebase-apply exists
(a real rebase, or an orphan autostash an aborted pull --rebase --autostash left behind), SyncIn/SyncOut
detect it first and return an error that names the remedy, where git's own "there is already a
rebase-merge directory" names none: rebase --continue/--abort for a real rebase, or the
stash list inspection followed by removing the directory for an orphan autostash. Detection plus
instruction, never automatic deletion: the directory may hold a real operator rebase, or an
autostash holding the only copy of uncommitted work, and deleting another writer's state is not the
server's call.
The advisory client lock¶
The client side has its own advisory lock, for the same reason and with a different scope:
.cartographer-client.lock in the client's target directory, taken by every path that
read-modify-writes the lockfile or .cartographer.yaml (sync, disconnect,
doctor --repair-hashes, the TUI's sync actions). The concurrent writers are separate
cartographer sync processes — the session-start bootstrap hook starts one per agent session —
so the lock keeps one process from overwriting another provider's lockfile entry (D172). It is an
flock on unix and a LockFileEx byte-range lock on Windows, released on every exit path, with a
bounded 30s wait that fails naming the file rather than proceeding. Details and the surrounding order of operations: sync.md §The client lock.
How changes land¶
There is one way to write (D903): every successful write tool (all but the three that run their own
transaction, above) is wrapped by gitWrap and
produces one commit per logical operation, on the branch currently checked out in the KB clone,
whatever the configuration. No setting turns the commit off; the only KB that does not commit is
a folder that is not a git repository. Multi-file operations are committed together, individual
file replacement is atomic, and Cartographer creates no working branch, opens no pull request,
talks to no forge and never merges into main: it pushes to the KB's branch itself, and the review
that matters happens before the write, in the lint gate, commit_gate, the token roles and the
audit trail, with every write a revertible commit. Repository review policy belongs to the remote
hosting workflow, and a remote that protects the branch Cartographer writes to is answered in
§Remote synchronization.
Each commit names the writer as author and committer (D904, D950): the request's
Cartographer-Author header, else the identity configured for the KB in the server file
(git.identity, git.identities), else the token (<token id> <cartographer@<host>>). A forge with a membership push rule checks the
committer too, so it is the writer, not the server; the instance that recorded the commit is in a
Cartographer-Host trailer, and a write's message ends with a Cartographer-Token: <token id>
trailer, both appended by the server. A call that commits to two KBs (concept_transfer,
artifact_move) gives both commits the writer of its header; without one each KB's own configured identity applies. A commit that is not a write (the external
changes commit, a sync merge, conflict resolution) is by the KB's configured identity, else the
server. A sync that rebases local commits onto the remote gives each its author back as committer
(gitx.RealignCommitters). Format and validation: control plane.
A KB syncs exactly when it has a remote, and that branch must be the KB's canonical branch:
the branch of the settings.yaml committed on the remote's default branch when it sets one
(D335, D903), otherwise the remote's default branch (D264). branch lives only in that file, never
in the server's kbs[], because it is a decision of the team that owns the KB, the same for every
server that mounts it; and it is read only from the default branch: the working copy's file
is ignored, so a change of branch committed on any other branch has no effect until it reaches
the default one. Without the key, Cartographer asks the remote on every sync (git ls-remote --symref, and
re-points origin/HEAD at the answer, since the one git clone wrote is never
refreshed by a fetch); when the remote's HEAD names a branch it does not have —
a self-hosted bare repository initialised with master and then pushed main —
the canonical branch is main if the remote has it, and unknown otherwise. Cartographer never
deletes or force-pushes a branch on the remote and never checks out a different branch in the
clone, except the lossless switch of SyncIn (below). The single exception to creating none is the first push of a KB to an empty remote,
which creates main with upstream tracking. A KB made for an empty remote by
kb create --remote is pinned to main and given its initial commit; a server never does that by itself (D906).
With branch set, the remote's default branch plays no part for that KB beyond holding the
file: the configured branch is the only one pulled and pushed, and the first push creates it on
the remote, with upstream tracking, when it is missing — on an empty remote as on one that already
has other branches. No other branch is ever created. A fresh clone is checked out on it (tracking
it when the remote has it, branched from the remote's default branch otherwise); an existing clone
is switched only by the sync, and only when that loses nothing (below). A server's kbs[].branch takes the place
of branch for that server (D1093). A name git would not accept is a finding in the file and never a branch.
Each commit subject is <tool_name>: <resource>, so the history of a KB is
readable as an audit trail. The resource is built from the arguments that
identify what the write touched: path for the artifact tools, concept_id/path
for the asset tools, and otherwise the first of id, name, source_id,
contradiction_id present in the call. A tool invoked with none of them commits
as the bare tool name.
There is no pull-request flow: a git_profile: server (in a KB's settings.yaml, a kbs[] entry or the server's git:
block) or a kbs[] entry that sets git_branch keeps the KB from mounting, and the message says to unprotect the
branch or set branch.
Remote synchronization¶
Every git command Cartographer runs gets LC_ALL=C, over the process environment and any per-KB
variable: outcomes such as "nothing to commit" or a rebase conflict are recognised from git's English
messages, and a git built with translations prints them in the user's locale.
A KB with an origin remote syncs; one without never contacts anything (D903). The two timings
below are constants, not settings: a 30s freshness window and a 3s push debounce.
SyncInfetches and runs pull/rebase/autostash before a write. A freshness window of 30s skips repeated fetches in a burst. A read-only tool never waits for it (D258): when the window has expired it is served at once from the local clone and starts the same refresh in the background, one per KB at a time, under the same KB lock; what that refresh pulls is visible from the next call on, and a failure there is non-fatal. Every fetch is bounded (15s; on Unix the whole git process group is killed on expiry, on Windows git is killed andWaitDelaybounds the pipes). A push is bounded the same way (60s,gitx.PushTimeout) and a timed-out push is not retried:SyncOutfails at once and the commit waits locally for the next write. No git command prompts for a credential (GIT_TERMINAL_PROMPT=0, ssh in batch mode, D1004). After a failed fetch reads skip the fetch for 60s, so a remote that is down costs one bounded fetch rather than one per call; a write still fetches, synchronously (D237). When that fetch fails, the write proceeds only ifHEADhas no commit missing fromorigin/<branch>— the base is as current as the last successful fetch left it: it commits locally, its push is deferred, the state becomesdegradedwithlast_errorstartingfetch failed, writing on local base, and the write response carries that warning. With unpushed commits, or no tracking ref to compare against, the write still fails. The next successful sync clears the state and rebases any fork a colleague's push created meanwhile (D311).- The write and local commit run under the same KB lock.
SyncOutqueues a push 3s after the last write (a burst of writes is one push). Sync-sensitive operations and graceful HTTP shutdown flush pending work. On a platform that freezes an idle instance — Cloud Run, which setsK_SERVICE— a debounced push would be stranded until the next request, so every push is made before the write returns, with no setting to turn on (D903).- A rejected push retries through fetch + pull/rebase + push, with bounded
backoff; automatic force is never used. A push the remote refuses because the
branch is protected (GitHub
GH006, GitLab "protected branches", or apre-receive hook declined, matched on the wording, not tied to a provider) is reported as such and not retried:sync_statusisfailedandlast_errorsays to unprotect the branch or setbranch; the write itself stays committed and safe in the clone, and the response carries the same warning.
sync_status is the authoritative read-only view of replication: it reports
whether the KB is not a git repository (disabled), has no remote, a push is pending, the latest
commit/push failed, or writes are blocked (degraded), together with the best
available ahead count. A failure is
cleared only after a real push succeeds. Offline writes remain local-successful;
in debounced mode their response reports pending because the later result is
not yet knowable.
Before its pull, SyncIn compares the checked-out branch with the
canonical branch. An empty remote has nothing to pull and the sync succeeds. A
different branch is refused with ErrBranchDiverged: the state becomes degraded,
the write is not performed, and last_error names the clone, both branches and the
recovery — merge the stray branch into the default branch on the remote, or check
out the default branch in the clone, then restart. With branch the
recovery is to check out the configured branch in the clone, or change the key on the
default branch; with the server's kbs[].branch (D1093), which wins over the KB's branch,
to check it out, or change or remove that key; a configured branch the remote does not have yet is not a divergence,
there is just nothing to pull. Before refusing, SyncIn tries to switch the clone to the
effective branch itself (D1093), whichever the source: it does when HEAD is on a named
branch, the working tree is clean and nothing is unpushed on it (a branch never pushed counts as
unpushed), and the target can be built without merging, resetting or deleting anything: the local
branch, else origin/<branch>, else a new branch from the remote's default branch. It logs one line and goes on with
its pull. When it cannot, the state is degraded and last_error ends with the reason
(uncommitted changes, N commits unpushed, no branch to create it from). Other divergence is never repaired
automatically, since merging two histories is the operator's decision. Reads keep
working from the local clone (their background refresh logs the error). SyncOut
repeats the guard for anything that bypassed SyncIn (the freshness window, a
checkout changed in between): it pushes only a branch the remote already has, or
main to an empty remote (or the configured branch, kbs[].branch included, which it creates), and
otherwise leaves the commit local and reports the same error. sync_status shows
branch and remote_default_branch side by side, with branch_source saying
whether the canonical branch comes from server (kbs[].branch), settings (the KB's branch key) or from the remote;
the next sync that finds the clone back on the canonical branch clears the
degraded state.
Without a remote, synchronization is a no-op and local commits still work.
Conflict registry¶
A rebase conflict is aborted immediately. Reserved files (log.md, index.md,
_map.md, _archive.md) are not concepts, so the server reconciles them itself
(D311):
data/log.mdcarriesmerge=unionin the KB's committed.gitattributes(written byInit; a KB that lacks it gets it in a separatechore: set merge=union for data/log.mdcommit after its first successful sync in a process), so two concurrentlog_appends merge into both entries.- A conflict whose files are all reserved is settled in place by the pull
(in
SyncInand inSyncOut's retry): one merge commit of the remote SHA withlog.mdset to the union of both sides and the structural files to the remote's version —index.mdis regenerated by the next write. The write goes on as if the pull had succeeded. - A reserved file that conflicts alongside a concept stays out of the registry; the write's error names it, and it takes the same automatic resolution when the concept conflicts are finalized.
- A reserved-only conflict the server could not settle is registered with
kind: reservedand its path asconcept_id;git_conflict_resolveacceptsunion(onlylog.md),oursortheirsfor it, neveredit.
No message hands the agent a git command. For conflicting concept files the
server records local/remote SHAs in
.cartographer/conflicts.json and marks the working copy's concept
status: degraded.
The KB remains available for unrelated concepts. conflicts_list exposes the
registry and sync_check reports open_conflicts.
git_conflict_resolve(concept_id, strategy, body?) records one of:
ours— retain the local version;theirs— retain the remote version;edit— use the supplied complete reconciled file;union— both sides' lines, for a reservedlog.mdentry only.
After every registered conflict has a resolution, one merge transaction materializes the selected contents, commits and attempts the push. On failure it aborts the merge and keeps the registry so resolution can be retried.
Optimistic content concurrency¶
concept_read returns normalized content hashes. Write tools that accept if_match reject an update with stale_write when the
stored content no longer matches.
There are no advisory per-concept leases or session locks. Concurrency safety comes from the KB mutex, content hashes and git conflict handling.
Search index concurrency¶
The derived indexes are read and written by concurrent requests — under HTTP a
search and a concept_write land on different goroutines — and they sit
outside the writer boundary above: the KB mutex serialises the filesystem and
git work, not the in-memory index.
search.Index is a pair of plain maps with no synchronization of its own, so
the liveIndex wrapper in internal/mcpserver owns that discipline. Its read
lock is held for the whole query, not just long enough to load the index
pointer: ranking walks the same maps an incremental add/remove mutates, so
a search that ranks after releasing the lock races every concurrent write, and
the usual symptom is a concurrent map read and map write panic rather than a
stale result. The allow predicate a caller passes is invoked while that lock
is held and must not call back into the same liveIndex.
Incremental updates come from one place: the per-KB searchReconciler
(D245). Its mutex serialises reconciliations — a second concurrent search
waits, then finds an empty delta — and is taken outside both the KB mutex and
the graph-cache mutex, which ConceptChanges takes on its own. reindex(full:
true) rebuilds under the same mutex, so a rebuild and a reconciliation never
interleave.
Graph cache concurrency¶
The link-graph cache (D241) has its own mutex on the KB, separate from the
writer boundary. It serialises validation — the stat walk and any re-parse —
and the publication of a new view. A view is immutable once published: every
graph reader takes the current one after validating and traverses it without
the lock, so a concurrent write can only make the next read see a newer
view. Writes are atomic renames, so a validation reads either the old file or
the new one, never a torn one. The maps a view exposes (Links,
IncomingLinks) are shared across readers and must never be mutated.
Operator recovery¶
Cartographer does not promise automatic repair of arbitrary interrupted git state. Before mounting a KB, the checkout should have no active merge/rebase and no unexplained working-tree changes. Back up the repository and follow the deployment recovery procedure for storage or remote failures.