Catalog
Commerce engineStableTome catalog layer — products, attributes, categories, vendor scoping, ProductTypeRegistry.
Get a registry token from your credentials page. You need a purchase that includes this package, or a Craft Library membership.
Add the registry and your token to the
.npmrcat the root of your project, with your token in place ofYOUR_TOKEN:@wabbit:registry=https://npm.wabbit.com/ //npm.wabbit.com/:_authToken=YOUR_TOKENThen install:
npm install @wabbit/tome-catalog
Overview
@wabbit/tome-catalog
Tome catalog layer — products, attributes, categories, vendor scoping, ProductTypeRegistry. Description copied verbatim from package.json.
Layer: domain (per ARCHITECTURE.md). Consumed (as an optional peer in each case) by @wabbit/tome-lms (course-as-product integration), @wabbit/tome-deals, @wabbit/tome-intake, and @wabbit/tome-blocks-catalog-pack (live-data hydration).
Install
pnpm add @wabbit/tome-catalogPeer ranges, copied from package.json:
| Peer | Range | Optional? | |---|---|---| | payload | >=3.67.0 | no | | @payloadcms/richtext-lexical | >=3.67.0 | no | | @wabbit/tome-core | >=1.17.0 <2.0.0 | marked yes in peerDependenciesMeta | | lucide-react | >=0.460.0 | yes |
Drift, verified in source: @wabbit/tome-core is declared optional, but src/access/vendor-scoping.ts, src/initCatalog.ts and the collection factories import unconditionally from @wabbit/tome-core subpaths (utilities/layerRegistry, utilities/typedSlug, utilities/layerFactoryConfig, access, access/checkRole, access/vendorScoped) — initCatalog.ts's own comment says so explicitly ("the package is not actually usable without it"). Treat @wabbit/tome-core as effectively required despite the manifest flag. lucide-react is genuinely optional: the layer's Package 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 createCatalogLayer(config?) — the canonical top-level layer entry. It returns a bare CollectionConfig[]: the five core collections (Products, ProductAttributes, ProductAttributeValues, Categories, ProductMedia) built from one shared CatalogConfig, plus an opt-in sixth (Entitlements, via entitlements: true | EntitlementsCollectionConfig) — and it delegates sidebar registration to initCatalog() internally, so you don't call that separately:
import { buildConfig } from 'payload'
import { createCatalogLayer } from '@wabbit/tome-catalog'
export default buildConfig({
collections: [
...createCatalogLayer({ vendorScoped: true }),
// ...your other collections
],
})Catalog has always also been consumable via its per-collection factories called directly (the pattern that predates createCatalogLayer) — that path still works and is unaffected:
import { buildConfig } from 'payload'
import {
initCatalog,
createProductCollection,
createCategoryCollection,
createProductAttributeCollection,
createProductAttributeValueCollection,
createProductMediaCollection,
createEntitlementsCollection,
} from '@wabbit/tome-catalog'
initCatalog() // optional — sidebar grouping only; safe to omit; superseded by createCatalogLayer for most consumers
export default buildConfig({
collections: [
createProductCollection({ vendorScoped: true }),
createCategoryCollection(),
createProductAttributeCollection(),
createProductAttributeValueCollection(),
createProductMediaCollection(),
createEntitlementsCollection(),
],
})initCatalog() itself did not change shape — it still returns void and only registers the admin-sidebar manifest; createCatalogLayer calls it internally rather than the other way around, so there remains exactly one place that owns the registerLayer call.
Three things the quickstart assumes about your config:
- A `media` upload collection exists. Products and categories relate their images to
mediaCollection, default'media'; passmediaCollectionif yours has another slug. - Order matters for vendor scoping.
vendorScoped: trueonly adds thevendorfield if@wabbit/tome-orgis already in the layer registry when the factory runs — create the org layer before callingcreateCatalogLayer, or the flag is silently a no-op (see below). - Product types are read at factory call-time. The Products
typeselect is built fromproductTypeRegistrywhencreateProductCollection/createCatalogLayerruns; a type registered afterwards never reaches the dropdown. Built-in types:physical(default),digital,service,subscription.
Default slugs are in CATALOG_DEFAULT_SLUGS (catalog-products, catalog-attributes, catalog-attribute-values, catalog-categories, catalog-media, catalog-product-variants); the opt-in entitlements collection defaults to entitlements. New products default to status: 'draft', and anonymous visitors can only read active ones.
Access and behaviour defaults worth knowing:
- Entitlements read is owner-or-admin. Without
vendorScoped, an admin reads every entitlement, any other signed-in user reads only rows whoseuserIdis theirs, and anonymous requests read nothing. Rows owned only throughaccountIdare not visible to the account's other members by default; passaccess.readif your site resolves entitlements by account. - Category `maxDepth` is enforced.
createCategoryCollection({ maxDepth: 2 })rejects a write that would nest a category below depth 2 (root = 0). Re-parenting a category does not re-check its existing descendants. - Renamed products slugs keep working in product cards. The default
physical/digital/service/subscriptioncard hooks find the products collection through a markercreateProductCollectionputs on the collection'scustomblock, so a non-defaultproductsSlugstill resolves. A products collection you build by hand without the factory is looked up ascatalog-products. - `upsertEntitlementForSubscription` tolerates concurrent deliveries. Two simultaneous deliveries of the same webhook fold back to one row (the oldest survives), and a duplicate-key error from a unique index you add on the key is resolved by updating the existing row.
API surface
Single export subpath (. only — no ./server, ./test, etc.).
| Group | Exports | |---|---| | Layer entry | createCatalogLayer(config?), CatalogLayerConfig — canonical top-level entry; returns CollectionConfig[], matching the createOrgLayer/createAccountsLayer bare-array shape. Accepts every CatalogConfig slug/flag plus entitlements and variants opt-ins and core's LayerFactoryConfig options | | Collection factories | createProductCollection, createProductAttributeCollection, createProductAttributeValueCollection, createCategoryCollection, createProductMediaCollection, createEntitlementsCollection, createProductVariantCollection (+ each factory's *CollectionConfig/*Seed type) | | Sidebar-only registration | initCatalog(config?), InitCatalogConfig — void-returning, registration-only; unchanged contract, now called internally by createCatalogLayer too | | Registry | ProductTypeRegistry (class), productTypeRegistry (singleton) — register(descriptor), get(value), has(value), getAll(), reset() (test-only) | | Access / vendor scoping | isOrgLayerPresent, shouldAddVendorField, buildVendorField, publicReadActiveOnly, vendorOwnerAccess, vendorCreateAccess, vendorAutoAssignHook | | Hooks | autoSlugHook, slugify, categoryDepthHook, attributeValidationHook, variantOptionUniquenessHook, canonicalizeVariantOptions (+ VariantOptionInput type) | | Queries | getProductsByCategory, getProductsByVendor, getCategoryTree, getFilterableAttributes, getEntitlements, upsertEntitlementForSubscription (+ each query's Args/result type) | | Types | AttributeType, ProductStatus, ProductMediaType, CatalogConfig, ProductTypeDescriptor, Category, ProductAttribute, ProductAttributeValue, Product, ProductMedia, ProductVariant, ProductVariantStatus, VariantWeightUnit, VariantDimensionUnit, CATALOG_DEFAULT_SLUGS |
Product variants (opt-in, default OFF)
catalog-product-variants models one sellable configuration of a product — the size x colour cell of a merch matrix. It is not part of createCatalogLayer()'s default bundle:
createCatalogLayer({ variants: true }) // defaults
createCatalogLayer({ variants: { slug: 'merch-variants' } }) // overridesDefault-off is a compatibility requirement, not a preference: consumers track this package on a caret range across all of 1.x, so an always-on new collection would be an unrequested schema migration on their next dependency update.
Two guards, doing different jobs: sku is unique at the database level, and a beforeValidate hook rejects a second variant of the same product that declares the same axis/value set (order- and case-independent) — unique identifiers do not prevent a duplicate of the thing being identified.
Stock is not here. On-hand quantity is warehouse state and lives in @wabbit/tome-fulfillment as fulfillment-stock rows, keyed on this same sku string — as are fulfillment-shipments line items and the optional variantSku on economy prices. Matching is by SKU string, not relationship, precisely because this collection is opt-in.
The vendorScoped flag — real, post-T1 behavior
Vendor scoping is all three conditions or nothing (verified in src/collections/Products.ts + src/access/vendor-scoping.ts):
@wabbit/tome-orgis registered in the layer registry (isOrgLayerPresent()checkshasLayer('org')andhasLayer('@wabbit/tome-org')), and- the factory is called with
vendorScoped: true, and - (implicitly) the site has an org collection at the
'members'slug. A custommembersSlugis only accepted bycreateProductCollectioncalled directly —createCatalogLayerdoes not forward one, so through the layer entry thevendorfield always relates tomembers.
Condition 1 is evaluated when the factory runs, so the org layer must be registered before createCatalogLayer / createProductCollection is called.
When any condition is false, the vendor field is absent from the schema entirely — not hidden in the admin UI, not present at all — and every product is treated as platform-owned. update/delete access gating is computed from the same vendorScopingActive boolean as the field, so they never drift out of lockstep with each other. read access is not touched by vendor scoping — publicReadActiveOnly always governs read (anonymous visitors see only status: 'active' products; any logged-in user can read every product, drafts included); catalog deliberately does not reuse @wabbit/tome-core's vendorScoped() wrapper because that wrapper force-overwrites read to owner-only, which would break public catalog browsing (documented inline in Products.ts). Any site-supplied config.access is spread last and wins over both the vendor-scoped defaults and the always-on read default.
Server / client posture
Fully server-side: Payload collection factories and query helpers, no React. sideEffects: ["./dist/registry/**"] (an array, not false) — the ProductTypeRegistry module has import-side-effect registration (other layers, e.g. @wabbit/tome-lms's catalog integration, register product types by importing the registry module), so it must survive bundler tree-shaking.
Links
- Gotchas: Foreign-layer relations must be conditionally spread, never `?? 'foreign-default-slug'` — directly relevant to the vendor-field pattern above
- CHANGELOG
Extending this package
New product types register into productTypeRegistry (see src/registry/ProductTypeRegistry.ts); the Products factory reads the registry when it is called, so any layer that registers a type before createCatalogLayer / createProductCollection runs automatically appears in the Products type dropdown, no catalog-side change needed. A type registered after the factory has run does not appear. One-off types can still be passed via additionalTypes on createProductCollection.
import { productTypeRegistry, createCatalogLayer } from '@wabbit/tome-catalog'
productTypeRegistry.register({ value: 'ticket', label: 'Event Ticket', source: 'my-site' })
const catalogCollections = createCatalogLayer() // 'ticket' is now a Products type optionTesting
pnpm --filter @wabbit/tome-catalog test runs the Vitest suite. Collection factories are pure functions, so most tests assert on the returned CollectionConfig without booting Payload. The layer and product-type registries are module-level state: call productTypeRegistry.reset() (then re-register the four built-ins if you need them) between tests that register types, and register or omit the org layer explicitly in tests that exercise vendorScoped.
Exports
@wabbit/tome-catalog
Changelog
775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
- 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
c071259: **BREAKING:** a default changed — see the Migration note below. Entitlements are now readable only by their owner or an admin by default, and four catalog defaults now behave as documented. - Security fix: without `vendorScoped`, the Entitlements collection's default `read` let any signed-in user read every entitlement. It is now owner-or-admin: admins read all rows, other users read rows whose `userId` is theirs, anonymous requests read nothing. **Migration:** a site that relied on the old open read (for example, account members reading an account's grants) passes its own `access.read`. - The default `physical`/`digital`/`service`/`subscription` product card hooks now find the products collection under a renamed slug instead of always querying `catalog-products`. `createProductCollection` marks its collection in `custom.tomeCatalogProducts` for this lookup. - `upsertEntitlementForSubscription` no longer leaves duplicate rows when two deliveries of the same webhook run at once; duplicates fold back to the oldest row, and a duplicate-key error from a unique index on the key updates the existing row instead of failing. - `createCategoryCollection({ maxDepth })` now rejects a write that would nest a category deeper than `maxDepth`; it was previously shown in the admin description only.
- c071259: **BREAKING:** a default changed — see the Migration note below. Entitlements are now readable only by their owner or an admin by default, and four catalog defaults now behave as documented. - Security fix: without `vendorScoped`, the Entitlements collection's default `read` let any signed-in user read every entitlement. It is now owner-or-admin: admins read all rows, other users read rows whose `userId` is theirs, anonymous requests read nothing. **Migration:** a site that relied on the old open read (for example, account members reading an account's grants) passes its own `access.read`. - The default `physical`/`digital`/`service`/`subscription` product card hooks now find the products collection under a renamed slug instead of always querying `catalog-products`. `createProductCollection` marks its collection in `custom.tomeCatalogProducts` for this lookup. - `upsertEntitlementForSubscription` no longer leaves duplicate rows when two deliveries of the same webhook run at once; duplicates fold back to the oldest row, and a duplicate-key error from a unique index on the key updates the existing row instead of failing. - `createCategoryCollection({ maxDepth })` now rejects a write that would nest a category deeper than `maxDepth`; it was previously shown in the admin description only.
- 7b8f2b6: The variant option-uniqueness check now reads a product's sibling variants in bounded pages instead of one unbounded read. Validation is unchanged.
- 96872f7: The layer now loads without `lucide-react` installed; the optional peer supplies only the sidebar icon. The `Package` icon is imported lazily when the layer registers. Previously a static import made every entry point that registers the layer throw on an install without `lucide-react`. Without it, the admin sidebar shows the nav domain's default icon.
- 0bd7c3f: customer-facing wording: internal references removed from admin descriptions, error messages and block metadata.
67eb3dc: Local relationship-id helpers are replaced by `@wabbit/tome-core/utilities/relationId`. Each call site maps to the core reader with the same return shape (raw vs. stringified id, `null` vs. `undefined`, polymorphic), so behaviour is unchanged. The `@wabbit/tome-core` peer floor goes up to `>=1.17.0` because that is the first core version exporting `relationIdRaw`, `relationIds` and `relationIdsRaw`.
- 67eb3dc: Local relationship-id helpers are replaced by `@wabbit/tome-core/utilities/relationId`. Each call site maps to the core reader with the same return shape (raw vs. stringified id, `null` vs. `undefined`, polymorphic), so behaviour is unchanged. The `@wabbit/tome-core` peer floor goes up to `>=1.17.0` because that is the first core version exporting `relationIdRaw`, `relationIds` and `relationIdsRaw`.
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.
- 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.
Track C I3: opt-in catalog-product-variants collection (default OFF — bare createCatalogLayer() is unchanged; enable via variants: true|{slug}, the entitlements-style knob) with SKU-axis options, semantic-duplicate hook, weight/dimensions. Economy Prices gain an optional variantSku TEXT field (advisory by design, non-unique); the checkout price query is untouched and now test-pinned.
- Track C I3: opt-in catalog-product-variants collection (default OFF — bare createCatalogLayer() is unchanged; enable via variants: true|{slug}, the entitlements-style knob) with SKU-axis options, semantic-duplicate hook, weight/dimensions. Economy Prices gain an optional variantSku TEXT field (advisory by design, non-unique); the checkout price query is untouched and now test-pinned.
1df8cf0: Track C I0 hygiene: dist ships extensioned specifiers (fix-dist-extensions --strict wired into build; assert-node-loadable preflight added — both dists now raw-Node loadable, PASS 2/2). Stale registerLayer versions corrected (catalog said 1.1.1 at 1.4.0; economy said 0.2.3 at 0.5.0) and test-pinned to package.json so future bumps can't silently drift. Economy gains its vitest harness (first tests in the package — the settlement logic landing in I1 requires it).
- 1df8cf0: Track C I0 hygiene: dist ships extensioned specifiers (fix-dist-extensions --strict wired into build; assert-node-loadable preflight added — both dists now raw-Node loadable, PASS 2/2). Stale registerLayer versions corrected (catalog said 1.1.1 at 1.4.0; economy said 0.2.3 at 0.5.0) and test-pinned to package.json so future bumps can't silently drift. Economy gains its vitest harness (first tests in the package — the settlement logic landing in I1 requires it).
6bc419c: Naming convergence (all additive; every old name keeps working as a `@deprecated` alias until that package's next major). `create*` is canonical for collection/layer factories (`define*` stays reserved for the blocks descriptor system): crm/deals/marketing/intake/forms gain `create*Collection` names for their former `define*Collection` factories. Layer entries converge on `createXLayer(config?) → bundle`: `createCrmLayer`/`createDealsLayer`/`createMarketingLayer`/`createCatalogLayer`/`createEconomyLayer`/`createChromeLayer`/`createLmsLayer`/`createAiLayer` (+ `createFormsLayer`/`createIntakeLayer`), returning bare `CollectionConfig[]` where the layer contributes only collections or an honest named bundle where it hands back more (chrome: `{ globals }`; lms/ai: `{ collections, hooks }`); void-returning `initCatalog`/`initEconomy` stay as the single registration call sites, delegated to internally. Naming note for forms consumers: `createFormsCollection` (singular factory) vs `createFormsCollections` (plural composer) vs `createFormsLayer` (layer entry) — each docblock states the distinction.
- 6bc419c: Naming convergence (all additive; every old name keeps working as a `@deprecated` alias until that package's next major). `create*` is canonical for collection/layer factories (`define*` stays reserved for the blocks descriptor system): crm/deals/marketing/intake/forms gain `create*Collection` names for their former `define*Collection` factories. Layer entries converge on `createXLayer(config?) → bundle`: `createCrmLayer`/`createDealsLayer`/`createMarketingLayer`/`createCatalogLayer`/`createEconomyLayer`/`createChromeLayer`/`createLmsLayer`/`createAiLayer` (+ `createFormsLayer`/`createIntakeLayer`), returning bare `CollectionConfig[]` where the layer contributes only collections or an honest named bundle where it hands back more (chrome: `{ globals }`; lms/ai: `{ collections, hooks }`); void-returning `initCatalog`/`initEconomy` stay as the single registration call sites, delegated to internally. Naming note for forms consumers: `createFormsCollection` (singular factory) vs `createFormsCollections` (plural composer) vs `createFormsLayer` (layer entry) — each docblock states the distinction.
- 36e537a: `createProductCollection({ vendorScoped: true })` now actually enforces vendor ownership, closing the gap where the flag rendered the `vendor` field but left `create`/`update`/`delete` fully open. When the flag is on AND `@wabbit/tome-org` is registered: create requires an authenticated user, update/delete require vendor ownership (admin bypass via core's `checkRole`), and a `beforeChange` hook force-assigns `vendor` on create. Explicit `config.access` still wins per-operation. Flag absent/false = byte-identical behavior to before (pinned by new tests — catalog gains a vitest suite, 24 tests).
- 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).
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.
b4e5625: catalog: account-scope entitlements (v3 Amendment A2). Adds a nullable `accountId` relationship to the Entitlements collection (configurable via `accountsSlug`, default `'accounts'`) so an entitlement can be owned by a multi-tenant account (`@wabbit/tome-accounts`). `getEntitlements` now accepts `accountId` as an alternative owner filter (provide `userId` OR `accountId`; exactly one required) and `upsertEntitlementForSubscription` dual-writes `accountId` when provided. Fully additive: `userId` stays the required, contract-frozen owner through the transition (grant handlers dual-write `accountId`, then a backfill populates existing rows), and the original v3 frozen fields + the A1 subscription fields are unchanged. Orthogonal to the still-pending `userId → memberId` member-identity refactor.
- b4e5625: catalog: account-scope entitlements (v3 Amendment A2). Adds a nullable `accountId` relationship to the Entitlements collection (configurable via `accountsSlug`, default `'accounts'`) so an entitlement can be owned by a multi-tenant account (`@wabbit/tome-accounts`). `getEntitlements` now accepts `accountId` as an alternative owner filter (provide `userId` OR `accountId`; exactly one required) and `upsertEntitlementForSubscription` dual-writes `accountId` when provided. Fully additive: `userId` stays the required, contract-frozen owner through the transition (grant handlers dual-write `accountId`, then a backfill populates existing rows), and the original v3 frozen fields + the A1 subscription fields are unchanged. Orthogonal to the still-pending `userId → memberId` member-identity refactor.
61af0ea: Entitlements gain subscription-bounded access fields (`expiresAt`, `subscriptionId`, `status` including a `cancelling` state) — the entitlement row is the access gate, billing state lives consumer-local. `getEntitlements` now filters to active/cancelling + non-expired rows (rows with no `status` are treated as active for backward compatibility). New idempotent `upsertEntitlementForSubscription` query keyed on (userId, productId). Additive — existing lifetime grants are unaffected (`expiresAt` null = lifetime).
- 61af0ea: Entitlements gain subscription-bounded access fields (`expiresAt`, `subscriptionId`, `status` including a `cancelling` state) — the entitlement row is the access gate, billing state lives consumer-local. `getEntitlements` now filters to active/cancelling + non-expired rows (rows with no `status` are treated as active for backward compatibility). New idempotent `upsertEntitlementForSubscription` query keyed on (userId, productId). Additive — existing lifetime grants are unaffected (`expiresAt` null = lifetime).
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.
Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12
- Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12
Updated dependencies [36dc023]
- Updated dependencies [36dc023]
- Updated dependencies [2612799] - @wabbit/tome-core@1.0.11
Updated dependencies - @wabbit/tome-core@0.2.0
- Updated dependencies - @wabbit/tome-core@0.2.0