Chapter 3 — The Lay of the Layering
# Chapter 3 — The Lay of the Layering
Every mature codebase has architecture rules. Most keep them in a diagram,
and the diagram rots the first time someone merges against it. Relurpify
keeps its rules in programs that run in CI and fail loudly — and in this
chapter you learn to read them, break one on purpose, and feel how fast
the law answers. By the end, make lint-arch stops being a ritual and
becomes what it is: the fastest code review you will ever get.
This is also the chapter where the book’s own code earns the right to exist. Last chapter ended with a gate telling us where our package belongs. Now you get the whole law, and the vocabulary it protects — including the type that threads through every graph the framework will ever run.
# Where you are
You have the fake model in cognitionzoo/critic/ (test-only, gate-clean)
and a green baseline. This chapter adds one more test file to the critic
package — an envelope walkthrough — and then deliberately breaks the law
twice, in a scratch clone, to watch the gates catch it. Nothing in this
chapter touches the network, the model, or your working tree.
# The shape: a domain DAG, not a diagram
Relurpify is a modular monolith: one Go module, a handful of local
binaries, no broker, nothing listening on a socket. The interesting
structure is inside the module, and it is expressed as a directed
acyclic graph of domains — top-level directories that may import the
domains they point at and no others. The normative table lives in the
repository’s own handoff docs and is encoded twice: in
tooling/arch/domaindag.go, and asserted by a layout test. The repo’s
rule when doc and encoder disagree: the encoder wins, and both are fixed
in the same commit.
The part of the DAG you will live in, simplified:
app ──▶ named/euclo, cognitionzoo ──▶ execution ──▶ context, capability,
governance, model …
cognitionzoo ──▶ execution, context, capability, governance, model, …
execution ──▶ context, capability, governance, model, …
context ──▶ capability, governance, model, …
governance ──▶ model, …
model ──▶ (leaf vocabulary; platform is its implementation home)Arrows point in the depends-on direction. Your critic package sits in
cognitionzoo, which may import execution, context, governance,
model — and is imported by app and named/euclo, never the reverse.
Two structural rules give the DAG its shape, and both have names you will
use:
- Vocabulary-with-owner. Every shared type lives in exactly one domain.
The language-model port is owned by
model; backends live inplatform/llm. Cross-domain re-exports via type aliases are forbidden — this is one of the reasons the charter’s “no aliases” ban is not a style quirk. - Consumer-defined ports. When a domain needs behavior from a domain
above it, the consumer declares a narrow interface in its own
portspackage; the upper domain implements it; the composition root injects the concrete. You saw the shape in Chapter 2:model.EventSinkis what it looks like when the leaf defines the narrow thing it is willing to know about.
# The gates: three programs and a fence
make lint-arch runs in about three seconds, and everything it prints is
one of five programs. From the Chapter 1 transcript, annotated:
| Program | What it reads | What it enforces |
|---|---|---|
envcheck |
AST of every production Go file | Only userconfig reads process environment variables |
shimcheck |
AST, string literals, identifiers | The charter’s forbidden vocabulary and the telltale type-converter signature |
symcheck |
AST | Symbols the project removed stay removed |
archcheck |
go list import graph |
forbidden, converter, sqlite, glob, stub, cycle, bucket, consumer checks |
domaincheck |
import graph, domain table | direction, governance-orch, context-ports |
(Makefile detail: lint-arch also runs lint-class-normalization and
lint-no-permissive-hitl before the AST gates — class strings must
normalize at every boundary, and no permissive HITL default may creep in.)
Three properties of this machinery matter more than any single check:
The gates read production code. The shared walker (tooling/arch/ gatescan) takes an IncludeTests flag whose comment states the policy:
“Gates that must police production code only leave it false.” All three
AST gates leave it false. _test.go files are not exemptions, not holes —
they are a different kind of material, and the gates know it. This is why
your test-only critic package scanned clean while 1,087 production files
were checked around it.
The consumer gate is the anti-dead-code gate. Every non-main,
production package must have at least one importer — production or test.
A package with zero importers of any kind is dead code, and the gate’s
comment says so in exactly those words. Test-only packages are skipped
(consumed by their own tests); main packages are skipped (they are
endpoints, not libraries); documented domain anchors are exempt by name;
and everything else goes through an allowlist whose entries are
load-bearing — the gate’s own tests fail if an exemption stops being
needed. You met this gate personally in Chapter 2.
The gates check the gates. make grep-architecture-gates runs the
grep-layer fences (what a syntax tree cannot express — a forbidden string
assembled by concatenation), and make check-gates-honest verifies that
the fences themselves still bite: that the env fence would still catch an
os.Getenv, that every AST gate exists and is wired into lint-arch. A
weakened gate is treated as a broken gate.
# The charter’s four bans
The repository’s AGENTS.md states the whole policy in four lines:
no shims
no compat
no aliases
no stubsshimcheck enforces the vocabulary: the words shim, compatibility,
backward compatibility outright, plus compat as a whole word — the
short spelling is legitimate inside names the project actually ships
(api_compat, openai_compatible), so the gate matches word boundaries:
“compat layer” is a violation, api_compat is a capability ID. And the
gate reads string literals — a comment saying “compatibility shim”
passes, a constant holding the string "compatibility alias…" does not.
You will prove both halves yourself in the lab.
Why ban them at all? Because each of the four is a half-finished migration
left in the tree: callers keep calling the old name, the new surface rots,
and the documentation drifts. The charter’s answer is that removals are
clean breaks. When the critic paradigm ships, there will be no
criticCompat wrapper around reflection and no “temporary” alias —
there will be one paradigm, and its name will be the only way to say it.
# The envelope: the type that threads everything
One type flows through every graph, node, and branch in the framework:
contextdata.Envelope (context/contextdata/). Its package doc states
the three-tier model the whole runtime is built on:
- Streamed Context (read-only) — assembled by the compiler from knowledge chunks, summaries, and checkpoint state; represented by references, never copies.
- Working Memory (mutable) — per-turn, per-task in-memory state; the only tier graph nodes may write.
- Retrieval State (controlled read) — results from retrieval operations, stored as references in the envelope.
The invariants are stated just as flatly: streamed context is read-only
outside the compiler; working memory is the only mutable tier for graph
nodes; checkpoints may be requested by nodes but are owned by the
compiler. Your Chapter 1 turn was a sequence of nodes mutating one
envelope under those rules — react_act writing observations,
euclo.checkpoint requesting a checkpoint — and every later chapter’s
graphs will live inside the same contract.
Three API facts are enough to work with an envelope:
-
Everything you write is classified twice. A working-memory value carries a memory class (
MemoryClassEphemeral,MemoryClassSession,MemoryClassTask) and an origin (OriginUser,OriginTool,OriginLLM). Provenance is not metadata you add later; it is part of the write.env.SetWorkingValueWithOrigin("critic.draft", "first draft", contextdata.MemoryClassTask, contextdata.OriginLLM) -
Typed reads are generic and honest.
contextdata.GetTyped[string]returns the value and anok— no assertions, no surprises. -
Branches clone, then merge deltas. A parallel branch works on
contextdata.CloneEnvelope(env); when branches converge, the parent applies each branch’s delta —ComputeBranchDeltacomputes what the branch added, modified, and deleted;ApplyBranchMergesinstalls it. The parent never saw the branch’s writes before the merge, and the merge, not the branch, is where conflicts become visible.
The other half of the flow is execution.Result
(execution/agent_executor.go) — what a node returns:
type Result struct {
Success bool
Data ResultPayload
Error string
Metadata map[string]any
NodeID string
}ResultPayload is a small interface with two shipped shapes —
ToolResultPayload (keyed fields) and PromptResultPayload (text plus
fields). Chapter 1’s result panel — node=euclo.done | status=ok — was
this type rendered.
# The envelope tour, runnable
The chapter overlay adds one test to the critic package that walks an envelope through exactly this lifecycle — write with class and origin, typed read, clone a branch, verify the parent is untouched, compute the delta, merge, verify the merged value:
env.SetWorkingValueWithOrigin("critic.draft", "first draft",
contextdata.MemoryClassTask, contextdata.OriginLLM)
branch := contextdata.CloneEnvelope(env)
branch.SetWorkingValueWithOrigin("critic.verdict", "issues found",
contextdata.MemoryClassTask, contextdata.OriginLLM)
// The parent is untouched by the branch's write.
if _, ok := contextdata.GetTyped[string](env, "critic.verdict"); ok {
t.Fatal("parent envelope saw a branch-local key before merge")
}
delta := contextdata.ComputeBranchDelta(env, branch)
stats, err := env.ApplyBranchMerges([]contextdata.BranchMergeUnit{
{Index: 0, ID: "critique", Delta: delta, Env: branch},
})(The full test is envelope_tour_test.go in the chapter overlay; it
asserts the delta carries exactly the one added key and the merge reports
exactly one unit and one key written.)
./verify.sh build ch03== tier 1 (build): ch03 ==
ok codeburg.org/lexbit/relurpify/cognitionzoo/critic 0.057s
[PASS] build: ch03 compiles clean and its tests passTen tests green now — the nine from Chapter 2 plus TestEnvelopeTour —
and the clone was built, vetted, and thrown away by the verifier without
your tree noticing.
# Hands-on: break the law, watch it answer
The lab plants two violations in a scratch clone, captures the failures,
and reverts. The full transcripts live in
labs/transcripts/ch03-gate-failures.md; here is each beat with what it
teaches.
Beat 1 — the same sin in a test file: silence. Plant
os.Getenv("CRITIC_FALLBACK") in the critic package’s test file and run
the gate:
[PASS] envcheck: no env access outside userconfig (1087 files scanned)Nothing. The gates do not read test files — by policy, not oversight. If you want your sin caught, ship it in production code.
Beat 2 — the same sin in production code: exact coordinates. The
identical os.Getenv, planted in a production file, plus its "os"
import:
GOPROXY=off GOSUMDB=off go run ./tooling/arch/cmd/envcheck
[FAIL] env: cognitionzoo/react/react_think_node.go:38 reads the process environment via os.Getenv
[FAIL] envcheck: 1 env access violation(s) outside userconfig
exit status 1
make: *** [Makefile:123: envcheck] Error 1File, line, mechanism. And make stops here: a red gate short-circuits
the run, so the first failure you see is the first one in line.
Beat 3 — the vocabulary ban, where it actually bites. A comment saying “compatibility shim” passes shimcheck; a string literal does not:
const criticLabAliasNote = "compatibility alias for the old flag name" [FAIL] shim: cognitionzoo/react/react_think_node.go:35 carries forbidden language "compatibility" in a string literal
[FAIL] shimcheck: 1 forbidden-language literal(s)The gate reads what ships, not what you say about what ships. That is the difference between a linter and a gate.
Beat 4 — revert, and the law is restored in one command:
git checkout cognitionzoo/react/react_think_node.go
make lint-arch[PASS] All architecture gates passed
[PASS] All domain checks passedEvery violation in this lab existed only in the scratch clone; your tree
never saw them. That is the dev loop you will run from now on — build,
vet, test, lint-arch — and the reason violations here are cheap: the
gates answer in seconds, with coordinates.
# Gotchas
- A green
envcheckdoes not mean your test code may read the environment. The gates police production code; the policy (“no env access outside userconfig”) is not suspended for tests, and AGENTS.md states it without a test-file asterisk. The gates are a floor, not the spec. make lint-archfails fast. When it stops at envcheck, later gates have not run. Fix in order, or run the gate you care about directly (go run ./tooling/arch/cmd/shimcheck) — every gate is amainintooling/arch/cmd/.- Gate failures name coordinates. Read the
[FAIL]lines before opening any file; the gate already knows where the sin is. - The consumer gate counts importers at
go listtime. If you add a production file to the critic package before Chapter 6 wires its first importer, the gate will (correctly) call it dead code. The chapter sequencing in this book exists so you never fight that gate. testdatadirectories are invisible to the toolchain. That is why the labs overlay lives underlabs/testdata/. It also means: never put real, needed Go packages inside a directory namedtestdata.
# Summary
- The architecture is a DAG of domains enforced by programs: import
direction (
domaincheck), dead code (consumer), forbidden vocabulary (shimcheck), the env boundary (envcheck), removed symbols (symcheck), plus cycle, bucket, and port checks — all insidemake lint-arch, all in about three seconds. - The charter bans shims, compat layers, aliases, and stubs — enforced on string literals and type-converter signatures, not comments. The gates police production code; test files are a different material with its own rules.
contextdata.Envelopeis the type that threads every run: three tiers (streamed context read-only, working memory mutable, retrieval by reference), every write carrying a memory class and an origin, branches cloning and merging deltas — andexecution.Resultcarrying each node’s verdict.- You broke the law twice and it answered with file, line, and mechanism, both times, in seconds — then reverted cleanly. That reflex — read the gate, fix the cause, never add an exemption — is Chapter 11’s curriculum, met early.
# Check yourself
- Name the two structural rules that give the domain DAG its shape, and
say which one decides where the
model.EventSinkinterface lives. - Why does an
os.Getenvin a_test.gofile passenvcheck, and does that make it acceptable? - Your critic package gains a production file in Chapter 5 with no
importer yet. What does
make lint-archprint, which check produced it, and what is the gate’s stated definition of such a package? - In the envelope tour, what does the parent see before
ApplyBranchMerges, and what exactly doesComputeBranchDeltareturn? - shimcheck passed a comment containing “compatibility shim” but failed a string constant. What does that tell you about what the gate reads — and about where the charter’s bans actually bite?
Answers. 1. Vocabulary-with-owner (each shared type lives in exactly
one domain) and consumer-defined ports (the consumer declares the narrow
interface it needs); the ports rule — EventSink is declared in the leaf
model domain so the model never imports telemetry. 2. The gates walk
production code only (IncludeTests: false is the stated policy); it is
detected-exempt, not acceptable — AGENTS.md’s env rule has no test-file
asterisk, and the gates are a floor. 3. consumer: … has no importers
from archcheck’s consumer check; the gate’s own comment defines it: zero
importers of any kind is dead code. 4. Nothing — the branch’s writes are
invisible until the merge; the delta is the added/modified/deleted key
sets (here exactly critic.verdict added). 5. It reads string literals
and identifiers (what ships), not comments (what you claim); the bans
bite on shipped vocabulary, which is why “temporary” aliases in constants
are exactly what the charter is for.
# Try it yourself
- Probe the skip list. In the scratch clone, plant the Beat 2 sin
inside a directory named
testdataand runenvcheckdirectly. Read the gatescan skip rules intooling/arch/gatescan/gatescan.goand reconcile what you observe withDefaultSkipDirs. Then revert. - Name the vocabulary. Find three legitimate uses of
compatin the shipped tree (grep -rn "compat" --include="*.yaml" relurpify_cfg/is a start) and explain, in shimcheck’s terms, why each survives the whole-word boundary rule. You are learning to read the law’s exceptions as load-bearing, not grudging.
# Where next
You can now read the tree as law and the envelope as contract. Time to
build on both. Chapter 4 opens execution/agentgraph for real: you will
implement the three-method Node interface by hand, build a graph with
NewGraph and AddEdge, seal it, run it over stringkit driven by your
fake model — and read your first preflight report. The critic pipeline
stops being a plan and starts being a graph.
The lab lives at devdocs/agent-construction-relurpify/labs/ —
verify.sh gates ch03 re-runs everything this chapter proved, and
transcripts/ch03-gate-failures.md holds the violation arc in full.