Org

Community engineStable
@wabbit/tome-orgv0.14.6

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

Install
  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
  3. Then install:

    npm install @wabbit/tome-org

Overview

@wabbit/tome-org

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 ARCHITECTURE.md). @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.

Install

pnpm add @wabbit/tome-org

Peer ranges, copied from package.json:

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

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:

| Subpath | Purpose | |---|---| | ./collections/application | Standalone createApplicationCollection export (Recruiting cluster) | | ./collections/joinRequest | Standalone createJoinRequestCollection export (Recruiting cluster) | | ./collections/onboardingRecord | Standalone createOnboardingRecordCollection export (Recruiting cluster) |

| Group | Exports | |---|---| | Layer factory | createOrgLayer, 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 | | Terminology | DEFAULT_ORG_TERMINOLOGY, mergeTerminology | | Permissions | ORG_PERMISSIONS, ORG_SUPER_PERMISSIONS, OrgPermissionKey, OrgPermissionValue, registerOrgPermissions, buildOrgSuperPermissionMap, ORG_OVERRIDABLE_PERMISSIONS | | Access helpers | hasPermission, hasAnyOrgPermission, hasPermissionAsync, checkOrgRole, selfOrPermission, authenticatedReadOwnOr, OrgUserLike | | Hooks | autoSlugHook, 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 overrides | resolveSelectOptions, SelectOption, SelectOptionOverride — the { mode: 'extend' \| 'replace', options } contract every fixed-vocabulary select field override (e.g. Rank's categoryOptions) uses | | Slug-field override | resolveSlugField (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) | | Types | OrgTerminology, 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 version | ORG_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.

Links

  • CHANGELOG — 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 behaviour 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 capitalised 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 behaviours 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) | Example | fieldNames key | |---|---|---| | member | member | (unchanged) | | scope | scope | (unchanged) | | division (scope value 'division') | wing (scope value 'wing') | divisionField: 'wing' | | team (scope value 'team') | unit (scope value 'unit') | teamField: 'unit' | | position | billet | position: 'billet' | | reason / grantedBy / grantedAt / expiresAt / revokedAt / status | same | (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. Behaviour 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 behaviour.
  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.

| Collection | Fields | Mode | Phase | Why | |---|---|---|---|---| | delegated-authority | member (subject), grantedBy (officer) | retain | post-identity | org history — current holder refs are already cleared by offboarding | | personnel-notes | member (subject), author (officer, ALSO member-keyed) | hard-delete, both directions | pre-identity | personal content requires erasure, not just reference-nulling | | status-requests | targetMember (subject), officer refs | retain | post-identity | decision records — part of the org's governance history | | conduct-records | subject, officer refs | retain | post-identity | legal-obligation/legitimate-interest — subjectRedaction already governs disclosure | | expert-designations | member (subject), endorser/revoker refs | retain | post-identity | org 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 ./collections/application, ./collections/joinRequest, ./collections/onboardingRecord also expose them as standalone subpaths (in addition to the root barrel).

| Factory | Purpose | |---|---| | createApplicationCollection | Staged-intake application — the recruitment pipeline's entry point | | createJoinRequestCollection | Target-injectable join request against any org node (division, team, guild, chapter) | | createOnboardingRecordCollection | A 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.

| Factory | Purpose | |---|---| | createAwardCollection | Catalog/definition collection for formal organizational awards | | createMedalCollection | Sibling of createAwardCollection, sharing its catalog-item field builders | | createRibbonCollection | Sibling of createAwardCollection/createMedalCollection | | createAwardPresentationCollection | An individual presentation of an Award/Medal/Ribbon to a member, on a date | | createKudosCollection | Peer-to-peer member recognition/appreciation, open to any authenticated member | | createMemorialCollection | Public "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

  • @wabbit/tome-org
  • @wabbit/tome-org/collections/application
  • @wabbit/tome-org/collections/joinRequest
  • @wabbit/tome-org/collections/onboardingRecord

Changelog

v0.14.6patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.14.5patch

bfe6c08: `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.

  • bfe6c08: `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.
v0.14.4patch

d3ad4ce: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behaviour change.

  • d3ad4ce: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behaviour change.
  • 0bd7c3f: customer-facing wording: internal references removed from admin descriptions, error messages and block metadata.
v0.14.3patch

30bdd74: `autoSlugHook` is now a typed delegate to core's `autoSlugFieldHook`, with an identical body. Two domain layers cannot import each other, so the shared body moved down to core.

  • 30bdd74: `autoSlugHook` is now a typed delegate to core's `autoSlugFieldHook`, with an identical body. Two domain layers cannot import each other, so the shared body moved down to core.
  • 67eb3dc: Local relationship-id helpers are replaced by `@wabbit/tome-core/utilities/relationId`. Each call site maps to the core reader with the same return shape (raw vs. stringified id, `null` vs. `undefined`, polymorphic), so behaviour is unchanged. The `@wabbit/tome-core` peer floor goes up to `>=1.17.0` because that is the first core version exporting `relationIdRaw`, `relationIds` and `relationIdsRaw`. `extractRelationshipId` and `extractRelationshipIds` (public) are kept as deprecated delegates to core, with identical semantics.
v0.14.2patch

8352ba5: 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.

  • 8352ba5: 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.
v0.14.1patch

8fc9702: Import `mergeHooks`, `fieldShape` and the select-option override contract from `@wabbit/tome-core` instead of keeping local copies (2026-09-01 sale-readiness audit §5.1, T3(a)). No API change: every symbol these packages exported before is still exported, now re-exported from core, and every factory produces byte-identical output. Deleted, with every call site repointed: - `org/src/hooks/mergeHooks.ts`, `lms/src/collections/shared/mergeHooks.ts`, `sc/src/extensions/mergeHooks.ts` → `@wabbit/tome-core/hooks/mergeHooks`. Core has exported this since July; sc's copy still carried a header claiming "Neither @wabbit/tome-core nor @wabbit/tome-org exports this." - `lms/src/server/jobs/paginate.ts`, `crowdfund/src/server/jobs/paginate.ts`, `workflow/src/server/paginate.ts` → `@wabbit/tome-core/jobs`. - `org/src/fieldShape.ts` + `org/src/insertFieldsAfter.ts`, `lms/src/collections/shared/fieldShape.ts` → `@wabbit/tome-core/fields/fieldShape`. Org's `resolveFieldDescription` / `FieldDescriptionOverride` were NOT part of the duplicated set and stay in the package, moved to `org/src/fieldDescriptions.ts`. - `org/src/optionOverrides.ts`, `lms/src/collections/shared/optionOverrides.ts` → `@wabbit/tome-core/fields/selectOptions`; `sc/src/collections/registry/shared.ts` now re-exports it (its `extractId` is a separate audit item and is untouched). **Peer floor raised to `@wabbit/tome-core` `>=1.14.0 <2.0.0`** in all five packages, because each now imports a subpath or a named export that first exists in that core minor: `./fields/fieldShape` and `./fields/selectOptions` are new subpaths, and `findPaged`/`chunk`/`readPositiveNumber` are new named exports on the pre-existing `./jobs`. Crowdfund's floor moves from `>=1.7.0` even though `./jobs` itself shipped in 1.7.0 — the subpath resolving is not the same thing as the export existing, which is the sharper version of the lesson its own 0.1.1 CHANGELOG records (`ERR_PACKAGE_PATH_NOT_EXPORTED`). Org moves from `>=1.2.0`, lms from `>=1.0.0`, sc from `>=1.11.0`, workflow from `>=1.7.0`. Header comments that pointed at the deleted files, or asserted core did not export these, were corrected rather than left dangling.

  • 8fc9702: Import `mergeHooks`, `fieldShape` and the select-option override contract from `@wabbit/tome-core` instead of keeping local copies (2026-09-01 sale-readiness audit §5.1, T3(a)). No API change: every symbol these packages exported before is still exported, now re-exported from core, and every factory produces byte-identical output. Deleted, with every call site repointed: - `org/src/hooks/mergeHooks.ts`, `lms/src/collections/shared/mergeHooks.ts`, `sc/src/extensions/mergeHooks.ts` → `@wabbit/tome-core/hooks/mergeHooks`. Core has exported this since July; sc's copy still carried a header claiming "Neither @wabbit/tome-core nor @wabbit/tome-org exports this." - `lms/src/server/jobs/paginate.ts`, `crowdfund/src/server/jobs/paginate.ts`, `workflow/src/server/paginate.ts` → `@wabbit/tome-core/jobs`. - `org/src/fieldShape.ts` + `org/src/insertFieldsAfter.ts`, `lms/src/collections/shared/fieldShape.ts` → `@wabbit/tome-core/fields/fieldShape`. Org's `resolveFieldDescription` / `FieldDescriptionOverride` were NOT part of the duplicated set and stay in the package, moved to `org/src/fieldDescriptions.ts`. - `org/src/optionOverrides.ts`, `lms/src/collections/shared/optionOverrides.ts` → `@wabbit/tome-core/fields/selectOptions`; `sc/src/collections/registry/shared.ts` now re-exports it (its `extractId` is a separate audit item and is untouched). **Peer floor raised to `@wabbit/tome-core` `>=1.14.0 <2.0.0`** in all five packages, because each now imports a subpath or a named export that first exists in that core minor: `./fields/fieldShape` and `./fields/selectOptions` are new subpaths, and `findPaged`/`chunk`/`readPositiveNumber` are new named exports on the pre-existing `./jobs`. Crowdfund's floor moves from `>=1.7.0` even though `./jobs` itself shipped in 1.7.0 — the subpath resolving is not the same thing as the export existing, which is the sharper version of the lesson its own 0.1.1 CHANGELOG records (`ERR_PACKAGE_PATH_NOT_EXPORTED`). Org moves from `>=1.2.0`, lms from `>=1.0.0`, sc from `>=1.11.0`, workflow from `>=1.7.0`. Header comments that pointed at the deleted files, or asserted core did not export these, were corrected rather than left dangling.
  • 4aeedad: One `LayerFactoryConfig` every layer factory's config extends, and one factory verb. Fourteen layer packages end in the same one call a consumer writes into `payload.config.ts`, and no two agreed on what `config` may contain: full seam vocabulary in three (org, lms, ledger), partial in six, NONE in six (2026-09-01 sale-readiness audit §5.3). A site that learned `adminGroup` from org and `hooks` from sc discovered, package by package, that six factories accept neither — not because the seam had been rejected, but because nothing said it existed. **New in core (a NEW exports-map subpath, hence the minor):** `@wabbit/tome-core/utilities/layerFactoryConfig` exports the `LayerFactoryConfig` interface — `adminGroup`, `access` (per-collection override map), `hooks` (appended via `mergeHooks`, never replacing), `extraFields`, `fieldOverrides`, `omitFields`, `fieldOrder`, `slugs` — and `applyLayerFactoryConfig(collections, config)`, which honours the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `create*Layer` config now extends it.** Twelve extend `LayerFactoryConfig` directly and APPLY it through `applyLayerFactoryConfig` (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, `adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted. Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). **economy** and **gamification** both declare `@wabbit/tome-core` as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches `registerLayer` through a lazy `require()` in a try/catch for exactly this reason. `applyLayerFactoryConfig` is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by `assert:no-forked-primitives`. The trigger is stated: the day core becomes a required peer, delete the note and add one line. Both take the type via `import type`, which is erased at runtime. Two packages drop seams EXPLICITLY rather than accept-and-ignore. **chrome** extends `Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>` because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against `CollectionConfig`, and chrome's slugs already have direct per-surface knobs (`header.slug`, `footer.slug`) a parallel map could contradict. The one seam it keeps, `adminGroup`, IS applied: globals carry `admin.group` exactly as collections do. **rpg** extends `Omit<LayerFactoryConfig, 'access'>` because `CharacterSheetsConfig` is a single collection's config that doubles as the layer factory's config, and its own `access` already means "this collection's access object" — one level shallower than the base's slug-keyed map. Two meanings under one name is the confusion this interface exists to end. **Factory-verb convergence.** Three verbs were live. `createWorkflowLayer(config?)` is new in `@wabbit/tome-workflow` (a new export — hence the minor) and returns a spreadable, deliberately EMPTY `CollectionConfig[]`: this layer is an engine, not a collection set, so the empty array is the honest answer and lets `...createWorkflowLayer()` compose exactly like every sibling. Its `WorkflowLayerConfig` omits every seam for the same reason, and exists as the stable place a real option will land. `createGamificationLayer` and `createRpgLayer` are pure aliases of `registerGamificationLayer` / `registerRpgLayer`. `initWorkflow`, `registerGamificationLayer` and `registerRpgLayer` are all `@deprecated` with sunset at each package's next major; none is removed. **Forcing function:** `scripts/assert-layer-factory-contract.mjs` + `pnpm assert:layer-factory-contract`, wired into `platform-discipline.yml` after `assert:layer-version` (source reading only, pre-build). Every exported `create*Layer` must take a config parameter whose type resolves to `LayerFactoryConfig` — through `extends`, an intersection, or an explicit `Omit<…>` — with verb aliases followed to their `register*`/`init*` target. Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. `docs/guides/create-a-new-layer-package.md` gains a "The factory contract" section stating the rule and the three permitted responses. Three factories are ALLOWLISTED with a reason each: `createAiLayer` returns credential wiring and owns no collections, so every seam is meaningless to it; `createFormsLayer` and `createIntakeLayer` are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files. **Peer floors:** accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise `@wabbit/tome-core` to `>=1.14.0 <2.0.0`. The new subpaths do not exist below that, and a too-low floor is how `ERR_PACKAGE_PATH_NOT_EXPORTED` reached crowdfund's consumers once already. These are marked `patch` because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.
  • 73081e6: Manifest metadata: `homepage`, `bugs`, `engines`. All 46 publishable manifests were missing the three fields a consumer sees before any code (2026-09-01 sale-readiness audit §6). Metadata only — no source, no build, no runtime change. - `homepage` deep-links to that package README on GitHub (`.../tree/main/packages/<dir>#readme`). Without it a registry page links to the monorepo root and the reader has to guess which of 46 folders they want. - `bugs.url` points at the repo issue tracker, so a paying customer has a place to report a defect that is not email. - `engines.node` is `>=22`, matching the root `engines` and `.nvmrc` set the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use for `node --test`, and a package installed on Node 20 fails at a runtime the installer cannot connect back to the version. The forcing function ships with the change: `scripts/assert-manifest-metadata.mjs` (root `pnpm assert:manifest-metadata`, wired into `platform-discipline.yml` beside `assert:license-metadata`) fails when any publishable manifest lacks `description`, `repository.directory` matching its own folder, `homepage`, `bugs`, `engines.node` equal to the repo floor, `license`, `files` or `sideEffects`. It reported 138 violations before this change and 0 after.
  • ce3d12d: 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 behaviour 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 characterisation 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.
  • 04309f5: Collapse the audited serial-await fan-outs in Payload hooks, jobs and access checks. No behaviour changes — every try/catch, failure reporter and `overrideAccess` justification is preserved; only the number of round-trips changes. **`find({ limit: 0 })` → `payload.count()`** — `limit: 0` sets `pagination: false` in the Mongo adapter, so the query loads every matching row into memory to produce one number. `@wabbit/tome-org` documents this as a production incident in `hooks/attendance-count-sync.ts:6-11` and had reintroduced it in `collections/recruiting/JoinRequest.ts`'s one-pending-request guard; `@wabbit/tome-sc`'s squadron member recount had the same shape. **Independent lookups → `Promise.all` / `Promise.allSettled`** — org's `createGiverWindowAccess` (a per-request access check that took two serial round-trips), `EventAttendance`'s display-name composer and `AwardPresentation`'s, sc's RSI profile+org page fetches (the hot path for handle validation, against a third-party host), squadron recalcs, and accounts' three offboarding teardown callbacks. `allSettled` wherever a branch had its own fallback, so a failed member lookup still cannot stop the event title from resolving. sc's RSI adapter keeps its 404 short-circuit exactly, and ledger's per-leg balance guard decides in leg order so the thrown `NegativeBalanceError` still names the same wallet the serial version did. **Independent per-row writes → `batchWrite`** (`@wabbit/tome-core/utilities/batch`) — org's notification open/resolve fan-outs, the division/team cleanup and sunset cascades (up to 1,000 rows each), the event cascade-delete, the non-atomic `memberCount` fallback; lms's certification-expiry sweep (now paced in `WRITE_CHUNK` chunks like its sibling reconciler) and the course-delete enrollment drop; workflow's deadline sweep; crowdfund's tier-claim reconcile. **Same `data` for every row → one bulk `payload.update({ where, data })`** — sc's asset-assignment auto-close and the transfer-request GDPR redaction, matching `sc/src/gdpr.ts`'s `makeNullRefHandler`. The auto-close also drops a latent correctness hazard: its page cursor advanced while its own writes removed rows from the filter it was paging over, so a page boundary could skip assignments. **Two collection-level fixes.** sc's Fleet had two field-level `beforeChange` hooks each issuing a `findByID` for the SAME ship on every write; they are now one collection-level hook that reads the ship once and sets both `chassisName` and `name`. lms's `checkCertificationExpiry` re-derived `recountHolders` once per expired award with no cache; it now recounts once per affected certification, after the sweep — which is also more correct, since only the final count was ever right. `@wabbit/tome-crowdfund`'s `settleCampaign` pledge loop is untouched and now carries an explicit `eslint-disable` plus the reason: it captures money one pledge at a time against a `maxCapturesPerRun` budget that only bounds anything if the iterations are serialized. `@wabbit/tome-org`, `@wabbit/tome-lms`, `@wabbit/tome-workflow` and `@wabbit/tome-crowdfund` raise their `@wabbit/tome-core` peer floor to `>=1.14.0`, the release that adds `./utilities/batch`. org and lms were also understating their floor before this change — both already imported `@wabbit/tome-core/jobs`, added in core 1.7.0, while declaring `>=1.2.0` / `>=1.0.0`.
v0.14.0minor

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

  • d1b7ea0: 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).
v0.13.0minor

6d32747: **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.

  • 6d32747: **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.
v0.12.1patch

4c1718a: 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.

  • 4c1718a: 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.
v0.12.0minor

6f1fc50: **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.

  • 6f1fc50: **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.
v0.11.1patch

879a493: 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.

  • 879a493: 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.
v0.11.0minor

9e3a893: **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.

  • 9e3a893: **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.
v0.10.0minor

cb3f93e: 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`.

  • cb3f93e: 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`.
v0.9.0minor

54f8009: 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.

  • 54f8009: 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.
  • 63f50b5: 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`.
  • 3240131: 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.
  • 6b242a4: 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.
v0.8.0minor

5e85ff2: 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.

  • 5e85ff2: 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.
v0.7.0minor

64c22fc: 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.

  • 64c22fc: 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.
v0.6.0minor

db62bee: 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 capitalised 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.

  • db62bee: 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 capitalised 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.
v0.5.0minor

ef3ef03: 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 behaviour 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`.

  • ef3ef03: 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 behaviour 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`.
v0.4.0minor

90f41ae: 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 behaviour 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) behaviour, 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.

  • 90f41ae: 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 behaviour 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) behaviour, 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.
v0.3.9patch

b5c324e: 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.

  • b5c324e: 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.
v0.3.8patch

1275810: 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.

  • 1275810: 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.
v0.3.7patch

7b66dcd: `dist` is now loadable by raw Node. tsup builds with `bundle: false`, so it emitted relative specifiers exactly as the TypeScript source wrote them — extensionless (`from "./hierarchy"`, `require("./hierarchy")`). Bundlers and tsx resolve those; raw Node does not. ESM raised `ERR_MODULE_NOT_FOUND`, and CJS was worse: `require("./x")` resolved to the ESM `.js` twin (`.cjs` is not in Node's CJS extension search list), and Node 22+ `require(esm)` then died on _that_ file's own extensionless import. Any consumer outside a bundler — the payload CLI under plain node, `generate:types`, ops scripts, codegen tools — hit this on every subpath that had relative imports; single-file subpaths loaded fine, which is why it went unnoticed. A post-build step (`scripts/fix-dist-extensions.mjs --strict`) now appends explicit extensions (`.js` / `/index.js`, `.cjs` / `/index.cjs`) and fails the build on any specifier it cannot resolve rather than guessing. No source changes, and bundler consumers are unaffected — extensioned relative specifiers are universally resolvable.

  • 7b66dcd: `dist` is now loadable by raw Node. tsup builds with `bundle: false`, so it emitted relative specifiers exactly as the TypeScript source wrote them — extensionless (`from "./hierarchy"`, `require("./hierarchy")`). Bundlers and tsx resolve those; raw Node does not. ESM raised `ERR_MODULE_NOT_FOUND`, and CJS was worse: `require("./x")` resolved to the ESM `.js` twin (`.cjs` is not in Node's CJS extension search list), and Node 22+ `require(esm)` then died on _that_ file's own extensionless import. Any consumer outside a bundler — the payload CLI under plain node, `generate:types`, ops scripts, codegen tools — hit this on every subpath that had relative imports; single-file subpaths loaded fine, which is why it went unnoticed. A post-build step (`scripts/fix-dist-extensions.mjs --strict`) now appends explicit extensions (`.js` / `/index.js`, `.cjs` / `/index.cjs`) and fails the build on any specifier it cannot resolve rather than guessing. No source changes, and bundler consumers are unaffected — extensioned relative specifiers are universally resolvable.
v0.3.6patch

dbac581: 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 behavioural change — same functions, same leaf they always resolved to.

  • dbac581: 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 behavioural change — same functions, same leaf they always resolved to.
v0.3.5patch

71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (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.

  • 71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (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.
v0.3.4patch

6779aa1: 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).

  • 6779aa1: 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).
v0.3.3patch

36e537a: `registerLayer` is now statically imported (forms/intake pattern) instead of lazily `require()`d in ten layer packages' init/register paths. The lazy pattern silently no-ops under Payload's native-ESM CLI (`generate:types` / `generate:importmap`), so layer registration could vanish without error. Packages whose tome-core peer is genuinely optional (economy, ai, gamification) deliberately keep the guarded lazy path; tome-core's `admin-nav/self-register.ts` deliberately keeps its subpath `require()` (documented ESM/CJS dual-cache fix — do not convert).

  • 36e537a: `registerLayer` is now statically imported (forms/intake pattern) instead of lazily `require()`d in ten layer packages' init/register paths. The lazy pattern silently no-ops under Payload's native-ESM CLI (`generate:types` / `generate:importmap`), so layer registration could vanish without error. Packages whose tome-core peer is genuinely optional (economy, ai, gamification) deliberately keep the guarded lazy path; tome-core's `admin-nav/self-register.ts` deliberately keeps its subpath `require()` (documented ESM/CJS dual-cache fix — do not convert).
  • 36e537a: Every package now declares an explicit `sideEffects` field (38 added; motion/engine/forms already correct). Registration-bearing modules (render files' `registerRenderer`, `blocks/*/index.ts` `defineBlock` self-registration, widget `register.ts` files, productHooks, permission self-registrations, print templates, chrome built-in variants) are listed so bundlers can tree-shake everything else WITHOUT dropping import-time registrations — previously the field was unset, which blocked cross-module tree-shaking through the barrels entirely. Never blanket `false` on a package with registration or CSS.
  • aef2725: DRY adoption sweep (the audit's "adoption, not extraction" rule): crm/deals capability presets delegate to core's `sessionHasCapabilityOrLegacyAdmin`; new core `buildOwnershipWhere`/`ownershipOrBypass` (via `./access`) adopted by core's vendorScoped, catalog's vendor-scoping, and org's ownOrScoped (public APIs unchanged); `slugField()` adopted at 7 sites where semantics matched exactly (core lms collections + createMemberCollection — replacing a third independent slugify), with ~25 sites honestly skipped for named semantic divergences (auto-regenerate-on-clear vs allow-empty, collection-level hook pattern) now listed as core-enhancement candidates; new `formatDisplayDate` in blocks-core utilities (UTC-pinned, hydration-safe) adopted at 5 verified-identical sites; lms-ui consolidates its two certificate date formatters locally; `useMediaQuery`/`useIsMobile` published from tome-ui and adopted by AppShell + admin's SidebarProvider; gamification's `awardPoints` now uses the authoritative `getPointsBalance` (fixes a divergent 1000-row scan cap vs the correct 10000).
v0.3.2patch

dca85a3: Core runtime-floor sweep: each package's `@wabbit/tome-core` peer floor now matches the newest core runtime export it actually imports, instead of the platform-wide `>=1.0.0` baseline from the original peer-range sweep. The stale floors let npm silently install a package next to a core version missing a module it runtime-imports, producing a hard `next build` failure at import time (reproduced 2026-07-11: tome-starter locked core 1.0.12 + admin 0.6.3 — `isAdminNavDomain` does not exist in core 1.0.x, where `registry/adminNav` was type-only). - `@wabbit/tome-admin` → `>=1.3.0 <2.0.0` — `nav/manifestResolver` runtime-imports `isAdminNavDomain` from `registry/adminNav`, first shipped as a runtime export in core 1.3.0 (Sidebar v2 Wave 0, d8ff1b2). - `@wabbit/tome-deals` → `>=1.1.0 <2.0.0` — runtime-imports `auth/repScoping` (`buildRepWhereClause` et al.) and `utilities/normalize` (`normalizeEmail`), both introduced in core 1.1.0 (consolidation pass, a9801fe). - `@wabbit/tome-accounts` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`roleSatisfiesPermission`, permission registration), introduced in core 1.2.0 (platform permission engine, 9238072). - `@wabbit/tome-org` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`checkPermissionHierarchical` et al.). - `@wabbit/tome-sc` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` across access helpers and military collections. Same defect class as the `tome-crm` floor raise to `>=1.1.0` (b027075); `tome-crm` is already correct and unchanged here.

  • dca85a3: Core runtime-floor sweep: each package's `@wabbit/tome-core` peer floor now matches the newest core runtime export it actually imports, instead of the platform-wide `>=1.0.0` baseline from the original peer-range sweep. The stale floors let npm silently install a package next to a core version missing a module it runtime-imports, producing a hard `next build` failure at import time (reproduced 2026-07-11: tome-starter locked core 1.0.12 + admin 0.6.3 — `isAdminNavDomain` does not exist in core 1.0.x, where `registry/adminNav` was type-only). - `@wabbit/tome-admin` → `>=1.3.0 <2.0.0` — `nav/manifestResolver` runtime-imports `isAdminNavDomain` from `registry/adminNav`, first shipped as a runtime export in core 1.3.0 (Sidebar v2 Wave 0, d8ff1b2). - `@wabbit/tome-deals` → `>=1.1.0 <2.0.0` — runtime-imports `auth/repScoping` (`buildRepWhereClause` et al.) and `utilities/normalize` (`normalizeEmail`), both introduced in core 1.1.0 (consolidation pass, a9801fe). - `@wabbit/tome-accounts` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`roleSatisfiesPermission`, permission registration), introduced in core 1.2.0 (platform permission engine, 9238072). - `@wabbit/tome-org` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`checkPermissionHierarchical` et al.). - `@wabbit/tome-sc` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` across access helpers and military collections. Same defect class as the `tome-crm` floor raise to `>=1.1.0` (b027075); `tome-crm` is already correct and unchanged here.
v0.3.1patch

bed3f90: Docs-manifest emitter pipeline (W3 ship-readiness). `@wabbit/tome-blocks-core` now ships a standalone Node ESM CLI at `scripts/emit-docs-manifests.mjs` that emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free `<pkg>/meta` block-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild: `node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>`. To let the emitter import block metadata uniformly without dragging Payload config into a build script, the `./meta` payload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block's `BlockMeta` was relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed. Every supported-core package additionally adds `CHANGELOG.md` to its published `files` array so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.

  • bed3f90: Docs-manifest emitter pipeline (W3 ship-readiness). `@wabbit/tome-blocks-core` now ships a standalone Node ESM CLI at `scripts/emit-docs-manifests.mjs` that emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free `<pkg>/meta` block-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild: `node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>`. To let the emitter import block metadata uniformly without dragging Payload config into a build script, the `./meta` payload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block's `BlockMeta` was relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed. Every supported-core package additionally adds `CHANGELOG.md` to its published `files` array so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.
v0.3.0minor

1a5e085: 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`.

  • 1a5e085: 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`.
v0.2.6patch

a9801fe: Consolidation pass (2026-06-10 audit dialect-drift findings) — the platform stops forking its own conventions: **tome-core (minor — new public APIs):** - `./auth/repScoping` — `buildRepWhereClause({ adminCapability, repField })` + `buildCapabilityScopedRead({ readCapability, adminCapability, repField })` + `sessionHasCapabilityOrLegacyAdmin` + `DENY_ALL_WHERE`. The canonical "rows I own" access primitive, promoted from crm/deals' ~90%-identical copies (266 LOC → one parameterized implementation). - `./utilities/normalize` — `normalizeEmail` (trim + lowercase). Email is the cross-layer join key; one normalizer, everywhere. - `./fields/slug` — `formatSlug` upgraded to the canonical algorithm (promoted from catalog's strictly-more-robust slugify: collapses whitespace/hyphen runs, trims edge hyphens); new `buildAutoSlugHook(sourceField, slugField)` collection-level variant. Stored slugs untouched; only future generations on irregular-whitespace inputs differ. **catalog / org / crm / deals (patch):** local copies replaced with delegations to the core primitives. Public names and signatures unchanged (`slugify`, `autoSlugHook`, `buildNormalizeEmailHook`, `normalizeDealEmail`, `repWhereClause`, `accountRepWhereClause`, `dealsRepWhereClause`, `dealsRepOrAdminWhereClause`). Notably, org's auto-slug header had _claimed_ to wrap core's slugifier while carrying a divergent local copy — now it actually does.

  • a9801fe: Consolidation pass (2026-06-10 audit dialect-drift findings) — the platform stops forking its own conventions: **tome-core (minor — new public APIs):** - `./auth/repScoping` — `buildRepWhereClause({ adminCapability, repField })` + `buildCapabilityScopedRead({ readCapability, adminCapability, repField })` + `sessionHasCapabilityOrLegacyAdmin` + `DENY_ALL_WHERE`. The canonical "rows I own" access primitive, promoted from crm/deals' ~90%-identical copies (266 LOC → one parameterized implementation). - `./utilities/normalize` — `normalizeEmail` (trim + lowercase). Email is the cross-layer join key; one normalizer, everywhere. - `./fields/slug` — `formatSlug` upgraded to the canonical algorithm (promoted from catalog's strictly-more-robust slugify: collapses whitespace/hyphen runs, trims edge hyphens); new `buildAutoSlugHook(sourceField, slugField)` collection-level variant. Stored slugs untouched; only future generations on irregular-whitespace inputs differ. **catalog / org / crm / deals (patch):** local copies replaced with delegations to the core primitives. Public names and signatures unchanged (`slugify`, `autoSlugHook`, `buildNormalizeEmailHook`, `normalizeDealEmail`, `repWhereClause`, `accountRepWhereClause`, `dealsRepWhereClause`, `dealsRepOrAdminWhereClause`). Notably, org's auto-slug header had _claimed_ to wrap core's slugifier while carrying a divergent local copy — now it actually does.
  • 4b2f368: 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.
v0.2.5patch

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

  • Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12
v0.2.4patch

Updated dependencies [36dc023]

  • Updated dependencies [36dc023]
  • Updated dependencies [2612799] - @wabbit/tome-core@1.0.11
v0.2.0minor

52c6bcb: **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.

  • 52c6bcb: **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.
v0.1.1patch

Updated dependencies - @wabbit/tome-core@0.2.0

  • Updated dependencies - @wabbit/tome-core@0.2.0