DECISIONS · v0.3.8.67
ADR-007 Module boundary
Source: docs/adr/ADR-007-module-boundary.md — versioned with the code.
Status: Accepted — phases 0 through 5 shipped against it (v3.8.3 … v3.8.16)
Supersedes: nothing. Related: ADR-001 (runtime composition), ADR-006 (agent harness)
Record of execution: docs/archive/v3/REFACTOR-PLAN.md
Context
Anthill.Core was 34,247 lines across 35 top-level namespaces. It held the scheduler and the
Proxmox client, the mission lifecycle and the Ollama wire format, pheromone memory and a DuckDuckGo
HTML scraper. Nothing was wrong with any one of those; what was wrong was that they were the same
assembly, so every claim about what the colony required in order to run was unfalsifiable.
Two claims in particular were made repeatedly and were false. "The colony runs without an AI
provider" — it could not compile without one: ModelRouter named OllamaClient,
OpenAiCompatibleClient and AnthropicClient in two switch statements. "The homelab is optional" —
it was 6,549 lines of the core, and the core's own tests loaded it.
An assembly boundary is the only version of that claim a compiler can check.
Decision
Four projects, and the arrows point one way.
Anthill.SDK contracts and pure helpers. No I/O, no implementations, no dependency
beyond the BCL and Logging.Abstractions. Referenced by everything.
Anthill.Core scheduling, memory, coordination: Queen, Objective→Mission→Task→Action,
the task queue, the event bus, worker management, pheromones, and the
tool REGISTRY, AUTHORIZATION and INVENTORY. References the SDK only.
Anthill.Modules.* capability: reasoning providers, the homelab, the tools that act on the
machine. Reference the SDK. NEVER referenced by Core.
Anthill.Api the composition root. References Core, the SDK and every module, and is
the only place in the process that names a module type.
The rule, stated once. If Core needs behaviour a module provides, Core declares an interface in the SDK and a module implements it. When it is unclear which side something belongs on, it is a module: Core earns code by being required for scheduling, memory or coordination.
The distinction that did the most work is between capability and coordination, and it is sharper
than it sounds. The tool layer is the case that proves it: ShellCommandTool is capability and left
the core; ToolRegistry, ToolAuthorization and ToolInventory are coordination and stayed, even
though all four were in the same file. Deciding WHICH tool runs and whether the caller may run it is
scheduling. Running it is not.
Consequences
The boundary is enforced by the compiler's own metadata, not by review. ModuleBoundaryTests
(v3.8.8) reads assembly references and fails if Core names a module, if a module names anything of
ours but the SDK, or if the SDK acquires a database driver or an HTTP stack. A project reference
that is present but unused still fails — it is the reference that permits the coupling.
Every phase before that test verified the boundary by hand with a grep, and every one of those greps would have passed right up until the moment someone added a using statement.
The SDK's dependency list is the colony's dependency list. Everything references the SDK, so
anything it takes on is inherited by every module. This has a real cost and the cost is correct: in
v3.8.16 ToolFailure.Classify could not name HttpRequestException, because doing so would emit a
System.Net.Http reference. It matches by type name instead. The alternative was to relax the guard
for a carve-out it cannot express.
Modules cannot schedule. IModuleContext exposes the event bus, two narrow views of the memory
(IPheromoneMemory, IEventLog), scoped configuration, a logger factory, and typed registration for
reasoning providers, capability probes and tools. There is no Queen, no TaskScheduler, no
SqliteMemory. Colony intelligence emerges from scheduling in the core; a module that could schedule
would be a second, competing source of it.
Registration is typed rather than a generic RegisterService<T>, deliberately. A generic surface is
a service locator: it makes what a module can contribute unbounded and unreadable, and forces the
core to search by type at the point of use.
A capability's absence must be a typed refusal, never a throw. Composing without the reasoning
module means model calls return UnavailableProvider's refusal and missions still plan and dispatch.
Composing without the tools module means six tools return "not registered" — a ValidationFailure —
and missions still run. This is what makes "the colony runs without X" testable rather than
aspirational.
The composition root grows. ApiHost.cs went from 3,227 lines to 3,283 across the refactor while
the core shrank by 8,980. That is the expected shape and not a defect: every module extracted has to
be wired somewhere, and one file that names every module is the boundary working. Splitting it is
phase 6.
The cost that is paid on every extraction. A module cannot be handed a core type, so anything it
needs becomes an SDK contract: IReasoningProviderFactory, IFieldCipher, IToolRuntimeOptions,
IWorkspacePathGuard, IToolDefinitionPolicy. Each is a small amount of indirection bought with a
guarantee. The discipline that keeps it from becoming ceremony is that no contract is written until a
module actually needs it — phase 3 proposed seven store interfaces and shipped the two that had a
consumer.
What was measured, and what it cost to assume
Recorded at length in docs/archive/v3/REFACTOR-PLAN.md §6. In short: every phase that came in smaller than
feared did so because the coupling was counted rather than inferred from names — twenty
Anthill.Core.Common imports in the homelab were two helpers; 151 ToolResult references were 13
edits; ToolDefinition's "entanglement with ToolAuthorization and ToolInventory" was three lines.
Every phase that surprised us did so because something was assumed.
The rule that came out of it: a file is only as movable as its most qualified reference, and checking
using statements is not a purity check.