Accounts
Community engineStableTome multi-tenant SaaS accounts layer — accounts, memberships (seats/roles), projects, and an account-scoped authority resolver built on the converged @wabbit/tome-core permission engine.
Add our registry to the
.npmrcat the root of your project. Free packages install without a token.@wabbit:registry=https://npm.wabbit.com/Then install:
npm install @wabbit/tome-accounts
Overview
@wabbit/tome-accounts
Tome multi-tenant SaaS accounts layer — accounts, memberships (seats/roles), projects, and an account-scoped authority resolver built on the converged @wabbit/tome-core permission engine. Description copied verbatim from package.json.
Layer: domain (per ARCHITECTURE.md). Standalone within this batch — no dependency on crm/lms/catalog/org/deals/economy/marketing; built entirely on @wabbit/tome-core's permission engine. Per its own barrel docblock: "It does NOT introduce a second permission system" — account permissions are membership-scoped (a user can own one account and be a member of another), but the resolution math is the shared @wabbit/tome-core/auth/permissions engine.
Install
pnpm add @wabbit/tome-accountsPeer ranges, copied from package.json:
| Peer | Range | Optional? | |---|---|---| | payload | >=3.67.0 | no | | @wabbit/tome-core | >=1.17.0 <2.0.0 | no |
No other peers. Notably no `lucide-react` — like deals, accounts passes a string (iconName: 'IdCard') to the admin-nav manifest rather than importing an icon component, so it carries no icon-package dependency (verified: zero lucide-react references in src/).
60-second quickstart
The current API is createAccountsLayer(config), returning the Phase-1a collection trio (Account, Membership, Project):
import { buildConfig } from 'payload'
import { createAccountsLayer } from '@wabbit/tome-accounts'
export default buildConfig({
collections: [
...existingCollections, // incl. users + a members identity collection
...createAccountsLayer({ memberSlug: 'members' }),
],
})Phase-1b governance/audit collections are opt-in via config flags, off by default:
createAccountsLayer({
memberSlug: 'members',
auditLog: true, // adds createAuditLogCollection()
approvals: true, // adds createAccountApprovalCollection()
invitations: true, // adds createAccountInvitationCollection()
})Invitations (opt-in)
invitations: true adds an account-invitations collection and unlocks the invitation helpers. Pass an object to tune it: { slug, ttlDays /* default 7 */, ownerRoles /* default ['owner'] */, roles /* extra Membership role options */ }.
const { rawToken, invitation } = await createAccountInvitation(payload, {
account, email, role: 'editor', invitedBy: memberId, req, // req = the owner's request
}) // rawToken is returned ONCE; only its SHA-256 digest is stored
await validateAccountInvitation(payload, token) // { status: 'valid' | 'expired' | 'revoked' | 'accepted' | 'invalid' }
await acceptAccountInvitation(payload, { rawToken: token, member: { id, email } })
await resendAccountInvitation(payload, { invitationId, req }) // rotates the token, old link dies
await revokeAccountInvitation(payload, { invitationId, req })Roles are consumer-defined: any role string is accepted (register its permissions with registerAccountRole). Only a seat holding one of ownerRoles can create, revoke, resend or list invitations; accepting is the token-holder path. Accepting is single-use (atomic compare-and-set), checks expiry and revocation, and by default requires the accepting member's email to equal the invited address; pass requireEmailMatch: false when you deliver the link yourself and vouch for the recipient. A removed seat is re-admitted on its existing row (removed -> invited -> active). The built-in owner role is refused unless allowOwnerRole: true. Delivery (email, copy-link) and the routes are yours.
createAccountsLayer also calls registerAccountPermissions() to register the account permission namespace into the shared engine — idempotent, since the registration module also self-registers on import.
The accounts slug and better-auth
With defaults, this layer's Account collection and better-auth's own account model both want the slug accounts. The platform's cross-layer slug guard refuses to boot when that happens (LayerCollectionSlugCollisionError), so you will see it at startup, not as mixed data. Resolve it on ONE side:
- rename this layer's collection:
createAccountsLayer({ memberSlug: 'members', slugs: { accounts: 'client-accounts' } }), or - rename better-auth's model in
@wabbit/tome-core's better-auth factory:internalModelNames: { account: 'authAccount' }→ slugauthAccounts.
Renaming both only moves the collision. If the site is already live, pick the side whose collection is empty, or migrate the rows first — see the migration note on the auth factory option in @wabbit/tome-core.
API surface
Single export subpath (. only) — the simplest exports map of the eight packages in this batch.
| Group | Exports | |---|---| | Layer factory | createAccountsLayer | | Collection factories | createAccountCollection, createMembershipCollection, createProjectCollection | | Types | ACCOUNTS_DEFAULT_SLUGS, AccountsSlugs, AccountsIdentityConfig, BaseAccountsCollectionConfig, AccountCollectionConfig, MembershipCollectionConfig (adds extraRoles), ProjectCollectionConfig, AccountsLayerConfig, AccountBilling | | Billing refs | resolveAccountBillingRefs (+ AccountBillingRefsInput, AccountBillingRefsSource, ResolvedAccountBillingRefs) — see Billing refs | | Permission taxonomy | ACCOUNT_PERMISSIONS, ACCOUNT_SUPER_PERMISSIONS, ALL_ACCOUNT_PERMISSION_VALUES, ACCOUNT_ROLES, ACCOUNT_ROLE_PERMISSIONS, ACCOUNT_OWNER_ROLE, registerAccountRole, getRolePermissionValues (+ AccountPermissionKey, AccountPermissionValue, AccountRole) | | Permission registration | registerAccountPermissions, buildAccountSuperPermissionMap | | Authority resolver (Phase 1a) | getAccountAuthority, accountHasPermission, authoritySatisfies, accountPermission, accountMember, accountScopedRead (+ AccountAuthority, AccountAuthorityOptions, AccountAccessConfig, and PlatformAdminOptions — the operator bypass that AccountAccessConfig extends: platformAdminRoles (default ['super-admin', 'admin'], read from the flat user.role), platformAdminCapability (default 'accounts:manage', checked with canAsync, so super-admin passes without a grant) and platformAdminBypass (default true; false gives strict tenant scoping with no operator bypass)) | | Per-project ABAC (Phase 1b) | canActOnProject, authorityCanActOnProject, hasAccountWideProjectReach, projectScopedReadForAccount, projectScopedFieldAccess, projectScopedQuery (+ ProjectScopedAccessConfig) | | Membership lifecycle (Phase 1b) | MEMBERSHIP_STATUSES, ACTIVE_MEMBERSHIP_STATUSES, MEMBERSHIP_TRANSITIONS, isActiveMembershipStatus, canTransitionMembership (a removed seat may move to invited), transitionMembership, acceptInvitation, suspendMembership, reactivateMembership, MembershipTransitionError, executeMembershipOffboarding (+ 6 related types) | | Approval pipeline (Phase 1b, optional) | ACCOUNT_APPROVAL_REQUEST_SLUG, APPROVAL_REQUEST_STATUSES, createAccountApprovalCollection, submitAccountRequest, approveAccountRequest, denyAccountRequest (+ 5 related types) | | Audit (Phase 1b, optional) | ACCOUNT_AUDIT_LOG_SLUG, createAuditLogCollection, writeAudit (fire-and-forget per source comment), auditStatusRoleChangeHook (+ AuditEntry, AuditLogCollectionConfig, WriteAuditOptions, AuditChangeHookConfig) | | Invitations (optional) | createAccountInvitationCollection, createAccountInvitation, validateAccountInvitation, acceptAccountInvitation, revokeAccountInvitation, resendAccountInvitation, normalizeInvitationEmail, generateInvitationToken, hashInvitationToken, safeEqualHex, INVITATION_TOKEN_BYTES, ACCOUNT_INVITATION_DEFAULT_SLUG, ACCOUNT_INVITATION_STATUSES, ACCOUNT_INVITATION_DEFAULT_TTL_MS (+ AccountInvitationStatus, AccountInvitationCollectionConfig, InvitationHelperOptions, InvitationFailureCode, InvitationResult, AccountInvitationDoc, CreateAccountInvitationArgs, ValidateInvitationResult, InvitationAcceptor, AcceptAccountInvitationArgs, AcceptInvitationResult) | | Hooks | autoSlugHook, assertUniqueMembership, assertUniqueProjectSlug |
Billing refs
The Account collection carries two shapes for provider billing refs:
- Legacy (deprecated):
stripeCustomerId/stripeSubscriptionId— Stripe-named, no provider tag. Kept for backward compatibility; not removed, since a rename would be a platform-wide breaking migration to unblock one consuming layer. - Current: an additive
billinggroup —{ provider: 'stripe' | 'authorizenet' | 'manual' | string, customerRef, subscriptionRef, updatedAt }.providerdefaults to'stripe'and is validated non-empty. Both the group and the legacy fields are admin-readOnly (server-stamped, never owner-editable).
Read either shape through resolveAccountBillingRefs(account), which reads billing first (when it carries a customerRef or subscriptionRef) and falls back to the legacy Stripe fields (tagged provider: 'stripe') when billing is unpopulated:
import { resolveAccountBillingRefs } from '@wabbit/tome-accounts'
const refs = resolveAccountBillingRefs(account)
// { provider, customerRef: string | null, subscriptionRef: string | null, source: 'billing' | 'legacy' | 'none' }source tells the caller which shape answered — useful for a migration sweep that wants to know how many accounts still only have the legacy shape. A pure function: no Payload import, safe to call against a fixture in a test.
Server / client posture
Fully server-side: Payload collection factories, access/authority resolvers, and permission registration — no React anywhere in the package. sideEffects: ["./dist/registerAccountPermissions.*"] — the permission-registration module self-registers on import and must survive tree-shaking, the same pattern as @wabbit/tome-org's registerOrgPermissions.
Links
Extending this package
Per the barrel's own docblock, this package stops at Phase 1a (Account/Membership/Project + authority resolver + access factories) with Phase 1b (lifecycle/governance/audit, already present and gated behind auditLog/approvals config flags). Phases 2-4 — the token model, the account-scoped proxy, account-scoped billing, and the /account dashboard — are explicitly not part of this package; the dashboard lives in wabbit-site-core. Custom account roles register via registerAccountRole rather than extending ACCOUNT_ROLES directly.
Exports
@wabbit/tome-accounts
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.
ff684db: `createAccountsLayer` now accepts `invitations: true`, adding an `account-invitations` collection and helpers to invite, accept, revoke and resend email invitations. Invite tokens are generated from a CSPRNG, returned once and stored only as a SHA-256 digest. Accepting is single-use (atomic compare-and-set), checks expiry (7-day default, configurable with `ttlDays`) and revocation, and requires the invited email unless `requireEmailMatch: false`. Roles are consumer-defined; only seats holding the configured `ownerRoles` can create, revoke, resend or list invitations. A removed seat can now be re-invited: the seat machine allows `removed -> invited`, and accepting re-admits the existing row. `createMembershipCollection` gains an `extraRoles` option for consumer-defined roles. Every option is off by default and existing behaviour is unchanged.
- ff684db: `createAccountsLayer` now accepts `invitations: true`, adding an `account-invitations` collection and helpers to invite, accept, revoke and resend email invitations. Invite tokens are generated from a CSPRNG, returned once and stored only as a SHA-256 digest. Accepting is single-use (atomic compare-and-set), checks expiry (7-day default, configurable with `ttlDays`) and revocation, and requires the invited email unless `requireEmailMatch: false`. Roles are consumer-defined; only seats holding the configured `ownerRoles` can create, revoke, resend or list invitations. A removed seat can now be re-invited: the seat machine allows `removed -> invited`, and accepting re-admits the existing row. `createMembershipCollection` gains an `extraRoles` option for consumer-defined roles. Every option is off by default and existing behaviour is unchanged.
- e827eb3: Security: owner seats and account billing state are now protected over the public API. Access-checked writes that used to succeed are now refused. `MANAGE_OWNER` was defined but never enforced, so any role with `MANAGE_MEMBERS` (`admin` by default) could `PATCH` its own seat to `owner` and then remove the real owner. Membership writes now need `MANAGE_OWNER` to create or update a seat to an owner role, to change, suspend, remove or delete a seat that holds one, and a seat can no longer be moved to another account. The last active owner seat cannot be removed, suspended, demoted or deleted through the API. Owner roles come from the new `ownerRoles` option on `createMembershipCollection`; `createAccountsLayer` reuses `invitations.ownerRoles` for it (default `['owner']`). `MANAGE_MEMBERS` behaviour for non-owner seats is unchanged, and so are platform operators. On the Account collection, `MANAGE_ACCOUNT` no longer covers lifecycle and billing state. `billing`, `stripeCustomerId` and `stripeSubscriptionId` are refused for every access-checked create and update, `status` is writable only by platform operators, and `ownerMember` needs `MANAGE_OWNER`. `name`, `slug` and `type` stay editable. Server code that writes these fields (billing webhooks, provisioning, lifecycle helpers) must use `overrideAccess: true`, which bypasses all of the above. Create-time gaps closed on the same fields. `billing` (the group and every subfield, including `billing.provider`) is now locked on create as well as update, so an API caller can no longer POST an account with `billing.provider: 'manual'` and have entitlement checks read it as paid; a denied provider falls back to the `'stripe'` default. `status` on create is platform-operator-only (anyone else gets the `'active'` default), and `ownerMember` on create must be the creating user's own member profile, or the caller must be a platform operator. A create naming another member is refused by the field's `required` check. Provisioning flows that create accounts on someone's behalf must use `overrideAccess: true`.
0fad0e7: README: document the default `accounts` slug collision with better-auth's `account` model — what the boot-time guard reports and the two one-sided resolutions (`slugs.accounts` on this layer, or renaming the better-auth model in tome-core's auth factory).
- 0fad0e7: README: document the default `accounts` slug collision with better-auth's `account` model — what the boot-time guard reports and the two one-sided resolutions (`slugs.accounts` on this layer, or renaming the better-auth model in tome-core's auth factory).
d99744d: The platform-operator bypass now reads legacy role slugs with core's role reader instead of the deprecated `checkRole`; who bypasses account scoping is unchanged. An empty `platformAdminRoles` still disables the legacy leg. Internal: collection slugs are typed through the shared `typedSlug()` helper instead of inline casts.
- d99744d: The platform-operator bypass now reads legacy role slugs with core's role reader instead of the deprecated `checkRole`; who bypasses account scoping is unchanged. An empty `platformAdminRoles` still disables the legacy leg. Internal: collection slugs are typed through the shared `typedSlug()` helper instead of inline casts.
30bdd74: `autoSlugHook` is now a typed delegate to core's `autoSlugFieldHook`, with an identical body. Two domain layers cannot import each other, so the shared body moved down to core.
- 30bdd74: `autoSlugHook` is now a typed delegate to core's `autoSlugFieldHook`, with an identical body. Two domain layers cannot import each other, so the shared body moved down to core.
- 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`.
bc386c0: Fix a silent Payload collection-slug collision between `createBetterAuth()` and `createAccountsLayer()` that broke every sign-up on a consumer mounting both at their defaults. **Root cause:** better-auth's internal `account` model and `@wabbit/tome-accounts`' tenant Account collection both default to the Payload slug `accounts`. The collections plugin that assembled them merged the two definitions instead of failing, producing a collection whose required `name`/`slug`/`ownerMember` fields (from tome-accounts) were never supplied by better-auth's own `linkAccount`/`createAccount` calls — every `POST /api/auth/sign-up/email` returned a 500 naming those fields, not the collision that caused them. **The fix (`@wabbit/tome-core`):** - `registerLayer` (`utilities/layerRegistry`) now checks a newly-registering layer's `collections` against every already-registered layer's `collections` and throws a new `LayerCollectionSlugCollisionError` — naming the slug, both claimant layers, and a remedy — the moment two layers claim the same slug, regardless of which two layers or which mount order. Exported alongside a `isLayerCollectionSlugCollisionError` type guard so a layer factory's own try/catch (most exist only to swallow the pre-existing "already registered" HMR throw) can re-throw a genuine collision instead of silently eating it. - `createBetterAuth()` now registers the Payload collection slugs it mounts (`@wabbit/tome-core-auth`) with this guard, and its own try/catch re-throws a collision instead of swallowing it. - New opt-in `internalModelNames.account` on `createBetterAuth()` renames better-auth's internal account model (e.g. `{ account: 'authAccount' }` → Payload slug `authAccounts`) — the auth-side resolution when a collision fires. Default is unchanged (`accounts`), so upgrading never renames an existing consumer's table. A consumer that opts in AFTER going live must rename or migrate the existing `accounts` table/collection first — see the option's JSDoc. **The fix (`@wabbit/tome-accounts`):** `createAccountsLayer()`'s existing `registerLayer` try/catch now re-throws a genuine `LayerCollectionSlugCollisionError` instead of swallowing it as a duplicate-registration no-op. The layer's existing `slugs: { accounts: '<other-slug>' }` override is the accounts-side resolution — pick ONE side, not both. **Backward compatibility:** a consumer mounting only one of the two layers is unaffected — both new tests and the existing suites confirm no throw and identical registered collections. Both defaults (`accounts` on each side) are unchanged; the guard only fires when two layers genuinely collide.
- bc386c0: Fix a silent Payload collection-slug collision between `createBetterAuth()` and `createAccountsLayer()` that broke every sign-up on a consumer mounting both at their defaults. **Root cause:** better-auth's internal `account` model and `@wabbit/tome-accounts`' tenant Account collection both default to the Payload slug `accounts`. The collections plugin that assembled them merged the two definitions instead of failing, producing a collection whose required `name`/`slug`/`ownerMember` fields (from tome-accounts) were never supplied by better-auth's own `linkAccount`/`createAccount` calls — every `POST /api/auth/sign-up/email` returned a 500 naming those fields, not the collision that caused them. **The fix (`@wabbit/tome-core`):** - `registerLayer` (`utilities/layerRegistry`) now checks a newly-registering layer's `collections` against every already-registered layer's `collections` and throws a new `LayerCollectionSlugCollisionError` — naming the slug, both claimant layers, and a remedy — the moment two layers claim the same slug, regardless of which two layers or which mount order. Exported alongside a `isLayerCollectionSlugCollisionError` type guard so a layer factory's own try/catch (most exist only to swallow the pre-existing "already registered" HMR throw) can re-throw a genuine collision instead of silently eating it. - `createBetterAuth()` now registers the Payload collection slugs it mounts (`@wabbit/tome-core-auth`) with this guard, and its own try/catch re-throws a collision instead of swallowing it. - New opt-in `internalModelNames.account` on `createBetterAuth()` renames better-auth's internal account model (e.g. `{ account: 'authAccount' }` → Payload slug `authAccounts`) — the auth-side resolution when a collision fires. Default is unchanged (`accounts`), so upgrading never renames an existing consumer's table. A consumer that opts in AFTER going live must rename or migrate the existing `accounts` table/collection first — see the option's JSDoc. **The fix (`@wabbit/tome-accounts`):** `createAccountsLayer()`'s existing `registerLayer` try/catch now re-throws a genuine `LayerCollectionSlugCollisionError` instead of swallowing it as a duplicate-registration no-op. The layer's existing `slugs: { accounts: '<other-slug>' }` override is the accounts-side resolution — pick ONE side, not both. **Backward compatibility:** a consumer mounting only one of the two layers is unaffected — both new tests and the existing suites confirm no throw and identical registered collections. Both defaults (`accounts` on each side) are unchanged; the guard only fires when two layers genuinely collide.
1339d61: Additive `billing` group on the Account collection (`provider`, `customerRef`, `subscriptionRef`, `updatedAt`), plus `resolveAccountBillingRefs(account)` to read it. `Account.ts`'s `stripeCustomerId`/`stripeSubscriptionId` carry no provider tag, so a consumer resolving entitlements against a second billing provider (Authorize.net, a staff-stamped `manual` invoice) cannot tell which provider those refs belong to. The legacy fields are kept and marked deprecated (a rename would be a platform-wide breaking migration to unblock one consuming layer) rather than removed; `resolveAccountBillingRefs` reads `billing` first and falls back to the legacy Stripe-named fields, tagging the result `source: 'billing' | 'legacy' | 'none'` so a caller — or a future migration sweep — can tell which shape answered. New export, hence the minor.
- 1339d61: Additive `billing` group on the Account collection (`provider`, `customerRef`, `subscriptionRef`, `updatedAt`), plus `resolveAccountBillingRefs(account)` to read it. `Account.ts`'s `stripeCustomerId`/`stripeSubscriptionId` carry no provider tag, so a consumer resolving entitlements against a second billing provider (Authorize.net, a staff-stamped `manual` invoice) cannot tell which provider those refs belong to. The legacy fields are kept and marked deprecated (a rename would be a platform-wide breaking migration to unblock one consuming layer) rather than removed; `resolveAccountBillingRefs` reads `billing` first and falls back to the legacy Stripe-named fields, tagging the result `source: 'billing' | 'legacy' | 'none'` so a caller — or a future migration sweep — can tell which shape answered. New export, hence the minor.
4aeedad: One `LayerFactoryConfig` every layer factory's config extends, and one factory verb. Fourteen layer packages end in the same one call a consumer writes into `payload.config.ts`, and no two agreed on what `config` may contain: full seam vocabulary in three (org, lms, ledger), partial in six, NONE in six (2026-09-01 sale-readiness audit §5.3). A site that learned `adminGroup` from org and `hooks` from sc discovered, package by package, that six factories accept neither — not because the seam had been rejected, but because nothing said it existed. **New in core (a NEW exports-map subpath, hence the minor):** `@wabbit/tome-core/utilities/layerFactoryConfig` exports the `LayerFactoryConfig` interface — `adminGroup`, `access` (per-collection override map), `hooks` (appended via `mergeHooks`, never replacing), `extraFields`, `fieldOverrides`, `omitFields`, `fieldOrder`, `slugs` — and `applyLayerFactoryConfig(collections, config)`, which honours the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `create*Layer` config now extends it.** Twelve extend `LayerFactoryConfig` directly and APPLY it through `applyLayerFactoryConfig` (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, `adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted. Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). **economy** and **gamification** both declare `@wabbit/tome-core` as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches `registerLayer` through a lazy `require()` in a try/catch for exactly this reason. `applyLayerFactoryConfig` is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by `assert:no-forked-primitives`. The trigger is stated: the day core becomes a required peer, delete the note and add one line. Both take the type via `import type`, which is erased at runtime. Two packages drop seams EXPLICITLY rather than accept-and-ignore. **chrome** extends `Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>` because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against `CollectionConfig`, and chrome's slugs already have direct per-surface knobs (`header.slug`, `footer.slug`) a parallel map could contradict. The one seam it keeps, `adminGroup`, IS applied: globals carry `admin.group` exactly as collections do. **rpg** extends `Omit<LayerFactoryConfig, 'access'>` because `CharacterSheetsConfig` is a single collection's config that doubles as the layer factory's config, and its own `access` already means "this collection's access object" — one level shallower than the base's slug-keyed map. Two meanings under one name is the confusion this interface exists to end. **Factory-verb convergence.** Three verbs were live. `createWorkflowLayer(config?)` is new in `@wabbit/tome-workflow` (a new export — hence the minor) and returns a spreadable, deliberately EMPTY `CollectionConfig[]`: this layer is an engine, not a collection set, so the empty array is the honest answer and lets `...createWorkflowLayer()` compose exactly like every sibling. Its `WorkflowLayerConfig` omits every seam for the same reason, and exists as the stable place a real option will land. `createGamificationLayer` and `createRpgLayer` are pure aliases of `registerGamificationLayer` / `registerRpgLayer`. `initWorkflow`, `registerGamificationLayer` and `registerRpgLayer` are all `@deprecated` with sunset at each package's next major; none is removed. **Forcing function:** `scripts/assert-layer-factory-contract.mjs` + `pnpm assert:layer-factory-contract`, wired into `platform-discipline.yml` after `assert:layer-version` (source reading only, pre-build). Every exported `create*Layer` must take a config parameter whose type resolves to `LayerFactoryConfig` — through `extends`, an intersection, or an explicit `Omit<…>` — with verb aliases followed to their `register*`/`init*` target. Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. `docs/guides/create-a-new-layer-package.md` gains a "The factory contract" section stating the rule and the three permitted responses. Three factories are ALLOWLISTED with a reason each: `createAiLayer` returns credential wiring and owns no collections, so every seam is meaningless to it; `createFormsLayer` and `createIntakeLayer` are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files. **Peer floors:** accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise `@wabbit/tome-core` to `>=1.14.0 <2.0.0`. The new subpaths do not exist below that, and a too-low floor is how `ERR_PACKAGE_PATH_NOT_EXPORTED` reached crowdfund's consumers once already. These are marked `patch` because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.
- 4aeedad: One `LayerFactoryConfig` every layer factory's config extends, and one factory verb. Fourteen layer packages end in the same one call a consumer writes into `payload.config.ts`, and no two agreed on what `config` may contain: full seam vocabulary in three (org, lms, ledger), partial in six, NONE in six (2026-09-01 sale-readiness audit §5.3). A site that learned `adminGroup` from org and `hooks` from sc discovered, package by package, that six factories accept neither — not because the seam had been rejected, but because nothing said it existed. **New in core (a NEW exports-map subpath, hence the minor):** `@wabbit/tome-core/utilities/layerFactoryConfig` exports the `LayerFactoryConfig` interface — `adminGroup`, `access` (per-collection override map), `hooks` (appended via `mergeHooks`, never replacing), `extraFields`, `fieldOverrides`, `omitFields`, `fieldOrder`, `slugs` — and `applyLayerFactoryConfig(collections, config)`, which honours the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `create*Layer` config now extends it.** Twelve extend `LayerFactoryConfig` directly and APPLY it through `applyLayerFactoryConfig` (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, `adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted. Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). **economy** and **gamification** both declare `@wabbit/tome-core` as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches `registerLayer` through a lazy `require()` in a try/catch for exactly this reason. `applyLayerFactoryConfig` is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by `assert:no-forked-primitives`. The trigger is stated: the day core becomes a required peer, delete the note and add one line. Both take the type via `import type`, which is erased at runtime. Two packages drop seams EXPLICITLY rather than accept-and-ignore. **chrome** extends `Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>` because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against `CollectionConfig`, and chrome's slugs already have direct per-surface knobs (`header.slug`, `footer.slug`) a parallel map could contradict. The one seam it keeps, `adminGroup`, IS applied: globals carry `admin.group` exactly as collections do. **rpg** extends `Omit<LayerFactoryConfig, 'access'>` because `CharacterSheetsConfig` is a single collection's config that doubles as the layer factory's config, and its own `access` already means "this collection's access object" — one level shallower than the base's slug-keyed map. Two meanings under one name is the confusion this interface exists to end. **Factory-verb convergence.** Three verbs were live. `createWorkflowLayer(config?)` is new in `@wabbit/tome-workflow` (a new export — hence the minor) and returns a spreadable, deliberately EMPTY `CollectionConfig[]`: this layer is an engine, not a collection set, so the empty array is the honest answer and lets `...createWorkflowLayer()` compose exactly like every sibling. Its `WorkflowLayerConfig` omits every seam for the same reason, and exists as the stable place a real option will land. `createGamificationLayer` and `createRpgLayer` are pure aliases of `registerGamificationLayer` / `registerRpgLayer`. `initWorkflow`, `registerGamificationLayer` and `registerRpgLayer` are all `@deprecated` with sunset at each package's next major; none is removed. **Forcing function:** `scripts/assert-layer-factory-contract.mjs` + `pnpm assert:layer-factory-contract`, wired into `platform-discipline.yml` after `assert:layer-version` (source reading only, pre-build). Every exported `create*Layer` must take a config parameter whose type resolves to `LayerFactoryConfig` — through `extends`, an intersection, or an explicit `Omit<…>` — with verb aliases followed to their `register*`/`init*` target. Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. `docs/guides/create-a-new-layer-package.md` gains a "The factory contract" section stating the rule and the three permitted responses. Three factories are ALLOWLISTED with a reason each: `createAiLayer` returns credential wiring and owns no collections, so every seam is meaningless to it; `createFormsLayer` and `createIntakeLayer` are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files. **Peer floors:** accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise `@wabbit/tome-core` to `>=1.14.0 <2.0.0`. The new subpaths do not exist below that, and a too-low floor is how `ERR_PACKAGE_PATH_NOT_EXPORTED` reached crowdfund's consumers once already. These are marked `patch` because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.
- 73081e6: Manifest metadata: `homepage`, `bugs`, `engines`. All 46 publishable manifests were missing the three fields a consumer sees before any code (2026-09-01 sale-readiness audit §6). Metadata only — no source, no build, no runtime change. - `homepage` deep-links to that package README on GitHub (`.../tree/main/packages/<dir>#readme`). Without it a registry page links to the monorepo root and the reader has to guess which of 46 folders they want. - `bugs.url` points at the repo issue tracker, so a paying customer has a place to report a defect that is not email. - `engines.node` is `>=22`, matching the root `engines` and `.nvmrc` set the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use for `node --test`, and a package installed on Node 20 fails at a runtime the installer cannot connect back to the version. The forcing function ships with the change: `scripts/assert-manifest-metadata.mjs` (root `pnpm assert:manifest-metadata`, wired into `platform-discipline.yml` beside `assert:license-metadata`) fails when any publishable manifest lacks `description`, `repository.directory` matching its own folder, `homepage`, `bugs`, `engines.node` equal to the repo floor, `license`, `files` or `sideEffects`. It reported 138 violations before this change and 0 after.
- 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`.
Wave 4 I0 hygiene: dist ships extensioned specifiers (fix-dist-extensions --strict + assert-node-loadable preflight — both dists now raw-Node loadable), registerLayer versions corrected and test-pinned to package.json, accounts' full @wabbit/tome-core/auth barrel import replaced by the auth/guards leaf (the barrel drags the BetterAuth plugin factory).
- Wave 4 I0 hygiene: dist ships extensioned specifiers (fix-dist-extensions --strict + assert-node-loadable preflight — both dists now raw-Node loadable), registerLayer versions corrected and test-pinned to package.json, accounts' full @wabbit/tome-core/auth barrel import replaced by the auth/guards leaf (the barrel drags the BetterAuth plugin factory).
71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.
- 71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.
3087d61: Platform operators can see tenant accounts again — `accountScopedRead`, `accountMember` and `accountPermission` now honour a super-admin bypass. Every gate in `access/accountAccess.ts` asked one question: does the viewer have standing INSIDE this account. That is correct for tenant users and wrong for the operator running the platform, who is a member of no customer account — so a super-admin's own admin panel filtered out every customer's Account, Membership and Project. The failure was silent: the list rendered EMPTY rather than forbidden, which reads as "the row was never created" and sends you debugging a provisioning hook that is working fine. The bypass mirrors `orgScoped` / `vendorScoped` in `@wabbit/tome-core` (capability OR legacy-role, additive), so the platform keeps one way of saying "an operator outranks tenant scoping": - `platformAdminRoles` (default `['super-admin', 'admin']`) — the legacy flat `user.role` leg. - `platformAdminCapability` (default `'accounts:manage'`) — checked via `canAsync`, which hydrates a `users.roles` RELATIONSHIP at depth 1 and treats a `super-admin` slug as an implicit `'*'` grant. Sites whose roles live in the relation (rather than the flat field) are covered by this leg with no configuration. - `platformAdminBypass: false` — opt out entirely, for deployments where operators must not read tenant data. `accountScopedRead` returns `true` rather than a `Where` for an operator, deliberately: an operator must also see rows whose account relationship is null or orphaned, which no `{account: {in: [...]}}` filter would ever match — and those are precisely the rows worth looking at when provisioning has gone wrong. No behaviour change for tenant users: non-admins are scoped exactly as before.
- 3087d61: Platform operators can see tenant accounts again — `accountScopedRead`, `accountMember` and `accountPermission` now honour a super-admin bypass. Every gate in `access/accountAccess.ts` asked one question: does the viewer have standing INSIDE this account. That is correct for tenant users and wrong for the operator running the platform, who is a member of no customer account — so a super-admin's own admin panel filtered out every customer's Account, Membership and Project. The failure was silent: the list rendered EMPTY rather than forbidden, which reads as "the row was never created" and sends you debugging a provisioning hook that is working fine. The bypass mirrors `orgScoped` / `vendorScoped` in `@wabbit/tome-core` (capability OR legacy-role, additive), so the platform keeps one way of saying "an operator outranks tenant scoping": - `platformAdminRoles` (default `['super-admin', 'admin']`) — the legacy flat `user.role` leg. - `platformAdminCapability` (default `'accounts:manage'`) — checked via `canAsync`, which hydrates a `users.roles` RELATIONSHIP at depth 1 and treats a `super-admin` slug as an implicit `'*'` grant. Sites whose roles live in the relation (rather than the flat field) are covered by this leg with no configuration. - `platformAdminBypass: false` — opt out entirely, for deployments where operators must not read tenant data. `accountScopedRead` returns `true` rather than a `Where` for an operator, deliberately: an operator must also see rows whose account relationship is null or orphaned, which no `{account: {in: [...]}}` filter would ever match — and those are precisely the rows worth looking at when provisioning has gone wrong. No behaviour change for tenant users: non-admins are scoped exactly as before.
36e537a: `registerLayer` is now statically imported (forms/intake pattern) instead of lazily `require()`d in ten layer packages' init/register paths. The lazy pattern silently no-ops under Payload's native-ESM CLI (`generate:types` / `generate:importmap`), so layer registration could vanish without error. Packages whose tome-core peer is genuinely optional (economy, ai, gamification) deliberately keep the guarded lazy path; tome-core's `admin-nav/self-register.ts` deliberately keeps its subpath `require()` (documented ESM/CJS dual-cache fix — do not convert).
- 36e537a: `registerLayer` is now statically imported (forms/intake pattern) instead of lazily `require()`d in ten layer packages' init/register paths. The lazy pattern silently no-ops under Payload's native-ESM CLI (`generate:types` / `generate:importmap`), so layer registration could vanish without error. Packages whose tome-core peer is genuinely optional (economy, ai, gamification) deliberately keep the guarded lazy path; tome-core's `admin-nav/self-register.ts` deliberately keeps its subpath `require()` (documented ESM/CJS dual-cache fix — do not convert).
- 36e537a: Every package now declares an explicit `sideEffects` field (38 added; motion/engine/forms already correct). Registration-bearing modules (render files' `registerRenderer`, `blocks/*/index.ts` `defineBlock` self-registration, widget `register.ts` files, productHooks, permission self-registrations, print templates, chrome built-in variants) are listed so bundlers can tree-shake everything else WITHOUT dropping import-time registrations — previously the field was unset, which blocked cross-module tree-shaking through the barrels entirely. Never blanket `false` on a package with registration or CSS.
dca85a3: Core runtime-floor sweep: each package's `@wabbit/tome-core` peer floor now matches the newest core runtime export it actually imports, instead of the platform-wide `>=1.0.0` baseline from the original peer-range sweep. The stale floors let npm silently install a package next to a core version missing a module it runtime-imports, producing a hard `next build` failure at import time (reproduced 2026-07-11: tome-starter locked core 1.0.12 + admin 0.6.3 — `isAdminNavDomain` does not exist in core 1.0.x, where `registry/adminNav` was type-only). - `@wabbit/tome-admin` → `>=1.3.0 <2.0.0` — `nav/manifestResolver` runtime-imports `isAdminNavDomain` from `registry/adminNav`, first shipped as a runtime export in core 1.3.0 (Sidebar v2 Wave 0, d8ff1b2). - `@wabbit/tome-deals` → `>=1.1.0 <2.0.0` — runtime-imports `auth/repScoping` (`buildRepWhereClause` et al.) and `utilities/normalize` (`normalizeEmail`), both introduced in core 1.1.0 (consolidation pass, a9801fe). - `@wabbit/tome-accounts` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`roleSatisfiesPermission`, permission registration), introduced in core 1.2.0 (platform permission engine, 9238072). - `@wabbit/tome-org` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`checkPermissionHierarchical` et al.). - `@wabbit/tome-sc` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` across access helpers and military collections. Same defect class as the `tome-crm` floor raise to `>=1.1.0` (b027075); `tome-crm` is already correct and unchanged here.
- dca85a3: Core runtime-floor sweep: each package's `@wabbit/tome-core` peer floor now matches the newest core runtime export it actually imports, instead of the platform-wide `>=1.0.0` baseline from the original peer-range sweep. The stale floors let npm silently install a package next to a core version missing a module it runtime-imports, producing a hard `next build` failure at import time (reproduced 2026-07-11: tome-starter locked core 1.0.12 + admin 0.6.3 — `isAdminNavDomain` does not exist in core 1.0.x, where `registry/adminNav` was type-only). - `@wabbit/tome-admin` → `>=1.3.0 <2.0.0` — `nav/manifestResolver` runtime-imports `isAdminNavDomain` from `registry/adminNav`, first shipped as a runtime export in core 1.3.0 (Sidebar v2 Wave 0, d8ff1b2). - `@wabbit/tome-deals` → `>=1.1.0 <2.0.0` — runtime-imports `auth/repScoping` (`buildRepWhereClause` et al.) and `utilities/normalize` (`normalizeEmail`), both introduced in core 1.1.0 (consolidation pass, a9801fe). - `@wabbit/tome-accounts` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`roleSatisfiesPermission`, permission registration), introduced in core 1.2.0 (platform permission engine, 9238072). - `@wabbit/tome-org` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`checkPermissionHierarchical` et al.). - `@wabbit/tome-sc` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` across access helpers and military collections. Same defect class as the `tome-crm` floor raise to `>=1.1.0` (b027075); `tome-crm` is already correct and unchanged here.
bed3f90: Docs-manifest emitter pipeline (W3 ship-readiness). `@wabbit/tome-blocks-core` now ships a standalone Node ESM CLI at `scripts/emit-docs-manifests.mjs` that emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free `<pkg>/meta` block-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild: `node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>`. To let the emitter import block metadata uniformly without dragging Payload config into a build script, the `./meta` payload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block's `BlockMeta` was relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed. Every supported-core package additionally adds `CHANGELOG.md` to its published `files` array so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.
- bed3f90: Docs-manifest emitter pipeline (W3 ship-readiness). `@wabbit/tome-blocks-core` now ships a standalone Node ESM CLI at `scripts/emit-docs-manifests.mjs` that emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free `<pkg>/meta` block-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild: `node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>`. To let the emitter import block metadata uniformly without dragging Payload config into a build script, the `./meta` payload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block's `BlockMeta` was relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed. Every supported-core package additionally adds `CHANGELOG.md` to its published `files` array so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.
598611c: Fix non-atomic approval CAS (double-execution race). `approveAccountRequest` and `denyAccountRequest` claimed a pending request via `payload.update({ where: { id, status: 'pending' } })`, which the `@payloadcms/db-mongodb` adapter implements as FIND-then-`updateMany` (read-then-write) — so two concurrent approvers both read `pending` and both win, double-running the `execute` callback. The claim now uses the adapter's atomic `Model.findOneAndUpdate({ _id, status: 'pending' } -> next)` (the same primitive the adapter uses for its own job-queue claims), with a documented non-atomic bulk-update fallback for non-Mongo adapters. The test fake was upgraded to model the adapter's real (non-atomic bulk update vs. atomic findOneAndUpdate) behavior, giving the concurrent-approver test genuine teeth.
- 598611c: Fix non-atomic approval CAS (double-execution race). `approveAccountRequest` and `denyAccountRequest` claimed a pending request via `payload.update({ where: { id, status: 'pending' } })`, which the `@payloadcms/db-mongodb` adapter implements as FIND-then-`updateMany` (read-then-write) — so two concurrent approvers both read `pending` and both win, double-running the `execute` callback. The claim now uses the adapter's atomic `Model.findOneAndUpdate({ _id, status: 'pending' } -> next)` (the same primitive the adapter uses for its own job-queue claims), with a documented non-atomic bulk-update fallback for non-Mongo adapters. The test fake was upgraded to model the adapter's real (non-atomic bulk update vs. atomic findOneAndUpdate) behavior, giving the concurrent-approver test genuine teeth.