# Tests green, architecture worse: a deterministic gate for coding agents

> Source: <https://dev.to/ake2l/tests-green-architecture-worse-a-deterministic-gate-for-coding-agents-4jhi>
> Published: 2026-09-15 15:25:04+00:00

My coding agents kept the tests green. The architecture still got worse.

In the DATAMIMIC EE core the agents didn't break the build. They broke the structure. Utilities landed in whatever module was closest, not where they belonged. Code imported past the public interface of another component. And the one that hurt most: clients got imported in places that had no business touching them, above all in the communication between data sources and tasks. In a small task a reviewer catches that. In a large, nested task it hides in a diff that looks reasonable, with every test green.

The problem has two halves. The first is obvious: an architecture change that nobody declared. The second is easy to miss. A change can make the code harder to analyze, so the next report looks clean only because the analyzer sees less of the program. A gate that can't detect when its own visibility gets worse can't distinguish clean code from code it can no longer see.

We build the EE core spec driven. Architecture decisions live in ADRs, and we move them into the skills our agents load. On small tasks that works. On bigger tasks and longer sessions it doesn't hold. A model's blind spots shift with the seed and with how full the context is, and an `AGENTS.md` or a skill is a request to the agent, not a check on its output.

Asking a model to judge the pull request moves the same problem one level up. The answer changes when you rephrase the question, and an agent can argue with it. A gate that an agent can talk its way around isn't a gate.

Architecture tests aren't new either. ArchUnit, import-linter and dependency-cruiser check whether one snapshot of the code obeys a set of rules, and Archkeel does that too. What I needed on top was a comparison: the accepted state against the candidate, the change against what the agent said it would do, and a hard stop when the evidence itself got worse.

[Fixture A](https://github.com/rapiddweller/archkeel/tree/0.3.0/fixtures/A-dispatch) in the [Archkeel repo](https://github.com/rapiddweller/archkeel) is the smallest case. You can run it yourself.

``` php
 def run(key: str) -> int:
-    return first() + second()
+    handlers = {"first": first, "second": second}
+    return handlers[key]()
```

The tests pass. No new forbidden import, no cycle, no private access. A gate that only compares findings says nothing changed.

The architecture may still be fine. What changed is that a static analyzer can no longer prove it: two resolved calls became one unresolved call. For a deterministic gate, that's enough to reject an undeclared change.

``` php
expectation_fulfilled: FAIL
regression check failed in calls_unresolved: 0->1
regression check failed in unresolved_ratio: 0/2->1/1
```

The ratio is compared with integer cross-multiplication. No rounded percentages. No score.

You describe a target architecture in a contract: components, the packages they own, the names each one makes public, and rules. Every ordered pair of components needs a decision, allowed or forbidden, each with a written reason. A pair nobody decided is an open decision, and validation stays red until it's gone.

Archkeel keeps its verdicts separate and never blends them into one number. Three of them carry the idea:

| Verdict | Question | 
|---|---|
| `observation_complete` | Did the scan see everything it claims to see? | 
| `declared_rules` | Does the code obey the contract? | 
| `expectation_fulfilled` | Did the change match what was declared, without regressions? | 

The third one is built for agents. Before an agent submits an implementation, it commits an expectation: what it intends to change in the architecture. Archkeel checks Git ancestry and the merge request history on the host to verify that the expectation was published before the first submission. An agent that writes the expectation afterwards gets rejected even when its code is clean. That's [Fixture B](https://github.com/rapiddweller/archkeel/tree/0.3.0/fixtures/B-posthoc).

Exit codes are boring on purpose. 0 passes, 1 rejects, 2 means Archkeel couldn't verify the input, always with a diagnostic that names the subject and a remedy. Unknown never becomes green.

My first onboarding got this wrong. `init` read today's imports and wrote them into the contract as the architecture. That report could only pass. It described the code, including every shortcut the agents had already taken.

In 0.3.0 `init` proposes components and their public interfaces, and it writes no dependency rule at all. Every pair is an open decision, listed by how many import sites use it. An import the code already has isn't a decision. If the target forbids it, the first report shows it red, and that's exactly what I want to see on day one.

Who decides? The architect. The packaged skill runs in two modes. In interview mode the agent reads the ADRs and architecture documents first, prepares every decision with a recommendation, confirms the overall picture once and then asks only about conflicts and gaps. When I choose against its recommendation, it asks why. In auto mode the agent decides alone: documents first, then documented principles, then its own judgment, labeled as such. Every rule records `decided_by`, and the report says it plainly: "156 of 159 rules decided by the agent, awaiting the architect."

We tried both on a field service app: Python 3.12, FastAPI, async SQLAlchemy on PostgreSQL with PostGIS, Redis and Taskiq for the background workers, OR-Tools for route planning. 13 components. The service's owner acted as the architect.

The first interview overwhelmed the architect. About 20 rounds of unranked questions over 156 component pairs. Some got answered in bulk, and three of those bulk answers contradicted the service's own architecture document. Nobody noticed until a second agent had decided the same 156 pairs blind, without seeing the architect's answers. It matched 140 of them, 89.7%. The three contradictions got fixed. Then the interviewing agent asked why the architect had gone against its recommendation on the remaining mismatches, and five more decisions changed.

That interview is why interview mode looks the way it does now. Recommendations with evidence, the overall picture confirmed once, questions only where there's a conflict or a gap.

The first report of the final target failed with 162 violations. 148 of them are one edge: the use cases import the persistence adapter directly, and both targets forbid that. The run also found five defects in Archkeel that no fixture had shown. One of them counted an import twice when a forbidden dependency and the interface boundary both caught it, and nearly doubled the headline. All five are fixed in 0.3.0. The anonymized contracts, both reports and the pair-by-pair comparison are in the repo under [`docs/evidence/internal-service/`](https://github.com/rapiddweller/archkeel/tree/0.3.0/docs/evidence/internal-service).

That agreement rate is one service, measured once. It's not a general accuracy of auto mode, and I won't sell it as one.

The reviewer gets an HTML report with the decision first, then the evidence. The agent gets the same result as JSON, plus a packaged instruction file from `uvx archkeel skill install claude` (or `codex`). I don't want one truth for the machine and a friendlier story for the human.

For the drift itself there's a component flow view. Every card is a component from the contract, every line an import between components that Archkeel observed, drawn by the number of import sites. Teal lines conform to the contract. A dashed red line breaks a rule and carries the rule's id. A dotted amber line is a dependency the code uses that nobody has decided yet.

That's the small shop sample from Archkeel's fixtures, with violations planted on purpose.

Archkeel runs its own gate in CI. Its contract has 6 components, and I decided all 30 component pairs myself: 46 rules, none of them by an agent. It requires exactly one owner per module, no component cycles, and it forbids `getattr`, `hasattr`, `cast`, `eval`, `exec`, dynamic imports and `type: ignore` anywhere in the package. Those are my rules for Archkeel, not defaults you inherit. Every enforcing rule was proven by planting a violation and watching the check fail.

Determinism is measured, not assumed. A test runs the report three times on two clones in different paths, with different hash seeds, working directories, time zones and locales, and requires byte-identical output without normalization. That covers one machine and one Python build. Across operating systems and Python versions I haven't proven it yet.

It won't tell you that two competing implementations of the same idea exist, unless a rule or a regression exposes them. It checks private access through imports, so `import pkg; pkg._member` slips through. It proves publication order, not that nobody edited privately before publishing. Host evidence comes from GitLab merge requests today; there's no GitHub adapter yet.

About one call in five stays unresolved: 630 of 3,303 on Archkeel itself, 998 of 4,318 on the service. They're counted and reported, never guessed. Runtime behavior, data flow and performance aren't observed at all. They belong in the reason behind a decision, and Archkeel checks that a reason exists, not that it's true.

Before I wrote a line of Archkeel, I tried what already exists on the EE core: static analyzers, code graph tools, and cleanup helpers with and without an LLM behind them. For what I needed they added overhead instead of focus. None of them gave me the comparison I was after: the accepted architecture against the candidate against the declared intent, with weaker evidence treated as a failure.

So Archkeel started as a guardrail for the agents working on the DATAMIMIC EE core. I extracted it because the problem isn't specific to our codebase. Components declare their public interface, and `interface_boundary` rejects any import from another component that reaches past it. That's the utility and client problem from the start of this post, turned into a red verdict with a file and a line.

It's MIT licensed. The code is on GitHub at [rapiddweller/archkeel](https://github.com/rapiddweller/archkeel), the package on [PyPI](https://pypi.org/project/archkeel/), and `uvx archkeel --help` is all it takes to start. Point it at a repository where agents write code and find a verdict that's wrong. Open an [issue](https://github.com/rapiddweller/archkeel/issues) with it. Those are the cases I want.
