Skip to content

test(crm): run the CRM relationships scenario through the built CLIs - #315

Merged
leemour merged 4 commits into
mainfrom
test/crm-relationships
Nov 9, 2026
Merged

leemour merged 4 commits into
mainfrom
test/crm-relationships

Conversation

@leemour

@leemour leemour commented Nov 7, 2026 •

Copy link
Copy Markdown
Member

Task

PR 9 of the CRM relationships plan (TASK-925, plan §8). It adds an end-to-end synthetic acceptance check that drives the built CLIs from the outside: b24's binary, memo's binary and memo's stdio MCP server.

Why

The owner asked for one picture of a client and its people that every answer backs with a source. The cli-core, bitrix24-cli and cli-memo PRs each test their own part. This check asks whether the parts make that picture together, and whether the docs' examples run as written. It feeds the decision to release the stack.

What

performance/crm/relationships/. The README covers why it was run, the exact commands, the results, what it showed and what was not measured.

  • run.mjs runs the Acme/Northstar scenario and the plan's 13 checks, and writes each check's evidence to result.json.
    • Its only inputs are paths: the npm install prefix, the two docs checkouts and an optional build description. A rerun after the release needs no edits.
    • It records versions, lockfile integrities and the executables' SHA-256.
  • portal.mjs is a strict fake Bitrix24 portal, preloaded into b24's real binary as a fetch hook, behind the shared external-I/O guard.
    • It answers only the REST methods, parameters and fields in the docs, and refuses anything else.
    • b24 does not publish its own fake.
  • Seeding. Telegram, mail and calendar rows go in through cli-core's store calls, as an import makes them. The owner's identity link and separation are store calls too, because memo has no command for them.
  • The owner's writes. memo counts a write as the owner's only from a terminal (src/author.ts). So the owner's memo writes run in a util-linux script pseudo-terminal.
  • Groups follow the owner's new model. work is the Bitrix24 account and the work Telegram account. personal is the personal Telegram account and the mailbox. Check 11 runs each lock both ways, with a control: every item a lock hides is found without the lock.
  • performance/crm/README.md is added and linked from performance/README.md. There is a CHANGELOG line under Unreleased.

This is a pre-release run on unreleased builds, on the final heads. None of them were published:

Testing

node performance/crm/relationships/run.mjs <prefix> <result.json> --memo-docs <dir> --b24-docs <dir> --build <file>: 12 of 13 checks pass.

  • Check 1 fails, as expected. search people Alice finds three persons after her identities join. cli-core's mergePerson moves the identities and never retires the person it empties (src/store/sqlite/person-links.ts:165). The code is the same on main. It waits for the Memory/Search stream's fix, and the check stays as it is.
  • Check 10 now passes. The owner's role edit makes one override, which hides the CRM's contact row. After two syncs, Alice reads only manager:confirmed at Acme, there is still one override link, and nothing churns.
  • Check 11 runs on the explicit-groups model.
  • Check 13 ran 35 of 42 examples from the updated docs, and all of them work. The other 7 have placeholders or install an OS timer.

Also found: memo_tools_search needs every word of a query to appear, so "who reports to whom" finds nothing.

Other checks run:

  • biome check is clean.
  • rumdl with the repository config is clean.
  • pnpm docs:check passes.
  • gitleaks found nothing.

Not measured: a live Bitrix24 portal, OAuth, a real Zoom recording, identity matching beyond exact email, and scale.

Rerun on the published packages (2026-11-09)

b24 0.9.0 and memo 0.131.72 on cli-core 0.619.0, from npm: 12 of 13 pass. Check 1 passes now. Check 11 fails on a real gap: with "deals": "deny" saved, an unattended memo search all lists the denied deal while deals context refuses it. Fix: cli-memo #777. The runner now fills the docs' relationships update 4 examples with a real person-on-project link.

🤖 Generated with Claude Code

leemour added a commit to WireCatLabs/cli-core that referenced this pull request Nov 8, 2026
… once (#2664)

## Task

Let the owner see and undo merges of people and split a person, and fix
duplicate people after a merge: the CRM acceptance run
(WireCatLabs/cli-testing#315, check 1) found three Alices after the
owner linked her CRM contact and Telegram identity.

## Why

- Exact-email matches merge people automatically, so every merge must be
undoable, and the owner needs to split a person whose identities do not
belong together.
- `mergePerson` moves every identity and leaves the emptied person row
in place. `taskPeople`, which memo's `people list` and (in memo's CRM
branch) `search people` read, still listed that empty row, so one person
showed two or three times.

## What

No store migration, no new column. Everything is read from
`identity_link_events`.

- **What counts as a merge** is judged by what the move did (`mergeSql`,
`merged-persons.ts`):
- A merge empties its person: every other identity on it then moves the
same way. An unlink leaves the rest behind.
  - `unlink`, `split` and `undo-merge` moves are never merges.
- `unlinkIdentity` now records method `unlink`. Older unlinks (`manual`)
are told apart by the same outcome rule. So is a merge into the owner's
person, which has no move of its own.
- **`PeopleStore.merges(uid)`** groups the identities now on the person
by the merge that brought each one there. An undo that brought an
identity back restores the merge it had before. Each group has a stable
id `merge:<to>-<from>`, with the identities, the method, who did it and
when. `parseMergeId`, `mergeId` and the type `PersonMerge` are exported.
- **`PeopleStore.undoMerge(to, from, by)`** is owner only
(`permission_error`, `identity.owner-only`).
- It moves the identities of that merge that are still on `to` back to
`from`, whose row was never deleted.
  - It leaves alone identities the owner split or unlinked off since.
  - A chain of merges undoes newest first.
- It writes owner events, so the person-level separation guard keeps the
people apart afterwards.
- It refuses with `conflict` / `identity.stale` when an identity was
merged on into another person.
  - It answers `not_found` when nothing of the merge is left.
- **`PeopleStore.split(uid, identities: IdentityRef[], by)`** is owner
only. It moves the chosen identities to one new person, with owner
events. It refuses an empty list, all of the identities, or an identity
that is not this person's.
- On both: `by` is `"owner"` only from a path that established the owner
ran it. No CLI or MCP command calls them yet.
- **A merged-away person** has no identity, has a merge out of it, and
is never the owner.
- `taskPeople` skips it and counts its tasks for the person it is now.
It resolves only merged-away people a task is assigned to, and counts in
one grouped pass. With 5,000 emailed CRM contacts (5,000 merged-away
persons) it takes 21 ms; the old query took 11 ms.
- `personByUid` (with `mergedInto`), `personSeenInAccounts`, name
resolution and the mention drain follow its latest merge.
- Rows naming it are not rewritten, so undo loses nothing. The readers
that filter by a person take `idsOf` (the person plus everyone merged
into them): the memories list and search, the facts list, the notes list
and annotations, relationships (`relations`, `listRelations`), the
imported documents' `person:` filter, the task board's `assignee:`
filter and `preparation`.
- Assigning a merged-away uid to a task the current person already holds
adds no second assignment.
- `syncReported` withdraws what a source reported on a merged-away
person once it reports the relationship on the current one, so it shows
once.
- **Account purge**:
- A kept merged-away person also keeps the identities that were merged
out of it (not ones unlinked or split off), so it stays merged away.
  - A merged-away person nothing points at is deleted with the account.
- Docs: the contacts section in `docs/dev/ARCHITECTURE.md`, and the
method notes in the schema reference.

## Testing

`src/store/person-merges.test.ts`, 17 tests on fresh sandbox stores:
- undo restores the uid and the identities;
- the same address coming in again does not rejoin them after undo;
- merge, undo, merge again, then undo again;
- a chain undone newest first;
- undo refused after a merge on into a third person;
- a merge into the owner of a person with several identities is listed
and resolves;
- undo after a split moves only what is still there;
- nothing to undo once the owner unlinked it all;
- an old `manual` unlink is not a merge;
- undo and split are owner only, plus the split refusals;
- split, then the same address does not rejoin them;
- the CRM scenario gives one person in `taskPeople`, and a name-only
person stays listed;
- old uids resolve through `personByUid` and `personSeenInAccounts`;
- a memory, fact, note and task written about the old uid stay on it and
are read for the current person (lists, board filter, task count);
- `preparation` reads rows written before the merge;
- a source's relationship reported again on the merged person shows
once;
- a document linked to the old uid shows under the current person's
`person:` filter;
- assigning the old uid to a task the current person holds is a no-op;
- a purge keeps a referenced merged-away person hidden and resolving,
and drops an unreferenced one.

Also run locally: 37 store and service suites covering facts, knowledge,
notes, memories, relations, task board, tasks, purge, separation, same
email, mentions, persons, CRM and private people (all green), plus `pnpm
typecheck`, `pnpm lint` and `pnpm docs:check`. The full suite runs in
CI.

## Performance

I ran a one-off script, not committed: `openStore` on a temp file, then
`savePeople` for 20,000 Telegram identities, so 20,001 persons. The
owner then made 5,000 `linkIdentities` merges (25,000 events, 5,000
merged-away persons), and 200 tasks were assigned. Each figure is the
mean of 3 warm runs on an AMD Ryzen AI 9 HX 470 with Node 24.19.

| Read | ms |
| --- | --- |
| `taskPeople({ limit: 5000 })` (memo `people list` / `search people`) |
71 |
| count of merged-away persons (the rule on every person row) | 9 |
| `mergedAssignees` (pairs for `taskPeople`) | 2 |
| `personByUid` of a merged-away uid | 0.24 |
| `merges(uid)` | 0.03 |
| `accounts.purge` of the whole 20,000-identity account (one-off) | 272
|

`EXPLAIN QUERY PLAN` of `taskPeople`:
- Every correlated subquery of the merge rule is an index search. It
uses `identity_links_by_person_id`,
`identity_link_events_by_from_person_id` and `_by_to_person_id`
(`rowid<?`), the covering `identity_link_events_by_identity_id`
(`rowid<?` / `rowid>?`) and the integer primary key.
- The task counts are materialized once (`MATERIALIZE counts`, then
`SCAN c LEFT-JOIN`).
- The only scan is `SCAN p` over persons, which the list needs anyway.

No result is over 200 ms except the one-off whole-account purge, so I
made no change.

## Limits

- Not changed to read merged-away rows yet:
- the task board's assignee facet counts, which still list an old uid
apart;
  - the links list (`notes.links`);
  - person context's other parts;
  - ref summaries, which name the old person.
- A meeting-participant or mention link that moves one identity out of a
person with others is not a merge, so `undoMerge` does not list it.
`split` covers that case.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
leemour and others added 3 commits November 9, 2026 19:19
The owner asked for one source-backed picture of clients and their people.
This adds the plan's acceptance scenario (TASK-925, plan section 8): Acme
and Northstar, synced by b24's real binary from a strict fake Bitrix24
portal, then read through memo's real binary and stdio MCP server.

- portal.mjs is a fetch preload that answers only documented REST
  methods, parameters and fields, and refuses anything else.
- Telegram, mail and calendar rows come in through cli-core's store calls,
  as an import makes them.
- The owner's memo writes go through a util-linux pseudo-terminal. memo
  takes a write as the owner's only from a terminal.
- Paths are the only inputs (install prefix, docs checkouts, an optional
  build description), so a rerun on released versions needs no edits.
- Versions, lockfile integrities and executable SHA-256 are recorded.

The first result is a pre-release run on unreleased local builds (cli-core
11 of 13 checks pass. Two fail on cli-core bugs:

- Merged-away persons stay listed in people list (person-links.ts:165).
- An owner role edit on a CRM-reported link stays "reported"
  (relation-proposals.ts:131).

Not measured: live Bitrix24, OAuth, a real Zoom recording, identity
matching beyond exact email, scale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The run now uses cli-core #2642 20cc10fb0 (with #2641 6c411e0eb), merged
locally with #2632 e04a396bb; bitrix24-cli #39 66137f596; and cli-memo #699
981c50d97 on #698 e41cbabda. The docs examples are checked against the
updated memo docs.

12 of 13 checks pass. Check 10 now passes: the owner's role edit is its
own confirmed link. Check 1 still fails on cli-core's mergePerson leaving
emptied persons behind (person-links.ts:165); now that search people finds
people with no identity, those leftovers show there too. It stays failing
until the Memory/Search stream's fix lands.

Check 11's control for "Grace is found without a lock" now asks for at
least one result. The duplicate person it also finds is check 1's subject.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The run now uses cli-core #2642 977d0ae29 (with #2641 2c946fa2b), merged
locally with #2632 37e93c7f2; bitrix24-cli #39 66137f596; and cli-memo #699
c583358cf on #698 760d846bd.

Check 10 follows the new rule: an owner's role edit on a CRM-reported link
makes one override that hides the CRM row. After the edit and two syncs,
it asserts:

- Alice reads only `manager:confirmed` at Acme.
- There is exactly one override link, left unchanged.
- No proposals or links churn.
- Bob's owner-set decision-maker role is untouched.

12 of 13 checks pass. Check 1 still waits for the Memory/Search stream's
mergePerson fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…0.9.0 and memo 0.131.72

12 of 13 pass. Check 1 passes now. Check 11 fails: an unattended memo search all lists a deal memo's saved
permissions deny (fixed in cli-memo #777). The runner gives the docs' 'relationships update 4' examples the person's
place on a project, since an earlier example of the page adds one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@leemour
leemour marked this pull request as ready for review November 9, 2026 18:31
@leemour
leemour force-pushed the test/crm-relationships branch from 1674106 to e8f54b2 Compare November 9, 2026 18:31
@leemour
leemour merged commit bb6750e into main Nov 9, 2026
1 check passed
leemour added a commit to WireCatLabs/cli-memo that referenced this pull request Nov 9, 2026
…ls and leads (#777)

## Why
[cli-testing #315](WireCatLabs/cli-testing#315),
rerun on the published memo 0.131.72 and b24 0.9.0, failed its check 11:
with `"deals": "deny"` in memo's saved permissions, an unattended `memo
search all` (an agent's shell, no terminal) returned `deal:1`, while
`memo deals context` refused the same deal. `src/search/denied.ts` says
search all, the contexts and `relationships list` leave such kinds out
(SEC-151); search all only checked the MCP server's levels.

## What
- `lanesFor` takes the CRM kinds memo's saved permissions deny and hides
them, with the denying key in `issues`.
- `search all` passes them for every run that is not the owner at a
terminal (`whoRuns`). The owner's own search keeps finding everything
(#739, `src/crm.test.ts` "whatever the permissions say" still passes).
- Changelog: Unreleased → Security.

## Testing
- New test in `src/mcp/crm-search.test.ts`: a command-line run with
`deals: deny` saved returns only the lead and names the denial; it fails
without the fix.
- `pnpm typecheck`, `pnpm lint` (8 warnings, same as main), `pnpm
docs:check`, full vitest: 132 files, 1317 tests pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
leemour added a commit that referenced this pull request Nov 11, 2026
…task phrases (#358)

## Task

Rerun the CRM relationships scenario (#315) on released b24 and memo,
and measure cli-core's new tools search
(cli-core #2878) before and after.

## Why

- Check 11 failed on 2026-11-09 (a denied deal showed in an unattended
`search all`). Its fix, cli-memo #777, shipped since.
- Check 12 found that `memo_tools_search` finds only 6 of 13 commands
when an agent words a task; #2878 changes the ranking.

## What

- `performance/crm/relationships`: rerun on b24 0.13.0 and memo 0.131.84
passes **13 of 13**; check 11 holds.
Three runner fixes, none a product bug: choose the CRM user whose deals
b24 copies, the fake portal answers
`user.admin`, and the owner keeps one contact that memo now hides as
test data (cli-core #2862).
- `performance/agents/tools-search`: 13 short and 13 task phrases over a
real stdio `memo mcp serve`.
Task phrases found **6 → 13 of 13**, first place 6 → 9, top 3 6 → 10;
short phrases unchanged (13, 9, 12).
The after run used local builds (memo branch on cli-core main 0.643.0,
unpublished); the README says to rerun it
  on the release.
- Linked from `performance/agents/README.md`; CHANGELOG entries under
Unreleased.

## Testing

- Both scripts ran end to end in temp folders with no keyring and no
real account.
- `pnpm lint`, `pnpm docs:check`, rumdl on the changed pages.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant