Problem
A sales manager has signed off a design: their stages, their approval threshold, their rule that a quote freezes once it is sent. Now somebody has to build it — and the fastest way to get it wrong is to open a reference implementation and start editing.
Copy the whole thing and you have shipped somebody else's business: campaign attribution and lead scoring for a five-person team that wanted a pipeline and a quote. Start from an empty folder instead and you will spend a week rediscovering that a sent quote needs a revision rather than an edit, that totals must be computed and not typed, and that a record a rep may not see has to come back as not found rather than forbidden.
The reference CRM exists so neither happens. It is a runnable blueprint: the platform layer is copied verbatim, the business layer is generated from the customer's design file, and this page is the map of which is which.
Read the module first — the roles, the stories, the records. This is what happens after the customer says yes.
What is in the box
/code/crm-suite-app unpacks to one FastAPI application:
main.py mounts each module's router under /api, serves the built UI,
exposes GET /healthz and POST / (the self-check)
db.py auth.py sa_token.py the platform layer — copied verbatim, never edited
models.py SQLAlchemy models: natural keys, Numeric(18,2) money
modules/
core.py accounts, contacts, activities, deals
lead_to_opportunity.py discovery → leads → conversion
qualification_scoring.py BANT/MEDDIC scoring against a policy row
pipeline_forecast.py stages as data, weighted forecast, risk
quote_cpq.py products, price books, quotes, the discount policy
campaign_to_lead.py campaigns, touchpoints, attribution
account_360.py the family graph, whitespace, lookalikes
tasks.py reports.py csv_import.py
web/ Vite + React + TanStack, talking only to the routes
36 tables in one bucket, because that is the point: a sales opportunity joins a finance invoice with no ETL, and the account family rollup is a graph walk over the same rows the SQL reads.
Smaller shapes ship too — /code/crm-core is accounts, contacts, activities and deals alone. Take
the smallest one that covers the customer's stories; the rest is not "extra value", it is code
somebody has to understand later.
Which file answers which story
The mapping is the point of the design file, and it survives into the build:
| Story | Where it lives |
|---|---|
| CRM-01 a rep sees only their own accounts | every owned read in core.py is scoped by the caller's identity |
| CRM-04 move a deal through our stages | pipeline_forecast.py + the pipeline_stages table — stages are rows |
| CRM-05 build a quote from the price list | quote_cpq.py: lines priced from a price book, totals computed |
| CRM-06 quotes over the threshold need approval | quote_cpq.py checks the discount_policy row on every edit and again on send |
| CRM-07 an approved quote is locked | the status guard at the top of every write path in quote_cpq.py |
| CRM-08 see a whole corporate family | account_360.py walks the typed graph, then aggregates |
| CRM-10 re-score leads every 6 hours | qualification_scoring.py exposes the route; the schedule is yours — see Jobs |
When you generate a customer's system, keep the story id in the route's docstring and in the test name. It is the only cheap way to answer "is this requirement actually verified?" six weeks later.
The five rules that decide whether it survives contact with a customer
1 · Rules live on the write path, not in the UI
The discount cap is enforced in the route that saves a line, the route that saves the header, and the route that marks the quote sent. The UI shows a bar reading 8.00% of 10% because a rule the user cannot see is a rule they will fight — but the bar is convenience. The route is the control.
This matters more here than on a normal database: column constraints are limited on DataK3, so a "closed period" or a "frozen quote" is a guard on the write path. Route every write through it, or make one module the sole writer, and the lock is real. Skip that and it is a convention.
2 · Tuning lives in rows
| In code | In rows the customer edits |
|---|---|
| that stages exist and who may move a deal | the stage names, their order, probability, forecast category |
| that a discount cap is enforced | 10%, 20% for VIP, per customer |
| that a sent quote freezes and revisions are new rows | validity days, follow-up days, expiry warning |
| that a lost deal needs a reason | the list of reasons |
The test: could their office manager change it on a settings screen? If yes, it is a row. This is what lets one reference module fit a machine dealer, a consultancy and a distributor without a fork — and it is the first thing to check when you are tempted to copy a constant into the code.
3 · Natural keys, idempotent writes
DataK3 has no sequences, and RETURNING does not return a staged write, so every table has a
natural or derived primary key: email for a contact, org_domain for an account,
quote_id|line_no for a quote line. Sequential document numbers are the exception to be careful
with: a counter row plus the primary key is the recipe, and a platform fault currently lets
concurrent inserts share a number — check before promising a customer strict numbering. Writes are
INSERT … ON CONFLICT (pk) DO UPDATE SET col = EXCLUDED.col (only EXCLUDED.<col> refs are
accepted — put constants in VALUES).
The payoff is that a re-import, a retried request and a double-clicked button all land once. The cost is that you must be able to name the identity of every record — which is why the design phase decides keys, not the ORM.
4 · Derive state instead of storing it
A quote is expired because valid_until has passed, not because a nightly job set a flag. Every
derived state is one less job, one less race, and one less thing to migrate. Store what a person
decided; derive what follows from it.
5 · Identity comes from the gateway
There is no auth code in the app. The gateway does the login and injects the caller; auth.py is a
header-trust role gate, copied unchanged. Permissions are named crm:<object>:<verb> and merged
into the customer's role catalog — merged, because setting only your module's roles strips
every other module's access from the same pool.
Jobs: there is no scheduler
Ignite is request-invoked. Three of the CRM's stories are system stories, and each needs a home:
| Story | Options, best first |
|---|---|
| CRM-10 re-score leads every 6 hours | an always-on worker pinned warm (--reserved 1 --max-replicas 1), or an external clock calling the route |
| CRM-15 snapshot the forecast nightly | the same, or drop it: the forecast can be computed on read until volumes say otherwise |
| CRM-11 notify the submitter when a quote is approved | inline in the approval route — no job at all |
Every job is idempotent and records its run, because "running it twice changes nothing" is part of the story's acceptance criteria.
Running it against your own bucket
The package is .env-driven: point it at a test bucket, never the customer's live one.
Create a DataK3 bucket called crm-dev for a CRM test copy, then show me its connection endpoints.
data_bucket_create→data_connectdodil data bucket create crm-dev --description "CRM reference — test copy"
dodil data connect crm-devThen the schema, the app, and the first read:
Apply the CRM package schema to the crm-dev bucket and list the tables it created.
data_sql→data_table_listcurl -sL https://blog.dodil.io/code/crm-suite-app/ -o crm.tar && tar xf crm.tar && cd crm-suite-app-v1
cp .env.example .env # BUCKET=crm-dev + your service-account id/secret
pip install -r requirements.txt
python migrate.py # idempotent: safe to re-run
uvicorn main:app --reload # http://localhost:8000Verify the way that proves something. Generate a POST / self-check into the customer's app —
it opens the bucket with the app's own credential and returns a row count per table, and
ignite invoke calls it. The packages here predate that and expose only GET /healthz. A 401 from
a deployed app's public URL is the gateway answering; it tells you nothing about the app.
The tests are the stories
The suite that ships with a generated system has three parts: one acceptance test per story id, one access test per role (right rows; other people's rows not found; excluded actions refused; no gateway identity rejected), and invariants (totals computed not typed, re-imports idempotent, frozen records refusing edits, currencies never summed together).
When a build we ran this way hit failures, the tests told us immediately that three of them were platform faults rather than app faults — because each failing test named the story it was verifying. That is the whole argument for naming them after requirements.
What this package does not do
Stated plainly, because a reference that hides its gaps wastes a day of somebody's time:
- Most tables are unprefixed (
contacts,quotes,products). In a shared bucket, prefix the module-owned ones (crm_) as you generate — two of them already collided with GL and were renamed. - No scheduler, as above.
- No notifications — CRM-11 has no channel in the reference.
- Permission names predate the convention (
quotes:approverather thancrm:quote:approve). - Its customer table is its own. If invoicing or accounting is anywhere on the roadmap, use the shared business partner from day one instead, and let CRM reference it. A second customer list is the most expensive cheap decision in this whole catalog.
One-shot
Give an agent this, after the design is signed off:
Build <customer>'s sales module from the signed-off ERP_CRM.md in this repo.
Read https://blog.dodil.io/modules/crm/spec.yaml for the reference vocabulary and
https://blog.dodil.io/modules/pattern.md for how a module is put together.
Fetch https://blog.dodil.io/code/crm-suite-app and copy db.py, auth.py and sa_token.py
verbatim; generate models, routers and screens from the design file, not from the
reference's routes. Prefix this module's tables crm_; reference the shared business
partner if invoicing is on the roadmap. Stages, thresholds, validity and loss reasons
are rows, not constants. Write one test per story id, one per role, one per invariant,
and run them against the dev bucket only. Report what you built and what you left out.
Where to go next
- The module — roles, stories, records, and the questions customers answer differently.
- The pattern — the anatomy every module follows.
- The lifecycle — how you got here, and what happens when they ask for a change.
- Connect your tools — driving the same rows from psql, a Neo4j driver or a vector client.