Architecture review · deepening opportunities

ClientsFlow Pipeline

2026-06-19 · branch feature/funnel-automation · 19,972 lines under app/ · no CONTEXT.md / ADRs yet

module seam leakage deep module wide interface small interface

Two modules carry 37% of the code: flows.py (4,094) and dash.py (3,347). dash.py reaches into flows.py internals 58 times — 28 of them through the private stringly-typed registry flows._c("…"). The twelve real adapters already exist under app/clients/; the friction is that nothing crosses a clean seam to reach them.

1 · Inject the client registry behind one Clients port

Strong mock · ports & adapters

app/flows.py:39–61 · app/dash.py (28× flows._c) · app/clients/*.py · tests/test_booking.py

Before — one global, reached through

flowchart TB
  subgraph reach["reached through, not injected"]
    F["flows.py business logic"]
    D["dash.py endpoints"]
  end
  C{{"_c(name)
stringly switch
global dict _clients"}} F --> C D -. "flows._c('notion') ×28" .-> C C --> G["clients/ghl"] C --> N["clients/notion"] C --> M["clients/missive"] C --> S["clients/stripe …"] T["tests"] -. "monkeypatch
flows._c" .-> C classDef leak stroke:#dc2626,stroke-width:2px,color:#dc2626; class D,T leak

After — port injected at the seam

flowchart TB
  F["flows.py"] --> P
  D["dash.py"] --> P
  P{{"Clients port
typed: .notion .ghl .missive"}} subgraph adapters["two adapters justify the seam"] R["RealClients
(prod)"] K["FakeClients
(tests)"] end P --- R P --- K R --> CL["app/clients/*"] classDef deep fill:#0f172a,color:#fff,stroke:#0f172a; class P deep

Problem

Every adapter is reached through a private global _c("name") that constructs on first use; dash.py crosses into it 28× and tests can only run by monkeypatching flows._c.

Solution

Pass a typed Clients object into flows and dash. Prod wires RealClients; tests wire FakeClients. The adapters in app/clients/ stay as they are — only the seam moves.

Wins

  • Two adapters justify the seam — prod + test
  • Accept dependencies, don't construct them
  • Locality: construction in one place, not 9 branches
  • Leverage: one port, every call site & test
  • Deletes the monkeypatch flows._c hack
  • _NoGHL stops lying — feature-gate at wiring
Live-system caution: migratable adapter-by-adapter (Notion first, GHL last). No business logic changes — only the call into _c becomes a call into the injected port.

2 · Deepen the deal/CRM operations dash reaches into

Strong local-substitutable

app/dash.py:196–470 · app/flows.py (store, _is_ours, _is_cold_inbox, _sync_stage) · app/state.py

Before — dash re-implements CRM ops via flows' privates

dash reads/updates/
fuzzy-finds Notion rows
directly
dash.py

dash.py

_is_oursstore()
_is_ours · _is_cold_inbox · _sync_stage · store()
flows.py

flows.py

After — one deep CRM module, small interface

dash.py
flows.py

callers

find · update_stage · is_ours · log_touch
Deal / CRM module
Notion schema knowledge

deep module

Problem

Deal lookup, stage moves, "is this ours?" predicates and Notion row-shaping live as private helpers in flows.py, yet dash.py both calls them and re-implements its own Notion reads/updates (lines 196–470). Schema knowledge is smeared across two modules.

Solution

Move the deal/CRM operations behind one module with a small interface (find, update_stage, is_ours, log_touchpoint). The Notion adapter sits behind it as an internal seam; both callers cross the same interface.

Wins

  • Notion schema lives in one place
  • Interface is the test surface — dashboard becomes testable
  • dash stops reaching into flows privates
  • Locality: stage-move bugs concentrate here
  • Internal seam (Notion), not exposed at the interface

3 · Carve a pure Decision module out of handle_missive_incoming

Strong in-process

app/flows.py:852–1319 — 467 lines, the largest function (next is 108)

Before — decide + I/O fused, no seam to test at

467-line orchestration
fetch body · is_ours? · cold/warm?
classify · draft · write Notion
post Slack · trigger outbound

tests mock all 6 deps → assert nothing meaningful

After — pure decision, thin apply

route_incoming(email, deal) → Decision
pure · in-process · no I/O
↓ Decision
apply(decision, clients)
writes Notion · Slack · outbound — thin, dumb

tests assert on Decision — the interface is the test surface

Problem

The routing logic — new vs existing lead, cold vs warm, auto-reply, negatives — is tangled with fetching, drafting and writing. There is no interface in the middle, so the only test seam is "mock everything and watch the mocks do nothing."

Solution

Extract a pure route_incoming(email, deal) → Decision. The orchestrator shrinks to apply(decision, clients). The decision data is the interface; in-process, so it needs no adapter.

Wins

  • Locality: all routing rules in one deep module
  • Interface = Decision = test surface
  • Tables of (email, deal) → Decision become real tests
  • Leverage: orchestrator becomes dumb & reusable
Timing: this is the highest-traffic live path and AUTOSEND_ENABLED is about to flip. Do it before go-live under the human gate, or defer until after — not mid-flip.

4 · Give the 8 dashboard panels a real Panel interface

Worth exploring in-process

app/dash.py:1553–1592 · dash_metrics · dash_services · dash_finance · dash_leadradar · dash_journey · dash_journey_sandbox · dash_automation_sandbox

Before — an unstated convention, special-cased

flowchart TB
  DH["dash.py build_html()
if finance/journey → owner_guard"] DH -.-> P1["dash_metrics
PANEL·JS·register"] DH -.-> P2["dash_finance
PANEL·JS·register"] DH -.-> P3["dash_journey
PANEL·JS·register"] DH -.-> P4["…5 more, each
repeats the shape"] P1 -. "_dash._cached" .-> CA["global _cache dict"] P2 -. "_dash._cached" .-> CA classDef leak stroke:#dc2626,stroke-width:2px; class DH,CA leak

After — one interface, eight adapters

flowchart TB
  REG["panel registry
uniform loop"] IF{{"Panel interface
key · data() · render() · auth"}} REG --> IF IF --- A1["MetricsPanel"] IF --- A2["FinancePanel"] IF --- A3["JourneyPanel"] IF --- A4["…5 more"] classDef deep fill:#0f172a,color:#fff,stroke:#0f172a; class IF deep

Problem

Each panel exports PANEL, JS and register() by convention only — there is no interface. build_html() special-cases owner-gated panels, and every panel mutates a shared global _cache by string key. Understanding "the dashboard" means bouncing between 8 files.

Solution

Name the seam: one Panel interface (key, data(), render(), auth). Each module becomes an adapter; the registry loop is uniform; the cache is owned by the registry, not a free-floating global.

Wins

  • Deletion test: panels earn their keep — fix is the interface, not collapse
  • Owner-gating is a field, not a special case
  • Cache owned, not a shared global mutated 8 ways
  • Data/render seam inside each adapter

5 · Deletion-test the shallow leaves

Speculative in-process

app/scrape.py (29) · app/retry.py (39) · app/textutil.py (79)

scrape_site()
29 lines

scrape.py — shallow

1 caller · delete = move

backoff · retry_call
39 lines

retry.py — borderline

~3 callers · earns it at N

4 fns
HU quote-strip
scattered otherwise

textutil.py — deep ✓

delete = duplicate regex 5×

Problem

scrape.py wraps BeautifulSoup for a single caller — deleting it just moves the 24 lines into enrich.py. retry.py is a standard backoff at ~3 sites. Neither concentrates complexity yet.

Solution

Inline scrape.py into enrich.py; leave retry.py until a 4th call site justifies it. Keep textutil.py — it passes the deletion test (inlining would duplicate the Hungarian quote-strip across 5 modules).

Wins

  • Fewer shallow modules to navigate
  • Deletion test applied, not assumed
  • Low stakes — defer freely

Top recommendation

1 · Inject the client registry

It is the enabler. Until the adapters arrive through an injected port, candidates 2 and 3 can't be tested without the monkeypatch flows._c hack, and the dashboard stays untestable. Lowest risk too — migratable one adapter at a time with zero business-logic change. Deepen this seam first; the rest open up behind it.