Architecture review — ClientsFlow Pipeline

Deepening opportunities · app/ · 2026-06-21

module seam leakage deep module

Two god-files carry the system: flows.py (4388 lines, every business flow) and dash.py (4293 lines — a server shell wrapped around a ~2000-line embedded JS board). The friction is not "too big" — it's shallow seams: interfaces as wide as their implementations, pure helpers tested while their callers leak, and a board contract that lives only in a browser.

Strong in-process Candidate A

Project the board card behind one board_view seam

app/dash.py:1025–1076 (api_board) · app/dash.py:2274–4293 (embedded JS) · app/flows.py (deal dict)

Before — implicit projection, leaky both sides

flowchart TB
  DD["deal dict
50+ internal fields"] AB["api_board
inline {…} literal"] JS["embedded JS board
2000 lines"] DD -. "raw .get() ×50" .-> AB AB -. "renames + 3 date
field fallbacks" .-> JS JS -. "callAt / sales_call_date
/ next_followup_date" .-> JS classDef leak fill:#fff,stroke:#dc2626,stroke-width:2px,color:#7f1d1d; classDef plain fill:#f8fafc,stroke:#94a3b8,color:#334155; class DD,JS plain class AB leak

The deal→card shape exists only as an inline dict literal; the JS re-derives "next action date" from three field names. Contract is unwritten — only a browser proves it.

After — one deep projection, one contract

deal dict
deep module
board_view(deal) → BoardCard
owns renames · the one next-action-date rule · stage→column · last-msg shape
api_board
JS board
unit test

three consumers · one interface

Problem

The deal→card projection is an inline literal in api_board; the JS re-derives the next-action date from three field names — the contract is testable only in a browser.

Solution

Move the projection into a board_view(deal) → BoardCard module that owns the renames, the single date rule, and the stage→column map; api_board, the JS, and tests all read that one shape.

Wins

  • locality: card bugs concentrate in one module
  • leverage: one interface, three consumers
  • interface shrinks; api_board absorbs nothing new
  • kills the 3-name "next action date" fallback
  • the JS↔server contract becomes a named shape
  • most board logic leaves the browser-only test path
Relates to ADR-0001. Does not contradict it — the JS still gets browser-tested; this only lifts the projection out into a unit-testable interface, shrinking what the Playwright harness must cover.
Strong in-process Candidate C

Split inbound routing into a pure decision core + thin I/O shell

app/flows.py:1003–1508 (handle_missive_incoming) · tests/test_inbox_routing_c.py

Before — 505 lines, decision welded to I/O

noise filter DMARC · cal · auto-reply
AI classify _c("ai") I/O
CRM fuzzy match _c("notion") I/O
mint / match / route deal ADR-0004
write activity-log row _c("notion") I/O
return — ~16 dict shapes

One path needs 4+ mocks orchestrated together; the routing rules (incl. ADR-0004's "a Client never becomes a new lead") are only fixture-tested.

After — decision pure, I/O at the edges

shell fetch context · write rows
deep module · pure
route_inbound(msg, ctx) → Decision
noise · classify · match · which row, which deal — no I/O. Returns one typed Decision.
unit test — no mocks

Problem

The branching that decides a Lead's fate is interleaved with Notion/AI I/O across 505 lines and ~16 return shapes — untestable without orchestrating four mocks, so the real rules stay fixture-only.

Solution

Take an already-fetched ctx and return a Decision (which row to write, which deal to mint/match) from a pure core; the shell does the fetch and the writes.

Wins

  • locality: every routing branch in one pure interface
  • the interface is the test surface — pass ctx, assert Decision
  • ADR-0004 rules become unit tests, not fixtures
  • leverage: the shell shrinks to fetch → decide → write
  • the existing _is_* helpers fold into the core
Synergy with ADR-0004 / ADR-0003. The "carry CRM stage; a Client never mints a new lead" rule (I1/I2) becomes assertable at one seam — the deepening makes the ADR's invariant cheaper to keep, not contested.
Worth exploring ports & adapters Candidate D

Give the hot clients a second adapter, the way the deal store already has one

app/flows.py:_c factory · app/state.py:17 (DealStore backend) · app/run_matrix.py:47–100 (Fake*) · app/clients/notion.py

Before — one adapter, hypothetical seam

DealStore — backend injectable ✓ (two adapters: real)
Notion monkeypatch only
Missive monkeypatch only
AI / Gemini monkeypatch only
run_matrix Fake* drifts from real sig

A forgotten monkeypatch hits the real API; the hand-kept Fake* classes silently fall out of sync with the clients they imitate.

After — two adapters justify the seam

NotionPort (interface)
HTTP adapter
prod
in-memory adapter
tests
↳ run_matrix Fake* collapse into the in-memory adapter
selected by injection — same pattern as DealStore(backend=)

Problem

Only the deal store and copy_store have a real test adapter; Notion, Missive and the AI client are monkeypatch-only, and run_matrix's Fake* classes drift from the real signatures.

Solution

Give the hottest client (Notion first) an in-memory adapter behind a small port, selected by the same injection the store uses; fold the Fake* classes into it.

Wins

  • two adapters make the seam real, not hypothetical
  • a forgotten mock can't hit the real API
  • one in-memory adapter replaces N drifting fakes
  • leverage: every flow test reuses the seam
Scope honestly. Start with Notion (the most-called, most-mocked client). One adapter proves the shape before Missive/AI follow — don't port all eight clients at once.
Worth exploring in-process Candidate B

Absorb the shallow dashboard panels into one deep Panel

app/dash_metrics.py · app/dash_services.py · app/dash_leadradar.py (+ dash_finance / dash_journey*)

Before — interface ≈ implementation (shallow)

interface
implementation

Each panel = cache + one Notion query + a JS string + a register() endpoint. The boilerplate is repeated ~7×; deleting one panel just moves its glue into dash.py.

After — thin interface, deep Panel absorbs glue

interface
Panel(load, js)

One Panel(load_fn, js) owns cache TTL + endpoint + mount. Each panel file shrinks to its load function and its JS — a declaration, not a wrapper.

Problem

The dash_* panel modules are shallow — their interface (register + cache + JS glue) is nearly the whole implementation, repeated across every panel.

Solution

Move the glue into one deep Panel module; each panel becomes a load_fn + a JS string registered through it.

Wins

  • interface shrinks; Panel absorbs the wrappers
  • leverage: one Panel, ~7 call sites
  • cache-key bugs concentrate in one module
  • new panels declare, not re-implement
Deletion test. These pass it — but inline them into a deep Panel, not into the 4293-line dash.py. Folding shallow glue into the god-file would concentrate complexity in the wrong place.

Top recommendation

Start with Candidate C — split inbound routing into a pure decision core

It sits on the highest-traffic, highest-risk path (every inbound email) and the deepening pays the most per line: a 505-line function with ~16 return shapes becomes one pure interface where the routing rules — including the ADR-0004 invariant a Client must never become a new Lead — are asserted with no mocks. It also unblocks the one-sided testability you already started: the pure helpers are tested, but their caller is the real bug surface. Candidate A (board_view) is the natural follow-on once routing has a clean seam.

Generated by /improve-codebase-architecture · vocabulary per /codebase-design · domain per CONTEXT.md