# Installation root vs canonical target repository root ## Purpose This document (tracked as issue #741, building on #706 and #739/#740) explains the two distinct filesystem roots the Gitea-Tools MCP server reasons about, why conflating them silently targets the wrong repository, and which rule applies when you add a new consumer. It is the repository-scope companion to [`gitea-execution-profiles.md`](gitea-execution-profiles.md) (the profile model) and [`gitea-dual-namespace-deployment.md`](gitea-dual-namespace-deployment.md) (the per-role namespace model). ## The two roots | | Installation root | Canonical target repository root | |---|---|---| | What it is | The checkout the server *code* lives in | The working root of the repository whose issues/PRs/branches the namespace *mutates* | | How it is derived | `PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))` | Configured per namespace, then pinned immutably into the session | | Configured by | Nothing — it follows the script | `canonical_repository_root` profile field, or the `GITEA_CANONICAL_REPOSITORY_ROOT` environment variable | | Changes at runtime? | No | No — first bind wins for the life of the process | | Accessor | `PROJECT_ROOT` | `_canonical_local_git_root()` (filesystem) / `_canonical_repository_slug()` (identity) | For a **single-repository** namespace — every Gitea-Tools namespace today — the two roots are the same path, and nothing about the existing behaviour changes. The distinction only becomes observable once a namespace is pointed at a different repository. ## Which root does my code need? Ask what the operation is *about*, not where the file happens to sit. **Use the installation root (`PROJECT_ROOT`)** when the operation concerns the Gitea-Tools software itself: - server implementation / version parity (`master_parity_gate`, the `startup_head` vs `current_head` staleness gate); - loading the server's own workflow, schema and skill files; - self-code hashing and stale-runtime detection; - locating installed scripts such as `mirror_refs.sh`. These are intentionally install-scoped. Do not "fix" them. **Use the canonical target root (`_canonical_local_git_root()`)** when the operation concerns the repository being worked on: - `git remote get-url` for repository identity; - branch creation, push, and commit; - ancestry and merge-base proofs; - worktree inventory, cleanup, and branch deletion; - any local git subprocess whose result feeds a mutation guard. **If you cannot tell, fail closed.** An ambiguous consumer that guesses the install root is the exact defect class #741 exists to eliminate. ## Why conflating them inverts the guards Before #741, `_local_git_remote_url()` ran `git remote get-url` with `cwd=PROJECT_ROOT` unconditionally. Every consumer of repository *identity* — `_resolve`, the #530 remote/repo guard, the anti-stomp org/repo fill, `_workspace_repository_slug` — therefore read the Gitea-Tools remote and called it "the workspace", no matter which repository the namespace was bound to. For a namespace whose canonical root points elsewhere, this **inverts** the guard rather than merely weakening it: - an operation naming the genuinely bound target repository is **rejected**, because that slug does not appear in the Gitea-Tools remote URL; - an operation naming Gitea-Tools is **accepted**. The filesystem guards (#274 branches-only and worktree membership) had already been migrated to the canonical root by #706, so the two halves of a single assessment described two different repositories. A related subtlety: repository identity must not be derived by looking a remote up by *name*. A target checkout commonly names its remote `origin` rather than `prgs`, so a name-keyed lookup returns nothing and the omitted coordinates fall through to the remote-wide default *target* — an unrelated repository. `_canonical_repository_slug()` probes candidate remote names against the canonical root instead. `_canonical_local_git_root()` is now the one place a target root is resolved. Do not re-derive it; new code that needs a target root calls that helper. ## Configuration Declare the binding on the profile, alongside `allowed_repositories`: ```json { "profiles": { "example-author": { "role": "author", "canonical_repository_root": "/absolute/path/to/target-repo", "allowed_repositories": ["Example-Org/target-repo"] } } } ``` The namespace-scoped environment variable `GITEA_CANONICAL_REPOSITORY_ROOT` overrides the profile field, and is normally exported next to the server `cwd` in the MCP client configuration. Validation is layered, and each layer fails closed: 1. **Config load.** The path must be a non-empty absolute string. All supported loaders — v1, v2-`environments`, and v2-`contexts` — validate it identically. (Before #741 only the v2-`contexts` loader validated it, and v2-`environments` silently *dropped* the field during flattening, so the namespace fell back to the install root — a fail-open.) 2. **Bind time.** The path must exist, be a git repository, and resolve to a repository identity matching the session's authorized slug. A configured but unresolvable root is never replaced by the install identity. 3. **Every mutation.** The pinned root is compared against the live configured value; a mismatch is treated as a forged or conflicting binding. `allowed_repositories` remains a separate authorization boundary (#714): the canonical root determines *which* repository is derived, and `allowed_repositories` determines whether the session may act on it. A root that resolves to a repository outside that list fails closed. ## Explicit coordinates confirm, never override Explicit `org`/`repo` arguments may **confirm** an existing canonical binding. They can never establish, complete, or replace one. A request naming a repository that contradicts the binding fails closed, in both directions: - a Gitea-Tools-rooted namespace cannot mutate another repository; - a namespace rooted at another repository cannot mutate Gitea-Tools. This matters because both-explicit coordinates short-circuit the #530 remote/repo match check, so without this rule a caller could name any repository and skip validation entirely. No request-supplied workspace, remote, owner, repository, or worktree can replace the immutable root. ## Parity is reported per dimension `gitea_assess_master_parity` reports two separately labelled dimensions: - `server_implementation` — the Gitea-Tools installation checkout. Its `startup_head` / `current_head` / `stale` / `restart_required` fields keep their original meaning, and **only this dimension gates mutations**: the running process executes the code it started with, so a merged fix is not live until the daemon restarts. - `target_repository` — the configured canonical target checkout and its last-known remote master. "In parity" is a statement about one dimension, never about the whole system. Read the dimension you actually care about.