Marketing
CRM & Marketing engineStableTome marketing layer — campaigns, audiences, provider adapters (Encharge + Kit), webhook ingest, and per-prospect prototype-site linkage.
Get a registry token from your credentials page. You need a purchase that includes this package, or a Craft Library membership.
Add the registry and your token to the
.npmrcat the root of your project, with your token in place ofYOUR_TOKEN:@wabbit:registry=https://npm.wabbit.com/ //npm.wabbit.com/:_authToken=YOUR_TOKENThen install:
npm install @wabbit/tome-marketing
Overview
@wabbit/tome-marketing
Tome marketing layer — campaigns, audiences, provider adapters (Encharge + Kit), webhook ingest, and per-prospect prototype-site linkage. Description copied verbatim from package.json.
Layer: domain (per ARCHITECTURE.md). Depends on @wabbit/tome-crm as a required peer (not optional — unusual for a cross-domain dependency in this batch; marketing's campaign memberships point at CRM contacts directly).
Install
pnpm add @wabbit/tome-marketingPeer ranges, copied from package.json:
| Peer | Range | Optional? | |---|---|---| | payload | >=3.67.0 | no | | typescript | >=5.7.0 | no | | lucide-react | >=0.460.0 | yes — declared but unused: zero lucide-react imports anywhere in src/ (verified by grep); marketing passes a string iconName: 'Megaphone' to the admin-nav manifest instead of an icon component. Orphaned peer declaration. | | @wabbit/tome-core | >=1.14.0 <2.0.0 | no | | @wabbit/tome-crm | >=0.2.0 <1.0.0 | no | | @wabbit/tome-workflow | >=0.1.1 <1.0.0 | no — peerDependenciesMeta marks it required, and the campaign stage-transition guard imports it statically |
dependencies: server-only@^0.0.1.
60-second quickstart
The current API is createMarketingLayer(config), which returns the three collections (campaigns, memberships, segments) as a CollectionConfig[] and registers the layer. initMarketing is a deprecated pure alias, kept for back-compat only.
Read this first — the layer builds collections; the server helpers do the provider work. createMarketingLayer reads the slug overrides (campaignsSlug, membershipsSlug, segmentsSlug, contactsSlug), repCollection, adminGroup and the shared LayerFactoryConfig fields. The rest of TomeMarketingConfig is read by the server helpers when you pass them the same object as config: activateCampaign syncs through config.providers when you give it no adapters, and both webhook handlers take activitiesSlug, siteVisitActivityType and the slugs from config when their own option is omitted. The CRM hooks are not config keys: build them with buildMarketingCrmConfig and hand them to the CRM layer. createMarketingWebhookHandler and buildMarketingCrmConfig always take their adapters directly.
// payload.config.ts
import { buildConfig } from 'payload'
import { createMarketingLayer } from '@wabbit/tome-marketing'
const marketingCollections = createMarketingLayer({ providers: [] }) // the collections never call providers; activateCampaign reads them from a passed config
export default buildConfig({
collections: [...marketingCollections /* ...other collections, incl. tome-crm's */],
})// Server code: hand the adapter to each helper that talks to the provider
import { enchargeAdapter } from '@wabbit/tome-marketing/adapters/encharge'
import {
activateCampaign,
buildMarketingCrmConfig,
createMarketingWebhookHandler,
} from '@wabbit/tome-marketing/server'
const encharge = enchargeAdapter({
apiKey: process.env.ENCHARGE_API_KEY!,
webhookSecret: process.env.ENCHARGE_WEBHOOK_SECRET, // omit it and every webhook is rejected
})
// Route handler: app/api/webhooks/encharge/route.ts
export const POST = createMarketingWebhookHandler({ adapter: encharge, payload })
// CRM layer config: counter sync + suppression mirroring
const { onActivityCreated, onSuppressionChange } = buildMarketingCrmConfig({ payload, providers: [encharge] })
// Activating from code: provider sync uses `adapters`, else the providers on a passed `config`
await activateCampaign({ payload, campaignId, adapters: [encharge] })Flipping a campaign from draft to active in the admin materializes its audience (enrolls the matching CRM contacts) through a collection hook, but it does not call any provider — only activateCampaign does, with adapters or a config carrying providers.
API surface
Six subpaths: ., ./server, ./widgets, ./adapters/encharge, ./adapters/kit, ./test.
`.`: createMarketingLayer, createCampaignsCollection, createCampaignMembershipsCollection, createSegmentsCollection, and all types (export * from ./types). Default slugs: marketing-campaigns, marketing-campaign-memberships, marketing-segments; the CRM slugs it points at default to crm-contacts and crm-activities.
Deprecated aliases (pure renames, @deprecated-tagged, removal at this package's next major): initMarketing → createMarketingLayer; defineCampaignsCollection → createCampaignsCollection; defineCampaignMembershipsCollection → createCampaignMembershipsCollection; defineSegmentsCollection → createSegmentsCollection.
`./server` — behind import 'server-only': campaign lifecycle (activateCampaign, pauseCampaign, completeCampaign), audience (materializeAudience, enrollContacts, excludeContact), KPI/query helpers (getCampaignKpis, getStalledMemberships, getExpiredPrototypeSites, recomputeSegmentSize), webhook factories (createMarketingWebhookHandler({ adapter, payload, … }) and createSiteVisitHandler({ payload, … }), each returning a (req: Request) => Promise<Response> route handler, plus buildSuppressionMirror), the shared verify utils (timingSafeEqualHex, computeHmacSha256 — see below), CRM integration (buildMarketingCrmConfig({ payload, providers }), returning the onActivityCreated / onSuppressionChange pair for the CRM layer's config), and access presets (MARKETING_CAPABILITIES, MarketingCapability, marketingCampaignsAccess, marketingMembershipsAccess, marketingSegmentsAccess). Every helper also exports its …Opts type. The site-visit activity type defaults to 'site-visited'; override it with the handler's own option (activityType on createSiteVisitHandler, siteVisitActivityType on createMarketingWebhookHandler) or with siteVisitActivityType on a config passed to either handler.
`./widgets` — currently a type-only placeholder (export type {}, verified by reading the file — just keeps the tsup entry valid). Planned per its own comment: CampaignPerformanceCard, TodayReplyQueue, StalledProspects, ExpiredPrototypeSites. None shipped yet.
`./adapters/encharge` — enchargeAdapter({ apiKey, webhookSecret?, allowUnverified? }) factory + raw client (createEnchargeClient, EnchargeClient, EnchargeIdentifyOpts, EnchargeTrackEventOpts, EnchargeAddTagOpts, EnchargeRemoveTagOpts). Has its own subpath README at src/adapters/encharge/README.md with the full env-var/webhook setup guide.
`./adapters/kit` — kitAdapter({ apiKey, formId?, webhookSecret?, allowUnverified?, … }) factory + raw client (KitClient, KitSubscriber, KitTag, KitSubscribeOpts, KitAddTagOpts, KitRemoveTagOpts). Also has its own subpath README at src/adapters/kit/README.md.
`./test` — real mock fixtures (not a stub): mockCampaign, mockMembership, mockSegment, mockNormalizedEvent, mockProviderAdapter, mockMarketingConfig.
The timingSafeEqualHex convention
./server's timingSafeEqualHex/computeHmacSha256 are generic HMAC/signature helpers for the webhook handler factory's own plumbing only — the module's own comment is explicit: "DO NOT use these utilities inside adapters — adapters self-verify using whatever scheme their provider demands." In practice the two shipped adapters diverge completely: Encharge has no native webhook signing, so its adapter verifies a shared-secret token in a custom X-Webhook-Token header (constant-time compared); Kit "does NOT sign webhook payloads" at all — its only verification is a URL-embedded ?token= query param. Neither adapter calls timingSafeEqualHex directly for its primary check; treat it as shared factory-level plumbing, not a provider-agnostic verification API.
Both adapters fail closed without a secret. If you omit the webhook secret, verifyWebhook returns false for every request and logs why (console.error), so a missing environment variable shows up as rejected webhooks, not an open endpoint. For local development before a secret exists, pass allowUnverified: true to enchargeAdapter / kitAdapter (or to verifyEnchargeWebhook / verifyKitWebhook directly): requests are then accepted with a logged warning. allowUnverified is ignored when NODE_ENV === 'production'.
Server / client posture
Server-side package: ./server is guarded by import 'server-only'; the main barrel is Payload collection factories + types. ./widgets is reserved for future admin-facing client components but ships no React today. The adapter subpaths hold API keys in closures; import them only from server code.
Testing
pnpm --filter @wabbit/tome-marketing test runs the Vitest suite (adapter webhook verification, the Encharge client, verify utils, workflow delegation, layer version). For your own tests, @wabbit/tome-marketing/test provides mockProviderAdapter() and mockMarketingConfig() alongside the document fixtures.
Links
- Subpath guides:
src/adapters/encharge/README.md,src/adapters/kit/README.md - CHANGELOG
Extending this package
A third ESP adapter implements the same shape as enchargeAdapter/kitAdapter's return object (slug, label, syncAudience, optional sendOne, addToSuppression, removeFromSuppression, verifyWebhook, parseWebhookEvent) and self-verifies its own webhook scheme — do not route a new adapter's verification through timingSafeEqualHex unless the provider's scheme is genuinely HMAC-hex-based like the factory's own.
Exports
@wabbit/tome-marketing@wabbit/tome-marketing/server@wabbit/tome-marketing/widgets@wabbit/tome-marketing/adapters/encharge@wabbit/tome-marketing/adapters/kit@wabbit/tome-marketing/test
Changelog
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.
947a42e: **BREAKING:** `TomeMarketingConfig` drops four keys nothing read; `providers`, `activitiesSlug` and `siteVisitActivityType` now take effect. The removed keys are `onSuppressionChange`, `onActivityCreated`, `onReplyReceived` and `aiAdapter`. **Migration:** remove those four keys from the object you pass to `createMarketingLayer`. Build the CRM hooks with `buildMarketingCrmConfig` and give them to the CRM layer, as before; the `TomeMarketingAiAdapter` type is still exported. - `activateCampaign` syncs through `config.providers` when no `adapters` are passed; explicit `adapters` still win. - `createMarketingWebhookHandler` and `createSiteVisitHandler` accept a `config` option; its slugs, `activitiesSlug` and `siteVisitActivityType` apply when the handler's own option is omitted. - `buildSuppressionMirror` is now typed against the CRM layer's `onSuppressionChange` directly; the function shape is unchanged.
- 947a42e: **BREAKING:** `TomeMarketingConfig` drops four keys nothing read; `providers`, `activitiesSlug` and `siteVisitActivityType` now take effect. The removed keys are `onSuppressionChange`, `onActivityCreated`, `onReplyReceived` and `aiAdapter`. **Migration:** remove those four keys from the object you pass to `createMarketingLayer`. Build the CRM hooks with `buildMarketingCrmConfig` and give them to the CRM layer, as before; the `TomeMarketingAiAdapter` type is still exported. - `activateCampaign` syncs through `config.providers` when no `adapters` are passed; explicit `adapters` still win. - `createMarketingWebhookHandler` and `createSiteVisitHandler` accept a `config` option; its slugs, `activitiesSlug` and `siteVisitActivityType` apply when the handler's own option is omitted. - `buildSuppressionMirror` is now typed against the CRM layer's `onSuppressionChange` directly; the function shape is unchanged.
- 9535dce: **BREAKING:** The Encharge and Kit adapters now reject every webhook when no webhook secret is configured, instead of accepting all of them. **Migration:** set `ENCHARGE_WEBHOOK_SECRET` / `KIT_WEBHOOK_SECRET` and pass it as `webhookSecret`. For local development before a secret exists, pass `allowUnverified: true` to `enchargeAdapter` / `kitAdapter` (or to `verifyEnchargeWebhook` / `verifyKitWebhook`); it is ignored when `NODE_ENV === 'production'`. Neither provider signs its webhooks, so the shared-secret check is the route's only authentication; a forgotten environment variable used to leave an open write endpoint. Rejections and allowed bypasses are both logged.
- e7fdc8c: Campaign KPIs and segment-size recomputation now count rows with `payload.count()` instead of reading every matching membership or contact. No API or behaviour change. Internal: collection slugs are typed through the shared `typedSlug()` helper instead of inline casts.
- 0bd7c3f: customer-facing wording: internal references removed from admin descriptions, error messages and block metadata.
8f41c63: **Encharge Ingest client: two assumptions verified against a live account, and blank values no longer overwrite a person's fields.** The client's identify payload was written from the Ingest API docs and carried `ASSUMPTION` flags because nobody had a key to check it with. a consumer's first ingest ran on 2026-09-19 with a real one, and the contact came back showing both a custom field and a tag applied by the same call. Two things are now observed rather than assumed, and the comments say so: - **Custom fields ride inside `user`.** A field sent as `sixdofSource` appeared on the person as "Sixdof Source". Sending custom fields in a sibling `properties` object — which `wabbit-site-core`'s local ingest helper does, and which was the reason that helper existed rather than importing this client — is not required for them to stick. - **Tags ride as a comma-separated string on `user`**, and the tag appeared on the contact. `removeTag` remains unverified and is now labelled as the only untested corner, since no consumer removes a tag yet. **Behaviour change:** `identifyPerson` now drops fields whose value is `undefined`, `null` or `''` instead of sending them. Encharge writes whatever it receives, so an empty string is a destructive write, not a no-op — a caller assembling fields from optional sources (attribution with no campaign, a form with a skipped field) would silently erase data it never meant to touch. `wabbit-site-core`'s helper carried this guard for months; it belongs here, where every consumer gets it. Values that are meaningfully falsy (`0`, `false`) are kept. Adds `tests/encharge-client.test.ts` pinning the payload shapes the live check confirmed, the blank-stripping rule, and that `trackEvent`'s properties stay a sibling of `user` (an event's properties are the event's, not the person's). Consumer follow-up: `wabbit-site-core` can now retire `src/utilities/enchargeIngest.ts` and call `createEnchargeClient(...).identifyPerson(...)` directly — the swap its own header comment deferred on 2026-06-10 pending exactly this verification.
- 8f41c63: **Encharge Ingest client: two assumptions verified against a live account, and blank values no longer overwrite a person's fields.** The client's identify payload was written from the Ingest API docs and carried `ASSUMPTION` flags because nobody had a key to check it with. a consumer's first ingest ran on 2026-09-19 with a real one, and the contact came back showing both a custom field and a tag applied by the same call. Two things are now observed rather than assumed, and the comments say so: - **Custom fields ride inside `user`.** A field sent as `sixdofSource` appeared on the person as "Sixdof Source". Sending custom fields in a sibling `properties` object — which `wabbit-site-core`'s local ingest helper does, and which was the reason that helper existed rather than importing this client — is not required for them to stick. - **Tags ride as a comma-separated string on `user`**, and the tag appeared on the contact. `removeTag` remains unverified and is now labelled as the only untested corner, since no consumer removes a tag yet. **Behaviour change:** `identifyPerson` now drops fields whose value is `undefined`, `null` or `''` instead of sending them. Encharge writes whatever it receives, so an empty string is a destructive write, not a no-op — a caller assembling fields from optional sources (attribution with no campaign, a form with a skipped field) would silently erase data it never meant to touch. `wabbit-site-core`'s helper carried this guard for months; it belongs here, where every consumer gets it. Values that are meaningfully falsy (`0`, `false`) are kept. Adds `tests/encharge-client.test.ts` pinning the payload shapes the live check confirmed, the blank-stripping rule, and that `trackEvent`'s properties stay a sibling of `user` (an event's properties are the event's, not the person's). Consumer follow-up: `wabbit-site-core` can now retire `src/utilities/enchargeIngest.ts` and call `createEnchargeClient(...).identifyPerson(...)` directly — the swap its own header comment deferred on 2026-06-10 pending exactly this verification.
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.
- 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: 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.
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: Security: both ESP webhook verifiers (Encharge, Kit) now use core's `timingSafeEqual`, replacing two independently authored and independently flawed local compares (Encharge short-circuited on length mismatch — a timing leak; Kit's dummy-loop mitigation never performed a real comparison on the mismatch path).
- 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.
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.
ffe5bfe: Fix the main barrel transitively importing `'server-only'`, which broke `payload generate:types` for any consumer wiring `initMarketing` in payload.config (found by a consumer's adoption, first 0.2.x consumer). Two static chains reached `@wabbit/tome-crm/server`: `index.ts → lib/materialize-audience` and `collections → hooks/suppression-at-enrollment`. Both now dynamic-import at hook-execution time — hook bodies never run during config build, so the barrel stays clean; runtime behavior is unchanged. Verified by hot-swapping the rebuilt dist into that consumer's app: `payload generate:types` + `tsc --noEmit` green.
- ffe5bfe: Fix the main barrel transitively importing `'server-only'`, which broke `payload generate:types` for any consumer wiring `initMarketing` in payload.config (found by a consumer's adoption, first 0.2.x consumer). Two static chains reached `@wabbit/tome-crm/server`: `index.ts → lib/materialize-audience` and `collections → hooks/suppression-at-enrollment`. Both now dynamic-import at hook-execution time — hook bodies never run during config build, so the barrel stays clean; runtime behavior is unchanged. Verified by hot-swapping the rebuilt dist into that consumer's app: `payload generate:types` + `tsc --noEmit` green.
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.
- 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.
c5175e9: Provider clients join the public surface, and peers stop pinning exact versions: - **`createEnchargeClient` (+ option types) re-exported from `./adapters/encharge`** and **`KitClient` (+ option types) from `./adapters/kit`** — consumers doing direct server-side person upserts / subscribes import the client instead of hand-rolling the HTTP calls (wabbit-site-core's `enchargeIngest.ts` existed only because this export was missing). - **Peer ranges widened** — `@wabbit/tome-core` and `@wabbit/tome-crm` peers are now explicit semver ranges (`>=1.0.0 <2.0.0`, `>=0.2.0 <0.4.0`) instead of `workspace:*`, which published as exact-version pins and broke installs on every substrate bump. - Package removed from the changesets `ignore` list (stale pre-publish entry).
- c5175e9: Provider clients join the public surface, and peers stop pinning exact versions: - **`createEnchargeClient` (+ option types) re-exported from `./adapters/encharge`** and **`KitClient` (+ option types) from `./adapters/kit`** — consumers doing direct server-side person upserts / subscribes import the client instead of hand-rolling the HTTP calls (wabbit-site-core's `enchargeIngest.ts` existed only because this export was missing). - **Peer ranges widened** — `@wabbit/tome-core` and `@wabbit/tome-crm` peers are now explicit semver ranges (`>=1.0.0 <2.0.0`, `>=0.2.0 <0.4.0`) instead of `workspace:*`, which published as exact-version pins and broke installs on every substrate bump. - Package removed from the changesets `ignore` list (stale pre-publish entry).
Three collections: `marketing-campaigns`, `marketing-campaign-memberships` (carries per-prospect prototype-site linkage), `marketing-segments`.
- Three collections: `marketing-campaigns`, `marketing-campaign-memberships` (carries per-prospect prototype-site linkage), `marketing-segments`.
- `initMarketing` (registers via the tome-core layer registry) + `buildMarketingCrmConfig` (campaign-counter cascade via CRM `onActivityCreated` + suppression mirror via `onSuppressionChange`).
- Server helpers: activate/pause/complete campaign, materialize audience, enroll/exclude contacts, KPIs, stalled memberships, expired prototype sites, recompute segment size.
- Webhook ingest: `createMarketingWebhookHandler` + `createSiteVisitHandler`. Verification is **shared-secret** per provider — neither Encharge nor Kit signs webhooks.
- Provider adapters (opt-in subpaths): **`/adapters/encharge`** (Ingest API; `X-Webhook-Token` header verification; consumer-mapped "Send Webhook" payload contract) and **`/adapters/kit`** (Kit v4; URL `?token=` verification; real Kit events — no email-open; email→id unsubscribe). Each ships a `README.md` with required consumer webhook config.