Workflow
Community engineStableTome workflow engine — a generic approval/transition state machine consolidating the accounts governance CAS claim primitive and the deals declarative transition table + side-effect registry. Engine + factory helpers only, no collections.
Get a free registry token from your credentials page. Every install from our registry needs one, free packages included.
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-workflow
Overview
@wabbit/tome-workflow
Tome workflow engine — a generic approval/transition state machine consolidating the accounts governance CAS claim primitive and the deals declarative transition table + side-effect registry. Engine + factory helpers only, no collections. Description copied verbatim from package.json.
Layer: domain (per ARCHITECTURE.md). Consolidates @wabbit/tome-accounts/governance's true-CAS approval engine and @wabbit/tome-deals/registry's declarative transition table + keyed side-effect registry into one engine, extended to cover five approval topologies observed across production consumers (single-approver, dual-sided, quorum, sequential-confirm, sole-authority-fallback). collections: [] is deliberate — this is an ENGINE, not a collection set; nothing here claims a slug.
Install
pnpm add @wabbit/tome-workflowPeer ranges, copied from package.json:
| Peer | Range | Optional? | |---|---|---| | payload | >=3.67.0 | no | | @wabbit/tome-core | >=1.14.0 <2.0.0 | no |
60-second quickstart
import { buildConfig } from 'payload'
import { createWorkflowLayer, type WorkflowTransitionTable } from '@wabbit/tome-workflow'
export default buildConfig({
// Registers the layer in tome-core's layerRegistry (idempotent) and returns
// an empty array — the engine owns no collections; your status field lives
// on your own collection.
collections: [...existingCollections, ...createWorkflowLayer()],
})
export const myTransitionTable: WorkflowTransitionTable = {
initial: 'pending',
transitions: [
{ from: 'pending', to: 'executing', requiresRole: 'admin', sideEffect: 'apply-change' },
{ from: 'pending', to: 'rejected' },
],
}Then, in a request handler or job runner (never in config-graph code — see "Server / client posture" below):
import { guardedTransition } from '@wabbit/tome-workflow/server'
const outcome = await guardedTransition(payload, {
collection: 'change-requests',
id: requestId,
table: myTransitionTable,
from: 'pending',
to: 'executing',
actorId: user.id,
hasRole: (role) => userHasRole(user, role),
})
// outcome.status: 'invalid-transition' | 'wrong-actor-class' | 'requires-role'
// | 'recused' | 'transition-guard-failed' | 'already-claimed'
// | 'side-effect-failed' | 'executed'guardedTransition never throws for a business refusal — it returns one of those outcomes, so switch on outcome.status. A transition's sideEffect is a key into the side-effect registry: register the handler with defineWorkflowSideEffect('apply-change', handler) at startup. If no handler is registered under that key, the engine logs a warning and still executes the transition.
The gotcha that bites: the atomic claim is MongoDB-only. Everything below about raw writes and bypassed hooks describes the Mongo adapter. On any other adapter (Postgres, SQLite) claimTransition falls back to a filtered payload.update({ where: { id, status: from } }). That path does run your collection hooks, and the code treats it as best-effort rather than a guaranteed compare-and-swap, so two concurrent callers are not proven to be kept apart there.
`executeClaimed` never re-runs a completed action. Only a throw from execute unwinds the row to unwindStatus (with lastExecutionError). If execute succeeds but the settle write to settledStatus fails, the write alone is retried (three attempts); if it still fails, the row is left at its claimed status, the failure is logged through payload.logger.error, and the result is { success: true, result, settled: false, settleError }. A claim from the unwind status cannot match that row, so the side effect is not repeated. Reconcile it by writing settledStatus yourself — never by unwinding it, which would let the next claim run the action again.
Claims bypass Payload hooks (by design)
On the Mongo adapter, claimTransition (and therefore guardedTransition, which composes it) wins its compare-and-swap by calling the Mongo adapter's raw Model.findOneAndUpdate({ _id, [statusField]: from } -> to) directly — the SAME primitive @payloadcms/db-mongodb uses for its own job-queue claims. That write never passes through Payload's `updateDocument` (payload/dist/collections/operations/utilities/update.js), so it fires no `beforeChange`, no `afterChange`, no revalidation — none of the side-machinery a normal payload.update() call triggers.
Why: atomicity is the entire reason this package exists. payload.update({ where }) — the path that WOULD run hooks — is find-then-updateMany under the Mongo adapter: it reads the current doc, then issues a bulk write. Two concurrent callers can both read the old status and both "win," which is exactly the double-execution race claimTransition exists to close (see claim.ts's module header for the full invariant). MongoDB's document-level findAndModify is atomic; Payload's where-update is not. There is no way to keep the atomicity guarantee AND route through Payload's hook-bearing update path for the claim write itself.
What this means for a consumer:
- No
beforeChangevalidation runs against the claim's$set— the claim primitive doesn't validate, it CAS-flips. - No
afterChangeside effects registered on the collection fire automatically — a notification producer, an apply hook, a revalidation hook wired into the collection config will NOT see this write unless you do one of the two things below. executeClaimed's settle/unwind write is not part of this bypass — it callspayload.update({ id, ... })(an ID-scoped local-API update, not awhere-CAS), which IS Payload's own operation and always runs the full hook lifecycle. Only the CAS write insideclaimTransitionbypasses hooks.
Two sanctioned patterns:
(a) Orchestrate side effects explicitly, inside the claim window — the pattern guardedTransition itself uses, and the same one an existing production consumer's status-request collection uses independently (direct updateOne + explicit side-effect orchestration, not a Payload hook). The side effect runs as ordinary application code immediately after a winning claim, with the guarantee that only the winner reaches that line. This is the default, zero-overhead pattern — reach for it first. Prefer it when the "side effect" is really domain logic (send an email, cascade a status to a related doc, enqueue a job) rather than something that must specifically observe every write to the collection through its normal hook chain.
(b) `dispatchHooks: true` on `claimTransition` — when a collection's registered afterChange hooks genuinely need to observe the transition (e.g. a generic revalidation hook, a notification producer that's wired at the collection level and shouldn't need workflow-specific awareness), pass dispatchHooks: true:
const won = await claimTransition(payload, {
collection: 'change-requests',
id: requestId,
from: 'pending',
to: 'executing',
dispatchHooks: true, // default false — opt-in, no behavior change otherwise
context: { source: 'guardedTransition' },
})Default is false and preserves the exact pre-existing behavior (raw write, no dispatch) — this is additive, not a breaking change. When true and the claim WINS, claimTransition fetches the post-claim doc (payload.findByID, depth: 0, overrideAccess: true) and calls dispatchAfterChange (see below) with operation: 'update' and a faithful previousDoc captured from the SAME atomic write (the adapter's findOneAndUpdate is asked for the pre-image via new: false — mongoose's default — rather than a separate pre-read, so there is no extra race window for previousDoc itself).
Consistency caveat: the post-claim doc is read via a SEPARATE, non-atomic findByID call after the winning write. The statusField it reports is guaranteed correct — once a claim wins, no other claimTransition call can flip it again from the same from value — but other fields on the doc could in principle be modified by an unrelated concurrent writer between the claim and this read. This is the same best-effort caveat guardedTransition's non-atomic settledStamp follow-up write already carries; it is not a new risk this feature introduces.
dispatchAfterChange / dispatchAfterDelete
Exported from ./server for any caller that bypasses Payload's own write pipeline and needs faithful hook dispatch — not just claimTransition's internal use:
import { dispatchAfterChange, dispatchAfterDelete } from '@wabbit/tome-workflow/server'
const doc = await dispatchAfterChange(payload, {
collection: 'change-requests',
doc: postClaimDoc,
previousDoc: preClaimDoc,
operation: 'update',
context: { source: 'my-raw-write' },
})Runs the collection's configured hooks.afterChange array in order, with Payload-faithful arguments ({ collection, context, data, doc, operation, overrideAccess, previousDoc, req }), mirroring updateDocument's own collection-hook loop (payload/dist/collections/operations/utilities/update.js). req is built via Payload's own createLocalReq when the caller doesn't supply one — a real PayloadRequest with locale/i18n/user/payload/dataloader plumbing, not a synthesized { payload, context: {} } stand-in — and createLocalReq also merges an existing req's context with the context argument for you. Hooks may return a replacement doc, threaded to the next hook and to the caller, exactly like Payload's own loop (hook(...) || result).
dispatchAfterDelete is the delete-path counterpart (a production consumer's closers run on afterDelete), mirroring deleteByID's afterDelete loop (payload/dist/collections/operations/deleteByID.js) — note Payload's own AfterDeleteHook type carries no operation/previousDoc/data, so neither does this: { collection, context, doc, id, req }.
One deliberate deviation from full fidelity: the real afterChange hook loop also receives data — the in-flight change payload from beforeChange, which a caller bypassing that pipeline entirely does not have. dispatchAfterChange accepts an optional data for callers who have a faithful analog (claimTransition passes its $set); otherwise it defaults data to doc, which is doc-shaped rather than patch-shaped. If a consuming afterChange hook inspects data to distinguish "what changed" from "the full doc," account for this default.
API surface
Three subpaths: ., ./server, ./version.
`.` (pure / config surface — safe in any environment, including a browser bundle):
| Group | Exports | |---|---| | Layer registration | createWorkflowLayer(config?) (returns a spreadable, deliberately empty CollectionConfig[] — this layer is an engine, not a collection set; config is accepted for vocabulary parity but currently unused), WorkflowLayerConfig; initWorkflow (@deprecated, sunset 1.0 — registers the layer and returns nothing); WORKFLOW_LAYER_VERSION (also at ./version) | | Topology types + helpers | RecusalContext, RecusalPredicate, ApproverPoolResolver, EscalationTier, SoleAuthorityFallback, SingleApproverTopology, SideState, DualSidedRollupStatus, DualSidedTopology, QuorumTopology, QuorumApprovalEntry, SequentialConfirmTopology, WorkflowTopology; notSelf, notSubmitter, notTarget, notAlreadySigned, composeRecusal, checkRecusal, computeRollup, uniqueApproverCount, thresholdMet, isDistinctSecondSigner, confirmationDeadline, confirmationWindowExpired | | Side-effect registry | WorkflowSideEffectHandler, defineWorkflowSideEffect, replaceWorkflowSideEffect, getWorkflowSideEffect, getRegisteredWorkflowSideEffectKeys, _resetWorkflowSideEffectRegistry (test-only) | | Transition table | WorkflowTransition (from, to, and optional key, requiresRole, by actor class, recusal, sideEffect key, guard), WorkflowTransitionTable ({ initial, transitions }), findTransition (first match on from→to), findTransitions (every match), resolveTransition + ResolveTransitionArgs / TransitionResolutionContext (picks one edge when several share a from→to pair, by key or actor), allowedTransitionsFrom, validateTransitionTable | | Notifications | workflowDedupKey, workflowGroupKey, slaAt, emitWorkflowOpen, emitWorkflowClose, WorkflowOpenNotificationArgs — the emitters go through @wabbit/tome-core/notifications; with no notifier registered there they do nothing, and they resolve false instead of throwing on failure |
`./server` — Node-only, payload-touching (writes, atomic claims); must not be reachable from a config graph a browser bundle walks:
| Group | Exports | |---|---| | Claim primitive | claimTransition, executeClaimed (+ ClaimTransitionArgs, ExecuteClaimedArgs, ExecuteClaimedResult) | | Guarded composition | guardedTransition (+ GuardedTransitionArgs, GuardedTransitionOutcome) | | Hook dispatch | dispatchAfterChange, dispatchAfterDelete (+ DispatchAfterChangeArgs, DispatchAfterDeleteArgs) — see "Claims bypass Payload hooks" above | | Deadline sweep | deadlineSweep, resolveDueTier, createDeadlineSweepHandler, createDeadlineSweepTask, createDeadlineSweepEndpoint (+ types) | | Pagination | findPaged, readPositiveNumber, JOB_PAGE_SIZE, JOB_DEFAULT_MAX_PAGES (+ FindPagedArgs) |
`./version` — WORKFLOW_LAYER_VERSION, pinned to package.json (enforced by tests/layer-version.test.ts).
Server / client posture
collections: [] — this package ships no collections, so there is no admin-nav manifest and nothing to navigate to. There are no React components either.
Testing
pnpm --filter @wabbit/tome-workflow test runs the Vitest suite in tests/ against a mock Payload (tests/helpers/mockPayload.ts), covering claims, guarded transitions, hook dispatch, the deadline sweep, topology helpers, the side-effect registry, notifications and transition tables. For your own tables, validateTransitionTable and allowedTransitionsFrom are pure and need no Payload. Everything under . is pure/config-safe. Everything under ./server touches payload at runtime and must only be imported from a request handler, job runner, or endpoint — never from a config graph a browser bundle walks.
Extending this package
New workflow side-effect handlers register via defineWorkflowSideEffect before a guardedTransition call resolves transition.sideEffect by key; a missing handler warns and lets the transition proceed rather than failing it (matching deals' status-transition-guard.ts posture). New recusal predicates compose via composeRecusal. A consumer needing full afterChange/afterDelete fidelity on a claim's write should reach for dispatchHooks: true (or call dispatchAfterChange/dispatchAfterDelete directly) rather than hand-replaying hooks with a synthesized request object.
Exports
@wabbit/tome-workflow@wabbit/tome-workflow/server@wabbit/tome-workflow/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.
7830a60: `executeClaimed` no longer unwinds a claim when `execute` succeeded but the settle write failed, so a retry cannot run the action a second time. Only a throw from `execute` unwinds to `unwindStatus`. A failing settle write is retried up to three times; if it still fails, the row stays at its claimed status, the failure is logged, and the result carries `settled: false` and `settleError` alongside `success: true` and the action's `result`.
- 7830a60: `executeClaimed` no longer unwinds a claim when `execute` succeeded but the settle write failed, so a retry cannot run the action a second time. Only a throw from `execute` unwinds to `unwindStatus`. A failing settle write is retried up to three times; if it still fails, the row stays at its claimed status, the failure is logged, and the result carries `settled: false` and `settleError` alongside `success: true` and the action's `result`.
84942bc: D1 (Locations wave): additive edge identity and table-level access semantics on the transition table. A `WorkflowTransition` may now carry an optional `key` (an identity unique only among edges sharing the same `(from, to)` pair), an actor-class gate `by` (free-form, like `requiresRole`; `'author' | 'curator' | 'system'` are what the Locations lifecycle ports over with), and its own `recusal: RecusalPredicate[]` — so a lifecycle rule such as "a curator who is also the report's author may not approve their own work" lives IN the reviewable table rather than in imperative caller code (ADR-018). Two new lookups: `findTransitions(table, from, to)` returns EVERY edge matching a pair (plural), and `resolveTransition(table, { from, to, key?, ctx })` is the guard-aware resolver that picks the first edge whose `by`, edge-level `recusal` and own `guard` all pass. Together they make a table with two legitimate edges between the same two statuses expressible — a consumer's `in-review -> draft` is both "Withdraw" (by the author) and "Request changes" (by a curator) — where a single-match lookup would silently collapse one into the other. `findTransition` is UNCHANGED and pinned by test: it still returns the FIRST edge of a shared pair, same predicate, same return shape. `guardedTransition` gains two optional args — `key` (select one edge of a shared pair; omitted, the lookup is byte-identical to before) and `actorClasses` (checked against the resolved edge's `by`, returning the new `wrong-actor-class` outcome BEFORE the claim touches the row) — and appends the resolved edge's own `recusal` to the caller's array, so both apply and neither replaces the other. Every pre-D1 table sets none of these fields, so no existing caller's behaviour changes.
- 84942bc: D1 (Locations wave): additive edge identity and table-level access semantics on the transition table. A `WorkflowTransition` may now carry an optional `key` (an identity unique only among edges sharing the same `(from, to)` pair), an actor-class gate `by` (free-form, like `requiresRole`; `'author' | 'curator' | 'system'` are what the Locations lifecycle ports over with), and its own `recusal: RecusalPredicate[]` — so a lifecycle rule such as "a curator who is also the report's author may not approve their own work" lives IN the reviewable table rather than in imperative caller code (ADR-018). Two new lookups: `findTransitions(table, from, to)` returns EVERY edge matching a pair (plural), and `resolveTransition(table, { from, to, key?, ctx })` is the guard-aware resolver that picks the first edge whose `by`, edge-level `recusal` and own `guard` all pass. Together they make a table with two legitimate edges between the same two statuses expressible — a consumer's `in-review -> draft` is both "Withdraw" (by the author) and "Request changes" (by a curator) — where a single-match lookup would silently collapse one into the other. `findTransition` is UNCHANGED and pinned by test: it still returns the FIRST edge of a shared pair, same predicate, same return shape. `guardedTransition` gains two optional args — `key` (select one edge of a shared pair; omitted, the lookup is byte-identical to before) and `actorClasses` (checked against the resolved edge's `by`, returning the new `wrong-actor-class` outcome BEFORE the claim touches the row) — and appends the resolved edge's own `recusal` to the caller's array, so both apply and neither replaces the other. Every pre-D1 table sets none of these fields, so no existing caller's behaviour changes.
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.
- 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.
- 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`.
0f6fb80: Claims bypass Payload hook dispatch by design (atomicity needs the raw adapter path) — now documented in claim.ts and a new README section. Adds dispatchAfterChange/dispatchAfterDelete (./server), built on Payload's own createLocalReq and matching the internal afterChange/afterDelete invocation shapes (payload 3.81), plus an opt-in dispatchHooks flag on claimTransition that captures the atomic write's pre-image as previousDoc and fires the collection's hooks after a winning claim. Default behavior unchanged. Found by the first consumer adoption, which had hand-replayed hooks with a synthesized req.
- 0f6fb80: Claims bypass Payload hook dispatch by design (atomicity needs the raw adapter path) — now documented in claim.ts and a new README section. Adds dispatchAfterChange/dispatchAfterDelete (./server), built on Payload's own createLocalReq and matching the internal afterChange/afterDelete invocation shapes (payload 3.81), plus an opt-in dispatchHooks flag on claimTransition that captures the atomic write's pre-image as previousDoc and fires the collection's hooks after a winning claim. Default behavior unchanged. Found by the first consumer adoption, which had hand-replayed hooks with a synthesized req.