The CRM is four record types, five dimensions, one board and one rule: a client is its own partition, everything that happens is a node belonging to that client, and every chip on the board is rendered from those nodes. Nothing is typed twice, nothing is a list inside a record — the board, the client page and the deal page all read the same nodes.

The records

Record Where it lives The node carries The content carries
Crm/Client a top-level node — the client's own partition (/PearlTechnology) name, description (tagline), icon website, industry · country · segment (dimension members), owner (a user), body (the workspace page, markdown)
Crm/Opportunity {Client}/{Deal} — a direct child name (the deal), description (the one-line summary) state (Open · Won · Lost · OnHold), offering (member), amount + currency (member), probability, expectedClose, owner, nextStepClient, nextStepSystemorph, nextStepDate
Crm/Interaction {Client}/{Interaction} — a direct child name (the chip's label), description (what happened) stage (a Crm/Stage member), status (Completed ✓ · Current ● · Planned ○), date (calendar), order, note, opportunity (the deal it advances, or none), contact
Crm/Contact {Client}/{Person} — a direct child name, description (role line) role, organisation, email, phone, profile, doNotContact, consent, notes
Essentials/Email (not a CRM type) {Client}/{Deal}/{Mail} — outgoing mail, under its deal name (what the mail is), description the generic message record of MeshWeaver Essentials: to, cc, subject, body (markdown), attachments (what travels as files — the document exported to PDF), relatedTo (the deal), approvers (user ids, comma-separated), requiredApprovals (the quorum — 2 of 3); status, sentAt, sentBy are stamped by Send. The approvals are the platform's _Approval satellites, never fields — see the /email skill

DimensionsCrm/Stage, Crm/Industry, Crm/Country, Crm/Segment, Crm/Offering, Crm/Currency — are node types whose members are nodes (Crm/Stage/Workshop, Crm/Country/CH). A record references a member by path, and the Edit page renders a real picker for it. The members shipped in this repo are the initial values; adding one is one JSON file, landed by PR. Nothing in code enumerates them — every page reads them live.

Documents — proposals, emails, cases, decks — stay where they are: ordinary nodes beside the records. The client page lists them; nothing has to move. A client partition keeps everything a Space had: its own schema, routing, _Access grants, threads, files. Retyping the root to Crm/Client adds the structured facts; it removes nothing. Access is per client partition, as before — the board shows each viewer the clients they can read.

Adding a record — the "+" is on the section that lists it

Every record SECTION on a client, deal or offer page offers what it lists — the deals, contacts and interactions on a client, the offers and correspondence on a deal, the questions on an offer. The "+" sits in the section's own header and opens the Create form with the type already chosen and the new record already aimed at the right place — a question lands under the offer whose page you were reading, a deal under its client. You never pick the type out of a list of ninety.

The promise is about those SECTIONS, not about every list on every page. A view that summarises or aggregates offers nothing, and none of these is an omission:

One thing the "+" cannot do yet. The Create form asks for a name and a place; it does not fill in a record's own fields. So an offer added from a deal's page belongs to that deal because of where it sits, and everything else — status, amount, what it covers, the dates — is set on the offer's Edit page afterwards. Naming the deal explicitly there is still worth doing when the offer is later moved.

Until 2026-09-12 only the generic Documents catalog carried a "+", so every record type had to be created through an agent or a hand-written node — which made the capability invisible to anyone who did not already know the type existed. The offer page was the sharpest case: it announced that open questions block the client's decision and gave no way to record one.

The journey — interactions, stages, one bar

An interaction is a node. A meeting, a workshop, documentation sent, an offer made, a wait for the client's answer — each is a Crm/Interaction under the client, one chip on the bar. Its stage is a Crm/Stage member whose category decides the colour: Prospecting (yellow — Prospect, Meeting, ImpulseSeminar, Documentation, CommercialIndication, Workshop, ProjectProposal, Nda), Engagement (green — DrivingLessons, Pilot, Consulting, Project, Subscription, Delivery, Client, Renewal), Waiting (gray — AwaitFollowUp, Dormant), Terminal (gray — Closed). Its status is Completed ✓, Current ● or Planned ○. The rendering rules are the Journey Model.

Which bar does an interaction belong to? One with no opportunity is RELATIONSHIP-level — the first chip (Prospect, Client; the members carry a relationship flag), a first meeting, an impulse seminar, an NDA — and draws on the client's bar. One that names a deal draws on that deal's bar, captioned with the deal's name. PartnerRe reads ✓ Client on the client, then AI Consulting: ● Consulting and ESL Lifecycle Platform: ● Project Proposal → ✓ Meeting (24 Aug) — two deals, two bars, one client.

Dates are calendar dates (yyyy-MM-dd), never instants: a workshop on 22 August is on 22 August in every time zone. The chip renders it as (22 Aug). A note rides on the chip only when it is a qualifier of at most 24 characters ((to send)); a longer note shows on the interaction's card and in the chip's tooltip, and a chip is never wider than 280 px — the rest is an ellipsis, the tooltip has it all. Repeats of one stage are numbered by the bar itself — Documentation (1st), (2nd) — never stored.

The states of a deal

State Meaning How it is reached
Open being pursued the default; Reopen from On hold
Won signed — the engagement runs or is delivered Close — won: the deal's Current interactions become Completed
Lost lost or withdrawn Close — lost: same
OnHold parked by either side Put on hold: the interactions are kept as they stand

The fine-grained position is the bar; the state is the coarse lifecycle the pipeline reports on. A Waiting interaction (AwaitFollowUp) is a Current chip whose ball is on the client's side — the board orders such deals after the ones where the next deliverable is ours.

Who records what — the process, step by step

  1. A new prospect. Create the client partition — a top-level node nodeType: "Crm/Client" with { "$type": "ClientContent", … } (see /crm) — and its first interaction: Prospect, Current. Grant the team on {Client}/_Access as you would on a Space. Record the first contact(s).
  2. A deal appears. Create {Client}/{Deal} with state: Open, the offering, and nextStepSystemorph — every open deal names what WE do next.
  3. Something happened. On the deal page, Record interaction (pick the stage, the date, an optional note): a Current wait on this deal becomes Completed, a Planned interaction of that stage is promoted in place, otherwise a new interaction node is created under the client, referencing the deal. Update the next steps on the deal. Parallel Current interactions are legitimate (Consulting running while a Project Proposal is out).
  4. Waiting. When the ball moves to the client, record AwaitFollowUp and set nextStepClient — and nextStepDate when there is one (a meeting on the 2nd, a decision by October). The board sorts dated next steps first.
  5. Decision. Close — won (then record the engagement — Pilot, Project, Subscription, Consulting — as the deal's Current interaction, and Client as a relationship interaction), Close — lost, or Put on hold.
  6. The board (/Crm/Portfolio) is never edited: it reads. The legend, the ordering rule and the next steps come from the records. It holds no list of its own — it DECLARES its sets (the open pipeline, the client roster) as queries the GUI runs, so the board shows exactly what the reader may read. Three analysis views sit in its node menu: Numbers (what the pipeline adds up to), Pipeline by client (open deals grouped and counted) and Recent activity (every interaction, newest first). Robert's hand-authored board is the specification it was built to.

Field edits (amount, expected close, next steps, facts) go through the node's Edit area — the framework's node-bound editor with real pickers for every dimension, one source of truth, no save button. Actions (record, close, hold) are buttons on the deal page, each a short list of node writes. Over MCP: create an interaction node, patch a deal's field, or /crm for the shapes.

How the records came to exist

The records were not typed in. The hand-authored board (/Portfolio) and each client's own documents were migrated into records during August 2026, in place — a retype keeps a node's path, its history and every inbound link, which is why a promoted offer is still found by the links that pointed at the document.

What the migration produced, and still holds:

There is no migration NodeType any more. It was removed once the work was done: a compiled control plane that lives in the module forever is a node that can be armed forever, and on 2026-08-30 exactly that — an armed request meeting a stale assembly — stripped four client documents before it was caught. One-off data work is now done with an executable Code node, run once, by a person, verifying each write by reading it back. The reference implementation is rbuergi/Script/promote-offers, which promoted the four client offers with every body intact.

If another client is onboarded, copy that script, name the documents, run it as a dry run, read the plan, then run it for real. The plan is not the result: check the node afterwards.

Before you change data: check what the portal is actually running

Any bulk change writes to real client partitions, so the thing worth checking first is that the portal runs the code you think it does.

On 2026-08-30 a migration ran against a stale build while its type reported compilationStatus: Ok and its compiled sources matched the repo exactly. It stripped the text out of four client documents, one of them a 7,000-word commercial offer, and one document's text could not be recovered. The type's own report is not proof.

Nor is any other field on it: comparing the type against its published Release looks like the answer and is not — releases lag routinely, and a type whose release pointer was three versions and a framework identity out of date was serving its pages perfectly the same afternoon.

The check that works is behavioural. Open a real instance of the type and look for what your change does:

And nothing is migrated until it is read back. After a bulk change, open the nodes and confirm the content you meant to carry is on them. A plan cannot show you an empty result; only the node can.

Offers, and the questions they raise

An offer is a record, not a document: {Client}/{Offer} carries its status (Draft · Sent · Accepted · Declined · Withdrawn), the amount and what it covers, the day it went out and the day it lapses. The prose stays — it is the offer's body, and the pricing documents stay beside it — but the handful of fields make it answerable: what is out with a client right now, what expires this month, what did they decline and why.

Configuring the process

The steps a deal is expected to walk are a node, not code: Crm/Workflow/{Id} holds an ordered list of steps, each naming a Crm/Stage member, whose move it is (us or the client) and how many days after the deal opens it is due. Three ship — Standard, Pilot and Subscription renewal.

A deal whose offering prescribes nothing has no plan, and renders exactly as it did before.

Numbers, and what they are allowed to claim

Numbers (/Crm/Portfolio/area/Metrics) is the one view that adds things up. Everything on it is counted over the deals you can see — the read runs under your identity, so a total is never larger than your access — and dates are read in your own time zone.

What it will not do matters more than what it will:

What needs attention, on a deal

A deal page shows an attention block when — and only when — something is wrong with it: an unanswered question on an offer (blocking), workflow steps past due, a next step overdue or missing, an expected close that has passed, a deal nothing has been recorded against, or one that has been quiet for thirty days. Unpriced and unowned are quiet notes.

These are rules over your records, not a model's opinion, and each line prints the fact that triggered it. A score of 63 cannot be argued with; "3 unanswered questions on an offer" can be, and is the sentence that gets someone to act. A healthy deal shows nothing at all — a permanent "Health: Good" panel teaches people to stop reading the panel.

Capturing activity from mail

The Gmail/M365 connector asks this module one question: what is this message about? The answer follows one rule — a wrong attribution is worse than no attribution. A mail nobody captured costs a minute; a mail filed against the wrong client puts one client's business on another client's page, and it is found by the person it was shown to.

So it refuses rather than guesses. Two clients on a thread: nothing is attributed, and both are named so you can decide. Two of your contacts at one client: the client is named, the person is not. Two open deals: the deal is left to you. An address at a personal mail provider never identifies a client — otherwise one client who happens to use Gmail would collect every stranger's Gmail — though that same person's exact address still resolves to them. Internal mail between colleagues attributes to nobody, and a mail you sent is attributed to whoever received it.

Nothing is written automatically. You are offered a proposal, because the cost of a wrong record is paid by whoever reads it later.

For developers: reading data

Every live read in this module goes through CrmQueries.Synced, never hub.GetQuery directly. It appends the content projection, because on a synced query content is the one column you have to ask for — omit it and every ContentAs<T>() returns null, every .Where(c is not null) drops the row, and the page renders as if there were no data at all. Nothing errors. This module shipped that twice; the helper exists so a call site cannot repeat it.

For agents

The /crm skill (Crm/Skill/crm) carries the exact node shapes and the process above as a procedure; the CRM assistant (Crm/Agent/crm-assistant) records interactions, opens and closes deals, sets next steps and adds contacts in conversation — writing only the fields the process names, never a stage outside the members.

A client OWNS its partition

Crm/Client declares ownsPartition: true — the shape Space and User use. A TOP-LEVEL instance of an owning type provisions its own Postgres schema on create and makes the creator its Admin; the partition write guard REFUSES creating a top-level node of any other type. So /crm's "new client" create (empty namespace) is legal only because of this flag — without it a new client could only ever arrive by retyping an existing Space, which is what the migration did.

Outgoing mail is approved before it goes — and it is not a CRM record

A mail to a client is an Essentials/Email record filed under its deal — the platform's generic message type, not one of this module's. Its page is read-only and formatted the way the mail will read (the envelope, the body, the attachments); the words are written on its Edit form. The gate is the platform's cross-cutting Approvals: name approvers (user ids) and a requiredApprovals quorum on the message (for PartnerRe, two of mkleiner, rsalzmann and rbuergi), press Request approvals, and each approver gets an ordinary approval request — decided on the message page or on the Approval Desk. Send appears only once the quorum has approved the text as it stands: an approval counts only if it was decided after the message was last saved, so an edit lapses it and the approvers are asked again; a rejection blocks until the text changes. Sending is recorded (the moment and the person); delivery is the mail connector's job, and the document travels as the PDF named under attachments, never pasted into the mail. The deal page lists the messages under Correspondence; the setup, field by field, is the /email skill of Essentials.

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.