Software engineering became software architecture.
Highly opinionated, based on my personal experience dogfooding my own setup.
Not a prescription. I'm scratching the surface, with a lot left to learn.
In Part 0 I agreed that coding is solved, and that it took four things to get there. None of them guarantees the architecture is right. This post is about that gap. Code quality is a convergent problem now — you can push it toward "good" mechanically. Whether the design is right is a different kind of problem, and I have no gate for it. That's the part nothing gates, the part you find by use rather than by spec. And I'll get to how even that is sliding away from me.
The convergence mechanism
A deterministic gate is a check that gives the same verdict every time, no matter which model ran or what context was loaded. Run it on every commit and the output can only move one way: toward whatever the gate calls "good." That's convergence. Tech debt piles up when nothing pushes back. A gate pushes back every single time.
I work in Python, so my gates are ruff, ty, and tach, run through prek, plus gitleaks and a stack of project-specific hooks. Different language, different tools. Substitute your equivalents. The constraint is the point, not the toolchain.
teatree is the personal code factory I'm dogfooding — a Django project that turns a ticket URL into a merged PR. Its pre-commit pipeline is a numbered sequence, phases 0 through 10, running more than sixty hooks. Most fire on every commit. A few run only at push, or as manual and CI lanes.
| Phase | Hooks | What it catches |
|---|---|---|
| 0 · Safety | merged-branch guard, main-clone guard, public-leak guard | commits and pushes that shouldn't happen at all |
| 1 · Init |
uv-lock, uv-sync
|
a drifted lockfile |
| 2 · Format |
ruff-format plus end-of-file, whitespace, line-ending, Markdown and TOML fixers |
inconsistent formatting |
| 3 · Syntax | large-file cap, JSON / YAML / TOML / merge-conflict checks | malformed or oversized files |
| 4 · Lint & structure |
ruff, tach, import-linter, tach-modules-declared, jscpd (duplication ≤ 1.5%), codespell, editorconfig |
rule, boundary and duplication violations |
| 5 · Types | ty |
type errors and warnings |
| 6 · Security |
gitleaks, banned-terms, no-overlay-leak, no-raw-sys.argv
|
secrets and private-overlay leaks |
| 7 · Skills & docs | skill-md / refs validators, README / CLI / dependency-graph generators | stale skills and generated docs |
| 7b · Gate guards | quality-gates, module-health, chokepoints, no-silent-skip, skill-prose-ban | silent relaxations of the gates themselves |
| 7b2 · Doc & version sync | blueprint-sync, invariant-numbering, size budget and cap, version-sync | BLUEPRINT and version files drifting from code |
| 7a · Evals (token-free) | skill-triggers, pinned-regressions | behaviour regressions |
| 8–9 · Audit (CI / manual) | pip-audit, CycloneDX SBOM, semgrep | vulnerable dependencies, SAST findings |
| 8b · Pre-push | doc-update gate, comment-density, ensure-PR | undocumented changes, orphan branches |
| 10 · Commit-msg | conventional-commits | non-conventional commit messages |
Two of those run stricter than people usually set them. ruff runs with lint.select = ["ALL"], every rule on, exceptions justified one by one. ty runs with error-on-warning = true, so a type warning fails the commit instead of scrolling past. Coverage sits behind the same wall: a hard floor (fail_under = 93, branch coverage on) plus a separate per-module floor on the newer code, so a single module can't silently rot while the project-wide number stays green. None of it is advisory. A failing gate is a failing commit.
This matters more in the agent era, not less. The agent produces more code, faster, than I would by hand. Convergence is what keeps that volume from becoming debt. Drop the gates and the same productivity that ships features ships mess at the same rate.
The one-directional ratchet
A gate you can relax isn't really a gate. The easy fix for a red check is to widen the ignore list or lower the floor. So a separate commit-msg hook watches for exactly that. It flags any commit that touches a lint-ignore list, fail_under, or an omit pattern, or reaches for --no-cov or --no-verify. The hook fails the commit and tells you to remove the suppression and fix the underlying issue. Convergence stays one-directional because the escape hatches are themselves gated.
A second hook works on structure, not lint: a 500-line file cap, a cap on module-level functions (prefer methods on a class), a ban on untyped dict[str, object] (prefer a dataclass or TypedDict). The two size caps don't force every oversized file under the limit at once. A file already over the line at HEAD stays, but it can only shrink. A commit that grows it — more lines or more public module-level functions than at HEAD — is blocked, and newly crossing a cap is blocked outright. So structure ratchets the same way coverage does. The dict[str, object] ban is stricter still, flagging only the lines a commit adds or changes.
The durable thing isn't the rule. It's where the rule lives. A rule kept in prose — a skill or a note or a thing I have to remember — recurs, because prose is read inconsistently. The same rule as a hook does not recur. The blueprint states it plainly: durability comes from enforcement encoded in code and structure, not prose that decays. That's most of why the quality layer converges and the next one doesn't.
The class of gate I've been leaning on more lately is the structural one — what I've seen called architectural fitness functions. A regular lint hook checks a line. A fitness function checks a property of the whole graph. tach enforces the module DAG. An import-linter contract forbids a layer reaching across a boundary it shouldn't. A small chokepoint registry maps each dangerous primitive to the one module allowed to call it, so a raw call anywhere else fails the check. The reason I reach for these now is volume. Detection plus prose, "remember not to do X," loses when an agent produces more code than I can read. A structural test that makes the violation impossible doesn't depend on anyone reading anything. And it's declarative: a new rule is one line that catches the whole class — including code that doesn't exist yet — not just the instance in front of me.
The thing no gate decides
Every gate above checks the code. None of them checks whether the design is right.
ruff will tell you a function is too complex. It won't tell you the function shouldn't exist, or belongs in a different module, or that the boundary it sits behind is in the wrong place. ty catches a type error but waves through a wrong abstraction that happens to type-check. Coverage tells you the code is exercised, not that it's the right code to exercise. You can pass all four legs from Part 0 — model, harness, deterministic constraints, skills — and ship a clean, fully-typed, fully-covered implementation of the wrong architecture.
Nothing gates the architecture. The closest thing in my setup is a design companion — not a gate, since it decides nothing — that fires before any code on changes to the core surfaces: the CLI, the core models, scanners, the overlay base class, a backend protocol. It forces a pass over nine checks:
- Layout — blueprint alignment, component boundaries, dependency direction
- Contracts — FSM phase boundaries, extension-point contracts, behavior preservation
- Under change — test surface, resilience invariants, identity and key normalization
It's a forcing function for design judgment before the code-first step.
Of those nine checks, exactly one can become a real gate: dependency direction, because tach enforces a declared module DAG mechanically — a lower-level module importing a higher-level one fails the check. The other eight need a human reasoning about whether the design is right. The companion only produces the checklist; the agent works through it before code, and nothing it produces decides anything. Architectural layering is the one architectural property mechanical enough to gate. The rest isn't, and that's not a gap I'll close later. It's the nature of the problem.
Here's the part I got wrong about my own setup. I assumed I'd be the one validating the architecture — that the eight ungated checks would route to me. They don't, and the reason is mundane: the design companion fires on every change to a core surface, and signing off on each pass meant the agent interrupting me every few minutes. That doesn't scale. So I let the agent make the call by default, and it pulls me in only when it flags its own uncertainty. Which means the agent is already making most of the architectural decisions. The ungated layer didn't stay human because no gate covers it. It's sliding to the model too, only without a gate to make that safe.
Found by use, not by spec
If you can't gate whether the architecture is right, you find out the only other way. You run the thing and watch it break.
My own README is blunt about this: not a stable product, expected to break, expected to change shape, dogfooded daily on real work. Bugs surface fast because every broken edge stops a real ticket. That last clause is the mechanism. A design flaw in a system you don't use is a hypothesis. A design flaw in a system you depend on every day is a stalled ticket, and a stalled ticket is impossible to ignore. Battle-testing isn't a phase after the design. It is the design process, because it's the only signal I have that's honest about architecture.
The shape it has today wasn't specced up front. It grew through several stages into the Django project it is now.
What survived, in order. It started as a single skill called ac-multitask — one monolithic "take a ticket and run it end to end" thing. I split that into about eight lifecycle skills, one per phase (ticket intake, workspace, code, test, review, ship, debug, follow-up), and those became the t3-* skill system. Then a unified t3 CLI with a state machine pulled them together. Then it became a Django extension, with models, migrations, real persistence. Then the inversion: the whole thing became the Django project itself, with the overlays demoted to lightweight packages on top of it. Then I packaged it all as a Claude plugin.
The clearest case of use confirming a call was the dashboard. I built an HTML one — first a static generated page, then a Django-served web UI with a wall of panels — and ran it daily for about two months. The statusline was there from the start, alongside it. Living in both showed me the statusline already carried everything I actually looked at. So I tore the dashboard out and kept the statusline as the only persistent UI. No gate could have told me which one to keep. Living in both did.
Each shape change came from hitting a wall with the previous one, not from a plan that anticipated the wall. The blueprint is where the current shape is written down. But it's written down after use confirmed it, as a record of what survived, not a spec dictated before the first line.
Up a level
The work we used to call software engineering hasn't disappeared. It moved up a level. The code-production part — write it, type it, test it, keep it clean — is what the four legs and the gates now carry. What's left is the part nothing constrains: deciding whether the design is right, and finding out by living in it.
And that part is moving too. No automated check decides architectural correctness — that's reasoning, not something the code can prove. The agent already makes most of those calls, because gating each one by hand doesn't scale. So the honest worry isn't that architecture stays a human job nothing gates. It's whether the model is good enough to own the layer it's quietly taking over.
Not yet. It still makes beginner mistakes — a boundary in the wrong place, a decision heading the wrong way — the kind of red flag any experienced developer has thrown and learned to spot on sight. I catch some of them by reading the agent's reasoning as it goes. The recognition is developer experience, not cleverness and not intuition about models. The luck is only in whether I happen to be reading when a bad call goes by. So the agent has the volume of architectural decisions now, but not yet the judgment to catch its own beginner mistakes, and the instrument that catches them is still a developer's eye — still scarce, still human.
I expect the model to close that gap, probably sooner than I'd be comfortable saying. But it hasn't yet. Today it still takes experience to catch what the model gets wrong, and that experience is the one part of this I can't hand to a gate.
What's Your Reaction?
Like
0
Dislike
0
Love
0
Funny
0
Wow
0
Sad
0
Angry
0
Comments (0)