last conformance run: 4/4 scenarios pass · 18 acts mapped 2026-08-23 00:39 UTC
| Version | 0.2-draft |
| Status | curated draft — implementation not started |
| Curator | Peter Varga (single editor; spec PRs separate from implementation PRs) |
| Calibration | a curated target list worked by a small team, not a mass-market funnel |
| Provenance | extracted from a production system (the JC360 US market-entry CRM); what stayed behind and why is in §9 |
This document is the contract all implementations of the crm area share: business acts, invariants, lifecycles, and required read models — never screens or schemas. Anything two reasonable implementations might do differently is marked freedom. Scenario files under scenarios/ are the executable part.
---
An operator or small team pursuing tens of accounts they can name, where each account was researched into the list and relationships decide outcomes: market entries, agency work, enterprise sales with a defined territory, partner development. The unit of value is not volume — it is what you verifiably know about a small number of companies and people.
The reference points:
In scope (contract): accounts (curated, narrative-bearing) → contacts (named-with-source or gap) → activities → pipeline → promotion to a trading customer.
Deferred (§9): introductions-with-evidence, blocker/delay logs, ingestion (email/calendar), scoring, dedup/merge, multi-pipeline.
Rejected — permanent non-goals:
| Entity | Lifecycle |
|---|---|
| campaign | active → paused (re-enterable) → concluded (terminal, reasoned). A campaign is a goal: the thesis every account's why_them argues against, and the standing brief an agent reads before filling the list |
| account | a pursuit within one campaign: not_started → researching → approaching → active → won — or parked on_hold (re-enterable), or terminal closed (pursued and lost/passed) / excluded (deliberately never pursued). Account names are unique per campaign |
| contact | gap → named (via resolve_gap only) — a named contact may become departed; never deleted |
| activity | immutable once recorded (a fact, not a workflow object) |
| stage reference | data, not code: each pipeline stage carries a label and a probability (freedom to define the set) |
won is the handoff moment: a won account may be promoted exactly once, creating (or linking) a core customer through the owning module's API. Pursuit ends where trading begins.
Acts are named abstractly; an implementation exposes each as a command under its own prefix and publishes an act → command map. Required arguments are contract. Refusals name what is missing, in one sentence, on every surface.
create_campaign (name, goal, target_profile?) The goal is required (CRM-13): a campaign without a stated thesis is a folder, not a pursuit. The goal is the brief — a research session reads it before adding accounts, and every why_them argues against it. Freedom: extra planning fields.
update_campaign (campaign, patch) — the goal can be sharpened, never blanked.
set_campaign_status (campaign, status, reason?) paused and concluded require a reason. Concluded is terminal: its accounts remain fully readable (CRM-6), but it takes no new targets.
add_account (campaign, name, why_them, source_url, tier?, vertical?, trigger_event?, hook?) The gate to the list. campaign is required (CRM-12) and why_them must argue that campaign's goal; source_url grounds it — an account that cannot say why it belongs to this pursuit, with a source, is not a target yet (CRM-3). Refused into concluded campaigns. Freedom: tier semantics, vertical taxonomy, additional research fields.
update_account (account, patch) — corrects or deepens the narrative. Provenance fields may be corrected, never blanked (CRM-9).
set_account_status (account, status, reason?) Lifecycle moves, guarded per §3. on_hold, closed, and excluded require a reason — parking or killing a researched account is a decision someone later asks about (CRM-7).
set_path_in (account, bullets[]) — the ordered "how we get in" plan, replaced whole.
add_contact (account, role_type, — then one of two shapes)
name + source required; title, confidence_note recommended. A name without a source is refused (CRM-1).gap_note required. The role exists, its holder is not publicly known — that absence is recorded as a finding (CRM-2).resolve_gap (contact, name, source, title?, confidence_note?) The only way a gap becomes named — and it demands the same provenance as a fresh named contact. Refused without a source.
update_contact (contact, patch) Corrections, departed marking, notes. The relationship-graph fields — mutual_via, mutual_url, linkedin_path — are human-only (CRM-4): an agent's write touching them is refused regardless of the member's role, with a sentence saying why.
log_activity (account, summary, occurred_at, contact?, direction?, medium?) occurred_at is when it happened, which is not when it was typed (CRM-5). Interpretation and summary are free prose; facts inside them follow CRM-10.
promote_to_customer (account, terms?, credit_limit?) Allowed only for won accounts, at most once (CRM-8). Creates the core customer through core's exported API — a logged act marking where this area's job ends and o2c's begins — and links it on the account. Freedom: carrying contacts across.
9 write acts + 5 read models — comfortably inside the 25-tool budget, leaving room for extensions.
Namespaced CRM-n (areas own their invariant namespaces; o2c's unprefixed INV-n is legacy).
resolve_gap with full provenance.why_them and a source_url. This is a curated list, not a funnel.on_hold, closed, excluded carry prose that will be read back.won → core customer, through the owner's API, logged, at most once per account.| Read model | Must answer |
|---|---|
account | full narrative (why/trigger/hook/sources), path-in bullets, contacts including gaps, recent activity, available next actions with refusal reasons |
contact | identity, provenance (source + confidence), gap state, activity touching them |
pipeline | accounts by status and tier with stage probabilities — the weighted "where are we" view |
gaps | every unresolved gap: role, account, gap note, age — the "what we verifiably don't know" worklist, first-class |
coverage | list health: accounts by status/tier, gap counts, accounts with no activity in N days — staleness is visible, not discovered |
campaigns | every campaign with its goal, status, and health (accounts by status, open gaps, staleness) — the per-goal Today |
pipeline, gaps, coverage and the account list must answer per-campaign and across campaigns (an optional campaign filter is contract; its shape is freedom).
| Contract | Freedom |
|---|---|
| the 9 acts, required args, refusal semantics | extension commands, extra optional args |
| lifecycles in §3, all 11 invariants | schema, id formats, tier/vertical taxonomies |
| read model content (§6) | shapes, pagination, extra views |
| provenance mandatory; gaps first-class | which fields beyond the graph set are human-only |
| stage probabilities from reference data | the stage set itself |
| promotion via core's API, once | carrying contacts/notes across on promotion |
| buyer/persona doctrine | — it lives in workspace content (seeded guidance, account notes), not module code |
As for o2c: an act → command map covering §4, every scenario under scenarios/ passing (steps name acts; refusals are contract), and the mechanically checkable invariants asserted by the platform gates. One note specific to this area: the conformance runner executes as an agent, so CRM-4 scenarios assert the refusal side; the human-path acceptance is asserted by a gate test, not a scenario.
This spec was extracted from a production single-tenant CRM built for a specific agency agreement. These stayed behind:
| Item | Why deferred, not lost |
|---|---|
| Introductions with mandatory evidence | The source system's introduction table made commission conditional on documented evidence — NOT NULL constraints that were literally sentences of §V of an agency agreement. Generalizable someday as "milestone with mandatory evidence," but shipping contract law as a default CRM concept helps nobody. |
| Blocker / delay log | Encoded §3 of the same agreement (principal-attributable delays extending the term). A generic "blocked, on whom, since when" may return; the contractual accrual math stays bespoke. |
Agreement fields (agreed_with_principal_at, tier-1-only hooks) | Deployment policy, not area semantics — in Saybooks terms, workspace content. |
| Org / pursuit normalization | The fully normalized model splits company (org facts, people, the human-entered network) from pursuit (campaign membership), so one company in two campaigns shares its contacts. Deferred until a company actually appears in two campaigns: today the cost of overlap is a duplicated account row with duplicated contacts — annoying, visible, fixable then. This line exists so the fault line is on record as seen, not missed. |
| Email/calendar ingestion | Integration territory; the area records, it does not watch. |
| Lead scoring, dedup/merge, multi-pipeline | Funnel-CRM machinery; against this area's calibration until real demand says otherwise. |
---
Change log: 0.2-draft amended (2026-08-24) — contacts gain optional email and phone on add_contact / resolve_gap / update_contact: in many industries reach details are plainly listed, and recording them is a sourced fact like a LinkedIn URL (CRM-1 covers the where-from; nothing changes about CRM-4 — the relationship-graph fields stay human-only). · 0.2-draft (2026-08-23) — campaigns become first-class (CRM-12, CRM-13): the 0.1 extraction had erased the campaign by promoting it to "the whole deployment"; a second research fill made it visible again. Acts +3, read models +1, org/pursuit split explicitly deferred. · 0.1-draft (2026-08-22) — initial curation, extracted from the JC360 US CRM. New platform mechanism motivated by this area: field-level human-only enforcement (CRM-4).
| Act | Kind | Required |
|---|---|---|
add_account | write | campaign name why_them source_url |
update_account | write | account |
set_account_status | write | account status |
set_path_in | write | account bullets |
add_contact | write | account role_type |
resolve_gap | write | contact name source |
update_contact | write | contact |
log_activity | write | account summary occurred_at |
promote_to_customer | write | account |
account | read | account |
contact | read | contact |
pipeline | read | — |
gaps | read | — |
coverage | read | — |
create_campaign | write | name goal |
update_campaign | write | campaign |
set_campaign_status | write | campaign status |
campaigns | read | — |
| Id | Invariant |
|---|---|
CRM-1 | Never invent a person: named contacts require a source; facts come only from sources. |
CRM-2 | A gap is a finding: recorded with a note, listed in the worklist, resolved only with provenance. |
CRM-3 | Every account earned its place: why_them and source_url are mandatory. |
CRM-4 | Relationship-graph fields are human-only: refused to agents regardless of role. |
CRM-5 | Activities record when it happened, not when it was typed. |
CRM-6 | Nothing is deleted: exclusion and closure are reasoned statuses. |
CRM-7 | Parking or killing an account requires a reason. |
CRM-8 | Promotion is a bridge act: won → core customer via the owner's API, at most once. |
CRM-9 | Provenance survives updates: correctable, never removable. |
CRM-10 | Interpretation is free; facts are sourced. |
CRM-11 | Every write is a logged command with an actor; refusals are logged; reads are not. |
CRM-12 | Every account pursues a campaign; pursuit state is campaign-relative. |
CRM-13 | No campaign without a goal — the goal is the brief agents read before filling the list. |
Each scenario is a file of acts with expected outcomes. ok means the act must succeed with the listed fields; refused means the act must be refused with a sentence containing the listed text. Refusals are contract.
| # | Act | Expect | Why |
|---|---|---|---|
| 1 | create_campaign | ok | |
| 2 | add_account | refused why_them | CRM-3: an account that cannot say why it belongs, with a source, is not a target yet |
| 3 | add_account | ok status="not_started" | |
| 4 | add_contact | refused source | CRM-1: a name without a source is not usable in a first approach — refused, not warned |
| 5 | add_contact | ok status="named" | |
| 6 | add_contact | ok status="gap" | CRM-2: the absence of a name is a finding worth recording, not a blank to fill later |
| 7 | gaps | ok length=1 | the gap worklist is first-class: 'what we verifiably don't know' is a view, not a feeling |
| 8 | resolve_gap | refused source | CRM-2: resolving a gap demands the same provenance as a fresh contact — the gap does not lower the bar |
| 9 | resolve_gap | ok status="named" | |
| 10 | gaps | ok length=0 |
| # | Act | Expect | Why |
|---|---|---|---|
| 1 | create_campaign | ok | |
| 2 | add_account | ok | |
| 3 | add_contact | ok | |
| 4 | update_contact | ok | ordinary fields are fine — interpretation is the agent's job (CRM-10) |
| 5 | update_contact | refused human | CRM-4: whoever's delegate the agent is, the relationship graph is entered by a person — the refusal explains why, in one sentence |
| 6 | update_contact | refused human |
| # | Act | Expect | Why |
|---|---|---|---|
| 1 | create_campaign | ok | |
| 2 | add_account | ok | |
| 3 | set_account_status | ok | |
| 4 | log_activity | ok | CRM-5: it happened on the 10th; today is when it got typed — the record keeps the 10th |
| 5 | set_account_status | refused reason | CRM-7: killing a researched account without saying why is refused — the list's value is that every change to it can be read back |
| 6 | promote_to_customer | refused won | CRM-8: promotion is for won accounts — pursuit and trade have a boundary, and this is it |
| 7 | set_account_status | ok | |
| 8 | set_account_status | ok | |
| 9 | set_account_status | ok | |
| 10 | promote_to_customer | ok | creates the core customer through core's exported API — one logged act marking where crm's job ends and o2c's begins |
| 11 | account | ok status="won" | |
| 12 | promote_to_customer | refused Already promoted | CRM-8: at most once — the link exists; a second customer would be a reconciliation problem wearing a convenience |
| # | Act | Expect | Why |
|---|---|---|---|
| 1 | create_campaign | refused goal | CRM-13: a campaign without a stated thesis is a folder. The goal is the brief an agent reads before adding anything |
| 2 | create_campaign | ok status="active" | |
| 3 | create_campaign | ok | |
| 4 | add_account | ok campaign_id="CAM-0001" | |
| 5 | add_account | ok | CRM-12: the same company under two campaigns is two pursuits with two whys — pursuit state is campaign-relative |
| 6 | add_account | refused already on | within one campaign, one row per company — a twin is a data bug, not a second chance |
| 7 | pipeline | ok length=1 | |
| 8 | pipeline | ok length=2 | read models answer per-campaign AND across — both are contract |
| 9 | set_campaign_status | refused reason | CRM-7 extended: a goal someone stops pursuing is a decision that gets asked about later |
| 10 | set_campaign_status | ok status="concluded" | |
| 11 | add_account | refused concluded | concluded takes no new targets — but everything it holds stays readable (CRM-6) |
| 12 | account | ok campaign_id="CAM-0002" |