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.py sat 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 .venv with no skip. The Pipecat venv is needed only for frame-level tests.
  • Brain takes a Handoff, not a Scenario. The demo scenarios are Handoff subclasses, 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.