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