Blessed Violators
Core modules follow a strict import policy: they must not import from services/ or plugins/. This keeps the core layer decoupled from concrete implementations, so it remains a pure wireframe.
There are exactly 6 exceptions to this rule. Each exists for a documented, deliberate reason.
The 6 Blessed Violators
| # | Core file | Imports from | Why |
|---|---|---|---|
| 1 | core/config/config.gleam | All service/plugin cfg.gleam modules | Config loader must delegate TOML parsing to every submodule's config. There is no way around importing each cfg module because each one defines its own TOML keys and defaults. |
| 2 | core/loop/runner.gleam | services/api/, services/context/, services/storage/, services/tokens/, plugins/hooks/ | Composition root. Wires concrete behaviour records and dependencies into a LoopContext, then calls the pure loop() function in orchestrator.gleam. The pure loop stays clean -- all wiring lives here. |
| 3 | core/registry/builder.gleam | All tool plugins, services/guardrails/, services/logger/, services/storage/ | Tool registry builder. Imports every tool plugin module and wires them with their behaviour dependencies. The companion module registry.gleam is pure dispatch with zero external imports. |
| 4 | core/session/handler.gleam | plugins/hooks/reflection/ | Composition/adapter. Wires session state, the loop runner, the registry builder, and the reflection hook together. Coordinates the full lifecycle of a user message from arrival through loop execution to reflection spawn. |
| 5 | core/session/session.gleam | services/storage/session_db | Session state machine. Persists session data to SQLite directly. This is a thin state wrapper that needs database access for load/create operations. |
| 6 | core/tool/executor.gleam | services/guardrails/approval_cache, services/logger/ | Tool execution. Needs the approval cache for persistent-deny logic (cache_approval_pd, consume_approval_pd) and the global logger for tool-level diagnostic output. Both are cross-cutting concerns. |
Cross-Cutting Exception: The Logger
services/logger/logger is a registered Erlang process (agent_logger), started first in the boot sequence and accessible globally. Rather than threading a Logger behaviour record through every single function signature (which would create noise without benefit), modules that need logging import services/logger/logger directly.
This is why core/tool/executor.gleam imports it -- every tool execution path needs diagnostic output, and injecting a logger parameter into deeply nested tool execution code would be pure ceremony.
The Split: Pure vs. Wired Modules
The blessed violators follow a deliberate pattern: every impure module is paired with a pure counterpart that never touches services or plugins.
| Pure module | Wired counterpart | Role |
|---|---|---|
core/loop/orchestrator.gleam | core/loop/runner.gleam | Orchestrator runs the pure conversation loop. Runner is the composition root that constructs the LoopContext and passes it in. |
core/registry/registry.gleam | core/registry/builder.gleam | Registry does pure tool dispatch/lookup. Builder imports every tool plugin and assembles the registry. |
core/session/session.gleam | core/session/handler.gleam | Session is a pure state transformer (in-memory record). Handler wires it into the full message processing pipeline. |
This split means that core logic (the orchestrator, the registry, the session state machine) can be reasoned about, tested, and evolved without knowing anything about HTTP clients, SQLite, or Telegram. The wiring modules are thin -- they exist only to connect the pure core to the concrete world.