# Release notes

Changes per released version of the Architecture MCP server, newest first. Starts at 26.3.1;
earlier versions shipped without release notes.

---

## 26.5.7 — 2026-09-11

### Changed

- **`analyze_cycle` now enumerates the route it did not take.** `alternative.relations` was an
  integer — how many relations the other route would cut — which made the better answer the one you
  could not inspect. On a real 18-package cycle the alternative reached cyclicity 24 against the
  chosen route's 121, with a *contained* residue against an uncontained 11-package cluster, and the
  reply said only "12 relations, 250 sites". Those 250 sites could be twelve mechanical import
  changes or two type-hierarchy rewrites, and nothing distinguished them; the recommendation could
  be passed to a user but not evaluated for them.

  It is now a list, each relation carrying `from`, `to`, `sites`, `kinds` and `violatingSites` — the
  same treatment `inheritanceFreeCut` already gave its edges, and for the same reason: cost is not
  effort. The nested `components` breakdown is deliberately omitted, since the route you *are* taking
  already provides it and the question about a road not taken is only what you would be touching.
  `alternative` also gained a `projection`, so a route that will not finish in one step says so.

  Note the field changed type, from a number to an array of objects under the same name.

### Fixed

- **C# analysis of a solution reached through a symlink could fail its NuGet restore**, with
  `The file '<project>.csproj.nuget.dgspec.json' already exists`. MSBuild derives some project paths
  from the argument it is handed and others from the working directory — and a POSIX `getcwd()`
  always reports the real path — so a solution under a symlink is seen under two names at once.
  NuGet treats one project as two, both write the same file, and whichever loses the race fails the
  restore. `dotnet restore` is now given a fully resolved path.

  On macOS this is not an exotic setup: `/var` is a symlink to `/private/var`, so every temporary
  directory has two names. A symlinked home or work directory is equally ordinary. Being a race it
  fires intermittently, which is what made it look like a network problem for a while.

---

## 26.5.6 — 2026-09-11

### Changed

- **`analyze_cycle` now offers the parent-package route for every package cycle, not only large
  ones.** It was gated on size — above 18 packages — and that was wrong twice over.

  It made improving a project downgrade its advice. A cycle that shrank from 19 packages to 18 lost
  the condensed answer entirely, and the flat answer replacing it was worse in absolute terms than
  the flat answer for the bigger group. The threshold had been set to the quotient solver's reach
  (18) rather than to the flat solver's break/split point (16), so 17- and 18-package cycles were
  too big to break flat and too small to condense.

  Fixing the boundary would not have been enough. The gate's premise — that a cycle the solver can
  break exactly needs no second option — confuses *optimal* with *achievable*. An exact break is
  optimal on cyclicity and says nothing about whether anyone will do it: on gradle, a 14-package
  cycle the solver breaks perfectly costs **80 dependency sites**, while one condensed relation
  costs **7**. Across gradle's flagged package cycles the condensed route was cheaper in 15 cases,
  the same in 7 and dearer in 3 — and most of the cheap ones were small enough for the flat answer
  to be exact.

  So the step is now offered whenever there is a coarser grouping to climb to, and you compare its
  cost against the flat cut the way you already compare `inheritanceFreeCut` or the shear-versus-break
  `alternative`. Shaving a subsystem off the outside is frequently the step that gets landed; the
  perfect cut is the one that gets postponed.

---

## 26.5.5 — 2026-09-10

### Fixed

- **`analyze_cycle` was omitting `inheritanceFreeCut` and `cheapestSeam` for any component cycle
  that spans packages** — which is most large ones, and included the exact case
  `inheritanceFreeCut` was built for. A cycle spanning packages is answered through its package
  quotient by a separate code path, and that path built its reply through a convenience constructor
  that quietly defaulted both fields to null. Nothing was wrong in the reply, but two fields were
  missing from it.

  Both are now present there. The inheritance-free alternative is computed over the **quotient**,
  where that path actually makes its decision — an alternative reached some other way would be an
  alternative to a cut you were never offered. On jPOS's `util` ↔ `space` cycle the reply now offers
  `NameRegistrar → TSpace` at 11 sites (`extraSites` 6, same resulting cyclicity) against a proposed
  cut that severs `JESpace extends Log`. That is the architecturally right direction, and it was
  previously reachable only by noticing `equivalentToChosen` on the alternative.

---

## 26.5.4 — 2026-09-09

### Added

- **The server now recognises a subsystem API modelled as a nested artifact when an `interface` was
  meant.** A nested artifact is a *region*, and declaring one **partitions** its parent: the
  components you carve into it stop being part of the parent, so the API's own use of the
  subsystem's internals — which an API usually has — becomes a boundary crossing and needs its own
  route. An `interface` is a *view* over what the artifact already holds; the internals stay put and
  nothing is stranded.

  Both constructs answer "X has an API and internals" and only one of them survives the API
  depending on X. Getting it backwards is expensive precisely because the error message is a pile of
  ordinary-looking violations: on jPOS it turned 3 violations into 7, all shaped
  `Container.QBeanApi → Container`, and took a full authoring cycle to diagnose. The violations are
  genuine, so nothing else in the server had anything to say — and what the author needs to hear is
  not "these are illegal" but "you probably wanted the other construct". `architectureWarnings` now
  says it, naming the artifact and the dependencies.

  `explain_architecture_dsl` carries the same distinction where the choice is actually made: the
  intent table now recommends `interface` for that intent and states why the nested form strands the
  parent, instead of offering both as if they were interchangeable.

- **`explain_architecture_dsl` answers "the base classes must not depend on their subclasses" with
  "you don't need a rule".** It is not expressible in `.arc` — subclasses are spread across the whole
  framework, which the base classes legitimately use — but it is already guarded: a subclass
  `EXTENDS` its superclass, so any superclass → subclass edge closes a component cycle by
  construction and the cyclicity ratchet catches it. Previously an agent asked for this would have
  concluded the DSL was limited, when the invariant was covered all along by the other half of the
  product.

- **A cut step now says how many steps it is the FIRST of, and what they cost in total.** Each
  `packageStep` / `parentPackageStep` carries `projection` — `{steps, sites, complete}` — obtained by
  following the tool's own recommendation forward until the quotient is acyclic.

  This exists because a cheap step was unreadable without it. "1 relation, 2 sites, cyclicity
  6400 → 6241, residue uncontained" reads as a nibble beside an alternative that finishes in one
  657-site move — and is in fact step one of five totalling **670**, a 1% premium for having the
  work in five reviewable pieces instead of one. Measured across jPOS and gradle the premium was 251
  vs 248 sites, 498 vs 492, and 670 vs 657: the small steps converge, and nothing in the reply said
  so. Compare `projection.sites` against `alternative.sites` to make that trade with both numbers in
  hand — several small changes are usually easier to land than one big one, and this is what the
  small path really costs. `complete: false` means the projection was cut short, so both numbers are
  lower bounds and the path is *not* known to converge.

- **A package cycle too large to solve exactly is now also answered through its PARENT packages.**
  `analyze_cycle` already condenses a package-spanning *component* cycle to packages and cuts that
  instead — the step becomes a handful of package relations rather than dozens of unrelated edges.
  A *package* cycle got no such treatment, on the reasoning that it was "already in package terms".
  84 package terms is not an answer either, and the package hierarchy offers another rung.

  For a package cycle above 18 packages the reply now carries `parentPackageStep`: the packages
  grouped by their first `groupDepth` path segments, and a cut of *that* graph. Measured, this is
  not marginal. jPOS's 19-package cycle went from a 22-relation, 424-site shear to a 16-relation,
  260-site break reaching cyclicity 28 with a **contained** residue; gradle's 84-package cycle went
  from 100 relations and 1057 sites to **6 relations** and 492. Every oversized package cycle in
  both calibration projects condensed into exact-solver range, and every condensed break left a
  contained residue — meaning what remains are self-contained knots inside single groups, each
  solvable on its own, rather than the same tangle one size smaller. Read `residueContained`; it is
  the property the cyclicity numbers hide.

- **`analyze_cycle` reports the `cheapestSeam`: the thinnest single relation whose removal
  decomposes the group at all.** This is a different question from the proposed cut, not a rival
  answer to it. The cut optimises cyclicity gain per dependency site and discards seams below a 20%
  improvement bar — so a one-site relation that peels one package off an 84-package group, a
  one-file fix and often the only place a human can start, is precisely what it throws away. On
  gradle, 54 of 56 flagged package cycles have such a seam, and in 14 the proposed cut costs five
  times more or worse.

  It reads usefully in the negative too: when the seam is somewhere else entirely, the relation you
  were about to refactor was never what held the group together.

- **`analyze_cycle` now offers an inheritance-free alternative when its proposed cut severs one.**
  Severing an `EXTENDS` or `IMPLEMENTS` relation means changing a type hierarchy, which is a
  different kind of work from deleting call sites — and the reply used to present the cheapest cut
  as if the only question were how many dependency sites it touched. When the proposed cut severs
  inheritance, the reply now carries `inheritanceFreeCut` with a second cut that does not, its
  `sites`, its `cyclicityAfter`, and `extraSites` — what avoiding the hierarchy costs.

  It offers rather than decides because nothing else honestly can. We measured it: across gradle's
  38 flagged component cycles the cheapest cut severed inheritance in 10, an inheritance-free cut
  existed in all 10, and the extra cost was −36, 0, 0, 2, 2, 4, 10, 33, 90 and 430 sites. Six were
  nearly free and three were prohibitive, two orders of magnitude apart, so no weighting could have
  been right for both. Read `extraSites` and decide against a codebase only you know. A **negative**
  value means the alternative is also cheaper — take it, there is no trade-off; that happens because
  a large group is split heuristically rather than solved, so constraining the search can find a
  better answer. The field is absent when the proposed cut touches no inheritance.

  Where no inheritance-free cut can exist — the inheritance edges close a loop by themselves, so
  breaking it must cut one of them — nothing is offered rather than something impossible.

- **`analyze_cycle` says when its two package-step routes are equivalent.** The `alternative` object
  gained `equivalentToChosen`: true when both routes reach the same cyclicity, the same package
  quotient and the same residue shape, and differ only in *which* dependencies they cut. On jPOS
  exactly that happened and the caller had to notice it by comparing six numbers, which it did not.
  A choice between equals is not a recommendation, and it should not read like one.

- **The server now checks your `.arc` files for cycles in the RULES.** An architecture whose own
  artifact graph is cyclic used to compile without a word, and `check_proposed_dependency` would
  answer `ALLOWED` for the very edge that closed the loop — the one blind spot least forgivable in
  a tool whose central claim is that dependency cycles are debt, since a ruleset that is itself
  tangled cannot make an honest statement about tangles in your code. Ruleset cycles are now
  reported in `architectureWarnings`, naming each artifact with the file and line it is declared
  at.

  The cause is almost always a `connect to` aimed at an artifact declared *above* it. That is not
  illegal by itself — two plain artifacts with a single upward edge are acyclic and nothing is
  reported. It becomes a cycle once something grants access back down, which the implicit grants
  usually already do: `strict` reaches the next sibling beneath, `relaxed` every sibling beneath,
  `public` is granted to the siblings above it. All three point downward, so one upward `connect`
  is all it takes. `unrestricted` is the only modifier that reaches upward, and it
  is **exempt**: it is a declared quarantine, and a cycle whose closing edge nobody wrote is that
  hatch doing its job rather than a finding. Do not silence a real one by reaching for it.

  `apply` clones a slice into the host file, so a cyclic slice applied in ten domains would
  otherwise be ten copies of one problem with one fix. Those are reported **once**, in the slice's
  own vocabulary, listing the `apply` sites that brought them in.

  `explain_architecture_dsl` says this too, where an author is standing when they write the
  offending line: sibling order is now stated as a *direction* rather than a layout convention,
  with the consequence of pointing against it spelled out; `connect to Parent.Inner` carries the
  same caveat, since narrowing the target does not change its direction; and a new rule 10 says
  which grants point which way.

- **`list_baseline_changes`** — the full diff between a saved baseline and the current model. It
  serves the entries behind the `sinceSessionBaseline` counts (see below), and it takes an optional
  `name`, so you can ask *"how does this compare to the baseline our CI gate uses?"* against any
  saved baseline **without activating it**. Until now the only way to compare against a different
  reference point was `switch_baseline`, which answers the question by moving the ratchet's
  anchor — a read with a side effect.

### Changed

- **A package relation is no longer treated as an architecture violation just because it CONTAINS
  one.** At package granularity a relation aggregates many component pairs, and the flag was set if
  *any* of them broke a rule. Violation edges are removed first and *for free*, so `q2 → util` — 121
  dependency sites, 24 of them violations — was reported as costing nothing to cut. That is not a
  labelling quirk: it told the caller that tearing out 97 sites of legal traffic was free. A
  relation is now flagged only when **all** of it breaks a rule, and a mixture is an ordinary cut
  carrying `violatingSites` (present only when it really is a mixture). Cut costs price the legal
  share, since the violating share was going to be removed anyway. Component granularity is
  unaffected — a violation is keyed on `(from, to, arcFile)`, so a component pair is wholly one or
  the other.

- **`analyze_cycle`'s description now says what its weight does and does not measure.** The cut
  minimises dependency SITES, which is not the same thing as architectural wrongness: the cheapest
  cut may be the wrong *direction* to cut, leaving a lower-level package depending on a higher-level
  one, and an agent following it literally improves the metric while making the design worse. The
  description now says so, and says the thing that fixes it — with `.arc` rules loaded the server
  can judge direction **for** you, because `violationEdges` are then known inversions and are
  removed first. Authoring rules improves the cut proposals, not just the reporting; that was true
  before and invisible.

- **`rescan_sources` and `reload_all` now report `sinceSessionBaseline` as counts and deltas, not
  lists.** The two cyclicity objects are unchanged (`{baseline, current, delta}` — they are the
  ratchet); everything else is a count: `addedViolationsCount`, `removedViolationsCount`,
  `problematicAddedDependenciesCount` and the eight cycle-membership counts. The reason is that this
  block only ever *grows*: it measures net change since a fixed point, so the longer a session runs
  the larger it gets, and it was re-sent in full on every scan. On a measured external session it
  was ~22 KB per call — a third of everything the server said across the run — and never read.
  `sinceLastRescan`, the view that attributes a change to your last edit and the one to watch in a
  fix loop, is unchanged and still complete. Call `list_baseline_changes` when one of the new counts
  turns out to be worth reading.

- **`removedViolationsByArcFile` is capped at 50, with `removedViolationsTotal` alongside**, the
  same treatment `problematicAddedDependencies` already had (the cap counts violation rows, not
  `.arc`-file groups). Reverting one experimental `.arc` layer produced 70 entries and 19 KB in a
  single reply. Neither cap can hide a regression: `addedViolationsByArcFile` is computed
  separately and is never capped.

- **Violations are now grouped by the `.arc` file whose rules they break.** `list_violations`
  returns `violationsByArcFile` — `[{arcFile, violations[]}]`, sorted by file — and the rescan diff
  uses the same shape for `addedViolationsByArcFile` / `removedViolationsByArcFile`. The `.arc`
  path is part of a violation's identity rather than a property of it: files are evaluated
  independently, so the same dependency denied by two files is genuinely two violations. Repeating
  that path on every row restated a grouping the data already had: on a measured session with one
  `.arc` loaded, rows ran ~280 bytes of which the repeated path was ~56 — a fifth of the one
  payload that has to be read in full, spent restating a constant. `verdict` stays on the row on purpose: it varies within a file (an ordinary
  `DENIED_BY_RULES` next to a `DENIED_BY_TO_DEPRECATED` is the normal state mid-migration) and it
  changes what you should do.

### Upgrading

Two reply-shape changes, both deliberate breaks rather than additions.

**Violation lists are grouped.** `list_violations` no longer returns `violations`; it returns
`violationsByArcFile`, whose entries are `{arcFile, violations[]}`. The rows inside no longer carry
`arcFile` — it is on the group. The rescan diff's `addedViolations` / `removedViolations` became
`addedViolationsByArcFile` / `removedViolationsByArcFile` with the same shape. The keys were
renamed rather than reused so that a reader written against the old shape finds *nothing* at the
old key, instead of silently reading `undefined` off a row that changed underneath it.

**`diff.sinceSessionBaseline` is now counts.** Anything reading violation, dependency or
cycle-membership *arrays* under that key will not find them — the fields are
`addedViolationsCount`, `removedViolationsCount`, `problematicAddedDependenciesCount` and
`…CyclesJoinedCount`/`…CyclesLeftCount`, and the entries come from `list_baseline_changes`. The
`Count` suffix is deliberate for the same reason: it keeps one field name from meaning an array
under `sinceLastRescan` and an integer under `sinceSessionBaseline`. Nothing else under
`sinceLastRescan` changed, beyond the added `removedViolationsTotal`.

If your `CLAUDE.md` quotes the rescan obligation, the field to check is now
`addedViolationsByArcFile`; `generate_config`'s `agentInstructions` paragraph says so already.

---

## 26.5.3 — 2026-09-03

### Added

- **`list_violations`, `list_cycles`, `query_dependencies` and `check_proposed_dependency` now tell
  you when their answer is out of date.** Each reply carries `sourcesStale`, true when a source file
  has been added, removed or edited since the model was last parsed. Before this, an agent that
  edited code and then asked for the violation queue was told "nothing wrong" about the code as it
  stood *before* its own edits, with nothing in the reply indicating that. `arcRulesStale` already
  did this for edited `.arc` files; this is the same signal for the other input. Run
  `rescan_sources` to clear it.

- **`generate_config` hands back a paragraph for your project's `CLAUDE.md`.** Its reply has a new
  `agentInstructions` field: a short markdown block stating the rescan obligation, the
  `check_proposed_dependency` habit, and the rule against grepping for dependencies. Some MCP
  clients truncate a server's instructions or fetch tool descriptions only on demand, and then
  nothing in an agent's context says this server exists at all — your own instructions file is the
  one channel that always reaches it. The tool offers the text and never writes into your
  repository.

- **`explain_architecture_dsl` now documents the C# and Python attribute retrievers.** The
  reference covered the six Java ones and mentioned Python's two only in passing, and said nothing
  at all about C#'s `CSharpTypeOf`, `CSharpExtendsClass`, `CSharpImplementsInterface`,
  `CSharpIsClass`, `CSharpIsInterface` and `CSharpIsEnum` — so there was no way to discover them
  from the server. It also now states, where component names are introduced, that a component's
  package is its DIRECTORY: in C# that need not match the namespace, which is the single thing
  most likely to surprise a C# reader writing rules.

### Changed

- **A C# module is now named after the ASSEMBLY it builds, not after its project.** Sonargraph
  names a C# module after the assembly, and a componentId begins with the module name — so keying
  on the project name gave the same source file a different id in each tool, and a shared `.arc`
  file quietly meant two different things depending on which tool read it. The parser is still
  ADDRESSED by project name (`project` in the configuration); only the label changed, which costs
  nothing because the projection has always joined on `project` and never on `name`.

  The two names differ only where a `.csproj` sets `<AssemblyName>` — nhibernate-core's
  `NHibernate.Tool.HbmXsd` builds plain `HbmXsd`, and that module is now called `HbmXsd`.

  **Breaking for such solutions.** Regenerating the configuration renames those modules and
  re-keys every componentId under them: existing baselines stop matching, and `.arc` rules that
  name the old module name go inert until updated. Solutions where every project builds an
  assembly of its own name — the common case — are unaffected. Where two projects build the SAME
  assembly there is now one module name for both; the later project is dropped with a warning,
  which is what Sonargraph does with that solution too.

### Fixed

- **`rescan_sources` said to call it after you "create, rename, or delete" a file, and never
  mentioned editing one.** Its rationale was framed entirely around new componentIds being
  indexed, which reads as "this is for new files". Editing an existing file changes what it
  depends on, and therefore which violations and cycles exist, so the description now leads with
  the obligation to rescan after ANY source change and says plainly that a passing build and green
  tests do not establish architectural correctness. `list_violations` and `list_cycles` now say
  that their answers are as of the last scan and to rescan first if sources have changed since.

- **The operating rules were being truncated out of the server instructions before an agent read
  them.** The instructions ran 17,920 characters with the LANGUAGES and C# primers first and the
  three RULES — the binding ones about the violation and cyclicity ratchets — starting at
  character 4,662. A client that truncates the instruction block therefore delivered the language
  detail and dropped the rules, which is precisely backwards; one was observed cutting at roughly
  character 2,442, mid-sentence in the C# primer.

  The instructions are now 4,683 characters and the rules start at character 615. What went was
  reference material, which had no business in the one channel clients truncate: the tool
  catalogue became a compact index naming every tool, and the per-language primers moved to where
  they are read on demand — the retrievers and the C# directories-not-namespaces rule into
  `explain_architecture_dsl`, and the prerequisites (.NET 10 SDK; Python 3.10 or newer and the
  `interpreter` field) into `generate_config`'s description, which had never mentioned C# at all.
  Nothing binding was dropped.

---

## 26.5.2 — 2026-08-26

### Fixed

- **`list_cycles` no longer tells your agent to break cycles by hand.** Its description claimed
  the server "does not compute a minimum feedback set, so use judgement" — which was wrong, and
  was read at the exact moment an agent decides what to do about a cycle. It now routes instead:
  `suggest_relocations` first for a **package** cycle (a file move costs an import update, not a
  refactoring), `analyze_cycle` for a **component** cycle or when relocation reports
  `CUTS_REQUIRED`. It also states what `analyze_cycle` actually returns — an exact minimum-weight
  cut on a small group, a shrinking split on a large one, iterated until groups are small enough
  to solve exactly — and that the goal is to drive cyclicity down rather than reach zero.

  In practice agents were reaching for `analyze_cycle` on package cycles and never calling
  `suggest_relocations` at all, so the cheapest repair was routinely skipped.

---

## 26.5.1 — 2026-08-21

### Upgrading

- **`locate_fqn` now reports EVERY component declaring a name, and its reply shape changed.** The
  top-level `filterName` and `perFile` moved down one level into a new `components` array:

  ```jsonc
  // before                          // now
  { "fqn": "...",                    { "fqn": "...",
    "filterName": "Mod/A/Foo",         "components": [
    "perFile": [ ... ] }                 { "filterName": "Mod/A/Foo", "perFile": [ ... ] } ] }
  ```

  One name can legitimately belong to several components: a C# `partial` type is one type written
  across several files, and the halves can be assigned to *different* artifacts — one under
  `Domain/` and another under `Generated/` is normal. Answering with one of them was answering a
  different question. Expect a single entry for most types and do not assume exactly one.

- **`query_dependencies` replies with `components` (a list) instead of `component`**, for the same
  reason, and it no longer refuses a name owned by several. It answers for all of them as one: the
  results are the union of what the parts reach, and the parts are not reported as depending on
  each other. `trace_dependency` is unchanged in shape — it now searches every pair and reports the
  endpoints of the shortest path it found.

- **`duplicateFqns` no longer reports a C# `partial` type.** If you were filtering those out
  yourself, you can stop. On nhibernate-core this was 401 of 2429 top-level names, so for a C#
  project the signal was almost entirely noise. Java and Python are unchanged: two modules shipping
  one name really is a collision there, and is still reported.

### Added

- **C# is supported.** Set `"language": "csharp"` in `zugel.json`, or just call `generate_config` —
  it detects a solution and writes that configuration itself. Violations, cycles, the baseline
  ratchet and the dependency queries then run on a C# model exactly as they do for Java.

  **What you need on the machine is the .NET 10 SDK** — the SDK, not just the runtime, because
  solutions are opened through MSBuild. The parser itself ships inside the jar, so nothing is
  downloaded and it works offline. If the solution's NuGet packages have never been restored, the
  first scan restores them for you rather than telling you to.

  **A solution file is required**, and `generate_config` records which one it chose. Where a
  repository holds several — nhibernate-core has three — the choice is written to
  `project.solution` and reported, so pointing it at a different one is an edit rather than a
  mystery.

  **The one thing that surprises C# developers: `.arc` rules address directories, not namespaces.**
  A component is a source file and its package is the folder it sits in, relative to the project
  directory — the same rule as Java. C# does not require namespaces to follow folders, and where a
  codebase's don't, the rules follow the folders. So an artifact is written
  `include "MyApp/Services/**"`, not `include "MyApp.Services.**"`.

  **An external component is `External/<assembly>/<namespace>/<type>`** — for example
  `External/System.Collections/System/Collections/Generic/List<T>`. The assembly is part of the name
  because it is the unit of external identity in .NET and a namespace does not imply one:
  NHibernate's `Antlr.Runtime.*` types come from `Antlr3.Runtime.dll`. It is a single segment, so
  `External/System.Collections/**` names everything from one assembly. Generic parameters are part
  of the name — arity is part of a .NET type's identity, so `Task` and `Task<TResult>` are different
  components — and a nested type has none of its own: a dependency on `HashSet<T>.Enumerator` lands
  on `HashSet<T>`.

  **Six attribute retrievers**, the same ones Sonargraph offers and with the same semantics:
  `CSharpTypeOf`, `CSharpExtendsClass`, `CSharpImplementsInterface`, `CSharpIsClass`,
  `CSharpIsInterface` and `CSharpIsEnum`. They assign components by what a type *is* rather than by
  where it sits — `include "CSharpImplementsInterface: **.IRepository"` gathers every repository
  however each one is named. They match dotted type names, so a single `*` stops at a dot.

  Two more things worth knowing before you write rules. A project that targets several frameworks
  is analysed under exactly one of them — the newest, recorded in the configuration — and
  componentIds never mention it, so adding a target framework cannot invalidate a baseline. And a
  project that declares `IsTestProject` is not part of the model at all: its cycles and violations
  would be noise against what the production code owes, so no rule can govern the test-to-production
  direction.

---

## 26.4.1 — 2026-08-20

### Added

- **Python is supported.** Set `"language": "python"` in `zugel.json`, or just call
  `generate_config` — it detects a Python project and writes that configuration itself. Everything
  else works as it does for Java: violations, cycles, the baseline ratchet, and the dependency
  queries all run on a Python model.

  **There is no build system to ask, so the structure comes from the layout.** A virtualenv tells
  you where packages are *installed* and never where the sources are, so poetry, uv, pdm and conda
  have nothing to contribute here. Every `pyproject.toml` is one module — named after the
  distribution it declares — with its source roots taken from setuptools, hatch or poetry metadata,
  or from src-vs-flat convention when the metadata says nothing. A project with no `pyproject.toml`
  at all is still configured from its directories.

  **Nothing has to be installed first.** An import names its own target, so a freshly cloned
  repository with no environment set up yields a complete, governable model. What you do need is a
  Python 3 interpreter the server can find — it looks for `python3`, then `python`, and on Windows
  the `py` launcher first. Prefer 3.10 or newer: Python's own parser only understands the syntax of
  its own release, so an older interpreter reports a project's modern syntax as errors in your
  source. To pin a specific one, add `"interpreter"` to the `project` section.

  **Writing `.arc` rules for Python.** A componentId is `module/path/to/file` — the source file's
  location under its source root, with no extension. `locate_fqn` works as it does for Java, and
  resolves three kinds of name to a component: a class, a module-level function, and the module
  itself — `pkg.archive` names the file, which is what an import writes.

  Python has **two attribute retrievers of its own**. `PythonTypeOf` matches any direct or indirect
  base class, so `include "PythonTypeOf: pydantic.BaseModel"` gathers a DTO layer, and
  `PythonTypeOf: airflow.sdk.bases.operator.BaseOperator` an operator layer — roles no naming
  convention identifies. Where Java needs four hierarchy retrievers, Python needs one: it has no
  class/interface split, so an ABC or a `Protocol` is simply another base.
  `PythonHasDecorator` matches any decorator on a class **or a function** — function decorators
  being where the signal usually is — and matches either spelling: the written `app.get`, whose
  head is an instance and resolves to nothing, or the resolved `airflow.decorators.task`, which
  keeps matching after `import task as t`. The Java retrievers do not apply, and naming one in a
  Python project fails at rule-compile time rather than silently matching nothing.

  One limitation: the hierarchy walk stops at the project's edge, since installed packages are not
  parsed. Deriving from `BaseModel` is seen; deriving from a third-party class that itself derives
  from `BaseModel` is not.

  **Generated code is declared by pattern rather than by root.** Java points
  `generatedSourceRoots` at a directory; Python has no such directory, because a module's dotted
  name *is* its path from the source root — anything importable as `pkg.api.models` has to live at
  `pkg/api/models.py`, beside the hand-written code. So declare
  `"generatedPatterns": ["**/*_pb2.py"]` in the `project` section instead. Matching files get the
  same treatment generated Java gets: cycles among them, and violations originating in them, are
  excused. `generate_config` seeds the protobuf globs when your project actually has such files.

  One configuration covers one language. A repository that is genuinely both needs a server per
  language, each with its own `--config_dir`.

### Changed

- **The launcher now checks for a new version on every start, and every four hours after that**
  (launcher 1.0.3). The four-hour interval used to *suppress* the startup check instead of repeating
  it, so restarting right after a release — the one moment anybody is waiting for an update — read a
  manifest fetched *before* that release existed, found nothing, and left `server_version` reporting
  "no update staged or pending" when what it really meant was "we have not looked since before that
  version was published". The startup check is now obligatory, and the interval does what it was
  wanted for: keeping a session that stays open for days from missing a release. Updating is
  otherwise unchanged: the download runs in the background and the new version activates on the
  *next* restart, and `--launcher.no_update_check` still switches the whole thing off.

  Getting it needs the new launcher, which does not update itself — re-download
  `zugel-launcher.jar` if you want this.

---

## 26.3.6 — 2026-08-18

### Added

- **`generate_config` bootstraps Bazel workspaces.** Maven, Gradle and Bazel are all detected now,
  and each build system present is tried in turn until one answers. Bazel is tried last on purpose:
  where a repository carries both a `MODULE.bazel` and a Gradle build, the Gradle build is usually
  the authoritative one, and a Bazel-only workspace has nothing ahead of it in the list anyway.

  **A Bazel module is one SOURCE ROOT, not one target.** Bazel has no unit corresponding to a Maven
  module — its unit is the target, at whatever granularity the build author found convenient — so a
  workspace compiling 325 targets out of three directories would otherwise be modelled as 325 modules
  nobody on that project would recognise. Source roots are derived from each file's declared package
  rather than from directory names, because Bazel workspaces do not follow the `src/main/java`
  convention and often have no `src` anywhere.

  Generated java comes along: the source jars produced by `genrule`, `java_proto_library`,
  `java_grpc_library` and the like are unpacked under `<config dir>/.zugel-bazel/generated-sources/`,
  which wants a `.gitignore` entry — the tool says so. Those components are flagged generated, so
  they are already excused from cycles and violations.

  **One caveat, and `generate_config` warns about it: a Bazel-generated `zugel.json` is
  machine-specific.** Every jar Bazel reports lives under `bazel-out/<platform>-<mode>/…`, so a
  configuration generated on macOS does not resolve on Linux, and `bazel clean` deletes the entire
  tree it points at. Regenerate it rather than committing it — that is cheap once Bazel's cache is
  warm. If you do scan against a stale one, the classpath warning below now tells you instead of
  letting the model quietly shrink.

  Verified on Windows as well as macOS, on every route Bazel installs by there — Bazelisk via npm,
  winget, Chocolatey and Scoop. One Windows prerequisite is not ours to fix and worth knowing: a
  workspace with Maven dependencies needs `BAZEL_SH` pointing at a bash (Git Bash will do), or
  `rules_jvm_external`'s fetch fails before we ever see it.

### Fixed

- **`unrestricted` did nothing when the artifact was declared last — the position it is usually
  written in.** An `unrestricted` artifact may depend on any of its siblings, above it or below it.
  The connectors that grant this were generated only for artifacts that had another sibling after
  them, so the *last* artifact in a body got none at all and every dependency it had was reported as
  a violation. That is the catch-all shape the modifier exists for: a Spring-style
  `unrestricted public artifact Other { include "**" }` closing the file reported violations on
  everything it touched. The guard belonged to `strict` alone, which reaches only the next sibling
  down; `relaxed` and `unrestricted` are unaffected in every other position, and neither
  `strict` nor `relaxed` changes behaviour at all.

  **These were false violations, so your next scan may show fewer.** If you changed code, or
  loosened an `.arc` file, to satisfy one of them, that change can be reverted.

- **A classpath entry that no longer exists is now reported, instead of quietly shrinking your
  model.** Every scan (`rescan_sources`, `reload_all`) now names in `configWarnings` any module whose
  configured classpath entries are not on disk, with how many are missing and what to do about it.
  This used to be completely silent: the sources still parse, so there was no warning and no error —
  and the only casualty was every dependency into those libraries, which means violations and cycles
  running through them could not be seen either. A `mvn clean` over a sibling module's jar, a
  checked-in `libs/` directory that moved, or a hand-edited configuration are the ways in. Re-running
  `generate_config` restores them. A project whose classpath is intact is unaffected.

- **`query_dependencies` now tells the truth about how complete its answer is.** `completeness` was
  wrong in both directions. It counted the language's own constructs as unresolved — a type variable
  (`T`, `RespT`) and the supertype of every `new Something() { … }`, though both resolve fine and
  neither names a file — so answers were reported as incomplete when nothing was missing. And it
  missed references it had nothing to report them *by*: a static call on a type whose jar is gone
  (`StringUtils.capitalize(x)`) or an unresolvable import left no trace at all, so answers that
  really were missing dependencies claimed to be complete. Both are fixed, and an unresolvable import
  is reported by its whole name rather than the package prefix the compiler gives.

  The same gap dropped dependency edges: `new SomeProjectClass() { … }` recorded no `NEW` edge, so
  that coupling was invisible to your architecture rules. It is visible now, so **a violation this
  was hiding may appear on your next scan** — the rule catching something real, not a new
  restriction.

- **`server_version` no longer announces an update that was never downloaded.** It counted a
  version as staged when the launcher's cache merely held a *directory* for it — which the launcher
  creates as soon as it fetches that version's release notes, or begins staging the download. A
  restart was therefore advised for a build that did not exist, and the launcher, which checks for
  the jar itself, went on correctly running the previous version. A version now counts only when a
  verified jar is present. Release notes are unaffected: they are deliberately cached for versions
  whose jar will never be fetched, so that a pinned or out-of-maintenance user can still find out
  what an update contains.

- **A failed update check is now reported instead of vanishing.** The launcher's explanation goes
  to stderr, and MCP clients stop capturing stderr once the handshake completes — so the reason a
  download never landed reached nobody, and the symptom was a version that silently never changed.
  Launcher 1.0.2 records the failure to a file; `server_version` reports it as
  `update.lastCheckFailed`. Older launchers do not write it, which reads as nothing to report.

---

## 26.3.5 — 2026-08-17

### Added

- **Attribute retrievers in `.arc` patterns.** An `include`/`exclude` can now match something other
  than a component's name by naming a retriever first — `include "JavaImplementsInterface:
  **.Controller"` gathers every class whose ancestry reaches that interface, wherever it is declared
  and whatever it is called. This is the case naming conventions cannot express: generated or
  framework-driven code that carries its role in an annotation or a contract rather than in its
  name. Seven are available for Java:

  | Retriever | Matches |
  |---|---|
  | `JavaIsClass` | any type that is a class |
  | `JavaIsInterface` | any type that is an interface |
  | `JavaExtendsClass` | any direct or indirect base class of a non-interface type |
  | `JavaImplementsInterface` | any interface implemented by a non-interface type, transitively |
  | `JavaExtendsImplementsInterface` | as above, but interfaces extending interfaces count too |
  | `JavaTypeOf` | any direct or indirect base type, class or interface |
  | `JavaHasAnnotation` | any annotation on the type |

  Wildcards work on fully qualified names, so `*` stops at a dot and `**` does not: `**.Controller`
  matches the interface in any package, `*.Controller` in one package level only. A type may carry
  several values (a class implements many interfaces) and matching any one of them matches.

  As in Sonargraph, only the component's **main type** is considered — the one named like the file —
  and external components match nothing. The walk up a hierarchy stops at the first type outside the
  analysed sources: its name still counts, but nothing above it does. A retriever name the
  configured language does not provide is a rule-compile error rather than a pattern that silently
  matches nothing.

  Two Sonargraph retrievers are deliberately absent: `JavaHasAnnotationValue`, which needs
  annotation property values, and `JavaBelongsToAggregateRoot`, which needs generic type arguments.
  `JavaHasAnnotation` differs from Sonargraph's in one way on purpose: it sees annotations on the
  type itself, not on its fields and methods, so a detail of one member cannot decide the whole
  component's artifact.

  `explain_architecture_dsl` covers them, so an agent asked to express a grouping that no name
  pattern can capture will find them without being told they exist.

### Fixed

- **Nested types now resolve in `locate_fqn`, `query_dependencies` and `trace_dependency`.**
  Passing `com.example.Outer.Inner` previously missed the FQN index and fell through to reporting
  the type as `External/com/example/Outer/Inner` with status `UNASSIGNED_UNKNOWN` — an internal,
  assigned type described as an unassigned external one, which an agent could reasonably read as
  "no artifact governs this target". It now resolves to the component declaring the enclosing type,
  since a component is a file. Pass nested names source-level with dots; the binary `Outer$Inner`
  form is still not recognised.

  A component's `contains` list consequently holds every declared type rather than only the
  top-level ones. Duplicate-FQN reporting is unaffected on purpose: nested types are excluded from
  it, because no dependency edge ever resolves to a nested name (edge targets are normalized to the
  enclosing top-level type), and a nested FQN can only collide when its enclosing type already has.

- `generate_config` no longer gives up when the first build system fails. On a project carrying
  both a Maven and a Gradle build — a Tycho `pom.xml` beside a Gradle build, for instance — a Maven
  failure now falls back to Gradle instead of aborting. Maven is still preferred when both are
  present and healthy. The fallback is reported in `warnings`, so a Gradle-derived module structure
  never appears without an explanation, and if nothing can configure the project the error names
  every build system tried rather than only the last.

### Upgrading

- **`locate_fqn` answers differently for a nested type.** It used to report
  `com.example.Outer.Inner` as `External/com/example/Outer/Inner` with status
  `UNASSIGNED_UNKNOWN`; it now resolves to the component declaring `Outer`. If you built anything
  on the old answer — stripping the nested part before asking, or treating that status as "not
  ours" — it can go.

---

## 26.3.4 — 2026-08-11

### Changed

- The product is now called **Zügel**. The configuration file is `zugel.json`; the server jar is
  `Zugel-<version>.jar`; the MCP server reports itself as `zugel`.
- **Existing projects keep working.** The server reads the previous `architecture-mcp.json` if no
  `zugel.json` is present, and the launcher renames the file for you the first time it starts a
  26.3.4 or newer server. It never renames when a `zugel.json` already exists, and never when the
  server it is about to start is older than 26.3.4 and could not read the new name.
- Update the server key in `.mcp.json` to `zugel` if you want the tools to appear as
  `mcp__zugel__*`; the old key keeps working.

---

## 26.3.3 — 2026-08-10

### Added

- **`release_notes`** — the server's own release notes, per version, read from the copy inside the
  running jar. It pairs with `server_version`: that answers which build you are talking to, this
  answers what is in it. Call it with no arguments for the running version, `since` for everything
  released after a version you were on, or `version` for one in particular.
- Each section carries an **`upgrading`** field when that release has one: what it does TO you —
  a forced re-parse, a changed reply shape, a moved configuration key — as opposed to what it
  offers. It stays in the section text as well, so nothing is lost by reading only `notes`.
- The notes are bundled at build time, so they work with no network and cannot describe a version
  newer than the server you are asking. Naming one is an error that lists what is available.

---

## 26.3.2 — 2026-08-10

### Improved

- `generate_config` writes JSON arrays with one entry per line, instead of putting a module's
  entire `classpath` on one line. Existing files keep working; re-run `generate_config` to
  reflow the current one.

---

## 26.3.1 — 2026-08-05

### Upgrading

- **The first scan after upgrading is a full re-parse.** The parse cache is now keyed to the
  build that produced it, so older cached models are refused. Minutes once on a large reactor;
  warm again afterwards.
- **Re-run `generate_config`** to pick up `moduleRoot`. Older configurations keep working
  without it.
- **Reply shape change:** `remove_baseline` returns `removed` as an array of names in both
  modes, previously a bare string. Everything else is additive.

### Added

- **`moduleFilter`** in `zugel.json` — `includes`/`excludes` patterns selecting which
  modules `generate_config` writes into the `project` section, replacing the flat exclusion
  list. Patterns use the product's wildcard syntax (`**`, `*` within one dot-separated segment,
  `?`) against the whole module name. `generate_config` gains `includeModules`,
  `excludeModules` and `removeModulePatterns`, all additive against what is persisted.
- **`moduleRoot`** per module — the module's own directory. Lets the loader settle a source root
  two modules claim, by the same rule the generators use.
- **`configWarnings`** on every scan: source root that is not a directory, `dependsOn` naming no
  configured module, source root claimed by two modules, module that indexed nothing. Warnings
  only — no scan fails and no configuration is rewritten.
- **`architectureWarnings`** on every scan: non-`optional` artifacts that matched no component,
  and deprecated artifacts that still hold components.
- **`baselineDrift`** on scans, at startup, and on all four baseline tools — baselines that have
  outlived the configuration they were captured under. While the active baseline has drifted,
  `diff.sinceSessionBaseline` is withheld. Adding a module does not drift a baseline; removing
  one or moving its source roots does.
- Baselines written before this release are reported with `status: UNVERIFIABLE` — no recorded
  scope to check. Their diffs are still served, and they are not deleted by the bulk cleanup.
- **`remove_baseline {"drifted": true}`** deletes every drifted baseline in one call. The active
  baseline is skipped rather than failing the call.
- **`server_version`** — reports the running build. The same version is now used for the startup
  log line and for `serverInfo.version` in the handshake (previously a hardcoded `1.0.0`).
- **`generate_config` reply carries a `rescan` object** with the same payload `rescan_sources`
  returns, plus an `arcFilesLoaded` count. `generate_config` was always a reload; a following
  `reload_all` is unnecessary.

### Changed

- **`diff.addedDependencies` is now `diff.problematicAddedDependencies`** and lists only new
  couplings that break a rule, sit inside a flagged cycle, or sit inside a newly created
  tolerated cycle. Ordered violations first, then cycle members, capped at 50, with
  `problematicAddedDependenciesTotal` giving the real count. Edges of pre-existing tolerated
  cycles and ordinary new dependencies are no longer listed. `addedViolations` is never capped.

### Fixed

- Member references are attributed to the receiver's static type rather than the declaring type,
  which differ when a subclass re-exposes an inherited API. Removes false violations. Unqualified
  and statically imported calls are distinguished.
- `list_artifact_components` returns a declared-but-empty artifact instead of `Artifact not
  found`; "matched nothing" and "never declared" are now separate states.
- External components are assigned in the standing index, so an artifact capturing a third-party
  library reports its contents. `list_reachable_components` still excludes externals.
- A source root claimed by two modules is settled at load time when `moduleRoot` is recorded,
  instead of by scan order. Without that evidence the collision is reported, as before.
- `generate_config` no longer reports "no .arc files configured (cycles-only mode)" when the
  project has `.arc` files.
- A source root that is not on disk is dropped and reported by name, instead of reaching ecj and
  surfacing as a compiler bug.
- The parse cache is keyed to the build that produced it, so a stale model is no longer reused
  after an upgrade.

### Known issues

ecj does not close the classpath jars it opens. The open set does not accumulate across rescans
(macOS and Windows). On Windows those jars stay locked while the server idles until a garbage
collection releases them. Generated configurations put only immutable cache jars under `~/.m2`
and `~/.gradle` on the parse classpath, and class directories hold no handle, so `mvn clean` is
unaffected. Restarting the server releases everything.
