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:
madt_worktrees(action="add", dir=<foreign-primary>, path=.worktrees/<task>, branch=<name>)madt_commit(dir=<linked-worktree>, files=[...], message="...")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:
| Tool | Refusal reason |
|---|---|
madt_branch_create | Moving a foreign primary’s HEAD is shared-state clobber. |
madt_commit | Writing into a stranger’s primary tree (even with files= named) modifies HEAD and the working tree without an explicit grant. |
madt_pull | A 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_commitrequires declaredfiles=[...]— there is noall=truemode 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=addis 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_pullis blocked on a foreign primary because it also rewrites the working tree.madt_fetchupdates 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 distinctmadt_fetchtool; shipped.) madt_push,madt_add,madt_branch_delete,madt_status, andmadt_worktreesare 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
IsPrimaryCheckoutDirprimitive lives ininternal/git/state.goand is a stable, importable predicate for any future rule that needs to distinguish a primary checkout from a linked worktree. - The combined predicate
IsForeignPrimaryCheckoutDirlives ininternal/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 distinctmadt_fetchtool; shipped.) - Item 2 — worktree ownership restriction: ADDRESSED.
madt_worktreesaction=rebase/removeagainst a FOREIGN repo is now restricted to worktrees THIS session created via the sanctioned flow — a checkout under a.worktreesdirectory 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/prunestay 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 fetchagainst a FOREIGN repo previously drew two conflicting denials — the remote hook’s credential-leak message (pointing at a baremadtea pull, itself refused on a foreign primary) and the local-git hook’s worktree-flow remedy.hooks/scripts/check-git-remote.shis now foreign-aware (reusing the sharedextract_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_DIRwhen 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_dirreturns empty). When the session IS inside a git repo the behavior is byte-identical to the original Decision — thegit-common-dircomparison 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 + pathSeparatoragainstscopeDir + pathSeparator, so..and symlinks are resolved first, equality is excluded, and/work/repos-evildoes NOT match a session at/work/repos. - Outcome. In-scope → NOT foreign / ALLOWED (Go:
IsForeignRepoDirandIsForeignPrimaryCheckoutDirreturn(false, nil), sodir=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 (anos.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.