Marketing
CapabilitiesTome marketing layer — campaigns, audiences, provider adapters (Encharge + Kit), webhook ingest, and per-prospect prototype-site linkage.
npm install @wabbit/tome-marketingOverview
@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 | yes |
dependencies: server-only@^0.0.1.
60-second quickstart
The current API is createMarketingLayer(config) — config.providers is required (pass [] for manual-only campaigns). initMarketing is a deprecated pure alias (R4 ruling #3, 2026-07):
import { buildConfig } from 'payload'
import { createMarketingLayer } from '@wabbit/tome-marketing'
import { enchargeAdapter } from '@wabbit/tome-marketing/adapters/encharge'
const marketingCollections = createMarketingLayer({
providers: [enchargeAdapter({ apiKey: process.env.ENCHARGE_API_KEY! })],
})
export default buildConfig({
collections: [...marketingCollections /* ...other collections */],
})API surface
Six subpaths: ., ./server, ./widgets, ./adapters/encharge, ./adapters/kit, ./test.
`.`: createMarketingLayer, createCampaignsCollection, createCampaignMembershipsCollection, createSegmentsCollection, and all types (export * from ./types).
Deprecated aliases (R4 ruling #3, 2026-07 — 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, buildSuppressionMirror, createSiteVisitHandler), the shared verify utils (timingSafeEqualHex, computeHmacSha256 — see below), CRM integration (buildMarketingCrmConfig), and access presets (MARKETING_CAPABILITIES, marketingCampaignsAccess, marketingMembershipsAccess, marketingSegmentsAccess).
`./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(config) 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(config) 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.
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.
Links
- Design spec:
docs/superpowers/specs/2026-05-10-tome-marketing-layer-design.md - 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
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. 6dof-academy'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. 6dof-academy'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: R4 rulings #2 + #3 (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: R4 rulings #2 + #3 (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 bickley-site-core 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 bickley-site-core: `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 bickley-site-core 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 bickley-site-core: `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.