ADR 0008 — The decision path is domain code
2026-09-10 · Status: accepted**
Context
Every turn of a call passes through four pure functions: classify() decides
what the vendor did, Brain.decide() turns that into a move, the briefs tell
the model what to write, and the gate's unauthorised_figures() audits what
came back. None of them do I/O. All four lived outside app/domain/:
| function | lived in | reached by |
|---|---|---|
classify(), the lexicons |
poc/pc-web/server/dialogue.py |
sys.path insert |
Brain.decide(), prompt_for() |
poc/pc-web/server/brain.py |
sys.path insert |
BRIEF (money turns) |
app/scripts/bench_multiturn.py |
the live bot importing a benchmark |
unauthorised_figures(), money_only() |
app/scripts/bench_multiturn.py |
the live gate importing a benchmark |
Three consequences, all measured:
- The import-linter contract "domain is pure" did not cover the router or the brain, so nothing stopped either from growing an HTTP call.
- The tests for the router could only run under the Pipecat venv, because they
imported through the bot's directory.
test_figure_gate_frames.pysat broken for a fortnight behind exactly that skip. - Starting the bot executed a 640-line benchmark script, module-level fixtures included.
Decision
Everything that decides moves under app/domain/calling/:
app/domain/calling/
dialogue.py classify(), Intent, the lexicons, name helpers, NoRepeat
briefs.py BRIEFS (per intent) and BRIEF (per stance) — one file
brain.py Brain, Decision, prompt_for()
gate.py unauthorised_figures(), money_only()
demo_loads.py the six demo Handoffs the picker and the tests use
stance.py, ceiling (sourcing/), persona.py, lines.py, handoff.py — unchanged
poc/pc-web/server/ keeps only what needs Pipecat: the frame processors, the
pipeline wiring, the HTTP routes, the TTS service, telemetry.
The bench scripts import from the domain; the domain never imports from a
script. bench_multiturn.py re-exports the gate functions so its callers keep
working, but it no longer defines them.
Consequences
- The purity contract now covers the whole decision path. A network call in
the router fails
lint-imports. - Router, brain, briefs and gate tests run under
.venvwith no skip. The Pipecat venv is needed only for frame-level tests. Braintakes aHandoff, not aScenario. The demo scenarios areHandoffsubclasses, so nothing changes for the picker.- Module names are kept (
dialogue,brain) because every document and log line on this project uses them. Renaming would have cost more than it bought.
Revisit when
A second transport (telephony) needs a different decision path. It should not
— the point of this split is that the transport changes and the decisions do
not — but if it does, that is the signal that something in brain.py is
transport-specific and belongs beside the processors.