Core
FoundationFoundation package for the Tome platform — auth, access control, field factories, data utilities, and infrastructure presets for Payload CMS + Next.js sites; every other @wabbit/tome-* layer builds on this one.
npm install @wabbit/tome-coreOverview
@wabbit/tome-core
Foundation package for the Tome platform — auth, access control, field factories, data utilities, and infrastructure presets for Payload CMS + Next.js sites. Every other @wabbit/tome-* layer builds on this one.
Installation
Published to the private registry at https://npm.wabbit.com/:
npm install @wabbit/tome-core --registry=https://npm.wabbit.com/Required peer dependencies: payload >=3.67.0, next >=15.0.0, react >=19.0.0, @payloadcms/richtext-lexical >=3.67.0. Optional peers, each gating one feature: posthog-js >=1.0.0 (/infra/posthog); the better-auth stack better-auth >=1.6.0 + @better-auth/passkey >=1.6.0 + @delmaredigital/payload-better-auth >=0.7.0 (auth/betterAuthFactory); and the optional plugins @payloadcms/plugin-form-builder >=3.67.0 / @payloadcms/plugin-nested-docs >=3.67.0 / @payloadcms/plugin-redirects >=3.67.0 / @payloadcms/plugin-search >=3.67.0 / @payloadcms/plugin-seo >=3.67.0 (/infra/plugins' basePlugins).
Peer dependencies
Generated from package.json#peerDependencies (the README gate fails if this table and the manifest disagree).
| Peer | Range | Required | |---|---|---| | @better-auth/passkey | >=1.6.0 | no (optional) | | @delmaredigital/payload-better-auth | >=0.7.0 | no (optional) | | @payloadcms/plugin-form-builder | >=3.67.0 | no (optional) | | @payloadcms/plugin-nested-docs | >=3.67.0 | no (optional) | | @payloadcms/plugin-redirects | >=3.67.0 | no (optional) | | @payloadcms/plugin-search | >=3.67.0 | no (optional) | | @payloadcms/plugin-seo | >=3.67.0 | no (optional) | | @payloadcms/richtext-lexical | >=3.67.0 | yes | | better-auth | >=1.6.0 | no (optional) | | next | >=15.0.0 | yes | | payload | >=3.67.0 | yes | | posthog-js | >=1.0.0 | no (optional) | | react | >=19.0.0 | yes |
Module Surface
All modules are subpath exports (no root barrel). Highlights:
- `/access` — collection access guards (
authenticated,admins,adminsOrSelf) plusorgScoped/vendorScopedmulti-tenant wrappers with force-overwrite hooks - `/auth/rbac` — role + permission checks; use
hasAdminAccessAsyncinside Payload access rules for a flat admin gate — sync variants require pre-populated roles and fail closed without them.checkRoleAsync(and every other flat checker in this module) is@deprecated: the one runtime-gate API for new code iscan/canAsyncfrom/auth/capabilities - `/auth/capabilities` — capability engine (
can/canAsync,defineCapabilities,grantCapabilities,seedTomeCoreCapabilities);/auth/capabilities/clientships the React hooks (useCapability,CapabilityGate) - `/auth/betterAuthFactory` —
createBetterAuth()plugin pair including the customSession enricher that populatesuser.role[]/activeOrganizationId/orgRole(required byorgScoped) - `/fields/slug`, `/fields/link`, `/fields/defaultLexical` — field factories with consumer slug/naming overrides
- `/data/errors`, `/data/cacheTags` — server-action error hierarchy; cache-tag builders +
safeRevalidateTag(safe outside Next.js request contexts) - `/content/pageBuilder`, `/content/richTextRenderer` — layout field +
RenderBlocks; Lexical → JSX renderer - `/identity` —
createMemberCollection(see "Hosting an existing member table" below for the seam options),resolveMemberFromSession(request-scoped cache) - `/hooks/mergeHooks` — hook-array append utility (per-key, not a wholesale spread) shared by every factory that accepts
config.hooks - *`/lms/`** — LMS collections, hooks, and guards (data-layer filters; enrollment gating is currently frontend-owned — see guards' jsdoc)
- `/infra` —
healthRoute,validateEnv,basePlugins, PostHog provider + rewrites - `/config/siteIdentity` —
initSiteConfig()(call once, early, in the app entry) +getSiteConfig() - `/registry`, `/utilities/layerRegistry` — layer detection + slot claims (register via lazy
require(), never static import — ESM/CJS hold separate caches) - `/media/cdnConfig` — Bunny CDN storage + Next image patterns
- *`/gdpr/`** — cookie consent + data-request surfaces
- `/verification` — external-profile-verification primitives:
createProfileVerificationFlow(HMAC ownership-proof codes + signed tokens),createDistributedRateLimiter(Redis-shaped semaphore, injected client),createResilientFetcher(retry/backoff, explicitretryOnstatus policy — default429/>=500,Retry-Afterhonored — never throws on HTTP status),ExternalProfileAdapter<TProfile>,createProfileCacheCollection+createProfileCacheInvalidator, andrunProfileReconcilewith Ruling AcreateProfileReconcileTask/createProfileReconcileEndpoint - `/field-reports` — subject watchlist + scored risk assessment + free-text field reports:
createFieldReportCollection,createSubjectAssessmentCollection(stored field names fully overridable viafieldNames— zero migration for a consumer with an existing table),createWatchlistGlobal(category → score-weight list; pass the SAMEcategoriesarray tocreateRiskScorer; stored field names fully overridable viafieldNames.{organizations,externalId,name,category,notes}— VNGD'sorgs/sidarray+id names, not just the id —externalIdFieldkept as a deprecated alias),createRiskScorer(score/assessAndStore, case-insensitive upsert, injectable weights/bands/thresholds;watchlistFieldNamesreads the watchlist global through the SAME namescreateWatchlistGlobalwas given — pass one resolved config to both;flagNamesoverrides every emitted flag string, includingcategoryFlag: (categoryValue) => stringfor the<category>_orgmapping, default already VNGD-compatible) +inputFromProfile(bridges anExternalProfileAdapterfetch into aRiskScoreInput),createReassessRiskTask/createReassessRiskEndpoint(Ruling A cache-only rescore — re-reads the watchlist, never re-fetches externally; also takesflagNamesso a prior row's flags are recognised under the SAME scorer config), andregisterFieldReportsGdpr(reportsnull-refonreportedBy; assessmentsredacthandle+notes ONLY on a matching erasure identity, score row kept — see the module'sgdpr.tsheader for why that isretain-with-basis in effect, not literallymode: 'retain';reports/assessmentseach takefalseto skip registering that collection — e.g. a consumer already registers it elsewhere at a pinned position — or{ phase?, order? }to reposition it, instead of relying on a later registration silently overwriting an earlier one by slug)
- `/field-reports`, Locations wave (2026-09-05) — a SUBJECT-SCOPED report shape alongside the handle-scoped one:
createLocationReportCollection(one required option,subjectRelationTo; injectablestatuses/transitionTable/tiers, the full house seam set —extraFields→fieldOverrides(rename vianame) →omitFields→fieldOrder, per-verbaccessinjection merged over the defaults one key at a time,mergeHooksfor consumer hooks,labels, relation slugs,slug). The donor's singlebeforeChangeis split into five named built-ins —statusForcing,authorStamping,contentEditGate,transitionGate,tierDerivation— each individually opt-out-able viabuiltInHooks. Read access is built fromresolveViewerTier(req)plus an optionalgrantsResolver(req, subjectId), and fails CLOSED on a viewer tier outsidetiers.effectiveReportTier(tiers, subjectTier, reportVisibility, fallback?)andstricterTiership as pure, Payload-free leaves — a report may be more restrictive than its subject, never less. The lifecycle table contract lives in the same subpath (ReportTransitionTable,resolveReportTransition,findReportTransitions,notReportSubmitter) and is structurally identical to@wabbit/tome-workflow's, so a realWorkflowTransitionTableassigns with no cast and that package's ownresolveTransitioncan be injected viatransitionResolver; core declares its own copy rather than importing upward into a layer that already depends on core. Server API:transitionReport,reDeriveTier,markStale— all three drive the collection's OWN gates through the local API with the actor injected onreq.context(LOCATION_REPORT_ACTOR_CONTEXT_KEY,systemTransition), rather than reaching around them.
Additional subpaths
Every other non-/gdpr/*, non-/lms/* exports-map subpath, with a one-line purpose from its source header:
| Subpath | Purpose | |---|---| | /admin-nav/self-register | tome-core's own self-registration into the admin sidebar nav manifest registry — tome-core has no initTomeCore() factory, so this is the canonical way its Content/Settings groups land in the registry | | /authority | Phase A Contract 1: the authority-resolution seam — layers call getAuthority(req), consumers supply the traversal via registerAuthorityResolver | | /notifications | Phase A Contract 3: task-notification emission seam — interface only, no-op default, consumer decides where notifications are stored | | /jobs | Scheduled-work adapters — asPayloadTask/asCronEndpoint wrap one JobHandler for either a Payload task queue or an external cron-triggered endpoint, plus authorizedCronRequest | | /auth/client | Pre-configured BetterAuth client bundle for the browser (createPayloadAuthClient) | | /auth/repScoping | The canonical "rows I own" Where-clause builders for rep-scoped access — admin-bypass, legacy-role fallback, and deny-all semantics in one place | | /auth/constants | Single source of truth for RBAC constants — role slugs, access types, permission keys | | /auth/guards | Async, BetterAuth-compatible access control guards — supersede the sync string-based guards in /access/guards | | /auth/collections/roles (case alias /auth/collections/Roles) | The Roles collection definition, referenced by the Users collection for RBAC — both spellings are real exports-map keys pointing at the same target | | /auth/initRoles | Idempotent default-role seeding utility, run during Payload onInit | | /license | licenseHeartbeat() — report-only license-site heartbeat, run during Payload onInit alongside initRoles; silent, five-second timeout, never blocks boot, opt out via TOME_LICENSE_HEARTBEAT=off | | /auth/jobs/cleanup-sessions | Removes expired BetterAuth sessions + stale verification tokens; CRON_SECRET-protected | | /auth/jobs/cleanup-unverified | Removes user accounts created but never email-verified, past a retention window; CRON_SECRET-protected | | /auth/permissions | The converged platform permission engine — declarative super-permission hierarchy, per-member override allowlist, sync/async/batched checkers | | /access/checkRole | @deprecated minimal role-checking utility for string-based role fields — use can/canAsync from /auth/capabilities instead | | /access/selfOrAdmin | Access factory allowing admins or the user themselves (supports User/Profile-split sites via a custom resolveUserId) | | /access/scoped | Phase A Contract 2: the Where-returning access contract — the security-grade alternative to boolean-only access helpers, since a boolean-only port of a Where-scoped gate silently widens access (including findByID) | | /access/orgScoped | Wraps a collection config with multi-tenant org isolation: admins bypass, members see/write only rows matching their active org, no active org fails closed, and the orgField relationship is added if absent | | /access/vendorScoped | Wraps a collection config with vendor-ownership checks — vendors manage only documents they own when @wabbit/tome-org's vendor role is enabled; a no-op when org is absent | | /infra/health | healthRoute() — a Next.js route-handler factory returning { status, checks: { db, redis? }, timestamp } for an app/api/health/route.ts | | /infra/env-scaffold | SHARED_ENV_VARS + validateEnv() — the shared env-var spec (required vs optional) and the startup validator to call from payload.config.ts/instrumentation.ts | | /registry/productTypeHookRegistry | Closed-shape hook interface for product-type behavior dispatch across layers; consumers register opt-in hook implementations and degrade gracefully when one is absent | | /registry/adminNav | Admin sidebar nav manifest contract — the DOMAINS frozen array and its isAdminNavDomain type-guard, used by layerRegistry.registerLayer for domain validation | | /utilities/normalize | Canonical value normalizers every layer must agree on (e.g. email — the cross-layer join key for CRM/deals/intake/marketing) | | /utilities/timingSafeEqual | Constant-time string comparison for shared-secret / webhook-token verification, avoiding the timing side-channel a naive === or hand-rolled loop leaks | | /utilities/typedSlug | Coerces a runtime string slug to Payload's generated CollectionSlug literal-union type, for factory collections that take slugs via config | | /fields/slug-ui | SlugComponent — the admin UI field component paired with /fields/slug's factory | | /config/assertRelationTargets | Phase A Contract 4: dangling-relation validator — catches a consumer override that renames a slug in only one of two related collections | | /fields/address | postalAddressGroup / postalAddressFields (two vocabularies: legacy-crm, postal), mapAddress — one address shape for crm/deals/fulfillment | | /fields/fieldShape | applyFieldShape, applyFieldOverrides, applyOmitFields, applyFieldOrder, insertFieldsAfter — the consumer field-shape pipeline every layer factory uses | | /fields/selectOptions | resolveSelectOptions with the canonical { mode: 'extend' \| 'replace', options } override (dedupes by value); the older { extend?, replace? } shape in /identity is deprecated | | /registry/createKeyedRegistry | createKeyedRegistry(symbolKey, opts) — globalThis/Symbol.for-anchored keyed registry with duplicate policies; the one way to build a cross-module registry | | /utilities/batch | batchWrite (bounded concurrency, per-item error isolation) and batchWriteInChunks (paced) — replaces serial await payload.* loops | | /utilities/layerFactoryConfig | LayerFactoryConfig — the base config every create*Layer factory extends — and applyLayerFactoryConfig | | /utilities/relationId | relationId / relationIdOrThrow — populated-doc-or-id → string id, one implementation |
Hosting an existing member table
createMemberCollection (@wabbit/tome-core/identity) defaults to a small, opinionated shape: a required-unique user relationship, a required displayName + generated slug, avatar, bio. Every option below is additive — omit all of them and you get exactly that shape, unchanged.
To host a member table that predates this factory (imported rows with no linked account yet, a different title field, your own standing lifecycle), reach for:
- `requireUser: false` + `userValidate` — make
useroptional for legacy/imported rows.unique: truestays on regardless; Payload's unique index is sparse for optional fields, so multiplenullrows don't collide. PassuserValidateto conditionally require it (e.g. "optional only whilestanding === 'legacy'"). - `identity: { includeDisplayName, displayNameRequired, titleField, slugSource, slugField }` —
includeDisplayName: false(W6-I4.1) OMITSdisplayNameentirely while KEEPING the factory's slug handling:slugSourcethen defaults totitleFieldinstead of'displayName', and the generated (or your own, viaslugField)slugfield is still emitted.titleFieldis REQUIRED whenincludeDisplayNameisfalse— there's nodisplayNameleft foradmin.useAsTitle/defaultColumnsto fall back to; the factory throwsMemberIdentityConfigErrorat collection-definition time otherwise. This closes the gapskipDefaultIdentitycouldn't: that flag dropsdisplayNameAND the slug pair TOGETHER, with no way to keep a factory-composed slug sourced from your own identifier field.displayNameRequired(ignored whenincludeDisplayNameisfalse) makesdisplayNameoptional while keeping it present.slugSource/slugFieldbehave as before: re-source the generated slug, or supply your ownslugField(...)pair outright (slugField: [...], orslugField: falseto drop it). - `includeClassification` / `includePortrait` / `includeReportsTo` / `includeTimezone` (+ `timezoneField`) / `includeRegion` / `includeJoinDate` (+ `joinDateField`) (W6-I4.1) — six generic member-table fields, each opt-in and default
false(the pre-existing default snapshot is unchanged).includeClassificationemits a'public' | 'classified'select (DEFAULT_MEMBER_CLASSIFICATION_OPTIONS);includePortraitan upload field distinct from the always-onavatar;includeReportsToa self-relationship for chain-of-command data;includeTimezonea plaintextfield (name configurable viatimezoneField, default'timezone') rather than a curated select — the factory doesn't own a timezone-options list;includeRegiona six-value coarse-geography select (DEFAULT_MEMBER_REGION_OPTIONS);includeJoinDateadatefield (name configurable viajoinDateField, default'joinDate'). None of these byte-match any one consumer's existing field shape (different value casings, no curated options list) — they're generic defaults a future consumer can take as-is, rename via the*Fieldoptions where offered, or still fully override viaextraFields. - `standingOptions: true | { extend, replace }` + `standingDefault` — opt into a
standingselect field. Omitted by default;DEFAULT_MEMBER_STANDING_OPTIONSis exported if you want to extend rather than replace it. - `defaultPopulate` / `indexes` — passthrough to the underlying
CollectionConfig, present in the output only when you provide them. - `defaultReadAccess: 'authenticated' | 'self-or-admin' | Access` — choose the
readstrategy without giving up the create/update/delete defaults (which a fullaccessoverride would do). Accepts any PayloadAccessfunction, including one that returns aWhereinstead of a boolean. - Every `config.hooks` key is appended after the factory's built-in hooks, not replaced — via the shared
@wabbit/tome-core/hooks/mergeHooksappend utility. A caller'safterChange(or any other key) composes alongside future built-in hooks on that same key instead of silently dropping them.
See docs/superpowers/specs/2026-08-22-vngd-tome-wave-6-org-plan.md §W6-I4 for the Vanguard members ground truth that motivated this, and the same plan's W6-I4.1 increment for why includeDisplayName and the six generic fields exist: the W6-I5 adapter (Vngd-Site-Core's src/collections/Members/index.ts) found displayNameRequired alone couldn't close the gap skipDefaultIdentity also couldn't, and the factory owned only 4 of Vanguard's 62 members fields.
Consumer Rules of Thumb
- Pass
*Slugconfig options everywhere you renamed a collection — platform code never hardcodes your slugs. - Use async access guards in Payload
access:rules; sync guards are for client/UI code where roles are already enriched. orgScoped/vendorScopedpresupposecreateBetterAuth()'s session enricher; wiring a different auth stack means re-implementing it.
Development (monorepo)
pnpm install
pnpm typecheck
pnpm build # via scripts/build-two-pass.mjs at the repo rootSee the repo root CLAUDE.md for conventions and docs/claude-gotchas.md for the hard-won build/publish gotchas.
Decisions that shaped this package
Full record: docs/specs-map.md → the Business wiki (docs/superpowers/specs/).
- `can`/`canAsync` is the one runtime-gate API for new code — four permission systems (
checkRole, flatcheckPermission, capabilities, hierarchicaleffectivePermissions) coexisted with no stated order; capabilities now sit on top ofeffectivePermissionsandcheckRole/checkPermissioncarry@deprecatedtags, sunset at core 2.0.2026-07-12-tome-r4-convergence-decisions-brief.md(ruling #1) - `core/lms` is deprecated in full, not just the one factory that already carried the tag — the half-tagged state was worse than either extreme; removal at core 2.0 is gated by a CI check for zero repo-wide
@wabbit/tome-core/lms/*importers, and it is explicitly distinct from the still-active@wabbit/tome-lmsdomain package.2026-07-12-tome-r4-convergence-decisions-brief.md(ruling #4) - The admin-nav registry (`DOMAINS`, `isAdminNavDomain`, `LicenseTier`) lives in core, additive to `layerRegistry` — the sidebar needed a validated domain taxonomy and an install-scoped license-tier type that every layer and
@wabbit/tome-admincould share without a circular dependency on the admin package itself.2026-07-06-tome-admin-sidebar-v2-contingent-nav-design.md - The invalid-domain guard warns and coerces in every environment; it never throws — the original dev-throw was reasoned to bite
next devwhen a dependency (not the consumer's own code) shipped an invalid domain, which a consumer cannot fix by editing their own config; hard-fail-on-your-own-misconfig now lives in the opt-innav-doctorcheck instead of aNODE_ENVbranch.2026-07-06-tome-admin-sidebar-v2-contingent-nav-design.md(Amendment A1, 2026-07-07) - Layer-direction is enforced by a machine-checkable `wabbit.layer` field read fresh at ESLint config-load time, not by convention — part of the repo-wide
core < domain < ui < blocks < app < metazone map._audit/2026-07-11-tome-core-react-architecture-audit.md - `/verification`'s `createDistributedRateLimiter` defaults to fail-CLOSED on backend unavailability — Vanguard's original
rsiRateLimiter.tsalways failed open on a Redis outage with no opt-out; that default is inverted here (same Wave 5 reasoning as the GDPR retention lock: a coordination-backend outage silently disabling a rate limit is a worse failure mode than denying a slot for one tick). A consumer that wants the old always-proceed behaviour passesonUnavailable: 'run'explicitly.createResilientFetcheralso diverges from the absorbedscrapeRsiDossier.ts, which retried EVERY non-2xx: it never THROWS on an HTTP status, but it does retry one — governed by an explicitretryOnpolicy defaulting tores.status === 429 || res.status >= 500(a429/5xxis presumed transient; every other 4xx, e.g.404, is returned on the first attempt, since retrying a stable client error wastes the budget for no benefit). A429's numericRetry-Afterheader is honored, capped attimeoutMsso a server-supplied wait can never stall one attempt past its timeout budget.2026-08-22-vngd-tome-wave-2r-residue-extraction-plan.md(§5a decision 1, 2R-I1; overseer refinement on PR review) - `/field-reports`'s `RiskScoreInput.visibility` is a single `'public' | 'hidden' | 'redacted'` enum, not Vanguard's two independent `isHidden`/`isRedacted` booleans — the two states describe the same RSI profile-visibility setting and cannot really co-occur; the ported test matrix's one case asserting a combined 40-point "hostile + hidden + redacted" score is split into two 30-point assertions instead (
scorer.test.ts).createWatchlistGlobal's per-orgcategoryfield does not store a score weight — the weight lives on thecategoriesarray passed to BOTHcreateWatchlistGlobalandcreateRiskScorer; a caller who edits weights in only one place desyncs them.assessAndStore/runReassessRiskalso widen Vanguard'shostileOrgsbehaviour deliberately: EVERY watchlist match is recorded (matchedWatchlist/storedaffiliations), includingweight: 0categories Vanguard's original silently dropped fromhostileOrgsentirely — score/flags still skip anything withweight <= 0.createReassessRiskTaskis narrower than Vanguard'sreassessThreatScores: it rescores from what is already stored on eachsubject-assessmentsrow (no member-scoped profile cache exists in this module) —assessAndStorenow persistsinput.createdAtontofieldNames.subjectCreatedAt(nullable, never overwritten by an omitted/undefinedcreatedAton a later scan) precisely sonew_accountCAN be recomputed on a cache-only pass:runReassessRiskreads it back and re-evaluates the window against the run's own injectablenow, so a subject correctly clearsnew_accountonce enough time has passed even without a fresh fetch. A row whosesubjectCreatedAtisnull(predates this field, or its source never supplied one) simply never has the flag set or cleared by fiat — overseer-ratified refinement, follow-up on PR #329.registerFieldReportsGdprregisterssubject-assessmentsasmode: 'redact', notmode: 'retain', to express §5a decision 2's "retain with basis, redact on request" as one registration:retainin this registry's vocabulary means the row is NEVER touched, which cannot express a conditional;redactdegrades to a no-op (behaviourally identical toretain) whenever the erasure identity's handle doesn't match, and only acts on the one caseretaincannot cover.2026-08-22-vngd-tome-wave-2r-residue-extraction-plan.md(§1b, §4(b), §5a decision 2, 2R-I4) - `/field-reports` closed three gaps the first VNGD adoption attempt hit (2R I4.1), all additive, zero default change: (1)
createWatchlistGlobalhardcoded theorganizationsarray field name with onlyexternalIdFieldoverridable —fieldNames.{organizations,externalId,name,category,notes}now covers every stored field, andcreateRiskScorer's newwatchlistFieldNamesoption reads the watchlist through the SAME names (previously conflated with the unrelatedfieldNames.affiliationExternalIdon the assessment side — a coincidence of matching defaults, not a real coupling); (2)createRiskScorerhardcoded themulti_affiliationflag value where VNGD's characterisedscoreThreat.spec.tsexpectsmulti_org—flagNamesnow overrides every emitted flag string (multiAffiliation/hiddenProfile/redactedProfile/newAccount/manualFlag/categoryFlag), threaded throughscore,assessAndStore, ANDrunReassessRisk(which must recognise a PRIOR row's flags under the same names via the newresolveFlagNamesexport); ascorer.test.ts"Vanguard-compatible config" block ports everyscoreThreat.spec.tscase against this option set, confirming identical scores/flags (one pre-existing divergence carries over: the combined "hostile + hidden + redacted = 40" case, split into two 30-point assertions, sincevisibilityis a single enum here); (3)registerFieldReportsGdpr's two registrations had a fixed phase/order with no opt-out, forcing a consumer with its own pinned registration to rely on "the laterregisterCollectioncall silently overwrites the earlier one by slug" —reports/assessmentseach now takefalse(skip registering that collection) or{ phase?, order? }(reposition it;assessments.phasestill defaults to'pre-identity', needed for its pre-overwrite handle search).2026-08-22-vngd-tome-wave-2r-residue-extraction-plan.md§1b, §5a
Exports
@wabbit/tome-core@wabbit/tome-core/admin-nav/self-register@wabbit/tome-core/access@wabbit/tome-core/access/guards@wabbit/tome-core/access/checkRole@wabbit/tome-core/access/selfOrAdmin@wabbit/tome-core/access/vendorScoped@wabbit/tome-core/access/orgScoped@wabbit/tome-core/access/scoped@wabbit/tome-core/authority@wabbit/tome-core/notifications@wabbit/tome-core/jobs@wabbit/tome-core/auth@wabbit/tome-core/auth/client@wabbit/tome-core/auth/rbac@wabbit/tome-core/auth/repScoping@wabbit/tome-core/auth/constants@wabbit/tome-core/auth/guards@wabbit/tome-core/auth/collections/roles@wabbit/tome-core/auth/collections/Roles@wabbit/tome-core/auth/initRoles@wabbit/tome-core/auth/jobs/cleanup-sessions@wabbit/tome-core/auth/jobs/cleanup-unverified@wabbit/tome-core/auth/betterAuthFactory@wabbit/tome-core/auth/capabilities@wabbit/tome-core/auth/capabilities/client@wabbit/tome-core/auth/permissions@wabbit/tome-core/gdpr@wabbit/tome-core/gdpr/cookieConsent@wabbit/tome-core/gdpr/registry@wabbit/tome-core/gdpr/runErasure@wabbit/tome-core/gdpr/erasureState@wabbit/tome-core/gdpr/retention@wabbit/tome-core/gdpr/compliance@wabbit/tome-core/gdpr/compliance/breach@wabbit/tome-core/gdpr/actions/exportUserData@wabbit/tome-core/gdpr/actions/deleteAccount@wabbit/tome-core/gdpr/components/CookieConsent@wabbit/tome-core/gdpr/components/AnalyticsLoader@wabbit/tome-core/gdpr/components/CookieSettingsButton@wabbit/tome-core/gdpr/seed/legalPages@wabbit/tome-core/gdpr/seed/lexicalHelpers@wabbit/tome-core/data/errors@wabbit/tome-core/data/cacheTags@wabbit/tome-core/hooks/mergeHooks@wabbit/tome-core/fields/slug@wabbit/tome-core/fields/slug-ui@wabbit/tome-core/fields/link@wabbit/tome-core/fields/address@wabbit/tome-core/fields/fieldShape@wabbit/tome-core/fields/selectOptions@wabbit/tome-core/fields/defaultLexical@wabbit/tome-core/config/siteIdentity@wabbit/tome-core/config/assertRelationTargets@wabbit/tome-core/content/pageBuilder@wabbit/tome-core/content/richTextRenderer@wabbit/tome-core/infra/health@wabbit/tome-core/infra/env-scaffold@wabbit/tome-core/infra/posthog@wabbit/tome-core/infra/plugins@wabbit/tome-core/media/cdnConfig@wabbit/tome-core/registry@wabbit/tome-core/registry/productTypeHookRegistry@wabbit/tome-core/registry/adminNav@wabbit/tome-core/registry/createKeyedRegistry@wabbit/tome-core/utilities/batch@wabbit/tome-core/utilities/layerFactoryConfig@wabbit/tome-core/utilities/layerRegistry@wabbit/tome-core/utilities/relationId@wabbit/tome-core/utilities/normalize@wabbit/tome-core/utilities/timingSafeEqual@wabbit/tome-core/utilities/typedSlug@wabbit/tome-core/lms@wabbit/tome-core/lms/collections/lesson@wabbit/tome-core/lms/collections/topic@wabbit/tome-core/lms/collections/module@wabbit/tome-core/lms/collections/course@wabbit/tome-core/lms/collections/enrollment@wabbit/tome-core/lms/collections/lesson-completion@wabbit/tome-core/lms/collections/badge@wabbit/tome-core/lms/collections/achievement@wabbit/tome-core/lms/collections/points@wabbit/tome-core/lms/collections/gradebook-entry@wabbit/tome-core/lms/collections/product@wabbit/tome-core/lms/collections/order@wabbit/tome-core/lms/collections/course-review@wabbit/tome-core/lms/collections/student-note@wabbit/tome-core/lms/collections/certificate@wabbit/tome-core/lms/collections/quiz-attempt@wabbit/tome-core/lms/collections/assignment-upload@wabbit/tome-core/lms/blocks@wabbit/tome-core/lms/blocks/video@wabbit/tome-core/lms/blocks/quiz@wabbit/tome-core/lms/blocks/assignment@wabbit/tome-core/lms/blocks/code@wabbit/tome-core/lms/blocks/note@wabbit/tome-core/lms/access@wabbit/tome-core/lms/guards@wabbit/tome-core/lms/utilities/progress@wabbit/tome-core/lms/utilities/gamification@wabbit/tome-core/lms/utilities/grading@wabbit/tome-core/lms/fields/items@wabbit/tome-core/lms/fields/prerequisites@wabbit/tome-core/lms/fields/suggested-next@wabbit/tome-core/lms/types@wabbit/tome-core/identity@wabbit/tome-core/verification@wabbit/tome-core/field-reports@wabbit/tome-core/license
Changelog
b081304: New `@wabbit/tome-core/license` subpath: `licenseHeartbeat()`, an `onInit`-compatible export (wire it alongside `initRoles`, same pattern) that reports a deployed site's presence to wabbit's licensing desk — D10's exact shape, `{ licenseHash, host, tomeCoreVersion, nodeEnv, families }`, sent once at boot and then daily via an `unref()`'d interval so it never blocks or holds open the process. `licenseHash` is the sha256 of the deploy credential the build installed with, resolved from the same sources npm itself would use (`NPM_TOKEN` env, then the project `.npmrc`'s `//npm.wabbit.com/:_authToken`, then `~/.npmrc`) — the raw token is never sent, and the response is never read. `families` is the distinct `wabbit.family` values of every installed `@wabbit/tome-*` package, read from each one's own `node_modules` manifest and cached per boot. Report-only by contract: there is no runtime denial path, on this end or any other — a heartbeat cannot disable anything. Silent by default (opt out with `TOME_LICENSE_HEARTBEAT=off`), a 5s timeout, and every failure swallowed to at most one `debug`-level log line. `buildHeartbeatPayload(env, opts)` is exported alongside it as a pure, network-free helper for anything that wants to inspect the payload shape without sending it.
- b081304: New `@wabbit/tome-core/license` subpath: `licenseHeartbeat()`, an `onInit`-compatible export (wire it alongside `initRoles`, same pattern) that reports a deployed site's presence to wabbit's licensing desk — D10's exact shape, `{ licenseHash, host, tomeCoreVersion, nodeEnv, families }`, sent once at boot and then daily via an `unref()`'d interval so it never blocks or holds open the process. `licenseHash` is the sha256 of the deploy credential the build installed with, resolved from the same sources npm itself would use (`NPM_TOKEN` env, then the project `.npmrc`'s `//npm.wabbit.com/:_authToken`, then `~/.npmrc`) — the raw token is never sent, and the response is never read. `families` is the distinct `wabbit.family` values of every installed `@wabbit/tome-*` package, read from each one's own `node_modules` manifest and cached per boot. Report-only by contract: there is no runtime denial path, on this end or any other — a heartbeat cannot disable anything. Silent by default (opt out with `TOME_LICENSE_HEARTBEAT=off`), a 5s timeout, and every failure swallowed to at most one `debug`-level log line. `buildHeartbeatPayload(env, opts)` is exported alongside it as a pure, network-free helper for anything that wants to inspect the payload shape without sending it.
- 8fd56ad: New in `@wabbit/tome-core/field-reports` (Locations wave): `createLocationReportCollection`, a SUBJECT-SCOPED report with a reviewed lifecycle and a denormalised read tier — a sibling of `createFieldReportCollection`, not a variant of it. One required option, `subjectRelationTo`; everything else is a seam: `statuses`, `transitionTable`, `tiers`, `initialStatus`, `timestampOnEnter`, the stored field names, and the resolvers for actor, author, subject tier, viewer tier and per-subject grants. Full house seam set — `extraFields` → `fieldOverrides` (rename via `name`) → `omitFields` → `fieldOrder`, per-verb `access` injection merged over the defaults one key at a time, `mergeHooks` for consumer hooks, `labels`, relation slugs, `slug`. The five behaviours the donor collection ran as one 100-line `beforeChange` are split into named built-ins — `statusForcing`, `authorStamping`, `contentEditGate`, `transitionGate`, `tierDerivation` — each individually opt-out-able via `builtInHooks`, so a consumer can replace one without forking the collection. `effectiveReportTier(tiers, subjectTier, reportVisibility, fallback?)` and `stricterTier(tiers, a, b)` ship as pure, Payload-free exports: a report may be MORE restrictive than its subject, never less. The read gate is built from `resolveViewerTier(req)` with an optional `grantsResolver(req, subjectId)`, treats tiers as a cumulative loosest-first ladder, keeps the legacy-row arm for documents written before the derived field existed, and FAILS CLOSED on a viewer tier outside `tiers`. The lifecycle table contract lives in the same subpath — `ReportTransitionTable`, `resolveReportTransition`, `findReportTransitions`, `allowedReportTransitionsFrom`, `notSelfReporter`, `notReportSubmitter` — and is structurally identical to `@wabbit/tome-workflow`'s, so a real `WorkflowTransitionTable` assigns with no cast and that package's own `resolveTransition` can be injected through the `transitionResolver` option. Core declares its own copy rather than importing `@wabbit/tome-workflow`, which already depends on core; the module header records the reasoning and what a future wave should do instead. Lifecycle server API, also on `./field-reports`: `transitionReport` (one report, returns a structured outcome rather than throwing — `transitioned` / `refused` with what WAS reachable / `no-op` / `not-found`), `reDeriveTier` (recompute the denormalised tier after a subject is reclassified; one read per distinct subject, writes only rows that actually changed, fails closed to the strictest tier on an unreadable subject) and `markStale` (batch sweep that puts every row through the transition table and COUNTS refusals instead of forcing them). All three drive the collection's own gates through the Payload local API with the actor injected on `req.context` (`LOCATION_REPORT_ACTOR_CONTEXT_KEY`, `systemTransition`) rather than reaching around them, and the bulk paths go through `batchWrite` + `findPaged` so a per-row failure is isolated and reported instead of aborting the run. No clearance, game-version or intel vocabulary anywhere in the module — every consumer-specific concept is a resolver or a config value.
ce3d12d: Two more primitives the audit found copied across layers, promoted with their forcing function (2026-09-01 sale-readiness audit §5.1, T3(g)). New public surface: - **`@wabbit/tome-core/fields/address`** (new subpath) — `postalAddressGroup(opts)`, `postalAddressFields(opts)`, `mapAddress` / `mapAddressToLegacy`, the `AddressVocabulary` type and the two field-name constants. The same six-field postal block was hand-rolled in FIVE places across three layers (`@wabbit/tome-crm` accounts + contacts, `@wabbit/tome-deals` billing + shipping, `@wabbit/tome-fulfillment` `Addresses`) in TWO INCOMPATIBLE VOCABULARIES: `address1/address2/city/state/zip/country` and `line1/line2/city/region/postalCode/country`. Two vocabularies for one concept guarantees a mapping layer at the CRM → shipping seam, and that mapping did not exist anywhere. **This module does not pick a winner, and no stored field name changes.** Both vocabularies are stored shapes with live rows behind them — renaming `zip` to `postalCode` in crm is a data migration, not a refactor. Every adopter passes the `vocabulary` it already stores. `required` marks the four load-bearing lines only (never `line2`, never `state`/`region` — requiring a state makes the schema US-shaped, which fulfillment's own comment argues against at length). `validateCountry` carries fulfillment's ISO-3166 alpha-2 check and its uppercase-normalising `beforeValidate` hook across verbatim; normalising rather than rejecting lowercase is deliberate, because the shipping rate table matches on this value and a rejected `"us"` teaches nothing. `fieldOverrides` merges per-sub-field changes through the existing `/fields/fieldShape` seam, which is how deals keeps its eight per-field labels and its `'US'` country default without a second copy of the block. `postalAddressFields` exists alongside the group because fulfillment stores the postal lines FLAT at collection top level, interleaved with `recipientName` and `phone`. Wrapping them in a group to reuse the group helper would have been exactly the stored-shape change this promotion refuses to make. - **`@wabbit/tome-core/utilities/relationId`** (new subpath) — `relationId(value): string | null` and `relationIdOrThrow(value, label)`. The audit counted this read in core's deprecated `lms` tree, lms (twelve copies under two names), sc, ledger, crowdfund, fulfillment, lms-ui and an inline ternary in crm. The finding was not the count but **four different return types**: `string | null`, `string | number | null`, `string | undefined`, `string | number | undefined`. Two call sites resolving the same row could disagree about equality — a populated doc's numeric id arriving unstringified next to a bare id string. Core picks `string | null` and stringifies: `String(id)` for both string and number, `null` (never `undefined`) for absent, `null` for an array (a `hasMany` value is the caller's loop, not a silent first-element read). Payload accepts either form in a `where` clause, so ids fed back into a query are unaffected, and `===` between two resolved ids now means what a reader thinks it means. A caller that genuinely needs the id in its stored type should not use this — that is a documented divergence rather than a fifth accidental copy. Forcing function: `scripts/assert-no-forked-primitives.mjs` gains a fourth, STRUCTURAL check. Checks 1–3 compare code, and the address fork is invisible to all three because it is not code — it is five object literals whose sub-field name sets are the same schema typed out by hand. The new check finds every `type: 'group'` field literal, reads the direct sub-field names out of its `fields:` array, and warns when that set COVERS either vocabulary. Coverage rather than equality on purpose: exact equality would have caught none of the five real copies, since crm prepends `name` and deals prepends `name` + `company`. Run on the pre-adoption tree it reports all four group-shaped copies (crm accounts:89, crm contacts:135, deals:192 and :207); on the adopted tree it reports none. `@wabbit/tome-ledger` keeps its own `extractId`, for the same architectural reason as its `mergeHooks` (core is an OPTIONAL peer there) plus a second one: its semantics differ deliberately (`string | undefined`, string-only input). That exemption is recorded as prose above the assert's ALLOWLIST rather than as an entry, because the ALLOWLIST is FILE-granular — an entry for a nine-line function inside a 130-line module would never match and would print as STALE on every run forever, training readers to ignore the stale report. That is the failure mode `assert-test-floor.mjs` already demonstrated for seven weeks.
- ce3d12d: Two more primitives the audit found copied across layers, promoted with their forcing function (2026-09-01 sale-readiness audit §5.1, T3(g)). New public surface: - **`@wabbit/tome-core/fields/address`** (new subpath) — `postalAddressGroup(opts)`, `postalAddressFields(opts)`, `mapAddress` / `mapAddressToLegacy`, the `AddressVocabulary` type and the two field-name constants. The same six-field postal block was hand-rolled in FIVE places across three layers (`@wabbit/tome-crm` accounts + contacts, `@wabbit/tome-deals` billing + shipping, `@wabbit/tome-fulfillment` `Addresses`) in TWO INCOMPATIBLE VOCABULARIES: `address1/address2/city/state/zip/country` and `line1/line2/city/region/postalCode/country`. Two vocabularies for one concept guarantees a mapping layer at the CRM → shipping seam, and that mapping did not exist anywhere. **This module does not pick a winner, and no stored field name changes.** Both vocabularies are stored shapes with live rows behind them — renaming `zip` to `postalCode` in crm is a data migration, not a refactor. Every adopter passes the `vocabulary` it already stores. `required` marks the four load-bearing lines only (never `line2`, never `state`/`region` — requiring a state makes the schema US-shaped, which fulfillment's own comment argues against at length). `validateCountry` carries fulfillment's ISO-3166 alpha-2 check and its uppercase-normalising `beforeValidate` hook across verbatim; normalising rather than rejecting lowercase is deliberate, because the shipping rate table matches on this value and a rejected `"us"` teaches nothing. `fieldOverrides` merges per-sub-field changes through the existing `/fields/fieldShape` seam, which is how deals keeps its eight per-field labels and its `'US'` country default without a second copy of the block. `postalAddressFields` exists alongside the group because fulfillment stores the postal lines FLAT at collection top level, interleaved with `recipientName` and `phone`. Wrapping them in a group to reuse the group helper would have been exactly the stored-shape change this promotion refuses to make. - **`@wabbit/tome-core/utilities/relationId`** (new subpath) — `relationId(value): string | null` and `relationIdOrThrow(value, label)`. The audit counted this read in core's deprecated `lms` tree, lms (twelve copies under two names), sc, ledger, crowdfund, fulfillment, lms-ui and an inline ternary in crm. The finding was not the count but **four different return types**: `string | null`, `string | number | null`, `string | undefined`, `string | number | undefined`. Two call sites resolving the same row could disagree about equality — a populated doc's numeric id arriving unstringified next to a bare id string. Core picks `string | null` and stringifies: `String(id)` for both string and number, `null` (never `undefined`) for absent, `null` for an array (a `hasMany` value is the caller's loop, not a silent first-element read). Payload accepts either form in a `where` clause, so ids fed back into a query are unaffected, and `===` between two resolved ids now means what a reader thinks it means. A caller that genuinely needs the id in its stored type should not use this — that is a documented divergence rather than a fifth accidental copy. Forcing function: `scripts/assert-no-forked-primitives.mjs` gains a fourth, STRUCTURAL check. Checks 1–3 compare code, and the address fork is invisible to all three because it is not code — it is five object literals whose sub-field name sets are the same schema typed out by hand. The new check finds every `type: 'group'` field literal, reads the direct sub-field names out of its `fields:` array, and warns when that set COVERS either vocabulary. Coverage rather than equality on purpose: exact equality would have caught none of the five real copies, since crm prepends `name` and deals prepends `name` + `company`. Run on the pre-adoption tree it reports all four group-shaped copies (crm accounts:89, crm contacts:135, deals:192 and :207); on the adopted tree it reports none. `@wabbit/tome-ledger` keeps its own `extractId`, for the same architectural reason as its `mergeHooks` (core is an OPTIONAL peer there) plus a second one: its semantics differ deliberately (`string | undefined`, string-only input). That exemption is recorded as prose above the assert's ALLOWLIST rather than as an entry, because the ALLOWLIST is FILE-granular — an entry for a nine-line function inside a 130-line module would never match and would print as STALE on every run forever, training readers to ignore the stale report. That is the failure mode `assert-test-floor.mjs` already demonstrated for seven weeks.
- 04309f5: Add `batchWrite` / `batchWriteInChunks` — the platform's bounded-concurrency batch writer — at the new `@wabbit/tome-core/utilities/batch` subpath. The 2026-09-01 sale-readiness audit found **15 serial-await-in-loop sites** across `@wabbit/tome-org`, `@wabbit/tome-lms` and `@wabbit/tome-sc`: Payload `afterChange`/`afterDelete` hooks and reconciler jobs fanning out N independent writes one `await` at a time, so an event with 200 attendees blocked the request for 200 sequential round-trips. In every case the correct pattern already existed a few files away in the same package — `division-team-reciprocity.ts:101-108` batched while `:88-99` did not; `gdpr.ts` used one bulk update in one handler and a per-row loop in its sibling. The primitive was never extracted, so nobody adopted it, so it kept getting rewritten wrong. - `batchWrite(items, fn, { concurrency = 8, onError, logger, label })` — bounded-concurrency mapper. `results` is index-aligned with `items` regardless of completion order, and a per-item throw is isolated into `errors` instead of poisoning its siblings (the failure mode that makes a naive `Promise.all` unsafe in a cascade hook, where one missing related doc must not abort the other 199 updates). `onError: 'throw'` reproduces a serial loop's exact abort semantics for callers that want them. - `batchWriteInChunks(items, fn, { chunkSize = 25, pauseMs = 250, shouldStop, ... })` — the same, plus the `WRITE_CHUNK`/`WRITE_PAUSE_MS` pacing invented in `@wabbit/tome-lms`'s `reconcileCourseCompletionAwards`: process a chunk, then pause, so a write's own `afterChange` fan-out has room to drain before the next chunk lands. `shouldStop` carries a per-run write budget without abandoning the pacing. The module header states what it is NOT for: a bulk `payload.update({ where, data })` beats any amount of concurrency when every item takes the same data, and a deliberately ordered loop (money capture, ledger legs, `settleCampaign`'s `maxCapturesPerRun` counter) stays sequential — pass `concurrency: 1` when you want the pacing and error isolation but must keep strict ordering. Shipped with its forcing function: a `no-restricted-syntax` rule in `eslint.config.mjs` warns on `await payload.*` as a direct statement inside a `for…of`/`for` body and points here.
- 4aeedad: `createKeyedRegistry` in core, and the gate that keeps the next registry anchored. Tome had eleven keyed registries and two implementations of one idea: five anchored their state on `globalThis` via `Symbol.for`, six held a module-local `Map` (2026-09-01 sale-readiness audit §5.1, "same mechanism, half correct"). The half that is wrong is wrong silently. A published package ships separate ESM and CJS builds — distinct module instances with distinct module-local state — so the moment one consumer static-imports one build and another `require()`s the other, or a bundler splits an RSC/SSR/client graph, a module-local `Map` exists twice and a registration made through one is invisible through the other. Nothing throws; the handler just never fires. `layerRegistry` shipped that bug in 2026-05 and moved onto `globalThis` in tome-core 1.0.10, both it and the render registry explain the mechanism at length in their headers, and six registries were written afterwards without it. A comment cannot make the next author read it. **New in core:** `@wabbit/tome-core/registry/createKeyedRegistry` (a NEW exports-map subpath — hence the minor). `createKeyedRegistry<T>(symbolKey, { onDuplicate, validate })` returns `{ register, replace, get, has, list, clear }` over a store anchored at `globalThis[Symbol.for(symbolKey)]`. `onDuplicate` is `'throw'` (default) / `'replace'` / `'ignore'`, chosen to match each migrating registry's CURRENT behaviour rather than a preferred one. `replace()` is the explicit override path every throw-on-duplicate registry in the repo already exposed for tests and consumer shadowing. The module's JSDoc carries the full migration recipe for the registries not migrated here. 14 unit tests, including the dual-instantiation proof: two separately-created registries on one key share a store, and the state survives a `vi.resetModules()` re-evaluation of the defining module — a module-local `Map` fails both. **Migrated in core:** `gdpr/registry.ts`. This one had BOTH halves of the defect — a module-local `Map` inside `GdprRegistryImpl`, and absence from core's own `sideEffects` array — while four layers (fulfillment, org, sc, plus consumer sites) register into it by import side effect. Split state meant `runErasure`/`exportUserData` reporting zero rows for collections registered into the other copy; a missing `sideEffects` entry meant a bundler was free to drop the registering module outright. The store is now anchored (`onDuplicate: 'replace'`, matching `registerCollection`'s documented overwrite) and `./dist/gdpr/registry.*` is in `sideEffects`, with a `sideEffectsRationale` block in the manifest recording why each entry is there. The class API is unchanged — same names, arguments, semantics, and `getAll()`'s registration-order guarantee. `unregisterCollection` rebuilds the store minus one key (the helper exposes no per-key delete because nothing else needs one), preserving that order. **Migrated in deals:** both registries. `registry/side-effect-registry.ts` is now a delegation shim over `@wabbit/tome-workflow`'s registry (see the workflow-adoption changeset) — anchored by that route. `registry/artifact-registry.ts` moves onto `createKeyedRegistry`, INCLUDING its `frozen` flag: a freeze applied to one module instance while another still accepted registrations would have enforced the config-time contract in exactly half the process. Public API, throws and messages are unchanged. This registry is populated in `payload.config.ts` and read during collection construction, and under the Payload CLI those are separate module instances — the observable failure was an `artifactType` select with no options and a thrown "Unknown artifact type". **Forcing function:** `scripts/assert-registry-anchoring.mjs` + `pnpm assert:registry-anchoring`, wired into `platform-discipline.yml` immediately after `assert:no-forked-primitives` (source + manifest reading only, so it runs pre-build and fails fast). Any module-scope mutable `Map`/`Set`/instance singleton whose name — or whose FILE name — announces a registry must import `createKeyedRegistry`, contain `Symbol.for(`, or have its built path listed in the package's `sideEffects` array; otherwise it fails with the migration recipe. Before this change it reported 3 violations (core's gdpr registry and both deals registries) and now reports 0. Eight registries are ALLOWLISTED with a written architectural reason each, not a schedule: forms ×3, intake and print are owned by the forms+intake access wave and their file sets are off-limits to this one; `blocks-core/src/registry/index.ts` is the deliberately explicit-instance DESCRIPTOR registry (ARCHITECTURE.md § Three Registry Mechanisms #2 — the registry that genuinely must be one store, the render registry, is separately `Symbol.for`-anchored and passes), and changing it is a twelve-package linked-family decision; blocks-gallery's two are import-side-effect registries its own header already calls "the outlier, not the template", in a package with zero tests, so they migrate in the wave that gives it tests.
- 8fc9702: Adopt the three primitives that four other layers had each copied verbatim, and add the gate that stops the next copy (2026-09-01 sale-readiness audit §5.1, T3(a)). New public surface: - **`@wabbit/tome-core/jobs`** gains `findPaged`, `readPositiveNumber`, `chunk`, `JOB_PAGE_SIZE`, `JOB_DEFAULT_MAX_PAGES` and the `FindPagedArgs` type — the bounded paginated job scan that existed identically in `@wabbit/tome-lms`, `@wabbit/tome-crowdfund` and `@wabbit/tome-workflow`. Each of those three headers named this exact promotion as its own trigger condition; crowdfund's said "if a third layer needs it, it is promoted into `@wabbit/tome-core/jobs` and both call sites collapse". Workflow became the third layer on 2026-08-18. No behaviour change: the body is the lms/crowdfund version, with the collection slug now passed through core's own `typedSlug()` rather than an inline cast. The `./jobs` subpath already existed — these are new named exports on it, so a consumer importing them needs core `>=1.14.0`. - **`@wabbit/tome-core/fields/fieldShape`** (new subpath) — `applyFieldShape`, `applyFieldOverrides`, `applyOmitFields`, `applyFieldOrder`, `insertFieldsAfter`, plus the `FieldOverrideMap` and `FieldShapeConfig` types. The canonical body is `@wabbit/tome-ledger`'s, the superset (it is the only copy that had the `applyFieldShape` combinator). Where the three copies had drifted they had drifted only in two error-message strings; the longer wording is kept, because it names the most common cause of the error ("check for a `fieldNames` rename applied before this"). - **`@wabbit/tome-core/fields/selectOptions`** (new subpath) — the ONE `{ mode: 'extend' | 'replace'; options }` select-vocabulary override contract, with `extend` deduped by `value`. The audit found this contract implemented five times under one name in three incompatible shapes, so the same config key behaved differently depending on which layer emitted the field. Core's own `field-reports/shared.ts` and `gdpr/compliance/processingRegister.ts` now import it and re-export their existing symbols unchanged. Deprecated, not removed: `SelectOptionOverride` in `@wabbit/tome-core/identity` (the `{ extend?, replace? }` shape) now carries `@deprecated` pointing at the canonical contract. It is public API on a 1.x package and keeps working — `MemberCollectionConfig.identity.standingOptions` is unaffected — but nothing new should adopt it. New gate: `pnpm assert:no-forked-primitives` (`scripts/assert-no-forked-primitives.mjs`), wired into `platform-discipline.yml` immediately after `assert:declared-imports` (source-only, pre-build). It hashes comment-stripped, import-stripped file bodies across every `packages/*/src/**/*.ts` and fails on a non-core file identical to a core file, or on two non-core files identical to each other. A second, function-granular check warns on near-forks — two files sharing at least two identically-named exported functions whose bodies are ≥90% identical — which is how the `fieldShape` trio would have been caught before it drifted. One allowlist entry, `@wabbit/tome-ledger`'s `mergeHooks`, with the architectural reason stated in full: ledger declares core as an OPTIONAL peer. The failure mode this closes is not "we forgot to DRY this up". Four files in the repo stated their own promotion trigger in prose, the trigger fired, and nothing happened, because a comment is not a gate.
- 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.
- 0836ef5: `fields/link` no longer throws "(0, import_deepMerge.default) is not a function" when required from CommonJS. The internal `deepMerge` helper was a `default` export consumed via a default import inside the package; with tsup `bundle: false` that compiles to `__toESM(require(...), 1).default` — the whole module object, not the function — so any CJS consumer of `@wabbit/tome-core/fields/link` (tome-chrome's header factory under raw Node or the Payload CLI) failed at the first `link()` call. ESM consumers were unaffected, which is why it went unnoticed until `assert:node-loadable` ran across every package. `deepMerge` is now a named export; the rule (named exports for intra-package modules, default only for React components) is recorded in docs/claude-gotchas.md.
- 73081e6: `infra/envScaffold` now covers the whole platform, not core's own third of it. `validateEnv()` checked 10 variables; the platform reads about 22, and every paid integration was outside the check — Stripe, Encharge, Kit, Resend, `AI_CREDENTIAL_KEY`, `TOME_ADMIN_LICENSE_KEY`. That is not a boot crash, it is a checkout that 500s or a webhook that verifies nothing, in production, weeks after the deploy that caused it. New `LAYER_ENV_VARS`, keyed by package name so it takes the same identifiers as `hasLayer()`, covering economy, crowdfund, marketing, ai, admin-pro, deals, intake, crm and print. `validateEnv({ layers })` merges the named blocks on top of `SHARED_ENV_VARS`; when two layers declare the same variable the strictest requirement wins and the descriptions are joined, so a site running both economy and crowdfund gets one `STRIPE_SECRET_KEY` line carrying both reasons. An unknown layer name throws rather than contributing nothing — a typo there would silently check less than the caller believes. Backwards compatible: `validateEnv()` with no argument checks exactly the set it always did, so no existing `payload.config.ts` changes behaviour. `EnvVarSpec` and the new `ValidateEnvOptions` are exported. `required` here means "required GIVEN this layer is installed", and the distinction is real rather than cosmetic: the Stripe pair and `TOME_ADMIN_LICENSE_KEY` are hard failures, while the marketing keys stay warnings because a site runs Encharge **or** Kit **or** neither and making them fatal would break a legitimate deployment. `RESEND_API_KEY` is likewise a warning — its absence selects a supported log-instead-of-send mode. One real bug fell out of the survey: `SHARED_ENV_VARS` and `env.example` both documented **`POSTHOG_HOST`**, while `infra/posthog.tsx` has always read **`NEXT_PUBLIC_POSTHOG_HOST`**. A site that set the documented name got the `/ingest` reverse-proxy default and no error. The scaffold now names the variable the code reads; `env.example` gains the per-layer block with the same required/optional annotations. 13 new tests in `tests/infra/envScaffold.test.ts`.
- 670d2a1: **`infra/plugins.ts` no longer types the five Payload plugin wrappers `(config: any)`.** This is the one place every Tome site wires redirects, SEO, form-builder, search and nested-docs, and it was the audit's headline "load-bearing `any`" (§6): a misconfiguration here was invisible to `tsc` for every consumer at once. Each constructor's parameter is now recovered as `Parameters<typeof pluginFn>[0]` through a **type-only** import of the plugin package. Type-only because the constructors are still passed in by the consuming site — importing them for real would reintroduce the ESM/CJS resolution problem that shape exists to avoid — and `import type` erases entirely at build time, so this adds zero runtime coupling. `Parameters<...>` rather than a named config import because the plugin packages export only their constructor from the barrel; the config interfaces live in unexported `./types.js` modules. A side benefit: the types track whichever plugin version the consumer resolved instead of a snapshot copied into this file. All five packages are already declared `peerDependencies` of core, so nothing is added to the manifest. New exported types: `RedirectsPluginOptions`, `SeoPluginOptions`, `FormBuilderPluginOptions`, `SearchPluginOptions`, `NestedDocsPluginOptions`. `search.beforeSync` is typed from the plugin's own config instead of `(args: any) => any`. The stricter types immediately found one real seam: nested-docs' `generateURL` takes four arguments and untyped docs, while Tome's option has always been the narrower `(docs: {slug}[]) => string`. Widening the public option would break every consumer (the narrow callback is not assignable to the wider parameter under `strictFunctionTypes`), so the public shape is unchanged and `basePlugins` now adapts between the two explicitly, defaulting a missing `slug` to `''` instead of emitting `/undefined` — the runtime bug the `any` had been hiding. `.d.ts` note: the emitted `dist/infra/plugins.d.ts` now references the `@payloadcms/plugin-*` types. Consumers already install these to pass the constructors in, and they are declared peers; a consumer with `skipLibCheck` (the Payload default) is unaffected either way.
- fa0491f: `validateEnv({ layers })` now knows about `TOME_CRM_BOOTSTRAP_READ_FALLBACK`, the flag crm 0.6.0 introduced to re-open the bootstrap `crm:read` bridge that is otherwise off in production. It is optional, so nothing fails without it; the point is that an operator reading the per-layer env schema to answer "what does installing the CRM oblige me to configure?" now sees the switch that governs whether unseeded sites expose contact and account PII, instead of discovering it from a log warning after the fact.
- 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.
- 090e984: README fixes surfaced by the extended `assert:readme-contract` gate (2026-09-01 sale-readiness audit, Tier 2), each verified against the package's own manifest or source: - **blocks-core** — the `./categories` and `./types` entry points are now named in the Public API section; both were published but undocumented. - **core** — added `/access/orgScoped`, `/access/vendorScoped`, `/infra/health` and `/infra/env-scaffold` to the additional-subpaths table, and noted that `/auth/collections/roles` has a real `/auth/collections/Roles` case alias in the exports map. - **crowdfund** — `CROWDFUND_LAYER_VERSION` is also published standalone at `./version`; the row now says so. - **dispatch** — the eight per-block `./blocks/*` config subpaths and all eight `./components/*` component subpaths are enumerated instead of one "etc." row. - **forms** — the peer table now lists `@wabbit/tome-core`, `@wabbit/tome-ui` and `typescript`, which are declared `peerDependencies` but appeared only in prose (or not at all). - **lms-ui** — `StudentProfileEditor` is flagged `@deprecated` in the component table, matching the tag its source has carried since R4 ruling #5.
- 73081e6: README peer tables, and the gate that now requires them. Sixteen packages declared `peerDependencies` and documented them nowhere a reader could scan — in prose inside an install paragraph, in a transposed "compatibility matrix" with the peers as columns, or not at all. Docs only; no source, no manifest, no runtime change (the one manifest change in this PR, admin's `sonner` peer, has its own changeset). Each of the sixteen gains a `## Peer dependencies` section generated from its own `package.json` — `| Peer | Range | Required |`, one row per peer, the range verbatim, `no (optional)` read from `peerDependenciesMeta`, plus one sentence on what is a real `dependency` rather than a peer and why the optional ones are optional. The worst omissions this surfaced: `@wabbit/tome-core` documented 2 of its 13 peers and left out both `next` and `@payloadcms/richtext-lexical`, which are required; `@wabbit/tome-admin` listed 5 of 20; `@wabbit/tome-readout` and `@wabbit/tome-sc` listed none. Eight block packs carried a hand-typed compatibility table that had drifted a full React major — still `>=18` after the peer floor moved to `>=19.0.0` — and none of the eight listed `react-dom` at all. Those tables are retired in favour of the generated one, with a line saying what they used to claim so the next reader does not reinstate them. The forcing function ships with the fix: `scripts/assert-readme-contract.mjs` now FAILS a package that declares peers without a peer table (a markdown table whose header row names a Peer and a Range column — the existing `Optional?` and `Notes` third columns still pass, so the thirty already-conforming READMEs were not touched). It is deliberately shape-only, not row-level: asserting that each row agrees with the manifest is the Tier 2 generation work. Verified non-vacuous by breaking one table's header and watching the gate fail, then restoring it. `CONTRIBUTING.md`'s assert-script list — which said "five" while sixteen existed — and the three guides that describe this gate were corrected in the same pass.
f2b849c: Wave 6 org-adoption I4.1 — closes the `identity.displayNameRequired` gap the first `createMemberCollection` consumer (Vanguard, 1395 rows, 0 with `displayName`) hit, and grows the factory's generic field coverage. All opt-in, defaults byte-identical. **`identity.includeDisplayName?: boolean` (default `true`).** `displayNameRequired` only ever toggled the field's `required` flag — there was no way to omit `displayName` entirely while keeping the factory's slug handling. `skipDefaultIdentity: true` was the only escape, but it drops `displayName` AND the `slug` pair TOGETHER, forcing Vanguard's real adapter to re-supply its own slug pair verbatim via `extraFields` just to keep a factory-composed slug. `includeDisplayName: false` closes the gap: no `displayName` field is emitted, `admin.useAsTitle`/`defaultColumns` fall back to `identity.titleField` (now REQUIRED in that case — the factory throws `MemberIdentityConfigError` at collection-definition time otherwise), `slugSource` defaults to `titleField` instead of `'displayName'`, and the `slug` field itself is still emitted by default (pass `slugField: false` to also drop it, same as today). **Six generic member-table fields, each opt-in and default off** — the factory only ever emitted 4 of Vanguard's 62 `members` fields (`user`/`avatar`/`bio`/`standing`); these are the ones judged generic enough for any member table to plausibly want, from auditing what Vanguard's real adapter supplied via `extraFields`: - `includeClassification` — `'public' | 'classified'` select, default `'public'` (`DEFAULT_MEMBER_CLASSIFICATION_OPTIONS` exported). - `includePortrait` — upload field (relationTo `mediaCollection`), distinct from the always-on `avatar`. - `includeReportsTo` — self-relationship (relationTo the collection's own `slug`) for chain-of-command data. - `includeTimezone` + `timezoneField` (default `'timezone'`) — plain `text` field, not a curated select (the factory doesn't own a timezone-options list); rename to match an existing column (e.g. Vanguard's `timeZone`). - `includeRegion` — six-value coarse-geography select (`DEFAULT_MEMBER_REGION_OPTIONS`: NA/EU/APAC/OCE/SA/MEA). - `includeJoinDate` + `joinDateField` (default `'joinDate'`) — plain `date` field. Does NOT replicate a nested onboarding/induction group shape some consumers may use instead. None of these byte-match any one consumer's existing field shape (different value casings, no curated timezone list) — generic defaults a future consumer can take as-is, rename where a `*Field` option is offered, or still fully override via `extraFields`. Also: `admin.defaultColumns`' first entry now reads `titleField` instead of a hardcoded `'displayName'` literal, so it never references a dropped field when `includeDisplayName` is `false` (unchanged value when `includeDisplayName` is `true`, since `titleField` still defaults to `'displayName'`). New `MemberIdentityConfigError` exported from `@wabbit/tome-core/identity`. New tests in `tests/identity/createMemberCollection.test.ts` (2 new describe blocks, 20 new cases); the pre-existing "default snapshot" block is untouched and green. See `docs/superpowers/specs/2026-08-22-vngd-tome-wave-6-org-plan.md` §W6-I4.1 and the W6-I5 adapter's "NOT ADOPTED AS PROPOSED" comment (`Vngd-Site-Core`'s `src/collections/Members/index.ts`) for the gap this closes.
- f2b849c: Wave 6 org-adoption I4.1 — closes the `identity.displayNameRequired` gap the first `createMemberCollection` consumer (Vanguard, 1395 rows, 0 with `displayName`) hit, and grows the factory's generic field coverage. All opt-in, defaults byte-identical. **`identity.includeDisplayName?: boolean` (default `true`).** `displayNameRequired` only ever toggled the field's `required` flag — there was no way to omit `displayName` entirely while keeping the factory's slug handling. `skipDefaultIdentity: true` was the only escape, but it drops `displayName` AND the `slug` pair TOGETHER, forcing Vanguard's real adapter to re-supply its own slug pair verbatim via `extraFields` just to keep a factory-composed slug. `includeDisplayName: false` closes the gap: no `displayName` field is emitted, `admin.useAsTitle`/`defaultColumns` fall back to `identity.titleField` (now REQUIRED in that case — the factory throws `MemberIdentityConfigError` at collection-definition time otherwise), `slugSource` defaults to `titleField` instead of `'displayName'`, and the `slug` field itself is still emitted by default (pass `slugField: false` to also drop it, same as today). **Six generic member-table fields, each opt-in and default off** — the factory only ever emitted 4 of Vanguard's 62 `members` fields (`user`/`avatar`/`bio`/`standing`); these are the ones judged generic enough for any member table to plausibly want, from auditing what Vanguard's real adapter supplied via `extraFields`: - `includeClassification` — `'public' | 'classified'` select, default `'public'` (`DEFAULT_MEMBER_CLASSIFICATION_OPTIONS` exported). - `includePortrait` — upload field (relationTo `mediaCollection`), distinct from the always-on `avatar`. - `includeReportsTo` — self-relationship (relationTo the collection's own `slug`) for chain-of-command data. - `includeTimezone` + `timezoneField` (default `'timezone'`) — plain `text` field, not a curated select (the factory doesn't own a timezone-options list); rename to match an existing column (e.g. Vanguard's `timeZone`). - `includeRegion` — six-value coarse-geography select (`DEFAULT_MEMBER_REGION_OPTIONS`: NA/EU/APAC/OCE/SA/MEA). - `includeJoinDate` + `joinDateField` (default `'joinDate'`) — plain `date` field. Does NOT replicate a nested onboarding/induction group shape some consumers may use instead. None of these byte-match any one consumer's existing field shape (different value casings, no curated timezone list) — generic defaults a future consumer can take as-is, rename where a `*Field` option is offered, or still fully override via `extraFields`. Also: `admin.defaultColumns`' first entry now reads `titleField` instead of a hardcoded `'displayName'` literal, so it never references a dropped field when `includeDisplayName` is `false` (unchanged value when `includeDisplayName` is `true`, since `titleField` still defaults to `'displayName'`). New `MemberIdentityConfigError` exported from `@wabbit/tome-core/identity`. New tests in `tests/identity/createMemberCollection.test.ts` (2 new describe blocks, 20 new cases); the pre-existing "default snapshot" block is untouched and green. See `docs/superpowers/specs/2026-08-22-vngd-tome-wave-6-org-plan.md` §W6-I4.1 and the W6-I5 adapter's "NOT ADOPTED AS PROPOSED" comment (`Vngd-Site-Core`'s `src/collections/Members/index.ts`) for the gap this closes.
d2347e3: Wave 6 org plan, W6-I4 — `createMemberCollection` (`@wabbit/tome-core/identity`) grows the config surface Vanguard's `members` needs to host it, upstream only: nothing is adopted here, and every option is additive with a default that reproduces the pre-I4 output byte-for-byte (a new "default snapshot" test block asserts this, kept green through the change). - **`requireUser` (default `true`) + `userValidate`:** the `user` relationship can now be optional — Vanguard's `members` has 1395 rows, 1167 with `user: null` (all `standing: 'legacy'`). `unique: true` stays on the field either way; Payload's unique index is sparse for optional fields, so multiple `null` rows don't collide. `userValidate` is wired only when `requireUser: false`. - **`identity` config (`displayNameRequired`, `titleField`, `slugSource`, `slugField`):** lets a consumer make `displayName` optional, point `admin.useAsTitle` at a different field (e.g. Vanguard's `rsiHandle`), and source the generated slug from that field instead — or supply their own `slugField(...)` pair entirely (`slugField: Field[]`), or drop the slug field while keeping `displayName` (`slugField: false`). This composes WITHOUT `skipDefaultIdentity`, which still drops both `displayName` and `slug` unchanged, exactly as before. - **`standingOptions` (`true | { extend?, replace? }`) + `standingDefault`:** adds an opt-in `standing` select field. Omitted by default — the pre-I4 factory has no `standing` field at all, and `@wabbit/tome-org`'s `Member` wrapper still supplies its own via `extraFields` today, unchanged by this release. `DEFAULT_MEMBER_STANDING_OPTIONS` exports the 8 values org hardcodes today, so a later increment can reference the same base set instead of re-typing it. - **`defaultPopulate` / `indexes` passthrough:** the factory did not spread unknown config into the returned `CollectionConfig` — both are now explicit passthroughs, present in the output only when provided. - **`defaultReadAccess` (`'authenticated' | 'self-or-admin' | Access`):** lets a caller choose the `read` strategy without replacing the whole `access` object (which would also drop the create/update/delete defaults). Payload's `Access` type already supports a `Where`-returning function (sync or async) — Contract C2 — so a custom function honours Vanguard's `membersReadAccess` verbatim. Ignored when `access` is provided; `access` always fully replaces, unchanged. - **Hook merge, every key:** `config.hooks` used to merge `beforeValidate` by hand (built-in first, caller appended) but spread every other key wholesale — a caller-supplied `afterChange` would have silently REPLACED a future built-in `afterChange` hook the moment one was added, the same bug independently found and fixed in `@wabbit/tome-org` and `@wabbit/tome-sc`. New shared `@wabbit/tome-core/hooks/mergeHooks` leaf subpath (ported verbatim from `packages/org/src/hooks/mergeHooks.ts` / `packages/sc/src/extensions/mergeHooks.ts`) generalizes the append semantics across every hook key; `org`/`sc` can migrate their local copies to this one in a later increment. Read-only consumer verification: `tome-starter` and `wabbit-site-core` call `createMemberCollection` with none of the new options (unaffected); `@wabbit/tome-org`'s `Member.ts` wrapper typechecks clean against the new signature; `@wabbit/tome-lms` does not consume this factory. See `docs/superpowers/specs/2026-08-22-vngd-tome-wave-6-org-plan.md` §W6-I4.
- d2347e3: Wave 6 org plan, W6-I4 — `createMemberCollection` (`@wabbit/tome-core/identity`) grows the config surface Vanguard's `members` needs to host it, upstream only: nothing is adopted here, and every option is additive with a default that reproduces the pre-I4 output byte-for-byte (a new "default snapshot" test block asserts this, kept green through the change). - **`requireUser` (default `true`) + `userValidate`:** the `user` relationship can now be optional — Vanguard's `members` has 1395 rows, 1167 with `user: null` (all `standing: 'legacy'`). `unique: true` stays on the field either way; Payload's unique index is sparse for optional fields, so multiple `null` rows don't collide. `userValidate` is wired only when `requireUser: false`. - **`identity` config (`displayNameRequired`, `titleField`, `slugSource`, `slugField`):** lets a consumer make `displayName` optional, point `admin.useAsTitle` at a different field (e.g. Vanguard's `rsiHandle`), and source the generated slug from that field instead — or supply their own `slugField(...)` pair entirely (`slugField: Field[]`), or drop the slug field while keeping `displayName` (`slugField: false`). This composes WITHOUT `skipDefaultIdentity`, which still drops both `displayName` and `slug` unchanged, exactly as before. - **`standingOptions` (`true | { extend?, replace? }`) + `standingDefault`:** adds an opt-in `standing` select field. Omitted by default — the pre-I4 factory has no `standing` field at all, and `@wabbit/tome-org`'s `Member` wrapper still supplies its own via `extraFields` today, unchanged by this release. `DEFAULT_MEMBER_STANDING_OPTIONS` exports the 8 values org hardcodes today, so a later increment can reference the same base set instead of re-typing it. - **`defaultPopulate` / `indexes` passthrough:** the factory did not spread unknown config into the returned `CollectionConfig` — both are now explicit passthroughs, present in the output only when provided. - **`defaultReadAccess` (`'authenticated' | 'self-or-admin' | Access`):** lets a caller choose the `read` strategy without replacing the whole `access` object (which would also drop the create/update/delete defaults). Payload's `Access` type already supports a `Where`-returning function (sync or async) — Contract C2 — so a custom function honours Vanguard's `membersReadAccess` verbatim. Ignored when `access` is provided; `access` always fully replaces, unchanged. - **Hook merge, every key:** `config.hooks` used to merge `beforeValidate` by hand (built-in first, caller appended) but spread every other key wholesale — a caller-supplied `afterChange` would have silently REPLACED a future built-in `afterChange` hook the moment one was added, the same bug independently found and fixed in `@wabbit/tome-org` and `@wabbit/tome-sc`. New shared `@wabbit/tome-core/hooks/mergeHooks` leaf subpath (ported verbatim from `packages/org/src/hooks/mergeHooks.ts` / `packages/sc/src/extensions/mergeHooks.ts`) generalizes the append semantics across every hook key; `org`/`sc` can migrate their local copies to this one in a later increment. Read-only consumer verification: `tome-starter` and `wabbit-site-core` call `createMemberCollection` with none of the new options (unaffected); `@wabbit/tome-org`'s `Member.ts` wrapper typechecks clean against the new signature; `@wabbit/tome-lms` does not consume this factory. See `docs/superpowers/specs/2026-08-22-vngd-tome-wave-6-org-plan.md` §W6-I4.
- 4d0ef26: Close three `./field-reports` gaps the first VNGD adoption attempt hit (Wave 2R I4.1), all additive with unchanged defaults: `createWatchlistGlobal` gains `fieldNames: { organizations?, externalId?, name?, category?, notes? }` (the array field name was previously hardcoded to `organizations`; `externalIdField` stays as a deprecated alias); `createRiskScorer` gains `watchlistFieldNames` (reads the watchlist through the SAME names `createWatchlistGlobal` was given, instead of piggybacking on the assessment collection's `fieldNames.affiliationExternalId`) and `flagNames` (overrides every emitted flag string — `multiAffiliation`/`hiddenProfile`/`redactedProfile`/`newAccount`/`manualFlag`/`categoryFlag` — threaded through `score`, `assessAndStore`, and `runReassessRisk` via the new `resolveFlagNames` export); `registerFieldReportsGdpr` gains `reports`/`assessments` options, each `false` (skip registering that collection) or `{ phase?, order? }` (reposition it), replacing "the later registration silently overwrites the earlier one by slug" as the only way to coexist with a consumer's own pinned registration. A new "Vanguard-compatible config" test block in `scorer.test.ts` ports every case from VNGD's characterised `scoreThreat.spec.ts` and confirms identical scores/flags (one pre-existing divergence carries over: the single `visibility` enum here splits VNGD's combined 40-point "hostile + hidden + redacted" case into two 30-point assertions).
6091eba: Add `./field-reports` subpath (Wave 2R / 2R-I4): subject watchlist + scored risk assessment + free-text field reports, absorbed from Vanguard's intel cluster. `createFieldReportCollection`, `createSubjectAssessmentCollection` (fully overridable stored field names via `fieldNames` — zero migration), `createWatchlistGlobal`, `createRiskScorer` (`score`/`assessAndStore`, injectable weights/bands/thresholds, case-insensitive upsert), `inputFromProfile`, Ruling A `createReassessRiskTask`/`createReassessRiskEndpoint` (cache-only rescore), and `registerFieldReportsGdpr` (reports `null-ref`; assessments conditional `redact` per §5a decision 2's retain-with-basis ruling).
- 6091eba: Add `./field-reports` subpath (Wave 2R / 2R-I4): subject watchlist + scored risk assessment + free-text field reports, absorbed from Vanguard's intel cluster. `createFieldReportCollection`, `createSubjectAssessmentCollection` (fully overridable stored field names via `fieldNames` — zero migration), `createWatchlistGlobal`, `createRiskScorer` (`score`/`assessAndStore`, injectable weights/bands/thresholds, case-insensitive upsert), `inputFromProfile`, Ruling A `createReassessRiskTask`/`createReassessRiskEndpoint` (cache-only rescore), and `registerFieldReportsGdpr` (reports `null-ref`; assessments conditional `redact` per §5a decision 2's retain-with-basis ruling).
cb8739b: Add `@wabbit/tome-core/verification` — neutral external-profile-verification primitives absorbed from Vanguard's RSI hardening (Wave 2R / 2R-I1): `createProfileVerificationFlow` (HMAC rolling-window ownership-proof codes + signed tokens), `createDistributedRateLimiter` + `createInMemoryLimiterClient` (fail-closed by default — a deliberate inversion of the always-fail-open behaviour it was absorbed from), `createResilientFetcher` (retry/backoff, explicit `retryOn` status policy defaulting to `429`/`>=500` with `Retry-After` honored and capped at `timeoutMs` — never throws on an HTTP status), the `ExternalProfileAdapter<TProfile>` contract, `createProfileCacheCollection` + `createProfileCacheInvalidator`, and `runProfileReconcile` with Ruling A `createProfileReconcileTask`/`createProfileReconcileEndpoint`. Additive only — nothing in the monorepo consumes this subpath yet (`RSIProfileAdapter` lands in `@wabbit/tome-sc` at 2R-I2).
- cb8739b: Add `@wabbit/tome-core/verification` — neutral external-profile-verification primitives absorbed from Vanguard's RSI hardening (Wave 2R / 2R-I1): `createProfileVerificationFlow` (HMAC rolling-window ownership-proof codes + signed tokens), `createDistributedRateLimiter` + `createInMemoryLimiterClient` (fail-closed by default — a deliberate inversion of the always-fail-open behaviour it was absorbed from), `createResilientFetcher` (retry/backoff, explicit `retryOn` status policy defaulting to `429`/`>=500` with `Retry-After` honored and capped at `timeoutMs` — never throws on an HTTP status), the `ExternalProfileAdapter<TProfile>` contract, `createProfileCacheCollection` + `createProfileCacheInvalidator`, and `runProfileReconcile` with Ruling A `createProfileReconcileTask`/`createProfileReconcileEndpoint`. Additive only — nothing in the monorepo consumes this subpath yet (`RSIProfileAdapter` lands in `@wabbit/tome-sc` at 2R-I2).
ad65127: `createProcessingRegisterCollection` (`@wabbit/tome-core/gdpr/compliance`, Wave 5 / I7.1) gains override options for its three fixed-vocabulary `select` fields — `dataCategoryOptions?`, `dataSubjectOptions?`, `securityMeasureOptions?: { mode: 'extend' | 'replace', options: {label, value}[] }` — closing a gap the initial absorb (I7) left: a consumer migrating an existing register (Vanguard) with vocabulary the default list doesn't cover (`dataCategories: fleet`, `dataSubjects: veterans` — renamed `alumni` on the way in — and four `securityMeasures` values) had no way to keep it. `extend` appends the consumer's options after the default vocabulary, deduped by `value` (default wins on collision); `replace` substitutes wholesale, the same full-replace contract `access` already uses. The three default arrays (`DEFAULT_DATA_CATEGORY_OPTIONS`, `DEFAULT_DATA_SUBJECT_OPTIONS`, `DEFAULT_SECURITY_MEASURE_OPTIONS`) are now exported so a consumer can compose rather than retype them. Also adds `userCollection?: string` (default `'users'`) for `lastReviewedBy`'s `relationTo`, mirroring `createConsentLedgerCollection`'s option of the same name — it was the one relationship field in this factory not already injectable. Fully backward compatible: a caller passing none of the four new options gets today's fields, byte-identical.
- ad65127: `createProcessingRegisterCollection` (`@wabbit/tome-core/gdpr/compliance`, Wave 5 / I7.1) gains override options for its three fixed-vocabulary `select` fields — `dataCategoryOptions?`, `dataSubjectOptions?`, `securityMeasureOptions?: { mode: 'extend' | 'replace', options: {label, value}[] }` — closing a gap the initial absorb (I7) left: a consumer migrating an existing register (Vanguard) with vocabulary the default list doesn't cover (`dataCategories: fleet`, `dataSubjects: veterans` — renamed `alumni` on the way in — and four `securityMeasures` values) had no way to keep it. `extend` appends the consumer's options after the default vocabulary, deduped by `value` (default wins on collision); `replace` substitutes wholesale, the same full-replace contract `access` already uses. The three default arrays (`DEFAULT_DATA_CATEGORY_OPTIONS`, `DEFAULT_DATA_SUBJECT_OPTIONS`, `DEFAULT_SECURITY_MEASURE_OPTIONS`) are now exported so a consumer can compose rather than retype them. Also adds `userCollection?: string` (default `'users'`) for `lastReviewedBy`'s `relationTo`, mirroring `createConsentLedgerCollection`'s option of the same name — it was the one relationship field in this factory not already injectable. Fully backward compatible: a caller passing none of the four new options gets today's fields, byte-identical.
8eaae2e: New `./gdpr/compliance` and `./gdpr/compliance/breach` subpaths (Wave 5 / W5-I7): three optional, consumer-opt-in compliance artefacts absorbed from Vanguard, none of them wired into any existing gdpr consumer — nothing changes for a site that does not import this subpath. - **`createProcessingRegisterCollection(options)`** — an Article 30 processing register factory (neutral default slug `processing-records`, overridable). The absorb win: `collectionsInvolved` is now a `select` whose options are DERIVED from `gdprRegistry.getAll()` at factory-call time (plus a free-text `otherCollections` escape hatch), replacing Vanguard's free-text field that had already drifted from what was actually registered. `access` is injected via an option — the default uses only core's generic `admins`/`authenticated` guards, never a hardcoded Vanguard permission key. `validateAgainstRegistry(rows, registrations?)` cross-checks in both directions: register rows naming a collection no longer registered (stale), and registered collections no register row covers (undocumented processing). - **`createConsentLedgerCollection(options)`** — an immutable (`update: () => false`, not overridable) consent ledger factory: `user`, `action` (select, extensible via `actions`), `policyVersion`, `ipHash`, `userAgent`, `metadata`, `at`. Collapses Vanguard's `consentType` + `granted` boolean + `grantedAt`/`revokedAt` pair into one generic `action` + `at` — the specific consent taxonomy is a consumer's `extraFields`, not core's business. Auto-registers itself with `gdprRegistry` as `mode: 'retain'` by default (`registerGdpr: false` to opt out) — it IS the erasure proof (Art 7(1)), never deleted regardless of the rest of a consumer's cascade. Plus pure helpers `requiresReconsent(consentedVersion, currentVersion)` (major-version-only comparison, no module-level constant) and `recordConsent(payload, input, options?)` (Local API, `overrideAccess: true`). - **`./gdpr/compliance/breach`** — `assessBreachSeverity`, `generateAuthorityNotification`, `generateSubjectNotification`, `createBreachRecord`, ported from Vanguard's `breachNotification.ts` (5 exports, ZERO call sites in production — absorbed as untested prior art, not hardened code; these are its first tests, full stop). Two real fixes made during the port: org identity (`organizationName`/`dpoContact`) is now a caller-supplied `BreachNotificationConfig` instead of hardcoded to Vanguard's name/contact, and Article 33(3)(c)/34(2)'s REQUIRED "likely consequences" content is a real `report.likelyConsequences` field with an honest fallback sentence instead of a rendered `[To be assessed based on breach specifics]` placeholder. `createBreachRecord(payload, report, { slug, ... })` takes its target collection explicitly rather than assuming Vanguard's `audit-logs`. 65 new unit tests: factory option overrides, `collectionsInvolved`/`validateAgainstRegistry` both directions, ledger immutability + auto-registration, the `requiresReconsent` major-version matrix, `recordConsent`'s call shape, `assessBreachSeverity`'s score-boundary matrix, and both notification templates' Article 33(3)(a-d)/34(2) required-field coverage.
- 8eaae2e: New `./gdpr/compliance` and `./gdpr/compliance/breach` subpaths (Wave 5 / W5-I7): three optional, consumer-opt-in compliance artefacts absorbed from Vanguard, none of them wired into any existing gdpr consumer — nothing changes for a site that does not import this subpath. - **`createProcessingRegisterCollection(options)`** — an Article 30 processing register factory (neutral default slug `processing-records`, overridable). The absorb win: `collectionsInvolved` is now a `select` whose options are DERIVED from `gdprRegistry.getAll()` at factory-call time (plus a free-text `otherCollections` escape hatch), replacing Vanguard's free-text field that had already drifted from what was actually registered. `access` is injected via an option — the default uses only core's generic `admins`/`authenticated` guards, never a hardcoded Vanguard permission key. `validateAgainstRegistry(rows, registrations?)` cross-checks in both directions: register rows naming a collection no longer registered (stale), and registered collections no register row covers (undocumented processing). - **`createConsentLedgerCollection(options)`** — an immutable (`update: () => false`, not overridable) consent ledger factory: `user`, `action` (select, extensible via `actions`), `policyVersion`, `ipHash`, `userAgent`, `metadata`, `at`. Collapses Vanguard's `consentType` + `granted` boolean + `grantedAt`/`revokedAt` pair into one generic `action` + `at` — the specific consent taxonomy is a consumer's `extraFields`, not core's business. Auto-registers itself with `gdprRegistry` as `mode: 'retain'` by default (`registerGdpr: false` to opt out) — it IS the erasure proof (Art 7(1)), never deleted regardless of the rest of a consumer's cascade. Plus pure helpers `requiresReconsent(consentedVersion, currentVersion)` (major-version-only comparison, no module-level constant) and `recordConsent(payload, input, options?)` (Local API, `overrideAccess: true`). - **`./gdpr/compliance/breach`** — `assessBreachSeverity`, `generateAuthorityNotification`, `generateSubjectNotification`, `createBreachRecord`, ported from Vanguard's `breachNotification.ts` (5 exports, ZERO call sites in production — absorbed as untested prior art, not hardened code; these are its first tests, full stop). Two real fixes made during the port: org identity (`organizationName`/`dpoContact`) is now a caller-supplied `BreachNotificationConfig` instead of hardcoded to Vanguard's name/contact, and Article 33(3)(c)/34(2)'s REQUIRED "likely consequences" content is a real `report.likelyConsequences` field with an honest fallback sentence instead of a rendered `[To be assessed based on breach specifics]` placeholder. `createBreachRecord(payload, report, { slug, ... })` takes its target collection explicitly rather than assuming Vanguard's `audit-logs`. 65 new unit tests: factory option overrides, `collectionsInvolved`/`validateAgainstRegistry` both directions, ledger immutability + auto-registration, the `requiresReconsent` major-version matrix, `recordConsent`'s call shape, `assessBreachSeverity`'s score-boundary matrix, and both notification templates' Article 33(3)(a-d)/34(2) required-field coverage.
4238d00: New `./gdpr/retention` subpath (Wave 5 / W5-I5): a pure `runRetention({ payload, policies, now?, dryRun?, lock?, findDueErasures?, onErasureDue?, onStalledErasure?, stalledAfterDays? })` retention engine, absorbed from Vanguard's `gdprRetentionCleanup` task with its single-flight lock and supersede semantics carried along, ported test-for-test (74 new tests) and shipped with both Ruling A adapters. - **Policy model as data:** `defineRetentionPolicies([...])` validates and normalizes `RetentionPolicy = { collection, action: 'hard-delete' | 'anonymize' | 'retain', olderThan: { field, days }, where?, batchSize?, label? }` (plus `onAnonymize`, required when `action: 'anonymize'` — new relative to Vanguard, whose retention engine never anonymized anything at the retention horizon). Cutoff is strict less-than (a row exactly at the cutoff is not yet eligible). Hard-delete batching re-queries page 1 like `deleteInBatches`; anonymize batching pages by explicit page number instead, since an anonymized row usually stays matched. Both cap at 2000 batches and report hitting the cap as a policy `errors[]` entry, not just a log line. - **Erasure-due sweep:** given a caller-supplied `findDueErasures(now)` (its own query + assembled lifecycle trail) and `onErasureDue(candidate)` (loads user/member, calls `runErasure`), the engine applies the supersede check via `resolveErasureTrail` before dispatching — the `6a4c2f47` regression fixture (an erasure request re-executed 2-6 times a night for 18 nights) is now a pinned unit test, alongside "complete-then-new-request executes once." - **Single-flight lock seam:** `lock?: { acquire(key): Promise<Release | null>, onUnavailable?: 'skip' | 'run' }`. Required outside `dryRun` whenever `policies` or `findDueErasures` are non-empty — `runRetention` throws immediately rather than running unlocked by omission. Ships `createInMemoryRetentionLock()` for tests; no redis dependency added. **`onUnavailable` defaults to `'skip'` (fail CLOSED)** — this inverts Vanguard's `acquireDrainLock`, which fails OPEN on a Redis outage. The `6a4c2f47` incident is the reason: running this sweep unlocked is a repeatable, multi-week data-integrity failure, judged worse here than skipping one tick. A consumer with Vanguard's legal-deadline pressure opts back in via `onUnavailable: 'run'`. - **Officer-task escalation:** `onStalledErasure?(info)` fires once per candidate, only when it remains unresolved after `onErasureDue` was tried and is overdue past `stalledAfterDays` (default 1). - **Ruling A adapters:** `createRetentionTask(config)` → Payload `TaskHandler`, `createRetentionEndpoint(config)` → `CRON_SECRET`-guarded endpoint. Both call the identical `runRetention`. Vanguard's 9 retention policies ship ONLY as a test fixture (`tests/gdpr/retention/fixtures/vanguardPolicies.ts`) exercising this model — their slugs are Vanguard's collections, not core's default policy set. See `packages/core/src/gdpr/README.md`'s `./gdpr/retention` section for the full contract.
- 4238d00: New `./gdpr/retention` subpath (Wave 5 / W5-I5): a pure `runRetention({ payload, policies, now?, dryRun?, lock?, findDueErasures?, onErasureDue?, onStalledErasure?, stalledAfterDays? })` retention engine, absorbed from Vanguard's `gdprRetentionCleanup` task with its single-flight lock and supersede semantics carried along, ported test-for-test (74 new tests) and shipped with both Ruling A adapters. - **Policy model as data:** `defineRetentionPolicies([...])` validates and normalizes `RetentionPolicy = { collection, action: 'hard-delete' | 'anonymize' | 'retain', olderThan: { field, days }, where?, batchSize?, label? }` (plus `onAnonymize`, required when `action: 'anonymize'` — new relative to Vanguard, whose retention engine never anonymized anything at the retention horizon). Cutoff is strict less-than (a row exactly at the cutoff is not yet eligible). Hard-delete batching re-queries page 1 like `deleteInBatches`; anonymize batching pages by explicit page number instead, since an anonymized row usually stays matched. Both cap at 2000 batches and report hitting the cap as a policy `errors[]` entry, not just a log line. - **Erasure-due sweep:** given a caller-supplied `findDueErasures(now)` (its own query + assembled lifecycle trail) and `onErasureDue(candidate)` (loads user/member, calls `runErasure`), the engine applies the supersede check via `resolveErasureTrail` before dispatching — the `6a4c2f47` regression fixture (an erasure request re-executed 2-6 times a night for 18 nights) is now a pinned unit test, alongside "complete-then-new-request executes once." - **Single-flight lock seam:** `lock?: { acquire(key): Promise<Release | null>, onUnavailable?: 'skip' | 'run' }`. Required outside `dryRun` whenever `policies` or `findDueErasures` are non-empty — `runRetention` throws immediately rather than running unlocked by omission. Ships `createInMemoryRetentionLock()` for tests; no redis dependency added. **`onUnavailable` defaults to `'skip'` (fail CLOSED)** — this inverts Vanguard's `acquireDrainLock`, which fails OPEN on a Redis outage. The `6a4c2f47` incident is the reason: running this sweep unlocked is a repeatable, multi-week data-integrity failure, judged worse here than skipping one tick. A consumer with Vanguard's legal-deadline pressure opts back in via `onUnavailable: 'run'`. - **Officer-task escalation:** `onStalledErasure?(info)` fires once per candidate, only when it remains unresolved after `onErasureDue` was tried and is overdue past `stalledAfterDays` (default 1). - **Ruling A adapters:** `createRetentionTask(config)` → Payload `TaskHandler`, `createRetentionEndpoint(config)` → `CRON_SECRET`-guarded endpoint. Both call the identical `runRetention`. Vanguard's 9 retention policies ship ONLY as a test fixture (`tests/gdpr/retention/fixtures/vanguardPolicies.ts`) exercising this model — their slugs are Vanguard's collections, not core's default policy set. See `packages/core/src/gdpr/README.md`'s `./gdpr/retention` section for the full contract.
8120ff5: GDPR `onDelete`/`onExport` handlers now receive the full `GdprStepContext` (Wave 5 / I1.1) — the same object `onRedact`/`onAnonymize`/`onNullRef` already got — instead of a bare `{ payload, userId, userEmail }`, closing the gap the first `runErasure` consumer (Vanguard) hit: a member-keyed collection (`member-notes.member`, `wallets.owner`, `group-memberships.member`) had no `memberId` to query by, and neither handler could see the subject's pre-overwrite `identity`. Fully additive — a handler destructuring only the old three keys is unaffected. `exportUserData`'s `ExportArgs` gains optional `member?`/`identity?` to thread this through; both default to absent/`{}` for every caller today. `onDelete` may now return either a bare `number` (legacy — normalised to `{ count, errors: [] }`) or a `GdprStepOutcome` (`{ count, skipped?, errors }`), so a handler can report a partial sweep (e.g. a media cap that leaves files behind) without throwing. Throwing still forfeits the count — a rejected promise carries no return value, so a thrown error always reports `count: 0`. Verified the default (handler-less) `hard-delete` path already honored `memberField` (`buildDefaultWhere`'s `userField = userId OR memberField = memberId` OR-clause predates this change); documented that `null-ref` has no default dispatch at all and has always required `onNullRef` unconditionally, since nulling a reference needs to know which field(s) to null and to what value.
- 8120ff5: GDPR `onDelete`/`onExport` handlers now receive the full `GdprStepContext` (Wave 5 / I1.1) — the same object `onRedact`/`onAnonymize`/`onNullRef` already got — instead of a bare `{ payload, userId, userEmail }`, closing the gap the first `runErasure` consumer (Vanguard) hit: a member-keyed collection (`member-notes.member`, `wallets.owner`, `group-memberships.member`) had no `memberId` to query by, and neither handler could see the subject's pre-overwrite `identity`. Fully additive — a handler destructuring only the old three keys is unaffected. `exportUserData`'s `ExportArgs` gains optional `member?`/`identity?` to thread this through; both default to absent/`{}` for every caller today. `onDelete` may now return either a bare `number` (legacy — normalised to `{ count, errors: [] }`) or a `GdprStepOutcome` (`{ count, skipped?, errors }`), so a handler can report a partial sweep (e.g. a media cap that leaves files behind) without throwing. Throwing still forfeits the count — a rejected promise carries no return value, so a thrown error always reports `count: 0`. Verified the default (handler-less) `hard-delete` path already honored `memberField` (`buildDefaultWhere`'s `userField = userId OR memberField = memberId` OR-clause predates this change); documented that `null-ref` has no default dispatch at all and has always required `onNullRef` unconditionally, since nulling a reference needs to know which field(s) to null and to what value.
bb4678b: GDPR registry gains cascade semantics needed to carry Vanguard's Article 17 erasure engine (Wave 5 / W5-I1), with zero behavior change for existing registrants. `GdprCollectionRegistration` adds optional `mode` (`'hard-delete' | 'soft-anonymize' | 'redact' | 'retain' | 'null-ref'`, default `'hard-delete'`), `phase` (`'pre-identity' | 'identity' | 'post-identity'`, default `'pre-identity'`), `order` (default `0`), `memberField` (a second FK for member-keyed collections), and mode handlers `onRedact`/`onAnonymize`/`onNullRef`. `gdprRegistry.getOrdered()` sorts by phase then order then registration sequence — a no-op for any registry containing only default-mode registrations. New `runErasure({ payload, user, member?, identity, dryRun? })` dispatches every registration on its mode and returns `{ status: 'completed' | 'completed-with-errors', steps }`, one `StepResult` per registration, with no bare `catch {}` — every failure lands in that step's `errors[]` and the run continues. `deleteAccount` is now a thin wrapper over `runErasure` that reproduces its pre-existing behavior byte-for-byte for starter/wabbit-site-core (pinned by a call-sequence parity test); `exportUserData` now iterates `getOrdered()` instead of `getAll()` (identical order for default-mode registries). Also adds `./gdpr/erasureState`: pure, zero-import cooling/due/overdue classification (`classifyErasureState`) and a generic latest-wins trail resolver (`resolveErasureTrail`) absorbed from Vanguard's erasure-lifecycle utilities, ported test-for-test.
- bb4678b: GDPR registry gains cascade semantics needed to carry Vanguard's Article 17 erasure engine (Wave 5 / W5-I1), with zero behavior change for existing registrants. `GdprCollectionRegistration` adds optional `mode` (`'hard-delete' | 'soft-anonymize' | 'redact' | 'retain' | 'null-ref'`, default `'hard-delete'`), `phase` (`'pre-identity' | 'identity' | 'post-identity'`, default `'pre-identity'`), `order` (default `0`), `memberField` (a second FK for member-keyed collections), and mode handlers `onRedact`/`onAnonymize`/`onNullRef`. `gdprRegistry.getOrdered()` sorts by phase then order then registration sequence — a no-op for any registry containing only default-mode registrations. New `runErasure({ payload, user, member?, identity, dryRun? })` dispatches every registration on its mode and returns `{ status: 'completed' | 'completed-with-errors', steps }`, one `StepResult` per registration, with no bare `catch {}` — every failure lands in that step's `errors[]` and the run continues. `deleteAccount` is now a thin wrapper over `runErasure` that reproduces its pre-existing behavior byte-for-byte for starter/wabbit-site-core (pinned by a call-sequence parity test); `exportUserData` now iterates `getOrdered()` instead of `getAll()` (identical order for default-mode registries). Also adds `./gdpr/erasureState`: pure, zero-import cooling/due/overdue classification (`classifyErasureState`) and a generic latest-wins trail resolver (`resolveErasureTrail`) absorbed from Vanguard's erasure-lifecycle utilities, ported test-for-test.
7b66dcd: `dist` is now loadable by raw Node. tsup builds with `bundle: false`, so it emitted relative specifiers exactly as the TypeScript source wrote them — extensionless (`from "./hierarchy"`, `require("./hierarchy")`). Bundlers and tsx resolve those; raw Node does not. ESM raised `ERR_MODULE_NOT_FOUND`, and CJS was worse: `require("./x")` resolved to the ESM `.js` twin (`.cjs` is not in Node's CJS extension search list), and Node 22+ `require(esm)` then died on _that_ file's own extensionless import. Any consumer outside a bundler — the payload CLI under plain node, `generate:types`, ops scripts, codegen tools — hit this on every subpath that had relative imports; single-file subpaths loaded fine, which is why it went unnoticed. A post-build step (`scripts/fix-dist-extensions.mjs --strict`) now appends explicit extensions (`.js` / `/index.js`, `.cjs` / `/index.cjs`) and fails the build on any specifier it cannot resolve rather than guessing. No source changes, and bundler consumers are unaffected — extensioned relative specifiers are universally resolvable.
- 7b66dcd: `dist` is now loadable by raw Node. tsup builds with `bundle: false`, so it emitted relative specifiers exactly as the TypeScript source wrote them — extensionless (`from "./hierarchy"`, `require("./hierarchy")`). Bundlers and tsx resolve those; raw Node does not. ESM raised `ERR_MODULE_NOT_FOUND`, and CJS was worse: `require("./x")` resolved to the ESM `.js` twin (`.cjs` is not in Node's CJS extension search list), and Node 22+ `require(esm)` then died on _that_ file's own extensionless import. Any consumer outside a bundler — the payload CLI under plain node, `generate:types`, ops scripts, codegen tools — hit this on every subpath that had relative imports; single-file subpaths loaded fine, which is why it went unnoticed. A post-build step (`scripts/fix-dist-extensions.mjs --strict`) now appends explicit extensions (`.js` / `/index.js`, `.cjs` / `/index.cjs`) and fails the build on any specifier it cannot resolve rather than guessing. No source changes, and bundler consumers are unaffected — extensioned relative specifiers are universally resolvable.
196d642: Phase A shared contracts — six cross-layer seams, all additive. - **`/authority`** — injected `AuthorityResolver` + request-cached `getAuthority`. The platform owns the seam, shape and caching guarantee; it never implements the cascade, which is consumer org policy. `scopes` is a string-keyed map rather than named fields so consumers with differing hierarchies aren't forced to misrepresent them. - **`/access/scoped`** — `ScopedAccessResult` (`boolean | Where`), `andWhere`/`orWhere` with defined boolean short-circuits, and `whereScopedTo` bridging a resolved authority to a row constraint. It returns `false`, never `{}`, when a subject commands nothing: an empty `Where` matches every row, so "no authority" expressed as `{}` is a total access bypass. - **`/notifications`** — task-notification emitter interface (no collection). Frozen `dedupKey`/`groupKey` conventions, no-op default so layers work standalone, and emit/resolve helpers guaranteed not to throw — a notification failure must never fail the mutation it describes. - **`/jobs`** — framework-agnostic `JobHandler` plus `asPayloadTask` and `asCronEndpoint`, so layers ship logic and consumers choose a runner. Both adapters invoke the same function. Also exports `authorizedCronRequest`. - **`/config/assertRelationTargets`** — startup validator for dangling `relationTo` targets. A missed sibling slug does not error in Mongo; it returns zero rows months later. - **`registerSuperRoles` / `isSuperRoleUser`** (in `/auth/permissions`) — opt-in, empty by default, wired into all three resolution paths. With nothing registered, behaviour is byte-identical to before. **Security fix:** three copies of `authorizedCronRequest` short-circuited on `authHeader.length !== expected.length` — the exact leak `utilities/timingSafeEqual` was promoted into core to eliminate. Core's two copies now delegate to the shared hash-then-compare implementation, so a wrong-length header costs the same work as a right-length one. It also rejects a blank secret outright.
- 196d642: Phase A shared contracts — six cross-layer seams, all additive. - **`/authority`** — injected `AuthorityResolver` + request-cached `getAuthority`. The platform owns the seam, shape and caching guarantee; it never implements the cascade, which is consumer org policy. `scopes` is a string-keyed map rather than named fields so consumers with differing hierarchies aren't forced to misrepresent them. - **`/access/scoped`** — `ScopedAccessResult` (`boolean | Where`), `andWhere`/`orWhere` with defined boolean short-circuits, and `whereScopedTo` bridging a resolved authority to a row constraint. It returns `false`, never `{}`, when a subject commands nothing: an empty `Where` matches every row, so "no authority" expressed as `{}` is a total access bypass. - **`/notifications`** — task-notification emitter interface (no collection). Frozen `dedupKey`/`groupKey` conventions, no-op default so layers work standalone, and emit/resolve helpers guaranteed not to throw — a notification failure must never fail the mutation it describes. - **`/jobs`** — framework-agnostic `JobHandler` plus `asPayloadTask` and `asCronEndpoint`, so layers ship logic and consumers choose a runner. Both adapters invoke the same function. Also exports `authorizedCronRequest`. - **`/config/assertRelationTargets`** — startup validator for dangling `relationTo` targets. A missed sibling slug does not error in Mongo; it returns zero rows months later. - **`registerSuperRoles` / `isSuperRoleUser`** (in `/auth/permissions`) — opt-in, empty by default, wired into all three resolution paths. With nothing registered, behaviour is byte-identical to before. **Security fix:** three copies of `authorizedCronRequest` short-circuited on `authHeader.length !== expected.length` — the exact leak `utilities/timingSafeEqual` was promoted into core to eliminate. Core's two copies now delegate to the shared hash-then-compare implementation, so a wrong-length header costs the same work as a right-length one. It also rejects a blank secret outright.
71d3b09: Purge Vanguard/VNGD client 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 ("VANGUARD COMMAND", "LOG-2954-0847", "Stanton // Crusader Orbit", "UEES STALWART" → 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 Vanguard/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 VNGD; "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 Vanguard/VNGD as a client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.
- 71d3b09: Purge Vanguard/VNGD client 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 ("VANGUARD COMMAND", "LOG-2954-0847", "Stanton // Crusader Orbit", "UEES STALWART" → 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 Vanguard/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 VNGD; "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 Vanguard/VNGD as a client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.
e30c705: Auth stack unpinned to current: the April workspace override (better-auth 1.4.18 / adapter 0.3.10, added when better-auth 1.6.2 dropped the apiKey plugin export that payload-better-auth 0.3.15 still imported) is removed — the factory dropped the apiKey plugin long ago and the adapter ecosystem resolved the breakage by moving it to @better-auth/api-key. Core now builds and tests (203/203) against better-auth 1.6.26 and @delmaredigital/payload-better-auth 0.10; published peer ranges are unchanged.
- e30c705: Auth stack unpinned to current: the April workspace override (better-auth 1.4.18 / adapter 0.3.10, added when better-auth 1.6.2 dropped the apiKey plugin export that payload-better-auth 0.3.15 still imported) is removed — the factory dropped the apiKey plugin long ago and the adapter ecosystem resolved the breakage by moving it to @better-auth/api-key. Core now builds and tests (203/203) against better-auth 1.6.26 and @delmaredigital/payload-better-auth 0.10; published peer ranges are unchanged.
8d52794: Platform follow-up fixes across three packages. **@wabbit/tome-core (minor):** `createBetterAuth()` now exposes email-delivery pass-throughs so production consumers can actually verify signups and reset passwords: `emailVerification` (better-auth's whole config block — `sendVerificationEmail`, `sendOnSignUp`, `autoSignInAfterVerification`, `expiresIn`, lifecycle hooks), `sendResetPassword`, and `resetPasswordTokenExpiresIn`, all typed against better-auth's own `BetterAuthOptions`. Previously the factory offered no way to wire these, so any deployment that left `requireEmailVerification` on (the production default) shipped an un-verifiable signup dead end — better-auth sent nothing and sign-in threw EMAIL_NOT_VERIFIED. Defaults are unchanged when the new options are not provided. **@wabbit/tome-chrome (patch):** the mobile nav Sheet in Navbar5 and the shared MobileNavSheet (used by Navbar1/Navbar2) now renders a visually-hidden `SheetTitle` ("Navigation"; configurable via `sheetTitle` on MobileNavSheet) and opts out of `aria-describedby`, fixing Radix's "DialogContent requires a DialogTitle" accessibility warning and its missing-Description sibling. **@wabbit/tome-blocks-extras (patch):** renderers no longer paint lucide icon NAMES as literal text. FeatureHeroWithCards (PascalCase names like "Timer"), FeatureWithIconGrid, CardGrid, CardBlock, and LexicalBanner (kebab-case names like "zap", "calendar") now resolve authored icon strings through a shared name→component map (`<Icon aria-hidden size="1em" />`, slot font-size owns sizing). Unmapped name-shaped strings render nothing; emoji/free text still render as text. Adds `lucide-react` as peer `>=0.460.0` + dev, matching the catalog-pack/chrome convention.
- 8d52794: Platform follow-up fixes across three packages. **@wabbit/tome-core (minor):** `createBetterAuth()` now exposes email-delivery pass-throughs so production consumers can actually verify signups and reset passwords: `emailVerification` (better-auth's whole config block — `sendVerificationEmail`, `sendOnSignUp`, `autoSignInAfterVerification`, `expiresIn`, lifecycle hooks), `sendResetPassword`, and `resetPasswordTokenExpiresIn`, all typed against better-auth's own `BetterAuthOptions`. Previously the factory offered no way to wire these, so any deployment that left `requireEmailVerification` on (the production default) shipped an un-verifiable signup dead end — better-auth sent nothing and sign-in threw EMAIL_NOT_VERIFIED. Defaults are unchanged when the new options are not provided. **@wabbit/tome-chrome (patch):** the mobile nav Sheet in Navbar5 and the shared MobileNavSheet (used by Navbar1/Navbar2) now renders a visually-hidden `SheetTitle` ("Navigation"; configurable via `sheetTitle` on MobileNavSheet) and opts out of `aria-describedby`, fixing Radix's "DialogContent requires a DialogTitle" accessibility warning and its missing-Description sibling. **@wabbit/tome-blocks-extras (patch):** renderers no longer paint lucide icon NAMES as literal text. FeatureHeroWithCards (PascalCase names like "Timer"), FeatureWithIconGrid, CardGrid, CardBlock, and LexicalBanner (kebab-case names like "zap", "calendar") now resolve authored icon strings through a shared name→component map (`<Icon aria-hidden size="1em" />`, slot font-size owns sizing). Unmapped name-shaped strings render nothing; emoji/free text still render as text. Adds `lucide-react` as peer `>=0.460.0` + dev, matching the catalog-pack/chrome convention.
`link()` / `linkGroup()` accept a `routes` option — a third link type for pages that live in the app's route tree rather than a collection. A code-owned page has no document for the internal-link relationship to point at, so the only way an editor could reach `/support` or `/docs/get-started` was to type the path into the external-URL box — which mislabels the data and leaves nothing for a consumer's link resolver to key on when choosing between a client-side route transition and a hard navigation. The new type is backed by a **select**, not a text field: the destination list is closed, so a broken internal link cannot be authored. Consumers generate the list from their own route tree. Stored shape is `{ type: 'route', route: '/support' }`. Independent of `relationTo` — a site may offer collections, routes, or both: | Configuration | Emitted radio | | -------------------- | ------------------------------- | | collections only | `reference, custom` (unchanged) | | collections + routes | `reference, route, custom` | | routes only | `route, custom` (new) | | neither | external-URL-only (unchanged) | Route sits directly after the collection option so the two same-site destinations read as a pair; legacy type options stay last. `naming` gains `typeValues.route`, `typeLabels.route` and `routeFieldName`, matching the existing reference/url escape hatches. **Fully additive.** With `routes` omitted or empty the emitted field is identical to before — asserted directly by test.
- `link()` / `linkGroup()` accept a `routes` option — a third link type for pages that live in the app's route tree rather than a collection. A code-owned page has no document for the internal-link relationship to point at, so the only way an editor could reach `/support` or `/docs/get-started` was to type the path into the external-URL box — which mislabels the data and leaves nothing for a consumer's link resolver to key on when choosing between a client-side route transition and a hard navigation. The new type is backed by a **select**, not a text field: the destination list is closed, so a broken internal link cannot be authored. Consumers generate the list from their own route tree. Stored shape is `{ type: 'route', route: '/support' }`. Independent of `relationTo` — a site may offer collections, routes, or both: | Configuration | Emitted radio | | -------------------- | ------------------------------- | | collections only | `reference, custom` (unchanged) | | collections + routes | `reference, route, custom` | | routes only | `route, custom` (new) | | neither | external-URL-only (unchanged) | Route sits directly after the collection option so the two same-site destinations read as a pair; legacy type options stay last. `naming` gains `typeValues.route`, `typeLabels.route` and `routeFieldName`, matching the existing reference/url escape hatches. **Fully additive.** With `routes` omitted or empty the emitted field is identical to before — asserted directly by test.
6bc419c: R4 rulings #1 + #4. Permissions convergence: capabilities (`can`/`canAsync`) are THE runtime-gate API — six flat/hierarchy-unaware checkers (`checkRole`, rbac's `checkPermission`/`checkRoleAsync`/`checkPermissionAsync`/`checkAnyPermission`/`checkAllPermissions`) are `@deprecated` (sunset core 2.0) with a decision tree in ARCHITECTURE.md; `vendorScoped`/`orgScoped` admin bypass is now role-OR-capability (`vendor:manage`/`org:manage`, configurable) — additive and default-safe, with one deliberate widening: super-admin passes the bypass via the capability engine's implicit grant even under a narrowed custom `adminRoles` (20-test truth table ships with it). LMS v1 sunset: the entire `core/lms` surface (37 exports) carries dated `@deprecated` tags naming each v2 replacement — including two honest no-replacement-yet blockers (Module's three-level shape; the typed lesson Block schemas pending LMS sub-spec 2) — and the new `assert:no-core-lms` script is the removal gate (report-only until 2.0; `--strict` flips it).
- 6bc419c: R4 rulings #1 + #4. Permissions convergence: capabilities (`can`/`canAsync`) are THE runtime-gate API — six flat/hierarchy-unaware checkers (`checkRole`, rbac's `checkPermission`/`checkRoleAsync`/`checkPermissionAsync`/`checkAnyPermission`/`checkAllPermissions`) are `@deprecated` (sunset core 2.0) with a decision tree in ARCHITECTURE.md; `vendorScoped`/`orgScoped` admin bypass is now role-OR-capability (`vendor:manage`/`org:manage`, configurable) — additive and default-safe, with one deliberate widening: super-admin passes the bypass via the capability engine's implicit grant even under a narrowed custom `adminRoles` (20-test truth table ships with it). LMS v1 sunset: the entire `core/lms` surface (37 exports) carries dated `@deprecated` tags naming each v2 replacement — including two honest no-replacement-yet blockers (Module's three-level shape; the typed lesson Block schemas pending LMS sub-spec 2) — and the new `assert:no-core-lms` script is the removal gate (report-only until 2.0; `--strict` flips it).
- 36e537a: New `timingSafeEqual` utility (exported at `./utilities/timingSafeEqual`): constant-time string comparison that hashes both operands to fixed-length SHA-256 digests before `node:crypto.timingSafeEqual`, so neither content nor length differences leak timing. Also anchors `productTypeHookRegistry` on `globalThis` (Symbol.for) so product-type hook registrations survive Next.js' split RSC/SSR/client module graphs — same fix, same rationale as blocks-core's render registry.
- 36e537a: Every package now declares an explicit `sideEffects` field (38 added; motion/engine/forms already correct). Registration-bearing modules (render files' `registerRenderer`, `blocks/*/index.ts` `defineBlock` self-registration, widget `register.ts` files, productHooks, permission self-registrations, print templates, chrome built-in variants) are listed so bundlers can tree-shake everything else WITHOUT dropping import-time registrations — previously the field was unset, which blocked cross-module tree-shaking through the barrels entirely. Never blanket `false` on a package with registration or CSS.
- aef2725: DRY adoption sweep (the audit's "adoption, not extraction" rule): crm/deals capability presets delegate to core's `sessionHasCapabilityOrLegacyAdmin`; new core `buildOwnershipWhere`/`ownershipOrBypass` (via `./access`) adopted by core's vendorScoped, catalog's vendor-scoping, and org's ownOrScoped (public APIs unchanged); `slugField()` adopted at 7 sites where semantics matched exactly (core lms collections + createMemberCollection — replacing a third independent slugify), with ~25 sites honestly skipped for named semantic divergences (auto-regenerate-on-clear vs allow-empty, collection-level hook pattern) now listed as core-enhancement candidates; new `formatDisplayDate` in blocks-core utilities (UTC-pinned, hydration-safe) adopted at 5 verified-identical sites; lms-ui consolidates its two certificate date formatters locally; `useMediaQuery`/`useIsMobile` published from tome-ui and adopted by AppShell + admin's SidebarProvider; gamification's `awardPoints` now uses the authoritative `getPointsBalance` (fixes a divergent 1000-row scan cap vs the correct 10000).
66f394b: Fix a duplicate-key race in the onInit seeders (`initializeRoles` in `auth/initRoles.ts`, `seedLegalPages` in `gdpr/seed/legalPages.ts`) on a fresh/empty database under concurrency — e.g. a Next.js build's "collecting page data" phase, which spawns many parallel worker processes each triggering Payload init against the same DB. **Root cause**: both seeders do a non-atomic check-then-create (`find` → `create`) per document. Two workers can both see a document missing and both call `create`; the loser's duplicate-key rejection wasn't handled correctly. `initializeRoles`'s catch matched on raw driver text (`'duplicate'` / `'E11000'`), but Payload's DB adapters (mongo, postgres, sqlite) all normalize native unique-constraint violations into a `payload` `ValidationError` (`{ data: { errors: [{ path, message }] } }`) before the error reaches consumer code — the raw Mongo `E11000` string never arrives, so the loser's error was rethrown, crashing that worker's `onInit`. `seedLegalPages` had the opposite defect: it blanket-caught **all** errors and continued, so a lost race (or any real failure) silently skipped seeding that page's remaining locales. **Fix**: detect the conflict via Payload's canonical, adapter-agnostic `ValidationError` shape (`instanceof ValidationError` + `data.errors[].path` matching the unique field the seeder keys on) instead of grepping driver-specific error text. On a detected conflict, re-fetch by the key to confirm the document now exists before treating it as success — if the re-fetch comes back empty, the conflict signal was a false positive and the original error is rethrown rather than silently swallowed. - `initializeRoles`: a confirmed race-loss logs the existing `○ Role already exists` line and continues. Sequential/single-worker behavior (log lines, created roles, return type) is unchanged. - `seedLegalPages`: a confirmed race-loss now **continues the per-locale loop against the winner's document** (winner and loser write identical data, so the overlap is idempotent) — a lost race can no longer leave a page missing its non-primary locales. **Behavior change**: non-unique create failures (and locale-update failures) are now rethrown instead of being logged and swallowed — a broken seed now fails loudly instead of half-seeding. **Uniqueness ground truth**: `name` and `slug` on the `roles` collection (`auth/collections/Roles.ts`) both carry `unique: true`, so a real DB-level constraint exists on every supported adapter — the roles failure mode was a crash, not silent duplicate rows; no schema change needed. For `seedLegalPages` the target Pages collection is **consumer-owned**: when the consumer's `slug` field is unique the race surfaces as the handled `ValidationError`; when it isn't, the race silently duplicates pages instead and no seeder-side catch can fire (documented on the helper). The same fix is applied to `@wabbit/tome-admin`'s `seedTomeAdminLayouts` in its own changeset. An atomic `payload.db.upsert()` was considered and rejected for all of these: it bypasses the collection's `hooks`/`access` pipeline that `payload.create()` runs, which would change sequential-case behavior.
- 66f394b: Fix a duplicate-key race in the onInit seeders (`initializeRoles` in `auth/initRoles.ts`, `seedLegalPages` in `gdpr/seed/legalPages.ts`) on a fresh/empty database under concurrency — e.g. a Next.js build's "collecting page data" phase, which spawns many parallel worker processes each triggering Payload init against the same DB. **Root cause**: both seeders do a non-atomic check-then-create (`find` → `create`) per document. Two workers can both see a document missing and both call `create`; the loser's duplicate-key rejection wasn't handled correctly. `initializeRoles`'s catch matched on raw driver text (`'duplicate'` / `'E11000'`), but Payload's DB adapters (mongo, postgres, sqlite) all normalize native unique-constraint violations into a `payload` `ValidationError` (`{ data: { errors: [{ path, message }] } }`) before the error reaches consumer code — the raw Mongo `E11000` string never arrives, so the loser's error was rethrown, crashing that worker's `onInit`. `seedLegalPages` had the opposite defect: it blanket-caught **all** errors and continued, so a lost race (or any real failure) silently skipped seeding that page's remaining locales. **Fix**: detect the conflict via Payload's canonical, adapter-agnostic `ValidationError` shape (`instanceof ValidationError` + `data.errors[].path` matching the unique field the seeder keys on) instead of grepping driver-specific error text. On a detected conflict, re-fetch by the key to confirm the document now exists before treating it as success — if the re-fetch comes back empty, the conflict signal was a false positive and the original error is rethrown rather than silently swallowed. - `initializeRoles`: a confirmed race-loss logs the existing `○ Role already exists` line and continues. Sequential/single-worker behavior (log lines, created roles, return type) is unchanged. - `seedLegalPages`: a confirmed race-loss now **continues the per-locale loop against the winner's document** (winner and loser write identical data, so the overlap is idempotent) — a lost race can no longer leave a page missing its non-primary locales. **Behavior change**: non-unique create failures (and locale-update failures) are now rethrown instead of being logged and swallowed — a broken seed now fails loudly instead of half-seeding. **Uniqueness ground truth**: `name` and `slug` on the `roles` collection (`auth/collections/Roles.ts`) both carry `unique: true`, so a real DB-level constraint exists on every supported adapter — the roles failure mode was a crash, not silent duplicate rows; no schema change needed. For `seedLegalPages` the target Pages collection is **consumer-owned**: when the consumer's `slug` field is unique the race surfaces as the handled `ValidationError`; when it isn't, the race silently duplicates pages instead and no seeder-side catch can fire (documented on the helper). The same fix is applied to `@wabbit/tome-admin`'s `seedTomeAdminLayouts` in its own changeset. An atomic `payload.db.upsert()` was considered and rejected for all of these: it bypasses the collection's `hooks`/`access` pipeline that `payload.create()` runs, which would change sequential-case behavior.
Admin label polish + formatted commerce money columns (PR #208): explicit labels for CRM collections ("CRM Accounts…"), Admin/Learner UI Preferences, and better-auth generated collections ("Auth Accounts", "Two-Factor Credentials", OAuth/JWKS casing) via the plugin's customizeCollection hook; nav SYSTEM_LABEL_OVERRIDES map (payload-kv → "Payload KV") applied at resolver + pinned-section label sites; Orders.total / Payments.amount / Prices.amount virtual afterRead fields format integer cents against the row currency ("4900" → "$49.00") in list views with no client components (zero generate:importmap coupling).
- Admin label polish + formatted commerce money columns (PR #208): explicit labels for CRM collections ("CRM Accounts…"), Admin/Learner UI Preferences, and better-auth generated collections ("Auth Accounts", "Two-Factor Credentials", OAuth/JWKS casing) via the plugin's customizeCollection hook; nav SYSTEM_LABEL_OVERRIDES map (payload-kv → "Payload KV") applied at resolver + pinned-section label sites; Orders.total / Payments.amount / Prices.amount virtual afterRead fields format integer cents against the row currency ("4900" → "$49.00") in list views with no client components (zero generate:importmap coupling).
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.
- 850d51c: Fix `assignment-uploads` upload collection rejecting every file. It set `mimeTypes: ['*/*']`, but Payload's `validateMimeType` strips only the first `*` (`'*/*'` → `'/*'`), so the wildcard matched no detected MIME type and the upload guard blocked all student file submissions. Removed the broken config — omitting `mimeTypes` is the correct "accept any file" setting, and Payload still blocks dangerous executable types via its built-in `checkFileRestrictions` allowlist.
1a5e085: auth: converge the platform permission engine. Adds the `./auth/permissions` sub-module — super-permission hierarchy (`MANAGE_X` implies `EDIT_X`/`DELETE_X`), per-member permission overrides with a constrained (fail-closed) overridable allowlist, sync/async/batched effective-permission checkers, fail-secure resolution on unpopulated roles, a request-scoped authority cache, and a registration seam (`registerSuperPermissions`/`registerOverridablePermissions`) for layer-specific permission key-sets. Fully additive — the existing `Roles` collection, `PERMISSIONS` catalog, `rbac` checkers, and `capabilities` engine are unchanged. This is now the single platform permission engine that `tome-org` and `tome-accounts` build on.
- 1a5e085: auth: converge the platform permission engine. Adds the `./auth/permissions` sub-module — super-permission hierarchy (`MANAGE_X` implies `EDIT_X`/`DELETE_X`), per-member permission overrides with a constrained (fail-closed) overridable allowlist, sync/async/batched effective-permission checkers, fail-secure resolution on unpopulated roles, a request-scoped authority cache, and a registration seam (`registerSuperPermissions`/`registerOverridablePermissions`) for layer-specific permission key-sets. Fully additive — the existing `Roles` collection, `PERMISSIONS` catalog, `rbac` checkers, and `capabilities` engine are unchanged. This is now the single platform permission engine that `tome-org` and `tome-accounts` build on.
a9801fe: Consolidation pass (2026-06-10 audit dialect-drift findings) — the platform stops forking its own conventions: **tome-core (minor — new public APIs):** - `./auth/repScoping` — `buildRepWhereClause({ adminCapability, repField })` + `buildCapabilityScopedRead({ readCapability, adminCapability, repField })` + `sessionHasCapabilityOrLegacyAdmin` + `DENY_ALL_WHERE`. The canonical "rows I own" access primitive, promoted from crm/deals' ~90%-identical copies (266 LOC → one parameterized implementation). - `./utilities/normalize` — `normalizeEmail` (trim + lowercase). Email is the cross-layer join key; one normalizer, everywhere. - `./fields/slug` — `formatSlug` upgraded to the canonical algorithm (promoted from catalog's strictly-more-robust slugify: collapses whitespace/hyphen runs, trims edge hyphens); new `buildAutoSlugHook(sourceField, slugField)` collection-level variant. Stored slugs untouched; only future generations on irregular-whitespace inputs differ. **catalog / org / crm / deals (patch):** local copies replaced with delegations to the core primitives. Public names and signatures unchanged (`slugify`, `autoSlugHook`, `buildNormalizeEmailHook`, `normalizeDealEmail`, `repWhereClause`, `accountRepWhereClause`, `dealsRepWhereClause`, `dealsRepOrAdminWhereClause`). Notably, org's auto-slug header had _claimed_ to wrap core's slugifier while carrying a divergent local copy — now it actually does.
- a9801fe: Consolidation pass (2026-06-10 audit dialect-drift findings) — the platform stops forking its own conventions: **tome-core (minor — new public APIs):** - `./auth/repScoping` — `buildRepWhereClause({ adminCapability, repField })` + `buildCapabilityScopedRead({ readCapability, adminCapability, repField })` + `sessionHasCapabilityOrLegacyAdmin` + `DENY_ALL_WHERE`. The canonical "rows I own" access primitive, promoted from crm/deals' ~90%-identical copies (266 LOC → one parameterized implementation). - `./utilities/normalize` — `normalizeEmail` (trim + lowercase). Email is the cross-layer join key; one normalizer, everywhere. - `./fields/slug` — `formatSlug` upgraded to the canonical algorithm (promoted from catalog's strictly-more-robust slugify: collapses whitespace/hyphen runs, trims edge hyphens); new `buildAutoSlugHook(sourceField, slugField)` collection-level variant. Stored slugs untouched; only future generations on irregular-whitespace inputs differ. **catalog / org / crm / deals (patch):** local copies replaced with delegations to the core primitives. Public names and signatures unchanged (`slugify`, `autoSlugHook`, `buildNormalizeEmailHook`, `normalizeDealEmail`, `repWhereClause`, `accountRepWhereClause`, `dealsRepWhereClause`, `dealsRepOrAdminWhereClause`). Notably, org's auto-slug header had _claimed_ to wrap core's slugifier while carrying a divergent local copy — now it actually does.
- baf401e: Removed two phantom export subpaths: `./lms/components/quiz-renderer` and `./lms/components/assignment-renderer`. Their targets (`dist/lms/components/QuizRenderer.*` / `AssignmentRenderer.*`) have **never existed** — `src/lms/components/` is absent from the package, the files are missing from every published tarball (verified against the registry), and no consumer imports the subpaths (verified across all four consumer repos). Leftover keys from before the LMS renderers moved to `@wabbit/tome-lms`. Caught by the new `assert-exports-map` + `smoke-registry-contract` checks on their first run.
8947ff1: Three additive packaging fixes surfaced by bickley-site-core's registry-consumption migration (path-aliasing was masking these — the actual package contracts didn't cover them): - `@wabbit/tome-blocks-marketing-starter`: add `./blocks/*` subpath exports for the 8 block directories (`banner`, `cta`, `faq`, `feature-hero`, `high-impact-hero`, `logo-slider`, `pricing`, `testimonial`). Source already shipped these as directories with `index.ts`; the `exports` map only declared `.` and `./render`, so any consumer of a specific block from the registry got a module-not-found error. Path-aliasing bypassed the exports map, hiding the gap. - `@wabbit/tome-core`: add `./auth/collections/Roles` (capital R) alongside the existing lowercase `./auth/collections/roles`. Both resolve to the same file (`./dist/auth/collections/Roles.{js,cjs,d.ts}`). The source file is `Roles.ts`; the exports map declared only lowercase, so consumers using the file's actual case (which is what TS path-aliasing produced when reading the source directly) couldn't import via the package's public API. - `@wabbit/tome-ui`: add `./tokens.css` alongside the existing `./tokens` (both point at `./dist/tokens.css`). Lets consumers write `import '@wabbit/tome-ui/tokens.css'` to match the CSS-file naming convention as well as the existing `import '@wabbit/tome-ui/tokens'`. All three additions are purely additive — no existing exports removed or changed, so existing consumers stay compatible.
- 8947ff1: Three additive packaging fixes surfaced by bickley-site-core's registry-consumption migration (path-aliasing was masking these — the actual package contracts didn't cover them): - `@wabbit/tome-blocks-marketing-starter`: add `./blocks/*` subpath exports for the 8 block directories (`banner`, `cta`, `faq`, `feature-hero`, `high-impact-hero`, `logo-slider`, `pricing`, `testimonial`). Source already shipped these as directories with `index.ts`; the `exports` map only declared `.` and `./render`, so any consumer of a specific block from the registry got a module-not-found error. Path-aliasing bypassed the exports map, hiding the gap. - `@wabbit/tome-core`: add `./auth/collections/Roles` (capital R) alongside the existing lowercase `./auth/collections/roles`. Both resolve to the same file (`./dist/auth/collections/Roles.{js,cjs,d.ts}`). The source file is `Roles.ts`; the exports map declared only lowercase, so consumers using the file's actual case (which is what TS path-aliasing produced when reading the source directly) couldn't import via the package's public API. - `@wabbit/tome-ui`: add `./tokens.css` alongside the existing `./tokens` (both point at `./dist/tokens.css`). Lets consumers write `import '@wabbit/tome-ui/tokens.css'` to match the CSS-file naming convention as well as the existing `import '@wabbit/tome-ui/tokens'`. All three additions are purely additive — no existing exports removed or changed, so existing consumers stay compatible.
36dc023: Align the BetterAuth dependency contract with what the auth layer's source already requires (post the 1.5/1.6 `apiKey` rename). `betterAuthFactory.ts` imports `twoFactor`/`customSession`/`organization` from `better-auth/plugins` + `passkey` from `@better-auth/passkey` and no longer uses `apiKey` (extracted to `@better-auth/api-key` in better-auth 1.6 / dropped from payload-better-auth 0.7). But the package's `peerDependencies` floors were still `better-auth >=1.0.0` / `@delmaredigital/payload-better-auth >=0.3.0`, so a consumer on a stale version installed cleanly and only failed at runtime with a cryptic `does not provide an export named 'apiKey'`. - **peerDependencies** floors raised: `better-auth >=1.6.0`, `@better-auth/passkey >=1.6.0`, `@delmaredigital/payload-better-auth >=0.7.0` — drift now fails loud at install, not at runtime. - **devDependencies** bumped to match (`better-auth ^1.6.11`, `@better-auth/passkey ^1.6.11`, `@delmaredigital/payload-better-auth ^0.7.3`) so the package's own build/tests exercise the real target versions. No source change — type-only / contract-only.
- 36dc023: Align the BetterAuth dependency contract with what the auth layer's source already requires (post the 1.5/1.6 `apiKey` rename). `betterAuthFactory.ts` imports `twoFactor`/`customSession`/`organization` from `better-auth/plugins` + `passkey` from `@better-auth/passkey` and no longer uses `apiKey` (extracted to `@better-auth/api-key` in better-auth 1.6 / dropped from payload-better-auth 0.7). But the package's `peerDependencies` floors were still `better-auth >=1.0.0` / `@delmaredigital/payload-better-auth >=0.3.0`, so a consumer on a stale version installed cleanly and only failed at runtime with a cryptic `does not provide an export named 'apiKey'`. - **peerDependencies** floors raised: `better-auth >=1.6.0`, `@better-auth/passkey >=1.6.0`, `@delmaredigital/payload-better-auth >=0.7.0` — drift now fails loud at install, not at runtime. - **devDependencies** bumped to match (`better-auth ^1.6.11`, `@better-auth/passkey ^1.6.11`, `@delmaredigital/payload-better-auth ^0.7.3`) so the package's own build/tests exercise the real target versions. No source change — type-only / contract-only.
- 2612799: `safeRevalidateTag` / `safeRevalidateTags` (`@wabbit/tome-core/data/cacheTags`) now lazily load `next/cache` via dynamic `import()` instead of `require()`. tome-core is `"type": "module"` and ships a dual tsup build with `bundle: false`. A bare `require('next/cache')` in source was preserved verbatim in the emitted ESM `dist/data/cacheTags.js`, where `require` is undefined — true-ESM consumers hit `require is not defined` at first revalidation (this broke wabbit-site-core's admin when source-linked). Dynamic `import()` is preserved verbatim by tsup in both the `.js` and `.cjs` outputs and is natively supported by Node under CommonJS, so it is the module-system-agnostic idiom — the same pattern `@wabbit/tome-core/infra/posthog` already uses to lazily pull an optional peer dep without bundler contamination. Behavior is otherwise identical: out-of-request-scope revalidation is still swallowed via `IGNORABLE_PATTERNS`, and a missing `next/cache` (standalone scripts, non-Next Payload hook cascades) now flows through the same ignorable path instead of throwing. Signature ripple: both functions return `Promise<void>` instead of `void` (dynamic `import()` is async). Every known call site invokes them fire-and-forget inside Payload `afterChange` hooks / server actions and discards the return value, so this is non-breaking in practice. Consumers that want to observe revalidation completion may now `await` them.
**Security: multi-tenant access hardening (CRITICAL).** Closes 2 cross-tenant write bugs and 5 hardening findings from the 2026-04-27 `/autoresearch:security` audit deferred at the 1.0.0 cut. No API changes. **`packages/core/src/access/orgScoped.ts`:** - Added `create:` access guard requiring authenticated user with current `activeOrganizationId` AND non-null `orgRole` (defense in depth alongside the hook fix). - `beforeChange` create hook now **force-overwrites** `orgField` to `user.activeOrganizationId` for non-admins regardless of submitted data. Previously the hook only assigned when the field was empty (`if (!data[orgField])`), letting an attacker plant rows in foreign orgs by submitting `data.organization = '<foreign-org-id>'` (finding 8.2). - Read/update/delete access now re-verifies current org membership via `u.orgRole !== null`, not just `activeOrganizationId` presence (finding 8.1). Stale active-org pointers (e.g. user removed from org since last login) no longer grant access. **`packages/core/src/access/vendorScoped.ts`:** - Added `read:` access guard mirroring `update`/`delete` — read was previously fully unrestricted across vendors despite the wrapper's "restricts CRUD" comment (finding 9.1). - Added `create:` access guard requiring authenticated user. - `beforeChange` create hook now **force-overwrites** `vendorField` to `user.id` for non-admins regardless of submitted data (finding 9.2). **`packages/core/src/auth/jobs/cleanupExpiredSessions.ts` + `cleanupUnverifiedAccounts.ts`:** - Replaced `authHeader !== \`Bearer ${cronSecret}\``plain-string compare with`crypto.timingSafeEqual`and added a`payload.logger.warn`on auth failure (finding 5.1). Plain`===` short-circuits on first byte mismatch and leaks prefix length under sufficient signal-to-noise ratio. **`packages/core/src/auth/betterAuthFactory.ts`:** - `localhost:3000` (http + https) origins are now gated behind `process.env.NODE_ENV !== 'production'` in the BetterAuth `trustedOrigins` array (finding 1.1). Pass explicit prod origins via `opts.trustedOrigins`. **`packages/core/src/access/checkRole.ts`:** - Added `@deprecated` JSDoc directing new code to `@wabbit/tome-core/auth/rbac` (finding 7.1). The function remains exported for backwards compatibility and is consumed internally by `orgScoped`/`vendorScoped` against the customSession-enriched flat role slug array — that consumption is intentional per finding 8.4 (a comment in each wrapper explains the rationale; full migration to async `auth/rbac` paths would impose a DB round-trip on every CRUD access check). No source changes outside `packages/core/src/{access,auth}`. Public API surface, exports map, and types unchanged.
- **Security: multi-tenant access hardening (CRITICAL).** Closes 2 cross-tenant write bugs and 5 hardening findings from the 2026-04-27 `/autoresearch:security` audit deferred at the 1.0.0 cut. No API changes. **`packages/core/src/access/orgScoped.ts`:** - Added `create:` access guard requiring authenticated user with current `activeOrganizationId` AND non-null `orgRole` (defense in depth alongside the hook fix). - `beforeChange` create hook now **force-overwrites** `orgField` to `user.activeOrganizationId` for non-admins regardless of submitted data. Previously the hook only assigned when the field was empty (`if (!data[orgField])`), letting an attacker plant rows in foreign orgs by submitting `data.organization = '<foreign-org-id>'` (finding 8.2). - Read/update/delete access now re-verifies current org membership via `u.orgRole !== null`, not just `activeOrganizationId` presence (finding 8.1). Stale active-org pointers (e.g. user removed from org since last login) no longer grant access. **`packages/core/src/access/vendorScoped.ts`:** - Added `read:` access guard mirroring `update`/`delete` — read was previously fully unrestricted across vendors despite the wrapper's "restricts CRUD" comment (finding 9.1). - Added `create:` access guard requiring authenticated user. - `beforeChange` create hook now **force-overwrites** `vendorField` to `user.id` for non-admins regardless of submitted data (finding 9.2). **`packages/core/src/auth/jobs/cleanupExpiredSessions.ts` + `cleanupUnverifiedAccounts.ts`:** - Replaced `authHeader !== \`Bearer ${cronSecret}\``plain-string compare with`crypto.timingSafeEqual`and added a`payload.logger.warn`on auth failure (finding 5.1). Plain`===` short-circuits on first byte mismatch and leaks prefix length under sufficient signal-to-noise ratio. **`packages/core/src/auth/betterAuthFactory.ts`:** - `localhost:3000` (http + https) origins are now gated behind `process.env.NODE_ENV !== 'production'` in the BetterAuth `trustedOrigins` array (finding 1.1). Pass explicit prod origins via `opts.trustedOrigins`. **`packages/core/src/access/checkRole.ts`:** - Added `@deprecated` JSDoc directing new code to `@wabbit/tome-core/auth/rbac` (finding 7.1). The function remains exported for backwards compatibility and is consumed internally by `orgScoped`/`vendorScoped` against the customSession-enriched flat role slug array — that consumption is intentional per finding 8.4 (a comment in each wrapper explains the rationale; full migration to async `auth/rbac` paths would impose a DB round-trip on every CRUD access check). No source changes outside `packages/core/src/{access,auth}`. Public API surface, exports map, and types unchanged.
**Graduate `@wabbit/tome-core` to 1.x** — version-policy change, no API change. Diagnosed root cause of the 2026-04-27 `pnpm changeset version` cascade: while tome-core sits at 0.x, `^0.4.0` peer ranges (resolved from `workspace:^` at publish) do NOT satisfy `0.5.0` per semver-zero rules, so `@changesets/assemble-release-plan` correctly force-major-bumps every peer-dependent on every minor release. Only fix is moving tome-core out of 0.x. After this release, `^1.x.0` peer ranges accept future minor bumps cleanly and the cascade disappears. Existing published peer-dependents (`@wabbit/tome-admin@0.4.1`, `@wabbit/tome-economy@0.2.0`, `@wabbit/tome-catalog@1.1.0`, `@wabbit/tome-blocks-core@0.3.0`) ship with peerDeps frozen at the 0.5.x range and will produce installation peer-dep warnings against `tome-core@1.0.0` until each republishes — warnings only, runtime works. They re-resolve their peer ranges to `^1.0.0` on their next publish naturally. Bumped manually rather than via `pnpm changeset version` to avoid retriggering the same cascade in the changesets run that documents this change. See memory `feedback_changesets_0x_major_bump_bug.md` for the diagnosis and reproduction.
- **Graduate `@wabbit/tome-core` to 1.x** — version-policy change, no API change. Diagnosed root cause of the 2026-04-27 `pnpm changeset version` cascade: while tome-core sits at 0.x, `^0.4.0` peer ranges (resolved from `workspace:^` at publish) do NOT satisfy `0.5.0` per semver-zero rules, so `@changesets/assemble-release-plan` correctly force-major-bumps every peer-dependent on every minor release. Only fix is moving tome-core out of 0.x. After this release, `^1.x.0` peer ranges accept future minor bumps cleanly and the cascade disappears. Existing published peer-dependents (`@wabbit/tome-admin@0.4.1`, `@wabbit/tome-economy@0.2.0`, `@wabbit/tome-catalog@1.1.0`, `@wabbit/tome-blocks-core@0.3.0`) ship with peerDeps frozen at the 0.5.x range and will produce installation peer-dep warnings against `tome-core@1.0.0` until each republishes — warnings only, runtime works. They re-resolve their peer ranges to `^1.0.0` on their next publish naturally. Bumped manually rather than via `pnpm changeset version` to avoid retriggering the same cascade in the changesets run that documents this change. See memory `feedback_changesets_0x_major_bump_bug.md` for the diagnosis and reproduction.
Initial publish to npm.wabbit.com — first registry release for the 5 non-block-pack packages. Companion to the existing sprint-3-blocks-split changeset (which handles the 11 linked block packages). Together these two changesets bring all 8 publish-pipeline-Phase-2-remediated packages to a coherent first-release cohort: - `@wabbit/tome-core` 0.1.0 → 0.2.0 - `@wabbit/tome-ui` 0.2.0 → 0.3.0 - `@wabbit/tome-motion` 0.1.0 → 0.2.0 - `@wabbit/tome-lms` 0.1.0 → 0.2.0 - `@wabbit/tome-lms-ui` 0.1.0 → 0.2.0 - `@wabbit/tome-blocks-core` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-extras` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-marketing-starter` 0.1.0 → 0.2.0 (via sprint-3) All 8 packages ship with metadata, dist/ output, exports map verified by P6 scratch-consumer smoke (35/35 resolutions), 'use client' + 'server-only' directives preserved through tsup bundle:false. Verdaccio v0 live since 2026-04-18 at npm.wabbit.com.
- Initial publish to npm.wabbit.com — first registry release for the 5 non-block-pack packages. Companion to the existing sprint-3-blocks-split changeset (which handles the 11 linked block packages). Together these two changesets bring all 8 publish-pipeline-Phase-2-remediated packages to a coherent first-release cohort: - `@wabbit/tome-core` 0.1.0 → 0.2.0 - `@wabbit/tome-ui` 0.2.0 → 0.3.0 - `@wabbit/tome-motion` 0.1.0 → 0.2.0 - `@wabbit/tome-lms` 0.1.0 → 0.2.0 - `@wabbit/tome-lms-ui` 0.1.0 → 0.2.0 - `@wabbit/tome-blocks-core` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-extras` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-marketing-starter` 0.1.0 → 0.2.0 (via sprint-3) All 8 packages ship with metadata, dist/ output, exports map verified by P6 scratch-consumer smoke (35/35 resolutions), 'use client' + 'server-only' directives preserved through tsup bundle:false. Verdaccio v0 live since 2026-04-18 at npm.wabbit.com.