Documentation menu

Documentation / Community engine / org

Org

Package
@wabbit/tome-org
Version
v0.19.2
npm install @wabbit/tome-org
First install from our registry?
  1. Get a registry token from your credentials page. You need a purchase that includes this package, or a Craft Library membership.

  2. Add the registry and your token to the .npmrc at the root of your project, with your token in place of YOUR_TOKEN:

    @wabbit:registry=https://npm.wabbit.com/
    //npm.wabbit.com/:_authToken=YOUR_TOKEN
Community engineStable

Tome organization layer — org structure (divisions, teams, groups), members, role hierarchy, events, campaigns, and documents, with configurable terminology and 40+ permission grants.

Overview

Tome organization layer — org structure (divisions, teams, groups), members, role hierarchy, events, campaigns, and documents, with configurable terminology and 40+ permission grants. Description copied verbatim from package.json.

Layer: domain (per the repo's ARCHITECTURE.md § The Layer Model). @wabbit/tome-lms and @wabbit/tome-catalog both detect it at runtime (via the layer registry) to unlock vendor/scoped-org relationships; @wabbit/tome-sc depends on it directly.

Peer dependencies

Peer ranges, copied from package.json:

PeerRangeOptional?
payload>=3.67.0no
@payloadcms/richtext-lexical>=3.67.0no
@wabbit/tome-core>=1.17.0 <2.0.0no
lucide-react>=0.460.0yes
@wabbit/tome-workflow>=0.1.0 <1.0.0yes

60-second quickstart

The current API is a single createOrgLayer(config) call that returns all 13 MVP collections:

import { buildConfig } from 'payload'
import { createOrgLayer } from '@wabbit/tome-org'

export default buildConfig({
  collections: [
    ...existingCollections, // incl. users + a media collection
    ...createOrgLayer({
      userCollection: 'users',
      mediaCollection: 'media',
      terminology: { division: { singular: 'Wing', plural: 'Wings' } },
    }),
  ],
})

createOrgLayer also registers org's permission namespace into the converged @wabbit/tome-core/auth/permissions engine (registerOrgPermissions() — there is no parallel org resolver; checkOrgRole/hasPermission delegate to the shared engine) and calls claimOrgMemberSlot() to claim the 'member-collection' logical slot. If you build with the individual factories instead of `createOrgLayer` (see "Extending this package" below), call `claimOrgMemberSlot()` yourself — see that section for why.

API surface

Four export subpaths: . (the primary surface, see below) plus three standalone collection-factory subpaths:

SubpathPurpose
@wabbit/tome-org/collections/applicationStandalone createApplicationCollection export (Recruiting cluster)
@wabbit/tome-org/collections/joinRequestStandalone createJoinRequestCollection export (Recruiting cluster)
@wabbit/tome-org/collections/onboardingRecordStandalone createOnboardingRecordCollection export (Recruiting cluster)
GroupExports
Layer factorycreateOrgLayer, claimOrgMemberSlot
Collection factories (13, createOrgLayer-wired)createDivisionCollection, createTeamCollection, createSquadCollection, createPositionCollection (+DEFAULT_POSITION_CATEGORY_OPTIONS, DEFAULT_AUTHORITY_TIER_OPTIONS, DEFAULT_AUTHORITY_LEVEL_OPTIONS), createMembershipCollection, createMemberCollection (+isActiveMember), createRankCollection (+DEFAULT_RANK_CATEGORY_OPTIONS), createPromotionCollection, createEventCollection, createEventAttendanceCollection, createEventTypeCollection, createCampaignCollection, createDocumentCollection
Individual factories (not wired into createOrgLayer)createDelegatedAuthorityCollection (+DEFAULT_DELEGATED_AUTHORITY_STATUS_OPTIONS), createPersonnelNotesCollection (+DEFAULT_PERSONNEL_NOTE_TYPE_OPTIONS)
Individual factories (not wired into createOrgLayer)createStatusRequestCollection (+DEFAULT_STATUS_REQUEST_TYPE_OPTIONS, DEFAULT_STATUS_REQUEST_STATUS_OPTIONS, DEFAULT_STATUS_REQUEST_APPROVER_SCOPE_OPTIONS), createConductRecordCollection (+DEFAULT_CONDUCT_RECORD_SEVERITY_OPTIONS, DEFAULT_CONDUCT_RECORD_STATUS_OPTIONS, DEFAULT_CONDUCT_RECORD_APPEAL_OUTCOME_OPTIONS, DEFAULT_CONDUCT_RECORD_STANDING_CHANGE_OPTIONS), createExpertDesignationCollection (+DEFAULT_EXPERT_DESIGNATION_STATUS_OPTIONS)
Event Slot factory (export-only, not wired into createOrgLayer)createEventSlotCollection, DEFAULT_EVENT_SLOT_SLUG (+ EventSlotFieldNames, EventSlotFieldDescriptions, EventSlotCollectionConfig) — denormalized per-event crew/role slot rows, the flattened read view for roster/claim/scheduling UI
Recruiting cluster (opt-in, not wired into createOrgLayer — see "Recruiting cluster" below)createApplicationCollection, createJoinRequestCollection, createOnboardingRecordCollection (+ their *CollectionConfig/*FieldNames types, DEFAULT_APPLICATION_STATUS_OPTIONS, DEFAULT_JOIN_REQUEST_STATUS_OPTIONS, buildSparseChecklistGroupField) and three shared adoptable hooks: createGuardDirectTransitionHook, createPendingStatusNotificationHook, createTaskCleanupOnDeleteHook
Recognition cluster (opt-in, not wired into createOrgLayer — see "Recognition cluster" below)createAwardCollection, createMedalCollection, createRibbonCollection, createAwardPresentationCollection, createKudosCollection, createMemorialCollection (+ their *CollectionConfig/*FieldNames/DEFAULT_*_OPTIONS types), shared catalog-item field builders (buildCatalogSlugField (deprecated — use buildOrgSlugField), buildCatalogNameField, etc.), registerRecognitionGdpr/unregisterRecognitionGdpr, and three adoptable hooks: createRecipientCountSyncHook, createAwardedBySelfEnforcementHook, createDisplayNameComposeHook
GDPR registration (five collections)registerPersonnelGdpr, unregisterPersonnelGdpr, RegisterPersonnelGdprConfig
TerminologyDEFAULT_ORG_TERMINOLOGY, mergeTerminology
PermissionsORG_PERMISSIONS, ORG_SUPER_PERMISSIONS, OrgPermissionKey, OrgPermissionValue, registerOrgPermissions, buildOrgSuperPermissionMap, ORG_OVERRIDABLE_PERMISSIONS
Access helpersorgMemberRead (the default collection read), hasPermission, hasAnyOrgPermission, hasPermissionAsync, checkOrgRole, selfOrPermission, authenticatedReadOwnOr, OrgUserLike
HooksautoSlugHook, mergeHooks, createMembershipAfterChange, createMembershipAfterDelete, promotionFourEyesHook, createAttendanceUniqueHook, createRankDeleteGuard (+ MembershipCountHookConfig, AttendanceUniqueHookConfig, RankDeleteGuardConfig)
Structural sync hooks (export-only, never auto-wired)createMemberCountSync, createDivisionTeamReciprocityHook, createTeamSunsetHook, createDivisionCleanupHook, createTeamCleanupHook, defaultSyncFailureReporter, reportOrgSyncFailure (+ MemberCountSyncConfig, MemberCountSyncFieldNames, DivisionTeamReciprocityConfig, TeamSunsetHookConfig, DivisionCleanupHookConfig, TeamCleanupHookConfig, OrgSyncFailureContext, OrgSyncFailureReporter)
Position guards + derivations (wired via createPositionCollection opt-in config, also exported standalone)createPositionScopeExclusivityHook, createPositionDeleteGuard, createPositionDerivationsHook, defaultComposeDisplayTitle (+ PositionScopeExclusivityConfig, PositionDeleteGuardConfig, PositionDerivationsConfig, PositionDisplayTitleContext, PositionScopeAnchor, ComposeDisplayTitle)
Delegated Authority hooks (the uniqueness guard is auto-wired via createDelegatedAuthorityCollection; the expiry task is export-only)createDelegatedAuthorityUniquenessHook, expireDelegatedAuthority, createDelegatedAuthorityExpiryTask (+ DelegatedAuthorityUniquenessConfig, ExpireDelegatedAuthorityOptions, ExpireDelegatedAuthorityResult)
Conduct Record hooks (export-only, wired via createConductRecordCollection's opt-in config keys)createConductRecordCoolingPeriodHook, createConductRecordAutoAssignReviewerHook, createConductRecordSubjectRedactionHook (+ ConductRecordCoolingPeriodConfig, ConductRecordAutoAssignReviewerConfig, ConductRecordAutoAssignReviewerPoolQueryContext, ConductRecordSubjectRedactionConfig)
Select-option overridesresolveSelectOptions, SelectOption, SelectOptionOverride — the `{ mode: 'extend' \'replace', options } contract every fixed-vocabulary select field override (e.g. Rank's categoryOptions`) uses
Slug-field overrideresolveSlugField (deprecated — use resolveOrgSlugField), buildDefaultSlugField — the `Field[] \false contract Rank's, Division's, and Team's slugField option shares (all three emit the identical built-in slug` field)
TypesOrgTerminology, OrgSlugs, BaseOrgCollectionConfig, OrgIdentityConfig, OrgLayerConfig, OrgLeadershipMode, DivisionLeadershipFieldNames, TeamLeadershipFieldNames, DelegatedAuthorityFieldNames, PersonnelNotesFieldNames, PersonnelNotesAuthorRankOrderConfig, StatusRequestFieldNames, ConductRecordFieldNames, ConductRecordStatusValues, ExpertDesignationFieldNames, and one *CollectionConfig type per collection above; ORG_DEFAULT_SLUGS
Layer versionORG_LAYER_VERSION

Server / client posture

Fully server-side: Payload collection factories, access predicates, and permission registration — no React. sideEffects: ["./dist/registerOrgPermissions.*"] — the permission-registration module has import-side-effect behavior (it self-registers on import; createOrgLayer calling registerOrgPermissions() explicitly is a belt-and-suspenders idempotent call, not the only trigger) and must survive tree-shaking.

Default read: org members only

Every org collection reads org members only by default (orgMemberRead): staff (a role with accessType: 'admin'), a holder of the collection's own write permissions, and any account with a row in the member collection (memberSlug, linked through its user field) read every row; an anonymous visitor or a signed-in account with no member row reads nothing. The rule is a boolean, never a filter. Before 0.19 the default was core's authenticated, which on a site with public sign-up let any account read every roster, squad, team, award and delegated-authority row.

// opt back out, per collection (a consumer access.read always wins)
createTeamCollection({ access: { read: authenticated } })
createOrgLayer({ access: { 'org-teams': { read: authenticated } } })
// a stricter membership test keeps the staff and manager rules
createTeamCollection({ access: { read: orgMemberRead({ memberSlug: 'members', isMember: isFullMember }) } })

Public pages: opt-in public read

Every org collection reads org members only by default, so an anonymous visitor reads nothing. A site with public event, division or member pages opts in per collection; never read with overrideAccess: true from a public page (that ignores visibility and leaks members-only events and their briefing).

createOrgLayer({
  publicRead: { events: true, divisions: true, members: true },
})
// factory-only consumers: createEventCollection({ publicRead: true }), and the same on
// createDivisionCollection / createMemberCollection
CollectionAnonymous visitors readHidden from anonymous visitors (field access.read)
events (publicEventsRead)visibility field (fieldNames.securityLevel) in publicValues (default ['public']), status not in excludeStatuses (default ['draft']), _status = published when drafts: truebriefing, organizer, co-hosts, plus extraGuardedFields
divisions (publicDivisionsRead)status in publicStatuses (default ['active'])extraGuardedFields only
members (publicMembersRead)classification in publicClassifications (default ['public']) AND standing in publicStandings (default ['active'])user, reportsTo, position, timezone, region, lastPromoted, plus extraGuardedFields

Org members keep the full read. Payload refuses a where on a field the reader cannot read, so an anonymous query filtering on a guarded field (for example organizer) throws: skip that query for anonymous visitors. An explicit access.read (factory access or the layer's per-collection access map) still wins over publicRead. With publicRead absent every collection is unchanged.

Waitlist counter on events

createOrgLayer({ includeWaitlistCount: true }) adds the events collection's read-only waitlistCount counter, right after attendeeCount, for a site that shows waiting lists. It is off by default and the collections are unchanged without it. The factory-only route is createEventCollection({ includeWaitlistCount: true }), which also takes fieldNames.waitlistCount and fieldDescriptions.waitlistCount. On a SQL database the counter is a new column, so a site with migrations generates one.

Event vocabulary and fields

The Event collection takes six seams, and there are two ways in. Either route changes only the Event collection: with none set it is the stock shape.

KeyUse
eventTypeOptions`{ mode: 'extend' \'replace', options }` for the event types, for example Game, Practice, Tournament, Market Day.
statusOptionsThe same contract for the status words.
statusDefaultThe default status; set it when statusOptions replaces the vocabulary so the default is one of the new values.
locationFieldA Field[] that replaces the freeform location text field at the same position, for a structured place.
extraFieldsExtra fields, appended at the end.
extraFieldsAfterThe field name extraFields follow, for example endDate.

One-call sites pass them as a group: createOrgLayer({ events: { eventTypeOptions, statusOptions, statusDefault, locationField, extraFields, extraFieldsAfter } }). Each key is forwarded only when set, and the other twelve collections are never touched. The group's type is OrgLayerEventsConfig, a Pick of EventCollectionConfig, so the two cannot drift.

A site that builds the Event collection itself, such as one that owns its org bridge file, passes the same six keys straight to the factory: createEventCollection({ eventTypeOptions, statusOptions, statusDefault, locationField, extraFields, extraFieldsAfter }). A group handed to createOrgLayer is dropped when that site replaces the layer's Event collection, so use the factory route there. A new field is a new column on a SQL database, so a site with migrations generates one.

  • CHANGELOG.md — the 0.3.2 entry documents a @wabbit/tome-core peer-floor raise to >=1.2.0 because checkPermissionHierarchical et al. are runtime-imported from core's permission engine and don't exist pre-1.2.0

Extending this package

Individual collection factories are exported specifically so a site can call one in isolation instead of createOrgLayer wholesale (e.g. to add a division-only slice without the full 13-collection MVP surface). The permission model is a registration into the shared tome-core engine, not a parallel resolver — a site adding custom org permissions should extend via registerOrgPermissions's pattern rather than building a second permission path.

Calling the individual factories directly (required for real, non-trivial adoptions — `createOrgLayer` would double-register collections another layer already owns, e.g. `@wabbit/tome-sc`'s squadrons):

  • Call `claimOrgMemberSlot()` yourself. createOrgLayer calls it automatically; the individual factories do not. As of this writing nothing in the platform reads the 'member-collection' slot claim, so skipping this has no functional effect today — but the registry's ownership record will be silently incomplete for your site, and a future increment may start gating on it (e.g. to detect a conflicting second member-identity layer). Call it once alongside your createMemberCollection(...) call.
  • Every factory merges `config.hooks` via `mergeHooks`, not a shallow spread. Passing hooks: { afterChange: [myHook] } to createMembershipCollection APPENDS myHook after the built-in memberCount-sync hook — it does not replace it. If you need to reorder or suppress a built-in hook, do it explicitly; do not rely on hook-array order being anything other than "factory defaults first, your hooks appended."
  • `createMembershipCollection`'s `entityType: 'squadron'` option is inert unless you pass `squadronSlug`. The select has offered 'squadron' since the MVP spec with no backing relationship field. Pass squadronSlug: 'squadrons' (or your override) if you have @wabbit/tome-sc installed and want that option to actually populate a relationship; otherwise leave it unset and treat 'squadron' as reserved-but-unimplemented.

Adopting ranks from an existing app

createRankCollection was designed for a fresh install (public read, admin-only CRUD, 8 generic categories, no delete guard). Adopting it over an app with an EXISTING rank table — different category vocabulary, different field names, a members-only read policy, a delete guard — needs the options below instead of a fork. All are opt-in; omitting every one of them reproduces today's exact default output (pinned by tests/factory-snapshots.test.ts).

import { createRankCollection } from '@wabbit/tome-org'
import { authenticated } from '@wabbit/tome-core/auth/guards'

createRankCollection({
  // Public read is a real posture choice, not a given — flip it for a
  // members-only roster. `access` is a full CollectionConfig['access']
  // shape; only the keys you pass are overridden, the rest keep the
  // factory's defaults (create/update/delete stay MANAGE_STRUCTURE-gated).
  access: { read: authenticated },

  // Extend (not replace) the default vocabulary with your own values.
  // Duplicate `value`s are deduped — the default wins.
  categoryOptions: { mode: 'extend', options: [{ label: 'Ace', value: 'ace' }] },
  categoryRequired: true, // if every existing row already has a category

  // Rename the emitted fields to match an existing table instead of
  // migrating data. You own keeping your own queries/populate hints
  // consistent with whichever names you configure.
  insigniaTextField: 'insigniatext', // one-character spelling divergence is easy to miss
  insigniaImageField: 'image',

  // Append fields your app already has that this factory doesn't model.
  extraFields: [
    { name: 'discordRoleId', type: 'text' },
    { name: 'promotionPrerequisites', type: 'array', fields: [{ name: 'requirement', type: 'text' }] },
    { name: 'ceremonyScript', type: 'richText' },
  ],

  // The deferred MVP+1 guard: throws a 400 APIError naming the holder
  // count instead of deleting a rank members still hold. A `payload.count`
  // READ against `memberSlug`/`memberRankField` (default `'org-members'`,
  // `'rank'`) — never a write, so it cannot loop back into Member.
  guardDeleteWhenHeld: true,
  memberSlug: 'members', // your Member collection's actual slug
  memberRankField: 'rank',

  // Your own hooks compose alongside guardDeleteWhenHeld via mergeHooks —
  // they never replace it, regardless of which hook key you use.
  hooks: { afterChange: [myRankAuditHook] },

  // Don't want the built-in `abbreviation` field, or want to supply your
  // own `slug` field(s) instead of the factory's? Use these instead of
  // filtering/replacing the factory's output after the fact.
  includeAbbreviation: false,
  slugField: [
    { name: 'slug', type: 'text', unique: true },
    { name: 'slugLock', type: 'checkbox', defaultValue: false }, // e.g. a paired slug + lock-field setup
  ],
  // — or slugField: false to omit the field entirely (you supply your own via extraFields).
})

slugField is the SAME option, with the SAME contract (Field[] | false, default = today's built-in field), on createDivisionCollection and createTeamCollection too — all three factories emit the identical built-in slug field shape, so the override is shared rather than reinvented per collection. includeAbbreviation is Rank-only: Division and Team have no abbreviation field to gate.

Adopting divisions/teams from an existing app

Some consumers call Divisions/Teams "Wings"/"Units" and model leadership by Position (Billet) reference rather than by Member — e.g. wings.leadership.commanderBillet vs Tome's Division.leader. createDivisionCollection/createTeamCollection grew a leadershipMode option for this rather than flipping the default, since existing consumers depend on today's member-referenced leader/deputy.

import { createDivisionCollection, createTeamCollection } from '@wabbit/tome-org'

createDivisionCollection({
  leadershipMode: 'position',
  positionSlug: 'billets', // your Position/Billet collection's actual slug
  leadershipFields: {
    commander: 'commanderBillet',
    executiveOfficer: 'executiveOfficerBillet',
    seniorNCO: 'seniorNCOBillet',
  },
})

createTeamCollection({
  leadershipMode: 'position',
  positionSlug: 'billets',
  leadershipFields: { leader: 'commanderBillet', other: 'otherLeadershipBillets' },
  divisionRequired: false, // example: division deletion nulls team.division rather than cascading
})

'position' mode REPLACES leader/deputy with a leadership group — it does not emit both. 'member' (the default, omit leadershipMode entirely) reproduces today's exact output, pinned by tests/factory-snapshots.test.ts.

Four additional gaps found in a real adoption. A consumer's own Division/Team adapters each needed a field or group kept local, verbatim, instead of letting the factory emit it. All four gaps below are closed by additive, opt-in options; defaults are unchanged:

import { createDivisionCollection, createTeamCollection } from '@wabbit/tome-org'

createDivisionCollection({
  leadershipMode: 'position',
  leadershipFields: {
    commander: 'commanderBillet',
    executiveOfficer: 'executiveOfficerBillet',
    seniorNCO: 'seniorNCOBillet',
  },
  // NEW: field-level access on the leadership group. A consumer can lock it
  // behind their own permission check — before this option existed, there
  // was no seam for that, forcing the whole group to stay a hand-written
  // local field.
  leadershipAccess: { update: canManageHierarchy },
  // NEW: passthrough for fields this factory doesn't model.
  extraFields: [
    { name: 'discordCategory', type: 'text' },
    { name: 'discordRole', type: 'text' },
  ],
})

createTeamCollection({
  leadershipMode: 'position',
  leadershipFields: {
    leader: 'commanderBillet',
    // NEW: naming these widens Team's leadership group from the original
    // 2-field shape (leader + other[]) to Division's 3-singular + other[]
    // shape. Each is emitted ONLY when named — omit both to keep the
    // original 2-field output byte-identical.
    executiveOfficer: 'executiveOfficerBillet',
    seniorNCO: 'seniorNCOBillet',
    other: 'otherLeadershipBillets',
  },
  leadershipAccess: { update: canManageHierarchy },
  // NEW: renames the Division-backlink field itself. Before this, it was
  // hardcoded to 'division' with no override — a consumer's Team adapter
  // had to keep its own 'wing' field local just for this. The export-only
  // structural hooks (createDivisionTeamReciprocityHook's `divisionField`,
  // createDivisionCleanupHook's `teamDivisionField`) already accepted this
  // same name as a config option — pass the same value to both.
  divisionField: 'wing',
  extraFields: [
    { name: 'vcsCode', type: 'text' },
    { name: 'standingOrders', type: 'richText' },
  ],
})

The four structural hooks below are exported factories, NOT auto-wired onto `createDivisionCollection`/`createTeamCollection`. A site without a Division/Team hierarchy installed must not gain a hook that queries collections it doesn't have — you opt in by attaching each one to your own collection's hooks, composed through mergeHooks so anything else already there survives.

import {
  createMemberCountSync,
  createDivisionTeamReciprocityHook,
  createTeamSunsetHook,
  createDivisionCleanupHook,
  createTeamCleanupHook,
  mergeHooks,
} from '@wabbit/tome-org'

// Member collection — keeps Division.memberCount / Team.memberCount honest.
// Counts the union of the hasMany array and the singular primary field, so
// a consumer using only one of the two conventions doesn't get undercounted.
const memberCountSync = createMemberCountSync({
  memberSlug: 'members',
  divisionSlug: 'wings',
  teamSlug: 'units',
  fields: { divisions: 'wings', primaryDivision: 'primaryWing', teams: 'units', primaryTeam: 'primaryUnit' },
  onSyncFailure: reportSyncFailure, // your own Sentry/notification reporter — see OrgSyncFailureReporter
})

// Division-like collection ("wings") — reciprocal backlink to its children.
const wingHooks = {
  afterChange: [
    createDivisionTeamReciprocityHook({
      divisionSlug: 'wings',
      teamSlug: 'units',
      teamsField: 'units', // Division's hasMany child-list field name
      divisionField: 'wing', // Team's backlink field name
      onSyncFailure: reportSyncFailure,
    }),
  ],
  afterDelete: [
    yourDiscordCleanupHook, // MUST run first — see ORDER below
    createDivisionCleanupHook({
      memberSlug: 'members',
      teamSlug: 'units',
      teamDivisionField: 'wing',
      divisionsField: 'wings',
      primaryDivisionField: 'primaryWing',
      onSyncFailure: reportSyncFailure,
    }),
  ],
}

// Team-like collection ("units") — sunset cascade + delete cleanup.
const unitHooks = {
  afterChange: [
    createTeamSunsetHook({
      memberSlug: 'members',
      positionSlug: 'billets',
      teamsField: 'units',
      primaryTeamField: 'primaryUnit',
      onSyncFailure: reportSyncFailure,
    }),
  ],
  afterDelete: [
    yourDiscordCleanupHook, // MUST run first — see ORDER below
    createTeamCleanupHook({
      memberSlug: 'members',
      teamsField: 'units',
      primaryTeamField: 'primaryUnit',
      onSyncFailure: reportSyncFailure,
    }),
  ],
}

ORDER requirement — read this before wiring `afterDelete`. createDivisionCleanupHook/createTeamCleanupHook NULL the very references (member.primaryDivision/divisions[], member.primaryTeam/teams[], team.division) that any consumer-side cleanup reading those fields (Discord role/channel teardown is one real consumer's case) needs to still be intact. Put your reference-reading cleanup FIRST in the afterDelete array, then append the cleanup hook from this package after it — exactly the ordering one production consumer's own Division adapter documents inline (afterDelete: [cleanupDiscordOnWingDelete, cleanupWingReferences, ...]). Getting this backwards doesn't throw; it silently leaves your reference-reading cleanup with nothing to read.

Never rethrows. Every hook above swallows its own failures and reports them through the injectable onSyncFailure (default: payload.logger.error, never a throw) rather than failing the write that triggered it — matching a common reporter contract (capture to Sentry + admin notification, never escalate past the caller's decision not to rethrow). Pass your own reporter to get real visibility instead of a swallowed log line.

Squad/squadron cascades are out of scope. A Team-sunset flow may also need to disband Groups/Squadrons under the team — @wabbit/tome-sc owns that domain, so createTeamSunsetHook/createTeamCleanupHook do not reach into it. Chain your own hook after these if you need that cascade too.

Adopting positions from an existing app

createPositionCollection's original authorityLevel field (strategic/operational/tactical/technical) was DESCRIPTIVE — nothing in this package or its known consumers ever read its value. A real consumer's own authorityLevel (organization/cascading/direct/advisory) is the opposite: an EXECUTABLE enum read by its leadership-resolution and permission-check logic to decide who can approve what. Same field name, disjoint vocabularies, one of them load-bearing — a naive merge would have silently disabled cascading authority with no error and no failing test.

The fix is a rename, not a merge: the old field is now `authorityTier` (same four values, same descriptive-only meaning), freeing the name authorityLevel for the executable semantics. This is the one deliberate default-output change in this factory — every other option below is opt-in, and tests/factory-snapshots.test.ts's Position assertions were updated to authorityTier deliberately.

import { createPositionCollection } from '@wabbit/tome-org'

createPositionCollection({
  // Renames Position's own division/team scope fields — these were
  // originally hardcoded to 'division'/'team', which a real adoption pass
  // hit as a gap.
  divisionField: 'wing',
  teamField: 'unit',
  divisionSlug: 'wings',
  teamSlug: 'units',

  // A real consumer has a THIRD, mutually-exclusive scope anchor with no
  // Tome structural correlate — `ship`. `scopeFields` (below) and the
  // `deriveDisplayTitle` walk both default to covering every entry here
  // automatically, so you don't have to repeat the field name twice.
  extraScopeFields: [{ name: 'ship', relationTo: 'commissioned-ships' }],

  // Some consumers have no use for a second, purely-descriptive
  // classification column alongside their real `authorityLevel` below.
  includeAuthorityTier: false,

  // Adds a SEPARATE `authorityLevel` field with a real consumer's four
  // executable values verbatim (labels + admin description) — Tome adopts
  // that behavior as the platform default for this field once a consumer
  // opts in, rather than inventing a third vocabulary.
  includeAuthorityLevel: true,
  // authorityLevelOptions: { mode: 'replace', options: [...] }, // only if your vocabulary diverges further

  // Some consumers' Position/category field is required; Tome's default isn't.
  categoryRequired: true,
  // categoryOptions: { mode: 'extend', options: [...] }, // only if your vocabulary diverges — many consumers' values match Tome's defaults verbatim

  // Ported faithfully from a real consumer's beforeValidate hook: throws
  // only when MORE THAN ONE scope anchor is set. Zero is a valid org-wide
  // position ("or none for org-wide billets"). This is NOT "exactly one" —
  // it's "at most one."
  enforceScopeExclusivity: true,
  // scopeFields defaults to ['wing', 'unit', 'ship'] here — derived from
  // divisionField/teamField/extraScopeFields above. Only pass this
  // explicitly to narrow or reorder it.

  // Absorbs a delete-guard that does two things: a leadership-slot
  // reference check against Division/Team (only meaningful when they run
  // `leadershipMode: 'position'`), and direct holder cleanup — clears the
  // holder's Member doc directly rather than relying on some other
  // afterChange chain to notice the delete, avoiding re-entrancy. Team's
  // check widened from 2 fields (leader/other) to a real consumer's 4
  // (commander/XO/seniorNCO/other) — pass all 4 names to get the wider
  // check; omit executiveOfficer/seniorNCO to keep the original 2-field
  // query byte-identical.
  guardDeleteWhenReferenced: true,
  memberPositionField: 'billet', // your Member collection's actual field name; default 'position'
  divisionLeadershipFields: { commander: 'commanderBillet', executiveOfficer: 'executiveOfficerBillet', seniorNCO: 'seniorNCOBillet' },
  teamLeadershipFields: {
    leader: 'commanderBillet',
    executiveOfficer: 'executiveOfficerBillet',
    seniorNCO: 'seniorNCOBillet',
    other: 'otherLeadershipBillets',
  },

  // Absorbs a beforeChange hook that computes `isVacant` (= !currentHolder)
  // and composes `displayTitle` together — both fields have existed since
  // MVP with nothing computing them. The walk covers ANY scope anchor
  // (wing, unit, OR ship, in that priority order — the first one set on
  // the doc wins), not just division/team.
  deriveDisplayTitle: true,
  // composeDisplayTitle's signature is a plain `{ title, scopeName, scopeKind }`
  // object — `scopeKind` is 'division'/'team'/the extra anchor's own name
  // ('ship' here), letting a composer react to which KIND of scope
  // resolved, not just its name. Default composition is "<scope name> —
  // <title>". One real consumer's own format is "<title>, <scope name>" —
  // pass this to match it:
  composeDisplayTitle: ({ title, scopeName }) => (scopeName ? `${title}, ${scopeName}` : title),

  // Append fields this factory doesn't model (a real consumer's
  // `requiredCertifications`, `academyGrants` — `academyGrants`/
  // `syncAcademyGrants` is LMS-domain and is NOT absorbed here).
  extraFields: [
    { name: 'requiredCertifications', type: 'relationship', relationTo: 'certifications', hasMany: true },
  ],
})

`academyGrants`/`syncAcademyGrants` stay consumer-local. That academy-authority cascade is LMS-domain (@wabbit/tome-lms territory) — this factory does not model it, so one consumer's cross-layer coupling never leaks into a package every consumer installs.

Adopting promotions from an existing app

createPromotionCollection's original shape (flat recommendedBy/approvedBy, lowercase status, an always-on four-eyes guard) is Tome's MVP default. A real production consumer's Promotions collection diverges on every one of those axes — enum case, field shape, immutability, and where four-eyes is actually enforced. Every option below is opt-in; the defaults are unchanged from the original factory output.

import { createPromotionCollection, DEFAULT_PROMOTION_STATUS_OPTIONS } from '@wabbit/tome-org'

createPromotionCollection({
  // Enums are config, not code — a real consumer's capitalized five
  // (thousands of live rows) replace Tome's lowercase five wholesale.
  statusOptions: {
    mode: 'replace',
    options: [
      { label: 'Pending', value: 'Pending' },
      { label: 'Approved', value: 'Approved' },
      { label: 'Completed', value: 'Completed' },
      { label: 'Denied', value: 'Denied' },
      { label: 'Canceled', value: 'Canceled' },
    ],
  },
  statusDefault: 'Pending',

  // A common adoption pattern: fromRank required, member locked once the
  // record exists.
  fromRankRequired: true,
  memberImmutable: true,

  // Swaps the flat recommendedBy/approvedBy for a grouped shape: a
  // recommendations[] array (recommendedBy + reason) plus separate
  // approval/denial groups (approvedBy+approvedAt / deniedBy+deniedAt+reason).
  approvalModel: 'grouped',

  // A pipeline-created ceremony promotion has no human recommender by
  // design — bypasses "at least one recommendation required" when the
  // named field is true.
  validateBypassField: 'systemGenerated',

  // Targeted, type-safe overrides for the grouped model's shape gaps —
  // every key is additive-only, omit any you don't need.
  groupedModelOverrides: {
    recommendations: {
      // admin.condition for the recommendations array field itself.
      condition: (data) => !data.systemGenerated,
      // beforeValidate hooks appended to recommendations[].reason.
      reasonBeforeValidate: [canonicalizeLinkShape(), rejectInlineDataUri({ mode: 'warn', fieldLabel: 'recommendation reason' })],
    },
    approval: {
      // Hide this group until it's populated or status matches (a common pattern).
      condition: (data) => !!data.approval?.approvedBy || data.status === 'Approved' || data.status === 'Completed',
    },
    denial: {
      condition: (data) => !!data.denial?.deniedBy || data.status === 'Denied',
      // A richer node vocabulary for denial.reason so member-facing content
      // (tables, the frontend editor's feature set) doesn't crash Lexical.
      reasonEditor: lexicalEditor({ features: ({ rootFeatures }) => [...rootFeatures, EXPERIMENTAL_TableFeature(), FrontendEditorNodesFeature()] }),
    },
  },

  // The last of the adoptable factories to gain this passthrough — covers
  // 7 fields with no Tome structural correlate at all.
  extraFields: [
    { name: 'authorizedBy', type: 'relationship', relationTo: 'members', admin: { condition: (data) => data.type === 'demotion' } },
    { name: 'systemGenerated', type: 'checkbox', defaultValue: false, admin: { readOnly: true } },
    { name: 'generatedBy', type: 'relationship', relationTo: 'members', admin: { readOnly: true, condition: (data) => !!data?.systemGenerated } },
    { name: 'triggeredByAcademy', type: 'relationship', relationTo: 'academies', index: true, admin: { readOnly: true } },
    { name: 'attendingCeremonies', type: 'relationship', relationTo: 'events', hasMany: true, admin: { readOnly: true } },
    { name: 'calledAtCeremony', type: 'relationship', relationTo: 'events', admin: { readOnly: true, condition: (data) => !!data.calledAtCeremony } },
    { name: 'calledAt', type: 'date', admin: { readOnly: true, condition: (data) => !!data.calledAtCeremony } },
  ],

  // A consumer that already enforces four-eyes itself at the application
  // layer (a self-block + submitter-block check against
  // recommendations[].recommendedBy) would otherwise get a SECOND,
  // independent enforcement of the same rule against fields
  // `approvalModel: 'grouped'` doesn't even populate. Disable it rather
  // than double-enforce.
  fourEyes: false,
})

`extraFields`/`validateBypassField`/`groupedModelOverrides` were added after a real adoption pass found all three gaps and had to keep recommendations/approval/denial as hand-written literals rather than factory output. displayName still collides by name with a consumer's own field-hook version and stays a hand-written literal regardless — extraFields doesn't help there, since the factory already emits its own displayName.

Two more absorbed behaviors are exported factories, NOT auto-wired — same export-only posture as the structural hooks above (createDivisionTeamReciprocityHook et al.): a site without a matching notifications collection or Member.rank field must not gain a hook that assumes one exists.

import { createPromotionNotificationCleanupHook, createMemberRankSyncHook } from '@wabbit/tome-org'

const promotionHooks = {
  afterChange: [
    // Absorbs the CORE of a rank-sync-on-completion flow: writes
    // member.rank on completion, with BOTH of its re-entry guards ported
    // as named, overridable context-flag options. Ceremony linking,
    // onboarding auto-graduation, Discord sync, notifications, and cache
    // revalidation all stay consumer-local — chain your own hook after
    // this one for those.
    createMemberRankSyncHook({
      memberSlug: 'members',
      completedStatus: 'Completed', // matches statusDefault/statusOptions above
      guards: {
        ceremonyClearContextFlag: 'rankUpdateClearCeremonies', // this IS the default
        reconcilerPassContextFlag: 'reconcilerPass',
      },
      onSyncFailure: reportSyncFailure,
    }),
  ],
  afterDelete: [
    // Absorbs a notification-cleanup flow born from a real orphaned-tasks
    // incident: pending/denied notifications are keyed to the promotion by
    // a variable-suffix dedup key (a recipient user id, etc.) with no
    // finite list to enumerate, so the hook discovers keys by PREFIX
    // instead — a discover-then-resolve, `like`-then-startsWith-recheck
    // mechanism.
    createPromotionNotificationCleanupHook({
      notificationsSlug: 'notifications',
      dedupKeyField: 'dedupKey',
      statusField: 'bucket',
      resolvedValue: 'done',
      groupKeyFor: (id) => `promotions:${id}:`, // the default — shown for clarity

      // Closes a gap a parity audit found — the original default write
      // (`{ bucket: 'done' }`) was a strict SUBSET of a real consumer's
      // write. Reaching parity means matching all four fields:
      resolveWrite: () => ({
        bucket: 'done',
        resolutionMode: 'auto_resolved',
        resolvedAt: new Date().toISOString(),
        expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
      }),
      // Per-row side effects this package has no opinion on — one consumer
      // emits a socket event to the recipient and busts their triage cache
      // tag. A throw here is caught/reported per row; it never aborts
      // resolving the rest.
      onResolved: async ({ notification }) => {
        const recipient = typeof notification.recipient === 'string' ? notification.recipient : notification.recipient?.id
        if (!recipient) return
        for (const tag of notificationTags(recipient)) revalidateTag(tag)
        await triggerEvent(`user-${recipient}`, 'notification:resolved', { id: notification.id, resolutionMode: 'auto_resolved' })
      },
      onSyncFailure: reportSyncFailure,
    }),
  ],
}

`DEFAULT_PROMOTION_STATUS_OPTIONS` is exported so a consumer can compose ({ mode: 'extend', options: [...DEFAULT_PROMOTION_STATUS_OPTIONS, ...] }) instead of replacing wholesale.

Never rethrows. Both hooks above swallow their own failures through the same injectable onSyncFailure contract as the structural hooks above (OrgSyncFailureReporter) — a completed promotion's member-rank sync or a deleted promotion's notification cleanup failing must never fail the write that triggered it.

Delegated Authority

Temporary cross-scope command delegation: grants scoped authority over a Division- or Team-shaped entity without touching the underlying Position assignment. Records are NEVER deleted — expired/revoked rows are the audit trail. Absorbed from a real consumer's ActingAuthority collection (11 fields). Not wired into `createOrgLayer` — call it directly with an explicit slug, same as Position/Rank/Promotion.

The scope select's options are DERIVED from divisionField/teamField, not a separate vocabulary: { label: <terminology>, value: divisionField/teamField }. A real consumer's stored values ('wing'/'unit') already equal its own field names for exactly this reason — its scope === 'unit' ? data.unit : data.wing branch only works because the value IS the field name.

import { createDelegatedAuthorityCollection } from '@wabbit/tome-org'

createDelegatedAuthorityCollection({
  slug: 'acting-authority', // a real consumer's slug
  divisionSlug: 'wings',
  teamSlug: 'units',
  positionSlug: 'billets',
  fieldNames: { divisionField: 'wing', teamField: 'unit', position: 'billet' },
})

Field-name mapping (a real adoption):

Neutral (default)ExamplefieldNames key
membermember(unchanged)
scopescope(unchanged)
division (scope value 'division')wing (scope value 'wing')divisionField: 'wing'
team (scope value 'team')unit (scope value 'unit')teamField: 'unit'
positionbilletposition: 'billet'
reason / grantedBy / grantedAt / expiresAt / revokedAt / statussame(unchanged)

The compound-uniqueness guard is always attached (not opt-in — this is a brand-new factory with no existing consumers to keep byte-identical): at most one ACTIVE record per (member, scope entity, position). A member can hold multiple active records over the same scope entity if acting in different positions; a record with no position only conflicts with other no-position records for the same (member, scope entity) pair. Ported faithfully from a real consumer's beforeValidate hook.

The expiry sweep is a separate export, wrapped for both queue and cron use — not a collection hook:

import { asPayloadTask, asCronEndpoint } from '@wabbit/tome-core/jobs'
import { createDelegatedAuthorityExpiryTask } from '@wabbit/tome-org'

const expireActingAuthority = createDelegatedAuthorityExpiryTask({ collection: 'acting-authority' })

// consumer A — long-lived container, Payload queue
tasks: [asPayloadTask(expireActingAuthority, { slug: 'expireActingAuthority' })]

// consumer B — serverless, external scheduler
endpoints: [asCronEndpoint(expireActingAuthority, { path: '/jobs/expire-acting-authority' })]

expireDelegatedAuthority(payload, now, options) is also exported standalone — a pure function, directly unit-testable without stubbing a TaskHandler. Behavior ported faithfully: paged 50-at-a-time, an optimistic "still active?" re-check immediately before each write (so a concurrent manual revocation's audit trail is never overwritten), records are never deleted, and a thrown error is caught/logged rather than propagated (expiry is idempotent, so a scheduler retry is harmless).

Access is not modeled generically. A real consumer's access check is ABAC over unit/wing leadership that this package cannot derive. Pass access to override the generic checkOrgRole(['MANAGE_STRUCTURE']) defaults.

Personnel Notes

Personnel records, disciplinary actions, and leadership notes — absorbed from a real consumer's MemberNotes collection (7 fields, tens of thousands of live rows). IMMUTABLE by default (update: () => false — "Notes cannot be edited once created."). Not wired into `createOrgLayer` — call it directly, same as the other personnel factories.

IMPORTANT: `author` relates to the MEMBER collection, not `users`. A real consumer's field is a members relationship (the officer's member profile, resolved via fetchMember(req) on create) — NOT the officer's user account. This factory does not replicate that auto-population (a fetchMember-equivalent utility is outside this package's scope); pass your own via hooks if you need it. It matters for GDPR: both member and author are member-id-keyed, so "erase both directions" means matching one memberId against TWO fields — see registerPersonnelGdpr below.

import { createPersonnelNotesCollection } from '@wabbit/tome-org'

createPersonnelNotesCollection({
  slug: 'member-notes', // a real consumer's slug
  memberSlug: 'members',

  // Absorbs `setAuthorRankOrder`: denormalizes the author's rank order onto
  // the note at creation time (immutability means it never goes stale),
  // enabling rank-based read filtering without a join at read time. Falls
  // back to a direct Rank lookup if the author's `rank` relationship comes
  // back unpopulated (a bare ID string) rather than treating it as unresolved.
  authorRankOrder: { rankSlug: 'ranks' }, // fail-secures to 99999 (highest = most restricted) on any resolution failure

  // Absorbs an audit-on-create hook — called after every CREATE. A throw
  // here never blocks the note from saving (try/catch-and-log-only).
  auditCreate: async ({ doc, req }) => {
    await req.payload.create({
      collection: 'audit-logs',
      data: { action: 'NOTE_ADDED', target: doc.member, /* ... */ },
      req,
    })
  },

  // Absorbs a read-audit hook — called on every read. The original is
  // OPT-IN per request (`context.enableAccessLog`); this seam does not
  // enforce that itself, so replicate the same check inside your own
  // implementation if you want the identical opt-in behavior.
  auditRead: async ({ doc, req }) => {
    if (!req.context?.enableAccessLog) return
    await req.payload.create({ collection: 'access-logs', data: { /* ... */ }, req, overrideAccess: true })
  },
})

Field-name mapping (a real adoption): member/author/type/content are overridable via fieldNames (all unchanged by default); classification and relevantDate are always-on, neutral-named — no divergence was found in that consumer's data for either.

Access is not modeled generically. Real-world access to this collection tends to be type-aware ABAC (per-type permission gates + chain-of-command) that this package cannot derive. The generic defaults reuse this package's pre-existing MANAGE_MEMBER_NOTES/CREATE_MEMBER_NOTES/DELETE_MEMBER_NOTES permission keys, shipped ahead of this collection for exactly this reason. Pass access to override.

Status Requests

Approval pipeline for member status changes (discharge, retirement, ban, plus self-requested short-leave/LOA) — absorbed from a real consumer's MemberStatusRequests collection (14 live rows). Not wired into `createOrgLayer` — call it directly, same as the other personnel factories.

Org owns the RECORD shape ONLY. No transition/execution hook is modeled — a real consumer's execution flow writes the settled status back via a DIRECT MONGO updateOne, bypassing Payload hooks entirely. That bypass IS the infinite-loop guard: the transition detector reads previousDoc.status !== 'executing', and routing the write through payload.update() instead would re-fire the same afterChange hook and offboard the member TWICE before the guard could engage. A consumer wires this transition engine as a separate module beside their adapter (the one calling createStatusRequestCollection) — using @wabbit/tome-workflow or a hand-rolled hook, either way OUTSIDE this factory.

import { createStatusRequestCollection } from '@wabbit/tome-org'

createStatusRequestCollection({
  slug: 'member-status-requests', // a real consumer's slug
  divisionSlug: 'wings',
  fieldNames: { scopedDivision: 'scopedWing', targetPrimaryDivisionAtTime: 'targetPrimaryWingAtTime' },
  titleDisplayField: 'rsiHandle',
  approverScopeOptions: {
    mode: 'replace',
    options: [
      { label: 'Wing CO/XO', value: 'wing_co' },
      { label: 'VLC', value: 'vlc' },
    ],
  },
  divisionScopedValue: 'wing_co',
})

type/status default to a real consumer's own five-value vocabularies verbatim (generic HR/pipeline terms, no divergence found). approverScope is the one exception: that consumer's real values (wing_co/vlc) are organization-specific jargon, so this factory ships a Tome-neutral default (division_lead/organization) and expects a { mode: 'replace' } override, same posture as RankCollectionConfig.categoryOptions.

Approval/denial groups are the SAME shared shape as Promotion's `approvalModel: 'grouped'` (buildApprovalDenialGroups, extracted in this increment) — approval: {approvedBy, approvedAt} + denial: {deniedBy, deniedAt, reason}. groupedModelOverrides mirrors PromotionCollectionConfig.groupedModelOverrides's contract exactly.

The built-in title-composition hook (computeTitle, default true) reproduces a common inline beforeChange pattern: "${capitalize(type)}: ${target member's titleDisplayField}", falling back to 'pending' when the target hasn't resolved. Set computeTitle: false to skip it.

Conduct Records

Formal discipline case tracking — absorbed from a real consumer's DisciplineRecords collection (2 live rows). Every field name defaults to that consumer's own — no rename was needed, only the collection SLUG differs by default (conduct-records vs. discipline-records). Not wired into `createOrgLayer`.

import { createConductRecordCollection } from '@wabbit/tome-org'

createConductRecordCollection({
  slug: 'discipline-records', // a real consumer's slug
  promotionSlug: 'promotions',

  // Absorbs enforceCoolingPeriod — severities has no default (severityOptions is itself config).
  coolingPeriod: {
    severities: ['written-warning', 'probation', 'demotion-recommendation', 'ban-recommendation'],
    days: 1, // 24 hours
  },

  // Absorbs autoAssignAppealReviewer's bookkeeping; the chain-of-command WALK is yours.
  autoAssignReviewer: {
    poolQuery: async ({ accountableOfficerId, excludeIds, req }) => {
      // billet -> unit CO/XO -> wing leadership -> org-level authority walk
      return resolveNextAuthority(req.payload, accountableOfficerId, excludeIds)
    },
  },

  // Absorbs anonymizeForSubject — "IS the subject, and not privileged" is yours to decide.
  subjectRedaction: {
    predicate: async ({ doc, req }) => {
      const viewer = await fetchMember(req)
      return viewer?.id === doc.subject && !checkPermission(PERMISSIONS.MANAGE_DISCIPLINE, req.user)
    },
  },
})

What stays consumer-side (never modeled, DO-NOT-DOUBLE): triggerStandingChange (cascades onto members via payload.update() — this factory emits relatedStandingChange as a plain field with the MODIFY_STANDING field-level gate absorbed, but never wires the write-back hook), auditDisciplineRecord (writes an audit-logs row this package doesn't own), and the 4 bespoke ABAC access functions (canReadDisciplineRecords's rank-floor query, etc. — pass access to override the generic MANAGE_DISCIPLINE-gated defaults).

What IS absorbed: enforceCoolingPeriod → coolingPeriod; setDisplayName → computeDisplayName/displayNameCompose (default composes "${severity label} — ${subject's subjectDisplayField}", reading the label from severityOptions rather than a second hardcoded table); autoAssignAppealReviewer → autoAssignReviewer (transition-detection + exclusion-set bookkeeping absorbed, the authority WALK is your poolQuery); anonymizeForSubject → subjectRedaction (field-nulling absorbed, the "is this reader the subject" ABAC is your predicate).

Expert Designations

Subject-Matter Expert designations — absorbed from a real consumer's SMEDesignations collection (0 live rows). Not wired into `createOrgLayer`.

LMS territory (a consumer's `specialistPath`/`qualifyingTier`) is deliberately NOT modeled — LMS stays opaque to org, per this package's layer boundary. Add it via extraFields:

import { createExpertDesignationCollection } from '@wabbit/tome-org'

createExpertDesignationCollection({
  slug: 'sme-designations', // a real consumer's slug
  memberDisplayField: 'rsiHandle',
  canEndorse: async ({ req, id }) => {
    // Academy Director + unit/wing leadership over the designation's specialist-path unit
    return canEndorseSME({ req, id })
  },
  // Reproduces a real consumer's "${member.rsiHandle} — ${path.name}" once specialistPath exists:
  composeDisplayName: async ({ req, data }) => {
    const path = await req.payload.findByID({ collection: 'specialist-paths', id: data.specialistPath, depth: 0 })
    const member = await req.payload.findByID({ collection: 'members', id: data.member, depth: 0 })
    return `${member.rsiHandle} — ${path.name}`
  },
  extraFields: [
    { name: 'specialistPath', type: 'relationship', relationTo: 'specialist-paths', required: true, index: true },
    { name: 'qualifyingTier', type: 'number', required: true },
  ],
})

A real consumer's compound-unique index on (member, specialistPath) needs specialistPath, which lives in extraFields — add it by mutating the returned config: cfg.indexes = [{ fields: ['member', 'specialistPath'], unique: true }].

`canEndorse` is an endorsement-access seam, not a full override: update access is true for MANAGE_SME_DESIGNATIONS holders OR when canEndorse resolves true. A real consumer's own endorsement check is a cascading-authority ABAC check (Academy Director scope, unit/wing leadership) this package cannot derive generically.

Discord notification stays entirely consumer-side, and runs last for free. This factory attaches NO afterChange hooks of its own — because mergeHooks APPENDS config.hooks after this factory's built-ins (hooks always append, never replace), a consumer's notifyDiscord passed via hooks.afterChange automatically runs last relative to anything this factory ever adds, reproducing a "Discord fires last so a webhook failure cannot block downstream hooks" ordering with no special wiring.

registerPersonnelGdpr — GDPR registration for all five personnel factories

import { registerPersonnelGdpr } from '@wabbit/tome-org'

registerPersonnelGdpr({
  delegatedAuthoritySlug: 'acting-authority', // default 'delegated-authority'
  personnelNotesSlug: 'member-notes', // default 'personnel-notes'
  statusRequestsSlug: 'member-status-requests', // default 'status-requests'
  conductRecordsSlug: 'discipline-records', // default 'conduct-records'
  expertDesignationsSlug: 'sme-designations', // default 'expert-designations'

  // A real erasure engine needs to be per-row tolerant for a collection
  // this large: one locked row under 'bulk' would abort the WHOLE delete
  // with zero rows erased.
  personnelNotesDeleteShape: 'tolerant-per-row',
})

Not called automatically by any factory — call it explicitly (or roll your own registration), same posture as @wabbit/tome-sc's registerScGdpr.

CollectionFieldsModePhaseWhy
delegated-authoritymember (subject), grantedBy (officer)retainpost-identityorg history — current holder refs are already cleared by offboarding
personnel-notesmember (subject), author (officer, ALSO member-keyed)hard-delete, both directionspre-identitypersonal content requires erasure, not just reference-nulling
status-requeststargetMember (subject), officer refsretainpost-identitydecision records — part of the org's governance history
conduct-recordssubject, officer refsretainpost-identitylegal-obligation/legitimate-interest — subjectRedaction already governs disclosure
expert-designationsmember (subject), endorser/revoker refsretainpost-identityorg history — current holder refs already cleared by offboarding

personnel-notes needs a CUSTOM onDelete rather than the registry's built-in userField/memberField OR pair: that pair always matches userField against userId, never memberId — but a real consumer's author is ALSO member-keyed, so "both directions" means matching one memberId against TWO DIFFERENT fields. deleteAs: ['member', 'author'] (the default) can be narrowed to one direction if your retention posture ever needs it.

Per-collection opt-out: each of the five registrations has its own register*: boolean (default true) — registerConductRecords: false skips ONLY that one, the other four still fire. Same seam as createFulfillmentLayer's/createSCLayer's registerGdpr: false, scoped per-collection since this function registers five collections in one call.

Per-registration `phase`/`order`: every registration's phase (*Phase) and order (*Order) are independently overridable — e.g. statusRequestsPhase: 'identity', statusRequestsOrder: 5 repositions it relative to a consumer's other GDPR registrations. Defaults reproduce every registration's pre-existing hardcoded position exactly.

`deleteShape: 'bulk' | 'tolerant-per-row'` (personnel-notes only — the sole hard-delete registration here): 'bulk' (default) is today's single payload.delete({ where: { or } }) call. 'tolerant-per-row' finds the matching rows first, then deletes each in its own try/catch — matching a real large-scale erasure-engine's shape, which needs this for a 20k+-row collection: one bad row under 'bulk' aborts the ENTIRE delete with zero rows erased; under 'tolerant-per-row' every other row still gets erased and the bad one surfaces in errors.

Recruiting cluster

Staged member-intake pipeline: an Application (staged-intake entry point), a target-injectable JoinRequest against any org node (division/team/guild/chapter), and an OnboardingRecord tracking a member's post-acceptance onboarding progress. Not wired into `createOrgLayer` — call the factories directly, and only @wabbit/tome-org/collections/application, @wabbit/tome-org/collections/joinRequest, @wabbit/tome-org/collections/onboardingRecord also expose them as standalone subpaths (in addition to the root barrel).

FactoryPurpose
createApplicationCollectionStaged-intake application — the recruitment pipeline's entry point
createJoinRequestCollectionTarget-injectable join request against any org node (division, team, guild, chapter)
createOnboardingRecordCollectionA member's onboarding progress record

All three share createGuardDirectTransitionHook, createPendingStatusNotificationHook, and createTaskCleanupOnDeleteHook — export-only adoptable hooks, opted into via the collections' own config keys, never auto-wired.

Recognition cluster

Formal and peer-to-peer member recognition: a catalog/definition trio (Award, Medal, Ribbon — the "what can be awarded" side), AwardPresentation (the "who actually got one" side — an individual presentation of an Award/Medal/Ribbon to a member), Kudos (peer-to-peer appreciation, distinct from the leadership-authorized catalog trio), and Memorial ("In Memoriam" tributes). Not wired into `createOrgLayer` — call the factories directly.

FactoryPurpose
createAwardCollectionCatalog/definition collection for formal organizational awards
createMedalCollectionSibling of createAwardCollection, sharing its catalog-item field builders
createRibbonCollectionSibling of createAwardCollection/createMedalCollection
createAwardPresentationCollectionAn individual presentation of an Award/Medal/Ribbon to a member, on a date
createKudosCollectionPeer-to-peer member recognition/appreciation, open to any authenticated member
createMemorialCollectionPublic "In Memoriam" tributes — long-form writeup, quote wall, image gallery, optional embedded video

The trio (Award/Medal/Ribbon) share catalog-item field builders from ./collections/recognition/catalogItemFields.ts (buildCatalogSlugField (deprecated — use buildOrgSlugField), buildCatalogNameField, etc., all exported from the root barrel). registerRecognitionGdpr/unregisterRecognitionGdpr register this cluster's GDPR erasure separately from registerPersonnelGdpr above. Adoptable hooks (export-only, never auto-wired except AwardPresentation's own opt-in recipientCountSync): createRecipientCountSyncHook, createAwardedBySelfEnforcementHook, createDisplayNameComposeHook.

Exports

Import path
@wabbit/tome-org
@wabbit/tome-org/collections/application
@wabbit/tome-org/collections/joinRequest
@wabbit/tome-org/collections/onboardingRecord

Changelog

0.19.xMinor

  • BREAKING: Every org collection now reads org members only by default (orgMemberRead); a signed-in account with no member row reads nothing.

    Before, each collection read core's authenticated: any signed-in account read every row. That was fine on a members' portal, where every account belonged to a member, but on a site with public sign-up it handed every roster, squad, team, position, award presentation, kudos, document, event and delegated-authority row to a stranger with an account.

    The new default, orgMemberRead, answers true for staff, for a holder of the collection's own write permissions, and for an account with a row in the member collection; false for everyone else, anonymous or signed in. It is always a boolean, never a filter.

    Migration: nothing to do on a site where every account has a member row. A site that relies on any-signed-in reads passes access: { read: authenticated } on the factory, or on the layer's per-collection access map; a consumer-supplied access.read wins exactly as before.

    A site with a stricter notion of membership passes access: { read: orgMemberRead({ memberSlug, isMember }) } and keeps the staff and manager rules. The Member collection now carries an explicit read (the same rule) instead of relying on Payload's signed-in default.

    createEventSlotCollection, createAwardCollection, createMedalCollection and createRibbonCollection gain a memberSlug option so the lookup follows a renamed member collection. The opt-in public-read rules are unchanged.

    isOrgStaffOrManager (staff or a listed permission, no membership) is exported alongside isOrgReader.

2 fixes (0.19.1 – 0.19.2)
  • 0.19.2
    Canceled statuses now accept both spellings.

    This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet.

    Training events, attendance, exam tickets, LMS orders, events, campaigns, CRM activities, resource requests, task forces and the event, status board and operation briefing blocks gain the American canceled (CANCELED on the status board) next to the existing value. Reads, queries, the closed-event lock, event board state and selection, the calendar export (STATUS:CANCELLED is unchanged, it is the iCalendar literal), the status board badge, theme selectors for data-event-status and the erasure trail treat the two spellings as one meaning.

    The event list board labels gain a canceled key that is read before the old cancelled key. The old field is hidden, not removed, so stored text is kept on save.

    The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)".

    Writers still store the old values.

  • 0.19.1

    Admin labels, field descriptions, demo content and messages now use American spellings.

0.18.xMinor

  • createOrgLayer takes an events group, so a one-call site can rename event types, swap status wording, replace the location field and add fields.

    The six keys are eventTypeOptions, locationField, statusOptions, statusDefault, extraFields and extraFieldsAfter, the same keys createEventCollection already takes; a site that builds that collection itself passes them there directly. Each is forwarded only when set, the other twelve collections are never touched, and a layer built without the group is unchanged.

    The group's type is exported as OrgLayerEventsConfig. On a SQL database a new field is a new column, so regenerate your migration if you add one.

0.17.xMinor

  • Documents can now be read in public, opt-in, and a public register of them cannot leak an internal one.

    createOrgLayer({ publicRead: { documents: true } }) (or createDocumentCollection({ publicRead })) adds a visibility select that defaults to internal, so turning the option on publishes nothing until an editor marks a document public. An anonymous visitor then reads only documents that are public, approved or published (archived and draft are never public) and not restricted to a division or team.

    author and collaborators are hidden from anonymous readers and cannot be used as filters. An explicit access.read still wins.

    New exports: buildPublicDocumentsWhere, publicDocumentsRead, PublicDocumentsReadOptions, PublicDocumentsCollectionOptions, DEFAULT_PUBLIC_DOCUMENT_GUARDED_FIELDS, DEFAULT_PUBLIC_DOCUMENT_STATUSES. A public register (an archive registration) should pass buildPublicDocumentsWhere() as its fixed where: a member's read is unrestricted, so access alone would show internal documents to every member.

    documents: { files: true } (or files on the factory) adds an upload field after content (default name file, relation to mediaCollection unless uploadCollection is set). Uploads are public by URL, so while public read is on the field refuses a file on a document whose visibility is not public, on create and when a document that holds a file is switched to internal; allowOnInternal: true lifts the rule for a site with a private upload collection.

    documents: { categoryOptions } (or categoryOptions on the factory) extends or replaces the category vocabulary; DEFAULT_DOCUMENT_CATEGORY_OPTIONS is exported and equals today's eleven values.

    With none of these options the collection is byte-identical to before: no visibility or file field, read: authenticated, the same category options (a golden cut from the pre-change source holds this). On a SQL database the visibility and file fields are new columns only on sites that opt in, which then generate a migration.

    • Signed in is not the same as a member. With public read on, the full document read (every row, author and collaborators readable and filterable) goes only to staff (a role with accessType: 'admin'), document managers (MANAGE_DOCUMENTS or DELETE_DOCUMENTS) and org members (a row in the member collection whose user is the signed-in user). Every other signed-in account, such as a customer or donor created by a public sign-up, reads exactly what an anonymous visitor reads: approved or published, public, unrestricted documents, with author and collaborators hidden and refused as filters. New exports: isOrgDocumentReader, documentReaderFieldRead, DocumentReaderOptions; publicDocumentsRead also takes memberSlug and memberUserField, and guardFieldsForSignedIn takes an optional field guard. The layer has no restrictedTo* rule for signed-in readers, so members still read restricted rows, as they did before the option. With the option off nothing changes: read: authenticated, held by the default-collection golden.
    • BREAKING: for sites that turned on publicRead.events, .divisions or .members in 0.16.0, signed-in accounts that are not staff, permission holders or org members now read only the public set (see the next bullet). A site that relied on every signed-in account seeing the full read must pass permissions to the rule or set an explicit access.read.
    • Security fix for the 0.16.0 public read of events, divisions and members: signed in is not the same as a member there either. With publicRead.events, .divisions or .members on, any signed-in account got the full read, so on a site with public sign-up a new customer account could read members-only and restricted events (with briefing, organizer and co-hosts), disbanded divisions, and classified members with user, reportsTo, position, timezone, region and last-promoted date. The full read now goes only to staff (accessType: 'admin'), holders of the collection's own write permissions (events: CREATE_EVENTS, EDIT_EVENTS, DELETE_EVENTS, MANAGE_EVENTS; divisions: EDIT_STRUCTURE, DELETE_STRUCTURE, MANAGE_STRUCTURE; members: INDUCT_MEMBERS, MANAGE_UNIT_ROSTER, MANAGE_STRUCTURE) and org members. Every other signed-in account reads exactly what an anonymous visitor reads, with the guarded fields hidden and refused as filters. A site that wants some other signed-in audience to see more can pass permissions to the rule or an explicit access.read. New exports: isOrgReader, orgReaderFieldRead, resolveOrgReaderOptions, ORG_READER_PERMISSIONS, OrgReaderOptions; publicEventsRead, publicDivisionsRead and publicMembersRead take memberSlug, memberUserField and permissions. With the options off nothing changes (read: authenticated on events and divisions, Payload's signed-in default on members).
1 fix (0.17.1)
  • 0.17.1

    README: subpath and file mentions are written package-qualified (for example @wabbit/tome-x/render) or as code text, so crawlers no longer resolve them as site URLs.

0.16.xMinor

  • createOrgLayer takes includeWaitlistCount and passes it to the events collection.

    createEventCollection already had the option, which adds a read-only waitlistCount counter right after attendeeCount, but createOrgLayer never passed it on, so a site on the one-call layer could not turn it on. createOrgLayer({ includeWaitlistCount: true }) now does.

    It is off by default, and a layer built without it (or with it false) is structurally identical to before. On a SQL database the counter is a new column, so a site that turns it on and uses migrations generates one.

    Purely additive: no peer is added or tightened, and nothing changes for a site that does not set it.

0.15.xMinor

  • Security/visibility fix: new opt-in publicRead lets a public site show public events, active divisions and public members to anonymous visitors without leaking private rows or fields.

    createOrgLayer({ publicRead: { events, divisions, members } }), or publicRead on createEventCollection / createDivisionCollection / createMemberCollection, applies the new publicEventsRead, publicDivisionsRead and publicMembersRead rules. Signed-in users keep today's read.

    Anonymous visitors get a database-side filter built from each collection's own fields: events whose visibility is public and whose status is not draft; divisions whose status is active; members whose directory visibility is public and whose standing is active. Field-level access.read hides briefing, the organizer and the co-hosts on events, and user, reportsTo, position, timezone, region and lastPromoted on members (extraGuardedFields adds more).

    Use this instead of reading with overrideAccess: true from a public page, which ignores visibility and exposes members-only events. Nothing changes for a site that does not opt in, and an explicit access.read still wins.

0.14.xMinor

  • Thread the fieldShape pipeline, decidedByRelationTo, notification fan-out, and checklist item field names through the recruiting factories (R2-I1.1) — the seam gaps a consumer adapter's correct STOPs found and R2-I2 confirmed empirically (createApplicationCollection({ omitFields: [...] }) silently ignored it).

    Fix — fieldShape pipeline now threaded through Application/JoinRequest/OnboardingRecord. None of the three recruiting factories applied omitFields/fieldOverrides/fieldOrder, and none accepted extraFieldsAfter (interior splice) — extraFields only ever appended at the tail. All three now run the same wiring order createAwardCollection established (extraFields via insertFieldsAfter → applyFieldOverrides → applyOmitFields → applyFieldOrder).

    Zero-config output is byte-identical; every new key is a no-op when omitted.

    Fix — JoinRequest `decidedByRelationTo`. decidedBy unconditionally shared requester's relation (memberSlug) with no way to point it elsewhere. A production consumer's real shape needs decidedBy on 'users', not the member collection.

    New decidedByRelationTo config key, defaulting to memberSlug (byte-identical back-compat).

    New — `adminOverrides` on all three recruiting factories. useAsTitle/defaultColumns/group/description were hardcoded in each factory's return statement, reachable nowhere except adminGroup (which only ever reaches group). This is a new convention, not an existing house mechanism being wired through — no other factory in @wabbit/tome-org exposes more than adminGroup either (Award/EventSlot included).

    Scoped to the three recruiting factories per this increment's brief, not a silent package-wide fix. adminOverrides shallow-merges last, so adminOverrides.group wins over adminGroup when both are given.

    Fix — `createPendingStatusNotificationHook` gains `buildNotifications` (plural). The singular buildNotification can express only one notification row per open transition; a production consumer needs N independent rows — one per resolved reviewer/officer. buildNotifications returns an array; each entry is created independently, deduped by its own key (an entry may set dedupKey explicitly — e.g. per-recipient — or falls back to ` ${baseDedupKey}:${index} ), and one entry's create failure is reported via onSyncFailure` without blocking the rest.

    Back-compat: buildNotification alone still works; buildNotifications wins when both are given. The hook now throws at creation time if neither is provided (previously buildNotification was a required config key — this is the same requirement, restated to cover the new either/or).

    Fix — sparse-checklist item field names. buildSparseChecklistGroupField's completions[] rows hardcoded itemKey/completedAt/completedBy with no rename seam — the one holdout among this package's field-producing builders. New itemFieldNames (on the builder directly, and per-entry on createOnboardingRecordCollection's checklistGroups) renames them; a production consumer needs templateItemId/checkedAt/checkedBy.

    Omitted keys keep the default names.

    All five gaps ship with red-first tests (each proven failing against pre-fix behavior) plus zero-config byte-identical pins for all three factories. Full @wabbit/tome-org suite: 849/849 (814 baseline + 35 new).

12 fixes (0.14.1 – 0.14.6)
  • 0.14.6
    Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps.

    What you install: one compiled .js (ESM) and .cjs (CommonJS) file per source module, its .d.ts / .d.cts declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: /*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */.

    The .map files and the sourceMappingURL comments that pointed at them are gone, which roughly halves the size of each tarball.

    Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line.

    A 'use client' directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged.

    No API change, no runtime behavior change, and nothing to do on upgrade.

    In @wabbit/tome-blocks-gallery, the source snapshots extractGallerySource writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 0.14.5
    createRibbonCollection({ certificationSlug: false }) omits the eligibility.requiredCertification relationship.

    That relationship points at an LMS-owned collection. On a site with no LMS installed it failed Payload's config validation at startup, and nothing short of dropping the Ribbon collection could fix that.

    The default ('certifications') and custom slugs are unchanged.

  • 0.14.4
    Internal refactor: collection slugs are now typed through the shared typedSlug() helper instead of inline casts.

    No API or behavior change.

  • 0.14.4

    customer-facing wording: internal references removed from admin descriptions, error messages and block metadata.

  • 0.14.3
    autoSlugHook is now a typed delegate to core's autoSlugFieldHook, with an identical body.

    Two domain layers cannot import each other, so the shared body moved down to core.

  • 0.14.3
    Local relationship-id helpers are replaced by @wabbit/tome-core/utilities/relationId.

    Each call site maps to the core reader with the same return shape (raw vs. stringified id, null vs. undefined, polymorphic), so behavior is unchanged. The @wabbit/tome-core peer floor goes up to >=1.17.0 because that is the first core version exporting relationIdRaw, relationIds and relationIdsRaw.

    extractRelationshipId and extractRelationshipIds (public) are kept as deprecated delegates to core, with identical semantics.

  • 0.14.2
    Fix createAwardPresentationCollection's recipient-count recount resolving the wrong catalog collection slug, which silently broke recipientCount on every write for a consumer using the factory's default catalog slugs.

    Root cause: createRecipientCountSyncHook's own default resolveTarget treats the discriminator FIELD VALUE (awardType: 'awards'/'medals'/'ribbons') as the target collection slug — a reasonable default for a generic hook, but createAwardPresentationCollection names its catalog collections awardSlug/medalSlug/ribbonSlug (default 'org-awards'/'org-medals'/'org-ribbons', each independently overridable), never the bare type value. Every afterChange/afterDelete on an AwardPresentation therefore attempted payload.update({ collection: 'awards', ... }) against a collection that doesn't exist, logging "recipientCount recount failed for awards id …: The collection with slug awards can't be found" and leaving recipientCount frozen (found on tome-starter 2026-09-12, where the consumer had already carried a resolveTarget/countWhere override as a bandaid — this patch replaces that at the root).

    The fix: createAwardPresentationCollection now derives its own resolveTarget (discriminator type → the factory's resolved awardSlug/medalSlug/ribbonSlug) and matching countWhere (reversing the resolved slug back to the discriminator's type value, so the presentation-collection where clause still compares awardType to 'awards', never to 'org-awards') from its own slug options, passed as the base recipientCountSync config BEFORE ...config.recipientCountSync — so a consumer's own explicit resolveTarget/countWhere override still wins unchanged.

    Backward compatibility: a consumer who already worked around this (a custom recipientCountSync.resolveTarget/countWhere) is unaffected — their override still takes precedence. A consumer relying on the (broken) default now gets a working recount instead of a silently-failing one; no API shape changed.

  • 0.14.1
    Import mergeHooks, fieldShape and the select-option override contract from @wabbit/tome-core instead of keeping local copies (2026-09-01 sale-readiness audit §5.1, T3(a)).

    No API change: every symbol these packages exported before is still exported, now re-exported from core, and every factory produces byte-identical output.

    Deleted, with every call site repointed:

    • org/src/hooks/mergeHooks.ts, lms/src/collections/shared/mergeHooks.ts, sc/src/extensions/mergeHooks.ts → @wabbit/tome-core/hooks/mergeHooks. Core has exported this since July; sc's copy still carried a header claiming "Neither @wabbit/tome-core nor @wabbit/tome-org exports this."
    • lms/src/server/jobs/paginate.ts, crowdfund/src/server/jobs/paginate.ts, workflow/src/server/paginate.ts → @wabbit/tome-core/jobs.
    • org/src/fieldShape.ts + org/src/insertFieldsAfter.ts, lms/src/collections/shared/fieldShape.ts → @wabbit/tome-core/fields/fieldShape. Org's resolveFieldDescription / FieldDescriptionOverride were NOT part of the duplicated set and stay in the package, moved to org/src/fieldDescriptions.ts.
    • org/src/optionOverrides.ts, lms/src/collections/shared/optionOverrides.ts → @wabbit/tome-core/fields/selectOptions; sc/src/collections/registry/shared.ts now re-exports it (its extractId is a separate audit item and is untouched).

    Peer floor raised to `@wabbit/tome-core` `>=1.14.0 <2.0.0` in all five packages, because each now imports a subpath or a named export that first exists in that core minor: ./fields/fieldShape and ./fields/selectOptions are new subpaths, and findPaged/chunk/readPositiveNumber are new named exports on the pre-existing ./jobs. Crowdfund's floor moves from >=1.7.0 even though ./jobs itself shipped in 1.7.0 — the subpath resolving is not the same thing as the export existing, which is the sharper version of the lesson its own 0.1.1 CHANGELOG records (ERR_PACKAGE_PATH_NOT_EXPORTED).

    Org moves from >=1.2.0, lms from >=1.0.0, sc from >=1.11.0, workflow from >=1.7.0.

    Header comments that pointed at the deleted files, or asserted core did not export these, were corrected rather than left dangling.

  • 0.14.1
    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 honors 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 `createLayer 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.

  • 0.14.1
    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.

  • 0.14.1
    Collapse the package's three slug-field builders and three slug hooks onto one src/slugField.ts (2026-09-01 sale-readiness audit §5.1, T3(g)).

    What there was: resolveSlugField.ts (buildDefaultSlugField + autoSlugHook, used by Division, Team, Rank, Event, Document, Wing, Unit); collections/recognition/catalogItemFields.ts (buildCatalogSlugField + catalogItemSlugifyHook, used by Award, Medal, Ribbon, and carrying its own hand-rolled slugify REGEX — one of the four the audit counted repo-wide, which is why the same title produced different anchors depending on which collection you were in); and a private third pair inside Memorial.ts, written only because the other two typed sourceField to a fixed 'name' | 'title' | 'displayName' union while Memorial's display field is callsign.

    What there is: src/slugField.ts — orgSlugHook, buildOrgSlugField, resolveOrgSlugField — taking a plain string source field (so Memorial's reason to fork is gone) and a variant. Two field shapes survive on purpose, because both are stored shapes: 'org-default' (unique + index + required + sidebar; re-derives whenever the slug is empty and re-formats an explicitly-set value) and 'catalog-item' (required + unique only; CREATE-ONCE, so an admin who renames an Award after publication keeps its original slug and anything keyed on it stays valid).

    This is a consolidation, not a unification.

    Every public name stays exported and delegates, carrying @deprecated pointing at the new one: resolveSlugField, buildDefaultSlugField, buildCatalogSlugField, catalogItemSlugifyHook. Nothing is removed, and src/index.ts exports no new names — the new module is internal, so this is a patch.

    One deliberate behavior change, stated rather than buried. The catalog trio's hook now uses core's formatSlug (as the org-wide and Memorial hooks already did) instead of its own /[^a-z0-9]+/g regex. The regexes agree on everything the audit cared about — case, whitespace runs, punctuation, edge hyphens, accented characters — except one: the old one mapped _ to -, and formatSlug KEEPS _ (it is a word character).

    The hook is create-only, so no stored slug moves; only Awards, Medals and Ribbons created after this change whose name contains an underscore will slug differently. tests/slug-field-consolidation.test.ts pins that case explicitly so the difference is a decision on the record rather than something rediscovered from a support ticket, and pins that a title without an underscore now produces the SAME anchor as the org-wide hook — which was the point.

    That test file is also the characterization proof: it was written from the pre-change source, run green against the three separate builders, and run green again after. It pins each caller's emitted field (hooks stripped, since a moved function is a new object) and each caller's three-state override contract — undefined = the built-in field, false = no field, Field[] = the consumer's fields verbatim and by array identity. org's suite: 43 files, 873 tests, green.

  • 0.14.1
    Collapse the audited serial-await fan-outs in Payload hooks, jobs and access checks.

    No behavior changes — every try/catch, failure reporter and overrideAccess justification is preserved; only the number of round-trips changes.

    `find({ limit: 0 })` → `payload.count()` — limit: 0 sets pagination: false in the Mongo adapter, so the query loads every matching row into memory to produce one number. @wabbit/tome-org documents this as a production incident in hooks/attendance-count-sync.ts:6-11 and had reintroduced it in collections/recruiting/JoinRequest.ts's one-pending-request guard; @wabbit/tome-sc's squadron member recount had the same shape.

    Independent lookups → `Promise.all` / `Promise.allSettled` — org's createGiverWindowAccess (a per-request access check that took two serial round-trips), EventAttendance's display-name composer and AwardPresentation's, sc's RSI profile+org page fetches (the hot path for handle validation, against a third-party host), squadron recalcs, and accounts' three offboarding teardown callbacks. allSettled wherever a branch had its own fallback, so a failed member lookup still cannot stop the event title from resolving. sc's RSI adapter keeps its 404 short-circuit exactly, and ledger's per-leg balance guard decides in leg order so the thrown NegativeBalanceError still names the same wallet the serial version did.

    Independent per-row writes → `batchWrite` (@wabbit/tome-core/utilities/batch) — org's notification open/resolve fan-outs, the division/team cleanup and sunset cascades (up to 1,000 rows each), the event cascade-delete, the non-atomic memberCount fallback; lms's certification-expiry sweep (now paced in WRITE_CHUNK chunks like its sibling reconciler) and the course-delete enrollment drop; workflow's deadline sweep; crowdfund's tier-claim reconcile.

    Same `data` for every row → one bulk `payload.update({ where, data })` — sc's asset-assignment auto-close and the transfer-request GDPR redaction, matching sc/src/gdpr.ts's makeNullRefHandler. The auto-close also drops a latent correctness hazard: its page cursor advanced while its own writes removed rows from the filter it was paging over, so a page boundary could skip assignments.

    Two collection-level fixes. sc's Fleet had two field-level beforeChange hooks each issuing a findByID for the SAME ship on every write; they are now one collection-level hook that reads the ship once and sets both chassisName and name. lms's checkCertificationExpiry re-derived recountHolders once per expired award with no cache; it now recounts once per affected certification, after the sweep — which is also more correct, since only the final count was ever right.

    @wabbit/tome-crowdfund's settleCampaign pledge loop is untouched and now carries an explicit eslint-disable plus the reason: it captures money one pledge at a time against a maxCapturesPerRun budget that only bounds anything if the iterations are serialized.

    @wabbit/tome-org, @wabbit/tome-lms, @wabbit/tome-workflow and @wabbit/tome-crowdfund raise their @wabbit/tome-core peer floor to >=1.14.0, the release that adds ./utilities/batch. org and lms were also understating their floor before this change — both already imported @wabbit/tome-core/jobs, added in core 1.7.0, while declaring >=1.2.0 / >=1.0.0.

0.13.xMinor

  • New Recruiting cluster — three opt-in collection factories plus three shared hook factories and a sparse-checklist field builder (Recruiting/Onboarding Wave / R2-I1).

    createApplicationCollection — staged-intake application (submitted/in-review/accepted/rejected/withdrawn, generic vocabulary — NOT any one consumer's own status words). createJoinRequestCollection — a join request against ANY org node, with targetRelationTo/targetLabel REQUIRED per the wave's naming directive (no default target — a factory that defaulted to 'teams' would be smuggling in an opinion about what "join requests" are for).

    createOnboardingRecordCollection — a member's onboarding progress record with two sparse-checklist groups (intake/handoff by default) and, per the wave plan's own instruction, NO built-in hooks under any configuration (a production consumer's real shape has none).

    Three factory-generic behaviors absorbed from the R2-I0 coupling map, each opt-in and export-only:

    • createGuardDirectTransitionHook — the STRUCTURE of a direct-approval guard (block a status field from entering a guarded value unless a privilege check passes), with the flag name, error copy, and guarded values 100% config. createApplicationCollection accepts it via guardDirectTransition.
    • createPendingStatusNotificationHook — the SAME upsertNotification/resolveNotificationsByGroupKey open/close fan-out both Applications and WingJoinRequests independently reimplemented byte-for-byte identically at a consumer's integration branch. Built ONCE; both createApplicationCollection and createJoinRequestCollection accept it via pendingStatusNotification.
    • createTaskCleanupOnDeleteHook — a generic afterDelete hook running an injected resolveTasks resolver, never rethrowing. createApplicationCollection accepts it via taskCleanupOnDelete.

    buildSparseChecklistGroupField (collections/recruiting/shared/sparseChecklistField.ts) — the sparse-completions-log shape (a completions[] array of only the items actually done, template lives elsewhere) the coupling map found used three independent times; createOnboardingRecordCollection uses it twice (intake/handoff).

    The approval/assignment contract: createJoinRequestCollection never assigns anything on approval — flipping status to an approved value is a plain field write with no afterChange hook reacting to it by default. This reproduces a production consumer's own proven separation (approval is a decision record; a separate handoff step does the actual assignment) as a hard contract, documented in the factory's own header, not an implementation detail a future change could accidentally erase.

    Three new subpath doors (./collections/application, ./collections/joinRequest, ./collections/onboardingRecord), each proven server-only-free via a dedicated subpath-loadable test plus the package's assert-node-loadable.mjs --every-file gate (196/196 targets pass). @wabbit/tome-workflow added as an optional peer + devDependency solely to prove, against the real claimTransition primitive, that JoinRequest's status field is CAS-compatible — the factory itself has no runtime dependency on the workflow engine.

    Not wired into createOrgLayer (design principle 2). No consumer-specific vocabulary ("wing", rank names) appears in any factory's runtime code — proven by a dedicated test that strips comments/string-literals from each source file and asserts zero "wing" substring remains, plus a consumer-emulation test per factory showing a production consumer's real shape is reachable purely through config injection.

0.12.xMinor

  • New Recognition cluster — six opt-in collection factories, three hook factories, one access factory, and GDPR registration (Recognition Wave / R-I1).

    createAwardCollection, createMedalCollection, createRibbonCollection — the catalog trio (formal award/medal/ribbon definitions), sharing a catalogItemFields.ts builder set (shared field/slug builders, not a rigid shared array — the three real collections' field orders and eligibility shapes genuinely diverge). createAwardPresentationCollection — the "who actually got one" record, wiring createDisplayNameComposeHook (compose seam), an opt-in createAwardedBySelfEnforcementHook (admin "on behalf of" bypass, non-admin forced to self), and a default-on createRecipientCountSyncHook (payload.count(), never find({limit:0}) — the historical N+1 regression this cluster's own pins guard against).

    createKudosCollection — peer-to-peer recognition, update access via the new exported createGiverWindowAccess factory (originator self-edit within a configurable minute window). createMemorialCollection — public "In Memoriam" tributes, published-gated read access.

    registerRecognitionGdpr/unregisterRecognitionGdpr (packages/org/src/collections/recognition/gdpr.ts) — kudos hard-deletes the erased member's own rows as RECIPIENT while separately null-refing rows where they were the GIVER (one registry slot, two dispositions); award-presentations and memorials retain. Awards/Medals/Ribbons are deliberately never registered — catalog/definition rows carry no member relationship to key an erasure off of.

    Every factory threads the shared fieldShape mechanism (fieldOverrides/fieldOrder/omitFields/fieldDescriptions-null-suppress/extraFields/extraFieldsAfter) and per-verb access overrides — no held-out defaults. Discord fan-out and any web-framework cache-tag revalidation stay consumer-side (Wave 9 doctrine): no factory attaches notification hooks; createRecipientCountSyncHook's onExtraSync is the composition seam for that coupling instead.

    Opt-in only — nothing in this cluster is wired into createOrgLayer; a consumer imports and assembles these six factories directly. ORG_LAYER_VERSION is unchanged.

1 fix (0.12.1)
  • 0.12.1
    Fixed a defect where createRecipientCountSyncHook's recount() (the recognition recipientCount sync attached to AwardPresentation's afterChange/afterDelete) ran its payload.count() against the resolved CATALOG collection (target.collection — 'awards'/'medals'/'ribbons') instead of the SOURCE presentation collection the hook is attached to.

    countWhere builds its where clause over presentation-only fields (awardType, award_awards, …), which real Payload rejects against the catalog collection ("The following path cannot be queried"); the hook's own catch swallowed that error into reportOrgSyncFailure (logger-only, never throws), so the recount silently never ran in any production-shaped use — recipientCount has been permanently stuck wherever it started since this hook shipped in 0.12.0.

    recount() already received sourceCollection as a parameter (the presentation collection, from collection.slug in both afterChange and afterDelete's hook args) but only used it in the failure report. The count call now targets sourceCollection; the subsequent payload.update is unchanged and still correctly targets target.collection — count-from-source, write-to-target is the contract.

    The existing unit tests asserted the count call's collection equaled the catalog collection, which enshrined the bug as expected behavior (the mock never validated the argument against real Payload's path-queryability rules); they now assert the count/update collection asymmetry directly, plus a regression pin using a source-collection slug distinct from every catalog slug so a reintroduced swap cannot pass by coincidence.

0.11.xMinor

  • Document factory threaded through the shared `fieldShape` mechanism (Wave 8 D-I1). createDocumentCollection was the last org collection factory not wired to applyFieldOverrides/applyFieldOrder/resolveFieldDescription (./fieldShape.ts) — every other factory (Event, EventAttendance, Division, Team, Rank, ...) already carries it.

    Grown against a production consumer's integration branch's real Documents collection (src/collections/Documents/index.ts, 1158 LOC) so that collection can eventually become a thin adapter over this factory. A default createDocumentCollection() call is byte-identical to today — every seam below is opt-in.

    New seams:

    • fieldNames — renames author/restrictedToDivision/restrictedToTeam/lastReviewedAt/scopedTo/status.
    • slugField: Field[] | false — same resolveSlugField contract as Event/Division/Team/Rank. The single-field → Field[] replacement already generalizes a slug + slugLock companion-pair swap; no separate replaceFields seam was needed.
    • scopeOptions/scopeDefault/scopeOrgWideValue — {mode,options} override for the scope select, same contract as Event.statusOptions.
    • scopedToMode: 'dual-fields' | 'polymorphic' — 'dual-fields' (default) keeps today's scopedToDivision/scopedToTeam pair. 'polymorphic' emits ONE relationship field (scopedToRelationTo-injectable relationTo) — a production consumer's real scopedTo is a single polymorphic relation to ['wings', 'units', 'commissioned-ships'].
    • workflowStatusOptions/workflowStatusDefault — {mode,options} override for workflowStatus. includeStatusField opt-in emits a SEPARATE status select (own vocabulary via statusFieldOptions/statusFieldDefault) for consumers who split publication state from review state.
    • fieldOverrides/fieldOrder/fieldDescriptions (suppress-via-null convention)/extraFields/extraFieldsAfter — the standard shared set. fieldOverrides also covers documentId's unique-flag/admin divergences; no dedicated documentIdField seam was needed.

    access was already fully injectable per-verb (shallow-merged over the factory defaults) — verified a query-returning Access function round-trips through config.access.read unchanged. No new hooks: this factory stays hook-free by design.

    38 new tests in document-collection-w8-di1.test.ts (587 total passing). ORG_LAYER_VERSION untouched.

1 fix (0.11.1)
  • 0.11.1
    Wave 8 D-I1.1 — closes the seam gap the D-I2 adoption pass at a production consumer's integration branch stopped on: createDocumentCollection unconditionally emitted freshnessDueDate/publishedAt with no rename slot and no omission seam, so a consumer whose real Document has neither field could never reach a zero-type-diff shape.

    Adds a new shared applyOmitFields(fields, omit) helper to fieldShape.ts — the DELETE seam applyFieldOverrides/applyFieldOrder/resolveFieldDescription don't cover, since none of them can make a factory-emitted field disappear. Same error discipline as applyFieldOrder (an unknown name throws).

    Threaded through createDocumentCollection only, via a new omitFields?: string[] config option, applied AFTER fieldOverrides and BEFORE fieldOrder — an omitted field is already gone by the time fieldOrder runs, so a consumer's fieldOrder list never has to name a field it just deleted. omitFields: ['freshnessDueDate', 'publishedAt'] removes exactly those two fields; omitting omitFields (the default) leaves both present, byte-identical to today.

    applyOmitFields is package-wide shared, but this increment threads it through only the Document factory — one gap, one factory. Every other factory in this package (Event, EventAttendance, Division, Team, Rank, ...) adopts the same helper on demand, if and when a real adopter hits an equivalent unconditional-field gap.

    Note in applyOmitFields's own doc comment: omitting a field only removes it from the Payload schema/admin UI. If a consumer's existing data still populates that column, or site-level code still reads/writes it, this helper does not migrate or warn about that — reasoning about what happens to a still-populated field after omission is the consumer's responsibility, not this seam's.

    ORG_LAYER_VERSION untouched — additive seam, no default change.

0.10.xMinor

  • Wave 7 I5.1/I6.1 — closes the seam gaps the I5 (Events/index.ts) and I6 (EventAttendance/EventSlots/EventTypes) adoption passes at a production consumer's integration branch hit and reported in their STOP lists.

    Every seam is opt-in with an identical default (createEventCollection()/createEventAttendanceCollection()/createEventSlotCollection()/createEventTypeCollection() with no config each emit their exact prior shape — the pre-existing default-shape baseline specs stay green, unmodified).

    New shared mechanism (`packages/org/src/fieldShape.ts`), built once and threaded through all four events-cluster factories — the design rule this increment works under: prefer ONE shared mechanism over ten bespoke options.

    • applyFieldOverrides(fields, overrides) — the fieldNamed() ELIMINATOR. Both adopt passes had to reach into a factory's already-built fields array by hand (I5's Events/index.ts restoring required/index/label/admin.description/field-level access on organizer/coHosts/attendeeCount/waitlistCount/campaign/status; I6's EventSlots/index.ts stripping slotId's admin back off via a manual .map()). fieldOverrides: Record<name, Partial<Field>> merges admin/access one level deep, every other key overwrites, applied post-construction.
    • applyFieldOrder(fields, order) — post-construction reorder by field name; names listed come first in sequence, everything else keeps its original relative order and is appended after; an unknown name throws. This is the fix for I6's real blocking gap: EventAttendance couldn't be routed through the factory AT ALL because "the factory's fixed field-emission order cannot reproduce this collection's real order."
    • resolveFieldDescription(defaultText, override) — the three-state fieldDescriptions: { x: null } suppress convention (undefined keeps the default, a string overrides, null omits the admin.description key entirely). Closes EventSlot.slotId, the one field in the cluster the factory always described with no way to reach a production consumer's real "no admin key at all" shape.

    `createEventCollection`: fieldOverrides/fieldOrder (shared mechanism); eventTypeOptions ({mode,options}, same contract as statusOptions/visibilityOptions — eventType had no options seam at all before this); slugField: Field[] | false (same contract as DivisionCollectionConfig.slugField/resolveSlugField.ts, now shared by Event too — a production consumer's real slugField() helper is a unique: false slug + slugLock companion checkbox with a custom hook and admin component, a wholesale-replace case).

    `createEventAttendanceCollection`: fieldOrder (closes the real blocking gap above); fieldOverrides; extraIndexes: { fields, unique? }[] (a production consumer's real three beyond [event, member]: [event, status], [member, status], [event, selectedSlotId] unique). Converges the local insertFieldsAfterLocal TODO onto the now-merged shared insertFieldsAfter.

    `createEventSlotCollection`: extraFields/extraFieldsAfter (previously append-only, no anchor); fieldOverrides/fieldOrder; slotId/slotIndex now route through resolveFieldDescription, so fieldDescriptions: { slotId: null } suppresses the field's admin key entirely (a production consumer's real shape — the I6 adopt pass had to strip it post-construction).

    `createSlotSyncHook`: hintText({ dupKeys }) overrides the duplicate-key hint on the FAILED log line (default: this factory's own existing text, unchanged — NOT a production consumer's real string, a pre-existing divergence this increment doesn't silently "fix"); logPrefix overrides the [createSlotSyncHook] prefix on all three log lines (that consumer's real hook logs under [syncEventSlots]); extendSlotData(slot, doc) merges extra keys into both the create and update payloads (that consumer's real requiredCertification).

    `createEventTypeCollection`: extraFields/extraFieldsAfter (there was NO anchor mechanism at all before this — closes a production consumer's real description textarea, which has no factory correlate); colorFieldType: 'text' | 'select' + colorOptions (that consumer's real color is a palette select, not freeform text — a field TYPE mismatch fieldOverrides can't reach); fieldOverrides (closes the remaining five real diffs: label.unique+description, value.validate, shortLabel.required, order's default value, isActive.admin).

    `createRestrictStatusChangeHook`/`createEnforceSelfOnlyHook`: both gain skipWhenNoUser. restrictStatusChange's default is false (today's exact behavior — always resolves the Event and calls isPrivileged regardless of req.user); true reproduces a production consumer's real !req.user || checkRole(...) early bypass, including skipping the Event lookup (findByID) entirely — a genuine gap the old isPrivileged-only seam couldn't close (a consumer's predicate could reject an anonymous request but not skip the DB call).

    enforceSelfOnly's default is true — this hook already reproduces that consumer's !req.user bypass unconditionally (walkInHookBypass.spec.ts's pinned source-text assertions match today's code exactly); the option NAMES that existing behavior rather than changing it, and false opts into enforcing the member-id check even for an unauthenticated create.

    New test files: tests/field-shape.test.ts (19 tests, the shared mechanism in isolation), tests/event-collection-w7-i5-1.test.ts (16 tests), tests/event-attendance-collection-w7-i6-1.test.ts (15 tests), tests/event-slot-w7-i6-1.test.ts (17 tests), tests/event-type-w7-i6-1.test.ts (12 tests) — 79 new tests, 549/549 org tests green; tsc --noEmit clean; build + assert-node-loadable.mjs --every-file 148/148 PASS. ORG_LAYER_VERSION untouched — every seam here is export-only/opt-in, none auto-wired into createOrgLayer.

0.9.xMinor

  • Wave 7 org-adoption I1 — createEventCollection grown seam-complete against the real Events collection read at a production consumer's integration branch (Events/index.ts, 1705 LOC + 7 relevant hook files).

    Every option is opt-in with an MVP-identical default (createEventCollection() with no config emits the exact 19-field shape it always has — see event-collection-w7-i1.test.ts's baseline); no factory in createOrgLayer calls any of the new options.

    Field-shape seams: statusOptions/statusDefault (a production consumer carries the MVP's seven lifecycle values plus two legacy DB-compatibility values, draft/published, it has never migrated off of); fieldNames (renames division/team/organizer/coHosts/securityLevel/attendeeCount/the two new participating-\* fields — that consumer: leadUnit, participatingWings/participatingUnits, visibility); includeParticipatingDivisions/includeParticipatingTeams (two NEW opt-in hasMany scope fields the MVP never modeled); visibilityOptions/visibilityDefault (that consumer's visibility values, public/members/restricted, diverge from the MVP's public/members-only/restricted); locationField (wholesale override for the MVP's freeform text field — that consumer's real location is a structured group); coHostsMaxRows (that consumer caps at 5, MVP unlimited); includeWaitlistCount (a NEW opt-in readOnly counter beside attendeeCount — the MVP has no waitlist model); fieldDescriptions (every previously-hardcoded admin.description string is now overridable — 2R-I6 precedent: a factory's generic wording replacing a consumer's own string, with no way back, is a rejected-round finding).

    `extraFields`/`extraFieldsAfter`: interior-positioned passthrough via a NEW shared helper, insertFieldsAfter(fields, anchor, extra) (packages/org/src/insertFieldsAfter.ts) — no factory in this package had an anchor mechanism before this. A production consumer's real leadUnit/participatingUnits/participatingWings sit immediately after coHosts, well before the capacity/counter fields near the end of this factory's array; the tail-append convention every other factory's extraFields uses (Rank/Promotion/StatusRequest/ExpertDesignation) can't reproduce that.

    Omitting extraFieldsAfter keeps the append-at-end default.

    Four adoptable hook factories, export-only, none auto-wired (same posture as every other adoptable hook in this package):

    • createClosedRecordLockHook (event-closed-record-lock.ts) — ports enforceClosedRecordLock: value-diff (not key-presence) based, blocks writes to a terminal-status event outside an allowlist, bypassable via bypassContextKeys (default: a production consumer's own bypassEventLock/migrationBackfill).
    • createLifecycleLogHook (event-lifecycle-log.ts) — ports logLifecycleTransition: append-only log REBUILT from originalDoc only on every save (client-supplied log data always discarded, array-item ids stripped so the save replaces wholesale), actor attribution priority chain (context key → injectable/default member-lookup resolver → system, auto: true).
    • createEventCascadeDeleteHooks (event-cascade-delete.ts) — generalizes a production consumer's four near-identical delete*.ts afterDelete hooks (event-slots/resource-requests/event-attendance/after-action-reports) into one {collection,field}[]-driven factory, per-target error isolation, never throws.
    • createNotificationResolverOnDeleteHook (event-notification-resolver.ts) — ports resolveEventNotificationsOnDelete: same resolveWrite/onResolved injectable seam shape as Wave 6's createPromotionNotificationCleanupHook, adapted to that consumer's real fixed-suffix EXACT-dedupKey resolution (events:{id}:exam-pending/aar-missing) rather than promotion's unbounded prefix scan. Never throws.

    New test files tests/event-collection-w7-i1.test.ts (34 tests: byte-identical-defaults baseline + every field-shape seam) and tests/event-hooks-w7-i1.test.ts (29 tests: all four hook factories, including allowlist/bypass, originalDoc-rebuild, per-target isolation, never-throws). 384/384 org tests green; tsc --noEmit clean; build + assert-node-loadable.mjs --every-file 132/132 PASS. ORG_LAYER_VERSION is NOT bumped (release-time task).

    Not seamed this increment (named per the build brief, not approximated): getEffectiveStatus's time-based lock derivation (an event whose end time has passed behaves as completed even before the status-flip cron runs) is consumer-specific — it reads dateTime.startDate/endDate field names this factory does not own — so createClosedRecordLockHook's terminalStatuses check is value-based against statusField only; a consumer wanting the time-based derivation keeps a synced "effective status" field and points statusField at it.

  • Grow createEventAttendanceCollection seam-complete for adoption by a production consumer (Wave 7 I2), ported from that consumer's real EventAttendance collection (its integration branch, src/collections/EventAttendance/index.ts + its hooks/ directory).

    All additive and opt-in — the zero-arg default output is unchanged (same field set, same single beforeValidate uniqueness hook, no indexes key).

    • statusOptions extend/replace override — the MVP's own nine-value vocabulary already matches that consumer's real nine by value (kept verbatim as the default).
    • uniquenessStrategy: 'index' | 'hook' — 'hook' (DEFAULT, unchanged) is today's find-then-throw createAttendanceUniqueHook, TOCTOU-racy under concurrent writes; 'index' adds a real unique compound Mongo index on [event, member] instead, no hook attached.
    • Ten opt-in field groups, each gated behind its own includeX flag and name-overridable via fieldNames/committedAssetFieldNames/readinessFieldNames: selectedSlotId, readiness (5-subfield advisory group), trainingOutcome (passed/failed/incomplete — deliberately never gets an admin.condition; the doc comment records a production consumer's production history of a stale relationship-ID condition hiding the field for the collection's entire life), auditGrades+calibrationDeviation, committedAsset (fleet-asset pledge group; the ship-catalog lookup hook is NOT ported), operationalStatus, rsvpDate (auto-stamped on create), attendanceConfirmedBy/attendanceConfirmedDate (gated on attendedStatusValue), and adminNotes (field-level access seam, default gated on MANAGE_ATTENDANCE/MANAGE_EVENTS).
    • fieldDescriptions override for every hardcoded admin.description this factory can emit (same contract as Wave 7 I1's EventFieldDescriptions).
    • computeDisplayName/displayNameCompose — default OFF (the MVP's displayName field exists today but nothing populates it; defaulting this on would add a hook where none exists, breaking the default-shape guarantee). Same naming as ConductRecordCollectionConfig's W6 seam.
    • extraFields/extraFieldsAfter — local insertFieldsAfterLocal helper (the shared insertFieldsAfter module lives only on the unmerged Wave 7 I1 branch as of this writing; TODO comment marks the post-merge converge point).
    • Four standalone hook factories, export-only and opt-in via dedicated config keys (restrictStatusChange, enforceSelfOnly, attendeeCountSync, engagementOnFirstAttendance) that thread this factory's own resolved slugs automatically:
    • createRestrictStatusChangeHook — port of restrictStatusChange; injectable isPrivileged(req, event) (a production consumer: organizer-OR-co-host).
    • createEnforceSelfOnlyHook — port of enforceSelfOnly; injectable resolveMemberId.
    • createAttendeeCountSyncHook — port of syncAttendeeCount; uses payload.count(), never find({ limit: 0 }) (that consumer's own documented anti-pattern fix); onExtraSync seam for ceremony/promotions maintenance.
    • createEngagementOnFirstAttendanceHook — port of updateOnboarding's idempotent first-attendance flip; isEngaged idempotency gate + consumer-owned write.
  • Wave 7 I3 — upstreams a production consumer's EventSlot mirror as two new opt-in exports: createEventSlotCollection (default slug org-event-slots) and createSlotSyncHook.

    Neither is wired into createOrgLayer (design principle 2, same posture as DelegatedAuthority/PersonnelNotes) — a consumer attaches both explicitly.

    `createEventSlotCollection` ports a production consumer's integration branch's EventSlots/index.ts (98 LOC): event relationship (slug injectable via eventSlug), slotId, and the denormalized descriptor fields (groupIndex/assetIndex/slotIndex/roleName/isCommander) — every field name overridable via fieldNames, every admin description overridable via fieldDescriptions. LMS-owned relations (requiredCertification, requiredSpecialistPath) are deliberately NOT modeled — same cross-wave-edges rule already applied in ExpertDesignation.ts/DelegatedAuthority.ts — add them via extraFields.

    read defaults to authenticated; create/update/delete default-gate on CREATE_EVENTS/MANAGE_EVENTS, fully overridable via access.

    CRITICAL, pinned by test: the [event, slotId] compound index is emitted WITHOUT unique: true, by design — a real DB-level unique index here previously caused syncEventSlots's create() to throw a silently-swallowed MongoDB E11000 on any slotId reuse (the CMS "Duplicate" action being the reproducing case), leaving an event with zero mirror rows and every slot unclaimable with no visible symptom. Real de-duplication lives ONLY in the sync hook's reconcile pass, never the database.

    tests/event-slot.test.ts asserts both the field-level and the compound-index level carry no unique flag — do not "fix" this into a unique constraint.

    `createSlotSyncHook` ports Events/hooks/syncEventSlots.ts (225 LOC) as an Event-collection afterChange hook factory: an injectable extractSlots(doc) extracts the flat slot list from whatever nested shape a consumer's Event schema uses (a production consumer: operationDetails.operationalGroups[].assets[].crewSlots[] — this factory has no knowledge of that shape). Reconciles by deleting rows that fell out of the current slot set and creating-or-updating rows that are still current, with every current-slot write isolated under Promise.allSettled (one failing slot — classically a duplicate-key race — never aborts the batch or the others).

    Clears orphaned attendance selectedSlotId-equivalent references via an injectable attendance config (slug + field names), opt-out via attendance: false or omitting it. NEVER throws: the whole body runs under a top-level try/catch routed through the package's existing reportOrgSyncFailure/onSyncFailure seam (same convention as member-count-sync.ts) — an Event save must succeed even when slot sync fails outright.

    New test file tests/event-slot.test.ts (20 tests) — factory shape, the non-unique-index assertions, fieldNames/fieldDescriptions/extraFields/access overrides, sync-hook create/delete/update paths, Promise.allSettled isolation with the E11000 hint text, the never-throws guarantee (including a custom onSyncFailure reporter), and the attendance-clearing opt-out. 341/341 org tests green; tsc --noEmit clean; build + assert-node-loadable.mjs --every-file 126/126 PASS.

    Isolation note: PRs #341 (events/Event.ts, hooks/event-*.ts, insertFieldsAfter.ts) and #342 (events/EventAttendance.ts, types.ts additions) are in flight concurrently. This PR touches none of their files — EventSlot.ts and event-slot-sync.ts are new files, and the only edits to shared barrels (collections/events/index.ts, hooks/index.ts, src/index.ts) are append-only new export lines.

    types.ts is untouched entirely: EventSlotCollectionConfig/SlotSyncHookConfig are defined locally in their own files rather than added to the shared config-type file #342 is already extending, and the collection's own default slug (DEFAULT_EVENT_SLOT_SLUG = 'org-event-slots') is a local constant rather than a new ORG_DEFAULT_SLUGS.eventSlots entry — a follow-up can fold it into OrgSlugs once the sibling PRs land. createOrgLayer and ORG_LAYER_VERSION are untouched; wiring slots into the layer factory is a deferred, explicit follow-up decision, not assumed here.

  • Wave 7 I4 — absorbs the EventType delete guard EventType.ts's own header deferred to MVP+1, behind a new opt-in includeDeleteGuard (default false, byte-identical to today's factory).

    `createEventTypeCollection({ includeDeleteGuard: true })` adds a beforeDelete hook (createEventTypeDeleteGuard, new export) that blocks deletion while any Event still references the type, throwing a 400 APIError. Ported from a production consumer's integration branch's src/collections/EventTypes/index.ts beforeDelete hook.

    Two matchOn modes seam the field-shape divergence between today's runtime and upstream's own documented future direction: 'value' (default) counts Events where eventTypeField (default 'eventType') equals the EventType doc's value STRING — that consumer's actual semantics, since its Events.eventType is a select field storing that string, and this package's own createEventCollection ships the identical select shape today; 'relationship' counts Events where eventTypeField equals the EventType doc's own id, for when eventType becomes a real relationship (that consumer's own file header names this as a future migration). eventsSlug defaults to ORG_DEFAULT_SLUGS.events.

    deleteBlockedMessage overrides the thrown text; the built-in default reproduces that consumer's exact string (Cannot delete "${label}": ${count} event(s) are using this type. Re-assign them first.).

    `fieldDescriptions` makes the three previously-hardcoded admin.description strings (value, color, defaultFeatures) overridable — same contract as Wave 7 I1/I2.

    New test file tests/event-type-guard.test.ts (17 tests) — default-shape byte-identical gate, fieldDescriptions overrides, guard wiring (hook count, mergeHooks append with a consumer's own hooks), both matchOn modes, deleteBlockedMessage override, custom eventsSlug/eventTypeField, and the zero-usage / no-value pass-through paths.

    Isolation note: PRs #341 (events/Event.ts, hooks/event-*.ts, insertFieldsAfter.ts), #342 (events/EventAttendance.ts, types.ts additions), and #343 (events/EventSlot.ts, hooks/event-slot-sync.ts, barrels) are in flight concurrently. This PR touches none of their files: event-type-delete-guard.ts and event-type-guard.test.ts are new files, and the only edits to shared files (collections/events/EventType.ts, hooks/index.ts, src/index.ts) are either fully self-contained (EventType.ts, not touched by any sibling) or append-only new export lines (the two barrels).

    types.ts is untouched entirely: EventTypeFactoryConfig/EventTypeFieldDescriptions are defined locally in EventType.ts (extending the existing, unmodified EventTypeCollectionConfig import) rather than added to types.ts, which #342 is already extending. createOrgLayer and ORG_LAYER_VERSION are untouched — this guard is export-only/opt-in, never auto-wired into the layer factory.

0.8.xMinor

  • Wave 6 org-adoption I8 — the last three org-domain personnel factories: createStatusRequestCollection, createConductRecordCollection, createExpertDesignationCollection.

    All opt-in, none wired into createOrgLayer (design principle 2). Ownership split: org owns the RECORD shape; a transition engine (e.g. @wabbit/tome-workflow) owns any TRANSITION logic — none of the three factories model a transition/execution hook.

    `createStatusRequestCollection` (default slug status-requests, a production consumer: member-status-requests, 14 live rows). 18 neutral fields absorbed from MemberStatusRequests/index.ts verbatim. type/status default to that consumer's own five-value vocabularies (generic HR/pipeline terms); approverScope ships a Tome-neutral default (division_lead/organization) since that consumer's real values (wing_co/vlc) are organization-specific jargon — replace wholesale via approverScopeOptions.

    The grouped approval/denial fields reuse Promotion's W6-I6 shape verbatim via a new shared helper, buildApprovalDenialGroups (collections/personnel/shared/approvalDenialGroups.ts) — extracted so this factory doesn't fork the same field arrays a second time; createPromotionCollection's own inline fields are left untouched (byte-identical factory-snapshots.test.ts guarantee, no regression risk taken). The built-in title-composition hook (computeTitle, default true) reproduces that consumer's inline beforeChange exactly, reading a configurable titleDisplayField (default 'displayName'; that consumer: 'rsiHandle').

    NOT modeled, by design: the donor consumer's executeStatusRequest afterChange hook. It writes the settled status back via a DIRECT MONGO updateOne, bypassing Payload hooks entirely — that bypass IS the infinite-loop guard.

    Routing it through payload.update() would re-fire the same afterChange hook and offboard the member TWICE. Per the wave plan's Wave 4 precedent, a consumer wires this as a workflow.ts module beside their adapter.

    `createConductRecordCollection` (default slug conduct-records, a production consumer: discipline-records, 2 live rows). 20 fields — every field name defaults to that consumer's own verbatim (only the collection slug differs by default; no field-shape divergence was found). Absorbs enforceCoolingPeriod (coolingPeriod — new createConductRecordCoolingPeriodHook, hooks/conduct-record-cooling-period.ts), setDisplayName (computeDisplayName/displayNameCompose, default composes from severityOptions's resolved label + a configurable subjectDisplayField), autoAssignAppealReviewer's transition-detection bookkeeping (autoAssignReviewer — new createConductRecordAutoAssignReviewerHook, hooks/conduct-record-appeal-reviewer.ts — the chain-of-command WALK is an injected poolQuery), and anonymizeForSubject (subjectRedaction — new createConductRecordSubjectRedactionHook, hooks/conduct-record-subject-redaction.ts — the "is this reader the subject" ABAC is an injected predicate).

    relatedStandingChange gets a field-level MODIFY_STANDING-gated access.update default (relatedStandingChangeAccess to override).

    Stays consumer-side, never modeled: triggerStandingChange (cascades a standing change onto members — real re-entrant hook machinery this package doesn't own), auditDisciplineRecord (writes to an audit-logs collection this package doesn't own), and the 4 bespoke ABAC access functions (rank-floor + led-unit queries this package has no hierarchy resolver for).

    `createExpertDesignationCollection` (default slug expert-designations, a production consumer: sme-designations, 0 live rows). 10 fields — LMS territory (that consumer's specialistPath/qualifyingTier) is deliberately NOT modeled per the wave plan's cross-wave-edges rule; add it via extraFields. canEndorse is an endorsement-access seam layered on top of MANAGE_SME_DESIGNATIONS.

    Discord notification stays entirely consumer-side and runs last automatically — this factory attaches no afterChange hooks of its own, so a consumer's notifyDiscord passed via hooks.afterChange is appended after everything else by mergeHooks (design principle 5), reproducing that consumer's "Discord fires last" ordering with no special wiring.

    GDPR: registerPersonnelGdpr extended to register all three new collections as retain/post-identity (Wave 5 compliance plan §3a's own dispositions, verbatim), and closes two gaps the W6-I7 adopt pass hit: every one of the now-five registrations has independently-overridable phase/order (*Phase/*Order config keys) and its own register*: boolean opt-out (default true, mirrors createFulfillmentLayer's/createSCLayer's registerGdpr: false, scoped per-collection); personnelNotesDeleteShape: 'bulk' | 'tolerant-per-row' — 'tolerant-per-row' finds then per-row try/catch deletes, matching a production consumer's real pinned erasure-engine shape for the 20,351-row personnel-notes collection (one bad row under 'bulk' previously aborted the WHOLE delete with zero rows erased).

    New test files tests/status-request-collection.test.ts (20 tests), tests/conduct-record-collection.test.ts (29 tests), tests/expert-designation-collection.test.ts (17 tests); tests/personnel-gdpr.test.ts extended (10 → 22 tests). 321/321 org tests green; tsc --noEmit clean; build + assert-node-loadable.mjs --every-file 122/122 PASS.

0.7.xMinor

  • Wave 6 org-adoption I3.1 + I7 — closes the four Position factory gaps the real adoption by a production consumer's billets hit (69af1fe6), and adds two new opt-in personnel factories under a consistent naming convention.

    All opt-in, defaults byte-identical.

    I3.1 — Position follow-up. Billets/index.ts (a production consumer's actual adapter over createPositionCollection) needed four things the I3 pass didn't model:

    • `divisionField`/`teamField`: Position's own division/team field names were hardcoded — that consumer's Billets call these wing/unit. Renaming also renames admin.defaultColumns and the field descriptions.
    • `extraScopeFields`: that consumer has a THIRD, mutually-exclusive scope anchor with no Tome structural correlate — ship (relationTo: 'commissioned-ships'). scopeFields (for enforceScopeExclusivity) now defaults to [divisionField, teamField, ...extraScopeFields.map(f => f.name)] automatically.
    • `includeAuthorityTier` (default true, today's shape): that consumer passes false — no use for a second, purely-descriptive classification column alongside its real authorityLevel.
    • `categoryRequired` (default false) + `categoryOptions`: category had no requiredness/vocabulary override, unlike Rank's identically-shaped field. The consumer's Billets.category is required: true. New export DEFAULT_POSITION_CATEGORY_OPTIONS.
    • `createPositionDeleteGuard`'s S2 check widened, in position-delete-guard.ts: Team's leadership check goes from 2 fields (leader/other) to that consumer's real 4 (leader/executiveOfficer/seniorNCO/other) — the new two are only queried when explicitly named via teamLeadershipFields, so an unconfigured consumer's query stays byte-identical.
    • `createPositionDerivationsHook`'s `composeDisplayTitle` signature changed from (data, ctx) to a plain { title, scopeName, scopeKind } object (nothing outside this package's own tests referenced the old shape) so a consumer's composer can react to ANY scope anchor — including extraScopeFields — not just division/team. The walk itself is generalized to N scopeAnchors, checked in priority order, wrapped in a single try/catch matching that consumer's own whole-chain (not per-branch) shape.

    New test file tests/position-collection-w6-i3-1.test.ts (20 tests, incl. a full consumer-shaped integration test: wing/unit/ship scopes, includeAuthorityTier: false, the 4-field Team guard, and a ship-anchored display title). factory-snapshots.test.ts and position-collection-w6-i3.test.ts untouched in behavior (two tests updated only for the composeDisplayTitle signature change) and green — every default output is unchanged.

    I7 — two new factories, under a consistent naming convention decided the same day.

    • `createDelegatedAuthorityCollection` (default slug delegated-authority, a production consumer: acting-authority, 0 live rows): temporary cross-scope command delegation, 11 fields mirroring that consumer's ActingAuthority verbatim under neutral names, all overridable via fieldNames. The scope select's option VALUES are derived from divisionField/teamField rather than a separate vocabulary — that consumer's real values (wing/unit) already equal its own field names for exactly this reason. The compound-uniqueness beforeValidate guard (at most one ACTIVE record per member/scope-entity/position) is always attached — a brand-new factory has no existing consumer to keep byte-identical for. createDelegatedAuthorityExpiryTask (new hooks/delegated-authority-expiry.ts) wraps the pure expireDelegatedAuthority(payload, now, options) as a standard JobHandler for asPayloadTask/asCronEndpoint (@wabbit/tome-core/jobs) — paged 50-at-a-time, optimistic re-check before each write, never deletes, never throws.
    • `createPersonnelNotesCollection` (default slug personnel-notes, a production consumer: member-notes, 20,351 live rows): 7 fields mirroring that consumer's MemberNotes verbatim. IMPORTANT: author is a MEMBER relationship, not users — matches that consumer's real shape (fetchMember(req)-resolved). immutable defaults true (update: () => false). Opt-in authorRankOrder config absorbs setAuthorRankOrder (denormalizes the author's rank order at create time, fail-secures to 99999 on any resolution failure, falls back to a direct Rank lookup when the author's rank relationship is an unpopulated ID). auditCreate/auditRead seams absorb auditNoteCreation/logReadAccess — both fire-and-log-only, never block the write/read on failure.
    • `registerPersonnelGdpr` (new src/gdpr.ts, mirrors @wabbit/tome-sc's registerScGdpr): registers delegated-authority as retain/post-identity (org history) and personnel-notes as hard-delete/pre-identity with a CUSTOM onDelete — the registry's built-in userField/memberField OR pair can't express "match one memberId against two DIFFERENT member-keyed fields" (member AND author), which is what a production consumer's real "both directions" erasure actually needs. deleteAs: ['member', 'author'] (default: both) narrows to one direction if ever needed. Dispositions match the Wave 5 compliance plan §3a's own rows for these two collections verbatim.

    New test files tests/delegated-authority.test.ts (18 tests), tests/personnel-notes.test.ts (19 tests), tests/personnel-gdpr.test.ts (10 tests). Neither factory is wired into createOrgLayer (design principle 2 — individual factories, explicit slugs, same as Position/Rank/Promotion).

    I6.1 — Promotion follow-up. Promotions/index.ts (a production consumer's actual adapter over createPromotionCollection, 26c86583 on that consumer's integration branch) hit three more gaps, documented in that commit's adapter header and pinned by promotionsHooksParity.spec.ts:

    • `extraFields`: the only one of the six adoptable factories in this wave without this passthrough — that consumer's 7 no-factory-correlate fields (authorizedBy, systemGenerated, generatedBy, triggeredByAcademy, attendingCeremonies, calledAtCeremony, calledAt) had nowhere to go until now.
    • `approvalModel: 'grouped'` completeness, all additive-only under new validateBypassField/groupedModelOverrides options: validateBypassField adds the array-level validate bypassing "at least one recommendation required" when the named field (that consumer: systemGenerated) is true; groupedModelOverrides.{recommendations,approval,denial} add admin.condition per group/array (that consumer hides approval/denial until populated or status matches), a beforeValidate hook seam on recommendations[].reason (that consumer: link canonicalization + inline-data-URI guard), and a rich-text editor override for denial.reason (that consumer needs a richer node vocabulary to avoid a Lexical crash on member-facing content).
    • `createPromotionNotificationCleanupHook`'s resolve-write: promotionsHooksParity.spec.ts proved the hook's one-field write ({ bucket: 'done' }) was a strict SUBSET of that consumer's real resolveNotificationByDedupKey, which writes FOUR fields (bucket/resolutionMode/resolvedAt/expiresAt) — missing expiresAt specifically meant the 30-day retention cron would never sweep the row. New resolveWrite (default: today's one-field write) and onResolved (default: none; that consumer: socket emit + triage-cache bust, called per resolved row, errors caught and reported per-row without aborting the sweep) close the gap to parity.

    New test file tests/wave6-i6-1.test.ts (20 tests). factory-snapshots.test.ts and wave6-i2-1-i6.test.ts untouched in behavior and green — every default output (including the pinned approvalModel: 'grouped' field shapes) is unchanged.

    243/243 org tests green; tsc --noEmit clean; build + assert-node-loadable.mjs --every-file 108/108 PASS.

0.6.xMinor

  • Wave 6 org-adoption I2.1 + I6 — closes the four Division/Team factory gaps the real adoption by a production consumer hit, and makes createPromotionCollection adoptable.

    All opt-in, defaults byte-identical.

    I2.1 — Division/Team follow-up. Wings/index.ts/Units/index.ts (a production consumer's actual adapters over these factories) each documented a gap they had to work around locally instead of via config:

    • `extraFields` (Division + Team): neither factory had the passthrough Rank/Position already had — added, same contract.
    • `leadershipAccess` (Division + Team): field-level access for the position-mode leadership group — that consumer locks it behind canManageHierarchy and previously had no seam for that at all, forcing the whole group to be kept as its own object, verbatim.
    • `divisionField` (Team): the Division-backlink field name was hardcoded to 'division' with no override, so that consumer's 'wing' name could never be produced by the factory. Renaming it also renames the admin.defaultColumns entry and description.
    • `leadershipFields.executiveOfficer`/`.seniorNCO` (Team): the position-mode leadership group had only leader + other[] where that consumer's real shape has FOUR fields — the same 3-singular + other[] shape Division already had. Each new field is emitted ONLY when named, so the original 2-field default stays byte-identical. Division and Team now share the identical option surface.

    I6 — Promotion adoptable.

    • `statusOptions` + `statusDefault`: enums are config, not code — a production consumer's capitalized five (Pending/Approved/Completed/Denied/Canceled, 2663 live rows) replace Tome's lowercase five via the same {mode, options} contract as Rank.categoryOptions. Exported DEFAULT_PROMOTION_STATUS_OPTIONS.
    • `fromRankRequired` (default false) + `memberImmutable` (default false, adds field-level access.update: () => false on member).
    • `approvalModel: 'flat' | 'grouped'` (default 'flat'): 'grouped' swaps the flat recommendedBy/approvedBy for a production consumer's real shape — a recommendations[] array (recommendedBy + reason) plus approval (approvedBy+approvedAt) and denial (deniedBy+deniedAt+reason) groups, field names read verbatim from Promotions/index.ts.
    • `createPromotionNotificationCleanupHook` (new promotion-notification-cleanup.ts): export-only afterDelete factory absorbing resolvePromotionNotificationsOnDelete (the 7-orphaned-tasks incident) — discovers open notifications by dedup-key PREFIX (the suffix set embeds a variable recipient id, so it isn't finite) with a like-then-real-startsWith re-check, then resolves each one. Collection slug, key field, status field, and the prefix function are all injectable.
    • `createMemberRankSyncHook` (new member-rank-sync.ts): export-only afterChange factory absorbing the CORE of updateMemberRank — writes member.rank (+ an optional last-promoted date field) when a promotion transitions to its completed status, WITH both of that consumer's re-entry guards ported as named, overridable context-flag options (ceremonyClearContextFlag default 'rankUpdateClearCeremonies', reconcilerPassContextFlag default 'reconcilerPass'). Ceremony linking, onboarding auto-graduation, Discord sync, notifications, and cache revalidation stay consumer-local (events-wave territory) — chain a consumer hook after this one for those.
    • `fourEyes` (default true): gates the built-in promotionFourEyesHook off. That consumer enforces four-eyes itself at the application layer (canApprovePromotion's self-block + submitter-block, also checked in confirmSoftBan) — running Tome's hook too would be a second, independent enforcement of the same rule against fields approvalModel: 'grouped' doesn't even populate.

    Both new hooks follow the same export-only, never-auto-wired, never-rethrows posture as the I2 structural hooks (OrgSyncFailureReporter injectable). New test file tests/wave6-i2-1-i6.test.ts (41 tests); factory-snapshots.test.ts and wings-units-w6-i2.test.ts untouched and green — every default output is unchanged.

0.5.xMinor

  • Wave 6 org-adoption I3 — createPositionCollection grows the config surface a production consumer's Billets needs to adopt it, and closes the wave's headline risk: the authorityLevel enum collision.
    • `authorityLevel` collision closed at the root, via rename. Tome's original authorityLevel field (strategic/operational/tactical/technical) was DESCRIPTIVE — a monorepo-wide grep (plus the starter and wabbit-site-core) found zero consumers of its value. The consumer's Billets.authorityLevel (organization/cascading/direct/advisory) is EXECUTABLE — read by getLeadershipProfile, resolveLowestCommonAuthority, and checkActionPermission/*. Same field name, disjoint vocabularies, one load-bearing: a naive merge would have silently disabled cascading authority with no error and no failing test. The fix is a rename, not a merge: the old field is now `authorityTier` (same four values, same meaning) — freeing authorityLevel for the executable semantics. This is the one deliberate default-output change in this release: tests/factory-snapshots.test.ts's Position field-shape assertion now expects authorityTier in place of authorityLevel, updated deliberately in this same commit. Every other option below is opt-in with byte-identical defaults.
    • `includeAuthorityLevel` (default `false`) + `authorityLevelOptions`: adds a SEPARATE authorityLevel select carrying the consumer's four executable values verbatim (labels + admin description), exported as DEFAULT_AUTHORITY_LEVEL_OPTIONS. Tome adopts that consumer's behavior as the platform default for this field once a consumer opts in.
    • `enforceScopeExclusivity` (default `false`) + `scopeFields` (default `['division', 'team']`): absorbs the consumer's REAL Billets/index.ts beforeValidate hook, ported faithfully from the source (its integration branch) rather than the wave plan's looser paraphrase — it throws only when MORE THAN ONE scope anchor is set; zero is a valid org-wide position, matching that consumer's own inline comment ("or none for org-wide billets"). New position-scope-exclusivity.ts.
    • `guardDeleteWhenReferenced` (default `false`): absorbs beforeDeleteBillet's S2 (refuses delete when a Division/Team running leadershipMode: 'position' references this Position in a leadership slot) + S3 (directly clears the holder's Member doc, bypassing any afterChange chain — same re-entrancy reasoning rank-delete-guard.ts already documents for Rank). Configurable memberPositionField (default 'position', that consumer: 'billet') and divisionLeadershipFields/teamLeadershipFields (same shape as DivisionCollectionConfig/TeamCollectionConfig.leadershipFields). New position-delete-guard.ts.
    • `deriveDisplayTitle` (default `false`) + `composeDisplayTitle` override: absorbs the consumer's beforeChange hook, which computes isVacant (= !currentHolder) and composes displayTitle together in one pass — both fields have existed since MVP with nothing computing them. Default composition is "<scope name> — <title>"; pass composeDisplayTitle to match that consumer's own "<title>, <scope name>" shape verbatim. New position-derivations.ts, exports defaultComposeDisplayTitle.
    • `extraFields` passthrough, same contract as Rank/Division/Team — for the consumer's ship, requiredCertifications (both stay consumer-local, no Tome correlate). academyGrants/syncAcademyGrants (ADR-012) stay consumer-local entirely — LMS-domain, not absorbed here.

    All three new hooks are wired directly into createPositionCollection behind their opt-in flags (not export-only) via mergeHooks, so a consumer's own hooks compose alongside them rather than replacing them — same pattern as Rank's guardDeleteWhenHeld.

0.4.xMinor

  • Wave 6 org-adoption I2 — createDivisionCollection/createTeamCollection grow an opt-in leadershipMode + divisionRequired surface, and four structural sync hooks absorbed from a production consumer's Wings/Units/Members production behavior are exported (never auto-wired).

    Every option below is opt-in; the existing factory-snapshots.test.ts Division/Team assertions stay green, proving default output is byte-identical.

    • `leadershipMode: 'member' | 'position'` (default 'member', today's leader/deputy member-relationship fields, unchanged). 'position' drops those two fields and emits a leadership group of Position relationships instead — Division: commanderPosition/executiveOfficerPosition/seniorNCOPosition; Team: leaderPosition/otherLeadershipPositions[]. Field names are overridable via leadershipFields so that consumer can adopt with its existing commanderBillet/executiveOfficerBillet/seniorNCOBillet/otherLeadershipBillets names, no data rename. positionSlug threads the relationTo (default ORG_DEFAULT_SLUGS.positions).
    • `Team.divisionRequired` (default true, today's shape). That consumer's units.wing is nullable — wing deletion nulls the backlink rather than cascading — pass false to match.
    • `createMemberCountSync({ memberSlug, divisionSlug, teamSlug, fields })` — Member afterChange/afterDelete hooks that atomically sync Division/Team.memberCount, replacing the "not auto-synced" honesty gap those two fields' descriptions have carried since MVP. Counts the union of a member's hasMany array (divisions/teams) and singular primary field (primaryDivision/primaryTeam) so neither assignment convention silently undercounts. Uses a real atomic $inc when the DB adapter is Mongoose (duck-typed at runtime — this package has no @payloadcms/db-mongodb dependency), falling back to a documented, non-atomic read-modify-write otherwise. Export-only: a consumer opts in from their own Member collection's hooks.
    • `createDivisionTeamReciprocityHook`, `createTeamSunsetHook`, `createDivisionCleanupHook` / `createTeamCleanupHook` — exported factories porting the consumer's afterWingChange (de-dupe + reciprocal backlink, preventRecursion-guarded), handleUnitSunset (member/position cascade on an operational→sunset status flip), and cleanupWingReferences/afterUnitDelete (afterDelete reference cleanup) behavior, all fully configurable by slug/field name. All accept an injectable onSyncFailure reporter (that consumer: reportSyncFailure) and never rethrow — a failed cross-collection sync is reported, not allowed to fail the write that triggered it. None are auto-wired onto createDivisionCollection/createTeamCollection; see the README's "Adopting divisions/teams from an existing app" section for the hook ORDER requirement (consumer-side reference-reading cleanup, e.g. Discord, must run BEFORE the cleanup hooks in afterDelete).
    • `includeAbbreviation` (Rank, default `true`) and `slugField` (Rank/Division/Team, default = today's built-in `slug` field; `false` omits it; `Field[]` replaces it verbatim) — a same-PR follow-up. That consumer's ranks adapter (W6-I1 adopt) had to FILTER createRankCollection's emitted fields post-hoc to drop abbreviation and swap the built-in slug field for its own slugField('name') pair; consumer-side editing of factory output isn't an adoption contract, so both are now first-class options. slugField is shared verbatim across Rank/Division/Team (identical built-in field shape, new resolveSlugField.ts helper); includeAbbreviation is Rank-only since Division/Team have no abbreviation field to gate.

0.3.xMinor

  • Converge onto the single platform permission engine.

    tome-org now registers ORG_PERMISSIONS and its super-permission map (translated to value-space) into @wabbit/tome-core/auth/permissions and delegates checkOrgRole/hasPermission to the shared engine — the parallel org permission resolver is removed (no more two-systems redundancy). Public API is unchanged and verified to resolve identically to the prior implementation across representative role fixtures (a production-consumer-shaped role.permissions map, multi-role, unpopulated/string-id roles, null user) plus super-permission implication.

    Adds exports registerOrgPermissions, buildOrgSuperPermissionMap, ORG_OVERRIDABLE_PERMISSIONS.

11 fixes (0.3.1 – 0.3.9)
  • 0.3.9
    Wave 6 org-adoption I1 — createRankCollection grows the config surface a production consumer's Ranks collection needs to adopt it, with zero default-behavior change for existing consumers (starter, wabbit-site-core, other registry consumers).

    Every option below is opt-in; the default-config output is byte-identical, proven by the existing 27-assertion factory-snapshots.test.ts staying green plus 16 new option-specific tests.

    • `access` full override: already possible via BaseOrgCollectionConfig spreading config.access after the factory's defaults, but undocumented and untested for Rank specifically. The consumer needs read: authenticated in place of the default read: anyone — a real posture change (public rosters vs. members-only), not a cosmetic one, so it's called out explicitly here rather than left to an implicit spread.
    • `categoryOptions` + `categoryRequired`: the category select's 8 generic values had zero overlap with the consumer's 11 lore values, and the field wasn't required while the consumer's is. New categoryOptions?: { mode: 'extend' | 'replace', options } (same contract as @wabbit/tome-core/gdpr/compliance's dataCategoryOptions, ported to a new packages/org/src/optionOverrides.ts helper rather than importing the GDPR-domain module) lets a consumer extend or replace the vocabulary; categoryRequired?: boolean (default false, today's shape) flips requiredness. Defaults exported as DEFAULT_RANK_CATEGORY_OPTIONS.
    • `insigniaTextField` / `insigniaImageField`: field-name overrides so a consumer adopting this factory over an EXISTING table doesn't have to rename data. The consumer's collection spells these insigniatext (lowercase t — a one-character, silent-null-risk mismatch the wave plan flagged) and image; defaults stay insigniaText / insignia. A consumer renaming either field owns keeping their own readers (queries, populate hints, defaultColumns) consistent with the configured name — the factory does not alias the default name alongside a renamed one.
    • `extraFields` passthrough: appended after the standard field set, for the consumer's discordRoleId, promotionPrerequisites, ceremonyScript, and legacy id fields.
    • `guardDeleteWhenHeld` (default `false`): the factory's original header deferred a "before-delete hook prevents deletion when members hold the rank" guard to MVP+1 to avoid coupling Rank to Member in a delete-path hook loop before any consumer needed it. This is that deferred capability, absorbed from the consumer's Ranks/hooks beforeDelete guard (packages/org/src/hooks/rank-delete-guard.ts) — a payload.count read against the configured memberSlug/memberRankField, never a write, so it cannot loop. Throws a 400 APIError naming the holder count when any Member still references the rank; passes through silently at zero holders. Runs through mergeHooks, so a consumer's own beforeDelete/afterChange hooks compose alongside it rather than replacing it.

    Keeps the consumer's insigniatext spelling via an upstream field-name option rather than forcing a rename.

  • 0.3.8
    Wave 6 org-adoption preflight (W6-I0) — upstream hygiene with zero default-behavior change for existing consumers (starter, wabbit-site-core, other registry consumers).
    • Stale layer version fixed: registerLayer('@wabbit/tome-org', …) hardcoded version: '0.2.3' while the package had shipped as far as 0.3.7 — five patches stale, so any consumer gating on the registry saw an out-of-date contract. Now reads ORG_LAYER_VERSION from src/version.ts, pinned to package.json by tests/layer-version.test.ts (mirrors the @wabbit/tome-lms / @wabbit/tome-accounts pattern).
    • Hook-replacement bug fixed: every factory that merged config.hooks with { ...builtInDefaults, ...(config.hooks ?? {}) } silently DROPPED a built-in hook whenever a consumer set the same key — confirmed live in Membership.ts (a consumer afterChange would drop the memberCount sync) and Promotion.ts (a consumer beforeValidate would drop the four-eyes guard). All 12 collection factories now merge through a new mergeHooks(base, extra) helper (src/hooks/mergeHooks.ts, ported from packages/sc/src/extensions/mergeHooks.ts — no shared core leaf subpath exists yet, tracked as a follow-up) that APPENDS per hook key instead of replacing. mergeHooks is exported from the package barrel.
    • `member-collection` slot claim gap documented and closed: audited whether resolveMemberSlug falls back to the wrong slug when a consumer calls the individual factories (design principle 2's required shape for the real adoption by a production consumer) instead of createOrgLayer(). Finding: the slot claim is currently READ BY NOTHING in the monorepo and cannot even carry a slug value (LayerSlotClaim is {slot, claimant, claimedAt}) — resolveMemberSlug resolves purely from the memberSlug/userSlug config passed directly to each factory, entirely decoupled from the slot registry. So today there is zero functional risk. The gap that IS real: factory-only consumers never claim the slot, so the registry's ownership record is silently incomplete for exactly the consumption shape later increments will use. Added claimOrgMemberSlot() (exported from the package barrel) for those consumers to call explicitly; documented in the README.
    • New factory snapshot tests (tests/factory-snapshots.test.ts, 27 assertions): pins the default-config field names/types/required flags and hook-array shape for all 8 structure/personnel factories (Division, Team, Squad, Position, Membership, Member, Rank, Promotion) plus two regression tests proving a consumer hook now appends instead of replacing. No such test existed before this package's only prior test (permission-convergence.test.ts) exercised the access engine, not the factories.
    • False "auto-synced" claim corrected: Division.memberCount / Team.memberCount admin descriptions claimed the field auto-syncs; hooks/member-count.ts only ever wired the sync for Squad (Membership.afterChange/afterDelete). Descriptions now say so honestly. The actual Division/Team sync remains unimplemented — deferred to W6-I2 per the Wave 6 org plan.
    • `Membership.entityType: 'squadron'` dead-option gap fixed, opt-in: the entityType select has offered 'squadron' since the MVP spec with no backing relationship field, so a saved squadron row has never referenced anything. Added an OPT-IN squadronSlug?: string config (MembershipCollectionConfig) that, when passed, adds a squadron relationship field gated on entityType === 'squadron'. Left opt-in rather than defaulted: a hardcoded relationTo would point at a collection slug (e.g. @wabbit/tome-sc's squadrons) that may not exist in every consumer's payload.config.ts, which Payload's sanitizeConfig rejects at boot — so a forced default would have been the actually-breaking choice. Omitting squadronSlug reproduces today's exact (broken) behavior.

    Fully backward compatible: every new config key is optional and every changed default-config output is byte-identical to before, proven by the new snapshot suite.

  • 0.3.7
    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.

  • 0.3.6
    Import authenticated/anyone from the @wabbit/tome-core/auth/guards leaf instead of the /auth barrel.

    The barrel statically re-exports createBetterAuth, which drags @delmaredigital/payload-better-auth into the module graph — so any consumer loading this package outside a bundler (payload CLI under tsx: generate:types, seeds, migrations) crashed with ERR_MODULE_NOT_FOUND unless it installed an auth stack it may deliberately not use. Leaf-import discipline is what this platform's own consumer docs mandate; the layers now follow it themselves.

    No behavioral change — same functions, same leaf they always resolved to.

  • 0.3.5
    Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed).
    • dispatch: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (prior client-name and SC-universe strings, e.g. "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 client-lore/SC references; config examples de-lored. Pack positioning (SC-tier bundling) unchanged.
    • blocks-extras / blocks-content-writer: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized.
    • blocks-core: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized.
    • blocks-gallery: SourceBadge label for the vngd source value now renders "Legacy" (enum value unchanged).
    • accounts / core / lms / ui / org / admin / motion / longform / cop / blocks: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named the client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.
  • 0.3.4
    Neutralize gaming/military-flavored language across the org surfaces — labels, descriptions, and demo content only; zero schema changes (all field names, collection slugs, and enum/select VALUES are byte-identical, so no consumer data migration).
    • blocks-org-pack: CampaignBanner's codename field is now labeled "Name" with a business example ("Spring Launch" demo replaces "Operation Nightfall … contested systems"); MemberCard/MemberGrid rank fields labeled "Role" with business-ladder demo values (Principal/Staff/Senior replace Captain/Lieutenant/Sergeant, "Fleet Commander" → "Design Lead"); EventCalendar demo uses business events (workshop, hiring open house, quarterly business review — "Upcoming Operations" heading → "Upcoming Events"); OrgChart meta/variants describe a generic three-level hierarchy instead of Division → Teams → Squads (render output was already 100% data-driven — level headings come from the authored rows, so no new props were needed); block meta descriptions/usage neutralized throughout.
    • tome-org: flavored admin LABELS get neutral text while stored values stay put — Event status boarding/debrief labeled "Check-In"/"Wrap-Up"; eventType operation/patrol/exam labeled "Initiative"/"Outreach"/"Assessment"; securityLevel labeled "Access"; Campaign codename labeled "Internal Name" and campaignType recurring_op/special_operation/deployment labeled "Recurring Series"/"Special Initiative"/"Rollout"; Member classification labeled "Directory Visibility" with classified labeled "Private", "Chain of command" → "Reporting line"; Rank securityClearance labeled "Access Level" and category command labeled "Management"; Squad squadType fire_team/flight labeled "Crew"/"Pod", callsign labeled "Nickname"; Membership squadron labeled "Unit", role example "Pointman, Medic" → "Coordinator, Facilitator"; Position abbreviation example "CO, XO" → "COO, PM", category command labeled "Executive". The configurable DEFAULT_ORG_TERMINOLOGY (Division/Team/Squad/Rank) is deliberately unchanged — it is the documented override seam and @wabbit/tome-sc inherits it for its themed collections.
    • blocks-core: BLOCK_CATALOG entries for campaign-banner, member-card, and org-chart re-mirror the updated pack meta descriptions (catalog is generated from pack meta; only the entries owned by this change were refreshed).
  • 0.3.3
    registerLayer is now statically imported (forms/intake pattern) instead of lazily require()d in ten layer packages' init/register paths.

    The lazy pattern silently no-ops under Payload's native-ESM CLI (generate:types / generate:importmap), so layer registration could vanish without error. Packages whose tome-core peer is genuinely optional (economy, ai, gamification) deliberately keep the guarded lazy path; tome-core's admin-nav/self-register.ts deliberately keeps its subpath require() (documented ESM/CJS dual-cache fix — do not convert).

  • 0.3.3
    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.

  • 0.3.3

    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).

  • 0.3.2
    Core runtime-floor sweep: each package's @wabbit/tome-core peer floor now matches the newest core runtime export it actually imports, instead of the platform-wide >=1.0.0 baseline from the original peer-range sweep.

    The stale floors let npm silently install a package next to a core version missing a module it runtime-imports, producing a hard next build failure at import time (reproduced 2026-07-11: tome-starter locked core 1.0.12 + admin 0.6.3 — isAdminNavDomain does not exist in core 1.0.x, where registry/adminNav was type-only).

    • @wabbit/tome-admin → >=1.3.0 <2.0.0 — nav/manifestResolver runtime-imports isAdminNavDomain from registry/adminNav, first shipped as a runtime export in core 1.3.0 (Sidebar v2 Wave 0, d8ff1b2).
    • @wabbit/tome-deals → >=1.1.0 <2.0.0 — runtime-imports auth/repScoping (buildRepWhereClause et al.) and utilities/normalize (normalizeEmail), both introduced in core 1.1.0 (consolidation pass, a9801fe).
    • @wabbit/tome-accounts → >=1.2.0 <2.0.0 — runtime-imports auth/permissions (roleSatisfiesPermission, permission registration), introduced in core 1.2.0 (platform permission engine, 9238072).
    • @wabbit/tome-org → >=1.2.0 <2.0.0 — runtime-imports auth/permissions (checkPermissionHierarchical et al.).
    • @wabbit/tome-sc → >=1.2.0 <2.0.0 — runtime-imports auth/permissions across access helpers and military collections.

    Same defect class as the tome-crm floor raise to >=1.1.0 (b027075); tome-crm is already correct and unchanged here.

  • 0.3.1
    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.

0.2.xMinor

  • Publish pipeline setup — first-publish prep. Adds tsup config, dist build script with NODE_OPTIONS heap bump, publishConfig (restricted, npm.wabbit.com), main/module/types fields, files allowlist (dist, README.md, LICENSE.md), and exports map pointing at dist/.

    Mirrors the canonical @wabbit/tome-* publish template (catalog/economy/admin shape).

    Peer-dep correction: moves @wabbit/tome-core from dependencies (which was incorrect for a peer) to peerDependencies (workspace:*). Also keeps it in devDependencies so workspace install still resolves it at build time.

    Existing peerDependenciesMeta block was already declaring @wabbit/tome-core as a peer, so this fixes the orphan declaration. devDeps gains cross-env, rimraf, and tsup to match the canonical template.

    One source change to make the published `.d.ts` consumable. Annotated createMemberCollection return type as CollectionConfig (it was the lone factory without an explicit return type — the other 12 already had it). Without the annotation, tsup's dts rollup couldn't resolve some Payload internal subpath types referenced by the inferred wide return type and emitted literal import 'node_modules/payload/dist/...' paths in the d.ts that would 404 from a published consumer.

    This is the same pattern catalog/economy already follow; matches the discipline in feedback_payload_config_typed_for_callback_inference. Public API surface (collections, access helpers, hooks, factory, terminology, permissions) is otherwise unchanged.

    Why now: unblocks the agency-stack roadmap (@wabbit/tome-crm, @wabbit/tome-deals) — those layers depend on tome-org and consumer registry consumption requires tome-org to be on Verdaccio. Same template that catalog and economy got on 2026-04-27.

5 fixes (0.2.4 – 0.2.6)
  • 0.2.6
    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.

  • 0.2.6
    Platform-wide peer-range sweep: every workspace:*/workspace:^ entry in peerDependencies replaced with an explicit semver range (@wabbit/tome-core >=1.0.0 <2.0.0, tome-ui >=0.9.0 <1.0.0, tome-motion >=0.2.0 <1.0.0, tome-catalog >=1.1.0 <2.0.0, tome-admin >=0.5.0 <1.0.0; tome-crm ranges standardized to >=0.2.0 <1.0.0).

    The workspace protocol publishes as an exact-version pin, so every substrate bump stranded installed dependents — the breakage class proven by marketing@0.1.0/deals@0.1.1 requiring tome-crm@0.2.0 exactly. devDependencies keep workspace:* for the local link. (@wabbit/tome-admin-pro got the same source fix but is rc-versioned; it carries the change on its next intentional release.)

    tome-crm additionally gains a once-per-process production warning when the capability-registry fallback grants access — the bootstrap heuristic (any authenticated user passes crm:read) now announces itself instead of running silently on sites that forgot to seed capability grants (2026-06-10 audit hardening item).

    Graph-truth additions (same hygiene wave): tome-deals declares its lazy print integration as an optional peer (@wabbit/tome-print >=0.1.0 <1.0.0); tome-intake declares its lazy catalog routing strategy (@wabbit/tome-catalog >=1.1.0 <2.0.0, optional). These were undeclared dynamic imports — invisible to consumers and to pnpm's build topology.

  • 0.2.5
    Updated dependencies [8947ff1]
    • @wabbit/tome-core@1.0.12
  • 0.2.4

    Updated dependencies [36dc023]

  • 0.2.4
    Updated dependencies [2612799]
    • @wabbit/tome-core@1.0.11

0.1.x

1 fix (0.1.1)
  • 0.1.1
    Updated dependencies
    • @wabbit/tome-core@0.2.0

Full changelog across all packages