Documentation menu
Documentation / Community engine / org
Org
npm install @wabbit/tome-orgFirst install from our registry?
Get a registry token from your credentials page. You need a purchase that includes this package, or a Craft Library membership.
Add the registry and your token to the
.npmrcat the root of your project, with your token in place ofYOUR_TOKEN:@wabbit:registry=https://npm.wabbit.com/ //npm.wabbit.com/:_authToken=YOUR_TOKEN
Tome organization layer — org structure (divisions, teams, groups), members, role hierarchy, events, campaigns, and documents, with configurable terminology and 40+ permission grants.
Overview
Tome organization layer — org structure (divisions, teams, groups), members, role hierarchy, events, campaigns, and documents, with configurable terminology and 40+ permission grants. Description copied verbatim from package.json.
Layer: domain (per the repo's ARCHITECTURE.md § The Layer Model). @wabbit/tome-lms and @wabbit/tome-catalog both detect it at runtime (via the layer registry) to unlock vendor/scoped-org relationships; @wabbit/tome-sc depends on it directly.
Peer dependencies
Peer ranges, copied from package.json:
| 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 |
|---|---|
@wabbit/tome-org/collections/application | Standalone createApplicationCollection export (Recruiting cluster) |
@wabbit/tome-org/collections/joinRequest | Standalone createJoinRequestCollection export (Recruiting cluster) |
@wabbit/tome-org/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 | orgMemberRead (the default collection read), 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.
Default read: org members only
Every org collection reads org members only by default (orgMemberRead): staff (a role with accessType: 'admin'), a holder of the collection's own write permissions, and any account with a row in the member collection (memberSlug, linked through its user field) read every row; an anonymous visitor or a signed-in account with no member row reads nothing. The rule is a boolean, never a filter. Before 0.19 the default was core's authenticated, which on a site with public sign-up let any account read every roster, squad, team, award and delegated-authority row.
// opt back out, per collection (a consumer access.read always wins)
createTeamCollection({ access: { read: authenticated } })
createOrgLayer({ access: { 'org-teams': { read: authenticated } } })
// a stricter membership test keeps the staff and manager rules
createTeamCollection({ access: { read: orgMemberRead({ memberSlug: 'members', isMember: isFullMember }) } })Public pages: opt-in public read
Every org collection reads org members only by default, so an anonymous visitor reads nothing. A site with public event, division or member pages opts in per collection; never read with overrideAccess: true from a public page (that ignores visibility and leaks members-only events and their briefing).
createOrgLayer({
publicRead: { events: true, divisions: true, members: true },
})
// factory-only consumers: createEventCollection({ publicRead: true }), and the same on
// createDivisionCollection / createMemberCollection| Collection | Anonymous visitors read | Hidden from anonymous visitors (field access.read) |
|---|---|---|
events (publicEventsRead) | visibility field (fieldNames.securityLevel) in publicValues (default ['public']), status not in excludeStatuses (default ['draft']), _status = published when drafts: true | briefing, organizer, co-hosts, plus extraGuardedFields |
divisions (publicDivisionsRead) | status in publicStatuses (default ['active']) | extraGuardedFields only |
members (publicMembersRead) | classification in publicClassifications (default ['public']) AND standing in publicStandings (default ['active']) | user, reportsTo, position, timezone, region, lastPromoted, plus extraGuardedFields |
Org members keep the full read. Payload refuses a where on a field the reader cannot read, so an anonymous query filtering on a guarded field (for example organizer) throws: skip that query for anonymous visitors. An explicit access.read (factory access or the layer's per-collection access map) still wins over publicRead. With publicRead absent every collection is unchanged.
Waitlist counter on events
createOrgLayer({ includeWaitlistCount: true }) adds the events collection's read-only waitlistCount counter, right after attendeeCount, for a site that shows waiting lists. It is off by default and the collections are unchanged without it. The factory-only route is createEventCollection({ includeWaitlistCount: true }), which also takes fieldNames.waitlistCount and fieldDescriptions.waitlistCount. On a SQL database the counter is a new column, so a site with migrations generates one.
Event vocabulary and fields
The Event collection takes six seams, and there are two ways in. Either route changes only the Event collection: with none set it is the stock shape.
| Key | Use | |
|---|---|---|
eventTypeOptions | `{ mode: 'extend' \ | 'replace', options }` for the event types, for example Game, Practice, Tournament, Market Day. |
statusOptions | The same contract for the status words. | |
statusDefault | The default status; set it when statusOptions replaces the vocabulary so the default is one of the new values. | |
locationField | A Field[] that replaces the freeform location text field at the same position, for a structured place. | |
extraFields | Extra fields, appended at the end. | |
extraFieldsAfter | The field name extraFields follow, for example endDate. |
One-call sites pass them as a group: createOrgLayer({ events: { eventTypeOptions, statusOptions, statusDefault, locationField, extraFields, extraFieldsAfter } }). Each key is forwarded only when set, and the other twelve collections are never touched. The group's type is OrgLayerEventsConfig, a Pick of EventCollectionConfig, so the two cannot drift.
A site that builds the Event collection itself, such as one that owns its org bridge file, passes the same six keys straight to the factory: createEventCollection({ eventTypeOptions, statusOptions, statusDefault, locationField, extraFields, extraFieldsAfter }). A group handed to createOrgLayer is dropped when that site replaces the layer's Event collection, so use the factory route there. A new field is a new column on a SQL database, so a site with migrations generates one.
Links
CHANGELOG.md— the 0.3.2 entry documents a@wabbit/tome-corepeer-floor raise to>=1.2.0becausecheckPermissionHierarchicalet 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.
createOrgLayercalls 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 yourcreateMemberCollection(...)call. - Every factory merges `config.hooks` via `mergeHooks`, not a shallow spread. Passing
hooks: { afterChange: [myHook] }tocreateMembershipCollectionAPPENDSmyHookafter the built-inmemberCount-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. PasssquadronSlug: 'squadrons'(or your override) if you have@wabbit/tome-scinstalled and want that option to actually populate a relationship; otherwise leave it unset and treat'squadron'as reserved-but-unimplemented.
Adopting ranks from an existing app
createRankCollection was designed for a fresh install (public read, admin-only CRUD, 8 generic categories, no delete guard). Adopting it over an app with an EXISTING rank table — different category vocabulary, different field names, a members-only read policy, a delete guard — needs the options below instead of a fork. All are opt-in; omitting every one of them reproduces today's exact default output (pinned by tests/factory-snapshots.test.ts).
import { createRankCollection } from '@wabbit/tome-org'
import { authenticated } from '@wabbit/tome-core/auth/guards'
createRankCollection({
// Public read is a real posture choice, not a given — flip it for a
// members-only roster. `access` is a full CollectionConfig['access']
// shape; only the keys you pass are overridden, the rest keep the
// factory's defaults (create/update/delete stay MANAGE_STRUCTURE-gated).
access: { read: authenticated },
// Extend (not replace) the default vocabulary with your own values.
// Duplicate `value`s are deduped — the default wins.
categoryOptions: { mode: 'extend', options: [{ label: 'Ace', value: 'ace' }] },
categoryRequired: true, // if every existing row already has a category
// Rename the emitted fields to match an existing table instead of
// migrating data. You own keeping your own queries/populate hints
// consistent with whichever names you configure.
insigniaTextField: 'insigniatext', // one-character spelling divergence is easy to miss
insigniaImageField: 'image',
// Append fields your app already has that this factory doesn't model.
extraFields: [
{ name: 'discordRoleId', type: 'text' },
{ name: 'promotionPrerequisites', type: 'array', fields: [{ name: 'requirement', type: 'text' }] },
{ name: 'ceremonyScript', type: 'richText' },
],
// The deferred MVP+1 guard: throws a 400 APIError naming the holder
// count instead of deleting a rank members still hold. A `payload.count`
// READ against `memberSlug`/`memberRankField` (default `'org-members'`,
// `'rank'`) — never a write, so it cannot loop back into Member.
guardDeleteWhenHeld: true,
memberSlug: 'members', // your Member collection's actual slug
memberRankField: 'rank',
// Your own hooks compose alongside guardDeleteWhenHeld via mergeHooks —
// they never replace it, regardless of which hook key you use.
hooks: { afterChange: [myRankAuditHook] },
// Don't want the built-in `abbreviation` field, or want to supply your
// own `slug` field(s) instead of the factory's? Use these instead of
// filtering/replacing the factory's output after the fact.
includeAbbreviation: false,
slugField: [
{ name: 'slug', type: 'text', unique: true },
{ name: 'slugLock', type: 'checkbox', defaultValue: false }, // e.g. a paired slug + lock-field setup
],
// — or slugField: false to omit the field entirely (you supply your own via extraFields).
})slugField is the SAME option, with the SAME contract (Field[] | false, default = today's built-in field), on createDivisionCollection and createTeamCollection too — all three factories emit the identical built-in slug field shape, so the override is shared rather than reinvented per collection. includeAbbreviation is Rank-only: Division and Team have no abbreviation field to gate.
Adopting divisions/teams from an existing app
Some consumers call Divisions/Teams "Wings"/"Units" and model leadership by Position (Billet) reference rather than by Member — e.g. wings.leadership.commanderBillet vs Tome's Division.leader. createDivisionCollection/createTeamCollection grew a leadershipMode option for this rather than flipping the default, since existing consumers depend on today's member-referenced leader/deputy.
import { createDivisionCollection, createTeamCollection } from '@wabbit/tome-org'
createDivisionCollection({
leadershipMode: 'position',
positionSlug: 'billets', // your Position/Billet collection's actual slug
leadershipFields: {
commander: 'commanderBillet',
executiveOfficer: 'executiveOfficerBillet',
seniorNCO: 'seniorNCOBillet',
},
})
createTeamCollection({
leadershipMode: 'position',
positionSlug: 'billets',
leadershipFields: { leader: 'commanderBillet', other: 'otherLeadershipBillets' },
divisionRequired: false, // example: division deletion nulls team.division rather than cascading
})'position' mode REPLACES leader/deputy with a leadership group — it does not emit both. 'member' (the default, omit leadershipMode entirely) reproduces today's exact output, pinned by tests/factory-snapshots.test.ts.
Four additional gaps found in a real adoption. A consumer's own Division/Team adapters each needed a field or group kept local, verbatim, instead of letting the factory emit it. All four gaps below are closed by additive, opt-in options; defaults are unchanged:
import { createDivisionCollection, createTeamCollection } from '@wabbit/tome-org'
createDivisionCollection({
leadershipMode: 'position',
leadershipFields: {
commander: 'commanderBillet',
executiveOfficer: 'executiveOfficerBillet',
seniorNCO: 'seniorNCOBillet',
},
// NEW: field-level access on the leadership group. A consumer can lock it
// behind their own permission check — before this option existed, there
// was no seam for that, forcing the whole group to stay a hand-written
// local field.
leadershipAccess: { update: canManageHierarchy },
// NEW: passthrough for fields this factory doesn't model.
extraFields: [
{ name: 'discordCategory', type: 'text' },
{ name: 'discordRole', type: 'text' },
],
})
createTeamCollection({
leadershipMode: 'position',
leadershipFields: {
leader: 'commanderBillet',
// NEW: naming these widens Team's leadership group from the original
// 2-field shape (leader + other[]) to Division's 3-singular + other[]
// shape. Each is emitted ONLY when named — omit both to keep the
// original 2-field output byte-identical.
executiveOfficer: 'executiveOfficerBillet',
seniorNCO: 'seniorNCOBillet',
other: 'otherLeadershipBillets',
},
leadershipAccess: { update: canManageHierarchy },
// NEW: renames the Division-backlink field itself. Before this, it was
// hardcoded to 'division' with no override — a consumer's Team adapter
// had to keep its own 'wing' field local just for this. The export-only
// structural hooks (createDivisionTeamReciprocityHook's `divisionField`,
// createDivisionCleanupHook's `teamDivisionField`) already accepted this
// same name as a config option — pass the same value to both.
divisionField: 'wing',
extraFields: [
{ name: 'vcsCode', type: 'text' },
{ name: 'standingOrders', type: 'richText' },
],
})The four structural hooks below are exported factories, NOT auto-wired onto `createDivisionCollection`/`createTeamCollection`. A site without a Division/Team hierarchy installed must not gain a hook that queries collections it doesn't have — you opt in by attaching each one to your own collection's hooks, composed through mergeHooks so anything else already there survives.
import {
createMemberCountSync,
createDivisionTeamReciprocityHook,
createTeamSunsetHook,
createDivisionCleanupHook,
createTeamCleanupHook,
mergeHooks,
} from '@wabbit/tome-org'
// Member collection — keeps Division.memberCount / Team.memberCount honest.
// Counts the union of the hasMany array and the singular primary field, so
// a consumer using only one of the two conventions doesn't get undercounted.
const memberCountSync = createMemberCountSync({
memberSlug: 'members',
divisionSlug: 'wings',
teamSlug: 'units',
fields: { divisions: 'wings', primaryDivision: 'primaryWing', teams: 'units', primaryTeam: 'primaryUnit' },
onSyncFailure: reportSyncFailure, // your own Sentry/notification reporter — see OrgSyncFailureReporter
})
// Division-like collection ("wings") — reciprocal backlink to its children.
const wingHooks = {
afterChange: [
createDivisionTeamReciprocityHook({
divisionSlug: 'wings',
teamSlug: 'units',
teamsField: 'units', // Division's hasMany child-list field name
divisionField: 'wing', // Team's backlink field name
onSyncFailure: reportSyncFailure,
}),
],
afterDelete: [
yourDiscordCleanupHook, // MUST run first — see ORDER below
createDivisionCleanupHook({
memberSlug: 'members',
teamSlug: 'units',
teamDivisionField: 'wing',
divisionsField: 'wings',
primaryDivisionField: 'primaryWing',
onSyncFailure: reportSyncFailure,
}),
],
}
// Team-like collection ("units") — sunset cascade + delete cleanup.
const unitHooks = {
afterChange: [
createTeamSunsetHook({
memberSlug: 'members',
positionSlug: 'billets',
teamsField: 'units',
primaryTeamField: 'primaryUnit',
onSyncFailure: reportSyncFailure,
}),
],
afterDelete: [
yourDiscordCleanupHook, // MUST run first — see ORDER below
createTeamCleanupHook({
memberSlug: 'members',
teamsField: 'units',
primaryTeamField: 'primaryUnit',
onSyncFailure: reportSyncFailure,
}),
],
}ORDER requirement — read this before wiring `afterDelete`. createDivisionCleanupHook/createTeamCleanupHook NULL the very references (member.primaryDivision/divisions[], member.primaryTeam/teams[], team.division) that any consumer-side cleanup reading those fields (Discord role/channel teardown is one real consumer's case) needs to still be intact. Put your reference-reading cleanup FIRST in the afterDelete array, then append the cleanup hook from this package after it — exactly the ordering one production consumer's own Division adapter documents inline (afterDelete: [cleanupDiscordOnWingDelete, cleanupWingReferences, ...]). Getting this backwards doesn't throw; it silently leaves your reference-reading cleanup with nothing to read.
Never rethrows. Every hook above swallows its own failures and reports them through the injectable onSyncFailure (default: payload.logger.error, never a throw) rather than failing the write that triggered it — matching a common reporter contract (capture to Sentry + admin notification, never escalate past the caller's decision not to rethrow). Pass your own reporter to get real visibility instead of a swallowed log line.
Squad/squadron cascades are out of scope. A Team-sunset flow may also need to disband Groups/Squadrons under the team — @wabbit/tome-sc owns that domain, so createTeamSunsetHook/createTeamCleanupHook do not reach into it. Chain your own hook after these if you need that cascade too.
Adopting positions from an existing app
createPositionCollection's original authorityLevel field (strategic/operational/tactical/technical) was DESCRIPTIVE — nothing in this package or its known consumers ever read its value. A real consumer's own authorityLevel (organization/cascading/direct/advisory) is the opposite: an EXECUTABLE enum read by its leadership-resolution and permission-check logic to decide who can approve what. Same field name, disjoint vocabularies, one of them load-bearing — a naive merge would have silently disabled cascading authority with no error and no failing test.
The fix is a rename, not a merge: the old field is now `authorityTier` (same four values, same descriptive-only meaning), freeing the name authorityLevel for the executable semantics. This is the one deliberate default-output change in this factory — every other option below is opt-in, and tests/factory-snapshots.test.ts's Position assertions were updated to authorityTier deliberately.
import { createPositionCollection } from '@wabbit/tome-org'
createPositionCollection({
// Renames Position's own division/team scope fields — these were
// originally hardcoded to 'division'/'team', which a real adoption pass
// hit as a gap.
divisionField: 'wing',
teamField: 'unit',
divisionSlug: 'wings',
teamSlug: 'units',
// A real consumer has a THIRD, mutually-exclusive scope anchor with no
// Tome structural correlate — `ship`. `scopeFields` (below) and the
// `deriveDisplayTitle` walk both default to covering every entry here
// automatically, so you don't have to repeat the field name twice.
extraScopeFields: [{ name: 'ship', relationTo: 'commissioned-ships' }],
// Some consumers have no use for a second, purely-descriptive
// classification column alongside their real `authorityLevel` below.
includeAuthorityTier: false,
// Adds a SEPARATE `authorityLevel` field with a real consumer's four
// executable values verbatim (labels + admin description) — Tome adopts
// that behavior as the platform default for this field once a consumer
// opts in, rather than inventing a third vocabulary.
includeAuthorityLevel: true,
// authorityLevelOptions: { mode: 'replace', options: [...] }, // only if your vocabulary diverges further
// Some consumers' Position/category field is required; Tome's default isn't.
categoryRequired: true,
// categoryOptions: { mode: 'extend', options: [...] }, // only if your vocabulary diverges — many consumers' values match Tome's defaults verbatim
// Ported faithfully from a real consumer's beforeValidate hook: throws
// only when MORE THAN ONE scope anchor is set. Zero is a valid org-wide
// position ("or none for org-wide billets"). This is NOT "exactly one" —
// it's "at most one."
enforceScopeExclusivity: true,
// scopeFields defaults to ['wing', 'unit', 'ship'] here — derived from
// divisionField/teamField/extraScopeFields above. Only pass this
// explicitly to narrow or reorder it.
// Absorbs a delete-guard that does two things: a leadership-slot
// reference check against Division/Team (only meaningful when they run
// `leadershipMode: 'position'`), and direct holder cleanup — clears the
// holder's Member doc directly rather than relying on some other
// afterChange chain to notice the delete, avoiding re-entrancy. Team's
// check widened from 2 fields (leader/other) to a real consumer's 4
// (commander/XO/seniorNCO/other) — pass all 4 names to get the wider
// check; omit executiveOfficer/seniorNCO to keep the original 2-field
// query byte-identical.
guardDeleteWhenReferenced: true,
memberPositionField: 'billet', // your Member collection's actual field name; default 'position'
divisionLeadershipFields: { commander: 'commanderBillet', executiveOfficer: 'executiveOfficerBillet', seniorNCO: 'seniorNCOBillet' },
teamLeadershipFields: {
leader: 'commanderBillet',
executiveOfficer: 'executiveOfficerBillet',
seniorNCO: 'seniorNCOBillet',
other: 'otherLeadershipBillets',
},
// Absorbs a beforeChange hook that computes `isVacant` (= !currentHolder)
// and composes `displayTitle` together — both fields have existed since
// MVP with nothing computing them. The walk covers ANY scope anchor
// (wing, unit, OR ship, in that priority order — the first one set on
// the doc wins), not just division/team.
deriveDisplayTitle: true,
// composeDisplayTitle's signature is a plain `{ title, scopeName, scopeKind }`
// object — `scopeKind` is 'division'/'team'/the extra anchor's own name
// ('ship' here), letting a composer react to which KIND of scope
// resolved, not just its name. Default composition is "<scope name> —
// <title>". One real consumer's own format is "<title>, <scope name>" —
// pass this to match it:
composeDisplayTitle: ({ title, scopeName }) => (scopeName ? `${title}, ${scopeName}` : title),
// Append fields this factory doesn't model (a real consumer's
// `requiredCertifications`, `academyGrants` — `academyGrants`/
// `syncAcademyGrants` is LMS-domain and is NOT absorbed here).
extraFields: [
{ name: 'requiredCertifications', type: 'relationship', relationTo: 'certifications', hasMany: true },
],
})`academyGrants`/`syncAcademyGrants` stay consumer-local. That academy-authority cascade is LMS-domain (@wabbit/tome-lms territory) — this factory does not model it, so one consumer's cross-layer coupling never leaks into a package every consumer installs.
Adopting promotions from an existing app
createPromotionCollection's original shape (flat recommendedBy/approvedBy, lowercase status, an always-on four-eyes guard) is Tome's MVP default. A real production consumer's Promotions collection diverges on every one of those axes — enum case, field shape, immutability, and where four-eyes is actually enforced. Every option below is opt-in; the defaults are unchanged from the original factory output.
import { createPromotionCollection, DEFAULT_PROMOTION_STATUS_OPTIONS } from '@wabbit/tome-org'
createPromotionCollection({
// Enums are config, not code — a real consumer's capitalized five
// (thousands of live rows) replace Tome's lowercase five wholesale.
statusOptions: {
mode: 'replace',
options: [
{ label: 'Pending', value: 'Pending' },
{ label: 'Approved', value: 'Approved' },
{ label: 'Completed', value: 'Completed' },
{ label: 'Denied', value: 'Denied' },
{ label: 'Canceled', value: 'Canceled' },
],
},
statusDefault: 'Pending',
// A common adoption pattern: fromRank required, member locked once the
// record exists.
fromRankRequired: true,
memberImmutable: true,
// Swaps the flat recommendedBy/approvedBy for a grouped shape: a
// recommendations[] array (recommendedBy + reason) plus separate
// approval/denial groups (approvedBy+approvedAt / deniedBy+deniedAt+reason).
approvalModel: 'grouped',
// A pipeline-created ceremony promotion has no human recommender by
// design — bypasses "at least one recommendation required" when the
// named field is true.
validateBypassField: 'systemGenerated',
// Targeted, type-safe overrides for the grouped model's shape gaps —
// every key is additive-only, omit any you don't need.
groupedModelOverrides: {
recommendations: {
// admin.condition for the recommendations array field itself.
condition: (data) => !data.systemGenerated,
// beforeValidate hooks appended to recommendations[].reason.
reasonBeforeValidate: [canonicalizeLinkShape(), rejectInlineDataUri({ mode: 'warn', fieldLabel: 'recommendation reason' })],
},
approval: {
// Hide this group until it's populated or status matches (a common pattern).
condition: (data) => !!data.approval?.approvedBy || data.status === 'Approved' || data.status === 'Completed',
},
denial: {
condition: (data) => !!data.denial?.deniedBy || data.status === 'Denied',
// A richer node vocabulary for denial.reason so member-facing content
// (tables, the frontend editor's feature set) doesn't crash Lexical.
reasonEditor: lexicalEditor({ features: ({ rootFeatures }) => [...rootFeatures, EXPERIMENTAL_TableFeature(), FrontendEditorNodesFeature()] }),
},
},
// The last of the adoptable factories to gain this passthrough — covers
// 7 fields with no Tome structural correlate at all.
extraFields: [
{ name: 'authorizedBy', type: 'relationship', relationTo: 'members', admin: { condition: (data) => data.type === 'demotion' } },
{ name: 'systemGenerated', type: 'checkbox', defaultValue: false, admin: { readOnly: true } },
{ name: 'generatedBy', type: 'relationship', relationTo: 'members', admin: { readOnly: true, condition: (data) => !!data?.systemGenerated } },
{ name: 'triggeredByAcademy', type: 'relationship', relationTo: 'academies', index: true, admin: { readOnly: true } },
{ name: 'attendingCeremonies', type: 'relationship', relationTo: 'events', hasMany: true, admin: { readOnly: true } },
{ name: 'calledAtCeremony', type: 'relationship', relationTo: 'events', admin: { readOnly: true, condition: (data) => !!data.calledAtCeremony } },
{ name: 'calledAt', type: 'date', admin: { readOnly: true, condition: (data) => !!data.calledAtCeremony } },
],
// A consumer that already enforces four-eyes itself at the application
// layer (a self-block + submitter-block check against
// recommendations[].recommendedBy) would otherwise get a SECOND,
// independent enforcement of the same rule against fields
// `approvalModel: 'grouped'` doesn't even populate. Disable it rather
// than double-enforce.
fourEyes: false,
})`extraFields`/`validateBypassField`/`groupedModelOverrides` were added after a real adoption pass found all three gaps and had to keep recommendations/approval/denial as hand-written literals rather than factory output. displayName still collides by name with a consumer's own field-hook version and stays a hand-written literal regardless — extraFields doesn't help there, since the factory already emits its own displayName.
Two more absorbed behaviors are exported factories, NOT auto-wired — same export-only posture as the structural hooks above (createDivisionTeamReciprocityHook et al.): a site without a matching notifications collection or Member.rank field must not gain a hook that assumes one exists.
import { createPromotionNotificationCleanupHook, createMemberRankSyncHook } from '@wabbit/tome-org'
const promotionHooks = {
afterChange: [
// Absorbs the CORE of a rank-sync-on-completion flow: writes
// member.rank on completion, with BOTH of its re-entry guards ported
// as named, overridable context-flag options. Ceremony linking,
// onboarding auto-graduation, Discord sync, notifications, and cache
// revalidation all stay consumer-local — chain your own hook after
// this one for those.
createMemberRankSyncHook({
memberSlug: 'members',
completedStatus: 'Completed', // matches statusDefault/statusOptions above
guards: {
ceremonyClearContextFlag: 'rankUpdateClearCeremonies', // this IS the default
reconcilerPassContextFlag: 'reconcilerPass',
},
onSyncFailure: reportSyncFailure,
}),
],
afterDelete: [
// Absorbs a notification-cleanup flow born from a real orphaned-tasks
// incident: pending/denied notifications are keyed to the promotion by
// a variable-suffix dedup key (a recipient user id, etc.) with no
// finite list to enumerate, so the hook discovers keys by PREFIX
// instead — a discover-then-resolve, `like`-then-startsWith-recheck
// mechanism.
createPromotionNotificationCleanupHook({
notificationsSlug: 'notifications',
dedupKeyField: 'dedupKey',
statusField: 'bucket',
resolvedValue: 'done',
groupKeyFor: (id) => `promotions:${id}:`, // the default — shown for clarity
// Closes a gap a parity audit found — the original default write
// (`{ bucket: 'done' }`) was a strict SUBSET of a real consumer's
// write. Reaching parity means matching all four fields:
resolveWrite: () => ({
bucket: 'done',
resolutionMode: 'auto_resolved',
resolvedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
}),
// Per-row side effects this package has no opinion on — one consumer
// emits a socket event to the recipient and busts their triage cache
// tag. A throw here is caught/reported per row; it never aborts
// resolving the rest.
onResolved: async ({ notification }) => {
const recipient = typeof notification.recipient === 'string' ? notification.recipient : notification.recipient?.id
if (!recipient) return
for (const tag of notificationTags(recipient)) revalidateTag(tag)
await triggerEvent(`user-${recipient}`, 'notification:resolved', { id: notification.id, resolutionMode: 'auto_resolved' })
},
onSyncFailure: reportSyncFailure,
}),
],
}`DEFAULT_PROMOTION_STATUS_OPTIONS` is exported so a consumer can compose ({ mode: 'extend', options: [...DEFAULT_PROMOTION_STATUS_OPTIONS, ...] }) instead of replacing wholesale.
Never rethrows. Both hooks above swallow their own failures through the same injectable onSyncFailure contract as the structural hooks above (OrgSyncFailureReporter) — a completed promotion's member-rank sync or a deleted promotion's notification cleanup failing must never fail the write that triggered it.
Delegated Authority
Temporary cross-scope command delegation: grants scoped authority over a Division- or Team-shaped entity without touching the underlying Position assignment. Records are NEVER deleted — expired/revoked rows are the audit trail. Absorbed from a real consumer's ActingAuthority collection (11 fields). Not wired into `createOrgLayer` — call it directly with an explicit slug, same as Position/Rank/Promotion.
The scope select's options are DERIVED from divisionField/teamField, not a separate vocabulary: { label: <terminology>, value: divisionField/teamField }. A real consumer's stored values ('wing'/'unit') already equal its own field names for exactly this reason — its scope === 'unit' ? data.unit : data.wing branch only works because the value IS the field name.
import { createDelegatedAuthorityCollection } from '@wabbit/tome-org'
createDelegatedAuthorityCollection({
slug: 'acting-authority', // a real consumer's slug
divisionSlug: 'wings',
teamSlug: 'units',
positionSlug: 'billets',
fieldNames: { divisionField: 'wing', teamField: 'unit', position: 'billet' },
})Field-name mapping (a real adoption):
| Neutral (default) | 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. Behavior ported faithfully: paged 50-at-a-time, an optimistic "still active?" re-check immediately before each write (so a concurrent manual revocation's audit trail is never overwritten), records are never deleted, and a thrown error is caught/logged rather than propagated (expiry is idempotent, so a scheduler retry is harmless).
Access is not modeled generically. A real consumer's access check is ABAC over unit/wing leadership that this package cannot derive. Pass access to override the generic checkOrgRole(['MANAGE_STRUCTURE']) defaults.
Personnel Notes
Personnel records, disciplinary actions, and leadership notes — absorbed from a real consumer's MemberNotes collection (7 fields, tens of thousands of live rows). IMMUTABLE by default (update: () => false — "Notes cannot be edited once created."). Not wired into `createOrgLayer` — call it directly, same as the other personnel factories.
IMPORTANT: `author` relates to the MEMBER collection, not `users`. A real consumer's field is a members relationship (the officer's member profile, resolved via fetchMember(req) on create) — NOT the officer's user account. This factory does not replicate that auto-population (a fetchMember-equivalent utility is outside this package's scope); pass your own via hooks if you need it. It matters for GDPR: both member and author are member-id-keyed, so "erase both directions" means matching one memberId against TWO fields — see registerPersonnelGdpr below.
import { createPersonnelNotesCollection } from '@wabbit/tome-org'
createPersonnelNotesCollection({
slug: 'member-notes', // a real consumer's slug
memberSlug: 'members',
// Absorbs `setAuthorRankOrder`: denormalizes the author's rank order onto
// the note at creation time (immutability means it never goes stale),
// enabling rank-based read filtering without a join at read time. Falls
// back to a direct Rank lookup if the author's `rank` relationship comes
// back unpopulated (a bare ID string) rather than treating it as unresolved.
authorRankOrder: { rankSlug: 'ranks' }, // fail-secures to 99999 (highest = most restricted) on any resolution failure
// Absorbs an audit-on-create hook — called after every CREATE. A throw
// here never blocks the note from saving (try/catch-and-log-only).
auditCreate: async ({ doc, req }) => {
await req.payload.create({
collection: 'audit-logs',
data: { action: 'NOTE_ADDED', target: doc.member, /* ... */ },
req,
})
},
// Absorbs a read-audit hook — called on every read. The original is
// OPT-IN per request (`context.enableAccessLog`); this seam does not
// enforce that itself, so replicate the same check inside your own
// implementation if you want the identical opt-in behavior.
auditRead: async ({ doc, req }) => {
if (!req.context?.enableAccessLog) return
await req.payload.create({ collection: 'access-logs', data: { /* ... */ }, req, overrideAccess: true })
},
})Field-name mapping (a real adoption): member/author/type/content are overridable via fieldNames (all unchanged by default); classification and relevantDate are always-on, neutral-named — no divergence was found in that consumer's data for either.
Access is not modeled generically. Real-world access to this collection tends to be type-aware ABAC (per-type permission gates + chain-of-command) that this package cannot derive. The generic defaults reuse this package's pre-existing MANAGE_MEMBER_NOTES/CREATE_MEMBER_NOTES/DELETE_MEMBER_NOTES permission keys, shipped ahead of this collection for exactly this reason. Pass access to override.
Status Requests
Approval pipeline for member status changes (discharge, retirement, ban, plus self-requested short-leave/LOA) — absorbed from a real consumer's MemberStatusRequests collection (14 live rows). Not wired into `createOrgLayer` — call it directly, same as the other personnel factories.
Org owns the RECORD shape ONLY. No transition/execution hook is modeled — a real consumer's execution flow writes the settled status back via a DIRECT MONGO updateOne, bypassing Payload hooks entirely. That bypass IS the infinite-loop guard: the transition detector reads previousDoc.status !== 'executing', and routing the write through payload.update() instead would re-fire the same afterChange hook and offboard the member TWICE before the guard could engage. A consumer wires this transition engine as a separate module beside their adapter (the one calling createStatusRequestCollection) — using @wabbit/tome-workflow or a hand-rolled hook, either way OUTSIDE this factory.
import { createStatusRequestCollection } from '@wabbit/tome-org'
createStatusRequestCollection({
slug: 'member-status-requests', // a real consumer's slug
divisionSlug: 'wings',
fieldNames: { scopedDivision: 'scopedWing', targetPrimaryDivisionAtTime: 'targetPrimaryWingAtTime' },
titleDisplayField: 'rsiHandle',
approverScopeOptions: {
mode: 'replace',
options: [
{ label: 'Wing CO/XO', value: 'wing_co' },
{ label: 'VLC', value: 'vlc' },
],
},
divisionScopedValue: 'wing_co',
})type/status default to a real consumer's own five-value vocabularies verbatim (generic HR/pipeline terms, no divergence found). approverScope is the one exception: that consumer's real values (wing_co/vlc) are organization-specific jargon, so this factory ships a Tome-neutral default (division_lead/organization) and expects a { mode: 'replace' } override, same posture as RankCollectionConfig.categoryOptions.
Approval/denial groups are the SAME shared shape as Promotion's `approvalModel: 'grouped'` (buildApprovalDenialGroups, extracted in this increment) — approval: {approvedBy, approvedAt} + denial: {deniedBy, deniedAt, reason}. groupedModelOverrides mirrors PromotionCollectionConfig.groupedModelOverrides's contract exactly.
The built-in title-composition hook (computeTitle, default true) reproduces a common inline beforeChange pattern: "${capitalize(type)}: ${target member's titleDisplayField}", falling back to 'pending' when the target hasn't resolved. Set computeTitle: false to skip it.
Conduct Records
Formal discipline case tracking — absorbed from a real consumer's DisciplineRecords collection (2 live rows). Every field name defaults to that consumer's own — no rename was needed, only the collection SLUG differs by default (conduct-records vs. discipline-records). Not wired into `createOrgLayer`.
import { createConductRecordCollection } from '@wabbit/tome-org'
createConductRecordCollection({
slug: 'discipline-records', // a real consumer's slug
promotionSlug: 'promotions',
// Absorbs enforceCoolingPeriod — severities has no default (severityOptions is itself config).
coolingPeriod: {
severities: ['written-warning', 'probation', 'demotion-recommendation', 'ban-recommendation'],
days: 1, // 24 hours
},
// Absorbs autoAssignAppealReviewer's bookkeeping; the chain-of-command WALK is yours.
autoAssignReviewer: {
poolQuery: async ({ accountableOfficerId, excludeIds, req }) => {
// billet -> unit CO/XO -> wing leadership -> org-level authority walk
return resolveNextAuthority(req.payload, accountableOfficerId, excludeIds)
},
},
// Absorbs anonymizeForSubject — "IS the subject, and not privileged" is yours to decide.
subjectRedaction: {
predicate: async ({ doc, req }) => {
const viewer = await fetchMember(req)
return viewer?.id === doc.subject && !checkPermission(PERMISSIONS.MANAGE_DISCIPLINE, req.user)
},
},
})What stays consumer-side (never modeled, DO-NOT-DOUBLE): triggerStandingChange (cascades onto members via payload.update() — this factory emits relatedStandingChange as a plain field with the MODIFY_STANDING field-level gate absorbed, but never wires the write-back hook), auditDisciplineRecord (writes an audit-logs row this package doesn't own), and the 4 bespoke ABAC access functions (canReadDisciplineRecords's rank-floor query, etc. — pass access to override the generic MANAGE_DISCIPLINE-gated defaults).
What IS absorbed: enforceCoolingPeriod → coolingPeriod; setDisplayName → computeDisplayName/displayNameCompose (default composes "${severity label} — ${subject's subjectDisplayField}", reading the label from severityOptions rather than a second hardcoded table); autoAssignAppealReviewer → autoAssignReviewer (transition-detection + exclusion-set bookkeeping absorbed, the authority WALK is your poolQuery); anonymizeForSubject → subjectRedaction (field-nulling absorbed, the "is this reader the subject" ABAC is your predicate).
Expert Designations
Subject-Matter Expert designations — absorbed from a real consumer's SMEDesignations collection (0 live rows). Not wired into `createOrgLayer`.
LMS territory (a consumer's `specialistPath`/`qualifyingTier`) is deliberately NOT modeled — LMS stays opaque to org, per this package's layer boundary. Add it via extraFields:
import { createExpertDesignationCollection } from '@wabbit/tome-org'
createExpertDesignationCollection({
slug: 'sme-designations', // a real consumer's slug
memberDisplayField: 'rsiHandle',
canEndorse: async ({ req, id }) => {
// Academy Director + unit/wing leadership over the designation's specialist-path unit
return canEndorseSME({ req, id })
},
// Reproduces a real consumer's "${member.rsiHandle} — ${path.name}" once specialistPath exists:
composeDisplayName: async ({ req, data }) => {
const path = await req.payload.findByID({ collection: 'specialist-paths', id: data.specialistPath, depth: 0 })
const member = await req.payload.findByID({ collection: 'members', id: data.member, depth: 0 })
return `${member.rsiHandle} — ${path.name}`
},
extraFields: [
{ name: 'specialistPath', type: 'relationship', relationTo: 'specialist-paths', required: true, index: true },
{ name: 'qualifyingTier', type: 'number', required: true },
],
})A real consumer's compound-unique index on (member, specialistPath) needs specialistPath, which lives in extraFields — add it by mutating the returned config: cfg.indexes = [{ fields: ['member', 'specialistPath'], unique: true }].
`canEndorse` is an endorsement-access seam, not a full override: update access is true for MANAGE_SME_DESIGNATIONS holders OR when canEndorse resolves true. A real consumer's own endorsement check is a cascading-authority ABAC check (Academy Director scope, unit/wing leadership) this package cannot derive generically.
Discord notification stays entirely consumer-side, and runs last for free. This factory attaches NO afterChange hooks of its own — because mergeHooks APPENDS config.hooks after this factory's built-ins (hooks always append, never replace), a consumer's notifyDiscord passed via hooks.afterChange automatically runs last relative to anything this factory ever adds, reproducing a "Discord fires last so a webhook failure cannot block downstream hooks" ordering with no special wiring.
registerPersonnelGdpr — GDPR registration for all five personnel factories
import { registerPersonnelGdpr } from '@wabbit/tome-org'
registerPersonnelGdpr({
delegatedAuthoritySlug: 'acting-authority', // default 'delegated-authority'
personnelNotesSlug: 'member-notes', // default 'personnel-notes'
statusRequestsSlug: 'member-status-requests', // default 'status-requests'
conductRecordsSlug: 'discipline-records', // default 'conduct-records'
expertDesignationsSlug: 'sme-designations', // default 'expert-designations'
// A real erasure engine needs to be per-row tolerant for a collection
// this large: one locked row under 'bulk' would abort the WHOLE delete
// with zero rows erased.
personnelNotesDeleteShape: 'tolerant-per-row',
})Not called automatically by any factory — call it explicitly (or roll your own registration), same posture as @wabbit/tome-sc's registerScGdpr.
| 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 @wabbit/tome-org/collections/application, @wabbit/tome-org/collections/joinRequest, @wabbit/tome-org/collections/onboardingRecord also expose them as standalone subpaths (in addition to the root barrel).
| 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
| Import path |
|---|
@wabbit/tome-org |
@wabbit/tome-org/collections/application |
@wabbit/tome-org/collections/joinRequest |
@wabbit/tome-org/collections/onboardingRecord |
Changelog
0.19.xMinor
BREAKING: Every org collection now reads org members only by default (
orgMemberRead); a signed-in account with no member row reads nothing.Before, each collection read core's
authenticated: any signed-in account read every row. That was fine on a members' portal, where every account belonged to a member, but on a site with public sign-up it handed every roster, squad, team, position, award presentation, kudos, document, event and delegated-authority row to a stranger with an account.The new default,
orgMemberRead, answers true for staff, for a holder of the collection's own write permissions, and for an account with a row in the member collection; false for everyone else, anonymous or signed in. It is always a boolean, never a filter.Migration: nothing to do on a site where every account has a member row. A site that relies on any-signed-in reads passes
access: { read: authenticated }on the factory, or on the layer's per-collectionaccessmap; a consumer-suppliedaccess.readwins exactly as before.A site with a stricter notion of membership passes
access: { read: orgMemberRead({ memberSlug, isMember }) }and keeps the staff and manager rules. The Member collection now carries an explicitread(the same rule) instead of relying on Payload's signed-in default.createEventSlotCollection,createAwardCollection,createMedalCollectionandcreateRibbonCollectiongain amemberSlugoption so the lookup follows a renamed member collection. The opt-in public-read rules are unchanged.isOrgStaffOrManager(staff or a listed permission, no membership) is exported alongsideisOrgReader.
2 fixes (0.19.1 – 0.19.2)
- 0.19.2
Canceled statuses now accept both spellings.
This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet.
Training events, attendance, exam tickets, LMS orders, events, campaigns, CRM activities, resource requests, task forces and the event, status board and operation briefing blocks gain the American
canceled(CANCELEDon the status board) next to the existing value. Reads, queries, the closed-event lock, event board state and selection, the calendar export (STATUS:CANCELLEDis unchanged, it is the iCalendar literal), the status board badge, theme selectors fordata-event-statusand the erasure trail treat the two spellings as one meaning.The event list board labels gain a
canceledkey that is read before the oldcancelledkey. The old field is hidden, not removed, so stored text is kept on save.The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)".
Writers still store the old values.
- 0.19.1
Admin labels, field descriptions, demo content and messages now use American spellings.
0.18.xMinor
createOrgLayertakes aneventsgroup, so a one-call site can rename event types, swap status wording, replace the location field and add fields.The six keys are
eventTypeOptions,locationField,statusOptions,statusDefault,extraFieldsandextraFieldsAfter, the same keyscreateEventCollectionalready takes; a site that builds that collection itself passes them there directly. Each is forwarded only when set, the other twelve collections are never touched, and a layer built without the group is unchanged.The group's type is exported as
OrgLayerEventsConfig. On a SQL database a new field is a new column, so regenerate your migration if you add one.
0.17.xMinor
Documents can now be read in public, opt-in, and a public register of them cannot leak an internal one.
createOrgLayer({ publicRead: { documents: true } })(orcreateDocumentCollection({ publicRead })) adds avisibilityselect that defaults to internal, so turning the option on publishes nothing until an editor marks a document public. An anonymous visitor then reads only documents that are public, approved or published (archived and draft are never public) and not restricted to a division or team.authorandcollaboratorsare hidden from anonymous readers and cannot be used as filters. An explicitaccess.readstill wins.New exports:
buildPublicDocumentsWhere,publicDocumentsRead,PublicDocumentsReadOptions,PublicDocumentsCollectionOptions,DEFAULT_PUBLIC_DOCUMENT_GUARDED_FIELDS,DEFAULT_PUBLIC_DOCUMENT_STATUSES. A public register (anarchiveregistration) should passbuildPublicDocumentsWhere()as its fixedwhere: a member's read is unrestricted, so access alone would show internal documents to every member.documents: { files: true }(orfileson the factory) adds an upload field aftercontent(default namefile, relation tomediaCollectionunlessuploadCollectionis set). Uploads are public by URL, so while public read is on the field refuses a file on a document whose visibility is not public, on create and when a document that holds a file is switched to internal;allowOnInternal: truelifts the rule for a site with a private upload collection.documents: { categoryOptions }(orcategoryOptionson the factory) extends or replaces thecategoryvocabulary;DEFAULT_DOCUMENT_CATEGORY_OPTIONSis exported and equals today's eleven values.With none of these options the collection is byte-identical to before: no
visibilityorfilefield,read: authenticated, the samecategoryoptions (a golden cut from the pre-change source holds this). On a SQL database thevisibilityandfilefields are new columns only on sites that opt in, which then generate a migration.- Signed in is not the same as a member. With public read on, the full document read (every row,
authorandcollaboratorsreadable and filterable) goes only to staff (a role withaccessType: 'admin'), document managers (MANAGE_DOCUMENTSorDELETE_DOCUMENTS) and org members (a row in the member collection whoseuseris the signed-in user). Every other signed-in account, such as a customer or donor created by a public sign-up, reads exactly what an anonymous visitor reads: approved or published, public, unrestricted documents, withauthorandcollaboratorshidden and refused as filters. New exports:isOrgDocumentReader,documentReaderFieldRead,DocumentReaderOptions;publicDocumentsReadalso takesmemberSlugandmemberUserField, andguardFieldsForSignedIntakes an optional field guard. The layer has norestrictedTo*rule for signed-in readers, so members still read restricted rows, as they did before the option. With the option off nothing changes:read: authenticated, held by the default-collection golden. - BREAKING: for sites that turned on
publicRead.events,.divisionsor.membersin 0.16.0, signed-in accounts that are not staff, permission holders or org members now read only the public set (see the next bullet). A site that relied on every signed-in account seeing the full read must passpermissionsto the rule or set an explicitaccess.read. - Security fix for the 0.16.0 public read of events, divisions and members: signed in is not the same as a member there either. With
publicRead.events,.divisionsor.memberson, any signed-in account got the full read, so on a site with public sign-up a new customer account could read members-only and restricted events (withbriefing, organizer and co-hosts), disbanded divisions, and classified members withuser,reportsTo, position, timezone, region and last-promoted date. The full read now goes only to staff (accessType: 'admin'), holders of the collection's own write permissions (events:CREATE_EVENTS,EDIT_EVENTS,DELETE_EVENTS,MANAGE_EVENTS; divisions:EDIT_STRUCTURE,DELETE_STRUCTURE,MANAGE_STRUCTURE; members:INDUCT_MEMBERS,MANAGE_UNIT_ROSTER,MANAGE_STRUCTURE) and org members. Every other signed-in account reads exactly what an anonymous visitor reads, with the guarded fields hidden and refused as filters. A site that wants some other signed-in audience to see more can passpermissionsto the rule or an explicitaccess.read. New exports:isOrgReader,orgReaderFieldRead,resolveOrgReaderOptions,ORG_READER_PERMISSIONS,OrgReaderOptions;publicEventsRead,publicDivisionsReadandpublicMembersReadtakememberSlug,memberUserFieldandpermissions. With the options off nothing changes (read: authenticatedon events and divisions, Payload's signed-in default on members).
- Signed in is not the same as a member. With public read on, the full document read (every row,
1 fix (0.17.1)
- 0.17.1
README: subpath and file mentions are written package-qualified (for example
@wabbit/tome-x/render) or as code text, so crawlers no longer resolve them as site URLs.
0.16.xMinor
createOrgLayertakesincludeWaitlistCountand passes it to the events collection.createEventCollectionalready had the option, which adds a read-onlywaitlistCountcounter right afterattendeeCount, butcreateOrgLayernever passed it on, so a site on the one-call layer could not turn it on.createOrgLayer({ includeWaitlistCount: true })now does.It is off by default, and a layer built without it (or with it
false) is structurally identical to before. On a SQL database the counter is a new column, so a site that turns it on and uses migrations generates one.Purely additive: no peer is added or tightened, and nothing changes for a site that does not set it.
0.15.xMinor
Security/visibility fix: new opt-in
publicReadlets a public site show public events, active divisions and public members to anonymous visitors without leaking private rows or fields.createOrgLayer({ publicRead: { events, divisions, members } }), orpublicReadoncreateEventCollection/createDivisionCollection/createMemberCollection, applies the newpublicEventsRead,publicDivisionsReadandpublicMembersReadrules. Signed-in users keep today's read.Anonymous visitors get a database-side filter built from each collection's own fields: events whose visibility is
publicand whose status is notdraft; divisions whose status isactive; members whose directory visibility ispublicand whose standing isactive. Field-levelaccess.readhidesbriefing, the organizer and the co-hosts on events, anduser,reportsTo,position,timezone,regionandlastPromotedon members (extraGuardedFieldsadds more).Use this instead of reading with
overrideAccess: truefrom a public page, which ignores visibility and exposes members-only events. Nothing changes for a site that does not opt in, and an explicitaccess.readstill wins.
0.14.xMinor
Thread the fieldShape pipeline,
decidedByRelationTo, notification fan-out, and checklist item field names through the recruiting factories (R2-I1.1) — the seam gaps a consumer adapter's correct STOPs found and R2-I2 confirmed empirically (createApplicationCollection({ omitFields: [...] })silently ignored it).Fix — fieldShape pipeline now threaded through Application/JoinRequest/OnboardingRecord. None of the three recruiting factories applied
omitFields/fieldOverrides/fieldOrder, and none acceptedextraFieldsAfter(interior splice) —extraFieldsonly ever appended at the tail. All three now run the same wiring ordercreateAwardCollectionestablished (extraFieldsviainsertFieldsAfter→applyFieldOverrides→applyOmitFields→applyFieldOrder).Zero-config output is byte-identical; every new key is a no-op when omitted.
Fix — JoinRequest `decidedByRelationTo`.
decidedByunconditionally sharedrequester's relation (memberSlug) with no way to point it elsewhere. A production consumer's real shape needsdecidedByon'users', not the member collection.New
decidedByRelationToconfig key, defaulting tomemberSlug(byte-identical back-compat).New — `adminOverrides` on all three recruiting factories.
useAsTitle/defaultColumns/group/descriptionwere hardcoded in each factory's return statement, reachable nowhere exceptadminGroup(which only ever reachesgroup). This is a new convention, not an existing house mechanism being wired through — no other factory in@wabbit/tome-orgexposes more thanadminGroupeither (Award/EventSlotincluded).Scoped to the three recruiting factories per this increment's brief, not a silent package-wide fix.
adminOverridesshallow-merges last, soadminOverrides.groupwins overadminGroupwhen both are given.Fix — `createPendingStatusNotificationHook` gains `buildNotifications` (plural). The singular
buildNotificationcan express only one notification row per open transition; a production consumer needs N independent rows — one per resolved reviewer/officer.buildNotificationsreturns an array; each entry is created independently, deduped by its own key (an entry may setdedupKeyexplicitly — e.g. per-recipient — or falls back to `${baseDedupKey}:${index}), and one entry's create failure is reported viaonSyncFailure` without blocking the rest.Back-compat:
buildNotificationalone still works;buildNotificationswins when both are given. The hook now throws at creation time if neither is provided (previouslybuildNotificationwas a required config key — this is the same requirement, restated to cover the new either/or).Fix — sparse-checklist item field names.
buildSparseChecklistGroupField'scompletions[]rows hardcodeditemKey/completedAt/completedBywith no rename seam — the one holdout among this package's field-producing builders. NewitemFieldNames(on the builder directly, and per-entry oncreateOnboardingRecordCollection'schecklistGroups) renames them; a production consumer needstemplateItemId/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-orgsuite: 849/849 (814 baseline + 35 new).
12 fixes (0.14.1 – 0.14.6)
- 0.14.6
Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps.
What you install: one compiled
.js(ESM) and.cjs(CommonJS) file per source module, its.d.ts/.d.ctsdeclarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license:/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */.The
.mapfiles and thesourceMappingURLcomments that pointed at them are gone, which roughly halves the size of each tarball.Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line.
A
'use client'directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged.No API change, no runtime behavior change, and nothing to do on upgrade.
In
@wabbit/tome-blocks-gallery, the source snapshotsextractGallerySourcewrites from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code. - 0.14.5
createRibbonCollection({ certificationSlug: false })omits theeligibility.requiredCertificationrelationship.That relationship points at an LMS-owned collection. On a site with no LMS installed it failed Payload's config validation at startup, and nothing short of dropping the Ribbon collection could fix that.
The default (
'certifications') and custom slugs are unchanged. - 0.14.4
Internal refactor: collection slugs are now typed through the shared
typedSlug()helper instead of inline casts.No API or behavior change.
- 0.14.4
customer-facing wording: internal references removed from admin descriptions, error messages and block metadata.
- 0.14.3
autoSlugHookis now a typed delegate to core'sautoSlugFieldHook, with an identical body.Two domain layers cannot import each other, so the shared body moved down to core.
- 0.14.3
Local relationship-id helpers are replaced by
@wabbit/tome-core/utilities/relationId.Each call site maps to the core reader with the same return shape (raw vs. stringified id,
nullvs.undefined, polymorphic), so behavior is unchanged. The@wabbit/tome-corepeer floor goes up to>=1.17.0because that is the first core version exportingrelationIdRaw,relationIdsandrelationIdsRaw.extractRelationshipIdandextractRelationshipIds(public) are kept as deprecated delegates to core, with identical semantics. - 0.14.2
Fix
createAwardPresentationCollection's recipient-count recount resolving the wrong catalog collection slug, which silently brokerecipientCounton every write for a consumer using the factory's default catalog slugs.Root cause:
createRecipientCountSyncHook's own defaultresolveTargettreats the discriminator FIELD VALUE (awardType:'awards'/'medals'/'ribbons') as the target collection slug — a reasonable default for a generic hook, butcreateAwardPresentationCollectionnames its catalog collectionsawardSlug/medalSlug/ribbonSlug(default'org-awards'/'org-medals'/'org-ribbons', each independently overridable), never the bare type value. EveryafterChange/afterDeleteon an AwardPresentation therefore attemptedpayload.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 leavingrecipientCountfrozen (found on tome-starter 2026-09-12, where the consumer had already carried aresolveTarget/countWhereoverride as a bandaid — this patch replaces that at the root).The fix:
createAwardPresentationCollectionnow derives its ownresolveTarget(discriminator type → the factory's resolvedawardSlug/medalSlug/ribbonSlug) and matchingcountWhere(reversing the resolved slug back to the discriminator's type value, so the presentation-collection where clause still comparesawardTypeto'awards', never to'org-awards') from its own slug options, passed as the baserecipientCountSyncconfig BEFORE...config.recipientCountSync— so a consumer's own explicitresolveTarget/countWhereoverride still wins unchanged.Backward compatibility: a consumer who already worked around this (a custom
recipientCountSync.resolveTarget/countWhere) is unaffected — their override still takes precedence. A consumer relying on the (broken) default now gets a working recount instead of a silently-failing one; no API shape changed. - 0.14.1
Import
mergeHooks,fieldShapeand the select-option override contract from@wabbit/tome-coreinstead 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'sresolveFieldDescription/FieldDescriptionOverridewere NOT part of the duplicated set and stay in the package, moved toorg/src/fieldDescriptions.ts.org/src/optionOverrides.ts,lms/src/collections/shared/optionOverrides.ts→@wabbit/tome-core/fields/selectOptions;sc/src/collections/registry/shared.tsnow re-exports it (itsextractIdis 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/fieldShapeand./fields/selectOptionsare new subpaths, andfindPaged/chunk/readPositiveNumberare new named exports on the pre-existing./jobs. Crowdfund's floor moves from>=1.7.0even though./jobsitself shipped in 1.7.0 — the subpath resolving is not the same thing as the export existing, which is the sharper version of the lesson its own 0.1.1 CHANGELOG records (ERR_PACKAGE_PATH_NOT_EXPORTED).Org moves from
>=1.2.0, lms from>=1.0.0, sc from>=1.11.0, workflow from>=1.7.0.Header comments that pointed at the deleted files, or asserted core did not export these, were corrected rather than left dangling.
- 0.14.1
One
LayerFactoryConfigevery 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 whatconfigmay 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 learnedadminGroupfrom org andhooksfrom 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/layerFactoryConfigexports theLayerFactoryConfiginterface —adminGroup,access(per-collection override map),hooks(appended viamergeHooks, never replacing),extraFields,fieldOverrides,omitFields,fieldOrder,slugs— andapplyLayerFactoryConfig(collections, config), which honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated tofields/fieldShape'sapplyFieldShapeso the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config.It is a separate subpath from
./utilities/layerRegistrydeliberately — that module is in core'ssideEffectsarray, 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'sRecord<string, string | undefined>), and named<name>Slug?: stringscalars for relationship targets in OTHER layers (memberSlug,mediaSlug,eventSlug,rolesSlug) — those are pointers out of a layer, not entries in its key set.*Every `createLayer
config now extends it.** Twelve extendLayerFactoryConfigdirectly and APPLY it throughapplyLayerFactoryConfig(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-coreas an OPTIONAL peer and hold zero runtime imports of it — gamification reachesregisterLayerthrough a lazyrequire()in a try/catch for exactly this reason.applyLayerFactoryConfigis 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 byassert: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 againstCollectionConfig, 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 carryadmin.groupexactly as collections do. rpg extendsOmit<LayerFactoryConfig, 'access'>becauseCharacterSheetsConfigis a single collection's config that doubles as the layer factory's config, and its ownaccessalready 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 EMPTYCollectionConfig[]: 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
WorkflowLayerConfigomits every seam for the same reason, and exists as the stable place a real option will land.createGamificationLayerandcreateRpgLayerare pure aliases ofregisterGamificationLayer/registerRpgLayer.initWorkflow,registerGamificationLayerandregisterRpgLayerare all@deprecatedwith sunset at each package's next major; none is removed.Forcing function:
scripts/assert-layer-factory-contract.mjs+pnpm assert:layer-factory-contract, wired intoplatform-discipline.ymlafterassert:layer-version(source reading only, pre-build). Every exportedcreate*Layermust take a config parameter whose type resolves toLayerFactoryConfig— throughextends, an intersection, or an explicitOmit<…>— with verb aliases followed to theirregister*/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.mdgains a "The factory contract" section stating the rule and the three permitted responses.Three factories are ALLOWLISTED with a reason each:
createAiLayerreturns credential wiring and owns no collections, so every seam is meaningless to it;createFormsLayerandcreateIntakeLayerare 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-coreto>=1.14.0 <2.0.0. The new subpaths do not exist below that, and a too-low floor is howERR_PACKAGE_PATH_NOT_EXPORTEDreached crowdfund's consumers once already.These are marked
patchbecause the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead. - 0.14.1
Manifest metadata:
homepage,bugs,engines.All 46 publishable manifests were missing the three fields a consumer sees before any code (2026-09-01 sale-readiness audit §6). Metadata only — no source, no build, no runtime change.
homepagedeep-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.urlpoints at the repo issue tracker, so a paying customer has a place to report a defect that is not email.engines.nodeis>=22, matching the rootenginesand.nvmrcset the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use fornode --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(rootpnpm assert:manifest-metadata, wired intoplatform-discipline.ymlbesideassert:license-metadata) fails when any publishable manifest lacksdescription,repository.directorymatching its own folder,homepage,bugs,engines.nodeequal to the repo floor,license,filesorsideEffects. It reported 138 violations before this change and 0 after. - 0.14.1
Collapse the package's three slug-field builders and three slug hooks onto one
src/slugField.ts(2026-09-01 sale-readiness audit §5.1, T3(g)).What there was:
resolveSlugField.ts(buildDefaultSlugField+autoSlugHook, used by Division, Team, Rank, Event, Document, Wing, Unit);collections/recognition/catalogItemFields.ts(buildCatalogSlugField+catalogItemSlugifyHook, used by Award, Medal, Ribbon, and carrying its own hand-rolled slugify REGEX — one of the four the audit counted repo-wide, which is why the same title produced different anchors depending on which collection you were in); and a private third pair insideMemorial.ts, written only because the other two typedsourceFieldto a fixed'name' | 'title' | 'displayName'union while Memorial's display field iscallsign.What there is:
src/slugField.ts—orgSlugHook,buildOrgSlugField,resolveOrgSlugField— taking a plainstringsource field (so Memorial's reason to fork is gone) and avariant. 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
@deprecatedpointing at the new one:resolveSlugField,buildDefaultSlugField,buildCatalogSlugField,catalogItemSlugifyHook. Nothing is removed, andsrc/index.tsexports no new names — the new module is internal, so this is a patch.One deliberate behavior change, stated rather than buried. The catalog trio's hook now uses core's
formatSlug(as the org-wide and Memorial hooks already did) instead of its own/[^a-z0-9]+/gregex. The regexes agree on everything the audit cared about — case, whitespace runs, punctuation, edge hyphens, accented characters — except one: the old one mapped_to-, andformatSlugKEEPS_(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.tspins that case explicitly so the difference is a decision on the record rather than something rediscovered from a support ticket, and pins that a title without an underscore now produces the SAME anchor as the org-wide hook — which was the point.That test file is also the characterization proof: it was written from the pre-change source, run green against the three separate builders, and run green again after. It pins each caller's emitted field (hooks stripped, since a moved function is a new object) and each caller's three-state override contract —
undefined= the built-in field,false= no field,Field[]= the consumer's fields verbatim and by array identity. org's suite: 43 files, 873 tests, green. - 0.14.1
Collapse the audited serial-await fan-outs in Payload hooks, jobs and access checks.
No behavior changes — every try/catch, failure reporter and
overrideAccessjustification is preserved; only the number of round-trips changes.`find({ limit: 0 })` → `payload.count()` —
limit: 0setspagination: falsein the Mongo adapter, so the query loads every matching row into memory to produce one number.@wabbit/tome-orgdocuments this as a production incident inhooks/attendance-count-sync.ts:6-11and had reintroduced it incollections/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 andAwardPresentation'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.allSettledwherever 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 thrownNegativeBalanceErrorstill 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-atomicmemberCountfallback; lms's certification-expiry sweep (now paced inWRITE_CHUNKchunks 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'smakeNullRefHandler. 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
beforeChangehooks each issuing afindByIDfor the SAME ship on every write; they are now one collection-level hook that reads the ship once and sets bothchassisNameandname. lms'scheckCertificationExpiryre-derivedrecountHoldersonce 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'ssettleCampaignpledge loop is untouched and now carries an expliciteslint-disableplus the reason: it captures money one pledge at a time against amaxCapturesPerRunbudget that only bounds anything if the iterations are serialized.@wabbit/tome-org,@wabbit/tome-lms,@wabbit/tome-workflowand@wabbit/tome-crowdfundraise their@wabbit/tome-corepeer floor to>=1.14.0, the release that adds./utilities/batch. org and lms were also understating their floor before this change — both already imported@wabbit/tome-core/jobs, added in core 1.7.0, while declaring>=1.2.0/>=1.0.0.
0.13.xMinor
New Recruiting cluster — three opt-in collection factories plus three shared hook factories and a sparse-checklist field builder (Recruiting/Onboarding Wave / R2-I1).
createApplicationCollection— staged-intake application (submitted/in-review/accepted/rejected/withdrawn, generic vocabulary — NOT any one consumer's own status words).createJoinRequestCollection— a join request against ANY org node, withtargetRelationTo/targetLabelREQUIRED 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/handoffby 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.createApplicationCollectionaccepts it viaguardDirectTransition.createPendingStatusNotificationHook— the SAMEupsertNotification/resolveNotificationsByGroupKeyopen/close fan-out bothApplicationsandWingJoinRequestsindependently reimplemented byte-for-byte identically at a consumer's integration branch. Built ONCE; bothcreateApplicationCollectionandcreateJoinRequestCollectionaccept it viapendingStatusNotification.createTaskCleanupOnDeleteHook— a generic afterDelete hook running an injectedresolveTasksresolver, never rethrowing.createApplicationCollectionaccepts it viataskCleanupOnDelete.
buildSparseChecklistGroupField(collections/recruiting/shared/sparseChecklistField.ts) — the sparse-completions-log shape (acompletions[]array of only the items actually done, template lives elsewhere) the coupling map found used three independent times;createOnboardingRecordCollectionuses it twice (intake/handoff).The approval/assignment contract:
createJoinRequestCollectionnever assigns anything on approval — flippingstatusto 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'sassert-node-loadable.mjs --every-filegate (196/196 targets pass).@wabbit/tome-workflowadded as an optional peer + devDependency solely to prove, against the realclaimTransitionprimitive, thatJoinRequest'sstatusfield is CAS-compatible — the factory itself has no runtime dependency on the workflow engine.Not wired into
createOrgLayer(design principle 2). No consumer-specific vocabulary ("wing", rank names) appears in any factory's runtime code — proven by a dedicated test that strips comments/string-literals from each source file and asserts zero "wing" substring remains, plus a consumer-emulation test per factory showing a production consumer's real shape is reachable purely through config injection.
0.12.xMinor
New Recognition cluster — six opt-in collection factories, three hook factories, one access factory, and GDPR registration (Recognition Wave / R-I1).
createAwardCollection,createMedalCollection,createRibbonCollection— the catalog trio (formal award/medal/ribbon definitions), sharing acatalogItemFields.tsbuilder 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, wiringcreateDisplayNameComposeHook(compose seam), an opt-increateAwardedBySelfEnforcementHook(admin "on behalf of" bypass, non-admin forced to self), and a default-oncreateRecipientCountSyncHook(payload.count(), neverfind({limit:0})— the historical N+1 regression this cluster's own pins guard against).createKudosCollection— peer-to-peer recognition,updateaccess via the new exportedcreateGiverWindowAccessfactory (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
fieldShapemechanism (fieldOverrides/fieldOrder/omitFields/fieldDescriptions-null-suppress/extraFields/extraFieldsAfter) and per-verbaccessoverrides — 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'sonExtraSyncis 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_VERSIONis unchanged.
1 fix (0.12.1)
- 0.12.1
Fixed a defect where
createRecipientCountSyncHook'srecount()(the recognitionrecipientCountsync attached toAwardPresentation'safterChange/afterDelete) ran itspayload.count()against the resolved CATALOG collection (target.collection—'awards'/'medals'/'ribbons') instead of the SOURCE presentation collection the hook is attached to.countWherebuilds itswhereclause 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 intoreportOrgSyncFailure(logger-only, never throws), so the recount silently never ran in any production-shaped use —recipientCounthas been permanently stuck wherever it started since this hook shipped in 0.12.0.recount()already receivedsourceCollectionas a parameter (the presentation collection, fromcollection.slugin bothafterChangeandafterDelete's hook args) but only used it in the failure report. The count call now targetssourceCollection; the subsequentpayload.updateis unchanged and still correctly targetstarget.collection— count-from-source, write-to-target is the contract.The existing unit tests asserted the count call's
collectionequaled the catalog collection, which enshrined the bug as expected behavior (the mock never validated the argument against real Payload's path-queryability rules); they now assert the count/update collection asymmetry directly, plus a regression pin using a source-collection slug distinct from every catalog slug so a reintroduced swap cannot pass by coincidence.
0.11.xMinor
Document factory threaded through the shared `fieldShape` mechanism (Wave 8 D-I1).
createDocumentCollectionwas the last org collection factory not wired toapplyFieldOverrides/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 defaultcreateDocumentCollection()call is byte-identical to today — every seam below is opt-in.New seams:
fieldNames— renamesauthor/restrictedToDivision/restrictedToTeam/lastReviewedAt/scopedTo/status.slugField: Field[] | false— sameresolveSlugFieldcontract asEvent/Division/Team/Rank. The single-field →Field[]replacement already generalizes aslug+slugLockcompanion-pair swap; no separatereplaceFieldsseam was needed.scopeOptions/scopeDefault/scopeOrgWideValue—{mode,options}override for thescopeselect, same contract asEvent.statusOptions.scopedToMode: 'dual-fields' | 'polymorphic'—'dual-fields'(default) keeps today'sscopedToDivision/scopedToTeampair.'polymorphic'emits ONE relationship field (scopedToRelationTo-injectablerelationTo) — a production consumer's realscopedTois a single polymorphic relation to['wings', 'units', 'commissioned-ships'].workflowStatusOptions/workflowStatusDefault—{mode,options}override forworkflowStatus.includeStatusFieldopt-in emits a SEPARATEstatusselect (own vocabulary viastatusFieldOptions/statusFieldDefault) for consumers who split publication state from review state.fieldOverrides/fieldOrder/fieldDescriptions(suppress-via-nullconvention)/extraFields/extraFieldsAfter— the standard shared set.fieldOverridesalso coversdocumentId's unique-flag/admindivergences; no dedicateddocumentIdFieldseam was needed.
accesswas already fully injectable per-verb (shallow-merged over the factory defaults) — verified a query-returningAccessfunction round-trips throughconfig.access.readunchanged. 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_VERSIONuntouched.
1 fix (0.11.1)
- 0.11.1
Wave 8 D-I1.1 — closes the seam gap the D-I2 adoption pass at a production consumer's integration branch stopped on:
createDocumentCollectionunconditionally emittedfreshnessDueDate/publishedAtwith 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 tofieldShape.ts— the DELETE seamapplyFieldOverrides/applyFieldOrder/resolveFieldDescriptiondon't cover, since none of them can make a factory-emitted field disappear. Same error discipline asapplyFieldOrder(an unknown name throws).Threaded through
createDocumentCollectiononly, via a newomitFields?: string[]config option, applied AFTERfieldOverridesand BEFOREfieldOrder— an omitted field is already gone by the timefieldOrderruns, so a consumer'sfieldOrderlist never has to name a field it just deleted.omitFields: ['freshnessDueDate', 'publishedAt']removes exactly those two fields; omittingomitFields(the default) leaves both present, byte-identical to today.applyOmitFieldsis 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_VERSIONuntouched — additive seam, no default change.
0.10.xMinor
Wave 7 I5.1/I6.1 — closes the seam gaps the I5 (
Events/index.ts) and I6 (EventAttendance/EventSlots/EventTypes) adoption passes at a production consumer's integration branch hit and reported in their STOP lists.Every seam is opt-in with an identical default (
createEventCollection()/createEventAttendanceCollection()/createEventSlotCollection()/createEventTypeCollection()with no config each emit their exact prior shape — the pre-existing default-shape baseline specs stay green, unmodified).New shared mechanism (`packages/org/src/fieldShape.ts`), built once and threaded through all four events-cluster factories — the design rule this increment works under: prefer ONE shared mechanism over ten bespoke options.
applyFieldOverrides(fields, overrides)— thefieldNamed()ELIMINATOR. Both adopt passes had to reach into a factory's already-builtfieldsarray by hand (I5'sEvents/index.tsrestoringrequired/index/label/admin.description/field-levelaccessonorganizer/coHosts/attendeeCount/waitlistCount/campaign/status; I6'sEventSlots/index.tsstrippingslotId's admin back off via a manual.map()).fieldOverrides: Record<name, Partial<Field>>mergesadmin/accessone 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:EventAttendancecouldn'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-statefieldDescriptions: { x: null }suppress convention (undefinedkeeps the default, astringoverrides,nullomits theadmin.descriptionkey entirely). ClosesEventSlot.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 asstatusOptions/visibilityOptions—eventTypehad no options seam at all before this);slugField: Field[] | false(same contract asDivisionCollectionConfig.slugField/resolveSlugField.ts, now shared by Event too — a production consumer's realslugField()helper is aunique: falseslug +slugLockcompanion 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 localinsertFieldsAfterLocalTODO onto the now-merged sharedinsertFieldsAfter.`createEventSlotCollection`:
extraFields/extraFieldsAfter(previously append-only, no anchor);fieldOverrides/fieldOrder;slotId/slotIndexnow route throughresolveFieldDescription, sofieldDescriptions: { slotId: null }suppresses the field'sadminkey 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");logPrefixoverrides 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 realrequiredCertification).`createEventTypeCollection`:
extraFields/extraFieldsAfter(there was NO anchor mechanism at all before this — closes a production consumer's realdescriptiontextarea, which has no factory correlate);colorFieldType: 'text' | 'select'+colorOptions(that consumer's realcoloris a paletteselect, not freeform text — a field TYPE mismatchfieldOverridescan'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 isfalse(today's exact behavior — always resolves the Event and callsisPrivilegedregardless ofreq.user);truereproduces a production consumer's real!req.user || checkRole(...)early bypass, including skipping the Event lookup (findByID) entirely — a genuine gap the oldisPrivileged-only seam couldn't close (a consumer's predicate could reject an anonymous request but not skip the DB call).enforceSelfOnly's default istrue— this hook already reproduces that consumer's!req.userbypass unconditionally (walkInHookBypass.spec.ts's pinned source-text assertions match today's code exactly); the option NAMES that existing behavior rather than changing it, andfalseopts 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 --noEmitclean; build +assert-node-loadable.mjs --every-file148/148 PASS.ORG_LAYER_VERSIONuntouched — every seam here is export-only/opt-in, none auto-wired intocreateOrgLayer.
0.9.xMinor
Wave 7 org-adoption I1 —
createEventCollectiongrown 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 — seeevent-collection-w7-i1.test.ts's baseline); no factory increateOrgLayercalls 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(renamesdivision/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'svisibilityvalues,public/members/restricted, diverge from the MVP'spublic/members-only/restricted);locationField(wholesale override for the MVP's freeform text field — that consumer's reallocationis a structured group);coHostsMaxRows(that consumer caps at 5, MVP unlimited);includeWaitlistCount(a NEW opt-in readOnly counter besideattendeeCount— the MVP has no waitlist model);fieldDescriptions(every previously-hardcodedadmin.descriptionstring 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 realleadUnit/participatingUnits/participatingWingssit immediately aftercoHosts, well before the capacity/counter fields near the end of this factory's array; the tail-append convention every other factory'sextraFieldsuses (Rank/Promotion/StatusRequest/ExpertDesignation) can't reproduce that.Omitting
extraFieldsAfterkeeps 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) — portsenforceClosedRecordLock: value-diff (not key-presence) based, blocks writes to a terminal-status event outside anallowlist, bypassable viabypassContextKeys(default: a production consumer's ownbypassEventLock/migrationBackfill).createLifecycleLogHook(event-lifecycle-log.ts) — portslogLifecycleTransition: append-only log REBUILT fromoriginalDoconly 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-identicaldelete*.tsafterDelete 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) — portsresolveEventNotificationsOnDelete: sameresolveWrite/onResolvedinjectable seam shape as Wave 6'screatePromotionNotificationCleanupHook, 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) andtests/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 --noEmitclean; build +assert-node-loadable.mjs --every-file132/132 PASS.ORG_LAYER_VERSIONis 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 readsdateTime.startDate/endDatefield names this factory does not own — socreateClosedRecordLockHook'sterminalStatusescheck is value-based againststatusFieldonly; a consumer wanting the time-based derivation keeps a synced "effective status" field and pointsstatusFieldat it.Grow
createEventAttendanceCollectionseam-complete for adoption by a production consumer (Wave 7 I2), ported from that consumer's realEventAttendancecollection (its integration branch,src/collections/EventAttendance/index.ts+ itshooks/directory).All additive and opt-in — the zero-arg default output is unchanged (same field set, same single
beforeValidateuniqueness hook, noindexeskey).statusOptionsextend/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-throwcreateAttendanceUniqueHook, 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
includeXflag and name-overridable viafieldNames/committedAssetFieldNames/readinessFieldNames:selectedSlotId,readiness(5-subfield advisory group),trainingOutcome(passed/failed/incomplete — deliberately never gets anadmin.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 onattendedStatusValue), andadminNotes(field-levelaccessseam, default gated onMANAGE_ATTENDANCE/MANAGE_EVENTS). fieldDescriptionsoverride for every hardcodedadmin.descriptionthis factory can emit (same contract as Wave 7 I1'sEventFieldDescriptions).computeDisplayName/displayNameCompose— default OFF (the MVP'sdisplayNamefield exists today but nothing populates it; defaulting this on would add a hook where none exists, breaking the default-shape guarantee). Same naming asConductRecordCollectionConfig's W6 seam.extraFields/extraFieldsAfter— localinsertFieldsAfterLocalhelper (the sharedinsertFieldsAftermodule 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 ofrestrictStatusChange; injectableisPrivileged(req, event)(a production consumer: organizer-OR-co-host).createEnforceSelfOnlyHook— port ofenforceSelfOnly; injectableresolveMemberId.createAttendeeCountSyncHook— port ofsyncAttendeeCount; usespayload.count(), neverfind({ limit: 0 })(that consumer's own documented anti-pattern fix);onExtraSyncseam for ceremony/promotions maintenance.createEngagementOnFirstAttendanceHook— port ofupdateOnboarding's idempotent first-attendance flip;isEngagedidempotency gate + consumer-ownedwrite.
Wave 7 I3 — upstreams a production consumer's EventSlot mirror as two new opt-in exports:
createEventSlotCollection(default slugorg-event-slots) andcreateSlotSyncHook.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):eventrelationship (slug injectable viaeventSlug),slotId, and the denormalized descriptor fields (groupIndex/assetIndex/slotIndex/roleName/isCommander) — every field name overridable viafieldNames, every admin description overridable viafieldDescriptions. LMS-owned relations (requiredCertification,requiredSpecialistPath) are deliberately NOT modeled — same cross-wave-edges rule already applied inExpertDesignation.ts/DelegatedAuthority.ts— add them viaextraFields.readdefaults toauthenticated;create/update/deletedefault-gate onCREATE_EVENTS/MANAGE_EVENTS, fully overridable viaaccess.CRITICAL, pinned by test: the
[event, slotId]compound index is emitted WITHOUTunique: true, by design — a real DB-level unique index here previously causedsyncEventSlots'screate()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.tsasserts 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-collectionafterChangehook factory: an injectableextractSlots(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 underPromise.allSettled(one failing slot — classically a duplicate-key race — never aborts the batch or the others).Clears orphaned attendance
selectedSlotId-equivalent references via an injectableattendanceconfig (slug + field names), opt-out viaattendance: falseor omitting it. NEVER throws: the whole body runs under a top-level try/catch routed through the package's existingreportOrgSyncFailure/onSyncFailureseam (same convention asmember-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/accessoverrides, sync-hook create/delete/update paths,Promise.allSettledisolation with the E11000 hint text, the never-throws guarantee (including a customonSyncFailurereporter), and the attendance-clearing opt-out. 341/341 org tests green;tsc --noEmitclean; build +assert-node-loadable.mjs --every-file126/126 PASS.Isolation note: PRs #341 (
events/Event.ts,hooks/event-*.ts,insertFieldsAfter.ts) and #342 (events/EventAttendance.ts,types.tsadditions) are in flight concurrently. This PR touches none of their files —EventSlot.tsandevent-slot-sync.tsare 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.tsis untouched entirely:EventSlotCollectionConfig/SlotSyncHookConfigare defined locally in their own files rather than added to the shared config-type file#342is already extending, and the collection's own default slug (DEFAULT_EVENT_SLOT_SLUG = 'org-event-slots') is a local constant rather than a newORG_DEFAULT_SLUGS.eventSlotsentry — a follow-up can fold it intoOrgSlugsonce the sibling PRs land.createOrgLayerandORG_LAYER_VERSIONare untouched; wiring slots into the layer factory is a deferred, explicit follow-up decision, not assumed here.Wave 7 I4 — absorbs the EventType delete guard
EventType.ts's own header deferred to MVP+1, behind a new opt-inincludeDeleteGuard(defaultfalse, byte-identical to today's factory).`createEventTypeCollection({ includeDeleteGuard: true })` adds a
beforeDeletehook (createEventTypeDeleteGuard, new export) that blocks deletion while any Event still references the type, throwing a400 APIError. Ported from a production consumer's integration branch'ssrc/collections/EventTypes/index.tsbeforeDelete hook.Two
matchOnmodes seam the field-shape divergence between today's runtime and upstream's own documented future direction:'value'(default) counts Events whereeventTypeField(default'eventType') equals the EventType doc'svalueSTRING — that consumer's actual semantics, since itsEvents.eventTypeis aselectfield storing that string, and this package's owncreateEventCollectionships the identicalselectshape today;'relationship'counts Events whereeventTypeFieldequals the EventType doc's ownid, for wheneventTypebecomes a real relationship (that consumer's own file header names this as a future migration).eventsSlugdefaults toORG_DEFAULT_SLUGS.events.deleteBlockedMessageoverrides 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.descriptionstrings (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,fieldDescriptionsoverrides, guard wiring (hook count, mergeHooks append with a consumer's own hooks), bothmatchOnmodes,deleteBlockedMessageoverride, customeventsSlug/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.tsadditions), 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.tsandevent-type-guard.test.tsare 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.tsis untouched entirely:EventTypeFactoryConfig/EventTypeFieldDescriptionsare defined locally inEventType.ts(extending the existing, unmodifiedEventTypeCollectionConfigimport) rather than added totypes.ts, which #342 is already extending.createOrgLayerandORG_LAYER_VERSIONare untouched — this guard is export-only/opt-in, never auto-wired into the layer factory.
0.8.xMinor
Wave 6 org-adoption I8 — the last three org-domain personnel factories:
createStatusRequestCollection,createConductRecordCollection,createExpertDesignationCollection.All opt-in, none wired into
createOrgLayer(design principle 2). Ownership split: org owns the RECORD shape; a transition engine (e.g.@wabbit/tome-workflow) owns any TRANSITION logic — none of the three factories model a transition/execution hook.`createStatusRequestCollection` (default slug
status-requests, a production consumer:member-status-requests, 14 live rows). 18 neutral fields absorbed fromMemberStatusRequests/index.tsverbatim.type/statusdefault to that consumer's own five-value vocabularies (generic HR/pipeline terms);approverScopeships a Tome-neutral default (division_lead/organization) since that consumer's real values (wing_co/vlc) are organization-specific jargon — replace wholesale viaapproverScopeOptions.The grouped
approval/denialfields 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-identicalfactory-snapshots.test.tsguarantee, no regression risk taken). The built-intitle-composition hook (computeTitle, defaulttrue) reproduces that consumer's inlinebeforeChangeexactly, reading a configurabletitleDisplayField(default'displayName'; that consumer:'rsiHandle').NOT modeled, by design: the donor consumer's
executeStatusRequestafterChange hook. It writes the settled status back via a DIRECT MONGOupdateOne, 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 aworkflow.tsmodule 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). AbsorbsenforceCoolingPeriod(coolingPeriod— newcreateConductRecordCoolingPeriodHook,hooks/conduct-record-cooling-period.ts),setDisplayName(computeDisplayName/displayNameCompose, default composes fromseverityOptions's resolved label + a configurablesubjectDisplayField),autoAssignAppealReviewer's transition-detection bookkeeping (autoAssignReviewer— newcreateConductRecordAutoAssignReviewerHook,hooks/conduct-record-appeal-reviewer.ts— the chain-of-command WALK is an injectedpoolQuery), andanonymizeForSubject(subjectRedaction— newcreateConductRecordSubjectRedactionHook,hooks/conduct-record-subject-redaction.ts— the "is this reader the subject" ABAC is an injectedpredicate).relatedStandingChangegets a field-levelMODIFY_STANDING-gatedaccess.updatedefault (relatedStandingChangeAccessto override).Stays consumer-side, never modeled:
triggerStandingChange(cascades a standing change ontomembers— real re-entrant hook machinery this package doesn't own),auditDisciplineRecord(writes to anaudit-logscollection 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'sspecialistPath/qualifyingTier) is deliberately NOT modeled per the wave plan's cross-wave-edges rule; add it viaextraFields.canEndorseis an endorsement-access seam layered on top ofMANAGE_SME_DESIGNATIONS.Discord notification stays entirely consumer-side and runs last automatically — this factory attaches no
afterChangehooks of its own, so a consumer'snotifyDiscordpassed viahooks.afterChangeis appended after everything else bymergeHooks(design principle 5), reproducing that consumer's "Discord fires last" ordering with no special wiring.GDPR:
registerPersonnelGdprextended to register all three new collections asretain/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-overridablephase/order(*Phase/*Orderconfig keys) and its ownregister*: booleanopt-out (defaulttrue, mirrorscreateFulfillmentLayer's/createSCLayer'sregisterGdpr: 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-rowpersonnel-notescollection (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.tsextended (10 → 22 tests). 321/321 org tests green;tsc --noEmitclean; build +assert-node-loadable.mjs --every-file122/122 PASS.
0.7.xMinor
Wave 6 org-adoption I3.1 + I7 — closes the four Position factory gaps the real adoption by a production consumer's billets hit (
69af1fe6), and adds two new opt-in personnel factories under a consistent naming convention.All opt-in, defaults byte-identical.
I3.1 — Position follow-up.
Billets/index.ts(a production consumer's actual adapter overcreatePositionCollection) needed four things the I3 pass didn't model:- `divisionField`/`teamField`: Position's own
division/teamfield names were hardcoded — that consumer's Billets call thesewing/unit. Renaming also renamesadmin.defaultColumnsand the field descriptions. - `extraScopeFields`: that consumer has a THIRD, mutually-exclusive scope anchor with no Tome structural correlate —
ship(relationTo: 'commissioned-ships').scopeFields(forenforceScopeExclusivity) now defaults to[divisionField, teamField, ...extraScopeFields.map(f => f.name)]automatically. - `includeAuthorityTier` (default
true, today's shape): that consumer passesfalse— no use for a second, purely-descriptive classification column alongside its realauthorityLevel. - `categoryRequired` (default
false) + `categoryOptions`:categoryhad no requiredness/vocabulary override, unlike Rank's identically-shaped field. The consumer'sBillets.categoryisrequired: true. New exportDEFAULT_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 viateamLeadershipFields, 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 — includingextraScopeFields— not just division/team. The walk itself is generalized to NscopeAnchors, 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.tsandposition-collection-w6-i3.test.tsuntouched in behavior (two tests updated only for thecomposeDisplayTitlesignature 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'sActingAuthorityverbatim under neutral names, all overridable viafieldNames. Thescopeselect's option VALUES are derived fromdivisionField/teamFieldrather than a separate vocabulary — that consumer's real values (wing/unit) already equal its own field names for exactly this reason. The compound-uniquenessbeforeValidateguard (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(newhooks/delegated-authority-expiry.ts) wraps the pureexpireDelegatedAuthority(payload, now, options)as a standardJobHandlerforasPayloadTask/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'sMemberNotesverbatim. IMPORTANT:authoris a MEMBER relationship, notusers— matches that consumer's real shape (fetchMember(req)-resolved).immutabledefaultstrue(update: () => false). Opt-inauthorRankOrderconfig absorbssetAuthorRankOrder(denormalizes the author's rank order at create time, fail-secures to99999on any resolution failure, falls back to a direct Rank lookup when the author'srankrelationship is an unpopulated ID).auditCreate/auditReadseams absorbauditNoteCreation/logReadAccess— both fire-and-log-only, never block the write/read on failure. - `registerPersonnelGdpr` (new
src/gdpr.ts, mirrors@wabbit/tome-sc'sregisterScGdpr): registersdelegated-authorityasretain/post-identity (org history) andpersonnel-notesashard-delete/pre-identity with a CUSTOMonDelete— the registry's built-inuserField/memberFieldOR pair can't express "match onememberIdagainst two DIFFERENT member-keyed fields" (memberANDauthor), 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 intocreateOrgLayer(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 overcreatePromotionCollection,26c86583on that consumer's integration branch) hit three more gaps, documented in that commit's adapter header and pinned bypromotionsHooksParity.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/groupedModelOverridesoptions:validateBypassFieldadds the array-levelvalidatebypassing "at least one recommendation required" when the named field (that consumer:systemGenerated) istrue;groupedModelOverrides.{recommendations,approval,denial}addadmin.conditionper group/array (that consumer hidesapproval/denialuntil populated orstatusmatches), abeforeValidatehook seam onrecommendations[].reason(that consumer: link canonicalization + inline-data-URI guard), and a rich-texteditoroverride fordenial.reason(that consumer needs a richer node vocabulary to avoid a Lexical crash on member-facing content). - `createPromotionNotificationCleanupHook`'s resolve-write:
promotionsHooksParity.spec.tsproved the hook's one-field write ({ bucket: 'done' }) was a strict SUBSET of that consumer's realresolveNotificationByDedupKey, which writes FOUR fields (bucket/resolutionMode/resolvedAt/expiresAt) — missingexpiresAtspecifically meant the 30-day retention cron would never sweep the row. NewresolveWrite(default: today's one-field write) andonResolved(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.tsandwave6-i2-1-i6.test.tsuntouched in behavior and green — every default output (including the pinnedapprovalModel: 'grouped'field shapes) is unchanged.243/243 org tests green;
tsc --noEmitclean; build +assert-node-loadable.mjs --every-file108/108 PASS.- `divisionField`/`teamField`: Position's own
0.6.xMinor
Wave 6 org-adoption I2.1 + I6 — closes the four Division/Team factory gaps the real adoption by a production consumer hit, and makes
createPromotionCollectionadoptable.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
accessfor the position-modeleadershipgroup — that consumer locks it behindcanManageHierarchyand 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 theadmin.defaultColumnsentry and description. - `leadershipFields.executiveOfficer`/`.seniorNCO` (Team): the position-mode
leadershipgroup had onlyleader+other[]where that consumer's real shape has FOUR fields — the same 3-singular + other[] shape Division already had. Each new field is emitted ONLY when named, so the original 2-field default stays byte-identical. Division and Team now share the identical option surface.
I6 — Promotion adoptable.
- `statusOptions` + `statusDefault`: enums are config, not code — a production consumer's capitalized five (
Pending/Approved/Completed/Denied/Canceled, 2663 live rows) replace Tome's lowercase five via the same{mode, options}contract asRank.categoryOptions. ExportedDEFAULT_PROMOTION_STATUS_OPTIONS. - `fromRankRequired` (default
false) + `memberImmutable` (defaultfalse, adds field-levelaccess.update: () => falseonmember). - `approvalModel: 'flat' | 'grouped'` (default
'flat'):'grouped'swaps the flatrecommendedBy/approvedByfor a production consumer's real shape — arecommendations[]array (recommendedBy+reason) plusapproval(approvedBy+approvedAt) anddenial(deniedBy+deniedAt+reason) groups, field names read verbatim fromPromotions/index.ts. - `createPromotionNotificationCleanupHook` (new
promotion-notification-cleanup.ts): export-only afterDelete factory absorbingresolvePromotionNotificationsOnDelete(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 alike-then-real-startsWithre-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 ofupdateMemberRank— writesmember.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 (ceremonyClearContextFlagdefault'rankUpdateClearCeremonies',reconcilerPassContextFlagdefault'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-inpromotionFourEyesHookoff. That consumer enforces four-eyes itself at the application layer (canApprovePromotion's self-block + submitter-block, also checked inconfirmSoftBan) — running Tome's hook too would be a second, independent enforcement of the same rule against fieldsapprovalModel: 'grouped'doesn't even populate.
Both new hooks follow the same export-only, never-auto-wired, never-rethrows posture as the I2 structural hooks (
OrgSyncFailureReporterinjectable). New test filetests/wave6-i2-1-i6.test.ts(41 tests);factory-snapshots.test.tsandwings-units-w6-i2.test.tsuntouched and green — every default output is unchanged.
0.5.xMinor
Wave 6 org-adoption I3 —
createPositionCollectiongrows the config surface a production consumer'sBilletsneeds to adopt it, and closes the wave's headline risk: theauthorityLevelenum collision.- `authorityLevel` collision closed at the root, via rename. Tome's original
authorityLevelfield (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'sBillets.authorityLevel(organization/cascading/direct/advisory) is EXECUTABLE — read bygetLeadershipProfile,resolveLowestCommonAuthority, andcheckActionPermission/*. 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) — freeingauthorityLevelfor 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 expectsauthorityTierin place ofauthorityLevel, updated deliberately in this same commit. Every other option below is opt-in with byte-identical defaults. - `includeAuthorityLevel` (default `false`) + `authorityLevelOptions`: adds a SEPARATE
authorityLevelselect carrying the consumer's four executable values verbatim (labels + admin description), exported asDEFAULT_AUTHORITY_LEVEL_OPTIONS. Tome adopts that consumer's behavior as the platform default for this field once a consumer opts in. - `enforceScopeExclusivity` (default `false`) + `scopeFields` (default `['division', 'team']`): absorbs the consumer's REAL
Billets/index.tsbeforeValidate 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"). Newposition-scope-exclusivity.ts. - `guardDeleteWhenReferenced` (default `false`): absorbs
beforeDeleteBillet's S2 (refuses delete when a Division/Team runningleadershipMode: 'position'references this Position in a leadership slot) + S3 (directly clears the holder's Member doc, bypassing any afterChange chain — same re-entrancy reasoningrank-delete-guard.tsalready documents for Rank). ConfigurablememberPositionField(default'position', that consumer:'billet') anddivisionLeadershipFields/teamLeadershipFields(same shape asDivisionCollectionConfig/TeamCollectionConfig.leadershipFields). Newposition-delete-guard.ts. - `deriveDisplayTitle` (default `false`) + `composeDisplayTitle` override: absorbs the consumer's
beforeChangehook, which computesisVacant(= !currentHolder) and composesdisplayTitletogether in one pass — both fields have existed since MVP with nothing computing them. Default composition is"<scope name> — <title>"; passcomposeDisplayTitleto match that consumer's own"<title>, <scope name>"shape verbatim. Newposition-derivations.ts, exportsdefaultComposeDisplayTitle. - `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
createPositionCollectionbehind their opt-in flags (not export-only) viamergeHooks, so a consumer's own hooks compose alongside them rather than replacing them — same pattern as Rank'sguardDeleteWhenHeld.- `authorityLevel` collision closed at the root, via rename. Tome's original
0.4.xMinor
Wave 6 org-adoption I2 —
createDivisionCollection/createTeamCollectiongrow an opt-inleadershipMode+divisionRequiredsurface, and four structural sync hooks absorbed from a production consumer'sWings/Units/Membersproduction behavior are exported (never auto-wired).Every option below is opt-in; the existing
factory-snapshots.test.tsDivision/Team assertions stay green, proving default output is byte-identical.- `leadershipMode: 'member' | 'position'` (default
'member', today'sleader/deputymember-relationship fields, unchanged).'position'drops those two fields and emits aleadershipgroup of Position relationships instead — Division:commanderPosition/executiveOfficerPosition/seniorNCOPosition; Team:leaderPosition/otherLeadershipPositions[]. Field names are overridable vialeadershipFieldsso that consumer can adopt with its existingcommanderBillet/executiveOfficerBillet/seniorNCOBillet/otherLeadershipBilletsnames, no data rename.positionSlugthreads therelationTo(defaultORG_DEFAULT_SLUGS.positions). - `Team.divisionRequired` (default
true, today's shape). That consumer'sunits.wingis nullable — wing deletion nulls the backlink rather than cascading — passfalseto match. - `createMemberCountSync({ memberSlug, divisionSlug, teamSlug, fields })` — Member
afterChange/afterDeletehooks that atomically syncDivision/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$incwhen the DB adapter is Mongoose (duck-typed at runtime — this package has no@payloadcms/db-mongodbdependency), 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), andcleanupWingReferences/afterUnitDelete(afterDelete reference cleanup) behavior, all fully configurable by slug/field name. All accept an injectableonSyncFailurereporter (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 ontocreateDivisionCollection/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 inafterDelete). - `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 dropabbreviationand swap the built-inslugfield for its ownslugField('name')pair; consumer-side editing of factory output isn't an adoption contract, so both are now first-class options.slugFieldis shared verbatim across Rank/Division/Team (identical built-in field shape, newresolveSlugField.tshelper);includeAbbreviationis Rank-only since Division/Team have noabbreviationfield to gate.
- `leadershipMode: 'member' | 'position'` (default
0.3.xMinor
Converge onto the single platform permission engine.
tome-orgnow registersORG_PERMISSIONSand its super-permission map (translated to value-space) into@wabbit/tome-core/auth/permissionsand delegatescheckOrgRole/hasPermissionto 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-shapedrole.permissionsmap, multi-role, unpopulated/string-id roles, null user) plus super-permission implication.Adds exports
registerOrgPermissions,buildOrgSuperPermissionMap,ORG_OVERRIDABLE_PERMISSIONS.
11 fixes (0.3.1 – 0.3.9)
- 0.3.9
Wave 6 org-adoption I1 —
createRankCollectiongrows the config surface a production consumer'sRankscollection 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.tsstaying green plus 16 new option-specific tests.- `access` full override: already possible via
BaseOrgCollectionConfigspreadingconfig.accessafter the factory's defaults, but undocumented and untested for Rank specifically. The consumer needsread: authenticatedin place of the defaultread: 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
categoryselect's 8 generic values had zero overlap with the consumer's 11 lore values, and the field wasn'trequiredwhile the consumer's is. NewcategoryOptions?: { mode: 'extend' | 'replace', options }(same contract as@wabbit/tome-core/gdpr/compliance'sdataCategoryOptions, ported to a newpackages/org/src/optionOverrides.tshelper rather than importing the GDPR-domain module) lets a consumer extend or replace the vocabulary;categoryRequired?: boolean(defaultfalse, today's shape) flips requiredness. Defaults exported asDEFAULT_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(lowercaset— a one-character, silent-null-risk mismatch the wave plan flagged) andimage; defaults stayinsigniaText/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/hooksbeforeDelete guard (packages/org/src/hooks/rank-delete-guard.ts) — apayload.countread against the configuredmemberSlug/memberRankField, never a write, so it cannot loop. Throws a400 APIErrornaming the holder count when any Member still references the rank; passes through silently at zero holders. Runs throughmergeHooks, so a consumer's ownbeforeDelete/afterChangehooks compose alongside it rather than replacing it.
Keeps the consumer's
insigniatextspelling via an upstream field-name option rather than forcing a rename. - `access` full override: already possible via
- 0.3.8
Wave 6 org-adoption preflight (W6-I0) — upstream hygiene with zero default-behavior change for existing consumers (starter, wabbit-site-core, other registry consumers).
- Stale layer version fixed:
registerLayer('@wabbit/tome-org', …)hardcodedversion: '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 readsORG_LAYER_VERSIONfromsrc/version.ts, pinned topackage.jsonbytests/layer-version.test.ts(mirrors the@wabbit/tome-lms/@wabbit/tome-accountspattern). - Hook-replacement bug fixed: every factory that merged
config.hookswith{ ...builtInDefaults, ...(config.hooks ?? {}) }silently DROPPED a built-in hook whenever a consumer set the same key — confirmed live inMembership.ts(a consumerafterChangewould drop thememberCountsync) andPromotion.ts(a consumerbeforeValidatewould drop the four-eyes guard). All 12 collection factories now merge through a newmergeHooks(base, extra)helper (src/hooks/mergeHooks.ts, ported frompackages/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.mergeHooksis exported from the package barrel. - `member-collection` slot claim gap documented and closed: audited whether
resolveMemberSlugfalls 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 ofcreateOrgLayer(). Finding: the slot claim is currently READ BY NOTHING in the monorepo and cannot even carry a slug value (LayerSlotClaimis{slot, claimant, claimedAt}) —resolveMemberSlugresolves purely from thememberSlug/userSlugconfig 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. AddedclaimOrgMemberSlot()(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.memberCountadmin descriptions claimed the field auto-syncs;hooks/member-count.tsonly 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
entityTypeselect has offered'squadron'since the MVP spec with no backing relationship field, so a savedsquadronrow has never referenced anything. Added an OPT-INsquadronSlug?: stringconfig (MembershipCollectionConfig) that, when passed, adds asquadronrelationship field gated onentityType === 'squadron'. Left opt-in rather than defaulted: a hardcodedrelationTowould point at a collection slug (e.g.@wabbit/tome-sc'ssquadrons) that may not exist in every consumer'spayload.config.ts, which Payload'ssanitizeConfigrejects at boot — so a forced default would have been the actually-breaking choice. OmittingsquadronSlugreproduces 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.
- Stale layer version fixed:
- 0.3.7
distis now loadable by raw Node. tsup builds withbundle: 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.jstwin (.cjsis not in Node's CJS extension search list), and Node 22+require(esm)then died on _that_ file's own extensionless import.Any consumer outside a bundler — the payload CLI under plain node,
generate:types, ops scripts, codegen tools — hit this on every subpath that had relative imports; single-file subpaths loaded fine, which is why it went unnoticed. A post-build step (scripts/fix-dist-extensions.mjs --strict) now appends explicit extensions (.js//index.js,.cjs//index.cjs) and fails the build on any specifier it cannot resolve rather than guessing.No source changes, and bundler consumers are unaffected — extensioned relative specifiers are universally resolvable.
- 0.3.6
Import
authenticated/anyonefrom the@wabbit/tome-core/auth/guardsleaf instead of the/authbarrel.The barrel statically re-exports
createBetterAuth, which drags@delmaredigital/payload-better-authinto the module graph — so any consumer loading this package outside a bundler (payload CLI under tsx:generate:types, seeds, migrations) crashed with ERR_MODULE_NOT_FOUND unless it installed an auth stack it may deliberately not use. Leaf-import discipline is what this platform's own consumer docs mandate; the layers now follow it themselves.No behavioral change — same functions, same leaf they always resolved to.
- 0.3.5
Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed).
- dispatch: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (prior client-name and SC-universe strings, e.g. "LOG-2954-0847", "Stanton // Crusader Orbit", "UEES STALWART" → neutral equivalents).
- readout: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored.
- blocks-signal-theme: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client-lore/SC references; config examples de-lored. Pack positioning (SC-tier bundling) unchanged.
- blocks-extras / blocks-content-writer: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized.
- blocks-core: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized.
- blocks-gallery: SourceBadge label for the
vngdsource value now renders "Legacy" (enum value unchanged). - accounts / core / lms / ui / org / admin / motion / longform / cop / blocks: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named the client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.
- 0.3.4
Neutralize gaming/military-flavored language across the org surfaces — labels, descriptions, and demo content only; zero schema changes (all field names, collection slugs, and enum/select VALUES are byte-identical, so no consumer data migration).
- blocks-org-pack: CampaignBanner's
codenamefield is now labeled "Name" with a business example ("Spring Launch" demo replaces "Operation Nightfall … contested systems"); MemberCard/MemberGridrankfields 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/debrieflabeled "Check-In"/"Wrap-Up"; eventTypeoperation/patrol/examlabeled "Initiative"/"Outreach"/"Assessment";securityLevellabeled "Access"; Campaigncodenamelabeled "Internal Name" and campaignTyperecurring_op/special_operation/deploymentlabeled "Recurring Series"/"Special Initiative"/"Rollout"; Memberclassificationlabeled "Directory Visibility" withclassifiedlabeled "Private", "Chain of command" → "Reporting line"; RanksecurityClearancelabeled "Access Level" and categorycommandlabeled "Management"; Squad squadTypefire_team/flightlabeled "Crew"/"Pod",callsignlabeled "Nickname"; Membershipsquadronlabeled "Unit", role example "Pointman, Medic" → "Coordinator, Facilitator"; Position abbreviation example "CO, XO" → "COO, PM", categorycommandlabeled "Executive". The configurableDEFAULT_ORG_TERMINOLOGY(Division/Team/Squad/Rank) is deliberately unchanged — it is the documented override seam and@wabbit/tome-scinherits 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).
- blocks-org-pack: CampaignBanner's
- 0.3.3
registerLayeris now statically imported (forms/intake pattern) instead of lazilyrequire()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'sadmin-nav/self-register.tsdeliberately keeps its subpathrequire()(documented ESM/CJS dual-cache fix — do not convert). - 0.3.3
Every package now declares an explicit
sideEffectsfield (38 added; motion/engine/forms already correct).Registration-bearing modules (render files'
registerRenderer,blocks/*/index.tsdefineBlockself-registration, widgetregister.tsfiles, 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 blanketfalseon a package with registration or CSS. - 0.3.3
DRY adoption sweep (the audit's "adoption, not extraction" rule): crm/deals capability presets delegate to core's
sessionHasCapabilityOrLegacyAdmin; new corebuildOwnershipWhere/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; newformatDisplayDatein blocks-core utilities (UTC-pinned, hydration-safe) adopted at 5 verified-identical sites; lms-ui consolidates its two certificate date formatters locally;useMediaQuery/useIsMobilepublished from tome-ui and adopted by AppShell + admin's SidebarProvider; gamification'sawardPointsnow uses the authoritativegetPointsBalance(fixes a divergent 1000-row scan cap vs the correct 10000). - 0.3.2
Core runtime-floor sweep: each package's
@wabbit/tome-corepeer floor now matches the newest core runtime export it actually imports, instead of the platform-wide>=1.0.0baseline 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 buildfailure at import time (reproduced 2026-07-11: tome-starter locked core 1.0.12 + admin 0.6.3 —isAdminNavDomaindoes not exist in core 1.0.x, whereregistry/adminNavwas type-only).@wabbit/tome-admin→>=1.3.0 <2.0.0—nav/manifestResolverruntime-importsisAdminNavDomainfromregistry/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-importsauth/repScoping(buildRepWhereClauseet al.) andutilities/normalize(normalizeEmail), both introduced in core 1.1.0 (consolidation pass, a9801fe).@wabbit/tome-accounts→>=1.2.0 <2.0.0— runtime-importsauth/permissions(roleSatisfiesPermission, permission registration), introduced in core 1.2.0 (platform permission engine, 9238072).@wabbit/tome-org→>=1.2.0 <2.0.0— runtime-importsauth/permissions(checkPermissionHierarchicalet al.).@wabbit/tome-sc→>=1.2.0 <2.0.0— runtime-importsauth/permissionsacross access helpers and military collections.
Same defect class as the
tome-crmfloor raise to>=1.1.0(b027075);tome-crmis already correct and unchanged here. - 0.3.1
Docs-manifest emitter pipeline (W3 ship-readiness).
@wabbit/tome-blocks-corenow ships a standalone Node ESM CLI atscripts/emit-docs-manifests.mjsthat emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free<pkg>/metablock-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
./metapayload-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'sBlockMetawas 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.mdto its publishedfilesarray so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.
0.2.xMinor
Publish pipeline setup — first-publish prep. Adds tsup config, dist build script with NODE_OPTIONS heap bump, publishConfig (restricted, npm.wabbit.com), main/module/types fields, files allowlist (
dist,README.md,LICENSE.md), and exports map pointing atdist/.Mirrors the canonical
@wabbit/tome-*publish template (catalog/economy/admin shape).Peer-dep correction: moves
@wabbit/tome-corefromdependencies(which was incorrect for a peer) topeerDependencies(workspace:*). Also keeps it indevDependenciesso workspace install still resolves it at build time.Existing
peerDependenciesMetablock was already declaring@wabbit/tome-coreas a peer, so this fixes the orphan declaration. devDeps gainscross-env,rimraf, andtsupto match the canonical template.One source change to make the published `.d.ts` consumable. Annotated
createMemberCollectionreturn type asCollectionConfig(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 literalimport '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 ontome-organd consumer registry consumption requirestome-orgto be on Verdaccio. Same template that catalog and economy got on 2026-04-27.
5 fixes (0.2.4 – 0.2.6)
- 0.2.6
Consolidation pass (2026-06-10 audit dialect-drift findings) — the platform stops forking its own conventions.
tome-core (minor — new public APIs):
./auth/repScoping—buildRepWhereClause({ adminCapability, repField })+buildCapabilityScopedRead({ readCapability, adminCapability, repField })+sessionHasCapabilityOrLegacyAdmin+DENY_ALL_WHERE. The canonical "rows I own" access primitive, promoted from crm/deals' ~90%-identical copies (266 LOC → one parameterized implementation)../utilities/normalize—normalizeEmail(trim + lowercase). Email is the cross-layer join key; one normalizer, everywhere../fields/slug—formatSlugupgraded to the canonical algorithm (promoted from catalog's strictly-more-robust slugify: collapses whitespace/hyphen runs, trims edge hyphens); newbuildAutoSlugHook(sourceField, slugField)collection-level variant. Stored slugs untouched; only future generations on irregular-whitespace inputs differ.
catalog / org / crm / deals (patch): local copies replaced with delegations to the core primitives. Public names and signatures unchanged (
slugify,autoSlugHook,buildNormalizeEmailHook,normalizeDealEmail,repWhereClause,accountRepWhereClause,dealsRepWhereClause,dealsRepOrAdminWhereClause).Notably, org's auto-slug header had _claimed_ to wrap core's slugifier while carrying a divergent local copy — now it actually does.
- 0.2.6
Platform-wide peer-range sweep: every
workspace:*/workspace:^entry inpeerDependenciesreplaced 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-crmranges 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.0exactly. devDependencies keepworkspace:*for the local link. (@wabbit/tome-admin-progot the same source fix but is rc-versioned; it carries the change on its next intentional release.)tome-crm additionally gains a once-per-process production warning when the capability-registry fallback grants access — the bootstrap heuristic (any authenticated user passes
crm:read) now announces itself instead of running silently on sites that forgot to seed capability grants (2026-06-10 audit hardening item).Graph-truth additions (same hygiene wave): tome-deals declares its lazy print integration as an optional peer (
@wabbit/tome-print >=0.1.0 <1.0.0); tome-intake declares its lazy catalog routing strategy (@wabbit/tome-catalog >=1.1.0 <2.0.0, optional). These were undeclared dynamic imports — invisible to consumers and to pnpm's build topology. - 0.2.5
Updated dependencies [8947ff1]
- @wabbit/tome-core@1.0.12
- 0.2.4
Updated dependencies [36dc023]
- 0.2.4
Updated dependencies [2612799]
- @wabbit/tome-core@1.0.11
0.1.x
1 fix (0.1.1)
- 0.1.1
Updated dependencies
- @wabbit/tome-core@0.2.0