The layer map — package layers as an executable guard
The package layers are pinned by a test, not by prose: internal/layerguard walks every non-test import of ./pkg/... and ./internal/... — in five build contexts, one hop deep, test files aside, the bounds the next section states — and refuses the dependencies the map forbids. The rules codify what the tree already does, so the guard bites on the FIRST new violation — the cheapest moment to fix one.
| the layer | must not import |
|---|---|
pkg/dsl/ir (leaf) | pkg/runtime, pkg/server, pkg/dispatcher, pkg/cli, pkg/backend, pkg/forge, pkg/runner |
pkg/dsl (compiles alone) | pkg/runtime, pkg/server, pkg/dispatcher, pkg/cli, pkg/backend |
any pkg/* | pkg/server (the composition root is reached through cmd/ and the seams) |
pkg/runtime | pkg/server, pkg/dispatcher, pkg/cli |
pkg/backend/* | pkg/server, pkg/dispatcher, pkg/cli |
pkg/dispatcher | pkg/server, pkg/cli |
internal/* | pkg/server |
pkg/store (leaf) | pkg/server, pkg/runtime, pkg/dispatcher, pkg/backend, pkg/cli |
A rule forbids crossing into a family: a package of a banned family importing its own family is cohesion, not a layer crossing, so pkg/server reading pkg/server/projects is not a violation of the third rule.
What the guard reads, and what it does not
- Every non-test file, over five listing contexts. One
go listanswers for one build context, so a non-test file behind a GOOS suffix or a build tag can cross a layer where nobody looks: apkg/store/lock_windows.goimportingpkg/cliis green under linux and red underGOOS=windows, same tree, same test binary. The guard unions five contexts — the host,GOOS=windows,GOOS=darwin,GOOS=freebsd(the three files that read!linux && !darwin && !windows), and then the build tags the files those four left uncompiled still ask for, ANYWHERE in the module, read off the listing instead of listed in the test:desktop,dev,goldens_record,netbsdandopenbsdon 2026-10-30, wherepkg/botreplay's three edges andcmd/iterion-desktop's three live. That list is not the promise: a file no context compiles is refused byTestEveryNonTestFileIsSeenByAListingContext, andtestdata/crossbuildreplays both attacks on a module of its own. - Which build compiles a file is the go tool's answer; whether ANY build does is the constraint's. The walk hands out every non-test
.gofile under the two trees — 1732 on 2026-10-30, it skips nothing — and the listing the guard already reads sorts them, becausego listpublishes both the files a context compiled (GoFiles,CgoFiles) and the files it named and left out (IgnoredGoFiles): 1714 compiled, 18 the go tool names in no listing of any context (thetestdata/fixtures below, a file called_x.goor.x.go, a directory called_xor.x— go/build drops such a name before it reads a constraint, and a pattern names no package in such a directory), 0 held out by the one name no listing may carry, 0 left over. That name isignore: the toolchain's own generators carry it, sogo list -tags ignoreexits 1 on GOROOT's packages over this module (TestTheReservedIgnoreTagCannotBePassedToTheListing) and every test of the guard would fatal over a treego build ./...compiles clean. A file it keeps out earns that exemption only where NO build of the toolchain compiles it: the constraintgo/buildhonours, withignorepinned false, satisfied by no assignment of the names it has left. So//go:build ignoreandignore && linuxare exempt,ignore || mytagis not —go build -tags mytagcompiles it — and neither isignore || (windows && mytag), whichGOOS=windows go build -tags mytagships. A listing context is a POINT, four platforms carrying no tag and one tag set carrying no platform, so "no context of mine matches it" was the reading that went silent on that last crossing.TestTheGuardAndTheGoToolReadTheSameFileswrites a module per shape and compares the guard togo list -deps: if the crossing ships, the guard names it; if no build holds it, the guard is silent. - Out of the listing is not out of the build. A
_x,.xortestdataDIRECTORY holds an importable package: a pattern names it nowhere, andgo build ./...ships it the moment something imports it, rules or no rules.TestEveryImportOfTheJudgedTreesIsAJudgedPackagerefuses that edge — an import underpkg/orinternal/the listing does not hold is a package whose own imports no rule reads, and the remedy is to rename the directory or to list it. The importer can be anywhere, which is why the listing covers the whole module (./..., 230 packages, the module's own root package included) and not just the two judged trees:cmd/is the composition root, no pattern ofpkg/orinternal/names it, andcmd/ximportingpkg/store/_oldis the same defect one tree further out. So can the file that ASKS for a build tag: the demand above is read off that same listing, and read off the two judged trees alone it never hearddesktop, which left three edges of the composition root in no listing at all. A dropped FILE name is reached by nothing, since no import names a file, andTestNoGuardIsReadAroundByTheBuildholds every Go file of the repository to a package of the module, so a stray one is refused there.testdata/neverbuiltis the module that says all of it, underTestTheGoToolSortsTheShapesTheGuardOwesNoContextFor. - Direct imports, one hop. A rule hit is one import edge; a banned family reached THROUGH another package is not one.
pkg/dsl/iris a leaf in that sense only: its direct imports name nopkg/forgeorpkg/dispatcherpackage. Whatgo list -deps ./pkg/dsl/iradds on top of the one hop ispkg/backend/tool/privacy/detector: one package of those same banned families, riding an acceptedpkg/backendleaf. Until iterion#2857 the hop also heldpkg/dispatcher/native/boardops— seven capability constants — and the closure three packages more (pkg/dispatcher/native,pkg/dispatcher/trackerandpkg/forge); the vocabulary moved topkg/boardcaps, a leaf outside every banned family, which the implementation re-exports. Neither half is prose —TestTheLeafsOneHopReadingIsWhatThePageSaysreads this sentence off this page and faces both halves with the tree, and the IR's own closure witness (TestTheLeafClosureLinksNoDispatcherForgeMailerOrClawRuntime) refuses the dispatcher, the forge, the mailer and the claw runtime coming back through ANY hop. Read "must not import" above as the one hop the guard checks. - Test files are out of scope, by decision —
go listruns without-test, so a coupling that lives only in a_test.gofile is none of the guard's business:pkg/backend/model's test variant importspkg/dispatcher/nativeandboardops, and a green guard says nothing about it.
The verdict, and what it cost to get one
The first reading of this page claimed "verified 2026-10-23 over 903 internal import pairs: zero violations". It was not a verdict: the guard compared go list's full import path (github.com/SocialGouv/iterion/pkg/dsl/ir) to the bare prefixes above (pkg/dsl/ir), so no rule ever bound a package, nothing was checked, and the test passed over a tree that held twelve violations (#2829). The module prefix is now trimmed at one seam, parseListing, so no full path reaches a comparison, and six tests keep the guard honest about itself: every clause of every rule must bind a package of the tree — the banned side too, where seven of the first rule's eight clauses live — every accepted exception must still be a pair some rule names, every non-test file must be compiled by some listing context or sorted out of it by the go tool, every import under the two trees must name a package the listing holds, the bounds this page states must be the bounds the guard claims, and the number of times the rules fire is pinned, so a rule born mute over pairs already accepted is an edit a reviewer sees.
Faced on 2026-10-29 at e59110ed5, over the 213 packages and 974 module-internal import edges the five contexts named there: 12 violating pairs, all twelve accepted with their reason in allowExceptions, and 19 rule hits over them; 10 pairs and 15 hits on 2026-10-30, iterion#2857 having lifted the two ir-leaf violations that were weight rather than vocabulary. The same ten, read off a listing of the whole module: 230 packages, 1064 module-internal edges, 1061 of them landing under pkg/ or internal/ and every one naming a package the listing holds. Those counts are each reading's provenance, not a standing claim — they move with the tree, and the numbers that have to stay true are the pinned expectedAllowCount and expectedRuleHitCount, which the guard itself checks. The ten are three families, not ten decisions:
pkg/dsl/ir→ fivepkg/backend/*packages: the IR validates a.botagainst what the chosen backend can do, and reads the capability vocabulary where it is defined rather than keeping a second copy. All five are leaves (1 to 3 module packages each). The two that were not —automemoryandboardops, 10 and 43 module packages — were resolved by iterion#2857 with no second list: the IR reads the registry's auto-memory cell itself (the one cellautomemory.SupportsBackendreads, pinned byTestBackendCapabilityConsumersConform), and the board capability names moved topkg/boardcaps, whichboardopsre-exports.pkg/cli→pkg/server,pkg/server/projects:iterion studioanditerion dispatchcompose an in-process server, andpkg/cliis imported bycmd/iterionandcmd/iterion-desktoponly.pkg/backend/tool,pkg/backend/delegate→pkg/dispatcher/native{,/boardops}: the claw board tools and the MCP board bridge run every operation through the oneboardopsentry the stdio and HTTP transports use.
The ratchet
The guard's exception list is pinned by its count (expectedAllowCount in the test): a violation that review accepts goes in with its reason, and both the entry and the count are edits of the test file — visible in review, never silent rot. It can only shrink: an entry whose pair no rule names on today's tree is refused, so a resolved violation cannot keep buying silence.
The count of rule hits is pinned beside it (expectedRuleHitCount), because an accepted pair silences EVERY rule on that pair: a rule added later whose only target is a pair already accepted would land green and mute, with nothing in this file to edit and nothing in review to read. Its arrival moves that number instead.
The written invariants the map encodes
The rules are the existing doctrine made checkable, not a new policy: AGENTS.md names pkg/errtrack THE tracker and pkg/log THE logger (name-based rules, enforced by review and the boundary guards), the seams in philosophy.md fix the import directions above (the DSL compiles alone; the IR is a leaf the backends read; the store carries no executor; the server is composed in cmd/). A rule that becomes false is a decision: it is edited here, in review, with its reason — the same ratchet as the test's exception count.
