ADR 0019 — Foreign-repo primary checkouts are immutable to madtea

  • Status: Accepted
  • Date: 2026-07-03
  • Tracking: legacy 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.

Issue legacy tracker 1862 (cross-repo intent contract) 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 legacy tracker 1862 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 legacy tracker 1862): 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 legacy tracker 1862 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, legacy tracker 1981): the “(no all=true)” phrasing above predates legacy tracker 1964 / PR legacy tracker 1977, which 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 with legacy tracker 1914.)
  • 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 legacy tracker 1862 shared-primitive file — keeping all foreign-repo-boundary logic in one place.

Follow-ups (legacy tracker 1914)

Issue legacy tracker 1914 recorded three deferred follow-ups to the legacy tracker 1912 clamp. 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 with legacy tracker 1914.)
  • 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 legacy tracker 1912 clamp).
  • 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 (legacy tracker 1862) 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 legacy tracker 1862 cross-repo intent contract.

Amendment (2026-07-08, legacy tracker 2063) — 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. legacy tracker 2063 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, legacy tracker 2032) — 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? legacy tracker 2032 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.

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