Fulfillment
Commerce enginePreviewTome fulfillment layer — shipping addresses (GDPR-registered), shipments, warehouse stock with an append-only adjustment trail, a flat-rate shipping seam, and a deferred tax adapter seam.
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-fulfillment
Overview
@wabbit/tome-fulfillment
Tome fulfillment layer — shipping addresses (GDPR-registered), shipments, warehouse stock with an append-only adjustment trail, a flat-rate shipping seam, and a deferred tax adapter seam. Description copied verbatim from package.json.
Layer: domain (per ARCHITECTURE.md).
This is what happens after the money moves. Nothing in this package charges anything, and it has no payment adapter.
Install
pnpm add @wabbit/tome-fulfillmentPeer ranges, copied from package.json:
| Peer | Range | Optional? | |---|---|---| | payload | >=3.67.0 | no | | @wabbit/tome-core | >=1.17.0 <2.0.0 | no | | lucide-react | >=0.460.0 | yes |
lucide-react is genuinely optional: the layer's Truck sidebar icon is loaded lazily, and without the package the admin sidebar shows the nav domain's default icon instead.
@wabbit/tome-core is a required peer, not optional: the collections import utilities/typedSlug, the layer registration imports utilities/layerRegistry, and the GDPR registration imports gdpr/registry — all statically, all unconditionally. @wabbit/tome-economy is deliberately not a peer at any level: the coupling to Orders is a text id, on purpose (see "Orders are untouched" below).
60-second quickstart
import { buildConfig } from 'payload'
import { createFulfillmentLayer } from '@wabbit/tome-fulfillment'
export default buildConfig({
collections: [
...createFulfillmentLayer({ userCollection: 'members' }),
// ...your other collections
],
})That call also registers fulfillment-addresses with core's GDPR export/delete pipelines. Per-collection factories are exported too, for sites that wire slugs and access by hand:
import {
createAddressesCollection,
createShipmentsCollection,
createStockCollection,
registerFulfillmentGdpr,
} from '@wabbit/tome-fulfillment'If you wire the collections by hand, you must call `registerFulfillmentGdpr()` yourself — see the GDPR section.
An address's optional user relates to users by default (userCollection). Admin rights on all three collections are the fulfillment:admin capability (FULFILLMENT_ADMIN_CAPABILITY). Anonymous requests are denied by every default access rule, so guest-checkout addresses, shipments and stock rows are written from your server code with overrideAccess: true (the /server stock functions already do this).
Module surface
Root entry (@wabbit/tome-fulfillment) — config-graph safe, no Node-only imports:
| Export | What it is | |---|---| | createFulfillmentLayer(config?) | Canonical layer entry. Returns CollectionConfig[], registers the layer, registers GDPR. | | initFulfillment(config?) | Registration only (admin sidebar manifest), for hand-wired sites. | | createAddressesCollection / createShipmentsCollection / createStockCollection | Per-collection factories. | | registerFulfillmentGdpr(config?) | Registers the PII collection with core's GDPR pipelines. | | appendOnlyAdjustments | The beforeChange hook enforcing the stock trail. Exported so a consumer replacing the stock collection can keep it. | | createFlatRateTable(rates) | Builds the shipping resolver. Returns computeShipping({ country, itemCount }). | | NoShippingRateError / InvalidShippingRateTableError | Thrown by the resolver (no rate for a country) and by createFlatRateTable (bad table). | | noopTaxAdapter / TaxAdapter | The deferred tax seam. Returns zero. | | adminOnly / ownOrAdminRead / ownOrAdminWrite / hasAdminRole, FULFILLMENT_ADMIN_CAPABILITY | The default access shapes and the fulfillment:admin capability they check, reusable when you override access. | | resolveRelationId (deprecated alias — use relationIdRaw from @wabbit/tome-core/utilities/relationId) | Read an id off a relationship that may be an id or a populated doc. | | Types | FulfillmentConfig, InitFulfillmentConfig, Address, Shipment, ShipmentLineItem, ShipmentStatus, StockAdjustment, StockRow, RegisterFulfillmentGdprConfig, ShippingRate, ShippingQuote, ShippingQuoteArgs, ShippingRateResolver, TaxComputationArgs, TaxResult, and each factory's *CollectionConfig. | | FULFILLMENT_DEFAULT_SLUGS | fulfillment-addresses / fulfillment-shipments / fulfillment-stock. | | FULFILLMENT_LAYER_VERSION | The version registered with the layer registry; test-pinned to package.json. | | ./version (@wabbit/tome-fulfillment/version) | Standalone version export, resolvable without pulling in the rest of the root entry. |
/server subpath (@wabbit/tome-fulfillment/server) — takes a Payload instance, writes to the database:
| Export | What it is | |---|---| | reserveStock(payload, args) | Promise units to an order. reserved up. | | releaseStock(payload, args) | Un-promise them. reserved down. | | commitStock(payload, args) | The parcel shipped. reserved and onHand down. | | InsufficientStockError / StockRowNotFoundError | Typed failures. | | StockOperationArgs / StockMutationResult | { variantSku, quantity, reason?, stockSlug?, now? } in; { applied, stockId, onHand, reserved, available } out. |
Shipping and tax stay on the root entry, not /server: a shipping quote is pure arithmetic and a storefront legitimately renders it client-side.
The three collections
| Slug | What it holds | Access default | |---|---|---| | fulfillment-addresses | PII. Recipient, street, city, postcode, ISO-3166 alpha-2 country. user is optional — guest checkout is first-class. | owner-scoped read/write, admin delete | | fulfillment-shipments | One parcel. Advisory orderId text, ship-to address, lifecycle, carrier + tracking, line items. | admin only | | fulfillment-stock | onHand, reserved, and an append-only adjustments trail, keyed by variantSku. | admin only |
Orders are untouched. All fulfillment state lives in shipment rows referencing an order; no field is ever added to economy's Orders, because three live checkout paths write Orders with placeholder relations and any new required Order field 500s a checkout before Stripe is reached. completed on an Order keeps meaning paid; shipped-ness is the shipment's business. orderId is therefore TEXT, not a relationship.
Partial shipments come from many shipment rows per order, each carrying the line items in that parcel. There is no stored "fully shipped" flag — it is a derivation, and a stored copy would be the first thing to drift.
Line items and stock use `variantSku` text refs. @wabbit/tome-catalog's opt-in catalog-product-variants collection carries the same unique sku string, and matching is by that string, not by relationship — so fulfillment works whether or not a site enables catalog variants. The SKU is unique and indexed here so a future variant relationship can be added alongside and back-filled by matching the SKU. The SKU stays regardless — it is the key a pick list, a carrier manifest and a supplier PO all speak.
GDPR
fulfillment-addresses is PII and registers with core's registry:
import { gdprRegistry } from '@wabbit/tome-core/gdpr/registry'Core's exportUserData and deleteAccount both iterate gdprRegistry.getAll(), so a collection that never registered is silently absent from both. createFulfillmentLayer() registers for you (pass registerGdpr: false to own it yourself); a hand-wired site must call registerFulfillmentGdpr({ addressesSlug }) itself. Registration is idempotent — the registry is a Map keyed by slug.
Only addresses register. fulfillment-stock holds no user data. fulfillment-shipments is a commercial record — the same category as an invoice, retained under legal obligation rather than erased on request; cascading a deletion into it would delete the merchant's own books. If you need a shipment's denormalized delivery detail scrubbed too, register your own onDelete handler, because only you know your retention obligations.
Registering makes core's pipelines cascade. It does not make your site compliant: retention windows, the privacy notice, and whether an address on an in-flight parcel may be erased mid-delivery are all yours.
Shipping rates — a seam, not a provider
import { createFlatRateTable } from '@wabbit/tome-fulfillment'
const computeShipping = createFlatRateTable([
{ country: 'US', amountCents: 800 },
{ country: 'CA', amountCents: 1600 },
{ amountCents: 3200 }, // wildcard fallback
])
computeShipping({ country: 'us', itemCount: 3 }) // { amountCents: 800 }Resolution is most specific first: exact country (case-insensitive), then the wildcard row (country omitted or '*'), then a thrown NoShippingRateError. The table is validated at construction — empty table, duplicate country, two wildcards, negative or fractional cents all throw immediately, so a misconfigured table fails at boot rather than at a customer's checkout.
No match throws rather than returning zero, because a missing rate and free shipping must not look the same; the merchant otherwise eats the postage on every order to a country nobody remembered to add. Free shipping is declared explicitly: createFlatRateTable([{ amountCents: 0 }]).
Flat means one price per shipment. itemCount is in the signature because a provider quote needs it and call sites should pass it from day one — the flat table does not price on it. The resolver is synchronous; a provider adapter will be async, and that will be a breaking change to this signature, which is what 0.x is for.
Tax — deferred, and staying that way
noopTaxAdapter returns { taxCents: 0 } for every input. There is no calculation in this package by design: tax is deferred until a merchant's actual filing posture is known, running flat-rate and US-only, no tax line, in the meantime. What is deferred is not the code but the questions the code would answer on the merchant's behalf — nexus, registration, merchant of record, who files. computeTax is async so the first real provider (e.g. Stripe Tax) drops in with no call-site change; that is the trigger condition for lifting the deferral.
Stock: reserve → commit, and the race you should know about
import { reserveStock, commitStock } from '@wabbit/tome-fulfillment/server'
await reserveStock(payload, { variantSku: 'TSHIRT-BLK-M', quantity: 2, reason: 'reserve:order_9f2' })
await commitStock(payload, { variantSku: 'TSHIRT-BLK-M', quantity: 2, reason: 'commit:shipment_41' })A stock row must exist before any of these run. They look the row up by variantSku and throw StockRowNotFoundError when there is none — create one fulfillment-stock row per SKU (with its starting onHand) when the product goes on sale. quantity must be a positive integer (anything else throws a TypeError), and InsufficientStockError is thrown, without writing, when a reserve exceeds available, or a release or commit exceeds what is currently reserved (a commit also checks onHand). Commit therefore only works on units that were reserved first — reserve, then commit.
onHand is what is in the building; reserved is how much of it is already promised; available is `onHand - reserved` and is never stored — a third number is a third thing that can disagree.
reason doubles as an idempotency key: an operation whose exact reason is already in the trail returns { applied: false } having written nothing. Keys must be unique per logical operation (reserve:order_9f2:SKU, not reserve). Omit it and the operation has no replay protection and applies every time — fine for a hand-run operator adjustment, wrong for anything a job retries. Jobs must pass a key.
No lock. Each operation is read-check-write against one row, matching the crowdfund settlement job's posture: tome ships no lock primitive and inventing one here would be a parallel mechanism. Two concurrent reservations for the last unit can both pass the check and oversell by one. What is true instead: the window is one server-side round trip; min: 0 on both counters means Payload rejects a write that would go negative; the trail makes an oversell visible afterwards; and a consumer that cannot tolerate one serialises these calls with whatever single-flight it already owns. If overselling ever becomes unacceptable rather than undesirable, the fix is a conditional database UPDATE ... WHERE onHand - reserved >= :qty, not a fatter hook and not an in-process mutex.
The adjustment trail is append-only. A beforeChange hook rejects any write that removes, reorders, or edits an existing entry; record a compensating adjustment instead. delta is the signed change to onHand and nothing else, so initialOnHand + Σdelta === onHand always holds — which is why reserve and release entries carry delta: 0 (they moved a promise, not a unit) and why the reason prefix carries which counter moved. The honest limit: this is a Payload-layer guard, not a database constraint. Anything writing beneath Payload bypasses it.
Testing
pnpm --filter @wabbit/tome-fulfillment test
pnpm --filter @wabbit/tome-fulfillment typecheckThe suite covers collection shape and knobs, append-only enforcement, flat-rate resolution (specific > wildcard > typed error), the no-op tax adapter, stock reserve/release/commit including insufficient-stock rejection, idempotent replay and trail writes, GDPR registration against core's real registry, and the layer-version pin.
Build and publish preflight
build runs tsup (bundle: false, esm + cjs + dts) and then fix-dist-extensions --strict, which is not optional: tsup emits relative specifiers extensionless and raw Node cannot resolve them. pnpm assert:node-loadable proves the repair held and belongs in the publish preflight. Both were wired from this package's first commit rather than retrofitted — which is how catalog and economy ended up shipping unloadable subpaths for months. Run node ../../scripts/assert-node-loadable.mjs --every-file (not just the default exports-map pass) ahead of any publish that touches this package's internal import graph — default mode only enters through package.json#exports and can miss a live module cycle between two internal files that only breaks from one entry direction (see the script's own header for the mechanism and the @wabbit/tome-sc 0.3.0 case that motivated it).
Publishing is heavyweight-gated: this package is consumed beside economy on a live money path.
Exports
@wabbit/tome-fulfillment@wabbit/tome-fulfillment/server@wabbit/tome-fulfillment/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.
0edd49b: The layer now loads without `lucide-react` installed; the optional peer supplies only the sidebar icon. The `Truck` 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.
- 0edd49b: The layer now loads without `lucide-react` installed; the optional peer supplies only the sidebar icon. The `Truck` 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` / `ownOrAdminWrite` now delegate to core's `ownOrCapabilityAccess`, with identical results. This closes the crowdfund/fulfillment near-fork that `assert:no-forked-primitives` flagged.
- 30bdd74: `ownOrAdminRead` / `ownOrAdminWrite` 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.
- ce3d12d: Adopt `@wabbit/tome-core/fields/address` and `@wabbit/tome-core/utilities/relationId` at the sites the audit counted (2026-09-01 sale-readiness audit §5.1, T3(g)). **No stored field name, and no emitted field array, changes anywhere in this changeset** — each adopter passes the vocabulary it already stores, and each ships a characterisation test that was written from the pre-change source, run green against the untouched factory, and run green again after. **Address group — five sites, one implementation.** - `@wabbit/tome-crm` — `accounts` and `contacts` each carried a byte-identical seven-field `address` group. Both now spread `postalAddressFields({ vocabulary: 'legacy-crm' })` after their own `name` line (`name` is the company/contact line, not a postal line). `tests/address-characterisation.test.ts` pins both groups whole. - `@wabbit/tome-deals` — `billingAddress` and `shippingAddress` inside the frozen Customer Snapshot were copies three and four. They now come from one `buildSnapshotAddressGroup` helper: `name` + `company` prepended locally, the six postal lines from core, and the eight per-field labels plus the `'US'` country default passed through core's `fieldOverrides` seam. The snapshot is a legal-offer record frozen after send, so a field-name change would orphan the address on every deal already sent; `tests/address-characterisation.test.ts` pins both groups and the fact that they differ only in the group label and the recipient line's label. - `@wabbit/tome-fulfillment` — the fifth copy, and the only one that validated `country`. Its postal lines stay FLAT at collection top level (they are stored columns with PII rows and a GDPR registration behind them), now via `postalAddressFields({ vocabulary: 'postal', required: true, validateCountry: true })`. The ISO-3166 validator and its uppercase-normalising hook moved into core verbatim; because a moved function is a new object, `tests/address-characterisation.test.ts` pins the whole top-level field ORDER plus the validator's and hook's BEHAVIOUR (accepts `US`, rejects `usa`, rewrites `' us '` to `'US'`), not their identity. **`relationId` — the four-return-types problem.** - `@wabbit/tome-lms` — twelve modules under `src/server` (`academy`, `catalog`, `certificates`, `course`, `dashboard`, `enrollment`, `grades`, `leaderboard`, `learnerShell`, `notes`, `profile`, `reviews`) carried a byte-identical `string | null` copy. They import `relationId` from core now. One behavioural difference, strictly an improvement: on a malformed populated doc (`{ id: null }`, `{ id: {} }`) the old copy returned the STRING `'null'` / `'[object Object]'` as an id; core returns `null`. `tests/relation-id-adoption.test.ts` pins the adoption itself, because adoption is the thing that decays — the July 2026 audit's finding, repeated verbatim in September, was "extraction keeps happening, adoption does not." **Not migrated, deliberately:** `src/guards`, `src/utilities/{grading,prerequisites,progress}.ts`, `src/hooks/**`, `src/server/mutations/helpers.ts` and `src/server/awardGate.ts` return `string | number` or `undefined`. Migrating those is a semantic change, not an import change, and belongs in a pass that owns their call sites. The new test names them as out of scope so the next reader does not have to re-derive why. - `@wabbit/tome-sc` — the registry sub-cluster's copy is gone; `collections/registry/shared.ts` re-exports core's `relationId`, keeping `extractId` as a local alias (the module is private to that sub-cluster). **This one WIDENS:** the sc copy returned `string | number`, so a populated doc's numeric id came through unstringified. It is now stringified, which makes `===` between two resolved ids agree — the behaviour every call site in the cluster already assumed. Ids handed back to `payload.find`/`update` are unaffected, since Payload accepts either form in a `where` clause. sc's 179 tests stay green. - `@wabbit/tome-crm` — the inline ternary in `integration/deals.ts` (`typeof oppRaw === 'object' ? oppRaw.id : oppRaw`) was the fifth shape and had the same numeric-id asymmetry; it is one `relationId(deal.opportunity)` call now. **`fetchMemberId` ×4 — one implementation (sc).** `asset-availability`, `fleet-logs` and `fleet` each carried a verbatim copy of the auth-user → Member-row lookup, and `resource-requests` carried its projecting twin. All four now import from `src/access/fetchMemberId.ts`, which documents why each query knob is load-bearing: `overrideAccess: true` (the member collection's own read access may itself depend on membership, so without the bypass this is a circular check that denies the owner their own row), `depth: 0`, `pagination: false`. The id is returned in its STORED type here rather than through `relationId` — this is an identity read fed straight back into a `where` clause, not a relationship read. `tests/fleet-shared-helpers.test.ts` pins the adoption, the three knobs, and the null-for-anonymous contract.
- 637db74: Seed the CHANGELOG.md that the `files` field ships but which never existed (the package published 0.1.0 without a changeset).
- 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.
5dda6c04: NEW package — Tome fulfillment layer: shipping addresses (GDPR-registered), stock and stock adjustments, shipments, and pluggable rate/tax seams (`createFlatRateTable`, `noopTaxAdapter`). Server helpers on `./server`. Family `commerce`, tier `pro` (stamped in eb317800).
- 5dda6c04: NEW package — Tome fulfillment layer: shipping addresses (GDPR-registered), stock and stock adjustments, shipments, and pluggable rate/tax seams (`createFlatRateTable`, `noopTaxAdapter`). Server helpers on `./server`. Family `commerce`, tier `pro` (stamped in eb317800).