dodil/erp-brick-lead

README.md

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

StoryHow
CRM-13 a trade-show list becomes leads the same dayimport_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 activityrecord_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 workflownew → 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 onceconvert_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 wonfind_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 leadsowner-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)

FindingNow
C2 a rep planted the deal another rep's conversion landed onthe 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 leadsa 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
C3a 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
C4bp_id (in a conversion; the lookalikes filter) is a string of digits
H12 a 200 KB CSV cell was a 500a 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.

BrickNeedsAsked forIf not installedIf down
business-partnerbp:partner:promotepromote the domain to a customer; read-only: "is this domain a customer?"convert refused 409, nothing created; lookalikes exclude only converted companies, and say soconvert 503, nothing created; lookalikes fall back the same way, with a note
activityactivity:contact:writethe contact, by emailconvert refused 409, nothing createdconvert 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, ownerconvert refused 409, nothing createdconvert 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 — Lead is 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_events with 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