ADR 0019 - Foreign primary checkouts are immutable

  • Status: Accepted
  • Date: 2026-07-03
  • Tracking: internal pre-Codeberg tracker (1912)

Context

ADR 0007 (structural fail-closed, multi-repo) introduced dir= targeting: a madtea agent can name any other on-disk git repository as the target of a mutating local-git tool (madt_branch_create, madt_commit, madt_push, …). ADR 0009 (non-overridable guards) established that the agent-facing safety hooks are structurally enforced with no opt-out.

The cross-repo intent contract (ADR 0007 amendment) extended dir= to any repository on disk — not just siblings sharing a workspace root — and built the worktree flow as the sanctioned mechanism for agents contributing to a foreign repo:

  1. madt_worktrees(action="add", dir=<foreign-primary>, path=.worktrees/<task>, branch=<name>)
  2. madt_commit(dir=<linked-worktree>, files=[...], message="...")
  3. madt_finish(dir=<foreign-primary>, branch=<name>)

The cross-repo intent contract permitted mutating calls against the foreign repo’s PRIMARY checkout as long as the working tree was clean (no uncommitted tracked-file changes). This was designed to allow madt_branch_create to start feature work in a sibling repo.

The problem: a PRIMARY checkout’s checked-out branch and working tree are shared state — another agent, user, or concurrent linked worktree may depend on the primary’s current HEAD. Allowing madtea to move that HEAD (via madt_branch_create) or write a commit into it (via madt_commit) without the repo’s “owner” having explicitly created a work surface for madtea is a class of interference that the clean-tree guard alone cannot prevent.

The explicit worktree flow (madt_worktrees action=add) was already the recommended path. Blocking the primary makes it the only path — a stronger, structural guarantee consistent with ADR 0009’s non-overridable posture.

Decision

The PRIMARY checkout of a FOREIGN repository is immutable to madtea.

“Primary checkout” is defined structurally: a directory where git rev-parse --git-dir equals git rev-parse --git-common-dir. In a linked worktree those two differ. In the primary they are the same.

“Foreign” is defined structurally (from ADR 0007 amendment): the target’s git-common-dir differs from the MCP server’s own launch-fixed working-directory repo’s git-common-dir. Same-repo linked worktrees (sharing the primary’s git-common-dir) are NOT foreign.

Three tools are blocked when the combined predicate holds:

ToolRefusal reason
madt_branch_createMoving a foreign primary’s HEAD is shared-state clobber.
madt_commitWriting into a stranger’s primary tree (even with files= named) modifies HEAD and the working tree without an explicit grant.
madt_pullA pull rewrites the primary’s checked-out branch and working tree — the same class as a checkout move.

The refusals are non-overridable (ADR 0009 posture): no dir= flag, no git config key, and no hook opt-out bypasses them.

Linked worktrees of foreign repos remain the SANCTIONED writable surface. IsForeignPrimaryCheckoutDir returns false for a linked worktree, so the existing per-tool rules apply to linked worktrees unchanged: madt_commit still requires files= (no all=true), madt_branch_create still refuses a dirty tree.

Staging section superseded by ADR 0024 (2026-07-10): the “(no all=true)” phrasing above predates the changes that removed blanket staging entirely. Read it as: madt_commit requires declared files=[...] — there is no all=true mode left to exclude. This narrows only the staging wording; the foreign-primary immutability decision is unchanged.

The prescribed remedy (taught verbatim in every refusal message):

madt_worktrees(action="add", dir=<foreign-primary>, path=".worktrees/<task>", branch="<branch-name>")
madt_commit(dir="<resolved-worktree-path>", files=[...], message="...")
madt_finish(dir=<foreign-primary>, branch="<branch-name>")

Consequences

  • Agents cannot accidentally disturb a foreign primary’s HEAD. A dir= pointing at a foreign primary never moves its checked-out branch or writes a commit into it. The interference class is structurally eliminated, not just discouraged.
  • The worktree flow is the only foreign-write path. madt_worktrees action=add is a prerequisite before any commit can land in a foreign repo. The refusal message teaches this flow inline so no prior knowledge is needed.
  • A fetch-only primitive is now available as madt_fetch. madt_pull is blocked on a foreign primary because it also rewrites the working tree. madt_fetch updates remote-tracking refs without touching HEAD or the working tree and is explicitly permitted against a foreign primary, making it the safe “freshen refs before branching a worktree” primitive. (2026-07-04: tool-shape decided by the owner — a distinct madt_fetch tool; shipped.)
  • madt_push, madt_add, madt_branch_delete, madt_status, and madt_worktrees are NOT blocked on a foreign primary. Read-only tools (madt_status, madt_log, madt_tag_list) are unrestricted; push, add, branch-delete, and worktrees are mutating but do not move HEAD or write into the working tree in the same shared-state class.
  • The IsPrimaryCheckoutDir primitive lives in internal/git/state.go and is a stable, importable predicate for any future rule that needs to distinguish a primary checkout from a linked worktree.
  • The combined predicate IsForeignPrimaryCheckoutDir lives in internal/mcp/foreignrepo.go — the shared-primitive file — keeping all foreign-repo-boundary logic in one place.

Follow-ups

Three deferred follow-ups to the immutability decision were recorded. Their status:

  • Item 1 — fetch-only primitive: SHIPPED. A “fetch only — refresh remote-tracking refs without touching HEAD or the working tree” primitive permitted against a foreign primary is now built as madt_fetch (a distinct tool, the tool-shape the owner chose). It is the recommended first step before branching a worktree from a foreign repo’s latest remote-tracking ref. (2026-07-04: tool-shape decided by the owner — a distinct madt_fetch tool; shipped.)
  • Item 2 — worktree ownership restriction: ADDRESSED. madt_worktrees action=rebase/remove against a FOREIGN repo is now restricted to worktrees THIS session created via the sanctioned flow — a checkout under a .worktrees directory on a feature branch that differs from the repo’s default branch. Any other foreign worktree is refused so madtea never disturbs one the foreign repo’s owner (or another agent) created; add/prune stay unrestricted, and SAME-repo targets are unchanged. Enforced in the MCP layer (guardForeignWorktreeOwnership, internal/mcp/foreignrepo.go), consistent with the other agent-facing foreign-repo guards (the CLI human surface is unguarded, matching the immutability rule).
  • Item 3 — unified foreign-fetch denial: ADDRESSED. A credentialed git fetch against a FOREIGN repo previously drew two conflicting denials — the remote hook’s credential-leak message (pointing at a bare madtea pull, itself refused on a foreign primary) and the local-git hook’s worktree-flow remedy. hooks/scripts/check-git-remote.sh is now foreign-aware (reusing the shared extract_target_dir) and emits ONE coherent message carrying both the credential-safety rationale and the sanctioned worktree-flow pointer for that case; non-foreign remote-op denials are unchanged.

Alternatives considered

  • Maintain the clean-tree guard only (no primary block). Rejected: the clean-tree guard prevents carrying uncommitted work; it does not prevent moving the HEAD to a new branch or writing a new commit into the primary tree. Both of those affect any other agent or user whose session is rooted at the primary.
  • Block all foreign dir= targets entirely. Rejected: the linked-worktree flow is valuable and established. A blanket block would remove the only sanctioned mechanism for cross-repo contribution from a single agent session, and the worktree’s isolation semantics (the caller owns the worktree; the primary is untouched) already address the shared-state concern.
  • Make the block opt-out via a git config key. Rejected: ADR 0009 non-overridable posture. An agent that receives a refusal and has the ability to set the config to bypass it has no protection whatsoever; the only structural guarantee is non-overridability.

Relates to ADR 0007 (structural fail-closed multi-repo), ADR 0009 (non-overridable guards), and the ADR 0007 cross-repo intent contract.

Amendment (2026-07-08) — launch-directory scope: repos under the session’s parent dir are in-scope

The original Decision defines “foreign” purely by a git-common-dir mismatch against the MCP server’s own launch-fixed working directory, tacitly assuming the session is launched inside a git repo. When an agent is instead launched in a plain PARENT directory that merely contains git repos — the common /work/repos layout, where each clone lives at /work/repos/<name> — every one of those repos has a git-common-dir that differs from the (repo-less) session directory, so all of them resolved as foreign. The launch was a deliberate “work across these repos” gesture, yet madt_branch_create dir=, madt_commit dir=, and madt_pull dir= refused, and the PreToolUse hooks blocked raw git and Edit inside them. This amendment records the failing sequence: session at /work/repos, madt_clone into /work/repos/bridges succeeds, then madt_branch_create dir=/work/repos/bridges and a raw git checkout -b inside it are both refused.

Amended rule. When the session scope dir is NOT itself inside a git repo, a target repository whose git toplevel is strictly under that directory is treated as IN-SCOPE — not foreign, not blocked — on BOTH the Go and hook surfaces. This ADR’s rug-pull protection targets repos owned by OTHER sessions; a repo the session was deliberately launched over is presumed in-scope.

  • Session scope dir. Go: os.Getwd() — the MCP server’s launch-fixed working directory. Hook: CLAUDE_PROJECT_DIR when set and a directory, else the hook’s own cwd — taken RAW, regardless of whether it is a git repo.
  • Trigger. The new in-scope path applies ONLY when the session scope dir is not itself inside a git repo (Go: git.GetCommonDirDir("") returns an error; hook: resolve_session_common_dir returns empty). When the session IS inside a git repo the behavior is byte-identical to the original Decision — the git-common-dir comparison is untouched.
  • Containment test. A target is in-scope iff its git toplevel, resolved to an absolute symlink-free path, is a STRICT proper subdirectory of the resolved session scope dir. The check is filepath-boundary-aware: it prefix-matches toplevel + pathSeparator against scopeDir + pathSeparator, so .. and symlinks are resolved first, equality is excluded, and /work/repos-evil does NOT match a session at /work/repos.
  • Outcome. In-scope → NOT foreign / ALLOWED (Go: IsForeignRepoDir and IsForeignPrimaryCheckoutDir return (false, nil), so dir= branch/commit/ pull succeed; hook: exit 0, no block). Genuinely out-of-scope — a target that resolves but is not under the scope — → foreign / BLOCKED, exactly as before.
  • Fail direction (asymmetric, intentional). The Go path fails toward the existing refusal: it returns (true, nil) = FOREIGN whenever it cannot positively confirm in-scope (an os.Getwd/toplevel-resolution error, or a toplevel not strictly under scope), never panicking or propagating the error. The hook fails OPEN (exit 0) on any uncertainty and never newly-blocks a non-git target or a file/verb inside a linked worktree; it only newly-blocks a target that positively resolves to a git PRIMARY checkout whose toplevel is out of scope.

Unchanged. Everything outside this narrow case keeps the original semantics exactly. A session launched inside a repo (or nested in one) behaves byte-for-byte as before, including multirepo.go’s nested-repo ambiguity errors (ADR 0007). Out-of-scope targets — siblings of the session dir and absolute-elsewhere paths — stay foreign with unchanged refusals. The linked-worktree ownership guards (guardForeignWorktreeOwnership, Follow-up Item 2) are not weakened, and a repo’s own linked worktrees placed under the session dir inherit the same in-scope treatment their primary gets. Consistent with ADR 0009 and the original Decision, the scope comes ONLY from where the session was launched — there is no agent-reachable flag, env override, or config key to widen it.

Amendment (decided 2026-07-06, recorded 2026-07-09) — reader-side freshness is the sanctioned staleness model (ADR 0019)

ADR 0019 makes a foreign primary’s checkout immutable to madtea but left one question open: once a worktree contribution has merged, HOW does the now-stale foreign primary get brought up to date? This amendment settles it.

Decision. Reader-side freshness is the sanctioned model. A foreign primary is NEVER freshened by the writer that just contributed to it. It stays behind on purpose and SELF-REPORTS its staleness — madt_status reports how far behind origin/<default> it is, as the behind count while it sits on its default branch (the usual case) or mainlineBehind on any other branch — and it is freshened by its next actual USER, from within that user’s own session, at their point of use. Staleness is the consumer’s problem at point-of-use, not the contributor’s to pre-emptively resolve.

Concretely, the sanctioned foreign-contribution flow ENDS at madt_finish(dir=<foreign-primary>, branch=<name>) plus worktree cleanup (madt_worktrees action=remove). No step reaches back to pull, checkout, or otherwise sync the primary afterward. The madt_pull foreign-primary refusal is aligned with this: it no longer suggests a madt_fetch recovery — it states the primary is read-only and left to self-report. The agent-facing worktree flow is documented end to end under madt_help(topic="foreign-repos").

Rejected: writer-side freshen. A writer-side freshen — a fast-forward-only pull of the primary after merge, or a lease-based claim that lets one session temporarily own and advance the primary — was considered and REJECTED. Both reintroduce the exact shared-state hazard ADR 0019 eliminates: between the merge and the freshen there is a TOCTOU window in which another agent, the repo’s owner, or a concurrent worktree may have moved the primary’s HEAD or dirtied its tree, so an “automatic” ff-only pull can still clobber or fail non-deterministically. A lease adds cross-session coordination state plus its own liveness/expiry failure modes for no benefit the reader-side model does not already provide. The primary’s HEAD is owned by whoever is rooted at it; a contributor reaching back to advance it is precisely the interference class the immutability rule exists to prevent.

This amendment records a posture decision only; it changes no guard. The madt_pull / madt_commit / madt_branch_create foreign-primary refusals and the worktree-ownership restriction are unchanged and un-weakened.

Amendment (2026-09-08, #418) - the forge-API write path inherits the immutability rule

The Decision above reasons about dir= / local checkouts: it blocks moving or writing a foreign PRIMARY’s tree. It never covered the forge-API write path. A content write via owner_repo= (madt_files create/update/delete, an API-plane madt_commit, madt_branch_create on the forge) reaches a repo with no local clone, so nothing local can be proven not-behind and the foreign-primary guard never fires.

Amended rule. A forge-API content write to a repo with no reconciled local view is guarded in the TOOL layer, keyed on server-side forge state the agent cannot fake:

  • Target repo empty/unborn -> ALLOWED. A create-new write has nothing to clobber, so a genuinely new repo works with no extra step.
  • Target repo already populated -> REFUSED, non-overridably (ADR 0009 posture). No confirm flag, env, or git-config key widens it: an agent-flippable confirm is not a guard, since an unattended agent would set it and clobber. The remedy is the sanctioned path - clone + worktree->finish - or the CLI, which this ADR leaves unguarded.

This extends the immutability decision from the local dir= path to the forge-API path, so the same shared-state protection holds however the write is routed. The raw madt_api_call passthrough stays a documented gap: no reliable repo target is extractable from an arbitrary endpoint (consistent with ADR 0027 excluding the passthrough). Implementation tracked in #418.

Amendment (2026-09-08, #416) - a working directory added at runtime is in-scope

The launch-directory amendment (2026-07-08) fixes scope at launch: only where the session started is in-scope, and there is no agent-reachable flag to widen it. But an MCP client can add a working directory mid-session and notify the server via roots/list_changed, and the AGENT cannot add one - only the operator can (in Claude Code: /add-dir, /cd, --add-dir, additionalDirectories). So an added root is an operator-authorized scope grant, not agent self-widening, and blocking writes in a directory the operator added defeats the purpose of adding it.

Amended rule. When the client advertises roots and adds one at runtime, the server re-derives scope from the updated roots. A root the operator added is in-scope: reads AND writes to that directory’s own origin, without the foreign-primary block, for that session. This refines the scope source from launch-only to the operator - launch, or a directory the operator adds at runtime. The agent still never widens its own scope.

Trust boundary. The server trusts the client’s guarantee that roots reflect operator grants (Claude Code enforces it: the agent cannot add a root). This matches the existing posture - the client is chosen, trusted infrastructure, and these guards are accident-detectors (ADR 0007 / 0009), not defenses against a compromised client. The same notification lets the wrong-repo detector follow an agent’s /cd.

This relies on the MCP roots capability, deprecated (SEP-2577) but functional into 2027; migration to its successor (SEP-2322) is tracked in #428. Implementation tracked in #416. See the companion ADR 0027 amendment.

Canonical source: docs/adr/0019-foreign-primary-checkouts-immutable.md in the madtea repo.