Drift and conflicts
Two people edit the same page. Contexo handles that in two places, and it helps to keep them apart:
- Drift is a read-time warning. Your local copy is behind the server; nothing has failed yet.
- A conflict is a write-time rejection. You pushed with a stale
parent_shaand the server refused that file with a 409.
Drift detection exists to make conflicts rare. When it works, the agent learns the page moved before it edits, and pulls first.
Where the parent sha comes from
Section titled “Where the parent sha comes from”Every push sends a parent_sha per file, read from .contexo/.sync/state.json:
{ "last_pull_sha": "a4b2c1d3…", "page_shas": { "wiki/concepts/stripe-subscription.md": "a4b2c1d3…" }}That map is written by pushes and pulls only. Editing a page locally does not touch it — which is exactly the point: it records the version you started from, so the server can tell whether you edited the current version or an old one.
A page with no entry in page_shas (never pushed, never pulled) is pushed with an empty parent and
can’t conflict, and can’t drift either — there is no baseline to compare against.
Drift, as the agent sees it
Section titled “Drift, as the agent sees it”When an agent reads ctx://wiki/<slug> or ctx://raw/<session-id>, the MCP server compares your
recorded sha for that page against the server’s current one. If they differ, it fetches the diff and
prepends this block to the page bytes:
<DRIFT_NOTICE>This page changed on the server since your last pull. your version: a4b2c1d server now: b7e9f20
What changed: ~ frontmatter changed ~ ## Concepts + ## Webhooks - ## Open Questions
Consider `ctx pull` before editing this page. If you push withoutpulling, the server will 409 unless your local parent_sha matches.Call ctx_diff(slug=...) for the full per-section diff.</DRIFT_NOTICE>One line per changed section — + added, - removed, ~ modified, ~> renamed — so the notice
stays smaller than the page it is attached to. When a page changed only outside its ## sections you
get (changes outside structured sections) instead of a section list.
Four properties of the check that decide whether you’ll ever see it:
- It runs on page reads only.
ctx://index,ctx://tagsandctx://searchare served straight from disk with no drift check. - It needs a baseline. Pages that have never been pulled are skipped, including pages you authored locally and haven’t pushed.
- The result is cached for 60 seconds per page. An agent re-reading the same page in a loop triggers one round-trip, not fifty. A teammate’s push shows up on the next read after that window.
- It is best-effort. No credentials, no network, a 5xx from the server, an unparseable diff — all degrade to “no notice”. Drift detection never breaks a page read, and a failure is not cached, so the next read retries.
Set CONTEXO_DRIFT_DISABLE=1 in the agent’s environment to turn the check off entirely — useful when
you’re offline and don’t want the latency of a doomed HTTP call on every read.
Drift, as you see it
Section titled “Drift, as you see it”ctx status runs the same comparison across every locally tracked page:
Initialized: yes
Server: https://contexo.example.com
Repo: acme-api
Authenticated: yes (legacy API key)
User: dev <dev@example.com>
Local pages: 1
Last pull: (never)
Pages never pushed: 0
Pages drifted on server: 0When pages have moved, that last line becomes Pages drifted on server (run `ctx pull` to refresh): 2,
followed by one indented row per page showing the path and (local <sha> → server <sha>). It is one
request per tracked page, so on a big knowledge base ctx status --no-drift is the fast,
offline-friendly version.
The conflict loop, when the agent is driving
Section titled “The conflict loop, when the agent is driving”If the agent pushes anyway — because drift was disabled, or the teammate pushed in the last minute,
or the page was edited from a different machine — the server accepts the files whose parents match
and rejects the rest with a 409. ctx_push turns that rejection into a directive rather than an
error:
<MERGE_REQUIRED>Push rejected: 1 file(s) have moved on the server since your edit.
Reconcile each file below by writing a merged version thatincorporates BOTH your changes AND the server's changes, thenre-invoke ctx_push. The state has been updated so the re-pushuses the server's current sha as the new parent.
─── FILE 1/1: wiki/concepts/stripe-subscription.md ─── ancestor (both diverged from): a4b2c1d server now: b7e9f20
YOUR changes vs ancestor: ~ frontmatter changed ~ ## Concepts
SERVER changes vs ancestor: ~ frontmatter changed ~ ## Concepts + ## Webhooks
CONFLICTING regions (both sides changed): ## Concepts — synthesize both edits into one coherent version
--- ANCESTOR VERSION --- <full text of the version you both started from> --- YOUR VERSION (the one you tried to push) --- <full text of what you tried to push> --- SERVER VERSION --- <full text of what is on the server now>
STEPS for the agent: 1. For each file above: write a merged version via ctx_write_page(slug=..., type=..., body=<merged content>) … 2. Re-invoke ctx_push with the SAME filters. …</MERGE_REQUIRED>It is a three-way merge presented as text: the common ancestor, your version, the server’s version,
plus both one-sided section diffs so the model can see who changed what. Sections modified on both
sides are called out as conflicting regions with an explicit instruction to synthesise rather than
pick a winner. When no section overlaps, the block says so — (No section was modified by both sides — auto-mergeable in principle.) — and the merge is mechanical.
The whole loop is: push → <MERGE_REQUIRED> → ctx_write_page with merged content → push again.
Only the rejected files wait for that second push: in a mixed batch the files whose parents matched
were committed by the first one, and the directive lists just the ones that weren’t.
Reconciling by hand
Section titled “Reconciling by hand”ctx push has no merge machinery. On a 409 it prints N conflict(s): to stderr with a
current=… expected_parent=… line per file, tells you to
Resolve by running 'ctx pull', merging the conflicting pages, then 'ctx push' again, and exits
non-zero with push: N conflict(s) remain. Files whose parents did match are already committed.
Follow that advice literally and you will lose your edit, because ctx pull overwrites — it does
not merge. Any page that changed on the server is rewritten with the server’s bytes, and your local
version is gone. So do this instead:
# 1. See exactly what your local copy would change, while you still have it.ctx diff stripe-subscription --local
# 2. Keep a copy of your version.cp .contexo/wiki/concepts/stripe-subscription.md /tmp/mine.md
# 3. Take the server's version.ctx pull
# 4. Merge /tmp/mine.md into the pulled file by hand (or with your editor's merge tool).
# 5. Preview, then push.ctx push --show-diffStep 1 is the one people skip. ctx diff --local is the only view of your divergence that exists
before the pull destroys it, and it is section-aware, so it tells you which headings you actually
touched.
Step 5 shows the [EDIT] preview and asks for confirmation before altering an existing page. Pass
--yes only when you’re scripting; without a TTY the push refuses to alter existing pages rather
than proceeding unattended.
How to not be here
Section titled “How to not be here”- Pull at the start of a session. The
ctx_pulltool description tells agents to do exactly this. - Leave drift detection on. The notice is cheap and it is the only warning that arrives before the edit.
- Push soon after you write. The window between writing and pushing is the window in which someone else’s push turns yours into a merge.
ctx statuswhen you come back to a project after a few days.
Environment switches
Section titled “Environment switches”| Variable | Effect |
|---|---|
CONTEXO_DRIFT_DISABLE=1 |
No <DRIFT_NOTICE> on MCP page reads. ctx status --no-drift is the CLI equivalent. |
CONTEXO_DISTILL_DISABLE=1 |
ctx_push never pauses for the source-page handshake. |
CONTEXO_CAPTURE_DISABLE=1 |
ctx capture turn no-ops, so no buffer is written for the session. |
See also
Section titled “See also”ctx_push— the tool that produces<MERGE_REQUIRED>ctx status— the drift report, and--no-driftctx diff—--localbefore you pull, sha-to-sha after- The daily flow — the push path that leads here
