brick-lead
Leads: people and companies not yet worked into a deal — imported from a spreadsheet, scored by your own
policy, converted into partner + contact + deal in one step, and found again as lookalikes of what you won.
A brick on erp-platform, held to the CRM spec (modules/crm).
Every action is both an API route and an MCP tool.
What it does
| Story | How |
|---|---|
| CRM-13 a trade-show list becomes leads the same day | import_leads takes the CSV as text (email required; name, company, website, country, industry, size, title, source, owner optional; comma, semicolon or tab). The lead id is derived from the email, so importing twice creates nothing new. Rows with no email or a bad one are reported with their spreadsheet row number, never dropped; the result counts created / updated / unchanged / skipped. A re-import fills in what the file says and never changes the owner, the status or the lead's first source |
| CRM-10 the call list reflects this morning's activity | record_signal (called by the campaign brick or a web hook) records engagement once per event. score_leads re-scores every open lead against the scoring policy (config rows: points per fit attribute and per signal, a window, a qualify threshold). The score is derived, explains itself (score_reasons), and stores the run that computed it; a second run changes nothing. There is no scheduler: whatever runs your jobs calls it |
| the lead workflow | new → working → qualified (the policy says the score, computed now, qualifies — by a rep, or by the scoring run for a worked lead) or disqualified (needs a reason) → converted (terminal) |
| CRM-03 a qualified lead becomes account, contact and deal at once | convert_lead → business-partner POST /partners/promote (by domain, as customer) → activity POST /contacts (by email) → pipeline POST /opportunities/from-source (first stage, source_ref "lead:<id>", owned by whoever converts — a machine's route since pipeline 0.3.0). Each result is kept on the lead as it lands, so a neighbour down part-way leaves the lead retryable: converting again finishes the rest and creates nothing twice — including a deal whose reply was lost. Converting a converted lead returns the same three ids |
| CRM-14 customers like the one we just won | find_lookalikes ranks lead companies by the cosine distance between fit profile vectors (industry, size band, region) stored in a VECTOR(31) column — <=> on DataK3. The won account's profile is the lead it was converted from (or attributes you pass). Excluded: the account, every company converted from a lead, and any domain the partner register holds as a customer |
| CRM-01 a rep sees only their own leads | owner-scoped; another rep's lead is 404. Managers, analysts and marketing see all (marketing imports for the team); only the owner or a manager changes a lead |
0.2.0 — the CRM audit's wave 1 (2026-10-17)
| Finding | Now |
|---|---|
| C2 a rep planted the deal another rep's conversion landed on | the deal step uses the pipeline's machine route (from-source, pipeline:opportunity:link_source); a person can no longer name a source_ref. A ref whose deal is another partner's stops the conversion with 409 — partner and contact kept, nothing written after it |
| half-converted leads | a lead with its partner or contact made keeps the domain they were made from (409 on a change); each step is recorded under the lead's version and re-run on a lost race, so concurrent converts lose no step; once the cause is gone (a partner unblocked, a neighbour back), converting again finishes with the same partner and contact |
| C3 | a lead carries a version (GET sends the ETag); edit, working, qualify, disqualify and assign take version or If-Match (409 when stale); without one a lost race is re-run. A re-import claims each lead's next version in bulk, so an import racing a rep's edit loses neither |
| C4 | bp_id (in a conversion; the lookalikes filter) is a string of digits |
| H12 a 200 KB CSV cell was a 500 | a row that cannot be read as CSV is 422 with its row number; bounded inputs elsewhere (names ≤ 200, min_score 0–100, limit 1–200, ISO times) |
Who
Permissions: lead:lead:write (reps: add, edit, work, qualify, disqualify, convert), lead:lead:import
(marketing), lead:lead:assign (managers), lead:policy:write (whoever owns lead qualification),
lead:lead:score (whatever runs the scoring job), lead:signal:record (the campaign brick's or a web hook's
service identity). Reads need no permission beyond being signed in; visibility is per lead. From 0.2.1 the last two
are machine permissions (Brick(machine_permissions=…), erp-platform 0.9.2): they pass on an exact grant only,
so a wildcard role (*:*:*, lead:*:*) runs every person command and never these.
A brick decides by permission, never by role name (erp-platform 0.9.2). From 0.3.0: seeing every lead is lead:lead:read_all (managers, marketing), changing anyone's lead:lead:manage, naming another owner for a new lead lead:lead:assign — until then the manager/analyst/marketing role names. The roles are the customer's:
src/brick_lead/roles.yaml ships the defaults, from the CRM spec's actors; a composition maps them to its pool. The
process (application/process.yaml) is served as BPMN 2.0 at GET /process/bpmn.
The process (application/process.yaml, 0.2.1) is served as BPMN 2.0 at GET /process/bpmn.
Neighbours
All three are reached only through application/neighbours.py (the anti-corruption layer). The lead brick's
service identity needs the permission in the second column.
| Brick | Needs | Asked for | If not installed | If down |
|---|---|---|---|---|
| business-partner | bp:partner:promote | promote the domain to a customer; read-only: "is this domain a customer?" | convert refused 409, nothing created; lookalikes exclude only converted companies, and say so | convert 503, nothing created; lookalikes fall back the same way, with a note |
| activity | activity:contact:write | the contact, by email | convert refused 409, nothing created | convert 503; the partner is kept, a retry continues |
| pipeline (≥ 0.3.0) | pipeline:opportunity:link_source (a machine's; no person role holds it) | the deal, first stage, source_ref, owner | convert refused 409, nothing created | convert 503; partner and contact kept, a retry opens the deal once. A ref whose deal is ANOTHER partner's: 409, partner and contact kept, nothing written after it |
A neighbour's refusal travels in its own words (a blocked customer, a missing country → 409/422), with what is already done.
Design (DDD)
- Language — the glossary at the top of
domain/lead.py; the same words name the classes, the use cases and the history. - Aggregate —
Leadis the root; its invariants live in it: the workflow's transitions, a reason to disqualify, converted is terminal and keeps its result, a conversion step cannot replace another's result, a converted lead is not edited. One lead per transaction — except two batch commands (import, a scoring run), which are one transaction on purpose so their counts are what was written. - Value objects —
Email,Domain,LeadKey(the derived id),Signal,Score(with its breakdown),ProfileVector,Rule,ScoringPolicy. - Domain events — past tense, raised by the aggregate and written to
lead_eventswith it: Added, Imported, Reimported, Described, WorkStarted, Qualified, Disqualified, Assigned, SignalRecorded, ConversionStepRecorded, Converted. - Domain service —
score(), a pure function of lead + signals + policy. Conversion is orchestrated in the application layer (it spans three neighbours and a transaction per step). - Policy as rows —
lead_scoring_policy+lead_scoring_rules, evaluated only by domain code (ScoringPolicy.qualifies). - Anti-corruption layer —
application/neighbours.py.
Tables
lead_leads (the aggregate), lead_profiles (the VECTOR(31) fit profile, derived), lead_events
(history), lead_signals, lead_scores (latest derived score + the run that computed it),
lead_score_runs, lead_scoring_policy + lead_scoring_rules (config rows), lead_leads_versions (0.2.0, the
optimistic-version claims). See src/brick_lead/schema.sql. 0.2.0 migration (additive): lead_leads.version
at the end of the CREATE and as an ALTER (DISRUPTIVE: reads stall for minutes — migrate before rolling 0.2.0), and
the claim table.
Lookalike ranking runs profile <=> CAST($target AS VECTOR(31)) in DataK3; on SQLite (the unit tests) the
same candidates are ranked in Python by the same cosine distance (LeadRepository.nearest).
Run
pip install -e '.[test]' # needs erp-platform; brick-business-partner, brick-activity and
pytest -q # brick-pipeline installed too run the tier-1 suite across all four
BUCKET=<scratch> DODIL_ORG=<org> python scripts/live_check.py
Not in 0.2
Embedding-based lookalikes (a language model over company descriptions: needs the billed ignite.model-user
role — a later option beside the fit profile); score decay inside the window; a reopen from disqualified;
merging a person lead into a company lead; a lookup of customers by domain when their bp_key is something
else (bp has no such route yet, so such a customer is excluded only if it was converted from a lead); the
on-behalf-of identity across apps at tier 2 (the deal is then owned by the lead brick's service identity —
the known loss erp_platform.seam documents).
License: Apache-2.0, see LICENSE