Status: PLAN — nothing here has been executed. Written 2026-09-15 for Roland's go. No live node, no NodeType and no query has changed. Every step below that touches memex.systemorph.com is system-changing and waits for an explicit go.

Why

The CRM has one root type, Crm/Client, and every organisation Systemorph has a relationship with has to be one. On 2026-09-15 notus — the partner that runs our social media — had to be created as a Crm/Client because nothing else exists (/Notus, "A PARTNER, not a client" in its description). A partner, a supplier and a client are the same shape — an organisation with its own partition, people, documents and correspondence — and differ in one fact: what kind of counterparty it is. So the fix is one type with that fact on it, not a second root type.

The target model

Today After
Root NodeType Crm/Client (ownsPartition: true) Crm/Counterparty (ownsPartition: true)
Content record ClientContent CounterpartyContent — every ClientContent field, plus type
The new fact type: node path of a Crm/CounterpartyType member ([MeshNode("nodeType:Crm/CounterpartyType")])
Members Crm/CounterpartyType/Client, …/Partner, …/Supplier (one JSON file each, like Crm/Industry/*)

A dimension, not an enum — by the test in AGENTS.md: this is a list the business extends (investor, reseller, authority will follow), and no code branches on it exhaustively. So it is a CrmDimension NodeType with member nodes, read live, and the Edit form renders a picker. If a view ever needs to treat one kind differently (a partner has no sales pipeline), it compares against a member PATH with an explicit default — never an exhaustive switch. There is therefore no stored enum and the StoredEnums "no member numbered 0" rule is not engaged; the guard still runs and must stay green.

The relationship stage stays on the journey. Prospect and Client already exist as Crm/Stage members with relationship: true — the first chip of a bar. That is where the relationship stands; the counterparty type is what kind of organisation it is. A prospect is a Client-type counterparty whose bar starts at Prospect. Keeping Prospect out of the type avoids two places that can disagree (see Decisions).

The data — the eight live roots on memex.systemorph.com

Root Today Target type
ATIOZ Crm/Client · ClientContent Crm/CounterpartyType/Client (to confirm)
HowdenRe Crm/Client · ClientContent …/Client
Notus Crm/Client · ClientContent (created 2026-09-15) …/Partner
PG3 Crm/Client · ClientContent …/Client
PartnerRe Crm/Client · ClientContent …/Client
PearlTechnology Crm/Client · ClientContent …/Client
Scheuchzer Crm/Client · ClientContent …/Client
VIGRe Crm/Client · ClientContent …/Client

Read before planning the run: search nodeType:Crm/Client partitions:all (systemorph MCP) returned exactly these eight on 2026-09-15. Re-read it immediately before Phase B — a ninth root created in between is migrated too, or Phase C is blocked (below).

Why it cannot be one release

A rename in a single release strands the data: the new module no longer declares Crm/Client, the eight roots still name it, their hubs cannot resolve their type, and a node whose type is missing does not error — every read of it waits out the 60 s subscribe timeout and the board renders empty. The data and the code have to be moved in an order where every intermediate state still works.

Phase A — ship both (additive, MINOR: 2.x → 2.(x+1))

  1. New types, old ones kept. Add Crm/Counterparty (NodeType, ownsPartition: true, same views, WithContentType<CounterpartyContent>()), Crm/CounterpartyType (dimension NodeType) and its members. Crm/Client stays exactly as it is.
  2. Both records, one reader. CounterpartyContent is a NEW record in Crm/Source/Records.cs; ClientContent stays, so the eight stored $type: "ClientContent" rows keep deserialising. The views read the node in whatever shape it arrives (silent-failure rule 1): ContentAs< CounterpartyContent> first, else ContentAs<ClientContent> mapped by a PURE Counterparties.From(ClientContent) — never a bare is test. Registering one record under two discriminator names (WithType<CounterpartyContent>("ClientContent")) would avoid the second record, but it is unverified that the registry accepts two names for one type — so the plan does not depend on it.
  3. Every query reads both. The query parser supports an IN list on a field (QueryOperator.In; core's own docs use nodeType:LanguageModel|ModelProvider). During the window:
    • CrmQueries.AllClientsnodeType:Crm/Client|Crm/Counterparty scope:subtree (the roster),
    • PipelineMenu.jsongates.nodeTypes: ["Crm/Client", "Crm/Counterparty"],
    • the Board's roster, the client views registered on BOTH NodeTypes (shared source),
    • CrmQueries.ClientType is split into LegacyClientType and CounterpartyType constants; no call site keeps a bare "Crm/Client" literal (a test pins that — below). This is what keeps the board full while the roots move one by one: a root is visible under either type, never under neither.
  4. Docs and instructions say both names during the window: Guide, /crm skill, crm-assistant, index.json description, README, AGENTS.md module table, .claude/skills/node-files.
  5. Gates, PR, merge, git_hub_sync check → update on Crm (systemorph), recompile every changed type (a Crm/Source edit touches all of them), verify compiledSources == currentSourceVersions and every Tests area green. Only then does Phase B start: its writes need CounterpartyContent registered on the owning hubs.

Phase B — retype the roots (data, on the portal, needs the go)

One Operation Request (Essentials/OperationRequest), filed with the script below, previewed, then approved by a global admin who is not the requester, and run as System:

Plan.Because("Crm/Client → Crm/Counterparty (Crm/Design/CounterpartyMigration)")
    .Node("PG3").Read()
    .Node("PG3").Update(n => n with {
        NodeType = "Crm/Counterparty",
        Content = Counterparties.From(n.ContentAs<ClientContent>(opts), "Crm/CounterpartyType/Client") })
    // … one Read + Update pair per root; Notus with "Crm/CounterpartyType/Partner"
    ;

Why these choices:

Verification — by read-back, per root, and refused on any mismatch:

Check Expected
get @{root}nodeType Crm/Counterparty
content $type CounterpartyContent
website, industry, country, segment, owner, body byte-equal to the before-image
type the member from the table above
name, description, icon unchanged
search path:{root} scope:subtree count unchanged (nothing under the root moved)
search path:{root} scope:subtree nodeType:AccessAssignment unchanged
search nodeType:Crm/Counterparty partitions:all 8 (read against coverage.partitions)
search nodeType:Crm/Client partitions:all 0
/Crm/Portfolio roster and each root's Overview renders, same cards as before

The before-image of every root (the Read() step's output and the node's version history) is what the rollback restores.

Phase C — remove the old type (MAJOR: 2.x → 3.0)

After a soak (see Decisions) and only when nodeType:Crm/Client partitions:all answers 0:

Every file that names Client today

File What changes (A = window, C = removal)
Crm/Client.json, Crm/Client/Source/ClientLayoutAreas.cs, Crm/Client/Test/* A: copied to Crm/Counterparty.json + Crm/Counterparty/…, views registered on both; C: old ones deleted
Crm/Source/Records.cs A: CounterpartyContent, CounterpartyType dimension record, Dimensions.CounterpartyTypeType; C: ClientContent removed
Crm/Source/CrmQueries.cs A: AllClients over both types, constants split; C: one type
Crm/Source/ClientView.cs, Crm/Source/Display.cs A: read either record via the mapper; labels "Counterparty"; C: mapper removed
Crm/Source/StoredEnums.cs comments only; the guard must stay green in both phases
Crm/Board.json, Crm/Board/Source/BoardLayoutAreas.cs, Crm/Board/Test/BoardTests.cs A: roster over both types; tests assert both are in the query; C: one
Crm/PipelineMenu.json A: both types in gates.nodeTypes; C: new only
Crm/Contact/Test/ContactTests.cs A: the "no duplicated MeshNode fields" check also runs over CounterpartyContent
Crm/Agent/crm-assistant.md, Crm/Skill/crm.md, Crm/Guide.md A: both names, the type field and its members; C: one name
Crm/index.json, README.md, AGENTS.md, .claude/skills/node-files/SKILL.md A: descriptions and tables; C: one name
Crm/Design/Ideas.md, Crm/Design/JourneyModel.md none — historical documents, left as written
Crm/Stage/Client.json, Crm/Workflow/*.json (owner: "Client") none — the relationship stage and the step owner are different things from the type

Tests — each one executed, and each one first seen red

In the types' Tests areas (run by the CI mesh gate and by run-node-tests.py Crm):

  1. Both-type rosterCrmQueries.AllClients names Crm/Client AND Crm/Counterparty in Phase A, only the new one in Phase C (asserted on the query string the GUI runs).
  2. No stray literal — no .cs file in Crm/ outside CrmQueries contains "Crm/Client" (the constant is the one place, so Phase C is a one-line change).
  3. Mapper is totalCounterparties.From(ClientContent) carries every property of ClientContent (reflection over the record, so a field added later fails the test instead of being dropped by the migration).
  4. Round-trip — a CounterpartyContent with a type member serialises and reads back through the hub's options, camelCase, with $type: "CounterpartyContent".
  5. Either shape renders — the Overview builder renders the same facts for a ClientContent node and its mapped CounterpartyContent twin.
  6. Menu gatePipelineMenu gates on both types in Phase A.
  7. StoredEnums — unchanged guard, green over the new assembly.
  8. Live (in-mesh, CI mesh only): retype a scratch root the test created as a Space, read it back, assert it is returned by the roster query, delete it. A viewer without Update gets a SKIP that says why, never a pass.

Each new test is committed only after watching it fail against the unfixed code (AGENTS.md, gate 4).

Rollout order and rollback

Step Rollback
1. Phase A PR → merge → sync Crm on systemorph → recompile → verify revert the PR and sync again — nothing references the new types yet
2. Re-read the root list; file the Operation Request; preview; approve; run a second Operation Request with the inverse Update (back to Crm/Client + the recorded ClientContent before-image), or restore_version per root from its version history
3. Verify (table above); leave both types live for the soak as step 2
4. Phase C PR (3.0) once nodeType:Crm/Client counts 0 everywhere the CRM is installed only by re-shipping 2.x — which is why step 4 waits for the zero count and the soak

Independent of this plan: creating a new counterparty

Today a top-level create of a Crm/Client is refused for everyone, platform admins included: core grants the "any signed-in user may create a top-level node" rule to Space only, and the partition checks read ownsPartition from static node definitions, which an in-mesh type is not. A core fix is in flight. Until it lands, the working path is the two-step in the Guide (create a Space, retype its root, grant the team) — and it applies to Crm/Counterparty exactly as it does to Crm/Client.

Decisions this needs from Roland

  1. NamesCrm/Counterparty + Crm/CounterpartyType, or Crm/Counterparty + Crm/Relationship (avoids a "type" dimension on a record that already has a node type)?
  2. Members — Client, Partner, Supplier to start; is Prospect a type (the plan says no: it is the first stage of a Client's journey)?
  3. Per root — confirm the seven clients are Client (ATIOZ in particular) and Notus Partner.
  4. Board — do partners and suppliers appear on the portfolio roster, or only clients (with a separate partner roster)? Do they get deals (a supplier contract, a partner offer)?
  5. Approver — the Operation Request must be approved by a global admin who did not file it (mkleiner, sglauser or rbuergi, whichever did not request).
  6. Soak — how long both types stay live before Phase C (proposed: one week, and not before the zero count).
  7. Other installations — any installation besides memex.systemorph.com holding Crm/Client roots must run Phase B before it takes 3.0; name them, or confirm there are none.
Reconnecting…
The server was updated. Reloading the page to pick up the latest version.