Documentation menu
Documentation / CRM & Marketing engine / crm
Crm
npm install @wabbit/tome-crmFirst install from our registry?
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_TOKEN
Tome CRM layer — contacts, accounts, opportunities, activities with sister-layer integration adapters (intake, deals, realtime, territory, ai-stubs).
Overview
Tome CRM layer — contacts, accounts, opportunities, activities, with sister-layer integration adapters (intake, deals, realtime, territory, ai-stubs). Description copied verbatim from package.json.
Layer: domain (per the repo's ARCHITECTURE.md § The Layer Model) — depends on @wabbit/tome-core and @wabbit/tome-workflow (the opportunity stage-transition guard delegates its graph check to workflow). @wabbit/tome-marketing declares crm as a required peer; @wabbit/tome-deals integrates with it as an optional sister layer.
Peer dependencies
Peer ranges, copied from package.json:
| Peer | Range | Optional? |
|---|---|---|
payload | >=3.67.0 | no |
typescript | >=5.7.0 | no |
lucide-react | >=0.460.0 | yes |
@payloadcms/ui | >=3.67.0 | yes — the admin views only |
react | >=19.0.0 | yes — the admin views only |
next | >=15.0.0 | yes — the admin views only |
@wabbit/tome-core | >=1.14.0 <2.0.0 | no |
@wabbit/tome-workflow | >=0.1.1 <1.0.0 | no — imported by the stage-transition guard |
dependencies: server-only@^0.0.1 (guards the @wabbit/tome-crm/server subpath below).
lucide-react is genuinely optional: the layer's Briefcase sidebar icon is loaded lazily, and without the package the admin sidebar shows the nav domain's default icon instead.
60-second quickstart
The current API is a single createCrmLayer(config) call that returns the four collection configs (initCrm is a deprecated pure alias, kept for back-compat only). Register custom activity types before calling it — the activity-type registry freezes the instant createCrmLayer runs:
import { buildConfig } from 'payload'
import { createCrmLayer, defineCrmActivityType } from '@wabbit/tome-crm'
defineCrmActivityType({ key: 'demo-call', label: 'Demo Call' }) // optional, must precede createCrmLayer
export default buildConfig({
collections: [
...createCrmLayer({ matchStrategy: 'email' }), // 'email' (default) | 'custom' (requires customMatcher)
// ...your other collections
],
})createCrmLayer also validates opportunityStageConfig (falls back to DEFAULT_OPPORTUNITY_STAGES — override with defineCrmStages(...)) and registers the layer into @wabbit/tome-core's layerRegistry for the admin sidebar manifest. It throws if matchStrategy: 'custom' is passed without a customMatcher function.
Defaults worth knowing before your first migration: collections are crm-contacts, crm-accounts, crm-opportunities, crm-activities (override with contactsSlug etc.); every assignedTo field relates to users (repCollection); contacts only gain a linkedMember relationship when you pass memberSlug; new contacts auto-link to an account whose domain matches their email domain (autoLinkAccountByDomain, default true); and in the deals cascade a deal status of accepted wins the opportunity (winTriggerStatus).
The opportunity edit view puts name above four unnamed tabs: Deal, Contacts, Notes & activity, Source. Being unnamed, they keep the stored document flat. On won and lost stages an Outcome block (lossCategory, closedAt, closeReason) opens under Stage, and Notes & activity lists the opportunity's activities through a read-only activities join. To add fields to a tab, use opportunityTabFields: { deal, contacts, notesAndActivity, source }. extraFields.opportunities still appends at the top level after the tabs, which is the right place for sidebar fields.
The activity-type registry is module-level and freezes on the first createCrmLayer call, so in a test file that calls createCrmLayer more than once, register custom types once at the top of the file.
API surface
The exports map has seven subpaths: ., @wabbit/tome-crm/server, @wabbit/tome-crm/server-api, @wabbit/tome-crm/admin, @wabbit/tome-crm/scripts, @wabbit/tome-crm/widgets, ./test.
`.` — config-time surface, safe to import anywhere:
| Export | What it is |
|---|---|
createCrmLayer(config?) | Top-level layer entry — returns CollectionConfig[] |
createCrmContactCollection / createCrmAccountCollection / createCrmOpportunityCollection / createCrmActivityCollection | Per-collection factories, for sites that want one collection in isolation |
defineCrmActivityType({ key, label, defaultStatus?, payloadShape? }) | Register a custom activity type (not a deprecated name — this is the activity-type registry, unrelated to the create/define collection-factory pair) — must be called before createCrmLayer |
DEFAULT_OPPORTUNITY_STAGES, defineCrmStages, getStageDefinition, validateStageConfig | Opportunity-stage config helpers |
CRM_CAPABILITIES, CrmCapability | Capability vocabulary for consumer role wiring |
CRM_REALTIME_EVENTS, CrmRealtimeEvent | Realtime event name constants |
CRM_AI_FEATURES, CrmAiFeature | AI-stub feature name constants |
| Types | TomeCrm{StageCategory,StageDefinition,OpportunityStageConfig,ContactLifecycleStage,AccountType,ActivityType,ActivityStatus,ActivityOutcome,Address,Activity*Payload,Contact,Account,Opportunity,Activity,MatchContactInput,MatchContactResult,IntakeSubmissionInput,IntakeTagField,ActivityTypeDefinition,Config,ExtraFields} |
Deprecated aliases (pure renames, @deprecated-tagged, removal at this package's next major): initCrm → createCrmLayer; defineCrmContactCollection → createCrmContactCollection; defineCrmAccountCollection → createCrmAccountCollection; defineCrmOpportunityCollection → createCrmOpportunityCollection; defineCrmActivityCollection → createCrmActivityCollection.
`@wabbit/tome-crm/server` — behind import 'server-only': matchOrCreateContact, advanceOpportunityStage, findContactByEmail, findOpenOpportunityForAccount, findOrCreateAccountByDomain, recordActivity, getOpportunityWithTimeline (+OpportunityTimeline type), suppression helpers (markContactUnsubscribed, markContactBounced, clearContactSuppression, isContactSendable, filterSendableContactIds, recordCrmActivityWithDedup), access presets (buildAccountDeleteGuard, crmContactsAccess, crmAccountsAccess, crmOpportunitiesAccess, crmActivitiesAccess, repWhereClause, accountRepWhereClause), and the cross-layer integration entry points: buildIntakeMatchStep/addIntakeTags/wireIntake/createIntakeOpportunity (intake), resolveOpportunityForDeal/onDealStatusChange/buildDealsCrmBridge (deals), lookupOwnerByZipLazy (territory), emitCrmEvent (realtime), registerCrmAiStubs (ai-stubs). For the deals bridge, pass buildDealsCrmBridge(config) to initDeals({ onStatusChange }) — The bridge is async (deal, prevStatus, req); pass it the same config object you gave createCrmLayer so slugs and stages line up. wireDeals is @deprecated: it only ever checked that the deals layer was present, it never attached the cascade, and it will be removed in v1.0. All sister-layer detection is lazy/runtime via the layer registry, not a hard import.
`@wabbit/tome-crm/server-api` — the same recordCrmActivityWithDedup, resolveOpportunityForDeal and onDealStatusChange as @wabbit/tome-crm/server, without its server-only guard. It exists for sister Tome layers that treat tome-crm as an optional peer and so load it through an unbundled import() (webpackIgnore / turbopackIgnore). That import runs on Node's native loader, which never sets the react-server condition, so @wabbit/tome-crm/server's guard always throws there. tome-deals uses this subpath. Application code should import @wabbit/tome-crm/server, whose guard still stops a client-component import at build time.
`@wabbit/tome-crm/widgets` and `./test` — both declared in package.json#exports but currently empty placeholders (export {}, verified by reading the files directly — no admin widgets or mock harness ship yet). Treat as reserved subpaths, not working surface.
Tagging contacts from intake answers
intakeTagFields copies a submitted field's value into the matched contact's tags, per intake form slug. It is off by default: a form with no entry leaves tags alone.
import { createCrmLayer } from '@wabbit/tome-crm'
const crmConfig = {
intakeTagFields: {
// a visitor who chose "project" on the "contact" form is tagged "path:project"
contact: { field: 'path', prefix: 'path:' },
},
}
const collections = createCrmLayer(crmConfig)The prefix is written literally ('path:' gives path:project; leave it out for the bare value). A multi-choice answer adds one tag per value, a tag the contact already has is not added again, and a tag is trimmed to 80 characters. Pass the submission to the intake match step and it applies the entry after matching:
import { buildIntakeMatchStep } from '@wabbit/tome-crm/server'
const match = buildIntakeMatchStep({ payload, config: crmConfig })
await match({ email, firstName, intake: { formSlug: 'contact', data: submission.data } })A site that calls matchOrCreateContact itself can call addIntakeTags({ payload, config, contactId, submission: { formSlug, data } }) afterwards; it returns the tags it added.
Contact-only forms. Pass dataPolicy: 'contact-only' on the submission (from the intake form's resolved policy) and the form-submission activity keeps its subject and ids but writes no summary from the message, and the form's intakeTagFields entry is not applied, whatever data holds. Omitted or 'standard' behaves as before. This is a limit on what this package copies onto a contact; whether a contact outlives the submission it came from is the site's decision.
Overview views (@wabbit/tome-crm/admin) and scripts (@wabbit/tome-crm/scripts)
@wabbit/tome-crm/adminexportsContactOverviewView(the contacts collection's default edit view, with the stock form on an Edit tab; the create route and document drawers fall through toDefaultEditView) andPipelineListView(the opportunities list: Pipeline mode with KPIs and stage groups, and All rows, which is Payload'sDefaultListViewunchanged).createCrmLayerandcreateCrmOpportunityCollectionregister both;admin: { overview: false }keeps the stock views.- The Contact Overview composer posts to
POST {api}/{activities}/log, an endpoint on the activities collection that runslogContactActivityas the signed-in user. - Opportunities gain a read-only virtual
valuecolumn (formattedvalueCents/currency) for the list. @wabbit/tome-crm/scriptsexportsbackfillStageChangedAt(dry run by default) for sites with opportunities created beforestageChangedAt.
Server / client posture
Fully server-side. The main barrel is Payload CollectionConfig factories, constants, and types — no React. @wabbit/tome-crm/server is hard-guarded with import 'server-only' so a client-component import fails the Next.js build rather than silently bundling. sideEffects: false in package.json (a bare boolean, not an array) — crm ships no import-side-effect registration modules.
Links
- Gotchas: Every exports-map subpath needs a filesystem-resolvable shape (the repo's
docs/claude-gotchas.md) — relevant given the four subpaths above CHANGELOG.md
Extending this package
- New activity types: call
defineCrmActivityTypebeforecreateCrmLayer— the registry throws on duplicate keys and freezes atcreateCrmLayercall time, so late registration is a hard error, not a silent no-op. - Custom opportunity stages: build a config with
defineCrmStages(...)and pass it ascreateCrmLayer({ opportunityStageConfig }); it must include at least onewon-category stage orvalidateStageConfigthrows. - Sister-layer integrations (deals, marketing, intake, territory) are all optional and detected via
hasLayer()at runtime — none of those layers is required to use crm standalone (the required peers arepayload,typescript,@wabbit/tome-coreand@wabbit/tome-workflow).
Testing
pnpm --filter @wabbit/tome-crm test runs the Vitest suite. The published @wabbit/tome-crm/test subpath is still an empty placeholder, so tests pass a stub payload (an object with the find / create / update methods the helper under test calls) to the @wabbit/tome-crm/server functions. @wabbit/tome-crm/server imports server-only, which throws outside a React Server environment unless your runner aliases it to an empty module.
Exports
| Import path |
|---|
@wabbit/tome-crm |
@wabbit/tome-crm/server |
@wabbit/tome-crm/server-api |
@wabbit/tome-crm/admin |
@wabbit/tome-crm/scripts |
@wabbit/tome-crm/widgets |
@wabbit/tome-crm/test |
Changelog
0.10.xMinor
BREAKING: The contacts default edit view is now the Contact Overview and the opportunities list is now the Pipeline list, backed by new activity and opportunity fields.
Migration: Opt out of both views with
admin: { overview: false }. The opportunities list default columnvalueCentsis now the virtualvalue; update any saved list configuration.The new fields need a migration on SQL adapters (MongoDB needs none). Run
backfillStageChangedAtfrom@wabbit/tome-crm/scriptsonce per consumer (dry run by default).- Activities gain
direction,durationMinutes,attendeesanddueAt; activity types gain an optionalgroupandicon. form-submissionandproposal-viewedbecome built-in activity types (an identical re-definition by a site is ignored, a conflicting one still throws).- Opportunities gain a read-only
stageChangedAt, stamped on create and on every stage change, and adealsjoin when the newdealsSlugoption is set. - Intake wiring logs a
form-submissionactivity on the matched contact (opt out withintake: { logActivity: false }). recordCrmActivityWithDedupaccepts anaggregationKeyoverride, and the deals bridge marks its send emailoutbound.- New
POST {api}/{activities}/logendpoint, used by the Contact Overview composer. @wabbit/tome-crm/serveraddsgetContactOverview,getPipelineSummaryandlogContactActivity; a new./adminexport carries the view names.react,nextand@payloadcms/uiare new optional peer dependencies.
- Activities gain
7 fixes (0.10.1 – 0.10.7)
- 0.10.7
Canceled statuses now accept both spellings.
This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet.
Training events, attendance, exam tickets, LMS orders, events, campaigns, CRM activities, resource requests, task forces and the event, status board and operation briefing blocks gain the American
canceled(CANCELEDon the status board) next to the existing value. Reads, queries, the closed-event lock, event board state and selection, the calendar export (STATUS:CANCELLEDis unchanged, it is the iCalendar literal), the status board badge, theme selectors fordata-event-statusand the erasure trail treat the two spellings as one meaning.The event list board labels gain a
canceledkey that is read before the oldcancelledkey. The old field is hidden, not removed, so stored text is kept on save.The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)".
Writers still store the old values.
- 0.10.6
Admin labels, field descriptions, demo content and messages now use American spellings.
- 0.10.5
Forms and intake gain an opt-in
contact-onlydata policy, per form or site-wide, plus retention policies and GDPR registration for submissions.standardforms behave as before.dataPolicy: 'contact-only'(ondefineForm,defineIntakeForm, orpolicy.defaultoncreateFormsLayerandcreateIntakeLayer, withpolicy.lockandpolicy.sensitiveTerms) limits a form to contact fields and one textarea of at most 500 characters, and refuses other field types, sensitive terms, a webhook target, the formsemailtarget (useintakeorpayload), an unacknowledged custom target or router, and an intake emailhtmlBuilder. It is checked when the form is defined, when the layer is created, and when an admin form is saved.Under the policy, intake stores declared fields only; staff email and subject lines carry no submitted values;
onIntakereceives identity with an empty payload; partial saves are forced off; and a notice renders above the submit button (hookdata-form-data-notice). The policy reduces what a form asks for and where the answers travel.It does not scan what people type, and it is not a legal or regulatory determination.
New exports:
formsRetentionPoliciesandintakeRetentionPolicies(hard-delete rows in core's retention model; nothing runs until you schedule them), and the data-policy helpers listed in each README.@wabbit/tome-crmtakes an optionaldataPolicyon the intake submission input; with'contact-only'theform-submissionactivity writes no summary andintakeTagFieldsis not applied.Check this when you upgrade:
forms-submissionsgains an indexedemailcolumn (filled from the form's first email field), so a database that applies migrations needs one; older rows have no value until backfilled. Both packages now register their submissions collection with core's GDPR registry, matched onemail, so core's data export and account erasure include those rows from now on. - 0.10.4
README: subpath and file mentions are written package-qualified (for example
@wabbit/tome-x/render) or as code text, so crawlers no longer resolve them as site URLs. - 0.10.3
New
./server-apisubpath:recordCrmActivityWithDedup,resolveOpportunityForDealandonDealStatusChange, without./server'sserver-onlyguard, for sister layers that load tome-crm through an unbundledimport().That native import never gets the
react-servercondition, so./server's guard always threw there, past the caller's try/catch, and killed the process../serveris unchanged; application code keeps using it. - 0.10.2
The contact Overview's summary strip (avatar, role, last contacted, open pipeline, owner) keeps Payload's horizontal gutter, so it no longer sits flush against the admin nav or clips the owner at the window edge.
- 0.10.1
Proposal views read "Opened proposal PROP-2026-0001" on the contact timeline instead of the raw key
proposal-viewed.recordCrmActivityWithDeduptakes an optionalsubjectfor a new row; without it the subject is still derived from the eventType.- tome-deals passes the proposal number as the subject when it logs a recipient view.
0.9.xMinor
Opportunity edit view is now tabbed:
namesits above four unnamed tabs (Deal, Contacts, Notes & activity, Source), so stored data is unchanged and no migration is needed.The Deal tab pairs Stage and Probability, with an Outcome block (Loss Category, Closed At, Close Reason) beneath them on won and lost stages.
closedAtis read-only in the admin.The Notes & activity tab lists the opportunity's CRM activities through a new
activitiesjoin. NewopportunityTabFieldsconfig seam (deal,contacts,notesAndActivity,source) appends fields to the end of a tab;extraFields.opportunitiesstill appends at the top level after the tabs.Stage transition guard now throws Payload
ValidationError(400 with a field-level admin message) instead of a plainError(opaque 500), so moving an opportunity to a lost stage without a loss category is a readable form error.
0.8.xMinor
New
intakeTagFieldsconfig copies a submitted intake field's value into the matched contact's tags; it is off unless a form has an entry.- `intakeTagFields: { [formSlug]: { field, prefix? } }`: for the named form, the value of
fieldbecomes a contact tag,prefixwritten literally ({ field: 'path', prefix: 'path:' }tags a visitor who chose "project" aspath:project). A multi-choice answer adds one tag per value, a tag the contact already has is not added again, blank values add nothing, and a tag is trimmed to 80 characters. - `buildIntakeMatchStep` applies the entry after matching when the match input carries the submission:
match({ email, intake: { formSlug, data } }). Withoutintake, or for a form with no entry, it behaves exactly as before. - New `addIntakeTags({ payload, config, contactId, submission })` on
./serverfor a site that callsmatchOrCreateContactitself. It returns the tags it added and the contact's tag list. - New types
TomeCrmIntakeTagFieldandTomeCrmIntakeSubmissionInput;TomeCrmMatchContactInputgains an optionalintake. No collection or field changes, so no migration.
- `intakeTagFields: { [formSlug]: { field, prefix? } }`: for the named form, the value of
0.7.xMinor
crm-contactscreate now requirescrm:write, the same gate as accounts, opportunities and activities.It was open to any authenticated session, so a self-registered visitor could POST contacts over REST. Server-side writers (intake bridge,
matchOrCreateContact) already useoverrideAccess: trueand are unaffected.A consumer that creates contacts through the REST API or the local API without
overrideAccessas a non-staff user must grantcrm:writeor passoverrideAccess.
0.6.xMinor
Security: the bootstrap
crm:read/crm:read:ownfallback — which granted CRM read access to ANY authenticated session whenever a site had not seeded capability grants — is now OFF in production by default.Outside production it still applies so a fresh site works out of the box. A production site that needs the bridge while it seeds grants can opt back in explicitly with
TOME_CRM_BOOTSTRAP_READ_FALLBACK=1.crm:write/crm:adminare unchanged.Why minor, not patch: a production deployment that never seeded CRM capabilities and relied on this silent bridge will lose CRM reads for non-admin sessions until it seeds grants or sets the flag. That was a live PII exposure (a public-demo consumer was found relying on it); the loud production warning this package already emitted has been telling operators to seed grants since 0.3.1.
11 fixes (0.6.1 – 0.6.3)
- 0.6.3
Published packages now contain compiled JavaScript and type declarations under a one-line license 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.ctsdeclarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license:/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */.The
.mapfiles and thesourceMappingURLcomments 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 behavior change, and nothing to do on upgrade.
In
@wabbit/tome-blocks-gallery, the source snapshotsextractGallerySourcewrites from an installed pack leave out the license 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. - 0.6.2
Internal refactor: collection slugs are now typed through the shared
typedSlug()helper instead of inline casts.No API or behavior change.
- 0.6.2
The layer now loads without
lucide-reactinstalled; the optional peer supplies only the sidebar icon.The
Briefcaseicon is imported lazily when the layer registers. Previously a static import made every entry point that registers the layer throw on an install withoutlucide-react.Without it, the admin sidebar shows the nav domain's default icon.
- 0.6.1
Adopt
@wabbit/tome-core/fields/addressand@wabbit/tome-core/utilities/relationIdat the sites the audit counted (2026-09-01 sale-readiness audit §5.1, T3(g)).No stored field name, and no emitted field array, changes anywhere in this changeset — each adopter passes the vocabulary it already stores, and each ships a characterization test that was written from the pre-change source, run green against the untouched factory, and run green again after.
Address group — five sites, one implementation.
@wabbit/tome-crm—accountsandcontactseach carried a byte-identical seven-fieldaddressgroup. Both now spreadpostalAddressFields({ vocabulary: 'legacy-crm' })after their ownnameline (nameis the company/contact line, not a postal line).tests/address-characterisation.test.tspins both groups whole.@wabbit/tome-deals—billingAddressandshippingAddressinside the frozen Customer Snapshot were copies three and four. They now come from onebuildSnapshotAddressGrouphelper:name+companyprepended locally, the six postal lines from core, and the eight per-field labels plus the'US'country default passed through core'sfieldOverridesseam. The snapshot is a legal-offer record frozen after send, so a field-name change would orphan the address on every deal already sent;tests/address-characterisation.test.tspins both groups and the fact that they differ only in the group label and the recipient line's label.@wabbit/tome-fulfillment— the fifth copy, and the only one that validatedcountry. Its postal lines stay FLAT at collection top level (they are stored columns with PII rows and a GDPR registration behind them), now viapostalAddressFields({ vocabulary: 'postal', required: true, validateCountry: true }). The ISO-3166 validator and its uppercase-normalizing hook moved into core verbatim; because a moved function is a new object,tests/address-characterisation.test.tspins the whole top-level field ORDER plus the validator's and hook's BEHAVIOR (acceptsUS, rejectsusa, rewrites' us 'to'US'), not their identity.
`relationId` — the four-return-types problem.
@wabbit/tome-lms— twelve modules undersrc/server(academy,catalog,certificates,course,dashboard,enrollment,grades,leaderboard,learnerShell,notes,profile,reviews) carried a byte-identicalstring | nullcopy. They importrelationIdfrom core now. One behavioral difference, strictly an improvement: on a malformed populated doc ({ id: null },{ id: {} }) the old copy returned the STRING'null'/'[object Object]'as an id; core returnsnull.tests/relation-id-adoption.test.tspins the adoption itself, because adoption is the thing that decays — the July 2026 audit's finding, repeated verbatim in September, was "extraction keeps happening, adoption does not."
Not migrated, deliberately:
src/guards,src/utilities/{grading,prerequisites,progress}.ts,src/hooks/**,src/server/mutations/helpers.tsandsrc/server/awardGate.tsreturnstring | numberorundefined. Migrating those is a semantic change, not an import change, and belongs in a pass that owns their call sites.The new test names them as out of scope so the next reader does not have to re-derive why.
@wabbit/tome-sc— the registry sub-cluster's copy is gone;collections/registry/shared.tsre-exports core'srelationId, keepingextractIdas a local alias (the module is private to that sub-cluster). This one WIDENS: the sc copy returnedstring | number, so a populated doc's numeric id came through unstringified. It is now stringified, which makes===between two resolved ids agree — the behavior every call site in the cluster already assumed. Ids handed back topayload.find/updateare unaffected, since Payload accepts either form in awhereclause. sc's 179 tests stay green.@wabbit/tome-crm— the inline ternary inintegration/deals.ts(typeof oppRaw === 'object' ? oppRaw.id : oppRaw) was the fifth shape and had the same numeric-id asymmetry; it is onerelationId(deal.opportunity)call now.
`fetchMemberId` ×4 — one implementation (sc).
asset-availability,fleet-logsandfleeteach carried a verbatim copy of the auth-user → Member-row lookup, andresource-requestscarried its projecting twin. All four now import fromsrc/access/fetchMemberId.ts, which documents why each query knob is load-bearing:overrideAccess: true(the member collection's own read access may itself depend on membership, so without the bypass this is a circular check that denies the owner their own row),depth: 0,pagination: false.The id is returned in its STORED type here rather than through
relationId— this is an identity read fed straight back into awhereclause, not a relationship read.tests/fleet-shared-helpers.test.tspins the adoption, the three knobs, and the null-for-anonymous contract. - 0.6.1
dist now raw-Node loadable: relative specifiers get explicit extensions post-build.
buildgains&& node ../../scripts/fix-dist-extensions.mjs --strictas its last step, joining the 13 packages that already ran it. tsup buildsbundle: falseand emits relative specifiers exactly as the TypeScript source wrote them — extensionless — which bundlers resolve and raw Node does not (ESMERR_MODULE_NOT_FOUND; CJS worse,require('./x')finds the ESM.jstwin 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.mjsnow 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--strictexited 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/.cjsappended.assert-node-loadable.mjsgained the matching carve-outs so the new repo-wide CI gate reports real defects only: a resolution failure whose path lands undernode_modulesis a peer SKIP (next@15 has no exports map, sonext/imagefails as an absolute path), and a bundler-asset load failure is an environmental SKIP (CJS surfaces it asSyntaxError: 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 importmissing/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. - 0.6.1
One
LayerFactoryConfigevery 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 whatconfigmay 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 learnedadminGroupfrom org andhooksfrom 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/layerFactoryConfigexports theLayerFactoryConfiginterface —adminGroup,access(per-collection override map),hooks(appended viamergeHooks, never replacing),extraFields,fieldOverrides,omitFields,fieldOrder,slugs— andapplyLayerFactoryConfig(collections, config), which honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated tofields/fieldShape'sapplyFieldShapeso the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config.It is a separate subpath from
./utilities/layerRegistrydeliberately — that module is in core'ssideEffectsarray, 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'sRecord<string, string | undefined>), and named<name>Slug?: stringscalars for relationship targets in OTHER layers (memberSlug,mediaSlug,eventSlug,rolesSlug) — those are pointers out of a layer, not entries in its key set.*Every `createLayer
config now extends it.** Twelve extendLayerFactoryConfigdirectly and APPLY it throughapplyLayerFactoryConfig(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-coreas an OPTIONAL peer and hold zero runtime imports of it — gamification reachesregisterLayerthrough a lazyrequire()in a try/catch for exactly this reason.applyLayerFactoryConfigis 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 byassert: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 againstCollectionConfig, 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 carryadmin.groupexactly as collections do. rpg extendsOmit<LayerFactoryConfig, 'access'>becauseCharacterSheetsConfigis a single collection's config that doubles as the layer factory's config, and its ownaccessalready 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 EMPTYCollectionConfig[]: 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
WorkflowLayerConfigomits every seam for the same reason, and exists as the stable place a real option will land.createGamificationLayerandcreateRpgLayerare pure aliases ofregisterGamificationLayer/registerRpgLayer.initWorkflow,registerGamificationLayerandregisterRpgLayerare all@deprecatedwith sunset at each package's next major; none is removed.Forcing function:
scripts/assert-layer-factory-contract.mjs+pnpm assert:layer-factory-contract, wired intoplatform-discipline.ymlafterassert:layer-version(source reading only, pre-build). Every exportedcreate*Layermust take a config parameter whose type resolves toLayerFactoryConfig— throughextends, an intersection, or an explicitOmit<…>— with verb aliases followed to theirregister*/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.mdgains a "The factory contract" section stating the rule and the three permitted responses.Three factories are ALLOWLISTED with a reason each:
createAiLayerreturns credential wiring and owns no collections, so every seam is meaningless to it;createFormsLayerandcreateIntakeLayerare 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-coreto>=1.14.0 <2.0.0. The new subpaths do not exist below that, and a too-low floor is howERR_PACKAGE_PATH_NOT_EXPORTEDreached crowdfund's consumers once already.These are marked
patchbecause 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. - 0.6.1
Pin each layer's registered version to
package.jsoninstead of a hand-typed literal.registerLayer(name, { version })is the contract a consumer reads back throughhasLayer/getLayerto 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-formsTOME_FORMS_VERSION = '0.1.0'0.3.2 @wabbit/tome-intakeTOME_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.tsexporting<NAME>_LAYER_VERSION, read by itsregisterLayercall — 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-localTOME_*_VERSIONconsts 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 intoplatform-discipline.ymlpre-build) parses everyregisterLayercall in the repo, resolves itsversionargument 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 theassert:test-floorstarting-debt allowlist.No runtime behavior changes for a consumer already on a current install — the version a layer reports simply becomes true.
- 0.6.1
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.
homepagedeep-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.urlpoints at the repo issue tracker, so a paying customer has a place to report a defect that is not email.engines.nodeis>=22, matching the rootenginesand.nvmrcset the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use fornode --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(rootpnpm assert:manifest-metadata, wired intoplatform-discipline.ymlbesideassert:license-metadata) fails when any publishable manifest lacksdescription,repository.directorymatching its own folder,homepage,bugs,engines.nodeequal to the repo floor,license,filesorsideEffects. It reported 138 violations before this change and 0 after. - 0.6.1
`normalizeEmail` adoption on the email fields the 2026-09-01 audit flagged, plus the one swallowed error that had no logger.
Email is the cross-layer join key — forms hands a submission to intake, intake to CRM contact matching, CRM to marketing suppression — and
findContactByEmail/matchOrCreateContactnormalize before they query. Any layer that stores a raw address forks the same person into two records at the first hand-off.- intake —
intake-submissions.email(required, indexed, the CRM join column) now normalizes through core's canonicalnormalizeEmailvia a newnormalizeEmailFieldfield hook. Field-level rather than folded intobeforeValidateIntake, which returns early on anything but a create: an admin retyping an address on update is exactly the case a create-only hook misses. - forms — the
emailRecipientconfig field gains the same hook, and the email field-type descriptor'ssanitizestops hand-rollingv.trim().toLowerCase()and delegates tonormalizeEmail. That fork agreed byte-for-byte today, which is the problem: the day the shared helper learns anything, forms silently stops agreeing. (assert:no-forked-primitivescatches exact-body forks, not inline expressions like this one.) - crm —
record-crm-activity-with-dedup.ts's account-resolutioncatchwas the one swallowed error in the repo's sample with no logger call at all (audit §6). The swallow is correct — an activity row without an account link beats a dropped webhook — but a contacts lookup failing there is normally a slug misconfiguration or a permissions change, and every later activity lands unlinked until someone notices. It now warns throughreq.payload.logger.warnwith aconsole.warnfallback, naming the contact and the slug, matchingaccess/presets.tsandhooks/stage-change-dispatch.ts.
Normalization stays deliverable-address preserving (trim + lowercase only; no dot-stripping, no plus-tag removal) — asserted, because the stored value is what gets emailed.
- intake —
- 0.6.1
First test suites for the four packages the 2026-09-01 sale-readiness audit named as "security-relevant code with no test" (§7).
No behavior changed in marketing or intake; forms and crm ship one behavior change each, described below and covered by the same suites.
- marketing —
webhooks/verify-utilsgets published HMAC-SHA256 known-answer vectors (RFC 4231 TC2, quick-brown-fox) plus a cross-check againstnode:cryptoas an independent oracle, and a full contract table fortimingSafeEqualHex(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. Thesecret(Encharge) vswebhookSecret(Kit) parameter-name split is pinned as a contract, not harmonised: passing the other package's key name leaves the secretundefined, 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 —
withIntakeAccessgets the full truth table:createdenied for everyone including admins (all writes go throughsubmitIntakeActionwithoverrideAccess: 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'sTODO: wire to tome-core capability registryis untouched — the suite pins the CURRENT roles-array heuristic, including its case-sensitivity and the fact that it ignoresrole/_populatedRoles, so the wiring change arrives as a deliberate diff. - forms —
server/targets/webhookcovered 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-Errorthrow, never throwing to the caller). The absence of any timeout is asserted explicitly rather than glossed:fetchis called with noAbortSignal, so a hanging endpoint hangs the submission — that assertion is the ticket, and it flips loudly when a timeout lands. - crm —
access/presetscovered for all three paths: seeded capability grants, the legacyadmin/superadmin/super-adminroles-array fallback, and the bootstrap fallback that openscrm:readto 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.buildAccountDeleteGuardand the once-per-process production warning are covered too.
All four packages gain a
testscript (vitest run) and a vitest devDependency, and are removed fromscripts/assert-test-floor.mjs's ALLOWLIST — a stale allowlist entry fails the assert in both directions. - marketing —
- 0.6.1
Delegates to
@wabbit/tome-workflow; local guards deprecated.@wabbit/tome-workflowis 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-workflowas a required, explicitly non-optional peer (>=0.1.1 <1.0.0) with aworkspace:*devDependency twin, and all three raise their@wabbit/tome-corepeer 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,getRegisteredSideEffectKeysand_resetSideEffectRegistryare now thin wrappers overdefineWorkflowSideEffect/replaceWorkflowSideEffect/getWorkflowSideEffect/getRegisteredWorkflowSideEffectKeys/_resetWorkflowSideEffectRegistry, each@deprecatedwith sunset at the next major.findTransitionandvalidateWorkflowlikewise wrap workflow'sfindTransition/validateTransitionTable.resolveWorkflowandDEFAULT_DEAL_WORKFLOWare NOT deprecated: the default quote lifecycle is deals' own domain data, andresolveWorkflowresolves 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
MaptoglobalThiskeyed bySymbol.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
Mapas 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 behavior 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'sfindTransition/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".buildStageTransitionGuardis NOT deprecated: it is a PayloadbeforeChangehook factory and workflow ships no hook;guardedTransitionis 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 aTomeCrmOpportunityStageConfigonto aWorkflowTransitionTable, and the allow decision comes from workflow. Three semantics stay local, each because delegating them would change behavior: (1) a stage that declares NOallowedTransitionsis UNCONSTRAINED in crm, and a transition table cannot distinguish "no edges declared" from "no edges permitted" — feeding those stages tofindTransitionwould 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-categorylossCategoryrequirement is a field-level data rule keyed off a stage'scategory, and hanging it onWorkflowTransition.guardwould 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(dealsstatus-transition-guard.ts) andtomeCrmSuppressStageDispatch(crmadvance-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.
0.5.xMinor
Naming convergence (all additive; every old name keeps working as a
@deprecatedalias 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 gaincreate*Collectionnames for their formerdefine*Collectionfactories. Layer entries converge oncreateXLayer(config?) → bundle:createCrmLayer/createDealsLayer/createMarketingLayer/createCatalogLayer/createEconomyLayer/createChromeLayer/createLmsLayer/createAiLayer(+createFormsLayer/createIntakeLayer), returning bareCollectionConfig[]where the layer contributes only collections or an honest named bundle where it hands back more (chrome:{ globals }; lms/ai:{ collections, hooks }); void-returninginitCatalog/initEconomystay as the single registration call sites, delegated to internally.Naming note for forms consumers:
createFormsCollection(singular factory) vscreateFormsCollections(plural composer) vscreateFormsLayer(layer entry) — each docblock states the distinction.Email lookup paths adopt core's
normalizeEmailinstead of hand-rolled lowercasing: crm'sfind-contact-by-email/match-or-create-contact, and deals'create-from-intake— the latter was missing.trim(), so a padded intake email could fork a duplicate CRM contact.registerLayeris now statically imported (forms/intake pattern) instead of lazilyrequire()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'sadmin-nav/self-register.tsdeliberately keeps its subpathrequire()(documented ESM/CJS dual-cache fix — do not convert).Every package now declares an explicit
sideEffectsfield (38 added; motion/engine/forms already correct).Registration-bearing modules (render files'
registerRenderer,blocks/*/index.tsdefineBlockself-registration, widgetregister.tsfiles, 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 blanketfalseon a package with registration or CSS.DRY adoption sweep (the audit's "adoption, not extraction" rule): crm/deals capability presets delegate to core's
sessionHasCapabilityOrLegacyAdmin; new corebuildOwnershipWhere/ownershipOrBypass(via./access) adopted by core's vendorScoped, catalog's vendor-scoping, and org's ownOrScoped (public APIs unchanged);slugField()adopted at 7 sites where semantics matched exactly (core lms collections + createMemberCollection — replacing a third independent slugify), with ~25 sites honestly skipped for named semantic divergences (auto-regenerate-on-clear vs allow-empty, collection-level hook pattern) now listed as core-enhancement candidates; newformatDisplayDatein blocks-core utilities (UTC-pinned, hydration-safe) adopted at 5 verified-identical sites; lms-ui consolidates its two certificate date formatters locally;useMediaQuery/useIsMobilepublished from tome-ui and adopted by AppShell + admin's SidebarProvider; gamification'sawardPointsnow uses the authoritativegetPointsBalance(fixes a divergent 1000-row scan cap vs the correct 10000).
0.4.xMinor
Add configurable
winTriggerStatusto the deals->CRM cascade (TomeCrmConfig).The linked opportunity advances to its
wonstage when a deal reaches this status. Default is'accepted'— unchanged behavior: a signed deal wins the opportunity (the quote/SOW lifecycle).Consumers whose payment is decoupled from signature (e.g. proposals paid via Stripe Checkout) set
winTriggerStatus: 'paid'so the win — and any spawn-on-won side-effect — fires on payment, not on signature. No behavior change for existing consumers; thepaidpayment-note activity is unaffected.
2 fixes (0.4.1 – 0.4.3)
- 0.4.3
Admin label polish + formatted commerce money columns: explicit labels for CRM collections ("CRM Accounts…"), Admin/Learner UI Preferences, and better-auth generated collections ("Auth Accounts", "Two-Factor Credentials", OAuth/JWKS casing) via the plugin's customizeCollection hook; nav SYSTEM_LABEL_OVERRIDES map (payload-kv → "Payload KV") applied at resolver + pinned-section label sites; Orders.total / Payments.amount / Prices.amount virtual afterRead fields format integer cents against the row currency ("4900" → "$49.00") in list views with no client components (zero generate:importmap coupling).
- 0.4.1
Docs-manifest emitter pipeline (W3 ship-readiness).
@wabbit/tome-blocks-corenow ships a standalone Node ESM CLI atscripts/emit-docs-manifests.mjsthat emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free<pkg>/metablock-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
./metapayload-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'sBlockMetawas 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.mdto its publishedfilesarray so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.
0.3.xMinor
v0.3 consumer seams — promotes the four platform gaps wabbit-site-core's dogfood proved (2026-06-10 audit).
- `extraFields` config seam —
TomeCrmConfig.extraFields.{contacts,accounts,opportunities,activities}appends site-specific fields (attribution, lead scoring, …) after platform fields. Retires the consumer-sidewithCrmExtensions()post-processing pattern. - `onOpportunityStageChange` now fires on every stage transition — the opportunities collection's afterChange hook is the dispatch point, so admin-UI edits and raw
payload.updatecalls dispatch the adapter, not justadvanceOpportunityStage(). The helper suppresses the hook via request context when its own call-time config carries the adapter, so each transition dispatches exactly once. Does not fire on create. - `findOpenOpportunityForAccount(payload, accountId, { config })` — canonical "one open opportunity per account" dedup helper, exported from
/server. - `buildDealsCrmBridge(config)` — ready-made callback for
initDeals({ onStatusChange })(sent→activity, accepted→won, rejected→lost, paid→payment note). Structurally typed (CrmDealLike); no dependency on @wabbit/tome-deals. `wireDeals` is deprecated — it was a layer-presence probe that attached nothing (the GAP-3 trap) and will be removed in v1.0.
- `extraFields` config seam —
3 fixes (0.3.1 – 0.3.2)
- 0.3.2
Fix two consumer-breaking defects found by a consumer's adoption (first post-0.3.x consumer).
- `linkedMember` is now composition-gated on `config.memberSlug` — previously the contacts factory built the relationship unconditionally with
relationTo: memberSlug ?? 'members', throwingInvalidFieldRelationshipat Payload init for any consumer without amemberscollection. The CRM spec makes member identity consumer-wired and optional; the field now follows the same presence pattern assourceSubmission/intakeSubmissionsSlug. Both existing consumers (wabbit-site-core, tome-starter) setmemberSlug: 'members'explicitly and keep the field unchanged; consumers that omitted it were crashing, so no working configuration changes behavior.
- `@wabbit/tome-core` peer floor raised `>=1.0.0` → `>=1.1.0` — crm's dist imports
@wabbit/tome-core/utilities/normalize, a subpath only exported from core 1.1.0, so the declared floor producedERR_PACKAGE_PATH_NOT_EXPORTEDat runtime on core 1.0.x installs.
- `linkedMember` is now composition-gated on `config.memberSlug` — previously the contacts factory built the relationship unconditionally with
- 0.3.1
Consolidation pass (2026-06-10 audit dialect-drift findings) — the platform stops forking its own conventions.
tome-core (minor — new public APIs):
./auth/repScoping—buildRepWhereClause({ adminCapability, repField })+buildCapabilityScopedRead({ readCapability, adminCapability, repField })+sessionHasCapabilityOrLegacyAdmin+DENY_ALL_WHERE. The canonical "rows I own" access primitive, promoted from crm/deals' ~90%-identical copies (266 LOC → one parameterized implementation)../utilities/normalize—normalizeEmail(trim + lowercase). Email is the cross-layer join key; one normalizer, everywhere../fields/slug—formatSlugupgraded to the canonical algorithm (promoted from catalog's strictly-more-robust slugify: collapses whitespace/hyphen runs, trims edge hyphens); newbuildAutoSlugHook(sourceField, slugField)collection-level variant. Stored slugs untouched; only future generations on irregular-whitespace inputs differ.
catalog / org / crm / deals (patch): local copies replaced with delegations to the core primitives. Public names and signatures unchanged (
slugify,autoSlugHook,buildNormalizeEmailHook,normalizeDealEmail,repWhereClause,accountRepWhereClause,dealsRepWhereClause,dealsRepOrAdminWhereClause).Notably, org's auto-slug header had _claimed_ to wrap core's slugifier while carrying a divergent local copy — now it actually does.
- 0.3.1
Platform-wide peer-range sweep: every
workspace:*/workspace:^entry inpeerDependenciesreplaced 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-crmranges 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.0exactly. devDependencies keepworkspace:*for the local link. (@wabbit/tome-admin-progot 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.
0.2.xMinor
Add the v0.2 marketing-substrate contract (consumed by
@wabbit/tome-marketing).All new fields are nullable/optional — non-breaking for existing consumers.
- Suppression state + maintenance helpers:
markContactUnsubscribed,markContactBounced(soft-bounce threshold with auto-suppress),clearContactSuppression,isContactSendable,filterSendableContactIds. Newcrm-contactsfieldsbouncedAt/bounceType/suppressionReason/suppressionSource, and anonSuppressionChangeconfig adapter (carriessourceso consumers can loop-guard provider suppression mirrors). - Provider-event ingestion:
recordCrmActivityWithDedupwith aggregate-at-ingest — a compositeaggregationKeycollapsesopened/clicked/site-visitedper contact/campaign/day, whilesent/replied/bounced/unsubscribedstay 1:1 — plus a(provider, externalId)unique index for idempotent webhook ingestion. Newcrm-activitiesfieldsprovider/eventType/aggregationKey/eventCount/firstEventAt/lastEventAt, abuildSuppressionCascadeHook, andbuildStampLastContactedHooknow skips provider events that are not "we contacted them". NOTE: the event-payload param onrecordCrmActivityWithDedupiseventPayload(notpayload, which is the Payload instance). - Optional `crm-activities.campaign` relationship (config-driven via
activityCampaignSlug, defaultmarketing-campaigns) for campaign attribution — only registered when the slug is set; CRM never imports marketing.
- Suppression state + maintenance helpers:
0.1.x
4 fixes (0.1.2 – 0.1.5)
- 0.1.5
Updated dependencies [8947ff1]
- @wabbit/tome-core@1.0.12
- 0.1.4
Updated dependencies [36dc023]
- 0.1.4
Updated dependencies [2612799]
- @wabbit/tome-core@1.0.11
- 0.1.2
Phase 1.5 dogfood findings — three bugs surfaced when Wabbit became the layer's first consumer.
- `crm-activities` duplicate field name. Both rich-text
bodyand the textarea fallback declaredname: 'body', discriminated byadmin.condition. Payload's sanitizer rejects same-level field-name collisions regardless of conditions, throwingDuplicateFieldName: 'body'at config build (no consumer could callpayload generate:typesor boot dev). Renamed the textarea tosummary.TomeCrmActivitytype andrecordActivityhelper updated to route input.body tobodyfor note/email types and input.summary tosummaryotherwise. - Hook type narrowing fails cross-repo. Consumers with a populated
payload-types.tswidenDataFromCollectionSlug<CollectionSlug>to a 50+ collection union, hidinglifecycleStage/lastContactedAtfrom the dynamic-slugfindByIDresults incascade-contact-lifecycleandstamp-last-contacted. Theauto-link-accounthook also tripped Wabbit'snoUncheckedIndexedAccess. Read results now narrow structurally to{ lifecycleStage?: string }/{ lastContactedAt?: string | null }; update payloads cast throughunknown as never; array indexing guarded. - `sourceSubmission` field hard-required `intake-submissions` collection. The relation field on
crm-contactsandcrm-opportunitiesresolved toconfig.intakeSubmissionsSlug ?? 'intake-submissions'unconditionally, throwing Payload init for any consumer without@wabbit/tome-intakeregistered. Field is now conditional: declared only whenintakeSubmissionsSlugis passed in config. Mirrors the composition-presence pattern used by@wabbit/tome-lms.
No public API changes outside
TomeCrmActivity(addedsummary?: string | null) and therecordActivityhelper (now readsinput.summaryfor non-rich types). v0.1.0 had zero published consumers; Wabbit's wiring is being adjusted in the same Phase 1.5 cycle. - `crm-activities` duplicate field name. Both rich-text