Crm

CRM & Marketing engineStable
@wabbit/tome-crmv0.6.3

Tome CRM layer — contacts, accounts, opportunities, activities with sister-layer integration adapters (intake, deals, realtime, territory, ai-stubs).

Install
  1. Get a registry token from your credentials page. You need a purchase that includes this package, or a Craft Library membership.

  2. Add the registry and your token to the .npmrc at the root of your project, with your token in place of YOUR_TOKEN:

    @wabbit:registry=https://npm.wabbit.com/
    //npm.wabbit.com/:_authToken=YOUR_TOKEN
  3. Then install:

    npm install @wabbit/tome-crm

Overview

@wabbit/tome-crm

Tome CRM layer — contacts, accounts, opportunities, activities, with sister-layer integration adapters (intake, deals, realtime, territory, ai-stubs). Description copied verbatim from package.json.

Layer: domain (per ARCHITECTURE.md) — depends on @wabbit/tome-core and @wabbit/tome-workflow (the opportunity stage-transition guard delegates its graph check to workflow). @wabbit/tome-marketing declares crm as a required peer; @wabbit/tome-deals integrates with it as an optional sister layer.

Install

pnpm add @wabbit/tome-crm

Peer ranges, copied from package.json:

| Peer | Range | Optional? | |---|---|---| | payload | >=3.67.0 | no | | typescript | >=5.7.0 | no | | lucide-react | >=0.460.0 | yes | | @wabbit/tome-core | >=1.14.0 <2.0.0 | no | | @wabbit/tome-workflow | >=0.1.1 <1.0.0 | no — imported by the stage-transition guard |

dependencies: server-only@^0.0.1 (guards the ./server subpath below).

lucide-react is genuinely optional: the layer's Briefcase sidebar icon is loaded lazily, and without the package the admin sidebar shows the nav domain's default icon instead.

60-second quickstart

The current API is a single createCrmLayer(config) call that returns the four collection configs (initCrm is a deprecated pure alias, kept for back-compat only). Register custom activity types before calling it — the activity-type registry freezes the instant createCrmLayer runs:

import { buildConfig } from 'payload'
import { createCrmLayer, defineCrmActivityType } from '@wabbit/tome-crm'

defineCrmActivityType({ key: 'demo-call', label: 'Demo Call' }) // optional, must precede createCrmLayer

export default buildConfig({
  collections: [
    ...createCrmLayer({ matchStrategy: 'email' }), // 'email' (default) | 'custom' (requires customMatcher)
    // ...your other collections
  ],
})

createCrmLayer also validates opportunityStageConfig (falls back to DEFAULT_OPPORTUNITY_STAGES — override with defineCrmStages(...)) and registers the layer into @wabbit/tome-core's layerRegistry for the admin sidebar manifest. It throws if matchStrategy: 'custom' is passed without a customMatcher function.

Defaults worth knowing before your first migration: collections are crm-contacts, crm-accounts, crm-opportunities, crm-activities (override with contactsSlug etc.); every assignedTo field relates to users (repCollection); contacts only gain a linkedMember relationship when you pass memberSlug; new contacts auto-link to an account whose domain matches their email domain (autoLinkAccountByDomain, default true); and in the deals cascade a deal status of accepted wins the opportunity (winTriggerStatus).

The activity-type registry is module-level and freezes on the first createCrmLayer call, so in a test file that calls createCrmLayer more than once, register custom types once at the top of the file.

API surface

The exports map has four subpaths: ., ./server, ./widgets, ./test.

`.` — config-time surface, safe to import anywhere:

| Export | What it is | |---|---| | createCrmLayer(config?) | Top-level layer entry — returns CollectionConfig[] | | createCrmContactCollection / createCrmAccountCollection / createCrmOpportunityCollection / createCrmActivityCollection | Per-collection factories, for sites that want one collection in isolation | | defineCrmActivityType({ key, label, defaultStatus?, payloadShape? }) | Register a custom activity type (not a deprecated name — this is the activity-type registry, unrelated to the create/define collection-factory pair) — must be called before createCrmLayer | | DEFAULT_OPPORTUNITY_STAGES, defineCrmStages, getStageDefinition, validateStageConfig | Opportunity-stage config helpers | | CRM_CAPABILITIES, CrmCapability | Capability vocabulary for consumer role wiring | | CRM_REALTIME_EVENTS, CrmRealtimeEvent | Realtime event name constants | | CRM_AI_FEATURES, CrmAiFeature | AI-stub feature name constants | | Types | TomeCrm{StageCategory,StageDefinition,OpportunityStageConfig,ContactLifecycleStage,AccountType,ActivityType,ActivityStatus,ActivityOutcome,Address,Activity*Payload,Contact,Account,Opportunity,Activity,MatchContactInput,MatchContactResult,ActivityTypeDefinition,Config,ExtraFields} |

Deprecated aliases (pure renames, @deprecated-tagged, removal at this package's next major): initCrm → createCrmLayer; defineCrmContactCollection → createCrmContactCollection; defineCrmAccountCollection → createCrmAccountCollection; defineCrmOpportunityCollection → createCrmOpportunityCollection; defineCrmActivityCollection → createCrmActivityCollection.

`./server` — behind import 'server-only': matchOrCreateContact, advanceOpportunityStage, findContactByEmail, findOpenOpportunityForAccount, findOrCreateAccountByDomain, recordActivity, getOpportunityWithTimeline (+OpportunityTimeline type), suppression helpers (markContactUnsubscribed, markContactBounced, clearContactSuppression, isContactSendable, filterSendableContactIds, recordCrmActivityWithDedup), access presets (buildAccountDeleteGuard, crmContactsAccess, crmAccountsAccess, crmOpportunitiesAccess, crmActivitiesAccess, repWhereClause, accountRepWhereClause), and the cross-layer integration entry points: buildIntakeMatchStep/wireIntake/createIntakeOpportunity (intake), resolveOpportunityForDeal/onDealStatusChange/buildDealsCrmBridge (deals), lookupOwnerByZipLazy (territory), emitCrmEvent (realtime), registerCrmAiStubs (ai-stubs). For the deals bridge, pass buildDealsCrmBridge(config) to initDeals({ onStatusChange }) — The bridge is async (deal, prevStatus, req); pass it the same config object you gave createCrmLayer so slugs and stages line up. wireDeals is @deprecated: it only ever checked that the deals layer was present, it never attached the cascade, and it will be removed in v1.0. All sister-layer detection is lazy/runtime via the layer registry, not a hard import.

`./widgets` and `./test` — both declared in package.json#exports but currently empty placeholders (export {}, verified by reading the files directly — no admin widgets or mock harness ship yet). Treat as reserved subpaths, not working surface.

Server / client posture

Fully server-side. The main barrel is Payload CollectionConfig factories, constants, and types — no React. ./server is hard-guarded with import 'server-only' so a client-component import fails the Next.js build rather than silently bundling. sideEffects: false in package.json (a bare boolean, not an array) — crm ships no import-side-effect registration modules.

Links

Extending this package

  • New activity types: call defineCrmActivityType before createCrmLayer — the registry throws on duplicate keys and freezes at createCrmLayer call time, so late registration is a hard error, not a silent no-op.
  • Custom opportunity stages: build a config with defineCrmStages(...) and pass it as createCrmLayer({ opportunityStageConfig }); it must include at least one won-category stage or validateStageConfig throws.
  • Sister-layer integrations (deals, marketing, intake, territory) are all optional and detected via hasLayer() at runtime — none of those layers is required to use crm standalone (the required peers are payload, typescript, @wabbit/tome-core and @wabbit/tome-workflow).

Testing

pnpm --filter @wabbit/tome-crm test runs the Vitest suite. The published @wabbit/tome-crm/test subpath is still an empty placeholder, so tests pass a stub payload (an object with the find / create / update methods the helper under test calls) to the ./server functions. ./server imports server-only, which throws outside a React Server environment unless your runner aliases it to an empty module.

Exports

  • @wabbit/tome-crm
  • @wabbit/tome-crm/server
  • @wabbit/tome-crm/widgets
  • @wabbit/tome-crm/test

Changelog

v0.6.3patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.6.2patch

07331e1: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behaviour change.

  • 07331e1: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behaviour change.
  • c023c45: The layer now loads without `lucide-react` installed; the optional peer supplies only the sidebar icon. The `Briefcase` icon is imported lazily when the layer registers. Previously a static import made every entry point that registers the layer throw on an install without `lucide-react`. Without it, the admin sidebar shows the nav domain's default icon.
v0.6.1patch

ce3d12d: Adopt `@wabbit/tome-core/fields/address` and `@wabbit/tome-core/utilities/relationId` at the sites the audit counted (2026-09-01 sale-readiness audit §5.1, T3(g)). **No stored field name, and no emitted field array, changes anywhere in this changeset** — each adopter passes the vocabulary it already stores, and each ships a characterisation test that was written from the pre-change source, run green against the untouched factory, and run green again after. **Address group — five sites, one implementation.** - `@wabbit/tome-crm` — `accounts` and `contacts` each carried a byte-identical seven-field `address` group. Both now spread `postalAddressFields({ vocabulary: 'legacy-crm' })` after their own `name` line (`name` is the company/contact line, not a postal line). `tests/address-characterisation.test.ts` pins both groups whole. - `@wabbit/tome-deals` — `billingAddress` and `shippingAddress` inside the frozen Customer Snapshot were copies three and four. They now come from one `buildSnapshotAddressGroup` helper: `name` + `company` prepended locally, the six postal lines from core, and the eight per-field labels plus the `'US'` country default passed through core's `fieldOverrides` seam. The snapshot is a legal-offer record frozen after send, so a field-name change would orphan the address on every deal already sent; `tests/address-characterisation.test.ts` pins both groups and the fact that they differ only in the group label and the recipient line's label. - `@wabbit/tome-fulfillment` — the fifth copy, and the only one that validated `country`. Its postal lines stay FLAT at collection top level (they are stored columns with PII rows and a GDPR registration behind them), now via `postalAddressFields({ vocabulary: 'postal', required: true, validateCountry: true })`. The ISO-3166 validator and its uppercase-normalising hook moved into core verbatim; because a moved function is a new object, `tests/address-characterisation.test.ts` pins the whole top-level field ORDER plus the validator's and hook's BEHAVIOUR (accepts `US`, rejects `usa`, rewrites `' us '` to `'US'`), not their identity. **`relationId` — the four-return-types problem.** - `@wabbit/tome-lms` — twelve modules under `src/server` (`academy`, `catalog`, `certificates`, `course`, `dashboard`, `enrollment`, `grades`, `leaderboard`, `learnerShell`, `notes`, `profile`, `reviews`) carried a byte-identical `string | null` copy. They import `relationId` from core now. One behavioural difference, strictly an improvement: on a malformed populated doc (`{ id: null }`, `{ id: {} }`) the old copy returned the STRING `'null'` / `'[object Object]'` as an id; core returns `null`. `tests/relation-id-adoption.test.ts` pins the adoption itself, because adoption is the thing that decays — the July 2026 audit's finding, repeated verbatim in September, was "extraction keeps happening, adoption does not." **Not migrated, deliberately:** `src/guards`, `src/utilities/{grading,prerequisites,progress}.ts`, `src/hooks/**`, `src/server/mutations/helpers.ts` and `src/server/awardGate.ts` return `string | number` or `undefined`. Migrating those is a semantic change, not an import change, and belongs in a pass that owns their call sites. The new test names them as out of scope so the next reader does not have to re-derive why. - `@wabbit/tome-sc` — the registry sub-cluster's copy is gone; `collections/registry/shared.ts` re-exports core's `relationId`, keeping `extractId` as a local alias (the module is private to that sub-cluster). **This one WIDENS:** the sc copy returned `string | number`, so a populated doc's numeric id came through unstringified. It is now stringified, which makes `===` between two resolved ids agree — the behaviour every call site in the cluster already assumed. Ids handed back to `payload.find`/`update` are unaffected, since Payload accepts either form in a `where` clause. sc's 179 tests stay green. - `@wabbit/tome-crm` — the inline ternary in `integration/deals.ts` (`typeof oppRaw === 'object' ? oppRaw.id : oppRaw`) was the fifth shape and had the same numeric-id asymmetry; it is one `relationId(deal.opportunity)` call now. **`fetchMemberId` ×4 — one implementation (sc).** `asset-availability`, `fleet-logs` and `fleet` each carried a verbatim copy of the auth-user → Member-row lookup, and `resource-requests` carried its projecting twin. All four now import from `src/access/fetchMemberId.ts`, which documents why each query knob is load-bearing: `overrideAccess: true` (the member collection's own read access may itself depend on membership, so without the bypass this is a circular check that denies the owner their own row), `depth: 0`, `pagination: false`. The id is returned in its STORED type here rather than through `relationId` — this is an identity read fed straight back into a `where` clause, not a relationship read. `tests/fleet-shared-helpers.test.ts` pins the adoption, the three knobs, and the null-for-anonymous contract.

  • ce3d12d: Adopt `@wabbit/tome-core/fields/address` and `@wabbit/tome-core/utilities/relationId` at the sites the audit counted (2026-09-01 sale-readiness audit §5.1, T3(g)). **No stored field name, and no emitted field array, changes anywhere in this changeset** — each adopter passes the vocabulary it already stores, and each ships a characterisation test that was written from the pre-change source, run green against the untouched factory, and run green again after. **Address group — five sites, one implementation.** - `@wabbit/tome-crm` — `accounts` and `contacts` each carried a byte-identical seven-field `address` group. Both now spread `postalAddressFields({ vocabulary: 'legacy-crm' })` after their own `name` line (`name` is the company/contact line, not a postal line). `tests/address-characterisation.test.ts` pins both groups whole. - `@wabbit/tome-deals` — `billingAddress` and `shippingAddress` inside the frozen Customer Snapshot were copies three and four. They now come from one `buildSnapshotAddressGroup` helper: `name` + `company` prepended locally, the six postal lines from core, and the eight per-field labels plus the `'US'` country default passed through core's `fieldOverrides` seam. The snapshot is a legal-offer record frozen after send, so a field-name change would orphan the address on every deal already sent; `tests/address-characterisation.test.ts` pins both groups and the fact that they differ only in the group label and the recipient line's label. - `@wabbit/tome-fulfillment` — the fifth copy, and the only one that validated `country`. Its postal lines stay FLAT at collection top level (they are stored columns with PII rows and a GDPR registration behind them), now via `postalAddressFields({ vocabulary: 'postal', required: true, validateCountry: true })`. The ISO-3166 validator and its uppercase-normalising hook moved into core verbatim; because a moved function is a new object, `tests/address-characterisation.test.ts` pins the whole top-level field ORDER plus the validator's and hook's BEHAVIOUR (accepts `US`, rejects `usa`, rewrites `' us '` to `'US'`), not their identity. **`relationId` — the four-return-types problem.** - `@wabbit/tome-lms` — twelve modules under `src/server` (`academy`, `catalog`, `certificates`, `course`, `dashboard`, `enrollment`, `grades`, `leaderboard`, `learnerShell`, `notes`, `profile`, `reviews`) carried a byte-identical `string | null` copy. They import `relationId` from core now. One behavioural difference, strictly an improvement: on a malformed populated doc (`{ id: null }`, `{ id: {} }`) the old copy returned the STRING `'null'` / `'[object Object]'` as an id; core returns `null`. `tests/relation-id-adoption.test.ts` pins the adoption itself, because adoption is the thing that decays — the July 2026 audit's finding, repeated verbatim in September, was "extraction keeps happening, adoption does not." **Not migrated, deliberately:** `src/guards`, `src/utilities/{grading,prerequisites,progress}.ts`, `src/hooks/**`, `src/server/mutations/helpers.ts` and `src/server/awardGate.ts` return `string | number` or `undefined`. Migrating those is a semantic change, not an import change, and belongs in a pass that owns their call sites. The new test names them as out of scope so the next reader does not have to re-derive why. - `@wabbit/tome-sc` — the registry sub-cluster's copy is gone; `collections/registry/shared.ts` re-exports core's `relationId`, keeping `extractId` as a local alias (the module is private to that sub-cluster). **This one WIDENS:** the sc copy returned `string | number`, so a populated doc's numeric id came through unstringified. It is now stringified, which makes `===` between two resolved ids agree — the behaviour every call site in the cluster already assumed. Ids handed back to `payload.find`/`update` are unaffected, since Payload accepts either form in a `where` clause. sc's 179 tests stay green. - `@wabbit/tome-crm` — the inline ternary in `integration/deals.ts` (`typeof oppRaw === 'object' ? oppRaw.id : oppRaw`) was the fifth shape and had the same numeric-id asymmetry; it is one `relationId(deal.opportunity)` call now. **`fetchMemberId` ×4 — one implementation (sc).** `asset-availability`, `fleet-logs` and `fleet` each carried a verbatim copy of the auth-user → Member-row lookup, and `resource-requests` carried its projecting twin. All four now import from `src/access/fetchMemberId.ts`, which documents why each query knob is load-bearing: `overrideAccess: true` (the member collection's own read access may itself depend on membership, so without the bypass this is a circular check that denies the owner their own row), `depth: 0`, `pagination: false`. The id is returned in its STORED type here rather than through `relationId` — this is an identity read fed straight back into a `where` clause, not a relationship read. `tests/fleet-shared-helpers.test.ts` pins the adoption, the three knobs, and the null-for-anonymous contract.
  • 0836ef5: dist now raw-Node loadable: relative specifiers get explicit extensions post-build. `build` gains `&& node ../../scripts/fix-dist-extensions.mjs --strict` as its last step, joining the 13 packages that already ran it. tsup builds `bundle: false` and emits relative specifiers exactly as the TypeScript source wrote them — extensionless — which bundlers resolve and raw Node does not (ESM `ERR_MODULE_NOT_FOUND`; CJS worse, `require('./x')` finds the ESM `.js` twin and Node 22+ `require(esm)` then dies on that file's own extensionless import). Every consumer outside a bundler hit this: the payload CLI under plain node, `generate:types`, `generate:importmap`, ops scripts, codegen tools. No source changes, no API changes, and bundler consumers are unaffected — extensioned relative specifiers are universally resolvable. Two supporting changes made the wiring possible, both in repo scripts rather than package source. `fix-dist-extensions.mjs` now skips bundler-asset specifiers (`.css`, `.module.css`, `.scss`, fonts, images, shaders) by explicit extension allowlist instead of reporting them as unresolvable — that single gap is why the 13 prior adopters were exactly the 13 packages that ship no CSS, since `--strict` exited 1 on any package with a relative stylesheet import. Dotted MODULE names (`./config.meta`, `./x.variants`, `./y.demo`) are deliberately NOT treated as assets and still get `.js`/`.cjs` appended. `assert-node-loadable.mjs` gained the matching carve-outs so the new repo-wide CI gate reports real defects only: a resolution failure whose path lands under `node_modules` is a peer SKIP (next@15 has no exports map, so `next/image` fails as an absolute path), and a bundler-asset load failure is an environmental SKIP (CJS surfaces it as `SyntaxError: Unexpected token '.'` raised from inside the stylesheet). Verified before/after on four packages built one at a time: print 8 FAIL → 0, readout 22 FAIL → 0, ai 3 FAIL → 0, gamification 2 FAIL → 0 (its failure was the other signature — a `directory import` missing `/index`). cop was already clean on a fresh build, so the audit's "27 of 46 fail" figure includes at least one package whose local dist was merely stale.
  • 4aeedad: One `LayerFactoryConfig` every layer factory's config extends, and one factory verb. Fourteen layer packages end in the same one call a consumer writes into `payload.config.ts`, and no two agreed on what `config` may contain: full seam vocabulary in three (org, lms, ledger), partial in six, NONE in six (2026-09-01 sale-readiness audit §5.3). A site that learned `adminGroup` from org and `hooks` from sc discovered, package by package, that six factories accept neither — not because the seam had been rejected, but because nothing said it existed. **New in core (a NEW exports-map subpath, hence the minor):** `@wabbit/tome-core/utilities/layerFactoryConfig` exports the `LayerFactoryConfig` interface — `adminGroup`, `access` (per-collection override map), `hooks` (appended via `mergeHooks`, never replacing), `extraFields`, `fieldOverrides`, `omitFields`, `fieldOrder`, `slugs` — and `applyLayerFactoryConfig(collections, config)`, which honours the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `create*Layer` config now extends it.** Twelve extend `LayerFactoryConfig` directly and APPLY it through `applyLayerFactoryConfig` (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, `adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted. Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). **economy** and **gamification** both declare `@wabbit/tome-core` as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches `registerLayer` through a lazy `require()` in a try/catch for exactly this reason. `applyLayerFactoryConfig` is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by `assert:no-forked-primitives`. The trigger is stated: the day core becomes a required peer, delete the note and add one line. Both take the type via `import type`, which is erased at runtime. Two packages drop seams EXPLICITLY rather than accept-and-ignore. **chrome** extends `Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>` because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against `CollectionConfig`, and chrome's slugs already have direct per-surface knobs (`header.slug`, `footer.slug`) a parallel map could contradict. The one seam it keeps, `adminGroup`, IS applied: globals carry `admin.group` exactly as collections do. **rpg** extends `Omit<LayerFactoryConfig, 'access'>` because `CharacterSheetsConfig` is a single collection's config that doubles as the layer factory's config, and its own `access` already means "this collection's access object" — one level shallower than the base's slug-keyed map. Two meanings under one name is the confusion this interface exists to end. **Factory-verb convergence.** Three verbs were live. `createWorkflowLayer(config?)` is new in `@wabbit/tome-workflow` (a new export — hence the minor) and returns a spreadable, deliberately EMPTY `CollectionConfig[]`: this layer is an engine, not a collection set, so the empty array is the honest answer and lets `...createWorkflowLayer()` compose exactly like every sibling. Its `WorkflowLayerConfig` omits every seam for the same reason, and exists as the stable place a real option will land. `createGamificationLayer` and `createRpgLayer` are pure aliases of `registerGamificationLayer` / `registerRpgLayer`. `initWorkflow`, `registerGamificationLayer` and `registerRpgLayer` are all `@deprecated` with sunset at each package's next major; none is removed. **Forcing function:** `scripts/assert-layer-factory-contract.mjs` + `pnpm assert:layer-factory-contract`, wired into `platform-discipline.yml` after `assert:layer-version` (source reading only, pre-build). Every exported `create*Layer` must take a config parameter whose type resolves to `LayerFactoryConfig` — through `extends`, an intersection, or an explicit `Omit<…>` — with verb aliases followed to their `register*`/`init*` target. Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. `docs/guides/create-a-new-layer-package.md` gains a "The factory contract" section stating the rule and the three permitted responses. Three factories are ALLOWLISTED with a reason each: `createAiLayer` returns credential wiring and owns no collections, so every seam is meaningless to it; `createFormsLayer` and `createIntakeLayer` are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files. **Peer floors:** accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise `@wabbit/tome-core` to `>=1.14.0 <2.0.0`. The new subpaths do not exist below that, and a too-low floor is how `ERR_PACKAGE_PATH_NOT_EXPORTED` reached crowdfund's consumers once already. These are marked `patch` because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.
  • b01ca1f: Pin each layer's registered version to `package.json` instead of a hand-typed literal. `registerLayer(name, { version })` is the contract a consumer reads back through `hasLayer`/`getLayer` to gate on a layer's capability. Eight packages passed a literal that nobody compared to the manifest, so an up-to-date install advertised an old contract and every gate keyed on it failed **silently** — nothing throws when a version string is stale. | Package | Registered | Actual | | --------------------------- | ------------------------------- | ------ | | `@wabbit/tome-rpg` | `'0.1.2'` | 0.2.2 | | `@wabbit/tome-gamification` | `'0.1.0'` | 0.3.1 | | `@wabbit/tome-crm` | `'0.3.0'` | 0.5.0 | | `@wabbit/tome-ai` | `'0.1.0'` | 0.4.0 | | `@wabbit/tome-forms` | `TOME_FORMS_VERSION = '0.1.0'` | 0.3.2 | | `@wabbit/tome-intake` | `TOME_INTAKE_VERSION = '0.1.0'` | 0.3.1 | | `@wabbit/tome-marketing` | `'0.1.0'` | 0.4.0 | | `@wabbit/tome-chrome` | `'0.6.0'` | 0.8.5 | Each package now carries a leaf `src/version.ts` exporting `<NAME>_LAYER_VERSION`, read by its `registerLayer` call — the shape nine sibling packages (accounts, catalog, crowdfund, deals, economy, fulfillment, ledger, lms, org, workflow) already used and stayed accurate with. Forms' and intake's module-local `TOME_*_VERSION` consts move into that module: a _named_ constant was never the guarantee, a _pinned_ one is. The forcing function ships with the fix. `pnpm assert:layer-version` (new, wired into `platform-discipline.yml` pre-build) parses every `registerLayer` call in the repo, resolves its `version` argument through literals and consts, and fails on any disagreement with the manifest — so this cannot recur in a package that never gets around to writing the test. Seven of these eight were found by the 2026-09-01 sale-readiness audit; chrome was found by the assert itself on its first run. crm, forms, intake, marketing and rpg gained their first test suite in the process (`tests/layer-version.test.ts`) and were removed from the `assert:test-floor` starting-debt allowlist. No runtime behavior changes for a consumer already on a current install — the version a layer reports simply becomes true.
  • 73081e6: Manifest metadata: `homepage`, `bugs`, `engines`. All 46 publishable manifests were missing the three fields a consumer sees before any code (2026-09-01 sale-readiness audit §6). Metadata only — no source, no build, no runtime change. - `homepage` deep-links to that package README on GitHub (`.../tree/main/packages/<dir>#readme`). Without it a registry page links to the monorepo root and the reader has to guess which of 46 folders they want. - `bugs.url` points at the repo issue tracker, so a paying customer has a place to report a defect that is not email. - `engines.node` is `>=22`, matching the root `engines` and `.nvmrc` set the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use for `node --test`, and a package installed on Node 20 fails at a runtime the installer cannot connect back to the version. The forcing function ships with the change: `scripts/assert-manifest-metadata.mjs` (root `pnpm assert:manifest-metadata`, wired into `platform-discipline.yml` beside `assert:license-metadata`) fails when any publishable manifest lacks `description`, `repository.directory` matching its own folder, `homepage`, `bugs`, `engines.node` equal to the repo floor, `license`, `files` or `sideEffects`. It reported 138 violations before this change and 0 after.
  • 670d2a1: **`normalizeEmail` adoption on the email fields the 2026-09-01 audit flagged, plus the one swallowed error that had no logger.** Email is the cross-layer join key — forms hands a submission to intake, intake to CRM contact matching, CRM to marketing suppression — and `findContactByEmail` / `matchOrCreateContact` normalize before they query. Any layer that stores a raw address forks the same person into two records at the first hand-off. - **intake** — `intake-submissions.email` (required, indexed, the CRM join column) now normalizes through core's canonical `normalizeEmail` via a new `normalizeEmailField` field hook. Field-level rather than folded into `beforeValidateIntake`, which returns early on anything but a create: an admin retyping an address on update is exactly the case a create-only hook misses. - **forms** — the `emailRecipient` config field gains the same hook, and the email field-type descriptor's `sanitize` stops hand-rolling `v.trim().toLowerCase()` and delegates to `normalizeEmail`. That fork agreed byte-for-byte today, which is the problem: the day the shared helper learns anything, forms silently stops agreeing. (`assert:no-forked-primitives` catches exact-body forks, not inline expressions like this one.) - **crm** — `record-crm-activity-with-dedup.ts`'s account-resolution `catch` was the one swallowed error in the repo's sample with no logger call at all (audit §6). The swallow is correct — an activity row without an account link beats a dropped webhook — but a contacts lookup failing there is normally a slug misconfiguration or a permissions change, and every later activity lands unlinked until someone notices. It now warns through `req.payload.logger.warn` with a `console.warn` fallback, naming the contact and the slug, matching `access/presets.ts` and `hooks/stage-change-dispatch.ts`. Normalization stays deliverable-address preserving (trim + lowercase only; no dot-stripping, no plus-tag removal) — asserted, because the stored value is what gets emailed.
  • 670d2a1: First test suites for the four packages the 2026-09-01 sale-readiness audit named as "security-relevant code with no test" (§7). No behaviour changed in marketing or intake; forms and crm ship one behaviour change each, described below and covered by the same suites. - **marketing** — `webhooks/verify-utils` gets published HMAC-SHA256 known-answer vectors (RFC 4231 TC2, quick-brown-fox) plus a cross-check against `node:crypto` as an independent oracle, and a full contract table for `timingSafeEqualHex` (case folding, single-nibble mismatch, length short-circuit, and the fact that it does not validate hex — two empty strings compare equal, so callers must check presence first). Both adapters' verify functions are covered end to end: valid token accepts, one-character change rejects, missing/empty/differently-cased header rejects, malformed URL fails closed, and the unconfigured-secret pass-through is asserted explicitly rather than left implicit. The `secret` (Encharge) vs `webhookSecret` (Kit) parameter-name split is pinned as a contract, not harmonised: passing the other package's key name leaves the secret `undefined`, which means bypass mode — a rename would open both endpoints silently. Also documents a real Web Crypto/node divergence: an empty secret THROWS rather than signing, which is the fail-closed outcome and is now pinned. - **intake** — `withIntakeAccess` gets the full truth table: `create` denied for everyone including admins (all writes go through `submitIntakeAction` with `overrideAccess: true`), read/update per preset, delete admin-only regardless of preset, plus wrapper semantics (overrides incoming access, shallow clone, defines exactly four keys). The file's `TODO: wire to tome-core capability registry` is untouched — the suite pins the CURRENT roles-array heuristic, including its case-sensitivity and the fact that it ignores `role`/`_populatedRoles`, so the wiring change arrives as a deliberate diff. - **forms** — `server/targets/webhook` covered for request shape (method, header merge and override, `payloadTransform`) and every failure path (non-2xx with detail, 200-char body truncation, body-read failure, network rejection, non-`Error` throw, never throwing to the caller). The absence of any timeout is asserted explicitly rather than glossed: `fetch` is called with no `AbortSignal`, so a hanging endpoint hangs the submission — that assertion is the ticket, and it flips loudly when a timeout lands. - **crm** — `access/presets` covered for all three paths: seeded capability grants, the legacy `admin`/`superadmin`/`super-admin` roles-array fallback, and the bootstrap fallback that opens `crm:read` to any authenticated session. The last one is asserted in both directions — what it opens (read on every collection) and what it still refuses (write, delete) — because an un-seeded production site is running on it. `buildAccountDeleteGuard` and the once-per-process production warning are covered too. All four packages gain a `test` script (`vitest run`) and a vitest devDependency, and are removed from `scripts/assert-test-floor.mjs`'s ALLOWLIST — a stale allowlist entry fails the assert in both directions.
  • 4aeedad: Delegates to `@wabbit/tome-workflow`; local guards deprecated. `@wabbit/tome-workflow` is the extracted canonical home for the status-transition table and the keyed side-effect registry — its own module headers say so, naming deals as the source it was ported from — and none of the three packages that still shipped a copy depended on it (2026-09-01 sale-readiness audit §5.1). All three now declare `@wabbit/tome-workflow` as a required, explicitly non-optional peer (`>=0.1.1 <1.0.0`) with a `workspace:*` devDependency twin, and all three raise their `@wabbit/tome-core` peer floor to `>=1.14.0 <2.0.0` (see the LayerFactoryConfig changeset — the new core subpaths do not exist below it). **deals — full delegation, five functions deprecated.** `defineDealSideEffect`, `replaceDealSideEffect`, `getDealSideEffect`, `getRegisteredSideEffectKeys` and `_resetSideEffectRegistry` are now thin wrappers over `defineWorkflowSideEffect` / `replaceWorkflowSideEffect` / `getWorkflowSideEffect` / `getRegisteredWorkflowSideEffectKeys` / `_resetWorkflowSideEffectRegistry`, each `@deprecated` with sunset at the next major. `findTransition` and `validateWorkflow` likewise wrap workflow's `findTransition` / `validateTransitionTable`. `resolveWorkflow` and `DEFAULT_DEAL_WORKFLOW` are NOT deprecated: the default quote lifecycle is deals' own domain data, and `resolveWorkflow` resolves an artifact type's optional workflow override, a deals concept with no workflow-layer equivalent. Two consequences of the deals delegation are invisible at a call site and are stated in the module headers. First, the side-effect store moves from a module-local `Map` to `globalThis` keyed by `Symbol.for` — a FIX, not a byproduct: deals ships separate ESM and CJS builds, so a handler registered through one instance was invisible through the other, and the transition then advanced with its side effect silently skipped. Workflow's own header names deals' local `Map` as the hazard it deliberately did not repeat. Second, the key namespace is now shared with every other workflow consumer, so a duplicate key across two layers throws at registration instead of quietly shadowing — the intended duplicate policy in both packages. One behaviour change to note: `validateWorkflow`'s returned message prefix is now `[tome-workflow]` rather than `[tome-deals]`, because the validator is workflow's; that function shipped with zero call sites and zero tests. **marketing — lookup delegated, three semantics kept local.** The §9 campaign lifecycle is now published (module-scope, not from the barrel) as `MARKETING_CAMPAIGN_TRANSITION_TABLE`, derived from the existing adjacency map so the two cannot disagree, and the allow decision plus the "allowed from here" list come from workflow's `findTransition` / `allowedTransitionsFrom`. The adjacency map is kept as the source it is derived from because a flat table cannot distinguish a deliberately terminal status (`archived`, empty list) from a status absent from the map entirely (data corruption) — this hook has always reported those as two different errors, and collapsing them would turn "your database has an unknown status" into "that transition is not permitted". `buildStageTransitionGuard` is NOT deprecated: it is a Payload `beforeChange` hook factory and workflow ships no hook; `guardedTransition` is a server-side call that owns the write, and adopting it moves the transition out of the collection hook entirely. That is marketing's 1.0 question. **crm — lookup delegated, three semantics kept local, and this is the one that could not be forced.** `buildStageTransitionTable(stageConfig)` projects a `TomeCrmOpportunityStageConfig` onto a `WorkflowTransitionTable`, and the allow decision comes from workflow. Three semantics stay local, each because delegating them would change behaviour: (1) a stage that declares NO `allowedTransitions` is UNCONSTRAINED in crm, and a transition table cannot distinguish "no edges declared" from "no edges permitted" — feeding those stages to `findTransition` would turn crm's open-by-default pipeline into a closed one for every consumer whose config declares transitions on some stages and not others; (2) an unknown stage key is a misconfiguration reported as one, ahead of any transition check; (3) the lost-category `lossCategory` requirement is a field-level data rule keyed off a stage's `category`, and hanging it on `WorkflowTransition.guard` would make every consumer of the exported table inherit a crm write-validation rule. The rejection message now also names the legal moves from the previous stage, sourced from `allowedTransitionsFrom` — strictly more diagnostic, same throw conditions. The deals↔CRM cascade re-entrancy handshake is untouched: `skipDealCascadeHooks` (deals `status-transition-guard.ts`) and `tomeCrmSuppressStageDispatch` (crm `advance-opportunity-stage.ts` → `stage-change-dispatch.ts`) behave exactly as the 2026-06-11 cascade-semantics amendment D2 documents. Neither guard's participation in that handshake changed; crm's stage guard never participated in it at all. New suites pinning the delegation, one per package (`tests/workflow-delegation.test.ts`, 12 + 9 + 8 assertions), each spying on the workflow module itself so a future edit that quietly restores a local copy fails a test rather than passing silently — which is exactly how the original fork survived four months of green CI.
v0.6.0minor

7d5657c: Security: the bootstrap `crm:read` / `crm:read:own` fallback — which granted CRM read access to ANY authenticated session whenever a site had not seeded capability grants — is now OFF in production by default. Outside production it still applies so a fresh site works out of the box. A production site that needs the bridge while it seeds grants can opt back in explicitly with `TOME_CRM_BOOTSTRAP_READ_FALLBACK=1`. `crm:write` / `crm:admin` are unchanged. Why minor, not patch: a production deployment that never seeded CRM capabilities and relied on this silent bridge will lose CRM reads for non-admin sessions until it seeds grants or sets the flag. That was a live PII exposure (a public-demo consumer was found relying on it); the loud production warning this package already emitted has been telling operators to seed grants since 0.3.1.

  • 7d5657c: Security: the bootstrap `crm:read` / `crm:read:own` fallback — which granted CRM read access to ANY authenticated session whenever a site had not seeded capability grants — is now OFF in production by default. Outside production it still applies so a fresh site works out of the box. A production site that needs the bridge while it seeds grants can opt back in explicitly with `TOME_CRM_BOOTSTRAP_READ_FALLBACK=1`. `crm:write` / `crm:admin` are unchanged. Why minor, not patch: a production deployment that never seeded CRM capabilities and relied on this silent bridge will lose CRM reads for non-admin sessions until it seeds grants or sets the flag. That was a live PII exposure (a public-demo consumer was found relying on it); the loud production warning this package already emitted has been telling operators to seed grants since 0.3.1.
v0.5.0minor

6bc419c: Naming convergence (all additive; every old name keeps working as a `@deprecated` alias until that package's next major). `create*` is canonical for collection/layer factories (`define*` stays reserved for the blocks descriptor system): crm/deals/marketing/intake/forms gain `create*Collection` names for their former `define*Collection` factories. Layer entries converge on `createXLayer(config?) → bundle`: `createCrmLayer`/`createDealsLayer`/`createMarketingLayer`/`createCatalogLayer`/`createEconomyLayer`/`createChromeLayer`/`createLmsLayer`/`createAiLayer` (+ `createFormsLayer`/`createIntakeLayer`), returning bare `CollectionConfig[]` where the layer contributes only collections or an honest named bundle where it hands back more (chrome: `{ globals }`; lms/ai: `{ collections, hooks }`); void-returning `initCatalog`/`initEconomy` stay as the single registration call sites, delegated to internally. Naming note for forms consumers: `createFormsCollection` (singular factory) vs `createFormsCollections` (plural composer) vs `createFormsLayer` (layer entry) — each docblock states the distinction.

  • 6bc419c: Naming convergence (all additive; every old name keeps working as a `@deprecated` alias until that package's next major). `create*` is canonical for collection/layer factories (`define*` stays reserved for the blocks descriptor system): crm/deals/marketing/intake/forms gain `create*Collection` names for their former `define*Collection` factories. Layer entries converge on `createXLayer(config?) → bundle`: `createCrmLayer`/`createDealsLayer`/`createMarketingLayer`/`createCatalogLayer`/`createEconomyLayer`/`createChromeLayer`/`createLmsLayer`/`createAiLayer` (+ `createFormsLayer`/`createIntakeLayer`), returning bare `CollectionConfig[]` where the layer contributes only collections or an honest named bundle where it hands back more (chrome: `{ globals }`; lms/ai: `{ collections, hooks }`); void-returning `initCatalog`/`initEconomy` stay as the single registration call sites, delegated to internally. Naming note for forms consumers: `createFormsCollection` (singular factory) vs `createFormsCollections` (plural composer) vs `createFormsLayer` (layer entry) — each docblock states the distinction.
  • 36e537a: Email lookup paths adopt core's `normalizeEmail` instead of hand-rolled lowercasing: crm's `find-contact-by-email` / `match-or-create-contact`, and deals' `create-from-intake` — the latter was missing `.trim()`, so a padded intake email could fork a duplicate CRM contact.
  • 36e537a: `registerLayer` is now statically imported (forms/intake pattern) instead of lazily `require()`d in ten layer packages' init/register paths. The lazy pattern silently no-ops under Payload's native-ESM CLI (`generate:types` / `generate:importmap`), so layer registration could vanish without error. Packages whose tome-core peer is genuinely optional (economy, ai, gamification) deliberately keep the guarded lazy path; tome-core's `admin-nav/self-register.ts` deliberately keeps its subpath `require()` (documented ESM/CJS dual-cache fix — do not convert).
  • 36e537a: Every package now declares an explicit `sideEffects` field (38 added; motion/engine/forms already correct). Registration-bearing modules (render files' `registerRenderer`, `blocks/*/index.ts` `defineBlock` self-registration, widget `register.ts` files, productHooks, permission self-registrations, print templates, chrome built-in variants) are listed so bundlers can tree-shake everything else WITHOUT dropping import-time registrations — previously the field was unset, which blocked cross-module tree-shaking through the barrels entirely. Never blanket `false` on a package with registration or CSS.
  • aef2725: DRY adoption sweep (the audit's "adoption, not extraction" rule): crm/deals capability presets delegate to core's `sessionHasCapabilityOrLegacyAdmin`; new core `buildOwnershipWhere`/`ownershipOrBypass` (via `./access`) adopted by core's vendorScoped, catalog's vendor-scoping, and org's ownOrScoped (public APIs unchanged); `slugField()` adopted at 7 sites where semantics matched exactly (core lms collections + createMemberCollection — replacing a third independent slugify), with ~25 sites honestly skipped for named semantic divergences (auto-regenerate-on-clear vs allow-empty, collection-level hook pattern) now listed as core-enhancement candidates; new `formatDisplayDate` in blocks-core utilities (UTC-pinned, hydration-safe) adopted at 5 verified-identical sites; lms-ui consolidates its two certificate date formatters locally; `useMediaQuery`/`useIsMobile` published from tome-ui and adopted by AppShell + admin's SidebarProvider; gamification's `awardPoints` now uses the authoritative `getPointsBalance` (fixes a divergent 1000-row scan cap vs the correct 10000).
v0.4.3patch

Admin label polish + formatted commerce money columns: explicit labels for CRM collections ("CRM Accounts…"), Admin/Learner UI Preferences, and better-auth generated collections ("Auth Accounts", "Two-Factor Credentials", OAuth/JWKS casing) via the plugin's customizeCollection hook; nav SYSTEM_LABEL_OVERRIDES map (payload-kv → "Payload KV") applied at resolver + pinned-section label sites; Orders.total / Payments.amount / Prices.amount virtual afterRead fields format integer cents against the row currency ("4900" → "$49.00") in list views with no client components (zero generate:importmap coupling).

  • Admin label polish + formatted commerce money columns: explicit labels for CRM collections ("CRM Accounts…"), Admin/Learner UI Preferences, and better-auth generated collections ("Auth Accounts", "Two-Factor Credentials", OAuth/JWKS casing) via the plugin's customizeCollection hook; nav SYSTEM_LABEL_OVERRIDES map (payload-kv → "Payload KV") applied at resolver + pinned-section label sites; Orders.total / Payments.amount / Prices.amount virtual afterRead fields format integer cents against the row currency ("4900" → "$49.00") in list views with no client components (zero generate:importmap coupling).
v0.4.1patch

bed3f90: Docs-manifest emitter pipeline (W3 ship-readiness). `@wabbit/tome-blocks-core` now ships a standalone Node ESM CLI at `scripts/emit-docs-manifests.mjs` that emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free `<pkg>/meta` block-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild: `node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>`. To let the emitter import block metadata uniformly without dragging Payload config into a build script, the `./meta` payload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block's `BlockMeta` was relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed. Every supported-core package additionally adds `CHANGELOG.md` to its published `files` array so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.

  • bed3f90: Docs-manifest emitter pipeline (W3 ship-readiness). `@wabbit/tome-blocks-core` now ships a standalone Node ESM CLI at `scripts/emit-docs-manifests.mjs` that emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free `<pkg>/meta` block-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild: `node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>`. To let the emitter import block metadata uniformly without dragging Payload config into a build script, the `./meta` payload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block's `BlockMeta` was relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed. Every supported-core package additionally adds `CHANGELOG.md` to its published `files` array so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.
v0.4.0minor

61af0ea: Add configurable `winTriggerStatus` to the deals->CRM cascade (`TomeCrmConfig`). The linked opportunity advances to its `won` stage when a deal reaches this status. Default is `'accepted'` — unchanged behavior: a signed deal wins the opportunity (the quote/SOW lifecycle). Consumers whose payment is decoupled from signature (e.g. proposals paid via Stripe Checkout) set `winTriggerStatus: 'paid'` so the win — and any spawn-on-won side-effect — fires on payment, not on signature. No behavior change for existing consumers; the `paid` payment-note activity is unaffected.

  • 61af0ea: Add configurable `winTriggerStatus` to the deals->CRM cascade (`TomeCrmConfig`). The linked opportunity advances to its `won` stage when a deal reaches this status. Default is `'accepted'` — unchanged behavior: a signed deal wins the opportunity (the quote/SOW lifecycle). Consumers whose payment is decoupled from signature (e.g. proposals paid via Stripe Checkout) set `winTriggerStatus: 'paid'` so the win — and any spawn-on-won side-effect — fires on payment, not on signature. No behavior change for existing consumers; the `paid` payment-note activity is unaffected.
v0.3.2patch

b027075: Fix two consumer-breaking defects found by a consumer's adoption (first post-0.3.x consumer): 1. **`linkedMember` is now composition-gated on `config.memberSlug`** — previously the contacts factory built the relationship unconditionally with `relationTo: memberSlug ?? 'members'`, throwing `InvalidFieldRelationship` at Payload init for any consumer without a `members` collection. The CRM spec makes member identity consumer-wired and optional; the field now follows the same presence pattern as `sourceSubmission`/`intakeSubmissionsSlug`. Both existing consumers (wabbit-site-core, tome-starter) set `memberSlug: 'members'` explicitly and keep the field unchanged; consumers that omitted it were crashing, so no working configuration changes behavior. 2. **`@wabbit/tome-core` peer floor raised `>=1.0.0` → `>=1.1.0`** — crm's dist imports `@wabbit/tome-core/utilities/normalize`, a subpath only exported from core 1.1.0, so the declared floor produced `ERR_PACKAGE_PATH_NOT_EXPORTED` at runtime on core 1.0.x installs.

  • b027075: Fix two consumer-breaking defects found by a consumer's adoption (first post-0.3.x consumer): 1. **`linkedMember` is now composition-gated on `config.memberSlug`** — previously the contacts factory built the relationship unconditionally with `relationTo: memberSlug ?? 'members'`, throwing `InvalidFieldRelationship` at Payload init for any consumer without a `members` collection. The CRM spec makes member identity consumer-wired and optional; the field now follows the same presence pattern as `sourceSubmission`/`intakeSubmissionsSlug`. Both existing consumers (wabbit-site-core, tome-starter) set `memberSlug: 'members'` explicitly and keep the field unchanged; consumers that omitted it were crashing, so no working configuration changes behavior. 2. **`@wabbit/tome-core` peer floor raised `>=1.0.0` → `>=1.1.0`** — crm's dist imports `@wabbit/tome-core/utilities/normalize`, a subpath only exported from core 1.1.0, so the declared floor produced `ERR_PACKAGE_PATH_NOT_EXPORTED` at runtime on core 1.0.x installs.
v0.3.1patch

a9801fe: Consolidation pass (2026-06-10 audit dialect-drift findings) — the platform stops forking its own conventions: **tome-core (minor — new public APIs):** - `./auth/repScoping` — `buildRepWhereClause({ adminCapability, repField })` + `buildCapabilityScopedRead({ readCapability, adminCapability, repField })` + `sessionHasCapabilityOrLegacyAdmin` + `DENY_ALL_WHERE`. The canonical "rows I own" access primitive, promoted from crm/deals' ~90%-identical copies (266 LOC → one parameterized implementation). - `./utilities/normalize` — `normalizeEmail` (trim + lowercase). Email is the cross-layer join key; one normalizer, everywhere. - `./fields/slug` — `formatSlug` upgraded to the canonical algorithm (promoted from catalog's strictly-more-robust slugify: collapses whitespace/hyphen runs, trims edge hyphens); new `buildAutoSlugHook(sourceField, slugField)` collection-level variant. Stored slugs untouched; only future generations on irregular-whitespace inputs differ. **catalog / org / crm / deals (patch):** local copies replaced with delegations to the core primitives. Public names and signatures unchanged (`slugify`, `autoSlugHook`, `buildNormalizeEmailHook`, `normalizeDealEmail`, `repWhereClause`, `accountRepWhereClause`, `dealsRepWhereClause`, `dealsRepOrAdminWhereClause`). Notably, org's auto-slug header had _claimed_ to wrap core's slugifier while carrying a divergent local copy — now it actually does.

  • a9801fe: Consolidation pass (2026-06-10 audit dialect-drift findings) — the platform stops forking its own conventions: **tome-core (minor — new public APIs):** - `./auth/repScoping` — `buildRepWhereClause({ adminCapability, repField })` + `buildCapabilityScopedRead({ readCapability, adminCapability, repField })` + `sessionHasCapabilityOrLegacyAdmin` + `DENY_ALL_WHERE`. The canonical "rows I own" access primitive, promoted from crm/deals' ~90%-identical copies (266 LOC → one parameterized implementation). - `./utilities/normalize` — `normalizeEmail` (trim + lowercase). Email is the cross-layer join key; one normalizer, everywhere. - `./fields/slug` — `formatSlug` upgraded to the canonical algorithm (promoted from catalog's strictly-more-robust slugify: collapses whitespace/hyphen runs, trims edge hyphens); new `buildAutoSlugHook(sourceField, slugField)` collection-level variant. Stored slugs untouched; only future generations on irregular-whitespace inputs differ. **catalog / org / crm / deals (patch):** local copies replaced with delegations to the core primitives. Public names and signatures unchanged (`slugify`, `autoSlugHook`, `buildNormalizeEmailHook`, `normalizeDealEmail`, `repWhereClause`, `accountRepWhereClause`, `dealsRepWhereClause`, `dealsRepOrAdminWhereClause`). Notably, org's auto-slug header had _claimed_ to wrap core's slugifier while carrying a divergent local copy — now it actually does.
  • 4b2f368: Platform-wide peer-range sweep: every `workspace:*`/`workspace:^` entry in `peerDependencies` replaced with an explicit semver range (`@wabbit/tome-core >=1.0.0 <2.0.0`, `tome-ui >=0.9.0 <1.0.0`, `tome-motion >=0.2.0 <1.0.0`, `tome-catalog >=1.1.0 <2.0.0`, `tome-admin >=0.5.0 <1.0.0`; `tome-crm` ranges standardized to `>=0.2.0 <1.0.0`). The workspace protocol publishes as an **exact-version pin**, so every substrate bump stranded installed dependents — the breakage class proven by marketing@0.1.0/deals@0.1.1 requiring `tome-crm@0.2.0` exactly. devDependencies keep `workspace:*` for the local link. (`@wabbit/tome-admin-pro` got the same source fix but is rc-versioned; it carries the change on its next intentional release.) tome-crm additionally gains a once-per-process **production warning when the capability-registry fallback grants access** — the bootstrap heuristic (any authenticated user passes `crm:read`) now announces itself instead of running silently on sites that forgot to seed capability grants (2026-06-10 audit hardening item). Graph-truth additions (same hygiene wave): tome-deals declares its lazy print integration as an optional peer (`@wabbit/tome-print >=0.1.0 <1.0.0`); tome-intake declares its lazy catalog routing strategy (`@wabbit/tome-catalog >=1.1.0 <2.0.0`, optional). These were undeclared dynamic imports — invisible to consumers and to pnpm's build topology.
v0.3.0minor

c5175e9: v0.3 consumer seams — promotes the four platform gaps wabbit-site-core's dogfood proved (2026-06-10 audit): - **`extraFields` config seam** — `TomeCrmConfig.extraFields.{contacts,accounts,opportunities,activities}` appends site-specific fields (attribution, lead scoring, …) after platform fields. Retires the consumer-side `withCrmExtensions()` post-processing pattern. - **`onOpportunityStageChange` now fires on every stage transition** — the opportunities collection's afterChange hook is the dispatch point, so admin-UI edits and raw `payload.update` calls dispatch the adapter, not just `advanceOpportunityStage()`. The helper suppresses the hook via request context when its own call-time config carries the adapter, so each transition dispatches exactly once. Does not fire on create. - **`findOpenOpportunityForAccount(payload, accountId, { config })`** — canonical "one open opportunity per account" dedup helper, exported from `/server`. - **`buildDealsCrmBridge(config)`** — ready-made callback for `initDeals({ onStatusChange })` (sent→activity, accepted→won, rejected→lost, paid→payment note). Structurally typed (`CrmDealLike`); no dependency on @wabbit/tome-deals. **`wireDeals` is deprecated** — it was a layer-presence probe that attached nothing (the GAP-3 trap) and will be removed in v1.0.

  • c5175e9: v0.3 consumer seams — promotes the four platform gaps wabbit-site-core's dogfood proved (2026-06-10 audit): - **`extraFields` config seam** — `TomeCrmConfig.extraFields.{contacts,accounts,opportunities,activities}` appends site-specific fields (attribution, lead scoring, …) after platform fields. Retires the consumer-side `withCrmExtensions()` post-processing pattern. - **`onOpportunityStageChange` now fires on every stage transition** — the opportunities collection's afterChange hook is the dispatch point, so admin-UI edits and raw `payload.update` calls dispatch the adapter, not just `advanceOpportunityStage()`. The helper suppresses the hook via request context when its own call-time config carries the adapter, so each transition dispatches exactly once. Does not fire on create. - **`findOpenOpportunityForAccount(payload, accountId, { config })`** — canonical "one open opportunity per account" dedup helper, exported from `/server`. - **`buildDealsCrmBridge(config)`** — ready-made callback for `initDeals({ onStatusChange })` (sent→activity, accepted→won, rejected→lost, paid→payment note). Structurally typed (`CrmDealLike`); no dependency on @wabbit/tome-deals. **`wireDeals` is deprecated** — it was a layer-presence probe that attached nothing (the GAP-3 trap) and will be removed in v1.0.
v0.2.0minor

Add the v0.2 marketing-substrate contract (consumed by `@wabbit/tome-marketing`). All new fields are nullable/optional — non-breaking for existing consumers. - **Suppression state + maintenance helpers:** `markContactUnsubscribed`, `markContactBounced` (soft-bounce threshold with auto-suppress), `clearContactSuppression`, `isContactSendable`, `filterSendableContactIds`. New `crm-contacts` fields `bouncedAt` / `bounceType` / `suppressionReason` / `suppressionSource`, and an `onSuppressionChange` config adapter (carries `source` so consumers can loop-guard provider suppression mirrors). - **Provider-event ingestion:** `recordCrmActivityWithDedup` with aggregate-at-ingest — a composite `aggregationKey` collapses `opened`/`clicked`/`site-visited` per contact/campaign/day, while `sent`/`replied`/`bounced`/`unsubscribed` stay 1:1 — plus a `(provider, externalId)` unique index for idempotent webhook ingestion. New `crm-activities` fields `provider` / `eventType` / `aggregationKey` / `eventCount` / `firstEventAt` / `lastEventAt`, a `buildSuppressionCascadeHook`, and `buildStampLastContactedHook` now skips provider events that are not "we contacted them". NOTE: the event-payload param on `recordCrmActivityWithDedup` is `eventPayload` (not `payload`, which is the Payload instance). - **Optional `crm-activities.campaign` relationship** (config-driven via `activityCampaignSlug`, default `marketing-campaigns`) for campaign attribution — only registered when the slug is set; CRM never imports marketing.

  • Add the v0.2 marketing-substrate contract (consumed by `@wabbit/tome-marketing`). All new fields are nullable/optional — non-breaking for existing consumers. - **Suppression state + maintenance helpers:** `markContactUnsubscribed`, `markContactBounced` (soft-bounce threshold with auto-suppress), `clearContactSuppression`, `isContactSendable`, `filterSendableContactIds`. New `crm-contacts` fields `bouncedAt` / `bounceType` / `suppressionReason` / `suppressionSource`, and an `onSuppressionChange` config adapter (carries `source` so consumers can loop-guard provider suppression mirrors). - **Provider-event ingestion:** `recordCrmActivityWithDedup` with aggregate-at-ingest — a composite `aggregationKey` collapses `opened`/`clicked`/`site-visited` per contact/campaign/day, while `sent`/`replied`/`bounced`/`unsubscribed` stay 1:1 — plus a `(provider, externalId)` unique index for idempotent webhook ingestion. New `crm-activities` fields `provider` / `eventType` / `aggregationKey` / `eventCount` / `firstEventAt` / `lastEventAt`, a `buildSuppressionCascadeHook`, and `buildStampLastContactedHook` now skips provider events that are not "we contacted them". NOTE: the event-payload param on `recordCrmActivityWithDedup` is `eventPayload` (not `payload`, which is the Payload instance). - **Optional `crm-activities.campaign` relationship** (config-driven via `activityCampaignSlug`, default `marketing-campaigns`) for campaign attribution — only registered when the slug is set; CRM never imports marketing.
v0.1.5patch

Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12

  • Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12
v0.1.4patch

Updated dependencies [36dc023]

  • Updated dependencies [36dc023]
  • Updated dependencies [2612799] - @wabbit/tome-core@1.0.11
v0.1.2patch

433d892: Phase 1.5 dogfood findings — three bugs surfaced when Wabbit became the layer's first consumer: 1. **`crm-activities` duplicate field name.** Both rich-text `body` and the textarea fallback declared `name: 'body'`, discriminated by `admin.condition`. Payload's sanitizer rejects same-level field-name collisions regardless of conditions, throwing `DuplicateFieldName: 'body'` at config build (no consumer could call `payload generate:types` or boot dev). Renamed the textarea to `summary`. `TomeCrmActivity` type and `recordActivity` helper updated to route input.body to `body` for note/email types and input.summary to `summary` otherwise. 2. **Hook type narrowing fails cross-repo.** Consumers with a populated `payload-types.ts` widen `DataFromCollectionSlug<CollectionSlug>` to a 50+ collection union, hiding `lifecycleStage` / `lastContactedAt` from the dynamic-slug `findByID` results in `cascade-contact-lifecycle` and `stamp-last-contacted`. The `auto-link-account` hook also tripped Wabbit's `noUncheckedIndexedAccess`. Read results now narrow structurally to `{ lifecycleStage?: string }` / `{ lastContactedAt?: string | null }`; update payloads cast through `unknown as never`; array indexing guarded. 3. **`sourceSubmission` field hard-required `intake-submissions` collection.** The relation field on `crm-contacts` and `crm-opportunities` resolved to `config.intakeSubmissionsSlug ?? 'intake-submissions'` unconditionally, throwing Payload init for any consumer without `@wabbit/tome-intake` registered. Field is now conditional: declared only when `intakeSubmissionsSlug` is passed in config. Mirrors the composition-presence pattern used by `@wabbit/tome-lms`. No public API changes outside `TomeCrmActivity` (added `summary?: string | null`) and the `recordActivity` helper (now reads `input.summary` for non-rich types). v0.1.0 had zero published consumers; Wabbit's wiring is being adjusted in the same Phase 1.5 cycle.

  • 433d892: Phase 1.5 dogfood findings — three bugs surfaced when Wabbit became the layer's first consumer: 1. **`crm-activities` duplicate field name.** Both rich-text `body` and the textarea fallback declared `name: 'body'`, discriminated by `admin.condition`. Payload's sanitizer rejects same-level field-name collisions regardless of conditions, throwing `DuplicateFieldName: 'body'` at config build (no consumer could call `payload generate:types` or boot dev). Renamed the textarea to `summary`. `TomeCrmActivity` type and `recordActivity` helper updated to route input.body to `body` for note/email types and input.summary to `summary` otherwise. 2. **Hook type narrowing fails cross-repo.** Consumers with a populated `payload-types.ts` widen `DataFromCollectionSlug<CollectionSlug>` to a 50+ collection union, hiding `lifecycleStage` / `lastContactedAt` from the dynamic-slug `findByID` results in `cascade-contact-lifecycle` and `stamp-last-contacted`. The `auto-link-account` hook also tripped Wabbit's `noUncheckedIndexedAccess`. Read results now narrow structurally to `{ lifecycleStage?: string }` / `{ lastContactedAt?: string | null }`; update payloads cast through `unknown as never`; array indexing guarded. 3. **`sourceSubmission` field hard-required `intake-submissions` collection.** The relation field on `crm-contacts` and `crm-opportunities` resolved to `config.intakeSubmissionsSlug ?? 'intake-submissions'` unconditionally, throwing Payload init for any consumer without `@wabbit/tome-intake` registered. Field is now conditional: declared only when `intakeSubmissionsSlug` is passed in config. Mirrors the composition-presence pattern used by `@wabbit/tome-lms`. No public API changes outside `TomeCrmActivity` (added `summary?: string | null`) and the `recordActivity` helper (now reads `input.summary` for non-rich types). v0.1.0 had zero published consumers; Wabbit's wiring is being adjusted in the same Phase 1.5 cycle.