Crowdfund
Commerce enginePreviewTome crowdfunding layer — campaigns, reward tiers, pledges, and the all-or-nothing settlement job (save card at pledge, charge off-session at deadline).
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-crowdfund
Overview
@wabbit/tome-crowdfund
Tome crowdfunding layer — campaigns, reward tiers, pledges, and the all-or-nothing settlement job (save card at pledge, charge off-session at deadline). Description copied verbatim from package.json.
Layer: domain (per ARCHITECTURE.md).
Install
pnpm add @wabbit/tome-crowdfundPeer 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 | no | | @wabbit/tome-economy | >=0.5.0 | yes | | lucide-react | >=0.460.0 | yes |
stripe is not a peer at any level. Nothing in this package imports it and no API key ever reaches this code — every provider call goes through an adapter the consumer constructs and passes in.
lucide-react is genuinely optional: the layer's Rocket sidebar icon is loaded lazily, and without the package the admin sidebar shows the nav domain's default icon instead.
@wabbit/tome-economy is optional because the coupling is structural, not nominal: the settlement job declares the two adapter methods it needs (chargeSavedPaymentMethod, releaseSavedPaymentMethod) as its own interface, which economy's StripeAdapter and FreeAdapter satisfy with no cast. Economy is still required in practice if you want the Orders the job writes.
60-second quickstart
import { buildConfig } from 'payload'
import { createCrowdfundLayer } from '@wabbit/tome-crowdfund'
export default buildConfig({
collections: [
...createCrowdfundLayer({ userCollection: 'members', mediaCollection: 'media' }),
// ...your other collections
],
})Defaults to know before the first migration: a pledge's optional backer relates to users (userCollection), a campaign's hero image to media (mediaCollection), and a tier's optional reward product to catalog-products (catalogProductsSlug). That tier relationship is built only when it has a target: pass catalogProductsSlug to relate tiers to a products collection you name, or false to omit it. Left unset, it targets catalog-products when @wabbit/tome-catalog is already in the layer registry (call initCatalog() or createCatalogLayer() before createCrowdfundLayer()), and is otherwise omitted with a console warning. If you use catalog through its per-collection factories without registering it, pass catalogProductsSlug: 'catalog-products' explicitly, or the product column is dropped at your next migration.
Pledges cannot be created through the API (create access is () => false); only your server code writes them, with overrideAccess. Campaign admin rights are the crowdfund:admin capability (CROWDFUND_ADMIN_CAPABILITY).
Per-collection factories are exported too, for sites that wire slugs and access by hand:
import {
createCampaignsCollection,
createTiersCollection,
createPledgesCollection,
} from '@wabbit/tome-crowdfund'Module surface
Root entry (@wabbit/tome-crowdfund) — config-graph safe, no Node-only imports:
| Export | What it is | |---|---| | createCrowdfundLayer(config?) | Canonical layer entry. Returns CollectionConfig[] and registers the layer. | | initCrowdfund(config?) | Registration only (admin sidebar manifest), for hand-wired sites. | | createCampaignsCollection / createTiersCollection / createPledgesCollection | Per-collection factories. | | CROWDFUND_DEFAULT_SLUGS | crowdfund-campaigns / crowdfund-tiers / crowdfund-pledges. | | CROWDFUND_LAYER_VERSION (also standalone at ./version) | Pinned to package.json by a test. | | adminOnly / publicRead / ownOrAdminRead / hasAdminRole (deprecated shim — use sessionHasCapabilityOrLegacyAdmin from @wabbit/tome-core/auth/repScoping) | The default access shapes, reusable when you override. | | CROWDFUND_ADMIN_CAPABILITY | 'crowdfund:admin' — the capability the admin access shapes check. | | resolveRelationId (deprecated alias — use relationIdRaw from @wabbit/tome-core/utilities/relationId) | Read an id off a relationship field that may be an id or a populated doc. | | Types | CrowdfundConfig, InitCrowdfundConfig, Campaign, CampaignMode, CampaignStatus (draft / live / settling / funded / failed), Tier, Pledge, PledgeStatus (pledged / captured / failed / released), and each factory's *CollectionConfig. |
/server subpath (@wabbit/tome-crowdfund/server) — Node-only job surface:
| Export | What it is | |---|---| | settleCampaign(payload, config, args) | The settlement pass. Takes now explicitly; safe to run repeatedly. | | createSettleCampaignHandler(config) | Framework-agnostic JobHandler. | | createSettleCampaignTask(config, opts?) | Payload jobs-framework task form (slug SETTLE_CAMPAIGN_TASK_SLUG = settleCrowdfundCampaigns); spread into jobs.tasks. | | createSettleCampaignEndpoint(config, opts?) | CRON_SECRET-guarded endpoint form: POST at SETTLE_CAMPAIGN_PATH (/jobs/settle-crowdfund-campaigns, under Payload's API route) with header Authorization: Bearer <CRON_SECRET>; answers 500 when CRON_SECRET is unset. | | reconcileCampaignStats(payload, args) | Converging re-derivation of pledgedCents / backerCount / tier claimed. | | findPaged / chunk / readPositiveNumber, JOB_PAGE_SIZE, JOB_DEFAULT_MAX_PAGES | Bounded paginated read with an explicit ceiling (re-exported from @wabbit/tome-core/jobs). | | Types | SettleCampaignConfig, SettleCampaignArgs, SettleCampaignSummary, CampaignSettlementResult, PledgeChargeAdapter (the structural adapter contract), PledgeChargeResult / PledgeChargeSuccess / PledgeChargeFailure, PledgeReleaseResult, ReconcileCampaignStatsArgs, CampaignStats, FindPagedArgs. |
Server / client posture
This package ships no React surface — no .tsx, no 'use client'. Everything under the root entry is Payload configuration (plain objects and factory functions) and is safe anywhere a payload.config.ts is evaluated.
Everything under /server is Node-only: it imports @wabbit/tome-core/jobs and talks to a payment provider through the adapter you pass it. Keep it out of any module a browser bundle walks. The split is why the job surface is a separate subpath rather than part of the barrel.
Settlement, in one paragraph
A pledge is a saved card and nothing else — hosted Checkout in mode: 'setup', $0 charged. A manual-capture authorization expires in about seven days and campaigns run about thirty, so authorize-then-capture cannot span a campaign; the card is charged off-session at the deadline, and only if the goal was met. At or after deadline, settleCampaign recomputes the funding verdict from pledge rows (never the denormalized pledgedCents stat), and pledgedTotal >= goalCents funds the campaign — the boundary is inclusive. Funded: each pledge is charged with the pledge id as the provider idempotency key, a completed Order is written per capture, and a decline leaves the pledge pledged with a failureReason to retry inside the dunning window (dunningDays, default 7) before being written off as failed. Not funded: every saved card is detached, pledges go released, and no charge is ever attempted.
import { createSettleCampaignEndpoint } from '@wabbit/tome-crowdfund/server'
import { StripeAdapter } from '@wabbit/tome-economy'
const settle = createSettleCampaignEndpoint({
adapter: new StripeAdapter({
secretKey: process.env.STRIPE_SECRET_KEY!,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
}),
dunningDays: 7,
orderPlaceholders: { customerId: process.env.GUEST_MEMBER_ID! },
})Settlement only considers campaigns in status live or settling. Orders go to economy's orders collection (ordersSlug) with provider: 'stripe' by default (orderProvider: 'free' pairs with the FreeAdapter); pass createOrders: false to capture without writing Orders. When a pledge has no backer or its tier has no product, the Order needs orderPlaceholders ids — without them the Order is skipped (the capture still stands) and the run reports it.
The job is converging: it re-derives the world on every run and is safe to re-run at any point, including mid-dunning. It takes no distributed lock — overlapping runs are made safe (not impossible) by the provider-side idempotency key. Serialise it yourself if you run several instances.
Taking a pledge
This package stores and settles pledges; it does not ship the pledge checkout. The flow a consumer writes, using @wabbit/tome-economy:
- In a server action, create the pledge row (
status: 'pledged',campaign,tier,amountCents,backerEmail, optionalbacker) withoverrideAccess: true. - Call
StripeAdapter#createSetupSession(...)withmetadata: { pledgeId }and redirect the backer to the hosted Checkout (setup mode — nothing is charged). - Pass
onSetupCompletedto economy's Stripe webhook handler; thesetup.completedevent carriespledgeId,customerId,setupIntentIdandpaymentMethodId. Write those onto the pledge — they are the handlessettleCampaigncharges at the deadline.
Testing
pnpm --filter @wabbit/tome-crowdfund testThe suite runs against an in-memory Payload stand-in and a scripted adapter — no keys, no network, no Stripe. Test mode is the only mode this package has ever run in.
Exports
@wabbit/tome-crowdfund@wabbit/tome-crowdfund/server@wabbit/tome-crowdfund/version
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.
d8152b2: **BREAKING:** a default changed — see the Migration note below. Crowdfund no longer fails Payload config validation when `@wabbit/tome-catalog` is not installed. A tier's optional reward `product` relationship is now built only when it has a target: an explicit `catalogProductsSlug` string always builds it, `false` omits it, and when unset it targets `catalog-products` only if catalog is registered in the layer registry (otherwise it is omitted with a console warning). **Migration:** a site that uses catalog through its per-collection factories without calling `initCatalog()` or `createCatalogLayer()` before `createCrowdfundLayer()` passes `catalogProductsSlug: 'catalog-products'` to keep the existing column.
- d8152b2: **BREAKING:** a default changed — see the Migration note below. Crowdfund no longer fails Payload config validation when `@wabbit/tome-catalog` is not installed. A tier's optional reward `product` relationship is now built only when it has a target: an explicit `catalogProductsSlug` string always builds it, `false` omits it, and when unset it targets `catalog-products` only if catalog is registered in the layer registry (otherwise it is omitted with a console warning). **Migration:** a site that uses catalog through its per-collection factories without calling `initCatalog()` or `createCatalogLayer()` before `createCrowdfundLayer()` passes `catalogProductsSlug: 'catalog-products'` to keep the existing column.
- d8152b2: The layer now loads without `lucide-react` installed; the optional peer supplies only the sidebar icon. The `Rocket` 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.
30bdd74: `ownOrAdminRead` now delegate to core's `ownOrCapabilityAccess`, with identical results. This closes the crowdfund/fulfillment near-fork that `assert:no-forked-primitives` flagged.
- 30bdd74: `ownOrAdminRead` now delegate to core's `ownOrCapabilityAccess`, with identical results. This closes the crowdfund/fulfillment near-fork that `assert:no-forked-primitives` flagged.
- 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`. `resolveRelationId` (public) is kept as a deprecated delegate to `relationIdRaw`, with identical semantics.
0836ef5: Admin gate → core primitive. "Is this user an admin?" was answered five incompatible ways across the platform (2026-09-01 sale-readiness audit §5.2); these four packages carried a deliberate clone of the same pre-`can()` role-string check, crowdfund's and fulfillment's headers both saying "matching economy verbatim". No behaviour change is intended for the legacy path, and tests pin it rather than prose asserting it. **crowdfund, fulfillment, rpg** now call `sessionHasCapabilityOrLegacyAdmin()` from `@wabbit/tome-core/auth/repScoping`, and compose the owner-scoped WHERE through `ownershipOrBypass()` from `@wabbit/tome-core/access`. All three declare `@wabbit/tome-core` as a required, explicitly non-optional peer, so these are plain static imports. The legacy path is unchanged: a `roles` array containing 'admin' is an admin, an unauthenticated request is not, and a non-admin session still resolves to `{ [ownerField]: { equals: user.id } }`. Deliberately widened: capability grants (`crowdfund:admin` / `fulfillment:admin` / `rpg:admin`) and core's `superadmin` / `super-admin` legacy aliases now pass too — the point of adopting the shared primitive. The Access functions are async now; Payload's `Access` type has always allowed a `Promise`, and the capability path needs an await. `hasAdminRole` stays exported from crowdfund and fulfillment as a `@deprecated` back-compat shim with byte-identical semantics; `CROWDFUND_ADMIN_CAPABILITY` and `FULFILLMENT_ADMIN_CAPABILITY` are new named exports. **economy** deliberately does NOT adopt the core primitive, and the reason is a constraint rather than an oversight: `@wabbit/tome-core` is a declared OPTIONAL peer here, the README states in two places that core is genuinely optional, and the only core reference in `src/` is a guarded lazy `require()` in `initEconomy`. A static import of a core access primitive from a collection factory would silently convert that optional peer into a required one. Instead the ten inline checks across `Orders` (×3), `Payments` (×2), `Prices` (×4) and `VendorEarnings` (×1) collapse into one internal `isEconomyAdmin()` in `src/access/adminGate.ts`, implementation moved not rewritten, with a written promotion trigger: the day core becomes a required peer of this package, delete the body and delegate. Orders' unique extra `req.user.collection === 'users'` condition is preserved exactly and pinned by a test. New suites: `crowdfund/tests/access.test.ts` (14), `fulfillment/tests/access.test.ts` (16), `economy/tests/admin-gate.test.ts` (11). Existing `collections.test.ts` assertions in crowdfund and fulfillment were updated to await the now-async access results — asserted VALUES unchanged. rpg has no test harness, so its change is covered by typecheck only. The forcing function ships with the consolidation: `eslint.config.mjs` gains a `no-restricted-syntax` warn-ratchet banning hand-rolled `.roles.includes(...)` admin checks in `access/**`, `collections/**` and `*access*.ts`, pointing at `sessionHasCapabilityOrLegacyAdmin` / `can`. The repo-wide count is 0 (down from 13), with three written `eslint-disable` exemptions: the two deprecated back-compat exports and economy's single gate.
- 0836ef5: Admin gate → core primitive. "Is this user an admin?" was answered five incompatible ways across the platform (2026-09-01 sale-readiness audit §5.2); these four packages carried a deliberate clone of the same pre-`can()` role-string check, crowdfund's and fulfillment's headers both saying "matching economy verbatim". No behaviour change is intended for the legacy path, and tests pin it rather than prose asserting it. **crowdfund, fulfillment, rpg** now call `sessionHasCapabilityOrLegacyAdmin()` from `@wabbit/tome-core/auth/repScoping`, and compose the owner-scoped WHERE through `ownershipOrBypass()` from `@wabbit/tome-core/access`. All three declare `@wabbit/tome-core` as a required, explicitly non-optional peer, so these are plain static imports. The legacy path is unchanged: a `roles` array containing 'admin' is an admin, an unauthenticated request is not, and a non-admin session still resolves to `{ [ownerField]: { equals: user.id } }`. Deliberately widened: capability grants (`crowdfund:admin` / `fulfillment:admin` / `rpg:admin`) and core's `superadmin` / `super-admin` legacy aliases now pass too — the point of adopting the shared primitive. The Access functions are async now; Payload's `Access` type has always allowed a `Promise`, and the capability path needs an await. `hasAdminRole` stays exported from crowdfund and fulfillment as a `@deprecated` back-compat shim with byte-identical semantics; `CROWDFUND_ADMIN_CAPABILITY` and `FULFILLMENT_ADMIN_CAPABILITY` are new named exports. **economy** deliberately does NOT adopt the core primitive, and the reason is a constraint rather than an oversight: `@wabbit/tome-core` is a declared OPTIONAL peer here, the README states in two places that core is genuinely optional, and the only core reference in `src/` is a guarded lazy `require()` in `initEconomy`. A static import of a core access primitive from a collection factory would silently convert that optional peer into a required one. Instead the ten inline checks across `Orders` (×3), `Payments` (×2), `Prices` (×4) and `VendorEarnings` (×1) collapse into one internal `isEconomyAdmin()` in `src/access/adminGate.ts`, implementation moved not rewritten, with a written promotion trigger: the day core becomes a required peer of this package, delete the body and delegate. Orders' unique extra `req.user.collection === 'users'` condition is preserved exactly and pinned by a test. New suites: `crowdfund/tests/access.test.ts` (14), `fulfillment/tests/access.test.ts` (16), `economy/tests/admin-gate.test.ts` (11). Existing `collections.test.ts` assertions in crowdfund and fulfillment were updated to await the now-async access results — asserted VALUES unchanged. rpg has no test harness, so its change is covered by typecheck only. The forcing function ships with the consolidation: `eslint.config.mjs` gains a `no-restricted-syntax` warn-ratchet banning hand-rolled `.roles.includes(...)` admin checks in `access/**`, `collections/**` and `*access*.ts`, pointing at `sessionHasCapabilityOrLegacyAdmin` / `can`. The repo-wide count is 0 (down from 13), with three written `eslint-disable` exemptions: the two deprecated back-compat exports and economy's single gate.
- 8fc9702: Import `mergeHooks`, `fieldShape` and the select-option override contract from `@wabbit/tome-core` instead of keeping local copies (2026-09-01 sale-readiness audit §5.1, T3(a)). No API change: every symbol these packages exported before is still exported, now re-exported from core, and every factory produces byte-identical output. Deleted, with every call site repointed: - `org/src/hooks/mergeHooks.ts`, `lms/src/collections/shared/mergeHooks.ts`, `sc/src/extensions/mergeHooks.ts` → `@wabbit/tome-core/hooks/mergeHooks`. Core has exported this since July; sc's copy still carried a header claiming "Neither @wabbit/tome-core nor @wabbit/tome-org exports this." - `lms/src/server/jobs/paginate.ts`, `crowdfund/src/server/jobs/paginate.ts`, `workflow/src/server/paginate.ts` → `@wabbit/tome-core/jobs`. - `org/src/fieldShape.ts` + `org/src/insertFieldsAfter.ts`, `lms/src/collections/shared/fieldShape.ts` → `@wabbit/tome-core/fields/fieldShape`. Org's `resolveFieldDescription` / `FieldDescriptionOverride` were NOT part of the duplicated set and stay in the package, moved to `org/src/fieldDescriptions.ts`. - `org/src/optionOverrides.ts`, `lms/src/collections/shared/optionOverrides.ts` → `@wabbit/tome-core/fields/selectOptions`; `sc/src/collections/registry/shared.ts` now re-exports it (its `extractId` is a separate audit item and is untouched). **Peer floor raised to `@wabbit/tome-core` `>=1.14.0 <2.0.0`** in all five packages, because each now imports a subpath or a named export that first exists in that core minor: `./fields/fieldShape` and `./fields/selectOptions` are new subpaths, and `findPaged`/`chunk`/`readPositiveNumber` are new named exports on the pre-existing `./jobs`. Crowdfund's floor moves from `>=1.7.0` even though `./jobs` itself shipped in 1.7.0 — the subpath resolving is not the same thing as the export existing, which is the sharper version of the lesson its own 0.1.1 CHANGELOG records (`ERR_PACKAGE_PATH_NOT_EXPORTED`). Org moves from `>=1.2.0`, lms from `>=1.0.0`, sc from `>=1.11.0`, workflow from `>=1.7.0`. Header comments that pointed at the deleted files, or asserted core did not export these, were corrected rather than left dangling.
- 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.
- 04309f5: Collapse the audited serial-await fan-outs in Payload hooks, jobs and access checks. No behaviour changes — every try/catch, failure reporter and `overrideAccess` justification is preserved; only the number of round-trips changes. **`find({ limit: 0 })` → `payload.count()`** — `limit: 0` sets `pagination: false` in the Mongo adapter, so the query loads every matching row into memory to produce one number. `@wabbit/tome-org` documents this as a production incident in `hooks/attendance-count-sync.ts:6-11` and had reintroduced it in `collections/recruiting/JoinRequest.ts`'s one-pending-request guard; `@wabbit/tome-sc`'s squadron member recount had the same shape. **Independent lookups → `Promise.all` / `Promise.allSettled`** — org's `createGiverWindowAccess` (a per-request access check that took two serial round-trips), `EventAttendance`'s display-name composer and `AwardPresentation`'s, sc's RSI profile+org page fetches (the hot path for handle validation, against a third-party host), squadron recalcs, and accounts' three offboarding teardown callbacks. `allSettled` wherever a branch had its own fallback, so a failed member lookup still cannot stop the event title from resolving. sc's RSI adapter keeps its 404 short-circuit exactly, and ledger's per-leg balance guard decides in leg order so the thrown `NegativeBalanceError` still names the same wallet the serial version did. **Independent per-row writes → `batchWrite`** (`@wabbit/tome-core/utilities/batch`) — org's notification open/resolve fan-outs, the division/team cleanup and sunset cascades (up to 1,000 rows each), the event cascade-delete, the non-atomic `memberCount` fallback; lms's certification-expiry sweep (now paced in `WRITE_CHUNK` chunks like its sibling reconciler) and the course-delete enrollment drop; workflow's deadline sweep; crowdfund's tier-claim reconcile. **Same `data` for every row → one bulk `payload.update({ where, data })`** — sc's asset-assignment auto-close and the transfer-request GDPR redaction, matching `sc/src/gdpr.ts`'s `makeNullRefHandler`. The auto-close also drops a latent correctness hazard: its page cursor advanced while its own writes removed rows from the filter it was paging over, so a page boundary could skip assignments. **Two collection-level fixes.** sc's Fleet had two field-level `beforeChange` hooks each issuing a `findByID` for the SAME ship on every write; they are now one collection-level hook that reads the ship once and sets both `chassisName` and `name`. lms's `checkCertificationExpiry` re-derived `recountHolders` once per expired award with no cache; it now recounts once per affected certification, after the sweep — which is also more correct, since only the final count was ever right. `@wabbit/tome-crowdfund`'s `settleCampaign` pledge loop is untouched and now carries an explicit `eslint-disable` plus the reason: it captures money one pledge at a time against a `maxCapturesPerRun` budget that only bounds anything if the iterations are serialized. `@wabbit/tome-org`, `@wabbit/tome-lms`, `@wabbit/tome-workflow` and `@wabbit/tome-crowdfund` raise their `@wabbit/tome-core` peer floor to `>=1.14.0`, the release that adds `./utilities/batch`. org and lms were also understating their floor before this change — both already imported `@wabbit/tome-core/jobs`, added in core 1.7.0, while declaring `>=1.2.0` / `>=1.0.0`.
- 090e984: README fixes surfaced by the extended `assert:readme-contract` gate (2026-09-01 sale-readiness audit, Tier 2), each verified against the package's own manifest or source: - **blocks-core** — the `./categories` and `./types` entry points are now named in the Public API section; both were published but undocumented. - **core** — added `/access/orgScoped`, `/access/vendorScoped`, `/infra/health` and `/infra/env-scaffold` to the additional-subpaths table, and noted that `/auth/collections/roles` has a real `/auth/collections/Roles` case alias in the exports map. - **crowdfund** — `CROWDFUND_LAYER_VERSION` is also published standalone at `./version`; the row now says so. - **dispatch** — the eight per-block `./blocks/*` config subpaths and all eight `./components/*` component subpaths are enumerated instead of one "etc." row. - **forms** — the peer table now lists `@wabbit/tome-core`, `@wabbit/tome-ui` and `typescript`, which are declared `peerDependencies` but appeared only in prose (or not at all). - **lms-ui** — `StudentProfileEditor` is flagged `@deprecated` in the component table, matching the tag its source already carries.
f4fd273: Raise the tome-core peer floor to >=1.7.0. The /server subpath imports @wabbit/tome-core/jobs at top level, which first exists in core 1.7.0 — the old >=1.0.0 floor let npm install a combination that fails at runtime with ERR_PACKAGE_PATH_NOT_EXPORTED (tsc cannot catch it; found by the first consumer install).
- f4fd273: Raise the tome-core peer floor to >=1.7.0. The /server subpath imports @wabbit/tome-core/jobs at top level, which first exists in core 1.7.0 — the old >=1.0.0 floor let npm install a combination that fails at runtime with ERR_PACKAGE_PATH_NOT_EXPORTED (tsc cannot catch it; found by the first consumer install).