How Zügel keeps agent-written code inside your intended architecture — and helps you dig out of the debt you already have.
This article covers the version 26.6.1 (released on Sep 30th 2026) of Zügel. This is a living document that gets updated every time new features are added or features are changed.
AI coding agents have changed the economics of writing code. They have not changed the economics of structure. An agent will happily add the fifteenth dependency from your persistence layer back into your UI, import an internal class from a subsystem that was never meant to expose it, or close a dependency cycle that fuses two modules into one inseparable blob — all while the tests stay green. Architecture erosion used to happen at human speed. Now it happens at machine speed. Zügel – the German word for rein – is designed to stop that from happening. It puts you back in control and directs the coding agent to write architecturally and structurally sound code.
The traditional answer — periodic architecture reviews, static-analysis gates in CI — catches the damage after it is written. That is too late for agent workflows: by the time CI complains, the agent has already built three features on top of the illegal dependency.
Zügel moves the check to the moment that matters: before the agent writes the dependency. It is an MCP (Model Context Protocol) server that any MCP-capable agent — Claude Code, or anything else that speaks the protocol — connects to like any other tool provider. Under the hood it:
- parses your sources itself (Java, Python and C# today with more languages coming soon),
- maintains architecture rules based on a subset of Sonargraph’s architecture DSL (optional),
- computes violations and dependency cycles from the resolved model,
- tracks every change against baselines, so progress and regressions are visible per edit, and
- answers precise dependency questions the agent would otherwise (badly) approximate with grep.
Zügel is a companion to Sonargraph, and can be used independently from it. Using it together with Sonargraph will still provide extra benefits like CI integration, dependency visualization and a powerful environment to design architecture rules based on the Sonargraph architecture DSL.
Setup
The initial setup of Zügel is described in this article.
The Key Concepts
Six ideas carry the whole system. They are worth two minutes each.
1. Components and filter names
The unit of architecture is the component — one source file, identified by a Sonargraph filter name: <module>/<package-path>/<file-name-without-extension>, for example MyApp/com/acme/shop/service/OrderService. External types are External/..., e.g. External/java/sql/Connection. Every tool speaks this vocabulary; tools that take a component also accept a plain Java FQN and resolve it for you. There are no file-system paths in the model — the identifiers work the same whether the code lives in one module or twenty.
2. Artifacts, interfaces, connectors
An .arc file partitions the components into named artifacts using include/exclude patterns, and declares which artifact may use which. Access always flows through a connector on the consumer side into an interface on the provider side. Every artifact has a default connector and a default interface, and a small set of modifiers (public, hidden, local, unrestricted, …) shapes them. Everything not explicitly allowed is a violation.
You do not need to memorize the DSL — that is the point of the explain_architecture_dsl tool, which serves the agent a complete language reference (semantics, modifiers, and a table translating spoken design intent into DSL constructs). More on that below.
3. Violations
A violation is one concrete illegal dependency: this component, in this file, on these source lines, breaks that rule. Violations are computed by the server from real compiler bindings — no false hits from comments or string literals, no missed usages via star imports. list_violations returns the current queue; every rescan reports exactly which violations your latest edits added or resolved.
4. Cycles and the cyclicity metric
Dependency cycles are the most damaging form of structural debt: a cycle fuses its members into one unit that cannot be understood, tested, or released in isolation — and cycles grow silently. The server detects cycle groups at two granularities (component and package) and scores them with a single number, the cyclicity: the sum of n² over all flagged cycle groups. A 10-node cycle scores 100; split it into two 5-node cycles and the score drops to 50 — the metric rewards partial progress, which makes it ideal as a ratchet: it must only ever go down.
Small cycles can be tolerated by policy, and cycles formed entirely by generated code are excused automatically — there is nothing you can do about those except change the code generator. Same for violations that originate in generated code.
Some cycles are even deliberate. The classic case is an ORM domain model: bidirectional JPA/Hibernate associations (Order holds its OrderLines, each OrderLine references its Order) make entity cycles a design decision, not debt. Carve them out with a tolerance rule so the metric tracks only actionable problems:
{
"cycles": {
"componentCycleTolerance": 3,
"packageCycleTolerance": 0,
"tolerated": [
{
"include": [
"MyApp/com/acme/shop/domain/entity/**"
]
}
]
}
}
A cycle is excused only when every member matches the include — which makes the carve-out self-guarding: the moment a service class gets tangled into the entity cycle, one member fails the include and the whole cycle flips back to flagged. And tolerance hides nothing: excused cycles stay inspectable (list_cycles with includeTolerated), and a newly created cycle — even one small enough to be excused — still shows up in every rescan diff. Tolerance excuses known cycles; it never makes new ones invisible.
5. Baselines and the ratchet
On the first scan the server pins a default baseline — a snapshot of violations and cycles. Every rescan then reports a diff with two views: sinceLastRescan (what your latest edits changed — the fix-loop view, spelled out entry by entry) and sinceSessionBaseline (net direction since the anchor point, as counts and the cyclicity deltas; ask for list_baseline_changes when you want the entries behind a number). The default baseline is automatic; the interesting ones are the named baselines you create yourself at moments that matter — more on those in use case 6.
6. Every answer says how fresh it is
The server answers from its model, and the model is only as current as the last scan. So the answers that depend on it say whether they still are: sourcesStale when a source file has been added, changed or deleted since the last parse, arcRulesStale when an .arc file has been edited since it was compiled, and configStale when zugel.json has changed since it was loaded — typically after a branch switch. The remedy is always one call: rescan_sources for sources, reload_all for rules and configuration.
The agent is told what the flags mean: while any of them is set, “no violations” is a statement about the code as it was, and the work must not be reported as clean on the strength of it. A governance answer that is silently out of date is worse than no answer.
Cycles first, architecture second — the adoption ladder
The tools form a deliberate hierarchy, and you do not have to use them all at once.
Rung one costs nothing: cycle detection needs no architecture at all. The moment generate_config has run, the server knows every dependency and every cycle in your codebase — no .arc file, no design meetings, no modeling session. That is cycles-only mode, and it is not a demo mode; it is where most codebases should start.
Here is why that capability is the foundation for everything else: a well-structured code base is the precondition for having an architecture at all. A layered design is, at bottom, a promise that dependencies flow in one direction — and a cycle is precisely a set of components for which no such direction exists. So for a codebase to be well-structured cyclic dependencies have to be avoided as much as possible. You cannot assign 92 mutually-entangled files to clean layers; they form a big blob that cannot be further divided into separate architectural components. Every cycle the agent avoids or breaks under RULE 2 (see below) doesn’t just tidy the code — it preserves your option to define an architecture later. A team that only ever uses cycles-only mode still gets the single most valuable guarantee: their codebase stays architecturable.
Rung two is the .arc file — and now the results get sharper. Acyclicity says dependencies flow in one direction; the rules say in which direction, between which parts, through which interfaces. That upgrade activates the whole second half of the toolbox: check_proposed_dependency verdicts before code is written, the violation queue, reachability enumeration, intent verification. And the two rungs reinforce each other — violation edges are the first candidates analyze_cycle proposes to cut, so the rules make even the cycle-breaking smarter.
The practical path is exactly the one from use case 5: run cycles-only until the metrics are under control, then let the agent turn an architecture conversation into a first .arc file — which is a far easier conversation to have over an acyclic codebase.
Three Rules For The Agent
When an agent connects, the server’s greeting is not a tool list — it is an operating contract. Three rules, stated bluntly, because agents (like humans) revert to habit under pressure:
RULE 1 — A defined architecture is BINDING. Treat a violation like a failing test: a signal to fix the code, never an obstacle to route around. Before writing a dependency, call check_proposed_dependency; on a denial, find a legal target or escalate to the human. And never, ever make a violation disappear by editing the .arc rules to permit it — that is deleting the alarm rather than fixing the fault. Only the user may relax their own architecture.
RULE 2 — Violations and cyclicity are RATCHETS: both may only go down or stay flat. After any change, the rescan diff must show no added violations and the cyclicity metrics held or fell. A rise is a regression to back out, not to leave behind. And as of 26.5.10 this rule has a guardrail of its own: check_proposed_dependency reports what the edge would do to the cycles as well as what the rules say about it — from the graph alone, so it works with no .arc file loaded.
RULE 3 — Never grep for a dependency. “Who uses X”, “what breaks if I change X”, “why does A depend on B” — these questions go to query_dependencies and trace_dependency, which walk real compiler bindings and do reverse and transitive lookups grep simply cannot do. Grep is for comments and configuration, not for dependencies.
We added Rule 3 after watching an agent — with all these tools loaded — reach for grep anyway. Old habits die hard, even artificial ones.
Use Case 1: Guardrails While The Agent Codes
This is the bread-and-butter loop. The agent is implementing a feature and is about to make OrderService use InvoiceRenderer. Before writing the import:
check_proposed_dependency
from: MyApp/com/acme/shop/service/OrderService
to: MyApp/com/acme/shop/billing/internal/InvoiceRenderer
The server evaluates the edge against every loaded .arc file and returns a verdict: ALLOWED, UNCONSTRAINED (no rule has an opinion), or a specific denial — DENIED_BY_RULES, DENIED_NO_ROUTE, or a deprecation denial. On a denial the agent doesn’t negotiate; it asks list_reachable_components for the from side:
Given this component, list every component it may legally depend on.
That returns the legal substitute targets — maybe billing‘s public InvoiceApi instead of its internals. The agent uses the legal target, the feature ships, the architecture holds.
The same call answers a second question. Rules are only half of what you asked the agent to protect; the other half is that cyclicity may only fall. So the reply also carries cycleImpact — what this edge would do to the dependency cycles:
"cycleImpact": {
"createsOrGrowsCycle": true,
"componentCycle": {
"size": 43, "cyclicityDelta": 85, "tolerated": false,
"absorbs": [ { "size": 42, "tolerated": false } ],
"nodes": ["..."]
}
}
Read size carefully, because it is the number that surprises people. A new cycle absorbs everything on a path from the target back to the source — so one import can fuse forty-three files into a single unit that no file move can separate.
Then read absorbs, which tells you what kind of mistake you are looking at. Here, 42 of those 43 components were already in one cycle: this edge adds a single component to a blob that was there before you arrived. Had absorbs been missing, the same size of 43 would have meant something far worse — that the code was acyclic and this one import is what tangles it. The first may be worth accepting while you attack the blob itself; the second is a design decision to stop and reconsider.
cyclicityDelta is what the metric will actually do — 85 rather than 1,849, because the 42-cycle was already contributing 1,764. It is computed by assessing the graph with the edge through the same machinery that assesses it without, so it is not an estimate: it is the number the next rescan_sources will report.
This half needs no .arc file. On a project with no rules every verdict is UNCONSTRAINED and legal is always true — which used to mean the guardrail said nothing at all in precisely the configuration where it is your only protection. If your tolerance policy would excuse the new cycle, it is still reported, with cyclicityDelta 0 and tolerated true. Tolerance decides what the metric counts, not what is structurally true
After edits, the agent calls rescan_sources. The result includes the change diff, and every added dependency carries an isViolation flag — so a new illegal edge is caught even if the agent forgot to pre-check. addedViolationsByArcFile must be empty; new cycles show up as cyclicity deltas. The contract is checkable after every single edit, not once per pull request.
Use Case 2: Ask The Model, Not The Text
Even outside enforcement, the resolved dependency model answers questions that otherwise cost an agent dozens of file reads:
- “Who uses X?” →
query_dependencies(X, incoming)— exact, including transitive closure if asked. - “Who subclasses or implements X?” → incoming with kind filter
[EXTENDS, IMPLEMENTS]. - “What breaks if I change X?” → incoming, transitive.
- “Why on earth does A depend on B?” →
trace_dependency(A, B)returns the shortest concrete dependency path — the chain of components and the edges (with source lines) along it. For an unexpected coupling this is the “aha” tool: you see the exact three hops that connect two things that should have nothing to do with each other, and each hop is flagged if it is itself a violation.
The answers come with a completeness marker, so the agent knows when a result is provably complete rather than best-effort. This is the difference between facts about the dependency graph and guesses about text.
Use Case 3: Working Down The Violation Queue
For a codebase with existing debt, the fixing workflow is deliberately simple:
list_violationsonce at the start of the session — the full queue, grouped under the.arcfile whose rules each one breaks, every entry with from/to components, the verdict, the dependency kinds (CALLS, EXTENDS, …), and the source lines.- For each violation:
list_reachable_componentsfrom the from component to find a legal replacement target,describe_artifactto understand the shape of the rules around it. - Fix,
rescan_sources, watch the diff: the violation moves toremovedViolationsByArcFile, nothing appears inaddedViolationsByArcFile, cyclicity holds. - Repeat until
list_violationsreturns empty.
Because every step is verified by the server, this workflow is safe to delegate to an agent wholesale: “work down the violation queue” is a well-defined, self-checking task.
Use case 4: Untangling cycles — move first, then cut
Breaking a big dependency cycle by hand is genuinely hard: which of the hundred edges do you cut? But the first question is whether you need to cut anything at all.
Sometimes the file is just in the wrong package
A surprising share of package cycles are not coupling problems. They are a file sitting in the wrong package, with the dependencies pointing perfectly sensibly in every other respect. suggest_relocations finds those and names the file to move — no dependency broken, no interface introduced, just a relocation and its import updates.
It works because of an invariant worth stating plainly: moving a file re-labels a node in the dependency graph and changes no edge. Component cyclicity therefore cannot move, whatever you relocate. Only the package view changes. That also bounds what moves can achieve — if a component cycle straddles two packages, those packages are mutually dependent under every possible assignment, and no amount of relocation will separate them. The tool says so, with the verdict CUTS_REQUIRED and a node to hand straight to analyze_cycle.
Every proposal is pre-checked against four rules, and the reply reports how many candidates each one rejected:
- it introduces no architecture violation — the candidate is evaluated against your
.arcrules at its prospective path, including its dependencies on external libraries; - it creates or enlarges no package cycle anywhere in the project — not just in the cycle being fixed;
- it overwrites no existing file, and never crosses a module boundary;
- it never empties its source package. Package cyclicity counts packages, so it can always be lowered by merging them — the degenerate optimum is one package for the whole project. A move that removes a label instead of decoupling anything is not a fix.
One warning the tool gives about itself: the suggested destination is a hint. Destinations are chosen by graph topology, so some are semantically wrong — it will cheerfully offer to move an AbstractTestTask into a filter package. What the analysis identifies reliably is which file welds the packages together. Judge the destination yourself, or ask.
When you do have to cut — analyze_cycle
Point it at any node of a cycle group and it returns one step:
- Violations first. If the cycle group contains edges that are also architecture violations, removing those is always the first recommendation — one fix serves two goals. If that alone improves cyclicity by 20% or more, the plan stops there.
- For a component cycle that spans several packages — and most big ones do — the cycle is condensed to its package quotient and that is solved instead. One node per package, one edge per package relation. A 92-component tangle across 16 packages is far past the reach of an exact solver; its 16-node quotient is not. The step comes back as a handful of package relations to remove rather than a list of unrelated edges — which is to say, the layering your packages nearly have, and something you can write into an
.arcfile. Each relation names the concrete component edges and source lines beneath it, so it stays actionable. - A package cycle gets the same treatment, one rung further up. Its packages are grouped by parent package and that graph is cut instead. On a real 84-package tangle this turned a hundred relations and 1057 dependency sites into six relations and 492 — and, more to the point, left a contained residue: what remains are self-contained knots inside single subsystems, each solvable on its own, rather than the same tangle one size smaller. You get this route even when the cycle is small enough to break perfectly, because optimal and achievable are different properties: on gradle, a 14-package cycle the solver breaks exactly costs 80 dependency sites, while one condensed relation costs 7. Shaving a subsystem off the outside is frequently the step that gets landed; the perfect cut is the one that gets postponed. Compare the two costs and pick.
- Two ways to take that step, and the tool measures rather than assumes: shear the package cycle at its cheapest seam (
SPLIT_PACKAGES) or remove the exact minimum set that leaves the package graph acyclic (BREAK_PACKAGES), a complete layering paid for at once. The seam wins only when it removes clearly more cyclicity per site; below a handful of packages the full break already is the small step. Whichever loses is returned alongside the winner, fully costed, so you can overrule the default. - Every step says what it is the first of. A cheap step is unreadable without that: “one relation, two sites, cyclicity 6400 → 6241” looks like a nibble beside an alternative that finishes in one 657-site move. It is in fact step one of five totalling 670 — a one percent premium for having the work in five reviewable pieces instead of one. The
projectionfield carries the step count and the total, so you can weigh “several small changes” against “one big one” with both numbers instead of a hunch. - Exact minimum cut for a small group that lies inside one package, and a spectral split for a large one — the original component-level machinery, now the second tier rather than the first.
ballOfMud: true is the honest warning: this step is real work and still will not finish the job — more than fifty dependency sites to apply, with a residue left behind. Either alone is fine; an expensive step that reaches zero solved its group, and a cheap step leaving a residue is just the next small step. It is the combination that says plan for a session, and probably ask the user first.
Two more things the reply carries, both there because the cheapest cut is not always the right one. cheapestSeam names the thinnest single relation whose removal decomposes the group at all — a different question from the proposed cut, and one the cut deliberately discards, because a one-site relation that peels a single package off an 84-package group barely moves the metric while being the only place a human can realistically start. And when the proposed cut severs an extends or implements, inheritanceFreeCut offers a second cut that does not, priced against the first. Severing a type hierarchy is a different kind of work from deleting call sites, and no weighting captures that: measured across one project’s flagged cycles, avoiding it cost anywhere between nothing and 430 extra dependency sites depending on the cycle. So the tool prices both routes and lets you pick.
One caveat worth stating plainly: the cut minimises the legal dependency sites it removes — sites that violate your rules count as free, because they have to go anyway — but it does not judge direction on its own. The cheapest cut can be the wrong direction to cut — leaving a lower-level package depending on a higher-level one — so an agent following it literally improves the metric while making the design worse. Judge direction yourself. And note what fixes this properly: with an .arc file loaded the server judges direction for you, because violations are stated inversions and are removed first. Writing rules improves the cut proposals, not just the reporting.
Each proposed cut lists the concrete component-to-component dependencies with their source lines, so the agent can go break them — typically by introducing an interface, moving a class, or inverting a dependency. Then rescan, watch the cyclicity fall, call again for the next step. Stepwise, measurable, ratcheted.
Use Case 5: Architecture By Conversation
The newest capability turns the direction around: instead of checking code against rules, the agent helps you write the rules.
The intended workflow is a conversation:
You: “The shop has a web layer, a service layer, and persistence. Web talks to services, services to persistence. The domain model is shared by everything. Oh, and JDBC should only ever be used from persistence.”
Agent: (calls
explain_architecture_dsl, receives the full DSL reference, and translates:)
artifact Web
{
include "Shop/com/acme/shop/web/**"
connect to Services
}
artifact Services
{
include "Shop/com/acme/shop/service/**"
connect to Persistence
}
artifact Persistence
{
include "Shop/com/acme/shop/persistence/**"
connect to Jdbc
}// shared: public artifacts go LAST, below their consumers
public artifact Model
{
include "Shop/com/acme/shop/model/**"
}// external classes: assigning them constrains who may use them
artifact Jdbc
{
include "**/java/sql/**"
}
The agent wires the file into zugel.json, calls reload_all, and reports what the rules found: “Two violations — web/CartController uses java.sql.ResultSet directly on lines 88 and 104.” Now you are having exactly the conversation you should be having: is that code debt to fix, or did we forget a legitimate rule?
The explain_architecture_dsl reference is what makes this reliable. The .arc DSL’s semantics are not guessable from syntax — sibling order is meaningful, public grants access only to siblings above it, nested artifacts share their parent’s connections unless marked local, the default interface excludes hidden nested artifacts. The reference encodes all of it, including a translation table from spoken intent (“implementation detail”, “shared by everything”, “only persistence may use JDBC”) to DSL constructs — and it ships inside the server jar, so it is always in sync with the engine that enforces it.
Please note that the architecture DSL used by Zügel is a subset of Sonargraph’s version. That means not all features available in the language are available in Zügel. Right now we do not support artifact templates and dependency restrictions based on dependency type. Also not all attribute retrievers are supported (e.g. “JavaHasAnnotationValue”). If you use “Export Zügel Configuration” in Sonargraph it will warn you about unsupported features and only export compatible architecture definitions to Zügel.
Two rules from the reference deserve highlighting because they embody the philosophy:
- “Read violations as information, not noise… never silently widen a rule to make a violation disappear. The architecture belongs to the user; you translate it, you do not weaken it.”
- “Verify intent, not just absence of violations” — a model that allows everything also has zero violations. The agent is instructed to use
check_proposed_dependencyto confirm that dependencies you want forbidden actually are.
When the rules themselves are wrong
Two authoring mistakes are easy to make and expensive to diagnose, because neither announces itself — one produces no violations at all, the other produces perfectly ordinary-looking ones. The server checks for both and reports them under architectureWarnings on every scan.
A ruleset that is itself cyclic. Sibling order in an .arc file is a direction, not a layout convention, and every implicit grant points downward — so a single connect to aimed at an artifact declared above it can close a loop in the rules. That file still compiles, and check_proposed_dependency will cheerfully answer ALLOWED for the very edge that closed the loop. For a tool whose central claim is that dependency cycles are debt, a tangled specification is the least forgivable blind spot there is: rules that are themselves circular cannot make honest statements about circularity in your code. The server now walks the artifact graph of each file and says so, naming the artifacts and the lines they are declared at. (unrestricted artifacts are exempt — that modifier is a declared quarantine, and a cycle whose closing edge nobody wrote is the hatch doing its job.)
An API modelled as a region when you meant a view. “X has an API and internals” has two plausible spellings and they are not interchangeable. A nested artifact is a region: declaring one partitions its parent, so the components you carve out stop being part of X — and the API’s own use of X’s internals, which an API usually has, becomes a boundary crossing needing its own route. An interface is a view over what X already holds; the internals stay put and nothing is stranded. Get it backwards and the error message is a pile of ordinary violations: on a real project this turned three violations into seven and cost a full authoring cycle to work out. The server now recognises the shape and names the remedy.
The same architectureWarnings list carries two findings that are not mistakes but still worth hearing. An artifact that matches no component makes every rule about it inert: usually its patterns do not fit the componentIds the scan produced, sometimes it is a rule written ahead of the code — which is what the optional modifier is for, and an optional artifact is not reported. And a deprecated artifact that still holds components is reported with a count and examples. A deprecated artifact accepts no new components; the ones still in it are the unfinished part of the migration, and as long as a connect to the artifact remains in place their usage stays legal, so no violation mentions them. This warning is the only thing that reports the work left.
When an .arc file does not compile
An .arc file with an error never stops Zügel. What happens instead depends on whether the file has worked before in this session:
- It compiled before, and an edit broke it. The version from before the edit stays in force, so a half-finished edit neither lifts a rule nor makes a violation look resolved.
reload_allreports the errors, and until the file compiles again every verdict and the violation list say that the rules in force are the previous ones. - It never compiled — broken when Zügel started, or newly added to
arcFiles. There is nothing to fall back on, so the file is inactive: none of its rules are enforced. Zügel says so in its greeting, and every verdict and the violation list name the file, so a clean answer cannot pass for a clearance under rules nobody checked.
Either way the errors come back in arcFileErrors, and the agent’s job is to show them to you — not to rewrite your rules until they compile. Fix the file, ask for a reload, and it is back in force. If it was inactive, its violations reappear in the diff as added; the reload marks the file reactivated, so they are not mistaken for new ones.
Use Case 6: Keeping Score With Baselines
Architecture work is a long game, and long games need scoreboards. The default baseline (pinned automatically on the first scan) keeps the everyday ratchet honest — but the real power is in named baselines you create at moments that matter. Two workflows cover most of what teams need:
First, a word on ergonomics: you never call these tools yourself. Baselines are managed in plain language — you say what you want, the agent picks the tool. The whole lifecycle is conversational:
“We’re starting the payment feature — snapshot the architecture first.” → the agent calls
create_baseline("feature-payment-api").“Which baselines do we have?” →
list_baselines— every saved name, and which one is currently active.“How do we compare to the state before the billing refactoring?” →
list_baseline_changes("before-extract-billing")— the full diff against that anchor, and it leaves the active baseline alone.“Measure everything against that point from now on.” →
switch_baseline("before-extract-billing")— the ratchet now measures against that anchor.“The branch is merged, clean up its baseline.” →
remove_baseline("feature-payment-api").
One thing to be clear about: a baseline is a measuring stick, not a restore point. Switching to an old baseline changes what the diffs compare against — it does not (and cannot) change any code. Your git history restores code; baselines answer “how does today compare to that moment?”
Every feature branch gets a baseline. The habit: branch, then tell the agent to snapshot. From that moment, sinceSessionBaseline in every rescan is the branch’s net architectural footprint — not the noise of individual edits, but the sum: which violations the branch would merge, which components it pulled into cycles, how the cyclicity moved. Intermediate churn cancels out; a violation introduced on Tuesday and fixed on Thursday never shows. Before the merge, ask “what would this branch do to the architecture?” — the agent reads that one diff and answers the question code review rarely asks. If the answer is “no new violations, cyclicity flat”, merge with confidence.
Every major refactoring gets one too — as proof of progress. Before extracting the billing subsystem: “baseline this as before-extract-billing.” A refactoring that runs over days produces dozens of intermediate states, some of which legitimately look worse mid-flight; the named anchor keeps the goal measurable while sinceLastRescan guards each individual step. When the diff finally reads “12 violations resolved, cyclicity 340 → 80”, the refactoring has a receipt — numbers for the team, not vibes. Weeks later, one sentence — “compare against before-extract-billing” — brings the anchor back for a retrospective, and “which twelve?” gets the list.
And the other half of the score: what never happened. Baselines measure the damage that got in. They cannot measure the damage that didn’t, because nobody notices the cycle that was never created — which is the renewal problem every preventive tool has. So the server keeps an append-only ledger of what the guardrail was asked and what happened next.
You read it the same way you read everything else here — by asking:
“What has Zügel actually prevented on this project?” → the agent calls prevention_report and reads you the numbers.
“Has the guardrail ever been overruled?” → the same report; overridden is the field, and it is there precisely so that question has an answer.
The ledger itself is a plain text file, one JSON object per line, sitting next to your zugel.json as .prevention.jsonl. You do not need the agent or the server to read it: grep, jq or a spreadsheet will do, which matters if you ever want to put the number in front of someone who does not use either.
Commit it. The number worth quoting is the project’s, not one laptop’s, and being in version control is also the only thing that makes an edit to the ledger visible — ignoring it would protect nothing, since the file sits on disk either way. The one thing an append-only file needs from git is a merge rule, or two branches that each recorded a check conflict on every merge. As of 26.5.11 the server writes that rule itself the first time it creates the ledger: a .prevention.jsonl merge=union line in the .gitattributes beside it. (26.5.10 advised keeping the ledger out of version control for exactly that conflict. If you followed that, drop the .gitignore entry.)
Its neighbour .baselines/ is the opposite case — megabytes of analysis state that regenerates from your sources — and it keeps itself out of git, via a .gitignore of its own written inside the directory when the server creates it. Your project’s own .gitignore is never touched by either.
The report itself looks like this:
{
"checks": 412, "flagged": 41,
"byRules": 22, "byCycle": 23, "grewExisting": 17,
"held": 33, "redirected": 6, "overridden": 2,
"rescans": 180, "dependenciesAdded": 3412, "violationsAdded": 0,
"componentCyclicity": -1020, "packageCyclicity": -72
}
Read the bottom row first. “41 proposals flagged” is unfalsifiable on its own; “3,412 dependencies added, not one violation ever added, component cyclicity down 1,020, and 41 proposals flagged along the way” is a sentence where every term was measured. The prevention count is the detail — the activity it happened during is the claim.
Three things about that ledger are deliberate. Your agent cannot write to it; every entry is a side effect of a tool it called for another reason, because a number the measured party can influence is worth nothing to the person being shown it. It records outcomes, not intentions — a warning alone only says the agent asked, so the server matches flagged proposals against what later rescans actually added: held means never written, redirected means the component asked about a different target, got a clean answer and wrote that instead, and overridden means written anyway. And overridden is published as prominently as the rest, because a report that counts only its wins is an advertisement and will be read as one.
One honest limit, which the reply states in its own caveat field: none of this is a counterfactual. “held” means a flagged proposal was not written — not that it would have been written without the warning. What the ledger shows is that the guardrail was consulted and obeyed, which is a smaller claim than “disasters averted” and the only one the data supports.
Three properties make this cheap enough to be habitual: baselines persist on disk (a branch abandoned for two weeks resumes its ratchet where it stopped — and a returning agent finds it via list_baselines), creating one is a single sentence the agent can even do unprompted at branch start, and the diff views come free with every rescan the agent runs anyway.
Two rules keep a baseline honest when the project itself changes. Adding a module never invalidates one — but everything inside the new module shows up in the next diff as added, so every diff view lists the modules the comparison point never covered (addedModules), and a new module’s violations cannot be mistaken for a regression. Removing a module or moving its source roots does invalidate a baseline: it now measures against components that no longer exist. Such a baseline is reported as drifted (baselineDrift), at startup and on every scan, and while the active baseline has drifted sinceSessionBaseline is withheld rather than wrong. Re-anchor with a new baseline; “remove all drifted baselines” cleans up the rest in one call.
Use Case 7: One Zügel, Many Repositories
If you work across several repositories, one Zügel serves all of them: set it up once for your user, as described in First Steps With Zügel. A session started that way is not bound to a project yet; the agent binds it to the repository it is working in with switch_project, and again whenever you move on:
“Switch Zügel to the payment service.” →
switch_projectbinds that repository.“Back to the shop.” →
switch_projectagain.
One project is bound at a time, and a switch releases the previous one entirely — its model, its rules, its baselines view — so nothing of one repository can leak into answers about another. What each project keeps lives on disk in its own directory: its baselines, its prevention ledger, and its cached model, which is why switching back to a repository Zügel has analyzed before takes seconds rather than a full parse. The reply to a switch is the report a reload gives: which .arc files are in force, any that do not compile, and a rescan diff against that project’s own last scan. A repository without a zugel.json lands in bootstrap mode, exactly as on a first start.
Every reply of every tool names the project it answers for, in projectRoot. That matters more than it sounds: an agent that remembers a verdict from the repository it just left would otherwise have no way to tell it apart from one about the code in front of it.
A project-based setup — Zügel configured inside the repository — binds its project at startup and can switch just the same, so the two setups mix freely.
We Eat Our Own Dog Food
Zügel develops itself under its own supervision — and its own .arc file defines the architecture of the analyzer, the DSL engine, and the parser.
That loop has already paid for itself several times over. The server caught a package cycle between its own analyzer and analyzer/tools packages, introduced during a refactor (cyclicity 4 — fixed by extracting a shared enum, verified back to 0 by the tool itself). It surfaced the need for generated-code handling when its own parser-generator output formed cycles no one can fix. And in the most satisfying episode, a discrepancy — our server reported 62 violations where Sonargraph showed zero — exposed a genuine engine bug in how nested artifacts inherited their ancestors’ interfaces. The bug was fixed, and the fixture that now guards it was mutation-tested: we verified the tests fail against both plausible-but-wrong implementations, not just pass against the right one.
There is no better test of an architecture tool than making it police its own architecture, with the vendor’s flagship product as the referee.
Testing it on Gradle
A 275-file dogfood proves correctness; it doesn’t prove scale. So we aimed the server at a large well known project: Gradle v9.5.0. No configuration existed — the whole run started from the bootstrap greeting.
Two tool calls later:
generate_config injected its init script into Gradle (no build file touched), let Gradle evaluate its own 200+-project build, and wrote the configuration: 214 modules, 4,212 external classpath entries — every single one home-relative and machine-portable, 2,689 inter-project dependsOn edges. Test harnesses and documentation projects without Java sources were skipped, each with a named warning. The server then initialized in place and parsed 10,148 Java files. End to end, greeting to queryable model: about six minutes, only 15 seconds of that were used by Zügel for parsing the code. The remaining time was needed for the initial build performed by Gradle.
analyze_cycle then went through the five biggest tangles the cycle report found — one call per cycle, one proposed first step each:
| Cycle (module) | Size | Verdict | One-step improvement | Price of the step |
|---|---|---|---|---|
| dependency-management | 92 | shear the package cycle | 72% (8,464 → 2,378) | 3 relations / 12 sites |
| core | 59 | full package layering | 85% (3,481 → 514) | 12 relations / 34 sites |
| core-api | 55 | shear the package cycle | 46% (3,025 → 1,620) | 3 relations / 3 sites |
| model-core | 36 | ball of mud | 92% (1,296 → 98) | 140 sites |
| file-collections | 29 | full package layering | 59% (841 → 349) | 1 relation / 19 sites |
Four of the five are attacked through their package quotient, and they are cheap. dependency-management — the resolution engine, 92 files across 16 packages — gives up 72% of its tangle for three package relations and twelve edit sites. core-api gives up 46% for three sites. core is the tidiest result of all: 34 sites buys a complete package layering, package cyclicity to zero, and every one of the six residual cycles then sits inside a single package. That is what an acyclic package graph guarantees, and it is the difference between “here are 91 edges, good luck” and “here are twelve relations; your packages nearly form a hierarchy already”.
Read the residues before celebrating, though. dependency-management‘s cheap step leaves 43 components in a cycle across seven packages and 23 in a cycle across three — still multi-package tangles, to be condensed again on the next call. This is still a big improvement from a 16 package cycle with 92 components. But the tool also returned the full breakup of the package cycle as an alternative solution. In that case 102 sites would have to be touched and the component cyclicity would go from 8,464 to 254, a 97% improvement while the package cycle would have disappeared.
And one cycle is honestly hard work: model-core, 36 files in a single package. There is no package structure to exploit — nothing to condense, no layering to discover — so the component-level machinery is all that is left, and its best offer costs 140 edit sites and still leaves 98 cyclicity behind. That is the one the tool flags ballOfMud: true. For scale: of gradle’s 173 component cycles, 140 have a first step costing under five sites, 172 cost 34 or fewer, and nothing at all falls between 40 and 100. model-core sits alone above the gap. Nineteen sites is minutes of work with a coding agent; 140 is a refactoring session.
Here is the part that should keep you up at night: we have seen the cycle in the dependency-management module before, when it was easier to fix. In a 2022 version of Gradle the cycle had 69 elements instead of 92, so it grew by about 30%. But the effort to fix it grew from 44 sites to 102 sites, almost 150% more. This should serve as a reminder that avoiding cyclic dependencies has an incredible ROI. And by using Zügel you can virtually guarantee, that this problem will never affect your system.
All Tools At A Glance
You rarely need a tool’s name — you ask in plain language and the agent picks the tool — but for reference, this is everything Zügel offers. Each tool’s own description, which the agent reads, carries its full contract.
| Area | Tool | What it answers |
|---|---|---|
| Rules | check_proposed_dependency | Would this new dependency be legal, and what would it do to the cycles? Called before writing it. |
| Rules | list_violations | Every current architecture violation, grouped by .arc file. |
| Rules | list_reachable_components | Everything a component may legally depend on. |
| Rules | describe_artifact | An artifact’s interfaces and connectors, and what they reach. |
| Rules | list_artifacts | All artifacts of every loaded .arc file. |
| Rules | list_artifact_components | Which components an artifact, its interfaces and its connectors hold. |
| Rules | locate_component | Which artifact a component belongs to, per .arc file. |
| Rules | locate_fqn | The same, starting from a fully-qualified type name. |
| Dependencies | query_dependencies | Who uses X, what X uses — direct or transitive, filterable by kind. |
| Dependencies | trace_dependency | The shortest dependency path from A to B, edge by edge. |
| Cycles | list_cycles | All component and package cycles, with the cyclicity metric. |
| Cycles | suggest_relocations | Package cycles a file move dissolves. |
| Cycles | analyze_cycle | The next concrete cut for a cycle, with its price. |
| Freshness | rescan_sources | Re-parse after source edits; returns the change diff. |
| Freshness | reload_all | Re-read zugel.json and the .arc files, then rescan. |
| Projects | switch_project | Bind Zügel to another repository. |
| Projects | generate_config | Create or refresh zugel.json from the build. |
| Trend | create_baseline | Snapshot the current state as a named reference point. |
| Trend | list_baselines | The saved baselines, and which one is active. |
| Trend | switch_baseline | Measure against another baseline from now on. |
| Trend | list_baseline_changes | The full diff against a baseline, entry by entry. |
| Trend | remove_baseline | Delete a baseline, or all drifted ones. |
| Trend | prevention_report | What the guardrail was asked, and what happened next. |
| Authoring | explain_architecture_dsl | The .arc language reference — read before writing rules. |
| Server | activate_license | Activate the activation code or license file you give the agent, at once and for every later start. |
| Server | server_version | Which build is running, and whether an update is waiting. |
| Server | release_notes | What changed in a version. |
What it is not
The server is deliberately scoped:
- It is not a CI gate. Sonargraph-Build is. Zügel is the fast inner loop; the authoritative check on the main branch stays where it is.
- It is not a visualization tool. When you want to see the dependency structure, open Sonargraph. Zügel answers questions; it does not draw.
- Deprecated-dependency warnings (as distinct from violations) are specified in the DSL but not yet implemented in the server — currently they report as violations.
Closing thought
Agents don’t have architectural taste, and pretending otherwise is how codebases rot at machine speed. But agents are excellent at following explicit, checkable contracts — better than humans, in fact, when every edit is verified by a tool rather than a code review.
That is the bet behind Zügel: make the architecture machine-checkable at edit time, teach the agent the three rules, and the same force that erodes structure becomes the force that maintains it. Your architecture stops being a diagram on a wiki page that new dependencies quietly ignore, and becomes a living contract — one that your fastest developer, the one that never sleeps, actually honors.