ADR-011: Workspace freshness: git remote drift detection
Date: 2026-05-24 (revised 2026-05-25) Status: Accepted (Phase 1 in SYN-93; Phase 2 implemented in SYN-93b, 2026-06-04) Author: Rômulo Febasi
Context
ADR-010 (workspace federation) lets one machine register many
workspaces and ADR-010 §F4 captures git_remote automatically on
register. Once workspaces start being shared via git between
team-mates, the file you read locally can be silently behind the
remote, and Synapse, by default, returns confidently wrong data.
For a tool whose value is “the AI consults your world with your facts”, returning stale facts is the worst failure mode: confident, silent, undetectable.
The user proposed: every N minutes, check whether the local workspace
is in sync with origin. If behind, warn (in the CLI and through the
MCP) and recommend git pull. Crucially, never block the user.
This ADR locks the design so SYN-93 lands opinionated, not improvised.
Decision
Synapse exposes a freshness signal per workspace: an advisory status derived from a periodic comparison between local HEAD and the configured git remote. The signal is advisory, never authoritative and never blocking. It surfaces in two places:
- CLI: a one-line note on stderr before the regular output of
commands that read from a workspace whose freshness is
behindordiverged. - MCP: every tool response that returns data sourced from a
workspace embeds a
freshnessfield; the AI assistant decides whether to surface it to the human.
Design rules
- Never blocking. A freshness check that fails (network error,
timeout, auth problem) does not fail the command. Status falls
back to
unreachable; the command runs normally. - Bounded latency. The async dispatcher has a hard 3-second timeout per remote check. Past that, the check is aborted; the stored status keeps its previous value.
- Async after the work. A command that reads a workspace triggers a background check after the command has already responded to the user. The current command sees the previous check’s result, which is acceptable because the goal is detection, not blocking.
- Rate-limited. A check fires at most once per workspace per
freshness.interval_minutes(default 30). Two parallel commands seeing a stale stamp may both fire, the race is benign (last writer wins). - Local-only by default. CLAUDE.md forbids shelling out to git
from production code, and the simplest signal (drift since the
user’s last
git fetch) already covers the most common failure mode. Phase 1 (SYN-93) compares the localrefs/heads/<branch>SHA with therefs/remotes/origin/<branch>SHA: both read directly from the filesystem, no subprocess, no network, no auth surface. Phase 2 (SYN-93b, deferred) layers a realgix-protocolls-refs call on top to detect drift the user hasn’t fetched yet, with the 3s timeout andunreachablefallback. The two phases share the same status enum and the same registry columns. - Network path is opt-out, not opt-in. Default is enabled when a
workspace has a
git_remote. Disable per-workspace withsyn ws freshness off <name>or globally in config:[freshness] enabled = false. TheSYNAPSE_NO_FRESHNESS=1env var disables for the current process only (CI-friendly). - No fetching. Synapse never runs
git fetch. Recommendation output suggests it to the user; execution belongs to the user. - Privacy: the check uses whatever git credentials are already
available (SSH agent, credential helper). Synapse stores no
credential and exposes no remote URL beyond what
git_remotealready cached.
Registry schema delta (lands in SYN-93)
Three columns added to the workspaces table:
ALTER TABLE workspaces ADD COLUMN last_remote_check TEXT;
ALTER TABLE workspaces ADD COLUMN remote_status TEXT;
ALTER TABLE workspaces ADD COLUMN remote_behind INTEGER;
remote_status is one of:
| value | meaning |
|---|---|
unknown | check never ran, or no git_remote |
up_to_date | local HEAD matches remote HEAD of the tracked branch |
behind | remote has commits the local doesn’t (count in remote_behind) |
ahead | local has commits not pushed (often fine for solo workflows) |
diverged | both have commits the other doesn’t |
unreachable | last check timed out, hit auth error, or network failed |
MCP payload shape
Every tool that returns workspace-sourced data adds a freshness
field:
{
"result": "...",
"freshness": {
"workspace": "client-alfa",
"status": "behind",
"behind_count": 5,
"last_check": "2026-05-24T19:42:00Z",
"advice": "git -C /Users/.../client-alfa pull"
}
}
advice is always a concrete command the user can copy; the AI is
encouraged to surface it verbatim. The field is omitted entirely
when status is up_to_date or unknown: silence is the polite
default.
CLI advisory
When a command opens a workspace whose remote_status ∈ {behind, diverged}, the binary writes one line to stderr before the
normal output:
syn: workspace 'client-alfa' is 5 commit(s) behind origin (last check 12m ago).
syn: run `git -C /Users/.../client-alfa pull` to refresh.
Suppression: SYNAPSE_NO_FRESHNESS=1, --no-freshness CLI flag, or
the [freshness] silent_cli = true config.
unreachable status produces no stderr: too noisy on flaky
networks. The status is still visible in syn ws list.
Phasing
- Phase 1 (SYN-93): filesystem-only. Reads
.git/HEADto find the current branch, then comparesrefs/heads/<branch>withrefs/remotes/origin/<branch>.behind_countis reported as a precise count when the local tip is reachable from the remote tip (or vice versa); when histories have diverged, status isdivergedand the count is set to-1(not computed in Phase 1 to keep the implementation walk-free). No subprocess, no network, no credentials. - Phase 2 (SYN-93b, shipped 2026-06-04):
freshness::check_remotelayers a real ls-refs handshake over Phase 1 viagix(no shelling out). It runs on a worker thread under a hard 3s timeout; any failure folds intounreachable, and the runner then retains the previously known status on the row (only bumpinglast_remote_check) so a transient offline moment never erases a real “behind”.behind_countis precise when the remote commits have been fetched (a bounded 1000-commitgix::rev_walk), and a count-lessBehindwhen the drift is brand-new (objects not yet fetched). Credentials come from the system (SSH agent / credential helper); Synapse stores none. The CLI advisory + MCP enrichment surface the richer status with no changes: except one new string,fresh-behind-unknown, for the count-less case.
Sub-features deferred to backlog
syn ws pullthat orchestratesgit pull(SYN-209 already covers the broader cross-machine sync question).- Auto-fetch (vs. only ls-remote): rejected. Pulling refs without user intent is closer to acting than informing.
- Cross-VCS support (jj, fossil, hg): out of scope; revisit when someone asks.
Consequences
- + Solves the silent-staleness failure mode without surprising the user (advisory only, opt-out at three levels).
- + Adds usefulness to MCP: the AI can frame answers as “this was last refreshed 14m ago; the source may be 5 commits behind”.
- + Discoverable via
syn ws list(the column ships in SYN-93 too). - − Background async needs care: the dispatcher must not leak
child processes when
synexits. Mitigated by a 3s timeout and a detach-on-exit policy. - − Workspaces with auth-locked remotes will sit at
unreachableforever for users with no credential. Documented; also produces no noise.
Alternatives considered
- Synchronous check inside every command. Rejected: adds 100-2000ms latency to every read. Async background gives the same signal with negligible foreground cost.
- Cron-style daemon (
synd) that polls in the background. Rejected: adds a moving part (process lifecycle, restart, logging) for marginal win over the in-process spawn-after-command pattern. - Detect via webhook from GitHub. Rejected: only works for GH, requires server setup, leaks workspace identity.
git fetchinstead ofgit ls-remote. Rejected: pulls refs the user didn’t ask for; counts as acting on the user’s repo.- Always-on, no opt-out. Rejected: corporate networks rate-limit
outbound git over time; a CI machine that runs
synin scripts would spam ls-remote.