ADR-0020 — MCP boundary and authorization model
Status: Accepted · 2026-09-03 · Governs M15 · Follows ADR-0019 · Implements ADR-0001 · Enforces ADR-0005
Amended 2026-09-03 and 2026-09-05 — three amendments, all accepted. The four decisions below stand unchanged. Amendment 1 resolves where the identity comes from: investigation found that no derivation from the analysed tree can satisfy the diff rule this ADR also states, and the owner chose R2, grant-supplied repository identity. Amendment 2 resolves how a session receives that grant: G1, launch-time argv, together with its trust boundary and its path-exposure tradeoff. Amendment 3 resolves who else may produce a grant: the desktop, after an explicit human repository selection, under the same one identity model — no second identity, and still nothing derived from the tree. This ADR is not rewritten.
Decisions recorded on acceptance
Four choices, selected by the project owner. This ADR was Proposed while they were open; it now records what was weighed and what was chosen.
| Decision | Selected | |
|---|---|---|
| A | What "map" denotes as an MCP query | A4 — a purpose-built, filtered and aggregated representation answering "what is this system?" |
| B — mechanism | How a repository becomes session-authorized | B2 — canonical repository identity |
| B — cardinality | How many repositories a session may hold | One, exactly |
| C | Transport | C1 — stdio |
Two things remain deliberately open, recorded under Deferred rather than guessed at: how the canonical identity is derived, because the repository contains no such algorithm, and how a session receives its initial grant. Neither may be settled by an implementer's inference — see Decision rule.
Context
M15's scope is "rmcp-based MCP server exposing graph queries (map/trace/blast/diff) to Claude Code and Cursor. Session-scoped repository authorization only", and its acceptance is "MCP client can query the graph of an authorized repo and nothing else; privacy gate extended to MCP; gates pass" (agentos/milestones/M15-mcp-server.md).
Slice 1 (ADR-0019, merged at 5d88405) moved the trace traversal into cartograph-graph and explicitly deferred these questions, recording that "a later slice must not read agreement into its silence." This ADR is where they were put to the owner and answered.
The invariant they serve is not reopened:
MCP exposes only the repository authorised in the current session. — ADR-0005,
ARCHITECTURE.md:176,SECURITY.md,docs/security/README.md("MCP session scoping | M15")
Decision
A. "map" is a purpose-built aggregated view — A4
MAP answers "what is this system?" (README.md's surface table). The MCP map query answers that question in a form an agent can read: a compact, deterministic, filtered and aggregated representation of the graph.
It is explicitly none of these:
| Not | Why not |
|---|---|
the raw ArchitectureGraph | 2,054,974 bytes of JSON on Airflow (3,104 nodes / 3,350 edges); Zulip 747,079. Unusable as a response, and dumping it answers no question |
cartograph_desktop::scene::Scene | owned by a peer client, and its x/y coordinates are meaningless to a text agent. Consuming it would invert the client boundary |
json::Summary / pipeline::Totals | answers a different question — "can Cartograph analyse this repository, and what did it find?" (summary_cmd.rs:5–6) — and must not be silently relabelled as MAP |
A4 does not exist yet. Nothing in the repository produces it. summary_cmd's count_by_kind / count_by_provenance / count_by_confidence are the nearest thing, and they key on presentation label strings, so kinds sharing the "edge" fallback merge — a rendering decision baked into a count, not an API.
A4 is therefore a new capability, and it is analysis. RULE 002 and ADR-0001 put it in a reusable library layer — cartograph-graph or cartograph-pipeline, whichever its eventual shape needs — and not in cartograph-cli (a binary crate; nothing can depend on it) and not in cartograph-mcp (a client holds no analysis).
Its concrete data model is not designed here. This ADR records the decision and the placement constraint; the shape is the subject of its own slice, and therefore sits outside the authorization slice.
B. Authorization is by canonical repository identity, one per session
The security invariant
One MCP session has exactly one authorized repository identity. Every graph query is bound to that identity. A query naming any other repository is refused before any analysis runs.
Three parts, each load-bearing:
- Exactly one. Not a set, not a default, not "the first one seen".
- Bound, not checked once. Every query carries the binding; authorization is not a gate passed at startup and then forgotten.
- Refused before analysis. No parsing, no file reads, no graph construction for an unauthorized repository. A refusal that arrives after the analyser has walked the tree has already done the thing it was meant to prevent.
Identity is not a filesystem path
The same repository can be named ., ../cartograph, D:\dev\cartograph, through a symlink, or with different case on Windows. A prefix comparison over raw paths is defeated by .., by a symlinked root, and on Windows by case and short-name forms.
The repository's own behaviour is split on exactly this point, which is why the distinction must be stated rather than assumed:
discovery::walkskips symlinks outright (if file_type.is_symlink() { continue; }), so the analyser never follows a link out of a tree;desktop::repository::validatedeliberately follows a symlinked root, because "a symlinked repository … is a normal way to keep a checkout elsewhere".
Containment inside a tree exists. Identity of the root does not.
What the repository provides — and does not
Investigated, with the result stated plainly rather than worked around:
| Candidate mechanism | Present? | Finding |
|---|---|---|
| Git remote / commit identity | No | gix is named in the frozen stack at M07 and is not a dependency. CommitId exists as a type but is never populated from a real repository — every production call site passes None; the only literal is a test in edge.rs:207 |
| Canonical path representation | Partial | root.canonicalize() appears once, in pipeline::repository_name, as .ok() with a fallback, and only to derive a display name. discovery::discover returns the root as given, uncanonicalised |
| Repository fingerprint | No | incremental::content_hash is per-file, uses DefaultHasher, and its own documentation says it is "not a security boundary" and "not stable across Rust releases" |
| Workspace identity | No | nothing of the kind exists |
NodeIdentity-style canonicalisation | Not applicable | cartograph_core::identity is node identity (kind, name, file) within one graph; it says nothing about which repository a graph came from |
Repository { name, requested } | Not an identity | name is a display string, "deliberately not the path", and is not unique — two checkouts of one repository share it, and two unrelated repositories can too |
There is no existing repository-identity mechanism in this repository.
This ADR therefore accepts the property, not an algorithm:
Session authorization binds one canonical identity to one repository, where that identity is stable across the different ways a single repository can be named, and distinct between different repositories.
How that identity is derived is deferred. Inventing a fingerprint here, with no evidence to select one, is the failure this ADR exists to prevent.
The one-repository rule, and what it does to diff
One authorized repository per session is deliberate: it is the smallest surface, and it matches the singular wording of every statement of the invariant — "the repository".
cartograph_graph::diff::diff(before, after) takes two graphs, and diff_cmd builds them from two paths. The rule therefore carries a consequence that must be enforced, not merely noted:
Both trees of a diff must resolve to the same authorized repository identity, and authorization validates both before the diff runs.
before = repository A with after = repository B is refused even when both paths are individually valid and readable. A diff across two repositories is not a diff; it is a way to read a second repository through a query that was authorized for the first.
What the second tree legitimately is — a revision, a worktree, a sibling checkout of the same repository — depends on how identity is derived, and is therefore part of that deferred decision.
Refusal semantics
An unauthorized request is refused before analysis, as an internal outcome of the authorization layer. No wire-level error code is fixed here. Mapping the internal refusal onto an MCP protocol error belongs to the transport slice; fixing a code before the transport exists would pin a contract nothing has exercised.
The refusal must obey the existing privacy gate: it may not echo a rooted path or its parent directories (RULE 015, and the tests named under Security implications).
C. Transport is stdio — C1
Accepted. The owner's choice resolves what this ADR previously recorded as an inference. The reasoning it rests on:
- RULE 013 — "fully useful with zero API keys and no network"; ADR-0005 — no source leaves the machine, default and unconditional.
- The CLI already contracts stdout = result, stderr = diagnostics and progress (
main.rs:23–24) — the partition a JSON-RPC-over-stdio server needs. - The npm launcher already "forwards argv, stdio and the exit code" (
CHECKPOINTS.md:1271), so a launch mechanism that inherits stdio exists. - No listener exists to build on: no socket, port, HTTP client or server anywhere in
crates/. - The frozen stack rejects Axum for v1, and non-goal 7 is "any cloud component before a user asks for one and offers to pay".
Recorded honestly, because the distinction matters for how this ADR should be read: before the owner chose, stdio was the strongest inference and not a requirement — no source names a transport, and RULE 013 constrains requiring a network rather than forbidding a loopback listener. It is now a decision, which is a different thing from a reading.
D. Dependency boundary
cartograph-core
↑
cartograph-graph trace · blast · diff (+ A4, if it lands here)
↑
cartograph-pipeline run · run_with_cache · Analysis (+ authorization)
↑
cartograph-mcp rmcp · tokio · stdio · tool registration
↑
Claude Code / Cursorcartograph-mcpmust not depend oncartograph-cli— a binary crate ([[bin]] name = "cartograph", no[lib];cargo test -p cartograph-cli --libanswers "no library targets found"). That fact is what forced ADR-0019.cartograph-core,-graphand-pipelinestay free ofrmcp,tokioand any transport dependency. Nothing about a traversal or a pipeline run is asynchronous.- A4 does not live in
cartograph-mcpmerely because MCP consumes it. rmcp(frozen stack, M15) andtokio(frozen stack, M04) are not in the workspace'sCargo.tomlorCargo.lock— zero entries for either — and arrive with the transport slice, not before. (tokiodoes appear indesktop/src-tauri/Cargo.lockas a Tauri transitive dependency; that crate isexcluded from the workspace by ADR-0016 decision 3 and is not a workspace dependency.)
E. Where the authorization type lives
Derived from the invariant rather than from convenience. The requirement is that authorization is enforced below the transport layer, so that no client can bypass it.
| Candidate | Assessment |
|---|---|
cartograph-mcp | Rejected. It is the transport layer. A guard living there is bypassed by every other client, which is what "below the transport layer" forbids |
cartograph-core | Rejected. Core is the domain model — nodes, edges, evidence, confidence. Which repository a caller may analyse is not a property of an edge |
cartograph-graph | Rejected. Graph queries operate on an &ArchitectureGraph that already exists; by then the analysis has run, so a guard here cannot refuse before analysis |
cartograph-pipeline | Selected. It owns run(path, Options) -> Analysis — the exact boundary where a path becomes an analysis, and therefore the only place a refusal can precede one. It is a library every client can use, and cargo test --workspace already covers it |
| a new library crate | Admissible if the type outgrows what cartograph-pipeline should hold. Not justified for one type today, and a crate added speculatively is the dependency policy's stated failure mode |
Deferred, deliberately
Not settled by this ADR. An implementation may not treat them as settled:
| Deferred | Why it is still open |
|---|---|
| How the canonical identity value is derived | Still open. Amendment 1 decided where identity comes from — the grant — and deliberately did not decide what the value is. No gix, no populated CommitId, no durable fingerprint exists to derive one from |
| Decided by Amendment 2: launch-time argv (G1) | |
| A4's concrete data model | Accepted as a decision and a placement constraint; its shape is its own slice |
| The wire-level refusal code | Belongs to the transport slice |
The grant channel must not be allowed to mutate the property: whatever delivers the grant, one canonical identity, bound to every query, refused before analysis is fixed.
Amendment 1 — the identity source, and the two-tree contradiction
Status: Accepted, 2026-09-03. The four decisions above are unaffected; this resolves the source of the identity they assume. The owner selected R2 — grant-supplied repository identity.
The contradiction
Two things this ADR states are, together, unsatisfiable by any derivation from the analysed tree:
- "One MCP session has exactly one authorized repository identity", derived as a canonical repository identity; and
- "Both trees of a diff must resolve to the same authorized repository identity", over a
diffthat takes two filesystem paths (diff_cmdcallspipeline::runtwice;graph::difftakes two graphs).
The production path that breaks it is Cartograph's own, running today on main:
architecture-review.yml
trees/base ← actions/checkout of github.repository (Rexy-5097/cartograph)
trees/head ← gh api …/tarball/$SHA | tar -xz (ronitsaha11/cartograph)
./target/release/cartograph diff trees/base trees/head --markdownTwo facts from that workflow, both verified rather than assumed:
- The trees are asymmetric.
trees/baseis a checkout and has.git;trees/headis a tarball extraction and has none — the runner loggedhead tree: 663 files, no .git: yes(run 33733818164). - For a fork pull request they come from different repositories. Base is
github.repository; head isgithub.event.pull_request.head.repo.full_name, observed asHEAD_REPO: ronitsaha11/cartograph. A Git-remote identity would call Cartograph's own before/after pair two different repositories and refuse the review that M14 was accepted on.
M13's evidence has the same shape: "Airflow ed68491d8b → 9b43d6abc0, two revisions extracted read-only" — two directories, compared by path.
What any option must establish
| Proposition | |
|---|---|
| P1 | before belongs to the authorized repository |
| P2 | after belongs to the authorized repository |
| P3 | before and after are the same repository |
| P4 | before = repository A with after = repository B is refused |
P4 is the one that makes the others worth having. An option that satisfies P1–P3 by weakening P4 has not solved the problem; it has renamed it.
Option R1 — containing-root authorization
The session authorizes a canonical root C; a query tree T is admitted when canonicalize(T) is C or beneath it.
| Relation of the trees to the root | In the M14 workflow trees/base and trees/head are siblings inside the runner workspace, so a containing root does exist |
| Symlink / junction escape | Resisted, measured. On this Windows machine canonicalize() resolves a directory junction to its target: …\link → …\real, identical to the target's own canonical form. A junction pointing outside C therefore canonicalizes outside C and fails containment — provided both sides are canonicalized |
| Windows behaviour | Measured. canonicalize() normalises separators, .., drive case, component case and trailing slash — six aliasing forms of one path all compared equal — and returns a \\?\-prefixed absolute path that names the machine and must never be echoed (RULE 015, is_rooted) |
| Extracted trees inside one root | Yes — this is the case the M14 workflow already produces |
| Does Cartograph's own workflow satisfy it? | Mechanically yes, and that is the problem: the root it would authorize is the runner workspace — a scratch directory that also holds the base source and the built binary — not a repository |
| Security guarantee | Containment, not identity. Honestly named it is an authorized subtree |
| P4 | Not satisfied. Two unrelated repositories placed under one authorized root both pass. R1 cannot tell A-before/B-after from a legitimate pair, because it never learns what a repository is |
| Residual | canonicalize() errors on a nonexistent path, so identity exists only while the tree does; and a directory replaced by a junction between check and analysis is a TOCTOU window |
R1 is coherent and enforceable. It is not the property this ADR accepted, and adopting it would mean saying so.
Option R2 — grant-supplied identity
Identity becomes part of the authorization state rather than something discovered in the directory. The grant binds one identity to the tree or trees that represent it.
| How the trees prove correspondence | They do not. The grant asserts it, and the guard enforces membership of the granted set |
| Where the proof happens | Outside the analyser, at grant time — by whoever knows that trees/base and trees/head are two views of one repository. In the M14 workflow that knowledge exists in the workflow, which fetched both |
| Must the grant carry metadata? | Yes — at minimum an identity and the paths it covers. What that metadata is remains the separately deferred grant-channel question, and this option does not decide it |
| Diff | Natural: one identity, two granted trees, P3 satisfied by the grant |
| Non-Git repositories | Unaffected — nothing is derived from the tree, so a .git-less extraction is as authorizable as a clone |
| Security assumption | Trust moves to the grant channel. The tree proves nothing; the guard enforces exactly what it was told |
| Session lifecycle | Identity lives for the session, which is what "one session, one identity" already says |
| P4 | Satisfied, conditionally: A-before/B-after is refused because B was never granted. The condition is that the granter is correct — the assertion is only as good as its source |
| Honest characterisation | Identity becomes an assertion, not a derivation. Whether an asserted identity is a canonical repository identity in the sense accepted above is exactly what the owner must decide |
Option R3 — diff is not on M15's MCP surface
One identity per session stands; diff waits for a mechanism that can connect two extracted trees.
| Effect on the acceptance sentence | None literally. M15's acceptance is "MCP client can query the graph of an authorized repo and nothing else; privacy gate extended to MCP; gates pass" — it does not enumerate the four queries. The enumeration is in the Scope line |
| Does the scope line require all four? | It reads "exposing graph queries (map/trace/blast/diff)" — a parenthetical list. Narrowing a scope line has precedent: ADR-0014 is "the governing record of M10's delivered scope" after three scope items were deferred |
| Usefulness to Claude Code / Cursor | map, trace and blast answer three of the product's questions; diff is the one an agent reviewing a change would reach for |
| Product cost | ROADMAP.md:68 names DIFF the retention surface. Excluding it from the agent surface is a product decision, not only an engineering one |
| Would the specification need rewriting? | Not rewritten — reconciled, in the ADR-0014 manner, recording what M15 delivered and why |
| P1–P4 | Vacuous for diff; unchanged for the single-tree queries |
Decision — R2, grant-supplied repository identity
The authorized repository identity is trusted session authorization state, supplied by the component that establishes the session. It is not inferred from the analysed tree.
R1 was rejected because it authorizes a filesystem location: two unrelated repositories beneath one authorized root both pass, so it cannot refuse before = repository A with after = repository B — the case the diff rule exists for. Adopting it would have quietly replaced repository identity with authorized location.
R3 was rejected because it removes diff rather than answering the question, and map, trace and blast would still need an identity source. It also costs the retention surface (ROADMAP.md:68) on the agent surface.
R2 fits how Cartograph's own analysis is actually driven. The M14 workflow already knows github.repository, the base SHA, the head repository and the head SHA; it then hands the analyser two filesystem paths and nothing else. The knowledge that those two trees are one repository exists in the component that fetched them, and nowhere in the trees. R2 puts the identity where the knowledge already is.
The trust boundary this creates, stated plainly
The component that establishes the session is part of the trust boundary. It asserts the repository identity and which trees represent it. The analysed tree is not authoritative proof of anything, and the guard does not attempt to verify the assertion.
This is a conscious architectural choice, and it must not be described as "validated repository", "secure path" or "canonical path" — those are different properties, and two of them are the ones this amendment rejected.
What the boundary does and does not defend:
- Defends: an MCP client cannot widen its own scope. It presents a tree; the session decides whether that tree was granted, and refuses before any analysis.
- Does not defend: a compromised or mistaken grant producer. It can authorize anything — but it is the process that launched the server and could have read those files directly. The guard's job is to stop the client widening scope, not to defend against its own launcher.
What R2 does and does not settle
| Settled | identity is authorization state, asserted at grant time, never derived from a query path |
| Settled | one identity per session; every query resolved against it before analysis |
| Settled | for diff, both trees must be associated with the same authorized identity, checked before either analysis runs |
| Not settled | the identity value — no algorithm is chosen, and none may be inferred |
| Not settled | the grant mechanism — not a launch argument, environment variable, handshake or config file until decided |
The four terms this amendment keeps apart, because collapsing any two of them is how the contradiction was reached in the first place:
| Term | Means |
|---|---|
| identity property | one canonical identity per session — accepted, unchanged |
| identity source | decided: the grant, not the tree |
| authorization state | what the session holds and enforces on every query |
| initial grant | how that state is first supplied — still deferred |
What this permits Slice 2 to build
An opaque identity type whose value arrives from the grant; a session holding exactly one such identity together with the trees the grant associated with it; a guard that admits a query only when its tree was granted for that identity, and a paired guard for diff that requires both trees to carry it. Nothing in that list requires the identity value or the grant channel to be chosen, which is why Slice 2 can proceed while both remain deferred.
Amendment 2 — the initial grant arrives in argv
Status: Accepted, 2026-09-03. Amendment 1 decided that identity is authorization state rather than something derived from a tree. It deliberately did not say who supplies it or how. This does.
Decision — G1, launch-time argv
The authorized repository grant is supplied in the process's own command line at start, before any MCP request is read.
Also accepted, and each load-bearing:
| Cardinality | one repository identity per MCP session |
| Establishment | the grant is fixed at process start; a session's authorization state is complete before query handling begins |
| Identity | trusted authorization state, opaque, supplied by the grant — derivation remains a separate, still-open problem |
| Client authority | an MCP request cannot replace, widen or add to the grant |
| Transport | stdio, unchanged from decision C1 |
| Path exposure | a repository path may appear in argv; accepted as a local-process tradeoff, subject to RULE 015 |
Why argv, from repository evidence
Seven checks, each verified against the tree rather than assumed:
- argv is the only production configuration surface that exists. The CLI takes a
PATHpositional and--pathoptions; nothing else configures a run. - No production code reads an environment variable or a configuration file. Every
env::varandenv::temp_dirsite in the workspace is inside a#[cfg(test)]module —incremental.rs(nine sites, all after its#[cfg(test)]at line 240) andpipeline.rs(one site, after line 615). There is nodirs, nohome_dir, noconfig_dir, no XDG lookup and no.cartographfile anywhere. mcpis already reserved as a CLI subcommand.no_unbuilt_milestone_command_is_present(crates/cartograph-cli/src/main.rs) listsmcpbesidewatch,serve,ask,uianddesktopas "a later milestone's deliverable", withdiffalready removed because M13 built it. The repository's own expectation is that the server is reached ascartograph mcp, and a subcommand's configuration surface is argv.- The launcher already forwards argv and stdio unchanged —
spawn(resolved.path, process.argv.slice(2), { stdio: "inherit" }). - Nothing in the repository contradicts launch-time authorization.
- Nothing requires client-initiated authorization.
- No source defines a better mechanism. Grants are named nowhere outside this ADR.
Two options were excluded on principle rather than on evidence. Server-side discovery is derivation from the tree, which Amendment 1 rejected. A client-initiated handshake would make the guarded party its own authority: if an MCP request can say "authorize me for this repository", then "a client cannot widen its own scope" is not a property, it is a sentence.
Trust model
The trusted grant producer is the process launcher. Authorization is established before any MCP query is handled. The MCP client is not the authority.
Concretely, the client cannot:
- select a different repository;
- replace the identity;
- add a second repository;
- expand the authorized set;
- authorize another tree.
The authorization object is immutable for the session. Slice 2 already enforces this in the type system: AuthorizedRepository exposes no mutator, and AuthorizedTree — the only thing authorization::analyze accepts — has private fields and no public constructor.
One repository, and what diff must do
Exactly one identity per session. For a single-tree query the tree's identity must match the session's. For diff, both must, and both are decided before either analysis runs:
| Case | Outcome |
|---|---|
| A before + A after, session authorized for A | allowed |
| A before + B after | refused |
| B before + A after | refused |
| A alone, session authorized for A | allowed |
| B alone, session authorized for A | refused |
Path exposure — an acknowledged tradeoff, not a non-issue
A repository path in argv is visible in the local process table. Recorded plainly:
- it is local process metadata;
- it is not sent over the network — RULE 013 is untouched, and the server needs no network at all;
- it is never an MCP response;
- it must not be echoed into logs or error output beyond what the existing redaction rules already permit;
- RULE 015 still applies in full, as do
discovery::is_rootedand the redaction it governs.
argv is not secret, and this exposure is not harmless. It is accepted because the alternative channels are worse or absent: a configuration file would be a new surface that RULE 014's reasoning points away from, and an environment variable would be a new surface with no precedent in the codebase. It is also already the status quo — cartograph --path <repo> has put a repository path in argv since M09 — with one difference that must not be glossed: an MCP server is a long-lived process, so the exposure lasts as long as the session rather than as long as one command.
What the repository cannot establish, and what must not fill the gap
These are not repository facts, and Slice 3 must determine them from observed behaviour rather than from convention:
| Unknown | Why it matters |
|---|---|
| Whether Claude Code launches an MCP server with user-authored argv | If argv were composed by the agent rather than the user, the launcher and the client would be the same party and the trust model above would collapse into the handshake option this amendment rejected |
| Whether Cursor does the same | The M15 scope names both clients; a mechanism that needs a per-client exception is not one mechanism |
| Whether either client can rewrite process arguments dynamically | The grant's immutability depends on argv being fixed at exec |
Exact rmcp lifecycle semantics — connection, session and process boundaries | "One repository per session" maps cleanly onto one process only if a process serves one session |
No industry convention may be substituted for any of these. They are recorded as Slice 3 obligations: verify against the real clients and the real library, and if the observation contradicts this amendment, amend it rather than reinterpreting it.
Consequences
The last architectural gap in M15 is closed to the point where Slice 3 can begin: identity is authorization state (Amendment 1), it arrives at process start (Amendment 2), it is enforced below the transport (Slice 2, merged), and the transport is stdio (decision C1). What remains open is the identity value's derivation, which none of this depends on — the boundary stores and compares an opaque token, so choosing a derivation later changes the granter and nothing else.
Amendment 3 — the desktop is a second grant producer
Status: Accepted, 2026-09-05. Amendment 1 decided that identity is authorization state rather than something derived from a tree. Amendment 2 decided how an MCP session receives it. Neither said whether any other client may hold one, and M16 asks the question: ASK is a desktop surface (ADR-0021 decision A) whose opt-in is per repository, and the desktop had no repository identity at all.
This amendment answers that, and nothing else. It does not reopen A, B or C, and it does not close the still-open derivation question below — because nothing is derived here either.
Decision — the desktop grants, the model does not change
RepositoryIdentity remains the single opaque repository identity model for the whole product. The desktop becomes a second producer of grants under that same model, after an explicit human repository selection.
| Identity model | one, unchanged: RepositoryIdentity, opaque, compared and never interpreted |
| MCP producer | launch-time argv, unchanged from Amendment 2 |
| Desktop producer | the trusted desktop grant boundary, after the human selects a repository |
| Derivation | none — never from filesystem path, source contents, Git, hashing or canonicalisation |
| Path | a locator, never the identity |
| Where it lives | Rust-side session state in cartograph-desktop; the shell and the frontend never receive the value |
| Opt-in association | per-repository ASK opt-in is keyed by that same identity |
| New types | none |
Why this does not weaken Amendment 1 or 2
The identity was written granter-agnostic from the start: RepositoryIdentity::from_grant documents the value as "a token whose meaning belongs to the granter", and AuthorizedRepository::from_grant is already public. Nothing in authorization.rs inspects, canonicalises, hashes or resolves the value — authorize compares granted paths by equality and clones the identity through, and "nothing on disk is touched". A second producer therefore changes who supplies the token, not what the token is or what the authorization layer may do with it.
The five M15 properties each survive, and each for a stated reason:
| M15 property | Why it holds |
|---|---|
| Identity comes from the granter | The granter differs; the rule does not |
| A client cannot widen scope | authorize still admits only granted trees, before analysis |
| The identity is opaque | Still never interpreted by the authorization layer |
| Authorization consumes the grant | Same code path, unchanged |
| Not derived from the analysed tree | The desktop mints a value; it does not compute one from the tree |
The locator-to-identity mapping is a persisted fact, not a derivation
Per-repository opt-in must survive a restart, and on restart the human selects a folder again. The only key available for that lookup is the locator. So the desktop records a mapping — locator, opaque identity, opt-in state — and on a later selection either recovers the identity that locator was recorded against, or mints a fresh one and records it.
This is deliberately not derivation. No function maps a path to an identity; the association is a fact that was written down once. The distinction is the whole point of Amendment 1, and it is load-bearing here: a derivation would make two spellings of one path agree, and a recorded mapping does not pretend to.
The identity-generation mechanism is not fixed by this amendment. It must be opaque and it must not be a function of the tree; beyond that it is an implementation detail, and no claim of cryptographic uniqueness is made or needed — the value is compared for equality within one machine's configuration, never used as a secret and never transmitted.
Consequences, stated rather than discovered later
- The desktop configuration becomes a grant-bearing surface. Amendment 2 weighed a configuration file as a grant channel for MCP and pointed away from it, on RULE 014's reasoning. That reasoning is revisited here rather than ignored: MCP is launched by another program and has argv; a desktop application launched by a person double-clicking it has no argv to carry a grant, and the alternative — asking for consent again every session — is the option the per-repository decision in ADR-0021 already excludes.
- The trust boundary is the OS user account, the same boundary that protects the keychain. Anyone who can write the configuration can already read the repositories it names.
- The configuration holds the minimum association data only — locator, opaque identity, opt-in state. It holds no API key, no token, no source, no
Evidence, no analysis graph and no environment values. The credential lives in the OS keychain (RULE 014), never here. - The frontend receives an enabled/disabled state and nothing else. The identity value does not cross into JavaScript.
- Moving or renaming a repository does not carry its opt-in. The locator no longer matches, so the next selection mints a new identity and the repository reads as not opted in. The user re-enables it.
- One physical tree reached through two locators may hold two identities — a symlinked root, a different case on Windows, a
..-relative path. This follows directly from "containment inside a tree exists; identity of the root does not", and is visible product behaviour rather than a defect to be papered over. - Stale entries may be pruned. A locator that no longer exists is a bookkeeping matter, not an authorization one.
cartograph-core,-graphand-pipelineare unchanged in shape. No new identity type is introduced anywhere.
What this amendment does not settle
- How the canonical identity value is derived stays open in Deferred, deliberately, and is not closed here. Amendment 3 does not derive anything; it records that one producer is handed a value and another mints one.
- C — the AI provider and HTTP client and D — the keychain crate remain deferred in ADR-0021. The frozen engineering specification that authorises dependencies is still not available in this repository, and no crate is named here.
Decision rule
No implementation may treat a deferred item as decided on the strength of "most likely", "obvious", "industry standard", "usual MCP behaviour" or "strongly implied". Only an explicit owner selection recorded here, or an authoritative repository source, settles one. RULE 009 — unknown values remain unknown — governs the project's own design for the same reason it governs the graph: a plausible guess recorded as a fact is worse than a gap, because nobody returns to check it.
Slice map
| Slice | Owns | State |
|---|---|---|
| 1 | trace traversal extracted into cartograph-graph | Complete — PR #38, merged at 5d88405 |
| 2 | the authorization boundary: canonical repository identity, single-repository session context, construction and validation of an authorized session, the query guard, refusal semantics, privacy tests | next |
| 3 | cartograph-mcp: rmcp, tokio, stdio transport, tool registration, session lifecycle integration, real Claude Code / Cursor validation | after 2 |
| A4 | the aggregated map capability, in a reusable library layer | may need its own slice, depending on its concrete API; it is not part of the authorization boundary |
Slice 2 explicitly does not own rmcp, tokio, stdio, tool registration, the wire protocol, or the MAP implementation.
Query readiness
| Query | Status |
|---|---|
| map | Not implemented — A4 is a new capability in a library layer |
| trace | Ready at the graph layer (cartograph_graph::trace, Slice 1) |
| blast | Ready at the graph layer (cartograph_graph::blast, M12) |
| diff | Ready at the graph layer (cartograph_graph::diff, M13), subject to both trees resolving to the one authorized identity |
One inherited consequence, recorded rather than discovered later: ADR-0019 kept refusals_near in the CLI, because refusals live in Analysis::results and reaching them from the graph would invert the dependency direction. An MCP trace therefore answers without refusals unless a later slice lifts them into cartograph-pipeline, which already depends on the resolver.
Security implications
The invariant is unchanged and non-negotiable: MCP exposes only the session-authorized repository. Placing the guard in cartograph-pipeline rather than in the transport is what makes that true for every client, rather than for the one that happens to be well-behaved.
The existing privacy gate is the floor that "privacy gate extended to MCP" must clear:
no_json_document_contains_an_absolute_machine_path(cartograph-cli/tests/cli.rs)a_rooted_drive_less_path_never_reaches_serialised_output(same file)no_error_message_from_a_rooted_path_contains_its_parents(cartograph-desktop/tests/lifecycle.rs)
SourceLocation already refuses absolute paths at construction and discovery::walk already skips symlinks; neither scopes a root, which is what Slice 2 adds.
Validation requirements
M15's acceptance — "MCP client can query the graph of an authorized repo and nothing else" — is a claim about a live client, so a real Claude Code or Cursor session is the evidence, at the standard M14's dogfood set. A measured constraint to design against: on the development machine an Airflow analysis takes 3m42s and Zulip 51s, and every query re-analyses from scratch. A session answering repeated queries will need pipeline::run_with_cache and IncrementalState, or a client will time out.
Alternatives
Recorded because they were weighed, not to pad the decision.
For A. The analysis summary — cheapest, needs no extraction, and answers a different question; relabelling it MAP would make the tool quietly claim something it does not do. The raw graph — literally "the graph", and two megabytes on a real repository. The render scene — answers MAP's question and is owned by a peer client, so consuming it inverts the client boundary and ships coordinates to something that cannot draw.
For B. Path-prefix authorization — simplest, and defeated by .., a symlinked root, and Windows case and short-name forms. Launch-time grant — the only option whose scoping is structural rather than enforced by code, but it fixes the grant channel, which the owner deliberately left open. Handshake tool — most flexible, largest trusted surface, and it needs a state machine before there is a transport to run it on. Several repositories per session — serves diff naturally, and widens what a confused or compromised client can read.
For C. Loopback TCP or HTTP — supported by rmcp and by both named clients, with no listener code, no port convention and non-goal 7 against it. Unix socket — local by construction, and Windows is a first-class target: M09's privacy defect was a Windows finding, and CI builds Windows.
For E. Putting the guard in cartograph-mcp — rejected above; it is the one placement that makes the invariant depend on the client behaving.
Consequences
M15 can proceed, and the order is fixed: Slice 2 builds the authorization boundary in cartograph-pipeline; Slice 3 adds cartograph-mcp with rmcp, tokio and stdio; A4 lands in a library layer once its shape is decided. No dependency is added before Slice 3.
Two decisions remain open and are named above rather than absorbed into code. Until the identity derivation is chosen, Slice 2 can define the type and the enforcement point — the property is fixed — but cannot claim that any particular identity algorithm is correct.