@classytic/crm 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,505 +1,531 @@
1
- # Changelog
2
-
3
- All notable changes to `@classytic/crm` are documented here.
4
-
5
- ## 1.1.1 - 2026-08-20
6
-
7
- ### Fixed
8
- - `RmaEventSchema` (and equivalent CRM event schemas) declare `type: 'object'` so hosts can register them without a cast.
9
-
10
- ## 1.1.0 - 2026-08-17
11
-
12
- ### Changed
13
- - `tenant` config key replaces `tenantFieldType` across all models and engine (`assertNoLegacyTenantKeys` guard applied)
14
- - `OpportunityRepository` gains new query methods
15
- - Model schemas standardised (consistent field ordering, injected tenant field)
16
-
17
- ## 1.0.0 - 2026-07-29
18
-
19
- ### Fixed — Lead and Contact forked one mailbox into two rows (BEHAVIOURAL, 2026-08-08)
20
-
21
- Same bug class as the suppression fix below, in the two collections that hold live
22
- customer identity. Neither had a private normalizer — they had **no** canonicaliser
23
- at all, and relied on a partial-unique `(organizationId, email)` /
24
- `(organizationId, contact.email)` index over a field whose only folding was mongoose
25
- `lowercase: true`. That does not IDNA-fold, so `ada@bücher.de` and
26
- `ada@xn--bcher-kva.de` were two distinct strings, the index admitted both, and one
27
- mailbox became two leads with no error. Reproduced before the fix; the case control
28
- (`ADA@…` → refused) proves the index itself always worked — the KEY was wrong.
29
-
30
- **The fix is at the SCHEMA, not only at the repository verbs.** `@spinekit/crm`
31
- mounts both resources through arc's mongoose CRUD adapter, so `POST /crm/leads`
32
- calls the inherited `Repository.create()` and `createLead`'s pre-check never runs. A
33
- verb-only fix would have left the live HTTP route forking while the verb tests went
34
- green. `Lead.email`, `Contact.contact.email` and `Contact.contact.personalEmail` now
35
- carry a `canonicalEmailSetter` (`src/core/email-identity.ts`) that applies
36
- `normalizeEmail` from **`@classytic/contact/email`**, imported directly. Mongoose
37
- runs string setters on query filters too, so reads fold with writes.
38
-
39
- - The setter **never throws** — it degrades to the legacy `trim().toLowerCase()`
40
- for anything the kernel refuses. A throw inside a setter surfaces during query
41
- casting as an opaque `CastError`, i.e. a 500 on a READ, and degrading also means
42
- nothing already stored is re-keyed by a rule that cannot explain its output.
43
- - `createLead` / `createContact` **do throw** — new `LeadEmailFormatError`
44
- (`LEAD_EMAIL_INVALID`) and `ContactEmailFormatError` (`CONTACT_EMAIL_INVALID`),
45
- both 400, nothing written. An uncanonicalisable address defeats the very index it
46
- is meant to arm: broken spellings never collide, so the same input inserts
47
- without limit while every call reports success.
48
- - Both verbs canonicalise **explicitly** before querying rather than relying on the
49
- schema setter to fold the filter. A pre-check resting on an invisible field option
50
- silently WIDENS the day someone edits the schema, and a widened filter does not
51
- fail — it matches nothing and answers "no duplicate".
52
-
53
- **Behaviour change worth naming:** `LeadService.convert()` passes the lead's stored
54
- email into `createContact`, so converting a lead whose address cannot be
55
- canonicalised now fails instead of minting a Contact whose only identity field is
56
- unusable. Dropping the address silently would be the worse half of that trade.
57
- Reachability was checked, not assumed: be-prod gates CRM writes with `requireAuth()`
58
- and there is no public lead-capture route, so the 400 reaches an operator or an
59
- importer, never an anonymous form visitor.
60
-
61
- New export `crmEmailKey(email): string | null`. The setter and the two throwing
62
- helpers stay internal — a host must not re-apply either.
63
-
64
- **Stored rows are NOT migrated.** `scripts/lead-contact-email-key-drift.mjs`
65
- (shipped, read-only; `npm run drift:email-keys`) classifies both collections as
66
- `unchanged` / `rekey` / `collision` / `uncanonicalisable`, prints a distinct banner
67
- for an absent collection and for one with rows but no emails, and writes nothing.
68
- `collision` is the fork: two rows, one mailbox, and the unique index means a naive
69
- `$set` fails outright — merging them and re-pointing the loser's opportunities,
70
- activities and subscriptions is a human decision.
71
-
72
- Pinned by `tests/integration/lead-contact-email-idna.scenario.test.ts` (15 cases,
73
- covering both the verb path and the arc-CRUD path) and
74
- `tests/integration/email-key-drift-tool.test.ts` (7 cases that falsify the report
75
- itself against planted rows of every class).
76
-
77
- **Not done here (deliberate):** no schema `validate` on the email fields, so the
78
- arc-CRUD route still accepts an uncanonicalisable address and stores it
79
- legacy-folded. Adding one would turn a currently-succeeding admin write into a 400
80
- and would break `convert()` on rows that already exist. That is a separate decision.
81
-
82
- ### Fixed — the suppression list keyed one mailbox as two (BEHAVIOURAL)
83
-
84
- `SuppressionRepository` held a private `email.trim().toLowerCase()`. It does not
85
- IDNA-canonicalise, so `ada@bücher.de` and `ada@xn--bcher-kva.de` — one host in two
86
- encodings — were two keys: an address suppressed under one spelling stayed
87
- **sendable** under the other, and nothing errored. The send succeeded and looked
88
- correct. A do-not-contact list is frequently a legal obligation, so this is worse
89
- than an ordinary dedup miss.
90
-
91
- All four verbs (`suppress`, `isSuppressed`, `filterSuppressed`, `unsuppress`) now
92
- canonicalise through `normalizeEmail` from **`@classytic/contact/email`**, imported
93
- directly — no re-export, no local wrapper. `@classytic/contact >=0.1.0` is a new
94
- **peer** dependency.
95
-
96
- `normalizeEmail` refuses what it cannot canonicalise, and the two sides spend that
97
- refusal differently:
98
-
99
- - **Writes throw** the new `SuppressionEmailFormatError` (400,
100
- `SUPPRESSION_EMAIL_INVALID`) and store nothing. A malformed row is a suppression
101
- that can never match — it suppresses nothing while reporting success. `unsuppress`
102
- throws too: it re-enables sending, so a `false` would read as "already removed".
103
- - **Reads fail CLOSED.** `isSuppressed()` returns `true` and `filterSuppressed()`
104
- reports as blocked for an address with no canonical form. They do not throw —
105
- `filterSuppressed` is a batch verb, and one malformed recipient must not abort a
106
- fanout to thousands of valid ones.
107
-
108
- New export `suppressionEmailKey(email): string | null` — the key a caller must test
109
- `filterSuppressed()` membership against, and the only way to tell "unparseable"
110
- apart from "on the list".
111
-
112
- **Stored rows are NOT migrated.** A row written under the old rule in a
113
- non-canonical form is now unreachable. `scripts/suppression-key-drift.mjs` (shipped,
114
- read-only) classifies every row as `unchanged` / `rekey` / `collision` /
115
- `uncanonicalisable` and writes nothing; `collision` rows cannot be moved by an
116
- `updateOne` at all, because `(organizationId, email)` is unique and another row
117
- already holds the target key. Rewriting a suppression list has legal consequences —
118
- run the report, decide by hand.
119
-
120
- Pinned by `tests/integration/suppression-idna.scenario.test.ts` (12 cases).
121
-
122
- ### Changed
123
- - **License:** relicensed from MIT to the **Classytic Source-Available License** (Community & Commercial). Evaluation/development use remains free; production use now requires a commercial license from Classytic LLC. See `LICENSE`.
124
- - Major bump marks the license change; versions published before 1.0.0 remain under their original MIT terms.
125
-
126
- ## 0.10.1 - 2026-07-22 (unpublished)
127
-
128
- ### Changed
129
- - Internal: `InProcess*Bus` is now a thin subclass of `@classytic/primitives/event-infra`'s shared `InProcessEventBus` (dedup / error-isolation / close semantics unchanged); `MemoryOutboxStore` re-exported from the shared runtime where applicable. No public API, event-name, or wire-envelope change. Requires `@classytic/primitives >=0.13.0`.
130
-
131
- ## [0.10.0] — unpublished (2026-07-22)
132
-
133
- ### Changed — `dispose()` renamed to `destroy()` (fleet lifecycle convention)
134
-
135
- - **`engine.destroy()`** is the canonical teardown verb (matches
136
- manufacturing / purchase / order / catalog / cart). `dispose()` remains
137
- as a deprecated one-line alias delegating to `destroy()` so hosts don't
138
- break mid-migration; it will be removed in a future minor.
139
- - **Behavioral fix**: teardown now closes the transport ONLY when the
140
- engine created it itself (`InProcessCrmBus`). Previously `dispose()`
141
- closed a host-injected `eventTransport` too — the host's bus is the
142
- host's to close.
143
- - Destroy is idempotent — a second call (or `dispose()` after `destroy()`)
144
- is a no-op. Mongoose models/connection are untouched (host-owned).
145
- - Lifecycle coverage in `tests/integration/engine-lifecycle.test.ts`.
146
-
147
- ## [0.9.0] — unpublished (2026-07-15)
148
-
149
- ### Changed — mongokit 3.22 adopted (peer floors: mongokit >=3.22.0, repo-core >=0.13.0, primitives >=0.11.0)
150
-
151
- - **Seven transition verbs ride `applyTransition`** (activity, campaign,
152
- enrollment exit + generic, lead, sequence, subscription, opportunity):
153
- machine pre-flight + CAS + accurate race diagnosis via the machines'
154
- existing errorFactories. All `this as any` casts at those sites are gone
155
- (native method).
156
- - **FIXED a real concurrency defect**: five sites built `statusHistory` by
157
- read-modify-write (`[...(existing.statusHistory ?? []), entry]`) inside
158
- the CAS patch — concurrent writers could drop history entries. Entries
159
- now ride `$push` via the `push` arg, byte-identical shapes preserved.
160
- - **`EmailEvent` log fence enforced** — "append-only" was a doc comment;
161
- `appendOnlyPlugin` now refuses every mutation path (405
162
- `APPEND_ONLY_VIOLATION`), with `deleteMany` allowed for host-gated GDPR
163
- erasure of a recipient's rows.
164
- - DELIBERATELY unchanged: `enrollment.advanceStep` (non-status
165
- `currentStepIndex` CAS with null semantics) and opportunity's stage-move
166
- claim — correct raw `claim` usage, not transition boilerplate.
167
-
168
- ## [0.8.0] — 2026-07-11
169
-
170
- ### Added — engagement-based lead scoring
171
- - **`ILeadEngagement` on the Lead** (`{ opens, clicks, replies, activities, lastEngagedAt? }`, nested default `{}` so old rows read as zeros). Behavioural counters, distinct from firmographic fields.
172
- - **`LeadRepository.recordEngagement(id, signal, ctx)`** where `signal ∈ 'open'|'click'|'reply'|'activity'` (`LeadEngagementSignal`). Atomic `$inc` on the matching counter + `lastEngagedAt` via `findOneAndUpdate`, then `recomputeScore`. Hosts feed it off `crm:message.received` (reply), `crm:activity.*` (activity), and email open/click tracking.
173
- - **`defaultLeadScorer` now weighs engagement** on top of firmographic fit: reply ×25 · click ×8 · open ×2 · activity ×5, capped at +80. `LeadInput` picks up `engagement`. Pure/deterministic — no wall-clock. Absent engagement scores as zero (score unchanged for pre-existing leads until a signal lands).
174
-
175
- ## [0.7.0] — 2026-07-05
176
-
177
- ### Added — org-wide email suppression list
178
-
179
- - **`Suppression` entity** (`crm.repositories.suppression`, model
180
- `Suppression`): global per-org "never email this address again" list —
181
- the gate hosts consult before EVERY outbound email. Unsubscribes on
182
- `Subscription` stay per-campaign; suppression is org-wide.
183
- - `suppress(email, { reason, source?, subjectKind?, subjectId?, metadata? }, ctx?)`
184
- — idempotent `getOrCreate` on the unique `(organizationId, email)`
185
- index (no check-then-save); emits `crm:suppression.added` only when
186
- THIS call inserted the row.
187
- - `isSuppressed(email, ctx?)` → boolean.
188
- - `filterSuppressed(emails, ctx?)` → `Set<string>` of the suppressed
189
- subset — ONE `$in` query for the campaign-fanout hot path.
190
- - `unsuppress(email, ctx?)` — atomic delete; emits
191
- `crm:suppression.removed` only when a row was actually removed.
192
- - All verbs normalize emails (trim + lowercase; schema stores lowercase).
193
- - **`SUPPRESSION_REASONS`** (`'bounce' | 'complaint' | 'unsubscribe' |
194
- 'manual'`) — the first three `satisfies readonly EmailEventKind[]` (P6),
195
- so a rename in `EMAIL_EVENT_KINDS` fails compilation here.
196
- - **Events** `crm:suppression.added` / `crm:suppression.removed` — Zod
197
- catalog definitions included (no-drift invariant stays green);
198
- `organizationId` rides event meta as usual.
199
- - **Validators** `suppressSchema` / `unsuppressSchema` /
200
- `suppressionFilterSchema`.
201
-
202
- ### Added — reply-detection exit verb
203
-
204
- - **`EnrollmentRepository.exitActiveOnReply(subjects, ctx?)`** — the ONE
205
- method a host's `crm:message.received` subscriber calls: finds ACTIVE
206
- enrollments for the given `{ subjectKind, subjectId }` list, batch-loads
207
- their sequences in a single `$in` query, skips those with
208
- `replyDetectionEnabled === false`, and exits the rest via `exit(id,
209
- 'reply')` (each exit emits `crm:enrollment.exited`). Lost CAS races
210
- (concurrent pause/complete/exit) are skipped, not failed. Returns the
211
- enrollments this call exited.
212
- - `EnrollmentRepositoryDeps` now carries `sequences: SequenceRepository`
213
- (same cross-repo deps idiom as `OpportunityRepository.pipelines` /
214
- `MessageRepository.conversations`) — wired automatically by
215
- `createCrm()`; only hosts constructing `EnrollmentRepository` by hand
216
- need to pass it.
217
-
218
- ## [0.6.0] — 2026-07-02
219
-
220
- Breaking-in-behavior release (ships as MINOR per house convention):
221
- outbox-first, session-safe event dispatch — the previously deferred
222
- PACKAGE_RULES §P8/§P8.1 work.
223
-
224
- ### Added — unit-of-work contract (P8.1, shared with refit/review/cart/invoice)
225
-
226
- - **`engine.withTransaction(ctx, body)`** — runs `body` inside a MongoDB
227
- transaction with post-commit event discipline: events dispatched through
228
- `txCtx` are queued under `ctx[PENDING_EVENTS]` and flushed to the
229
- transport only AFTER commit; the queue resets on retry attempts; an abort
230
- leaks nothing to in-process subscribers. Reuses the caller's session (and
231
- queue) when `ctx.session` is already set. Standalone-Mongo dev boots opt
232
- in via the existing `allowNonTransactional: true`.
233
- - **`PENDING_EVENTS`** (`Symbol.for('@classytic/crm:pendingEvents')`) +
234
- **`flushPending(transport, queue, logger?)`** + **`runUnit`** exported
235
- from the root for hosts composing their own outer transaction.
236
- - **`CrmConfig.outbox?: OutboxStore`** (from `@classytic/primitives/outbox`)
237
- — when wired, every domain event is persisted via
238
- `outbox.save(event, { session })` in the SAME session as the business
239
- write; the host's relay publishes committed rows. Outbox save failures
240
- PROPAGATE (P8: a swallowed save means the write persists while its event
241
- vanishes).
242
- - **`CrmEventDispatcher`** — EventTransport-compatible wrapper repositories
243
- route every event through (`engine.events` is now this wrapper; the host
244
- `publish`/`subscribe` surface is unchanged).
245
- - **`UnmanagedSessionError`** (`code: 'UNMANAGED_SESSION'`) — a host-owned
246
- `ctx.session` arriving with neither an outbox nor a `PENDING_EVENTS`
247
- queue now throws instead of ghost-publishing mid-transaction.
248
- **Behavioral break:** callers that previously threaded a bare session and
249
- tolerated ghost events must switch to `engine.withTransaction` (or wire
250
- an outbox / attach a queue).
251
-
252
- ### Added — timezone-aware forecasting
253
-
254
- - **`ForecastOptions.timeZone`** — IANA zone for the default month bucket
255
- (e.g. `'Asia/Dhaka'`). Without it, months cut at UTC boundaries: a deal
256
- expected to close July 1, 02:00 Dhaka time (June 30, 20:00 UTC) files
257
- under JUNE. Uses `civilDateOf` from `@classytic/primitives/timezone`.
258
- Peer floor bumped: `@classytic/primitives >=0.9.0`.
259
-
260
- ### Fixed — sequence/enrollment (completes the 0.6.0 WIP)
261
-
262
- - **Event catalog no-drift restored**: 10 missing Zod definitions added
263
- (`crm:sequence.{created,active,paused,archived}`,
264
- `crm:enrollment.{created,step_sent,paused,resumed,completed,exited}`) —
265
- `crm:sequence.created` and `crm:enrollment.step_sent` are host-emitted
266
- (create via `def.create()`), the rest publish in-repo.
267
- - **`EnrollmentRepository.setWorkflowRunId` / `advanceStep` no longer
268
- bypass the repository**: both used raw `model.findOneAndUpdate` with a
269
- manual tenant filter — escaping `multiTenantPlugin` scoping AND
270
- `ctx.session` (a call inside `withTransaction` would commit outside the
271
- transaction). `advanceStep` is now a `claim()` CAS on `currentStepIndex`
272
- with `where: { status: 'active' }`; `setWorkflowRunId` goes through
273
- `repo.update`.
274
-
275
- ### Tests
276
-
277
- - 14 new: `tests/unit/dispatch.test.ts` (6 — outbox propagation/session
278
- routing/guard/swallow), `tests/integration/unit-of-work.test.ts` (3 —
279
- convert() on a replica set: zero mid-tx events, post-commit flush, abort
280
- leaks nothing, bare-session guard), `tests/unit/forecast-timezone.test.ts`
281
- (5 — the +06:00 month-boundary trap). 110/110 total.
282
-
283
- ## [0.5.1] — 2026-06-14
284
-
285
- ### Fixed — declaration bundle
286
-
287
- - **fix: declaration bundle exported mangled aliases; consumer named imports
288
- now resolve.** The published `dist/index.d.ts` re-exported every public
289
- symbol under a minified alias (`export { AccountRepository as A, CRM_EVENTS
290
- as Rr, … }`), so downstream `import { AccountRepository, CRM_EVENTS } from
291
- '@classytic/crm'` failed with TS2305 / TS2460. Root cause: the `./events`
292
- entry re-exports a strict subset of the `index` barrel, which made
293
- `rolldown-plugin-dts` alias every name on the superset (`index`) entry when
294
- both were emitted in a single pass. Build now runs `index` and `events` as
295
- two independent tsdown passes (array config) so each `.d.ts` exports real
296
- names. `tsdown` bumped `^0.14.2` → `^0.21.10` to match sibling packages.
297
- No runtime/API change.
298
-
299
- ## [0.5.0] — 2026-06-14
300
-
301
- Stack upgrade + verified correctness fixes + PACKAGE_RULES compliance. No
302
- new features. Clean break — no deprecation aliases.
303
-
304
- ### Changed — stack currency
305
-
306
- - Peers bumped: `@classytic/mongokit` `>=3.16.0`, `@classytic/repo-core`
307
- `>=0.6.0`, `@classytic/primitives` `>=0.7.1`, `mongoose` `>=9.4.1`.
308
- Clean-installed from npm and verified resolved (3.16.0 / 0.6.0 / 0.7.2).
309
-
310
- ### Added — PACKAGE_RULES compliance
311
-
312
- - **Zod event catalog (rule 18).** New `crmEventDefinitions[]` (56 events,
313
- one Zod schema each) exported from the package root and a new `./events`
314
- subpath. Timestamps are `z.iso.datetime()`. A no-drift invariant test
315
- asserts every `CRM_EVENTS` constant has a definition. Hosts wire these
316
- into arc's `EventRegistry` for publish-time validation + OpenAPI.
317
- - **Model-collision policy (rule 21).** `createCrm()` now throws
318
- `CrmModelCollisionError` when a model name is already registered; pass
319
- `forceRecreate: true` (test/hot-reload) to delete + re-register.
320
- - **Index management (rule 35).** New `autoIndex` config flag (default
321
- `true`) and `engine.syncIndexes()` for `autoIndex: false` production
322
- boots.
323
- - **Capability boot gate.** `createCrm()` asserts the backend supports
324
- `upsert` / `getOrCreate` / `duplicateKeyError` (and `transactions`,
325
- unless `allowNonTransactional: true`) so misconfigured deployments fail
326
- loud at boot instead of mid-convert.
327
-
328
- ### Fixed — domain correctness
329
-
330
- - **Lead / Subscription / Campaign dedup are now race-safe.** Added
331
- authoritative partial-unique indexes — lead `(organizationId, email)`,
332
- campaign `(organizationId, name)`, subscription `(organizationId,
333
- contactId, campaignId)` scoped to live statuses — and catch the
334
- residual `E11000` via `isDuplicateKeyError`, re-throwing the same typed
335
- domain error the pre-check emits. The prior check-then-save left a
336
- TOCTOU window.
337
- - **Owner reassignment is now atomic.** `assign()` on lead / account /
338
- contact / opportunity / conversation uses a single `findOneAndUpdate`
339
- CAS (`{ ownerId: { $ne } }` guard, `returnDocument: 'before'`) — no
340
- read-modify-write lost-update window, and the emitted `previousOwnerId`
341
- is the true pre-image under concurrency.
342
- - **Cross-tenant leak in dashboard/feed reads closed.** `findRecentForFeed`,
343
- `countCreatedSince`, `countOpen`, `sumWonSince`, `countOverduePlanned`
344
- dropped raw `Model.find` / `countDocuments` with a manual
345
- `if (ctx) organizationId` filter (which read ALL tenants when ctx was
346
- absent) for the tenant-scoped `findAll` / `count` repository methods.
347
-
348
- ### Build hygiene
349
-
350
- - Sourcemaps and declaration maps are no longer emitted (`sourceMap` /
351
- `declarationMap` off in tsconfig; `sourcemap: false` in tsdown) — both
352
- leak source.
353
- - Removed `@deprecated` markers from `ConvertResult` convenience id fields
354
- (kept the fields; they were never deprecated, just mislabelled).
355
- - Dropped the stale pnpm lockfile/workspace and the bundled `.tgz`;
356
- installs are npm-from-registry only.
357
-
358
- ### Docs
359
-
360
- - Rewrote the README to the engine-factory API (`createCrm` + repository
361
- domain verbs). The previous README documented the abandoned v0.1
362
- port/adapter design (`AccountService`, `InMemory*Repository`,
363
- `OutreachPort`) that no longer exists.
364
-
365
- ### Tests
366
-
367
- - Added `tests/integration/invariants.test.ts` (tenant probe, lead +
368
- subscription dedup races, atomic reassignment) and
369
- `tests/unit/event-catalog.test.ts` (no-drift + round-trip). 87 → 96
370
- tests.
371
-
372
- ## [0.4.0] — 2026-05-22
373
-
374
- ### Changed — Subscription model renamed to `EmailSubscription`
375
-
376
- **Breaking at the Mongo identity level.** The CRM's email opt-in resource
377
- now registers as Mongoose model `EmailSubscription` (was `Subscription`)
378
- and persists into the `email_subscriptions` collection (was
379
- `subscriptions`).
380
-
381
- **Why:** the previous name collided with `@classytic/revenue`'s
382
- `Subscription` model when both packages were loaded on the same
383
- Mongoose connection — `RevenueModelCollisionError` at boot. Renaming
384
- gives the two concepts distinct identities at the DB level and lets
385
- revenue + CRM coexist without the host having to split connections.
386
-
387
- **TypeScript surface is unchanged.** `ISubscriptionDocument`,
388
- `SubscriptionRepository`, `subscription.handlers.ts`,
389
- `CrmModels.Subscription`, etc. all keep their existing names — only
390
- the Mongoose model name string and the underlying collection name
391
- changed. Consumers that import from `@classytic/crm` need no source
392
- changes; the host only needs to migrate existing data (see below).
393
-
394
- ### Migration
395
-
396
- For DBs with existing CRM subscription rows in the legacy `subscriptions`
397
- collection (distinguishable from revenue rows by `contactId` presence):
398
-
399
- ```js
400
- // MongoDB shell — one-time migration
401
- db.subscriptions.aggregate([
402
- { $match: { contactId: { $exists: true } } },
403
- { $out: 'email_subscriptions' },
404
- ]);
405
- // Then remove the migrated docs from the legacy collection:
406
- db.subscriptions.deleteMany({ contactId: { $exists: true } });
407
- ```
408
-
409
- Hosts using only CRM (no revenue) can also run this — leftover rows in
410
- `subscriptions` won't cause boot errors because there's no collision
411
- without revenue.
412
-
413
- ## [0.3.0] — 2026-05-17
414
-
415
- ### Added — `EmailEvent` domain
416
-
417
- Closes the loop between outbound CRM sends and recipient engagement.
418
- Hosts can now record opens, clicks, bounces, complaints, and unsubscribes
419
- against a Contact / Subscription / Campaign tuple — and downstream
420
- services (drip-sequence "if opened" branches, lead scoring, suppression
421
- lists) consume the same events without polling the email transport.
422
-
423
- - **Entity & enum** — `EmailEvent` (append-only) plus the
424
- `EmailEventKind` enum (`open` / `click` / `bounce` / `complaint` /
425
- `unsubscribe`) split into `ENGAGEMENT_KINDS` and `SUPPRESSION_KINDS`
426
- helper sets for classification at call sites.
427
- - **Port** — `EmailEventRepository` with filtered list, per-subject
428
- timeline, per-campaign aggregates (`uniqueOpens`, `uniqueClicks`,
429
- `bounces`, `complaints`, `unsubscribes`, `totalEvents`), and a
430
- GDPR-style `deleteForSubject()` purge.
431
- - **Service** — `EmailEventService.recordOpen()` /
432
- `.recordClick({ url })` / `.recordBounce()` / `.recordComplaint()` /
433
- `.recordUnsubscribe()` + read-side `.listForSubject()`,
434
- `.aggregateForCampaign()`, and `.hasEngaged(subjectId, campaignId,
435
- kind)` for drip-condition checks.
436
- - **Events** — `crm:email_event.opened` / `.clicked` / `.bounced` /
437
- `.complained` / `.unsubscribed` with Zod-validated payloads in
438
- `event-catalog`, wired into `crmEventDefinitions[]`.
439
- - **In-memory adapter** — `InMemoryEmailEventRepository` (tests +
440
- dev). Hosts implement against Mongo / Postgres / etc.
441
-
442
- ### Added — re-exports from `@classytic/primitives@0.6.0`
443
-
444
- To eliminate duplicate type imports at consumer call sites:
445
-
446
- ```ts
447
- import {
448
- appendStatus,
449
- StatusChangeEntry,
450
- StatusHistory,
451
- timeInStatus,
452
- lastTransitionTo,
453
- } from '@classytic/crm';
454
- ```
455
-
456
- These pass through from `@classytic/primitives/status-history`. The
457
- existing entity-specific `LeadStatusEntry` / `OpportunityStatusEntry` /
458
- etc. names are still exported for back-compat — they remain structurally
459
- compatible with `StatusChangeEntry<TStatus>`.
460
-
461
- ### Changed
462
-
463
- - Peer dep `@classytic/primitives` bumped from `>=0.5.0` to `>=0.6.0`
464
- to access the new `status-history` subpath and value-typed primitives.
465
- - Test harness `buildHarness()` now also exposes an
466
- `emailEvents: EmailEventService` and `emailEventRepo` for integration
467
- tests that exercise the engagement → drip path.
468
-
469
- ### Dependencies
470
-
471
- - `@classytic/primitives@>=0.6.0` (peer, was `>=0.5.0`)
472
- - `zod@>=4.0.0` (peer, unchanged)
473
-
474
- ## [0.1.0] — 2026-04-18
475
-
476
- Initial release.
477
-
478
- ### Added
479
-
480
- - **Eight domain entities.** `Contact`, `Account`, `Lead`, `Opportunity`, `Pipeline` (+ nested `Stage`), `Activity`, `Note`, `LossReason`.
481
- - **Three state machines.** Lead (`new` → `contacted` → `qualified` → `converted`, + `disqualified` / `nurturing` branches), Opportunity (`open` → `won` / `lost` / `abandoned` with in-pipeline Stage movement), Activity (`planned` → `in_progress` → `completed` / `cancelled`). All transitions CAS-guarded with explicit `*_TRANSITIONS` maps and `canTransition*` guards.
482
- - **Six services.**
483
- - `ContactService` — CRUD + tag filtering + email lookup.
484
- - `AccountService` — CRUD + domain lookup.
485
- - `PipelineService` — create, archive, stage helpers (`getStage`, `getFirstStage`, `getNextStage`).
486
- - `OpportunityService` — create at first stage (or specified), `moveToStage` with advance/regress event disambiguation, `win` / `lose` (with `lostReasonId`) / `abandon`.
487
- - `LeadService` — CRUD + full lifecycle (`markContacted`, `qualify`, `disqualify`, `nurture`) + `convert()` — the crown-jewel verb that atomically creates Contact + Account + Opportunity and transitions the lead to `converted`.
488
- - `ActivityService` — polymorphic activities (`plan`, `start`, `complete`, `cancel`) + notes (`addNote`, `listNotesBySubject`).
489
- - **Pure calculators.**
490
- - `defaultLeadScorer` + `composeScorers(...rules)` — host injects whatever rubric (firmographics, in-app signals, ML prediction, enrichment) via a `(lead: Lead) => number` function.
491
- - `forecast(opportunities, options?)` — committed + weighted pipeline by bucket (default ISO month), with custom bucket function support. Currency-mismatch detection.
492
- - `pipelineConversion(opportunities, stageIds)` — where do deals die? returns per-stage current count + won/lost counts + conversion rate.
493
- - **Nine repository ports** — one per entity (plus `EventTransport`, `IdGenerator`, `Clock`, optional `OutreachPort`). Full in-memory reference implementations shipped.
494
- - **Typed error hierarchy** rooted at `CrmError`: `NotFoundError`, `InvalidLeadTransitionError`, `InvalidOpportunityTransitionError`, `InvalidActivityTransitionError`, `StageNotInPipelineError`, `LeadAlreadyConvertedError`, `DuplicateLeadError`, `EmptyPipelineError`.
495
- - **Nineteen lifecycle events** (`CRM_EVENTS`): covering lead/account/contact/opportunity/pipeline/activity/note verbs, all following `crm:<resource>.<verb>` per PACKAGE_RULES rule 12.
496
- - **Optional `OutreachPort`** — one-method (`send`) + `NullOutreachPort` + `InMemoryOutreachPort` (recording). Host subscribes to `crm:activity.*` events and drives the port from their automation layer (keeps pacing / retry / bounce handling out of the core).
497
- - **Testing.** `vitest.config.ts` projects split `unit` + `integration` per [testing-infrastructure.md](../../testing-infrastructure.md). 48 tests (7 files) — all green in <1.5s.
498
-
499
- ### Design choices
500
-
501
- - Pure-TS port-based pattern — consistent with `@classytic/manufacturing`, `@classytic/assets`, `@classytic/fleet`, `@classytic/carrier-*`.
502
- - Separate Lead + Opportunity models (erpnext style) rather than unified (odoo style) — cleaner semantics for pre-qualification vs in-pipeline data and enables distinct analytics.
503
- - Polymorphic Activity / Note via `subjectKind` + `subjectId` so hosts can extend without schema changes.
504
- - `LeadService.convert()` is atomic in intent and sequenced for auditability — it creates Account, then Contact, then Opportunity, then transitions the lead. If a downstream repo fails, the ones that succeeded persist (hosts using Mongoose sessions get atomicity at their layer).
505
- - Campaigns, appointment booking, competitors, contracts, team routing, RBAC, custom-field validation, email-thread parsing deferred to v0.2+ to keep the surface focused.
1
+ # Changelog
2
+
3
+ All notable changes to `@classytic/crm` are documented here.
4
+
5
+ ## 1.2.0 - 2026-10-18
6
+
7
+ ### ⚠ Changed — string-key partial indexes are `{ $gte: '' }`, so lookups by key use them
8
+
9
+ A `{ $type: 'string' }` partial filter is never implied by an equality query, so a plain lookup by the
10
+ key could not use its own index. `$gte: ''` admits exactly the same rows (strings only) and is implied.
11
+ Renamed with a `_str` suffix (Mongo refuses one name with two filters): `contact_email_unique_str`, `lead_email_unique_str`, `message_external_id_unique_str`.
12
+ **Deploy:** build the new indexes (they coexist with the old), then drop the old ones — be-prod:
13
+ `npm run db:ensure-indexes`, then `npm run db:retire-type-string-indexes -- --apply`.
14
+
15
+ ### Fixed — `convert()` dropped the lead's `metadata`, severing every integration link
16
+
17
+ `metadata` is where a host records that a lead is a known party in another
18
+ system. Conversion is the moment the Contact is created, so dropping the bag
19
+ there destroyed the association permanently — and any consumer keyed on it read
20
+ an absent link and returned silently, for every converted lead, with no error
21
+ and no log. The whole bag now travels onto the contact; the kernel still learns
22
+ nothing about what any particular key means. A lead with no metadata still
23
+ produces a contact with none, rather than an empty object.
24
+
25
+ `tests/integration/lead-convert.scenario.test.ts`, falsified by re-dropping it.
26
+
27
+ ### Changed — peer floors
28
+
29
+ `@classytic/mongokit` `>=3.41.0`, `@classytic/primitives` `>=0.32.0`, `@classytic/repo-core` `>=0.31.0`, `mongoose` `>=9.11.0`.
30
+
31
+ ## 1.1.1 - 2026-08-20
32
+
33
+ ### Fixed
34
+ - `RmaEventSchema` (and equivalent CRM event schemas) declare `type: 'object'` so hosts can register them without a cast.
35
+
36
+ ## 1.1.0 - 2026-08-17
37
+
38
+ ### Changed
39
+ - `tenant` config key replaces `tenantFieldType` across all models and engine (`assertNoLegacyTenantKeys` guard applied)
40
+ - `OpportunityRepository` gains new query methods
41
+ - Model schemas standardised (consistent field ordering, injected tenant field)
42
+
43
+ ## 1.0.0 - 2026-07-29
44
+
45
+ ### Fixed — Lead and Contact forked one mailbox into two rows (BEHAVIOURAL, 2026-08-08)
46
+
47
+ Same bug class as the suppression fix below, in the two collections that hold live
48
+ customer identity. Neither had a private normalizer — they had **no** canonicaliser
49
+ at all, and relied on a partial-unique `(organizationId, email)` /
50
+ `(organizationId, contact.email)` index over a field whose only folding was mongoose
51
+ `lowercase: true`. That does not IDNA-fold, so `ada@bücher.de` and
52
+ `ada@xn--bcher-kva.de` were two distinct strings, the index admitted both, and one
53
+ mailbox became two leads with no error. Reproduced before the fix; the case control
54
+ (`ADA@…` → refused) proves the index itself always worked — the KEY was wrong.
55
+
56
+ **The fix is at the SCHEMA, not only at the repository verbs.** `@spinekit/crm`
57
+ mounts both resources through arc's mongoose CRUD adapter, so `POST /crm/leads`
58
+ calls the inherited `Repository.create()` and `createLead`'s pre-check never runs. A
59
+ verb-only fix would have left the live HTTP route forking while the verb tests went
60
+ green. `Lead.email`, `Contact.contact.email` and `Contact.contact.personalEmail` now
61
+ carry a `canonicalEmailSetter` (`src/core/email-identity.ts`) that applies
62
+ `normalizeEmail` from **`@classytic/contact/email`**, imported directly. Mongoose
63
+ runs string setters on query filters too, so reads fold with writes.
64
+
65
+ - The setter **never throws** — it degrades to the legacy `trim().toLowerCase()`
66
+ for anything the kernel refuses. A throw inside a setter surfaces during query
67
+ casting as an opaque `CastError`, i.e. a 500 on a READ, and degrading also means
68
+ nothing already stored is re-keyed by a rule that cannot explain its output.
69
+ - `createLead` / `createContact` **do throw** — new `LeadEmailFormatError`
70
+ (`LEAD_EMAIL_INVALID`) and `ContactEmailFormatError` (`CONTACT_EMAIL_INVALID`),
71
+ both 400, nothing written. An uncanonicalisable address defeats the very index it
72
+ is meant to arm: broken spellings never collide, so the same input inserts
73
+ without limit while every call reports success.
74
+ - Both verbs canonicalise **explicitly** before querying rather than relying on the
75
+ schema setter to fold the filter. A pre-check resting on an invisible field option
76
+ silently WIDENS the day someone edits the schema, and a widened filter does not
77
+ fail — it matches nothing and answers "no duplicate".
78
+
79
+ **Behaviour change worth naming:** `LeadService.convert()` passes the lead's stored
80
+ email into `createContact`, so converting a lead whose address cannot be
81
+ canonicalised now fails instead of minting a Contact whose only identity field is
82
+ unusable. Dropping the address silently would be the worse half of that trade.
83
+ Reachability was checked, not assumed: be-prod gates CRM writes with `requireAuth()`
84
+ and there is no public lead-capture route, so the 400 reaches an operator or an
85
+ importer, never an anonymous form visitor.
86
+
87
+ New export `crmEmailKey(email): string | null`. The setter and the two throwing
88
+ helpers stay internal — a host must not re-apply either.
89
+
90
+ **Stored rows are NOT migrated.** `scripts/lead-contact-email-key-drift.mjs`
91
+ (shipped, read-only; `npm run drift:email-keys`) classifies both collections as
92
+ `unchanged` / `rekey` / `collision` / `uncanonicalisable`, prints a distinct banner
93
+ for an absent collection and for one with rows but no emails, and writes nothing.
94
+ `collision` is the fork: two rows, one mailbox, and the unique index means a naive
95
+ `$set` fails outright — merging them and re-pointing the loser's opportunities,
96
+ activities and subscriptions is a human decision.
97
+
98
+ Pinned by `tests/integration/lead-contact-email-idna.scenario.test.ts` (15 cases,
99
+ covering both the verb path and the arc-CRUD path) and
100
+ `tests/integration/email-key-drift-tool.test.ts` (7 cases that falsify the report
101
+ itself against planted rows of every class).
102
+
103
+ **Not done here (deliberate):** no schema `validate` on the email fields, so the
104
+ arc-CRUD route still accepts an uncanonicalisable address and stores it
105
+ legacy-folded. Adding one would turn a currently-succeeding admin write into a 400
106
+ and would break `convert()` on rows that already exist. That is a separate decision.
107
+
108
+ ### Fixed — the suppression list keyed one mailbox as two (BEHAVIOURAL)
109
+
110
+ `SuppressionRepository` held a private `email.trim().toLowerCase()`. It does not
111
+ IDNA-canonicalise, so `ada@bücher.de` and `ada@xn--bcher-kva.de` — one host in two
112
+ encodings — were two keys: an address suppressed under one spelling stayed
113
+ **sendable** under the other, and nothing errored. The send succeeded and looked
114
+ correct. A do-not-contact list is frequently a legal obligation, so this is worse
115
+ than an ordinary dedup miss.
116
+
117
+ All four verbs (`suppress`, `isSuppressed`, `filterSuppressed`, `unsuppress`) now
118
+ canonicalise through `normalizeEmail` from **`@classytic/contact/email`**, imported
119
+ directly — no re-export, no local wrapper. `@classytic/contact >=0.1.0` is a new
120
+ **peer** dependency.
121
+
122
+ `normalizeEmail` refuses what it cannot canonicalise, and the two sides spend that
123
+ refusal differently:
124
+
125
+ - **Writes throw** the new `SuppressionEmailFormatError` (400,
126
+ `SUPPRESSION_EMAIL_INVALID`) and store nothing. A malformed row is a suppression
127
+ that can never match — it suppresses nothing while reporting success. `unsuppress`
128
+ throws too: it re-enables sending, so a `false` would read as "already removed".
129
+ - **Reads fail CLOSED.** `isSuppressed()` returns `true` and `filterSuppressed()`
130
+ reports as blocked for an address with no canonical form. They do not throw —
131
+ `filterSuppressed` is a batch verb, and one malformed recipient must not abort a
132
+ fanout to thousands of valid ones.
133
+
134
+ New export `suppressionEmailKey(email): string | null` — the key a caller must test
135
+ `filterSuppressed()` membership against, and the only way to tell "unparseable"
136
+ apart from "on the list".
137
+
138
+ **Stored rows are NOT migrated.** A row written under the old rule in a
139
+ non-canonical form is now unreachable. `scripts/suppression-key-drift.mjs` (shipped,
140
+ read-only) classifies every row as `unchanged` / `rekey` / `collision` /
141
+ `uncanonicalisable` and writes nothing; `collision` rows cannot be moved by an
142
+ `updateOne` at all, because `(organizationId, email)` is unique and another row
143
+ already holds the target key. Rewriting a suppression list has legal consequences —
144
+ run the report, decide by hand.
145
+
146
+ Pinned by `tests/integration/suppression-idna.scenario.test.ts` (12 cases).
147
+
148
+ ### Changed
149
+ - **License:** relicensed from MIT to the **Classytic Source-Available License** (Community & Commercial). Evaluation/development use remains free; production use now requires a commercial license from Classytic LLC. See `LICENSE`.
150
+ - Major bump marks the license change; versions published before 1.0.0 remain under their original MIT terms.
151
+
152
+ ## 0.10.1 - 2026-07-22 (unpublished)
153
+
154
+ ### Changed
155
+ - Internal: `InProcess*Bus` is now a thin subclass of `@classytic/primitives/event-infra`'s shared `InProcessEventBus` (dedup / error-isolation / close semantics unchanged); `MemoryOutboxStore` re-exported from the shared runtime where applicable. No public API, event-name, or wire-envelope change. Requires `@classytic/primitives >=0.13.0`.
156
+
157
+ ## [0.10.0] — unpublished (2026-07-22)
158
+
159
+ ### Changed — `dispose()` renamed to `destroy()` (fleet lifecycle convention)
160
+
161
+ - **`engine.destroy()`** is the canonical teardown verb (matches
162
+ manufacturing / purchase / order / catalog / cart). `dispose()` remains
163
+ as a deprecated one-line alias delegating to `destroy()` so hosts don't
164
+ break mid-migration; it will be removed in a future minor.
165
+ - **Behavioral fix**: teardown now closes the transport ONLY when the
166
+ engine created it itself (`InProcessCrmBus`). Previously `dispose()`
167
+ closed a host-injected `eventTransport` too — the host's bus is the
168
+ host's to close.
169
+ - Destroy is idempotent — a second call (or `dispose()` after `destroy()`)
170
+ is a no-op. Mongoose models/connection are untouched (host-owned).
171
+ - Lifecycle coverage in `tests/integration/engine-lifecycle.test.ts`.
172
+
173
+ ## [0.9.0] — unpublished (2026-07-15)
174
+
175
+ ### Changed — mongokit 3.22 adopted (peer floors: mongokit >=3.22.0, repo-core >=0.13.0, primitives >=0.11.0)
176
+
177
+ - **Seven transition verbs ride `applyTransition`** (activity, campaign,
178
+ enrollment exit + generic, lead, sequence, subscription, opportunity):
179
+ machine pre-flight + CAS + accurate race diagnosis via the machines'
180
+ existing errorFactories. All `this as any` casts at those sites are gone
181
+ (native method).
182
+ - **FIXED a real concurrency defect**: five sites built `statusHistory` by
183
+ read-modify-write (`[...(existing.statusHistory ?? []), entry]`) inside
184
+ the CAS patch — concurrent writers could drop history entries. Entries
185
+ now ride `$push` via the `push` arg, byte-identical shapes preserved.
186
+ - **`EmailEvent` log fence enforced** — "append-only" was a doc comment;
187
+ `appendOnlyPlugin` now refuses every mutation path (405
188
+ `APPEND_ONLY_VIOLATION`), with `deleteMany` allowed for host-gated GDPR
189
+ erasure of a recipient's rows.
190
+ - DELIBERATELY unchanged: `enrollment.advanceStep` (non-status
191
+ `currentStepIndex` CAS with null semantics) and opportunity's stage-move
192
+ claim — correct raw `claim` usage, not transition boilerplate.
193
+
194
+ ## [0.8.0] — 2026-07-11
195
+
196
+ ### Added — engagement-based lead scoring
197
+ - **`ILeadEngagement` on the Lead** (`{ opens, clicks, replies, activities, lastEngagedAt? }`, nested default `{}` so old rows read as zeros). Behavioural counters, distinct from firmographic fields.
198
+ - **`LeadRepository.recordEngagement(id, signal, ctx)`** where `signal ∈ 'open'|'click'|'reply'|'activity'` (`LeadEngagementSignal`). Atomic `$inc` on the matching counter + `lastEngagedAt` via `findOneAndUpdate`, then `recomputeScore`. Hosts feed it off `crm:message.received` (reply), `crm:activity.*` (activity), and email open/click tracking.
199
+ - **`defaultLeadScorer` now weighs engagement** on top of firmographic fit: reply ×25 · click ×8 · open ×2 · activity ×5, capped at +80. `LeadInput` picks up `engagement`. Pure/deterministic — no wall-clock. Absent engagement scores as zero (score unchanged for pre-existing leads until a signal lands).
200
+
201
+ ## [0.7.0] — 2026-07-05
202
+
203
+ ### Added — org-wide email suppression list
204
+
205
+ - **`Suppression` entity** (`crm.repositories.suppression`, model
206
+ `Suppression`): global per-org "never email this address again" list —
207
+ the gate hosts consult before EVERY outbound email. Unsubscribes on
208
+ `Subscription` stay per-campaign; suppression is org-wide.
209
+ - `suppress(email, { reason, source?, subjectKind?, subjectId?, metadata? }, ctx?)`
210
+ — idempotent `getOrCreate` on the unique `(organizationId, email)`
211
+ index (no check-then-save); emits `crm:suppression.added` only when
212
+ THIS call inserted the row.
213
+ - `isSuppressed(email, ctx?)` → boolean.
214
+ - `filterSuppressed(emails, ctx?)` → `Set<string>` of the suppressed
215
+ subset — ONE `$in` query for the campaign-fanout hot path.
216
+ - `unsuppress(email, ctx?)` — atomic delete; emits
217
+ `crm:suppression.removed` only when a row was actually removed.
218
+ - All verbs normalize emails (trim + lowercase; schema stores lowercase).
219
+ - **`SUPPRESSION_REASONS`** (`'bounce' | 'complaint' | 'unsubscribe' |
220
+ 'manual'`) — the first three `satisfies readonly EmailEventKind[]` (P6),
221
+ so a rename in `EMAIL_EVENT_KINDS` fails compilation here.
222
+ - **Events** `crm:suppression.added` / `crm:suppression.removed` — Zod
223
+ catalog definitions included (no-drift invariant stays green);
224
+ `organizationId` rides event meta as usual.
225
+ - **Validators** `suppressSchema` / `unsuppressSchema` /
226
+ `suppressionFilterSchema`.
227
+
228
+ ### Added — reply-detection exit verb
229
+
230
+ - **`EnrollmentRepository.exitActiveOnReply(subjects, ctx?)`** — the ONE
231
+ method a host's `crm:message.received` subscriber calls: finds ACTIVE
232
+ enrollments for the given `{ subjectKind, subjectId }` list, batch-loads
233
+ their sequences in a single `$in` query, skips those with
234
+ `replyDetectionEnabled === false`, and exits the rest via `exit(id,
235
+ 'reply')` (each exit emits `crm:enrollment.exited`). Lost CAS races
236
+ (concurrent pause/complete/exit) are skipped, not failed. Returns the
237
+ enrollments this call exited.
238
+ - `EnrollmentRepositoryDeps` now carries `sequences: SequenceRepository`
239
+ (same cross-repo deps idiom as `OpportunityRepository.pipelines` /
240
+ `MessageRepository.conversations`) — wired automatically by
241
+ `createCrm()`; only hosts constructing `EnrollmentRepository` by hand
242
+ need to pass it.
243
+
244
+ ## [0.6.0] — 2026-07-02
245
+
246
+ Breaking-in-behavior release (ships as MINOR per house convention):
247
+ outbox-first, session-safe event dispatch — the previously deferred
248
+ PACKAGE_RULES §P8/§P8.1 work.
249
+
250
+ ### Added — unit-of-work contract (P8.1, shared with refit/review/cart/invoice)
251
+
252
+ - **`engine.withTransaction(ctx, body)`** — runs `body` inside a MongoDB
253
+ transaction with post-commit event discipline: events dispatched through
254
+ `txCtx` are queued under `ctx[PENDING_EVENTS]` and flushed to the
255
+ transport only AFTER commit; the queue resets on retry attempts; an abort
256
+ leaks nothing to in-process subscribers. Reuses the caller's session (and
257
+ queue) when `ctx.session` is already set. Standalone-Mongo dev boots opt
258
+ in via the existing `allowNonTransactional: true`.
259
+ - **`PENDING_EVENTS`** (`Symbol.for('@classytic/crm:pendingEvents')`) +
260
+ **`flushPending(transport, queue, logger?)`** + **`runUnit`** exported
261
+ from the root for hosts composing their own outer transaction.
262
+ - **`CrmConfig.outbox?: OutboxStore`** (from `@classytic/primitives/outbox`)
263
+ — when wired, every domain event is persisted via
264
+ `outbox.save(event, { session })` in the SAME session as the business
265
+ write; the host's relay publishes committed rows. Outbox save failures
266
+ PROPAGATE (P8: a swallowed save means the write persists while its event
267
+ vanishes).
268
+ - **`CrmEventDispatcher`** — EventTransport-compatible wrapper repositories
269
+ route every event through (`engine.events` is now this wrapper; the host
270
+ `publish`/`subscribe` surface is unchanged).
271
+ - **`UnmanagedSessionError`** (`code: 'UNMANAGED_SESSION'`) — a host-owned
272
+ `ctx.session` arriving with neither an outbox nor a `PENDING_EVENTS`
273
+ queue now throws instead of ghost-publishing mid-transaction.
274
+ **Behavioral break:** callers that previously threaded a bare session and
275
+ tolerated ghost events must switch to `engine.withTransaction` (or wire
276
+ an outbox / attach a queue).
277
+
278
+ ### Added — timezone-aware forecasting
279
+
280
+ - **`ForecastOptions.timeZone`** — IANA zone for the default month bucket
281
+ (e.g. `'Asia/Dhaka'`). Without it, months cut at UTC boundaries: a deal
282
+ expected to close July 1, 02:00 Dhaka time (June 30, 20:00 UTC) files
283
+ under JUNE. Uses `civilDateOf` from `@classytic/primitives/timezone`.
284
+ Peer floor bumped: `@classytic/primitives >=0.9.0`.
285
+
286
+ ### Fixed — sequence/enrollment (completes the 0.6.0 WIP)
287
+
288
+ - **Event catalog no-drift restored**: 10 missing Zod definitions added
289
+ (`crm:sequence.{created,active,paused,archived}`,
290
+ `crm:enrollment.{created,step_sent,paused,resumed,completed,exited}`) —
291
+ `crm:sequence.created` and `crm:enrollment.step_sent` are host-emitted
292
+ (create via `def.create()`), the rest publish in-repo.
293
+ - **`EnrollmentRepository.setWorkflowRunId` / `advanceStep` no longer
294
+ bypass the repository**: both used raw `model.findOneAndUpdate` with a
295
+ manual tenant filter — escaping `multiTenantPlugin` scoping AND
296
+ `ctx.session` (a call inside `withTransaction` would commit outside the
297
+ transaction). `advanceStep` is now a `claim()` CAS on `currentStepIndex`
298
+ with `where: { status: 'active' }`; `setWorkflowRunId` goes through
299
+ `repo.update`.
300
+
301
+ ### Tests
302
+
303
+ - 14 new: `tests/unit/dispatch.test.ts` (6 — outbox propagation/session
304
+ routing/guard/swallow), `tests/integration/unit-of-work.test.ts` (3 —
305
+ convert() on a replica set: zero mid-tx events, post-commit flush, abort
306
+ leaks nothing, bare-session guard), `tests/unit/forecast-timezone.test.ts`
307
+ (5 — the +06:00 month-boundary trap). 110/110 total.
308
+
309
+ ## [0.5.1] — 2026-06-14
310
+
311
+ ### Fixed — declaration bundle
312
+
313
+ - **fix: declaration bundle exported mangled aliases; consumer named imports
314
+ now resolve.** The published `dist/index.d.ts` re-exported every public
315
+ symbol under a minified alias (`export { AccountRepository as A, CRM_EVENTS
316
+ as Rr, … }`), so downstream `import { AccountRepository, CRM_EVENTS } from
317
+ '@classytic/crm'` failed with TS2305 / TS2460. Root cause: the `./events`
318
+ entry re-exports a strict subset of the `index` barrel, which made
319
+ `rolldown-plugin-dts` alias every name on the superset (`index`) entry when
320
+ both were emitted in a single pass. Build now runs `index` and `events` as
321
+ two independent tsdown passes (array config) so each `.d.ts` exports real
322
+ names. `tsdown` bumped `^0.14.2` → `^0.21.10` to match sibling packages.
323
+ No runtime/API change.
324
+
325
+ ## [0.5.0] — 2026-06-14
326
+
327
+ Stack upgrade + verified correctness fixes + PACKAGE_RULES compliance. No
328
+ new features. Clean break — no deprecation aliases.
329
+
330
+ ### Changed — stack currency
331
+
332
+ - Peers bumped: `@classytic/mongokit` `>=3.16.0`, `@classytic/repo-core`
333
+ `>=0.6.0`, `@classytic/primitives` `>=0.7.1`, `mongoose` `>=9.4.1`.
334
+ Clean-installed from npm and verified resolved (3.16.0 / 0.6.0 / 0.7.2).
335
+
336
+ ### Added — PACKAGE_RULES compliance
337
+
338
+ - **Zod event catalog (rule 18).** New `crmEventDefinitions[]` (56 events,
339
+ one Zod schema each) exported from the package root and a new `./events`
340
+ subpath. Timestamps are `z.iso.datetime()`. A no-drift invariant test
341
+ asserts every `CRM_EVENTS` constant has a definition. Hosts wire these
342
+ into arc's `EventRegistry` for publish-time validation + OpenAPI.
343
+ - **Model-collision policy (rule 21).** `createCrm()` now throws
344
+ `CrmModelCollisionError` when a model name is already registered; pass
345
+ `forceRecreate: true` (test/hot-reload) to delete + re-register.
346
+ - **Index management (rule 35).** New `autoIndex` config flag (default
347
+ `true`) and `engine.syncIndexes()` for `autoIndex: false` production
348
+ boots.
349
+ - **Capability boot gate.** `createCrm()` asserts the backend supports
350
+ `upsert` / `getOrCreate` / `duplicateKeyError` (and `transactions`,
351
+ unless `allowNonTransactional: true`) so misconfigured deployments fail
352
+ loud at boot instead of mid-convert.
353
+
354
+ ### Fixed — domain correctness
355
+
356
+ - **Lead / Subscription / Campaign dedup are now race-safe.** Added
357
+ authoritative partial-unique indexes — lead `(organizationId, email)`,
358
+ campaign `(organizationId, name)`, subscription `(organizationId,
359
+ contactId, campaignId)` scoped to live statuses — and catch the
360
+ residual `E11000` via `isDuplicateKeyError`, re-throwing the same typed
361
+ domain error the pre-check emits. The prior check-then-save left a
362
+ TOCTOU window.
363
+ - **Owner reassignment is now atomic.** `assign()` on lead / account /
364
+ contact / opportunity / conversation uses a single `findOneAndUpdate`
365
+ CAS (`{ ownerId: { $ne } }` guard, `returnDocument: 'before'`) — no
366
+ read-modify-write lost-update window, and the emitted `previousOwnerId`
367
+ is the true pre-image under concurrency.
368
+ - **Cross-tenant leak in dashboard/feed reads closed.** `findRecentForFeed`,
369
+ `countCreatedSince`, `countOpen`, `sumWonSince`, `countOverduePlanned`
370
+ dropped raw `Model.find` / `countDocuments` with a manual
371
+ `if (ctx) organizationId` filter (which read ALL tenants when ctx was
372
+ absent) for the tenant-scoped `findAll` / `count` repository methods.
373
+
374
+ ### Build hygiene
375
+
376
+ - Sourcemaps and declaration maps are no longer emitted (`sourceMap` /
377
+ `declarationMap` off in tsconfig; `sourcemap: false` in tsdown) — both
378
+ leak source.
379
+ - Removed `@deprecated` markers from `ConvertResult` convenience id fields
380
+ (kept the fields; they were never deprecated, just mislabelled).
381
+ - Dropped the stale pnpm lockfile/workspace and the bundled `.tgz`;
382
+ installs are npm-from-registry only.
383
+
384
+ ### Docs
385
+
386
+ - Rewrote the README to the engine-factory API (`createCrm` + repository
387
+ domain verbs). The previous README documented the abandoned v0.1
388
+ port/adapter design (`AccountService`, `InMemory*Repository`,
389
+ `OutreachPort`) that no longer exists.
390
+
391
+ ### Tests
392
+
393
+ - Added `tests/integration/invariants.test.ts` (tenant probe, lead +
394
+ subscription dedup races, atomic reassignment) and
395
+ `tests/unit/event-catalog.test.ts` (no-drift + round-trip). 87 → 96
396
+ tests.
397
+
398
+ ## [0.4.0] — 2026-05-22
399
+
400
+ ### Changed — Subscription model renamed to `EmailSubscription`
401
+
402
+ **Breaking at the Mongo identity level.** The CRM's email opt-in resource
403
+ now registers as Mongoose model `EmailSubscription` (was `Subscription`)
404
+ and persists into the `email_subscriptions` collection (was
405
+ `subscriptions`).
406
+
407
+ **Why:** the previous name collided with `@classytic/revenue`'s
408
+ `Subscription` model when both packages were loaded on the same
409
+ Mongoose connection — `RevenueModelCollisionError` at boot. Renaming
410
+ gives the two concepts distinct identities at the DB level and lets
411
+ revenue + CRM coexist without the host having to split connections.
412
+
413
+ **TypeScript surface is unchanged.** `ISubscriptionDocument`,
414
+ `SubscriptionRepository`, `subscription.handlers.ts`,
415
+ `CrmModels.Subscription`, etc. all keep their existing names — only
416
+ the Mongoose model name string and the underlying collection name
417
+ changed. Consumers that import from `@classytic/crm` need no source
418
+ changes; the host only needs to migrate existing data (see below).
419
+
420
+ ### Migration
421
+
422
+ For DBs with existing CRM subscription rows in the legacy `subscriptions`
423
+ collection (distinguishable from revenue rows by `contactId` presence):
424
+
425
+ ```js
426
+ // MongoDB shell — one-time migration
427
+ db.subscriptions.aggregate([
428
+ { $match: { contactId: { $exists: true } } },
429
+ { $out: 'email_subscriptions' },
430
+ ]);
431
+ // Then remove the migrated docs from the legacy collection:
432
+ db.subscriptions.deleteMany({ contactId: { $exists: true } });
433
+ ```
434
+
435
+ Hosts using only CRM (no revenue) can also run this — leftover rows in
436
+ `subscriptions` won't cause boot errors because there's no collision
437
+ without revenue.
438
+
439
+ ## [0.3.0] — 2026-05-17
440
+
441
+ ### Added — `EmailEvent` domain
442
+
443
+ Closes the loop between outbound CRM sends and recipient engagement.
444
+ Hosts can now record opens, clicks, bounces, complaints, and unsubscribes
445
+ against a Contact / Subscription / Campaign tuple — and downstream
446
+ services (drip-sequence "if opened" branches, lead scoring, suppression
447
+ lists) consume the same events without polling the email transport.
448
+
449
+ - **Entity & enum** — `EmailEvent` (append-only) plus the
450
+ `EmailEventKind` enum (`open` / `click` / `bounce` / `complaint` /
451
+ `unsubscribe`) split into `ENGAGEMENT_KINDS` and `SUPPRESSION_KINDS`
452
+ helper sets for classification at call sites.
453
+ - **Port** — `EmailEventRepository` with filtered list, per-subject
454
+ timeline, per-campaign aggregates (`uniqueOpens`, `uniqueClicks`,
455
+ `bounces`, `complaints`, `unsubscribes`, `totalEvents`), and a
456
+ GDPR-style `deleteForSubject()` purge.
457
+ - **Service** — `EmailEventService.recordOpen()` /
458
+ `.recordClick({ url })` / `.recordBounce()` / `.recordComplaint()` /
459
+ `.recordUnsubscribe()` + read-side `.listForSubject()`,
460
+ `.aggregateForCampaign()`, and `.hasEngaged(subjectId, campaignId,
461
+ kind)` for drip-condition checks.
462
+ - **Events** — `crm:email_event.opened` / `.clicked` / `.bounced` /
463
+ `.complained` / `.unsubscribed` with Zod-validated payloads in
464
+ `event-catalog`, wired into `crmEventDefinitions[]`.
465
+ - **In-memory adapter** — `InMemoryEmailEventRepository` (tests +
466
+ dev). Hosts implement against Mongo / Postgres / etc.
467
+
468
+ ### Added — re-exports from `@classytic/primitives@0.6.0`
469
+
470
+ To eliminate duplicate type imports at consumer call sites:
471
+
472
+ ```ts
473
+ import {
474
+ appendStatus,
475
+ StatusChangeEntry,
476
+ StatusHistory,
477
+ timeInStatus,
478
+ lastTransitionTo,
479
+ } from '@classytic/crm';
480
+ ```
481
+
482
+ These pass through from `@classytic/primitives/status-history`. The
483
+ existing entity-specific `LeadStatusEntry` / `OpportunityStatusEntry` /
484
+ etc. names are still exported for back-compat — they remain structurally
485
+ compatible with `StatusChangeEntry<TStatus>`.
486
+
487
+ ### Changed
488
+
489
+ - Peer dep `@classytic/primitives` bumped from `>=0.5.0` to `>=0.6.0`
490
+ to access the new `status-history` subpath and value-typed primitives.
491
+ - Test harness `buildHarness()` now also exposes an
492
+ `emailEvents: EmailEventService` and `emailEventRepo` for integration
493
+ tests that exercise the engagement → drip path.
494
+
495
+ ### Dependencies
496
+
497
+ - `@classytic/primitives@>=0.6.0` (peer, was `>=0.5.0`)
498
+ - `zod@>=4.0.0` (peer, unchanged)
499
+
500
+ ## [0.1.0] — 2026-04-18
501
+
502
+ Initial release.
503
+
504
+ ### Added
505
+
506
+ - **Eight domain entities.** `Contact`, `Account`, `Lead`, `Opportunity`, `Pipeline` (+ nested `Stage`), `Activity`, `Note`, `LossReason`.
507
+ - **Three state machines.** Lead (`new` → `contacted` → `qualified` → `converted`, + `disqualified` / `nurturing` branches), Opportunity (`open` → `won` / `lost` / `abandoned` with in-pipeline Stage movement), Activity (`planned` → `in_progress` → `completed` / `cancelled`). All transitions CAS-guarded with explicit `*_TRANSITIONS` maps and `canTransition*` guards.
508
+ - **Six services.**
509
+ - `ContactService` — CRUD + tag filtering + email lookup.
510
+ - `AccountService` — CRUD + domain lookup.
511
+ - `PipelineService` — create, archive, stage helpers (`getStage`, `getFirstStage`, `getNextStage`).
512
+ - `OpportunityService` — create at first stage (or specified), `moveToStage` with advance/regress event disambiguation, `win` / `lose` (with `lostReasonId`) / `abandon`.
513
+ - `LeadService` — CRUD + full lifecycle (`markContacted`, `qualify`, `disqualify`, `nurture`) + `convert()` — the crown-jewel verb that atomically creates Contact + Account + Opportunity and transitions the lead to `converted`.
514
+ - `ActivityService` — polymorphic activities (`plan`, `start`, `complete`, `cancel`) + notes (`addNote`, `listNotesBySubject`).
515
+ - **Pure calculators.**
516
+ - `defaultLeadScorer` + `composeScorers(...rules)` — host injects whatever rubric (firmographics, in-app signals, ML prediction, enrichment) via a `(lead: Lead) => number` function.
517
+ - `forecast(opportunities, options?)` — committed + weighted pipeline by bucket (default ISO month), with custom bucket function support. Currency-mismatch detection.
518
+ - `pipelineConversion(opportunities, stageIds)` — where do deals die? returns per-stage current count + won/lost counts + conversion rate.
519
+ - **Nine repository ports** — one per entity (plus `EventTransport`, `IdGenerator`, `Clock`, optional `OutreachPort`). Full in-memory reference implementations shipped.
520
+ - **Typed error hierarchy** rooted at `CrmError`: `NotFoundError`, `InvalidLeadTransitionError`, `InvalidOpportunityTransitionError`, `InvalidActivityTransitionError`, `StageNotInPipelineError`, `LeadAlreadyConvertedError`, `DuplicateLeadError`, `EmptyPipelineError`.
521
+ - **Nineteen lifecycle events** (`CRM_EVENTS`): covering lead/account/contact/opportunity/pipeline/activity/note verbs, all following `crm:<resource>.<verb>` per PACKAGE_RULES rule 12.
522
+ - **Optional `OutreachPort`** — one-method (`send`) + `NullOutreachPort` + `InMemoryOutreachPort` (recording). Host subscribes to `crm:activity.*` events and drives the port from their automation layer (keeps pacing / retry / bounce handling out of the core).
523
+ - **Testing.** `vitest.config.ts` projects split `unit` + `integration` per [testing-infrastructure.md](../../testing-infrastructure.md). 48 tests (7 files) — all green in <1.5s.
524
+
525
+ ### Design choices
526
+
527
+ - Pure-TS port-based pattern — consistent with `@classytic/manufacturing`, `@classytic/assets`, `@classytic/fleet`, `@classytic/carrier-*`.
528
+ - Separate Lead + Opportunity models (erpnext style) rather than unified (odoo style) — cleaner semantics for pre-qualification vs in-pipeline data and enables distinct analytics.
529
+ - Polymorphic Activity / Note via `subjectKind` + `subjectId` so hosts can extend without schema changes.
530
+ - `LeadService.convert()` is atomic in intent and sequenced for auditability — it creates Account, then Contact, then Opportunity, then transitions the lead. If a downstream repo fails, the ones that succeeded persist (hosts using Mongoose sessions get atomicity at their layer).
531
+ - Campaigns, appointment booking, competitors, contracts, team routing, RBAC, custom-field validation, email-thread parsing deferred to v0.2+ to keep the surface focused.