Deals

Capabilities
@wabbit/tome-dealsv0.5.0

Tome Deals layer — deal lifecycle engine with artifact-type registry (quotes, proposals, SOWs, retainer agreements) and CRM cascade integration.

Installnpm install @wabbit/tome-deals

Overview

@wabbit/tome-deals

Tome Deals layer — deal lifecycle engine with an artifact-type registry (quotes, proposals, SOWs, retainer agreements) and CRM cascade integration. Description copied verbatim from package.json.

Layer: domain (per ARCHITECTURE.md). Optionally integrates with @wabbit/tome-crm (customer/opportunity cascade), @wabbit/tome-catalog (line items), and @wabbit/tome-print (document rendering) — all three are optional peers, none is required to use deals standalone.

Install

pnpm add @wabbit/tome-deals

Peer ranges, copied from package.json:

| Peer | Range | Optional? | |---|---|---| | payload | >=3.67.0 | no | | typescript | >=5.7.0 | no | | @wabbit/tome-core | >=1.14.0 <2.0.0 | no | | @wabbit/tome-crm | >=0.2.0 <1.0.0 | yes | | @wabbit/tome-catalog | >=1.1.0 <2.0.0 | yes | | @wabbit/tome-print | >=0.1.0 <1.0.0 | yes | | @wabbit/tome-workflow | >=0.1.1 <1.0.0 | yes |

dependencies: server-only@^0.0.1. Note: no `lucide-react` peer — deals passes a string iconName: 'Handshake' to the admin-nav manifest rather than importing an actual lucide icon component the way crm/catalog/org/economy/lms do, so it has no icon-package dependency at all (verified: zero lucide-react references anywhere in src/).

60-second quickstart

The current API is createDealsLayer(config), returning [dealsCollection, dealCounterCollection] (initDeals is a deprecated pure alias — R4 ruling #3, 2026-07):

import { buildConfig } from 'payload'
import { createDealsLayer } from '@wabbit/tome-deals'

export default buildConfig({
  collections: [
    ...createDealsLayer({ slug: 'deals', currency: 'USD' }),
    // ...your other collections
  ],
})

createDealsLayer pre-registers the default quote artifact type and five built-in side-effect handlers (sendDealEmail, stampInvoiceNumber, cascadeOpportunityWon/Lost/Paid), then freezes the artifact registry — defineDealArtifactType calls after createDealsLayer runs will throw. customerRelation auto-defaults to 'crm-contacts' when @wabbit/tome-crm is registered in the layer registry.

API surface

Four subpaths: ., ./server, ./test, ./legacy/quote-types.

`.`:

| Group | Exports | |---|---| | Layer entry | createDealsLayer | | Artifact-type registry | defineDealArtifactType, replaceDealArtifactType, getDealArtifactType, getRegisteredDealArtifactTypes (registry-registration verbs, not deprecated create* aliases — deals has no createDealArtifactType) | | Side-effect registry | defineDealSideEffect (deprecated — use @wabbit/tome-workflow side effects), replaceDealSideEffect, getDealSideEffect, getRegisteredSideEffectKeys | | Default registrations | DEFAULT_QUOTE_ARTIFACT, DEFAULT_DEAL_WORKFLOW, resolveWorkflow, findTransition (deprecated — delegates to @wabbit/tome-workflow), validateWorkflow | | Pure helper | computeDealTotals — the one export explicitly documented "safe for client import" (no server-only guard) | | Access | dealsAccess, deals, dealCountersAccess, DEALS_CAPABILITIES, DealsCapability | | Test escape hatch | _resetArtifactRegistry — contradiction found in source: the artifact-registry module's own comment says _freezeArtifactRegistry/_resetArtifactRegistry are "intentionally not re-exported from the public barrel," but src/index.ts line 223 re-exports _resetArtifactRegistry anyway (for test teardown). Verified by direct read, not the module's own comment. | | Types | TomeDealStatus, TomeDealTransition, TomeDealStatusWorkflow, TomeDealArtifactTypeDefinition, TomeDealsConfig, TomeDealAddress, TomeDealCustomer, TomeDiscountType, TomePaymentTerms, TomeDealDiscount, TomeDealLineItem, TomeDealScheduleEntry, TomeDeal, TomeHydratedDeal, TomeDealTotalsInput, TomeDealTotals, TomeDealSideEffectHandler, TomeDealSideEffectDefinition, TomeDealEmailContext, TomeDealEmailResult |

Deprecated alias (R4 ruling #3, 2026-07 — pure rename, @deprecated-tagged, removal at this package's next major): initDeals → createDealsLayer.

`./server` — behind import 'server-only': getDeal, getDealByNumber, advanceDealStatus, createDealFromIntake (+DealIntakeSubmission), sendDealEmailMessage, resolveOpportunityForDeal, resolveOpportunityForNewDeal, attachOpportunityToExistingDeal, isCrmRegistered, resolveCustomerRelationDefault, requiresRepOrAdmin, requiresDealsAdmin, dealsRepWhereClause, dealsRepOrAdminWhereClause.

`./test` — explicitly a stub per its own docblock ("Wave 2 integration will flesh this out"): re-exports DEFAULT_QUOTE_ARTIFACT/DEFAULT_DEAL_WORKFLOW plus two bare fixtures, mockQuoteDeal and mockProposalDeal.

`./legacy/quote-types` — deprecated backward-compat type aliases (TomeQuote → TomeDeal, TomeQuoteStatus → TomeDealStatus, etc.) for pre-rename @wabbit/tome-quotes consumers. The module's own comment states it "will be removed at v0.2.0" — the package is now past that stated removal point without the subpath having been removed. Don't build new code against it; migrate to the canonical TomeDeal* names.

Server / client posture

Server-only package: Payload collection factories + import 'server-only'-guarded ./server helpers. The single exception is computeDealTotals, explicitly called out in the barrel's own comment as safe for client-side import (a pure totals calculator with no I/O).

Links

  • Design spec: docs/superpowers/specs/2026-04-26-tome-deals-layer-design.md
  • Build plan: docs/superpowers/specs/2026-04-26-tome-deals-layer-build-plan.md
  • CRM cascade semantics amendment: docs/superpowers/specs/2026-06-11-tome-deals-crm-cascade-semantics-amendment.md
  • CHANGELOG

Extending this package

New artifact types register via defineDealArtifactType before createDealsLayer runs (or a consumer can pre-register 'quote' itself — createDealsLayer catches the duplicate-key throw for that one sanctioned override case only). New side-effect handlers register the same way via defineDealSideEffect (deprecated — use @wabbit/tome-workflow side effects); each of the five built-in handlers is registered inside a try/catch so a consumer pre-registering the same key wins.

Exports

  • @wabbit/tome-deals
  • @wabbit/tome-deals/server
  • @wabbit/tome-deals/test
  • @wabbit/tome-deals/legacy/quote-types

Changelog

v0.5.0minor

e044594: Artifact types can opt into a block layout: `hasLayout: true` on a `TomeDealArtifactTypeDefinition` adds a `layout` blocks field (Layout tab) to deals of that type, fed by the new `TomeDealsConfig.layoutBlocks`. Configuring `hasLayout` without `layoutBlocks` throws at config time. `narrativeBody` is unchanged; existing artifact types are unaffected.

  • e044594: Artifact types can opt into a block layout: `hasLayout: true` on a `TomeDealArtifactTypeDefinition` adds a `layout` blocks field (Layout tab) to deals of that type, fed by the new `TomeDealsConfig.layoutBlocks`. Configuring `hasLayout` without `layoutBlocks` throws at config time. `narrativeBody` is unchanged; existing artifact types are unaffected.
v0.4.3patch

6943636: The bootstrap read fallback (any authenticated session passes `deals:read` / `deals:read:own` when the capability registry holds no grant) is now OFF in production by default, matching `@wabbit/tome-crm` 0.6.0. A site mid-migration opts back in explicitly with `TOME_DEALS_BOOTSTRAP_READ_FALLBACK=1`; any other value is not an opt-in. Non-production keeps the bridge open. Production logs one warning per process when access runs on the fallback. Found live on a public demo consumer where self-registered visitors could read deal pricing and embedded customer PII.

  • 6943636: The bootstrap read fallback (any authenticated session passes `deals:read` / `deals:read:own` when the capability registry holds no grant) is now OFF in production by default, matching `@wabbit/tome-crm` 0.6.0. A site mid-migration opts back in explicitly with `TOME_DEALS_BOOTSTRAP_READ_FALLBACK=1`; any other value is not an opt-in. Non-production keeps the bridge open. Production logs one warning per process when access runs on the fallback. Found live on a public demo consumer where self-registered visitors could read deal pricing and embedded customer PII.
v0.4.2patch

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

  • ce3d12d: Adopt `@wabbit/tome-core/fields/address` and `@wabbit/tome-core/utilities/relationId` at the sites the audit counted (2026-09-01 sale-readiness audit §5.1, T3(g)). **No stored field name, and no emitted field array, changes anywhere in this changeset** — each adopter passes the vocabulary it already stores, and each ships a characterisation test that was written from the pre-change source, run green against the untouched factory, and run green again after. **Address group — five sites, one implementation.** - `@wabbit/tome-crm` — `accounts` and `contacts` each carried a byte-identical seven-field `address` group. Both now spread `postalAddressFields({ vocabulary: 'legacy-crm' })` after their own `name` line (`name` is the company/contact line, not a postal line). `tests/address-characterisation.test.ts` pins both groups whole. - `@wabbit/tome-deals` — `billingAddress` and `shippingAddress` inside the frozen Customer Snapshot were copies three and four. They now come from one `buildSnapshotAddressGroup` helper: `name` + `company` prepended locally, the six postal lines from core, and the eight per-field labels plus the `'US'` country default passed through core's `fieldOverrides` seam. The snapshot is a legal-offer record frozen after send, so a field-name change would orphan the address on every deal already sent; `tests/address-characterisation.test.ts` pins both groups and the fact that they differ only in the group label and the recipient line's label. - `@wabbit/tome-fulfillment` — the fifth copy, and the only one that validated `country`. Its postal lines stay FLAT at collection top level (they are stored columns with PII rows and a GDPR registration behind them), now via `postalAddressFields({ vocabulary: 'postal', required: true, validateCountry: true })`. The ISO-3166 validator and its uppercase-normalising hook moved into core verbatim; because a moved function is a new object, `tests/address-characterisation.test.ts` pins the whole top-level field ORDER plus the validator's and hook's BEHAVIOUR (accepts `US`, rejects `usa`, rewrites `' us '` to `'US'`), not their identity. **`relationId` — the four-return-types problem.** - `@wabbit/tome-lms` — twelve modules under `src/server` (`academy`, `catalog`, `certificates`, `course`, `dashboard`, `enrollment`, `grades`, `leaderboard`, `learnerShell`, `notes`, `profile`, `reviews`) carried a byte-identical `string | null` copy. They import `relationId` from core now. One behavioural difference, strictly an improvement: on a malformed populated doc (`{ id: null }`, `{ id: {} }`) the old copy returned the STRING `'null'` / `'[object Object]'` as an id; core returns `null`. `tests/relation-id-adoption.test.ts` pins the adoption itself, because adoption is the thing that decays — the July 2026 audit's finding, repeated verbatim in September, was "extraction keeps happening, adoption does not." **Not migrated, deliberately:** `src/guards`, `src/utilities/{grading,prerequisites,progress}.ts`, `src/hooks/**`, `src/server/mutations/helpers.ts` and `src/server/awardGate.ts` return `string | number` or `undefined`. Migrating those is a semantic change, not an import change, and belongs in a pass that owns their call sites. The new test names them as out of scope so the next reader does not have to re-derive why. - `@wabbit/tome-sc` — the registry sub-cluster's copy is gone; `collections/registry/shared.ts` re-exports core's `relationId`, keeping `extractId` as a local alias (the module is private to that sub-cluster). **This one WIDENS:** the sc copy returned `string | number`, so a populated doc's numeric id came through unstringified. It is now stringified, which makes `===` between two resolved ids agree — the behaviour every call site in the cluster already assumed. Ids handed back to `payload.find`/`update` are unaffected, since Payload accepts either form in a `where` clause. sc's 179 tests stay green. - `@wabbit/tome-crm` — the inline ternary in `integration/deals.ts` (`typeof oppRaw === 'object' ? oppRaw.id : oppRaw`) was the fifth shape and had the same numeric-id asymmetry; it is one `relationId(deal.opportunity)` call now. **`fetchMemberId` ×4 — one implementation (sc).** `asset-availability`, `fleet-logs` and `fleet` each carried a verbatim copy of the auth-user → Member-row lookup, and `resource-requests` carried its projecting twin. All four now import from `src/access/fetchMemberId.ts`, which documents why each query knob is load-bearing: `overrideAccess: true` (the member collection's own read access may itself depend on membership, so without the bypass this is a circular check that denies the owner their own row), `depth: 0`, `pagination: false`. The id is returned in its STORED type here rather than through `relationId` — this is an identity read fed straight back into a `where` clause, not a relationship read. `tests/fleet-shared-helpers.test.ts` pins the adoption, the three knobs, and the null-for-anonymous contract.
  • 4aeedad: `createKeyedRegistry` in core, and the gate that keeps the next registry anchored. Tome had eleven keyed registries and two implementations of one idea: five anchored their state on `globalThis` via `Symbol.for`, six held a module-local `Map` (2026-09-01 sale-readiness audit §5.1, "same mechanism, half correct"). The half that is wrong is wrong silently. A published package ships separate ESM and CJS builds — distinct module instances with distinct module-local state — so the moment one consumer static-imports one build and another `require()`s the other, or a bundler splits an RSC/SSR/client graph, a module-local `Map` exists twice and a registration made through one is invisible through the other. Nothing throws; the handler just never fires. `layerRegistry` shipped that bug in 2026-05 and moved onto `globalThis` in tome-core 1.0.10, both it and the render registry explain the mechanism at length in their headers, and six registries were written afterwards without it. A comment cannot make the next author read it. **New in core:** `@wabbit/tome-core/registry/createKeyedRegistry` (a NEW exports-map subpath — hence the minor). `createKeyedRegistry<T>(symbolKey, { onDuplicate, validate })` returns `{ register, replace, get, has, list, clear }` over a store anchored at `globalThis[Symbol.for(symbolKey)]`. `onDuplicate` is `'throw'` (default) / `'replace'` / `'ignore'`, chosen to match each migrating registry's CURRENT behaviour rather than a preferred one. `replace()` is the explicit override path every throw-on-duplicate registry in the repo already exposed for tests and consumer shadowing. The module's JSDoc carries the full migration recipe for the registries not migrated here. 14 unit tests, including the dual-instantiation proof: two separately-created registries on one key share a store, and the state survives a `vi.resetModules()` re-evaluation of the defining module — a module-local `Map` fails both. **Migrated in core:** `gdpr/registry.ts`. This one had BOTH halves of the defect — a module-local `Map` inside `GdprRegistryImpl`, and absence from core's own `sideEffects` array — while four layers (fulfillment, org, sc, plus consumer sites) register into it by import side effect. Split state meant `runErasure`/`exportUserData` reporting zero rows for collections registered into the other copy; a missing `sideEffects` entry meant a bundler was free to drop the registering module outright. The store is now anchored (`onDuplicate: 'replace'`, matching `registerCollection`'s documented overwrite) and `./dist/gdpr/registry.*` is in `sideEffects`, with a `sideEffectsRationale` block in the manifest recording why each entry is there. The class API is unchanged — same names, arguments, semantics, and `getAll()`'s registration-order guarantee. `unregisterCollection` rebuilds the store minus one key (the helper exposes no per-key delete because nothing else needs one), preserving that order. **Migrated in deals:** both registries. `registry/side-effect-registry.ts` is now a delegation shim over `@wabbit/tome-workflow`'s registry (see the workflow-adoption changeset) — anchored by that route. `registry/artifact-registry.ts` moves onto `createKeyedRegistry`, INCLUDING its `frozen` flag: a freeze applied to one module instance while another still accepted registrations would have enforced the config-time contract in exactly half the process. Public API, throws and messages are unchanged. This registry is populated in `payload.config.ts` and read during collection construction, and under the Payload CLI those are separate module instances — the observable failure was an `artifactType` select with no options and a thrown "Unknown artifact type". **Forcing function:** `scripts/assert-registry-anchoring.mjs` + `pnpm assert:registry-anchoring`, wired into `platform-discipline.yml` immediately after `assert:no-forked-primitives` (source + manifest reading only, so it runs pre-build and fails fast). Any module-scope mutable `Map`/`Set`/instance singleton whose name — or whose FILE name — announces a registry must import `createKeyedRegistry`, contain `Symbol.for(`, or have its built path listed in the package's `sideEffects` array; otherwise it fails with the migration recipe. Before this change it reported 3 violations (core's gdpr registry and both deals registries) and now reports 0. Eight registries are ALLOWLISTED with a written architectural reason each, not a schedule: forms ×3, intake and print are owned by the forms+intake access wave and their file sets are off-limits to this one; `blocks-core/src/registry/index.ts` is the deliberately explicit-instance DESCRIPTOR registry (ARCHITECTURE.md § Three Registry Mechanisms #2 — the registry that genuinely must be one store, the render registry, is separately `Symbol.for`-anchored and passes), and changing it is a twelve-package linked-family decision; blocks-gallery's two are import-side-effect registries its own header already calls "the outlier, not the template", in a package with zero tests, so they migrate in the wave that gives it tests.
  • 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.
  • 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.
  • 4aeedad: Delegates to `@wabbit/tome-workflow`; local guards deprecated. `@wabbit/tome-workflow` is the extracted canonical home for the status-transition table and the keyed side-effect registry — its own module headers say so, naming deals as the source it was ported from — and none of the three packages that still shipped a copy depended on it (2026-09-01 sale-readiness audit §5.1). All three now declare `@wabbit/tome-workflow` as a required, explicitly non-optional peer (`>=0.1.1 <1.0.0`) with a `workspace:*` devDependency twin, and all three raise their `@wabbit/tome-core` peer floor to `>=1.14.0 <2.0.0` (see the LayerFactoryConfig changeset — the new core subpaths do not exist below it). **deals — full delegation, five functions deprecated.** `defineDealSideEffect`, `replaceDealSideEffect`, `getDealSideEffect`, `getRegisteredSideEffectKeys` and `_resetSideEffectRegistry` are now thin wrappers over `defineWorkflowSideEffect` / `replaceWorkflowSideEffect` / `getWorkflowSideEffect` / `getRegisteredWorkflowSideEffectKeys` / `_resetWorkflowSideEffectRegistry`, each `@deprecated` with sunset at the next major. `findTransition` and `validateWorkflow` likewise wrap workflow's `findTransition` / `validateTransitionTable`. `resolveWorkflow` and `DEFAULT_DEAL_WORKFLOW` are NOT deprecated: the default quote lifecycle is deals' own domain data, and `resolveWorkflow` resolves an artifact type's optional workflow override, a deals concept with no workflow-layer equivalent. Two consequences of the deals delegation are invisible at a call site and are stated in the module headers. First, the side-effect store moves from a module-local `Map` to `globalThis` keyed by `Symbol.for` — a FIX, not a byproduct: deals ships separate ESM and CJS builds, so a handler registered through one instance was invisible through the other, and the transition then advanced with its side effect silently skipped. Workflow's own header names deals' local `Map` as the hazard it deliberately did not repeat. Second, the key namespace is now shared with every other workflow consumer, so a duplicate key across two layers throws at registration instead of quietly shadowing — the intended duplicate policy in both packages. One behaviour change to note: `validateWorkflow`'s returned message prefix is now `[tome-workflow]` rather than `[tome-deals]`, because the validator is workflow's; that function shipped with zero call sites and zero tests. **marketing — lookup delegated, three semantics kept local.** The §9 campaign lifecycle is now published (module-scope, not from the barrel) as `MARKETING_CAMPAIGN_TRANSITION_TABLE`, derived from the existing adjacency map so the two cannot disagree, and the allow decision plus the "allowed from here" list come from workflow's `findTransition` / `allowedTransitionsFrom`. The adjacency map is kept as the source it is derived from because a flat table cannot distinguish a deliberately terminal status (`archived`, empty list) from a status absent from the map entirely (data corruption) — this hook has always reported those as two different errors, and collapsing them would turn "your database has an unknown status" into "that transition is not permitted". `buildStageTransitionGuard` is NOT deprecated: it is a Payload `beforeChange` hook factory and workflow ships no hook; `guardedTransition` is a server-side call that owns the write, and adopting it moves the transition out of the collection hook entirely. That is marketing's 1.0 question. **crm — lookup delegated, three semantics kept local, and this is the one that could not be forced.** `buildStageTransitionTable(stageConfig)` projects a `TomeCrmOpportunityStageConfig` onto a `WorkflowTransitionTable`, and the allow decision comes from workflow. Three semantics stay local, each because delegating them would change behaviour: (1) a stage that declares NO `allowedTransitions` is UNCONSTRAINED in crm, and a transition table cannot distinguish "no edges declared" from "no edges permitted" — feeding those stages to `findTransition` would turn crm's open-by-default pipeline into a closed one for every consumer whose config declares transitions on some stages and not others; (2) an unknown stage key is a misconfiguration reported as one, ahead of any transition check; (3) the lost-category `lossCategory` requirement is a field-level data rule keyed off a stage's `category`, and hanging it on `WorkflowTransition.guard` would make every consumer of the exported table inherit a crm write-validation rule. The rejection message now also names the legal moves from the previous stage, sourced from `allowedTransitionsFrom` — strictly more diagnostic, same throw conditions. The deals↔CRM cascade re-entrancy handshake is untouched: `skipDealCascadeHooks` (deals `status-transition-guard.ts`) and `tomeCrmSuppressStageDispatch` (crm `advance-opportunity-stage.ts` → `stage-change-dispatch.ts`) behave exactly as the 2026-06-11 cascade-semantics amendment D2 documents. Neither guard's participation in that handshake changed; crm's stage guard never participated in it at all. New suites pinning the delegation, one per package (`tests/workflow-delegation.test.ts`, 12 + 9 + 8 assertions), each spying on the workflow module itself so a future edit that quietly restores a local copy fails a test rather than passing silently — which is exactly how the original fork survived four months of green CI.
v0.4.1patch

Wave 4 I0 hygiene: dist ships extensioned specifiers (fix-dist-extensions --strict + assert-node-loadable preflight — both dists now raw-Node loadable), registerLayer versions corrected and test-pinned to package.json, accounts' full @wabbit/tome-core/auth barrel import replaced by the auth/guards leaf (the barrel drags the BetterAuth plugin factory).

  • Wave 4 I0 hygiene: dist ships extensioned specifiers (fix-dist-extensions --strict + assert-node-loadable preflight — both dists now raw-Node loadable), registerLayer versions corrected and test-pinned to package.json, accounts' full @wabbit/tome-core/auth barrel import replaced by the auth/guards leaf (the barrel drags the BetterAuth plugin factory).
v0.4.0minor

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: Email lookup paths adopt core's `normalizeEmail` instead of hand-rolled lowercasing: crm's `find-contact-by-email` / `match-or-create-contact`, and deals' `create-from-intake` — the latter was missing `.trim()`, so a padded intake email could fork a duplicate CRM contact.
  • 36e537a: `registerLayer` is now statically imported (forms/intake pattern) instead of lazily `require()`d in ten layer packages' init/register paths. The lazy pattern silently no-ops under Payload's native-ESM CLI (`generate:types` / `generate:importmap`), so layer registration could vanish without error. Packages whose tome-core peer is genuinely optional (economy, ai, gamification) deliberately keep the guarded lazy path; tome-core's `admin-nav/self-register.ts` deliberately keeps its subpath `require()` (documented ESM/CJS dual-cache fix — do not convert).
  • 36e537a: Every package now declares an explicit `sideEffects` field (38 added; motion/engine/forms already correct). Registration-bearing modules (render files' `registerRenderer`, `blocks/*/index.ts` `defineBlock` self-registration, widget `register.ts` files, productHooks, permission self-registrations, print templates, chrome built-in variants) are listed so bundlers can tree-shake everything else WITHOUT dropping import-time registrations — previously the field was unset, which blocked cross-module tree-shaking through the barrels entirely. Never blanket `false` on a package with registration or CSS.
  • aef2725: DRY adoption sweep (the audit's "adoption, not extraction" rule): crm/deals capability presets delegate to core's `sessionHasCapabilityOrLegacyAdmin`; new core `buildOwnershipWhere`/`ownershipOrBypass` (via `./access`) adopted by core's vendorScoped, catalog's vendor-scoping, and org's ownOrScoped (public APIs unchanged); `slugField()` adopted at 7 sites where semantics matched exactly (core lms collections + createMemberCollection — replacing a third independent slugify), with ~25 sites honestly skipped for named semantic divergences (auto-regenerate-on-clear vs allow-empty, collection-level hook pattern) now listed as core-enhancement candidates; new `formatDisplayDate` in blocks-core utilities (UTC-pinned, hydration-safe) adopted at 5 verified-identical sites; lms-ui consolidates its two certificate date formatters locally; `useMediaQuery`/`useIsMobile` published from tome-ui and adopted by AppShell + admin's SidebarProvider; gamification's `awardPoints` now uses the authoritative `getPointsBalance` (fixes a divergent 1000-row scan cap vs the correct 10000).
v0.3.1patch

dca85a3: Core runtime-floor sweep: each package's `@wabbit/tome-core` peer floor now matches the newest core runtime export it actually imports, instead of the platform-wide `>=1.0.0` baseline from the original peer-range sweep. The stale floors let npm silently install a package next to a core version missing a module it runtime-imports, producing a hard `next build` failure at import time (reproduced 2026-07-11: tome-starter locked core 1.0.12 + admin 0.6.3 — `isAdminNavDomain` does not exist in core 1.0.x, where `registry/adminNav` was type-only). - `@wabbit/tome-admin` → `>=1.3.0 <2.0.0` — `nav/manifestResolver` runtime-imports `isAdminNavDomain` from `registry/adminNav`, first shipped as a runtime export in core 1.3.0 (Sidebar v2 Wave 0, d8ff1b2). - `@wabbit/tome-deals` → `>=1.1.0 <2.0.0` — runtime-imports `auth/repScoping` (`buildRepWhereClause` et al.) and `utilities/normalize` (`normalizeEmail`), both introduced in core 1.1.0 (consolidation pass, a9801fe). - `@wabbit/tome-accounts` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`roleSatisfiesPermission`, permission registration), introduced in core 1.2.0 (platform permission engine, 9238072). - `@wabbit/tome-org` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`checkPermissionHierarchical` et al.). - `@wabbit/tome-sc` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` across access helpers and military collections. Same defect class as the `tome-crm` floor raise to `>=1.1.0` (b027075); `tome-crm` is already correct and unchanged here.

  • dca85a3: Core runtime-floor sweep: each package's `@wabbit/tome-core` peer floor now matches the newest core runtime export it actually imports, instead of the platform-wide `>=1.0.0` baseline from the original peer-range sweep. The stale floors let npm silently install a package next to a core version missing a module it runtime-imports, producing a hard `next build` failure at import time (reproduced 2026-07-11: tome-starter locked core 1.0.12 + admin 0.6.3 — `isAdminNavDomain` does not exist in core 1.0.x, where `registry/adminNav` was type-only). - `@wabbit/tome-admin` → `>=1.3.0 <2.0.0` — `nav/manifestResolver` runtime-imports `isAdminNavDomain` from `registry/adminNav`, first shipped as a runtime export in core 1.3.0 (Sidebar v2 Wave 0, d8ff1b2). - `@wabbit/tome-deals` → `>=1.1.0 <2.0.0` — runtime-imports `auth/repScoping` (`buildRepWhereClause` et al.) and `utilities/normalize` (`normalizeEmail`), both introduced in core 1.1.0 (consolidation pass, a9801fe). - `@wabbit/tome-accounts` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`roleSatisfiesPermission`, permission registration), introduced in core 1.2.0 (platform permission engine, 9238072). - `@wabbit/tome-org` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`checkPermissionHierarchical` et al.). - `@wabbit/tome-sc` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` across access helpers and military collections. Same defect class as the `tome-crm` floor raise to `>=1.1.0` (b027075); `tome-crm` is already correct and unchanged here.
v0.2.3patch

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

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

Revert the 0.2.1 `access.create` gating on `total` — it broke the create form. 0.2.1 added `access.create` to make the itemized `total` read-only on the create form, but Payload evaluates field access once at form-init with empty data and does NOT re-evaluate it reactively as the artifact type is selected (unlike `admin.condition`). The result was `total` disabled for ALL types on the create form — including flat-amount proposals, breaking proposal entry. This restores 0.2.0 behavior: `total` editable on the create form for every shown type, correctly gated read-only on the edit form via `access.update`. For itemized types the create-form editability is cosmetic — `computeDealTotalsHook` overwrites the value on save, so no incorrect total can persist. A code comment documents why create is intentionally ungated (fully-correct reactive gating would need a custom client Field component).

  • Revert the 0.2.1 `access.create` gating on `total` — it broke the create form. 0.2.1 added `access.create` to make the itemized `total` read-only on the create form, but Payload evaluates field access once at form-init with empty data and does NOT re-evaluate it reactively as the artifact type is selected (unlike `admin.condition`). The result was `total` disabled for ALL types on the create form — including flat-amount proposals, breaking proposal entry. This restores 0.2.0 behavior: `total` editable on the create form for every shown type, correctly gated read-only on the edit form via `access.update`. For itemized types the create-form editability is cosmetic — `computeDealTotalsHook` overwrites the value on save, so no incorrect total can persist. A code comment documents why create is intentionally ungated (fully-correct reactive gating would need a custom client Field component).
v0.2.1patch

Fix flat-amount `total` editability on the **create** form. `hasFlatAmount` gated `total` editability via field `access.update` only, but the create form is governed by `access.create` — so an itemized type's `total` rendered editable on create (cosmetic: the compute-totals hook overwrites it on save, but incorrect). Now gates both `create` and `update` symmetrically: `total` is editable iff the artifact type is `hasFlatAmount`, on both forms.

  • Fix flat-amount `total` editability on the **create** form. `hasFlatAmount` gated `total` editability via field `access.update` only, but the create form is governed by `access.create` — so an itemized type's `total` rendered editable on create (cosmetic: the compute-totals hook overwrites it on save, but incorrect). Now gates both `create` and `update` symmetrically: `total` is editable iff the artifact type is `hasFlatAmount`, on both forms.
v0.2.0minor

2493781: Add `hasFlatAmount` artifact-type flag for priced-but-not-itemized deals. Artifact types with `hasFlatAmount: true` (e.g. proposals, retainer agreements) now expose an **editable `total`** field that the human sets directly, instead of the line-items-computed total. This fills the gap where a `hasLineItems: false` artifact had no price field at all — `total` is the field downstream charge/email flows read. - Mutually exclusive with `hasLineItems` (a type is either itemized or flat-priced). - Editability is enforced via field-level `access.update` (Payload v3 `admin.readOnly` is boolean-only): editable for flat-amount types, read-only for itemized types where `computeDealTotalsHook` remains the source of truth. - No change to `computeDealTotalsHook` — it already skips any artifact type with `hasLineItems !== true`, so a flat-amount total is never clobbered.

  • 2493781: Add `hasFlatAmount` artifact-type flag for priced-but-not-itemized deals. Artifact types with `hasFlatAmount: true` (e.g. proposals, retainer agreements) now expose an **editable `total`** field that the human sets directly, instead of the line-items-computed total. This fills the gap where a `hasLineItems: false` artifact had no price field at all — `total` is the field downstream charge/email flows read. - Mutually exclusive with `hasLineItems` (a type is either itemized or flat-priced). - Editability is enforced via field-level `access.update` (Payload v3 `admin.readOnly` is boolean-only): editable for flat-amount types, read-only for itemized types where `computeDealTotalsHook` remains the source of truth. - No change to `computeDealTotalsHook` — it already skips any artifact type with `hasLineItems !== true`, so a flat-amount total is never clobbered.
v0.1.3patch

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

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

c5175e9: Peer ranges widened — `@wabbit/tome-core`, `@wabbit/tome-crm`, and `@wabbit/tome-catalog` peers are now explicit semver ranges instead of `workspace:*` (which published as exact-version pins, breaking installs whenever a substrate package bumped, e.g. tome-crm 0.2.0 → 0.3.0).

  • c5175e9: Peer ranges widened — `@wabbit/tome-core`, `@wabbit/tome-crm`, and `@wabbit/tome-catalog` peers are now explicit semver ranges instead of `workspace:*` (which published as exact-version pins, breaking installs whenever a substrate package bumped, e.g. tome-crm 0.2.0 → 0.3.0).
v0.1.1patch

Auto-bridge the deal→CRM opportunity cascade. The `cascadeOpportunityWon/Lost/Paid` side-effects previously only forwarded to `config.onStatusChange`; when a consumer registered `@wabbit/tome-crm` but didn't wire `onStatusChange`, the cascade was a silent no-op (accepting/rejecting/paying a deal never advanced the linked opportunity). They now fall back to a new `dispatchDealStatusChangeToCrm` (integration/crm.ts) that lazy-imports CRM's `onDealStatusChange` and runs the cascade whenever CRM is registered. A consumer-provided `onStatusChange` still takes precedence (no double-fire); CRM-absent stays a clean no-op. Consumers with a custom opportunity stage pipeline should still wire `onStatusChange` explicitly. No API/schema change — purely additive defensive wiring.

  • Auto-bridge the deal→CRM opportunity cascade. The `cascadeOpportunityWon/Lost/Paid` side-effects previously only forwarded to `config.onStatusChange`; when a consumer registered `@wabbit/tome-crm` but didn't wire `onStatusChange`, the cascade was a silent no-op (accepting/rejecting/paying a deal never advanced the linked opportunity). They now fall back to a new `dispatchDealStatusChangeToCrm` (integration/crm.ts) that lazy-imports CRM's `onDealStatusChange` and runs the cascade whenever CRM is registered. A consumer-provided `onStatusChange` still takes precedence (no double-fire); CRM-absent stays a clean no-op. Consumers with a custom opportunity stage pipeline should still wire `onStatusChange` explicitly. No API/schema change — purely additive defensive wiring.
v0.1.0minor

Initial release of the Tome Deals layer — a deal-lifecycle engine with a config-time artifact-type registry (quotes, proposals, SOWs, retainer agreements), per-artifact-type field gating, a default `draft → sent → accepted → invoiced → paid` workflow, and CRM cascade integration. - **Artifact-type registry:** `defineDealArtifactType` registers types before `initDeals(...)`; the default `quote` artifact is preregistered. `artifactType` is a `select` frozen from the registry at config time, driving per-type field visibility via Payload `condition` clauses. - **Two collections:** `deals` (the lifecycle collection with per-artifact-type field gating) and `deal-number-counters` (independent per-artifact-type numbering, e.g. `PROP-2026-0001`, `SOW-2026-0001`). - **Side-effect engine:** five built-in side-effect handlers (`send-deal-email`, `stamp-invoice-number`, `cascade-opportunity-won/lost/paid`); the CRM cascade fires `advanceOpportunityStage` via the deals→crm integration adapter only when `@wabbit/tome-crm` is registered (deals fires `onStatusChange`; crm owns `onDealStatusChange`, no double-fire). - **Server helpers + libs** carried forward and generalized from `tome-procut`'s shipped `Quotes.ts`: `computeDealTotals`, `generateDealNumber`, `advanceDealStatus`, `getDeal`, `createDealFromIntake`, Resend-or-log email send. - **Optional integrations** (`crm`, `territory`, `print`) lazy-loaded via `layerRegistry`; `@wabbit/tome-crm` and `@wabbit/tome-catalog` are optional peers. - **Legacy compat:** `./legacy/quote-types` subpath re-exports `TomeQuote*` aliases for one minor version to ease the ProCut migration.

  • Initial release of the Tome Deals layer — a deal-lifecycle engine with a config-time artifact-type registry (quotes, proposals, SOWs, retainer agreements), per-artifact-type field gating, a default `draft → sent → accepted → invoiced → paid` workflow, and CRM cascade integration. - **Artifact-type registry:** `defineDealArtifactType` registers types before `initDeals(...)`; the default `quote` artifact is preregistered. `artifactType` is a `select` frozen from the registry at config time, driving per-type field visibility via Payload `condition` clauses. - **Two collections:** `deals` (the lifecycle collection with per-artifact-type field gating) and `deal-number-counters` (independent per-artifact-type numbering, e.g. `PROP-2026-0001`, `SOW-2026-0001`). - **Side-effect engine:** five built-in side-effect handlers (`send-deal-email`, `stamp-invoice-number`, `cascade-opportunity-won/lost/paid`); the CRM cascade fires `advanceOpportunityStage` via the deals→crm integration adapter only when `@wabbit/tome-crm` is registered (deals fires `onStatusChange`; crm owns `onDealStatusChange`, no double-fire). - **Server helpers + libs** carried forward and generalized from `tome-procut`'s shipped `Quotes.ts`: `computeDealTotals`, `generateDealNumber`, `advanceDealStatus`, `getDeal`, `createDealFromIntake`, Resend-or-log email send. - **Optional integrations** (`crm`, `territory`, `print`) lazy-loaded via `layerRegistry`; `@wabbit/tome-crm` and `@wabbit/tome-catalog` are optional peers. - **Legacy compat:** `./legacy/quote-types` subpath re-exports `TomeQuote*` aliases for one minor version to ease the ProCut migration.