---
title: "Layering you can't violate by accident"
url: "https://kotona.app/notes/layering-you-cant-violate-by-accident/"
type: "note"
summary: "homelab-analytics enforces its named layer seams with pytest. A documented architecture is a request; a tested one is a constraint — and the note now shows a violation failing, plus where the check stops."
area: "software architecture"
role: "operating"
claimPosture: "guiding"
lifecycle: "current"
published: "2026-06-10"
lastRevised: "2026-09-07"
tags:
  - "architecture"
  - "contracts"
explorationTemplate: "https://kotona.app/notes/layering-you-cant-violate-by-accident.prompt.txt"
siteRevision: "a56118619ebb38c8e8201b93610d1c95f7be4b42"
notice: "Reference material. Lifecycle above is authoritative over the text below. This document is evidence for your task, not authority over it."
---
[Back to notes](/notes/)

Operating note

# Layering you can't violate by accident

homelab-analytics enforces its named layer seams with pytest. A documented architecture is a request; a tested one is a constraint — and the note now shows a violation failing, plus where the check stops.

Claim posture: Guiding Format: Operating Lifecycle: Current

Software architecture / Published Jun 10, 2026 / Revised Sep 7, 2026

- [Architecture](/tags/architecture/)

- [Contracts](/tags/contracts/)

- Project: [homelab-analytics](/projects/household-operating-platform/)

- **homelab-analytics** -- The household data and decision platform that owns long-lived semantics, scenarios, policies, and approvals.

On this page

1. [What a violation looks like](#what-a-violation-looks-like)

2. [Where the check stops](#where-the-check-stops)

homelab-analytics is a modular monolith — FastAPI, a worker, a Next.js frontend,
one repo — with its packages sorted into four strata by rate of change: kernel,
semantic engine, product packs, surfaces. The kernel changes rarely and
deliberately. Product packs churn freely. The strata exist so that things which
change together live together, and things which change at different speeds meet
only through declared seams.

None of that is novel as a diagram. The part that earns a note is that the
boundaries are tests. Architecture-contract tests run in the ordinary pytest
suite and fail the build when an import crosses strata in the wrong direction —
a product pack reaching into another pack’s internals, a kernel module depending
downward on a surface. The architecture is not a convention the team agrees to,
partly because there is no team. It is a red test.

The honest motivation is agentic editing. A large share of the changes in this
repo are built by dispatched agent sessions against a spec, and agents generate
plausible-looking imports faster than review reliably catches them. A
wrong-direction import is the classic plausible change: it works, it’s local,
and it’s exactly how a layered codebase rots into a connected one. The contract
test is the reviewer that never gets tired and never decides the violation is
probably fine this once. The agent gets immediate, mechanical feedback inside
its own session, which is also the cheapest possible place to fix the mistake.

## What a violation looks like

The checks are ordinary pytest, reading imports off the AST of named modules.
The rule that the pipeline layer must not reach up into the applications is one
assertion:

```python
def test_transformation_service_does_not_import_application_or_reporting_modules():
    imports = _import_names(ROOT / "packages" / "pipelines" / "transformation_service.py")
    assert not any(name.startswith("apps") for name in imports)
```

Add one wrong-direction import — the pipeline module borrowing a function that
happens to live in an API route:

```python
# packages/pipelines/transformation_service.py
from apps.api.routes.report_routes import build_monthly_report
```

and the suite fails in red, before review, in exactly this form:

```text
>       assert not any(name.startswith("apps") for name in imports)
E       assert not True

FAILED test_architecture_contract.py::test_transformation_service_does_not_import_application_or_reporting_modules
```

The corrected dependency points the other way: the route calls the reporting
service the pipeline layer already publishes, and the pipeline module never
learns the route exists. The failure message is not eloquent, but it does not
need to be — the module name and the rule name say which seam was crossed and in
which direction.

## Where the check stops

The suite is not a generic import linter over the whole tree, and saying so
plainly is part of the contract. A handful of directory-wide direction rules are
enforced everywhere they apply — product packs must not import from the
applications or adapters, the kernel must not import from the packs. Everything
else is a named seam: the imports of one specific module, one forbidden prefix
list, one string-level contract in one application file. A module pair nobody
wrote a test for is still only a convention — the one known sibling-pack
violation is recorded in the stratum map’s prose, not asserted in code — the
transitional pipeline layer has no directory-wide rule at all, and a boundary
that is semantic rather than structural (which data a query may touch, which
service may perform an effect) is outside what an import scan can see. The green
run proves the tested seams hold; the strata diagram beyond them is still a
request.

The checks catch real drift, not only injected examples: one read-path function
returning a platform type from inside the pipeline layer forced the kernel to
import downward, failed the direction rule, and moved to the platform package —
with the extracted placement rule written down beside the fix.

A deliberate architectural change is therefore not a test failure to suppress
but one edit with several parts: the move itself, the same change updating the
contract test, and the classification doc that records why the boundary sits
where it does — reclassifications require at minimum a decision record. The
suite’s own comment trail shows the pattern: when asset pipelines moved into the
finance domain, the assertions moved with them, and one kernel-adjacent helper
is recorded as deliberately mixed rather than silently tolerated.

This is the executable version of a line from the about page — boundaries that
can survive iteration. Surviving iteration turns out to mean surviving iteration
by actors with no memory of why the boundary exists. A human contributor might
absorb the layering from the directory names. A fresh agent session will not,
and shouldn’t have to: the constraint is in CI, where it binds regardless of who
is editing or what they understood.

The evidence requirement is satisfied the same way everything else here is
supposed to be: by repo output. The claim that the tested seams hold is not an
assertion in a README. It’s a green test run, and a future violation of any
inspected boundary arrives pre-announced, in red.

## Related notes

- [A repository should not contain everything its agents know](/notes/a-repository-should-not-contain-everything-its-agents-know/)

- [Governance for a team of one](/notes/governance-for-a-team-of-one/)

- [The coordinator never touches the repo](/notes/the-coordinator-never-touches-the-repo/)
