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))
- New types, old ones kept. Add
Crm/Counterparty(NodeType,ownsPartition: true, same views,WithContentType<CounterpartyContent>()),Crm/CounterpartyType(dimension NodeType) and its members.Crm/Clientstays exactly as it is. - Both records, one reader.
CounterpartyContentis a NEW record inCrm/Source/Records.cs;ClientContentstays, 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, elseContentAs<ClientContent>mapped by a PURECounterparties.From(ClientContent)— never a bareistest. 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. - Every query reads both. The query parser supports an IN list on a field (
QueryOperator.In; core's own docs usenodeType:LanguageModel|ModelProvider). During the window:CrmQueries.AllClients→nodeType:Crm/Client|Crm/Counterparty scope:subtree(the roster),PipelineMenu.json→gates.nodeTypes: ["Crm/Client", "Crm/Counterparty"],- the Board's roster, the client views registered on BOTH NodeTypes (shared source),
CrmQueries.ClientTypeis split intoLegacyClientTypeandCounterpartyTypeconstants; 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.
- Docs and instructions say both names during the window: Guide,
/crmskill, crm-assistant,index.jsondescription, README, AGENTS.md module table,.claude/skills/node-files. - Gates, PR, merge,
git_hub_synccheck → update onCrm(systemorph), recompile every changed type (aCrm/Sourceedit touches all of them), verifycompiledSources == currentSourceVersionsand everyTestsarea green. Only then does Phase B start: its writes needCounterpartyContentregistered 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:
- Update, never create. A partition root cannot be re-created without dropping its partition,
and "create, never update" is the rule for making a Space — retyping an existing root is an
update by definition. It must be a FULL node update (
n with { … }on the node as read):patchdoes not write indexed columns (nodeType,mainNode), so a patched retype leaves the row sayingCrm/Clientto every query. Never carryversion,lastModifiedorlastModifiedByby hand. - As System, through the owning hub. AGENTS.md ("Two facts about the client partitions"):
the migration retyped in place with
CreateOrUpdateNodeunder System, which re-indexes the cross-partition mirror; a plain import does not. Measured 2026-09-15: a full MCPupdateretyping the newNotusSpace root toCrm/Clientwas returned bynodeType:Crm/Client partitions:allstraight away — the owning-hub update path re-indexes. The Operation Request is still the vehicle here for its preview, its approval bound to the script's hash, and its per-step activity log. - Preview first. It probes each root (exists, type, child count) and renders the plan table. The preview is not the result — see verification.
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:
- delete
Crm/Client.json+Crm/Client/, dropClientContentand the mapper, - queries back to a single
nodeType:Crm/Counterparty,PipelineMenugate to the new type only, - docs name only Counterparty; bump
Crm/index.jsoncontent.versionto3.0— removing a NodeType breaks any installation still holdingCrm/Clientroots, and the registry serves this repo to every installation, so every installation with CRM data must have run Phase B first. That is a floor, and the release note says so.
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):
- Both-type roster —
CrmQueries.AllClientsnamesCrm/ClientANDCrm/Counterpartyin Phase A, only the new one in Phase C (asserted on the query string the GUI runs). - No stray literal — no
.csfile inCrm/outsideCrmQueriescontains"Crm/Client"(the constant is the one place, so Phase C is a one-line change). - Mapper is total —
Counterparties.From(ClientContent)carries every property ofClientContent(reflection over the record, so a field added later fails the test instead of being dropped by the migration). - Round-trip — a
CounterpartyContentwith atypemember serialises and reads back through the hub's options, camelCase, with$type: "CounterpartyContent". - Either shape renders — the Overview builder renders the same facts for a
ClientContentnode and its mappedCounterpartyContenttwin. - Menu gate —
PipelineMenugates on both types in Phase A. - StoredEnums — unchanged guard, green over the new assembly.
- 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
- Names —
Crm/Counterparty+Crm/CounterpartyType, orCrm/Counterparty+Crm/Relationship(avoids a "type" dimension on a record that already has a node type)? - Members — Client, Partner, Supplier to start; is Prospect a type (the plan says no: it is the first stage of a Client's journey)?
- Per root — confirm the seven clients are
Client(ATIOZ in particular) and NotusPartner. - 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)?
- Approver — the Operation Request must be approved by a global admin who did not file it (mkleiner, sglauser or rbuergi, whichever did not request).
- Soak — how long both types stay live before Phase C (proposed: one week, and not before the zero count).
- Other installations — any installation besides memex.systemorph.com holding
Crm/Clientroots must run Phase B before it takes 3.0; name them, or confirm there are none.