Lms

Learning engineStable
@wabbit/tome-lmsv0.23.6

Tome LMS v2 layer — courses, lessons, topics, academies, enrollment/progress tracking, quizzes, assignments, certifications, gamification tie-ins, instructor scheduling, and student-facing data helpers.

Install
  1. Get a registry token from your credentials page. You need a purchase that includes this package, or a Craft Library membership.

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

    @wabbit:registry=https://npm.wabbit.com/
    //npm.wabbit.com/:_authToken=YOUR_TOKEN
  3. Then install:

    npm install @wabbit/tome-lms

Overview

@wabbit/tome-lms

The Tome LMS layer — courses, lessons, topics, academies, enrollment/progress tracking, quizzes, assignments, certifications, gamification tie-ins, instructor scheduling, and the public student-facing data helpers. This is the v2 LMS, published as its own package.

Layer: domain (per ARCHITECTURE.md) — depends on @wabbit/tome-core and @wabbit/tome-gamification; optionally composes with @wabbit/tome-catalog and @wabbit/tome-org. @wabbit/tome-lms-ui (app-adjacent) is the render shell built on top of this package.

v1 vs v2 — the honest state

@wabbit/tome-core/lms is the v1 location and still ships inside tome-core. This package (@wabbit/tome-lms) is the v2 carve-out; per its own barrel comment, "during the cutover window both coexist." The migration direction is v1 → v2 + @wabbit/tome-catalog: packages/core/src/lms/collections/Product.ts's createProductCollection carries a @deprecated notice (since 2026-04-26) pointing at @wabbit/tome-catalog Products with type: 'course' plus createCourseProduct() from @wabbit/tome-lms/server.

Install

pnpm add @wabbit/tome-lms

Peer ranges, copied from package.json:

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

`@wabbit/tome-core` is a required peer, not a regular dependency — peerDependenciesMeta["@wabbit/tome-core"] sets optional: false, which matches (not dangling). `@wabbit/tome-gamification` is the one that ships as a regular `dependencies` entry (workspace:^, rewritten to a pinned version at publish), not a peer — it installs automatically with the package, so nothing to add for it separately.

60-second quickstart

The current API is createLmsLayer(config?) (registerLmsLayer is a deprecated pure alias):

import { buildConfig } from 'payload'
import { createLmsLayer } from '@wabbit/tome-lms'

const { collections } = createLmsLayer({
  composition: { org: { present: true, memberSlug: 'members' } },
})

export default buildConfig({
  collections: [...collections /* ...your other collections */],
})

composition.org.present swaps vendor/scoped-org text-stub fields to real relationships; composition.catalog.present registers course as a catalog product type; composition.economy.present signals enrollment expiry should follow subscription status. config.lessonReadAccess: 'enrollment-gated' swaps Lessons' read access from the default 'public'.

Current API notes

Every layer entry in this family uses createXLayer(config?): createCrmLayer/createDealsLayer/createMarketingLayer and this package's createLmsLayer, matching the pre-existing createOrgLayer/createAccountsLayer shape. The older names (initCrm, initDeals, initMarketing, registerLmsLayer) all survive as @deprecated pure aliases, removed at each package's next major.

API surface

25 export subpaths total: ., ./server, ./scripts/migrate-lms-products-to-catalog, ./version, 9× ./utilities/*, and 12× ./collections/*:

| Subpath | Purpose | |---|---| | ./version | Layer version constant (createLmsLayer reports it to the layer registry — must equal package.json version) | | ./utilities/awardStatus | Canonical approvalStatus groupings for CertificationAwards ("does this award count") | | ./utilities/enrollmentProgress | Pure derivation of a course enrollment's progress/status from completedModules | | ./utilities/prerequisites | hasCompletedPrerequisites / isPhaseUnlocked — prerequisite evaluation against LessonCompletion rows | | ./utilities/instructorRoles | Pure role-derived instructor authority comparison | | ./utilities/ownerAuthority | Pure check of whether the owner stamp still confers authority | | ./utilities/certRenewalKeys | Notification key conventions for the certification renewal lifecycle | | ./utilities/attemptPolicy | Attempt/cooldown/lockout policy math for a graded module | | ./utilities/gradingCalibration | Pure scoring/classification math — quiz "select all that apply" + instructor calibration audits | | ./utilities/heldCertifications | "Does this member already hold this certification?" predicate + composite key | | ./collections/certificationAward | Standalone CertificationAwardCollection export | | ./collections/lesson | Standalone LessonCollection export | | ./collections/courseEnrollment | Standalone CourseEnrollmentCollection export | | ./collections/skillPath | Standalone SkillPathCollection export | | ./collections/academy | Standalone AcademyCollection export | | ./collections/course | Standalone CourseCollection export | | ./collections/topic | Standalone TopicCollection export | | ./collections/courseItem | Standalone CourseItemCollection export | | ./collections/quizAttempt | Standalone QuizAttemptCollection export | | ./collections/assignmentUpload | Standalone AssignmentUploadCollection export | | ./collections/examTicket | Standalone ExamTicketCollection export | | ./collections/examBypassRequest | Standalone ExamBypassRequestCollection export |

`.` highlights (100+ exports total — grouped by the barrel's own section comments):

| Group | Exports | |---|---| | Layer entry | createLmsLayer, LmsLayerConfig (registerLmsLayer is a @deprecated pure alias) | | Composition | detectCatalog, detectEconomy, detectOrg, resolveOrgSlug, LmsComposition; resolveMemberSlug (re-exported from @wabbit/tome-core/identity) | | Capabilities | TOME_LMS_CAPABILITIES, TOME_LMS_DEFAULT_GRANTS, seedTomeLmsCapabilities | | Access | LMS_ROLE_TIERS, LmsRoleTier, and ~35 access predicates/builders (courseRead, enrollmentRead, buildLessonRead, isAdmin, hasPrivilegedRole, etc.) | | Guards | canAccessCourseItem, canAccessLesson, isEnrolled, isInstructor, isCourseOwner, hasActiveAccess, GuardContext | | Collections (25) | AcademyCollection, CourseCollection, TopicCollection, LessonCollection, CourseItemCollection, CourseEnrollmentCollection, LessonCompletionCollection, StudentProfileCollection, QuizAttemptCollection, AssignmentUploadCollection, CertificationCollection, CertificationAwardCollection, ExpertCredentialCollection, SkillPathCollection, TrainingEventCollection, TrainingEventAttendanceCollection, InstructorAvailabilityCollection, BadgeCollection, AchievementCollection, PointsCollection, GradebookEntryCollection, CourseReviewCollection, StudentNoteCollection, ExamTicketCollection, ExamBypassRequestCollection — all re-exported individually so a site can swap one collection and pass its own replacement (ExamTicketCollection/ExamBypassRequestCollection are opt-in: not part of createLmsLayer's baseCollections, per the barrel's own comment) | | Hooks | Content-sync (syncDisplayContent, syncParentRelationship, cleanupParentReferences), progress (computeProgress, onLessonCompletion), cert-workflow (enforceApprovalStatus, updateCertificationHolderCount, updateMemberCerts, autoNominateExpert), scheduling (recountTrainingEventAttendees, assignInstructorFromUser), uniquePair | | Attempt-workflow hooks | createEnforceAttemptPolicy(options?) — QuizAttempt beforeChange that rebuilds attempt count, lockout and cooldown from prior attempts and blocks a create with AttemptPolicyBlockedError (instanceof-checkable, carries .reason); opt in with createQuizAttemptCollection's enforceAttemptPolicy option (default off; defaults 3 attempts, 48 h cooldown, override via progressionRules). createAssignAssignmentAttemptNumber(options?) — AssignmentUpload beforeChange that sets attemptNumber and links previousUpload per student, lesson and assignment block; wired by default. Types AttemptPolicyOptions, AssignmentAttemptNumberOptions | | Course workflow hooks | promoteOwnerToMaintainerOnApproval — on the transition into workflowStatus: 'approved', moves owner into maintainers (backfilling createdBy first) so the creator keeps a revocable grant; on by default, builtInHooks.promoteOwnerToMaintainerOnApproval: false turns it off. createRequirePublishedLessonsOnPublishHook(options?) — refuses to publish a course with no published lessons (checked through CourseItem rows). createDropEnrollmentsOnDeleteHook(options?) — on course delete, sets its open enrollments to dropped instead of leaving them dangling. The last two are off by default; enable them with createCourseCollection({ invariantHooks: { requirePublishedLessonsOnPublish: true, dropEnrollmentsOnDelete: true } }). Option types RequirePublishedLessonsOnPublishOptions, DropEnrollmentsOnDeleteOptions | | Utilities | Gamification (checkAndAwardBadges, awardPoints), grading (scoreQuizAnswers, gradeQuizAttempt, syncGradebookEntry), progress (computeEnrollmentProgress, flattenCurriculumTree, getCompletedLessonIds), prerequisites (hasCompletedPrerequisites, isPhaseUnlocked) | | Types | 25+ type exports — see src/types.ts for the full list |

`./server` — behind import 'server-only': catalog/landing (getCourseCatalog, getCourseBySlug), course (getCourseWithEnrollment, getCourseLandingData), academy (getAcademyCatalog, getAcademyWithCourses), enrollment/dashboard (getEnrollmentDetail, getStudentDashboard), grades (getStudentGrades, getCourseGradeDetail), certificates (getStudentCertificates, getCertificateByVerificationId), notes (getStudentNotes), leaderboard (getStudentLeaderboard), profile (getStudentProfile, updateStudentProfile), learner shell (getActiveEnrollments, getLessonChain), reviews (getApprovedReviewsForCourse, submitReview), all mutations (export *), and the catalog-integration helpers createCourseProduct/getCourseProduct (require composition.catalog.present === true).

`./scripts/migrate-lms-products-to-catalog` — standalone migration script for the v1→v2/catalog cutover.

Server / client posture

Fully server-side: Payload collections, access predicates, and ./server data helpers guarded by import 'server-only'. No React components or hooks in this package — student-facing UI lives in @wabbit/tome-lms-ui (app-adjacent layer), which this package does not depend on.

Links

Extending this package

Every collection is re-exported individually specifically so a site can override one (add a field, swap access) and pass its replacement instead of the default — the same goes for the content-sync/progress/cert-workflow/scheduling hooks, which are wired onto the default collections but re-exported for reuse on a swapped-in replacement.

Decisions that shaped this package

  • v1→v2 carve-out: `@wabbit/tome-core/lms` is deprecated in place, all new LMS work lands here — the entire core/lms surface is @deprecated (not just the one factory that used to carry the tag), coexistence is a documented cutover window not a permanent split, and removal at core 2.0 is gated on a CI check for zero remaining @wabbit/tome-core/lms/* importers.
  • CourseItem junction replaces the polymorphic `items` arrays on Course/Module/Topic — polymorphic relationships were never a real Payload primitive and the earlier prototype that would have proven them out never shipped, so curriculum ordering moved to a dedicated junction collection instead.
  • LMS owns Course, catalog owns Product — a course is sellable only through a `catalog-products` wrapper, never a course-local mini-catalog — closes the old lms-products duplicate-product-table (deprecated collection) so search/marketplace/reporting can enumerate "all products" through one registry instead of a sharded one; createCourseProduct/getCourseProduct (confirmed live in src/server/courseProduct.ts) are the wiring.
  • `lessonReadAccess: 'public' | 'enrollment-gated'` closes the anonymous-read gap without changing default behavior — default stays 'public' (zero behavior change for existing sites); gated mode filters anonymous/REST/GraphQL lesson reads through active enrollments via the CourseItem junction and fails closed on error.
  • `createLmsLayer` replaces `registerLmsLayer` as the canonical entry, old name kept as a deprecated pure alias — part of the platform-wide convergence onto createXLayer(config?) naming shared with org/accounts/crm/deals/marketing.

Exports

  • @wabbit/tome-lms
  • @wabbit/tome-lms/utilities/awardStatus
  • @wabbit/tome-lms/utilities/enrollmentProgress
  • @wabbit/tome-lms/utilities/prerequisites
  • @wabbit/tome-lms/utilities/instructorRoles
  • @wabbit/tome-lms/utilities/ownerAuthority
  • @wabbit/tome-lms/utilities/certRenewalKeys
  • @wabbit/tome-lms/version
  • @wabbit/tome-lms/server
  • @wabbit/tome-lms/scripts/migrate-lms-products-to-catalog
  • @wabbit/tome-lms/utilities/attemptPolicy
  • @wabbit/tome-lms/utilities/gradingCalibration
  • @wabbit/tome-lms/utilities/heldCertifications
  • @wabbit/tome-lms/collections/certificationAward
  • @wabbit/tome-lms/collections/lesson
  • @wabbit/tome-lms/collections/courseEnrollment
  • @wabbit/tome-lms/collections/skillPath
  • @wabbit/tome-lms/collections/academy
  • @wabbit/tome-lms/collections/course
  • @wabbit/tome-lms/collections/topic
  • @wabbit/tome-lms/collections/courseItem
  • @wabbit/tome-lms/collections/quizAttempt
  • @wabbit/tome-lms/collections/assignmentUpload
  • @wabbit/tome-lms/collections/examTicket
  • @wabbit/tome-lms/collections/examBypassRequest

Changelog

v0.23.6patch

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

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

98b2d16: Existence and count checks now use `payload.count()` instead of reading every matching row; behaviour is unchanged. Covers the enrollment, completion, credential and prerequisite guards, attempt numbering and dashboard totals. Challenge-mode prerequisite checks run with bounded concurrency instead of one at a time, and the course catalog no longer issues a duplicate enrollment read. Internal: collection slugs are typed through the shared `typedSlug()` helper instead of inline casts.

  • 98b2d16: Existence and count checks now use `payload.count()` instead of reading every matching row; behaviour is unchanged. Covers the enrollment, completion, credential and prerequisite guards, attempt numbering and dashboard totals. Challenge-mode prerequisite checks run with bounded concurrency instead of one at a time, and the course catalog no longer issues a duplicate enrollment read. Internal: collection slugs are typed through the shared `typedSlug()` helper instead of inline casts.
  • Updated dependencies [c8827e7]
  • Updated dependencies [bcdf9e5] - @wabbit/tome-gamification@0.3.3
v0.23.4patch

67eb3dc: Local relationship-id helpers are replaced by `@wabbit/tome-core/utilities/relationId`. Each call site maps to the core reader with the same return shape (raw vs. stringified id, `null` vs. `undefined`, polymorphic), so behaviour is unchanged. The `@wabbit/tome-core` peer floor goes up to `>=1.17.0` because that is the first core version exporting `relationIdRaw`, `relationIds` and `relationIdsRaw`. `relationshipId` and `server`'s `resolveRelationId` (public) are kept as deprecated delegates to `relationIdRaw(v, { polymorphic: true })`, with identical semantics. The internal `server/mutations` `extractId` is removed.

  • 67eb3dc: Local relationship-id helpers are replaced by `@wabbit/tome-core/utilities/relationId`. Each call site maps to the core reader with the same return shape (raw vs. stringified id, `null` vs. `undefined`, polymorphic), so behaviour is unchanged. The `@wabbit/tome-core` peer floor goes up to `>=1.17.0` because that is the first core version exporting `relationIdRaw`, `relationIds` and `relationIdsRaw`. `relationshipId` and `server`'s `resolveRelationId` (public) are kept as deprecated delegates to `relationIdRaw(v, { polymorphic: true })`, with identical semantics. The internal `server/mutations` `extractId` is removed.
  • 30bdd74: The duck-typed role readers in `access/roles.ts` (`readRoles`, the tier match, and `resolveRoleSlugs`) now delegate to `@wabbit/tome-core/auth/rbac`. Behaviour is unchanged: lms still memoizes under its own `req.context` key, and `readRoles` / `resolveRoleSlugs(req, userCollection?)` keep their signatures. `@wabbit/tome-gamification` keeps its own copy because core is an optional peer there. - @wabbit/tome-gamification@0.3.2
v0.23.3patch

feaf672: `gradeQuizAttempt` now reads quiz fields from the row's `blockData`, the shape `Lesson.blocks` actually persists (`{ blockType, blockData: json }` — Payload strips any other top-level key on write). Previously it read `questions`/`passingScore` flat off the row, so every stored quiz graded as 0/0 through the one-step path. It also accepts the two-step mutation's `quizBlock` block type alongside `quiz`. Flat in-memory rows still work.

  • feaf672: `gradeQuizAttempt` now reads quiz fields from the row's `blockData`, the shape `Lesson.blocks` actually persists (`{ blockType, blockData: json }` — Payload strips any other top-level key on write). Previously it read `questions`/`passingScore` flat off the row, so every stored quiz graded as 0/0 through the one-step path. It also accepts the two-step mutation's `quizBlock` block type alongside `quiz`. Flat in-memory rows still work.
v0.23.2patch

ce3d12d: Adopt `@wabbit/tome-core/fields/address` and `@wabbit/tome-core/utilities/relationId` at the sites the audit counted (2026-09-01 sale-readiness audit §5.1, T3(g)). **No stored field name, and no emitted field array, changes anywhere in this changeset** — each adopter passes the vocabulary it already stores, and each ships a characterisation test that was written from the pre-change source, run green against the untouched factory, and run green again after. **Address group — five sites, one implementation.** - `@wabbit/tome-crm` — `accounts` and `contacts` each carried a byte-identical seven-field `address` group. Both now spread `postalAddressFields({ vocabulary: 'legacy-crm' })` after their own `name` line (`name` is the company/contact line, not a postal line). `tests/address-characterisation.test.ts` pins both groups whole. - `@wabbit/tome-deals` — `billingAddress` and `shippingAddress` inside the frozen Customer Snapshot were copies three and four. They now come from one `buildSnapshotAddressGroup` helper: `name` + `company` prepended locally, the six postal lines from core, and the eight per-field labels plus the `'US'` country default passed through core's `fieldOverrides` seam. The snapshot is a legal-offer record frozen after send, so a field-name change would orphan the address on every deal already sent; `tests/address-characterisation.test.ts` pins both groups and the fact that they differ only in the group label and the recipient line's label. - `@wabbit/tome-fulfillment` — the fifth copy, and the only one that validated `country`. Its postal lines stay FLAT at collection top level (they are stored columns with PII rows and a GDPR registration behind them), now via `postalAddressFields({ vocabulary: 'postal', required: true, validateCountry: true })`. The ISO-3166 validator and its uppercase-normalising hook moved into core verbatim; because a moved function is a new object, `tests/address-characterisation.test.ts` pins the whole top-level field ORDER plus the validator's and hook's BEHAVIOUR (accepts `US`, rejects `usa`, rewrites `' us '` to `'US'`), not their identity. **`relationId` — the four-return-types problem.** - `@wabbit/tome-lms` — twelve modules under `src/server` (`academy`, `catalog`, `certificates`, `course`, `dashboard`, `enrollment`, `grades`, `leaderboard`, `learnerShell`, `notes`, `profile`, `reviews`) carried a byte-identical `string | null` copy. They import `relationId` from core now. One behavioural difference, strictly an improvement: on a malformed populated doc (`{ id: null }`, `{ id: {} }`) the old copy returned the STRING `'null'` / `'[object Object]'` as an id; core returns `null`. `tests/relation-id-adoption.test.ts` pins the adoption itself, because adoption is the thing that decays — the July 2026 audit's finding, repeated verbatim in September, was "extraction keeps happening, adoption does not." **Not migrated, deliberately:** `src/guards`, `src/utilities/{grading,prerequisites,progress}.ts`, `src/hooks/**`, `src/server/mutations/helpers.ts` and `src/server/awardGate.ts` return `string | number` or `undefined`. Migrating those is a semantic change, not an import change, and belongs in a pass that owns their call sites. The new test names them as out of scope so the next reader does not have to re-derive why. - `@wabbit/tome-sc` — the registry sub-cluster's copy is gone; `collections/registry/shared.ts` re-exports core's `relationId`, keeping `extractId` as a local alias (the module is private to that sub-cluster). **This one WIDENS:** the sc copy returned `string | number`, so a populated doc's numeric id came through unstringified. It is now stringified, which makes `===` between two resolved ids agree — the behaviour every call site in the cluster already assumed. Ids handed back to `payload.find`/`update` are unaffected, since Payload accepts either form in a `where` clause. sc's 179 tests stay green. - `@wabbit/tome-crm` — the inline ternary in `integration/deals.ts` (`typeof oppRaw === 'object' ? oppRaw.id : oppRaw`) was the fifth shape and had the same numeric-id asymmetry; it is one `relationId(deal.opportunity)` call now. **`fetchMemberId` ×4 — one implementation (sc).** `asset-availability`, `fleet-logs` and `fleet` each carried a verbatim copy of the auth-user → Member-row lookup, and `resource-requests` carried its projecting twin. All four now import from `src/access/fetchMemberId.ts`, which documents why each query knob is load-bearing: `overrideAccess: true` (the member collection's own read access may itself depend on membership, so without the bypass this is a circular check that denies the owner their own row), `depth: 0`, `pagination: false`. The id is returned in its STORED type here rather than through `relationId` — this is an identity read fed straight back into a `where` clause, not a relationship read. `tests/fleet-shared-helpers.test.ts` pins the adoption, the three knobs, and the null-for-anonymous contract.

  • ce3d12d: Adopt `@wabbit/tome-core/fields/address` and `@wabbit/tome-core/utilities/relationId` at the sites the audit counted (2026-09-01 sale-readiness audit §5.1, T3(g)). **No stored field name, and no emitted field array, changes anywhere in this changeset** — each adopter passes the vocabulary it already stores, and each ships a characterisation test that was written from the pre-change source, run green against the untouched factory, and run green again after. **Address group — five sites, one implementation.** - `@wabbit/tome-crm` — `accounts` and `contacts` each carried a byte-identical seven-field `address` group. Both now spread `postalAddressFields({ vocabulary: 'legacy-crm' })` after their own `name` line (`name` is the company/contact line, not a postal line). `tests/address-characterisation.test.ts` pins both groups whole. - `@wabbit/tome-deals` — `billingAddress` and `shippingAddress` inside the frozen Customer Snapshot were copies three and four. They now come from one `buildSnapshotAddressGroup` helper: `name` + `company` prepended locally, the six postal lines from core, and the eight per-field labels plus the `'US'` country default passed through core's `fieldOverrides` seam. The snapshot is a legal-offer record frozen after send, so a field-name change would orphan the address on every deal already sent; `tests/address-characterisation.test.ts` pins both groups and the fact that they differ only in the group label and the recipient line's label. - `@wabbit/tome-fulfillment` — the fifth copy, and the only one that validated `country`. Its postal lines stay FLAT at collection top level (they are stored columns with PII rows and a GDPR registration behind them), now via `postalAddressFields({ vocabulary: 'postal', required: true, validateCountry: true })`. The ISO-3166 validator and its uppercase-normalising hook moved into core verbatim; because a moved function is a new object, `tests/address-characterisation.test.ts` pins the whole top-level field ORDER plus the validator's and hook's BEHAVIOUR (accepts `US`, rejects `usa`, rewrites `' us '` to `'US'`), not their identity. **`relationId` — the four-return-types problem.** - `@wabbit/tome-lms` — twelve modules under `src/server` (`academy`, `catalog`, `certificates`, `course`, `dashboard`, `enrollment`, `grades`, `leaderboard`, `learnerShell`, `notes`, `profile`, `reviews`) carried a byte-identical `string | null` copy. They import `relationId` from core now. One behavioural difference, strictly an improvement: on a malformed populated doc (`{ id: null }`, `{ id: {} }`) the old copy returned the STRING `'null'` / `'[object Object]'` as an id; core returns `null`. `tests/relation-id-adoption.test.ts` pins the adoption itself, because adoption is the thing that decays — the July 2026 audit's finding, repeated verbatim in September, was "extraction keeps happening, adoption does not." **Not migrated, deliberately:** `src/guards`, `src/utilities/{grading,prerequisites,progress}.ts`, `src/hooks/**`, `src/server/mutations/helpers.ts` and `src/server/awardGate.ts` return `string | number` or `undefined`. Migrating those is a semantic change, not an import change, and belongs in a pass that owns their call sites. The new test names them as out of scope so the next reader does not have to re-derive why. - `@wabbit/tome-sc` — the registry sub-cluster's copy is gone; `collections/registry/shared.ts` re-exports core's `relationId`, keeping `extractId` as a local alias (the module is private to that sub-cluster). **This one WIDENS:** the sc copy returned `string | number`, so a populated doc's numeric id came through unstringified. It is now stringified, which makes `===` between two resolved ids agree — the behaviour every call site in the cluster already assumed. Ids handed back to `payload.find`/`update` are unaffected, since Payload accepts either form in a `where` clause. sc's 179 tests stay green. - `@wabbit/tome-crm` — the inline ternary in `integration/deals.ts` (`typeof oppRaw === 'object' ? oppRaw.id : oppRaw`) was the fifth shape and had the same numeric-id asymmetry; it is one `relationId(deal.opportunity)` call now. **`fetchMemberId` ×4 — one implementation (sc).** `asset-availability`, `fleet-logs` and `fleet` each carried a verbatim copy of the auth-user → Member-row lookup, and `resource-requests` carried its projecting twin. All four now import from `src/access/fetchMemberId.ts`, which documents why each query knob is load-bearing: `overrideAccess: true` (the member collection's own read access may itself depend on membership, so without the bypass this is a circular check that denies the owner their own row), `depth: 0`, `pagination: false`. The id is returned in its STORED type here rather than through `relationId` — this is an identity read fed straight back into a `where` clause, not a relationship read. `tests/fleet-shared-helpers.test.ts` pins the adoption, the three knobs, and the null-for-anonymous contract.
  • 8fc9702: Import `mergeHooks`, `fieldShape` and the select-option override contract from `@wabbit/tome-core` instead of keeping local copies (2026-09-01 sale-readiness audit §5.1, T3(a)). No API change: every symbol these packages exported before is still exported, now re-exported from core, and every factory produces byte-identical output. Deleted, with every call site repointed: - `org/src/hooks/mergeHooks.ts`, `lms/src/collections/shared/mergeHooks.ts`, `sc/src/extensions/mergeHooks.ts` → `@wabbit/tome-core/hooks/mergeHooks`. Core has exported this since July; sc's copy still carried a header claiming "Neither @wabbit/tome-core nor @wabbit/tome-org exports this." - `lms/src/server/jobs/paginate.ts`, `crowdfund/src/server/jobs/paginate.ts`, `workflow/src/server/paginate.ts` → `@wabbit/tome-core/jobs`. - `org/src/fieldShape.ts` + `org/src/insertFieldsAfter.ts`, `lms/src/collections/shared/fieldShape.ts` → `@wabbit/tome-core/fields/fieldShape`. Org's `resolveFieldDescription` / `FieldDescriptionOverride` were NOT part of the duplicated set and stay in the package, moved to `org/src/fieldDescriptions.ts`. - `org/src/optionOverrides.ts`, `lms/src/collections/shared/optionOverrides.ts` → `@wabbit/tome-core/fields/selectOptions`; `sc/src/collections/registry/shared.ts` now re-exports it (its `extractId` is a separate audit item and is untouched). **Peer floor raised to `@wabbit/tome-core` `>=1.14.0 <2.0.0`** in all five packages, because each now imports a subpath or a named export that first exists in that core minor: `./fields/fieldShape` and `./fields/selectOptions` are new subpaths, and `findPaged`/`chunk`/`readPositiveNumber` are new named exports on the pre-existing `./jobs`. Crowdfund's floor moves from `>=1.7.0` even though `./jobs` itself shipped in 1.7.0 — the subpath resolving is not the same thing as the export existing, which is the sharper version of the lesson its own 0.1.1 CHANGELOG records (`ERR_PACKAGE_PATH_NOT_EXPORTED`). Org moves from `>=1.2.0`, lms from `>=1.0.0`, sc from `>=1.11.0`, workflow from `>=1.7.0`. Header comments that pointed at the deleted files, or asserted core did not export these, were corrected rather than left dangling.
  • 4aeedad: One `LayerFactoryConfig` every layer factory's config extends, and one factory verb. Fourteen layer packages end in the same one call a consumer writes into `payload.config.ts`, and no two agreed on what `config` may contain: full seam vocabulary in three (org, lms, ledger), partial in six, NONE in six (2026-09-01 sale-readiness audit §5.3). A site that learned `adminGroup` from org and `hooks` from sc discovered, package by package, that six factories accept neither — not because the seam had been rejected, but because nothing said it existed. **New in core (a NEW exports-map subpath, hence the minor):** `@wabbit/tome-core/utilities/layerFactoryConfig` exports the `LayerFactoryConfig` interface — `adminGroup`, `access` (per-collection override map), `hooks` (appended via `mergeHooks`, never replacing), `extraFields`, `fieldOverrides`, `omitFields`, `fieldOrder`, `slugs` — and `applyLayerFactoryConfig(collections, config)`, which honours the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `create*Layer` config now extends it.** Twelve extend `LayerFactoryConfig` directly and APPLY it through `applyLayerFactoryConfig` (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, `adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted. Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). **economy** and **gamification** both declare `@wabbit/tome-core` as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches `registerLayer` through a lazy `require()` in a try/catch for exactly this reason. `applyLayerFactoryConfig` is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by `assert:no-forked-primitives`. The trigger is stated: the day core becomes a required peer, delete the note and add one line. Both take the type via `import type`, which is erased at runtime. Two packages drop seams EXPLICITLY rather than accept-and-ignore. **chrome** extends `Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>` because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against `CollectionConfig`, and chrome's slugs already have direct per-surface knobs (`header.slug`, `footer.slug`) a parallel map could contradict. The one seam it keeps, `adminGroup`, IS applied: globals carry `admin.group` exactly as collections do. **rpg** extends `Omit<LayerFactoryConfig, 'access'>` because `CharacterSheetsConfig` is a single collection's config that doubles as the layer factory's config, and its own `access` already means "this collection's access object" — one level shallower than the base's slug-keyed map. Two meanings under one name is the confusion this interface exists to end. **Factory-verb convergence.** Three verbs were live. `createWorkflowLayer(config?)` is new in `@wabbit/tome-workflow` (a new export — hence the minor) and returns a spreadable, deliberately EMPTY `CollectionConfig[]`: this layer is an engine, not a collection set, so the empty array is the honest answer and lets `...createWorkflowLayer()` compose exactly like every sibling. Its `WorkflowLayerConfig` omits every seam for the same reason, and exists as the stable place a real option will land. `createGamificationLayer` and `createRpgLayer` are pure aliases of `registerGamificationLayer` / `registerRpgLayer`. `initWorkflow`, `registerGamificationLayer` and `registerRpgLayer` are all `@deprecated` with sunset at each package's next major; none is removed. **Forcing function:** `scripts/assert-layer-factory-contract.mjs` + `pnpm assert:layer-factory-contract`, wired into `platform-discipline.yml` after `assert:layer-version` (source reading only, pre-build). Every exported `create*Layer` must take a config parameter whose type resolves to `LayerFactoryConfig` — through `extends`, an intersection, or an explicit `Omit<…>` — with verb aliases followed to their `register*`/`init*` target. Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. `docs/guides/create-a-new-layer-package.md` gains a "The factory contract" section stating the rule and the three permitted responses. Three factories are ALLOWLISTED with a reason each: `createAiLayer` returns credential wiring and owns no collections, so every seam is meaningless to it; `createFormsLayer` and `createIntakeLayer` are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files. **Peer floors:** accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise `@wabbit/tome-core` to `>=1.14.0 <2.0.0`. The new subpaths do not exist below that, and a too-low floor is how `ERR_PACKAGE_PATH_NOT_EXPORTED` reached crowdfund's consumers once already. These are marked `patch` because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.
  • 670d2a1: **`approveOJT` gains the in-body capability check its siblings already had.** 2026-09-01 sale-readiness audit §4.2: `approveOJT` was the only instructor-gated mutation with no in-body capability assertion — `cooldown.ts` and `training-event.ts` both have one. `runMutation`'s capability step only fires when the CALLER declared `requiredCapabilities`; declaring none means "check nothing". So a mutation context assembled without either a declared capability or a `capabilityResolver` — the easiest one to assemble — could approve any other member's OJT sign-off and mint the LessonCompletion that follows. The self-approval guard did not cover this: it only blocks approving your OWN submission, which is the one thing an attacker does not need to do. The body now fails closed with `MutationInternalError('not-authorized', …)` unless the caller declared `instructor` or `admin`, or wired a resolver — the same shape and the same fail-closed rationale as the two siblings. The rejection happens before any `payload` read or write. Tests: 10 new assertions — rejection with no capabilities and no resolver, with an unrelated capability, with a resolver that reports the capability missing, and with an empty `requiredCapabilities` array (each verified to touch nothing); acceptance for `instructor` and for `admin`; the rejected-status path creating no LessonCompletion; and self-approval still blocked for an authorized instructor, including across string/number member-id types. Also recorded rather than silently changed: a caller that declares no capabilities but DOES wire a resolver still passes, because the resolver is never consulted when nothing is declared. That is the siblings' behaviour too, it is the obvious next thing to tighten, and tightening it would change behaviour for existing callers — so it has a test pinning it instead of an unreviewed edit.
  • 73081e6: Manifest metadata: `homepage`, `bugs`, `engines`. All 46 publishable manifests were missing the three fields a consumer sees before any code (2026-09-01 sale-readiness audit §6). Metadata only — no source, no build, no runtime change. - `homepage` deep-links to that package README on GitHub (`.../tree/main/packages/<dir>#readme`). Without it a registry page links to the monorepo root and the reader has to guess which of 46 folders they want. - `bugs.url` points at the repo issue tracker, so a paying customer has a place to report a defect that is not email. - `engines.node` is `>=22`, matching the root `engines` and `.nvmrc` set the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use for `node --test`, and a package installed on Node 20 fails at a runtime the installer cannot connect back to the version. The forcing function ships with the change: `scripts/assert-manifest-metadata.mjs` (root `pnpm assert:manifest-metadata`, wired into `platform-discipline.yml` beside `assert:license-metadata`) fails when any publishable manifest lacks `description`, `repository.directory` matching its own folder, `homepage`, `bugs`, `engines.node` equal to the repo floor, `license`, `files` or `sideEffects`. It reported 138 violations before this change and 0 after.
  • 04309f5: Collapse the audited serial-await fan-outs in Payload hooks, jobs and access checks. No behaviour changes — every try/catch, failure reporter and `overrideAccess` justification is preserved; only the number of round-trips changes. **`find({ limit: 0 })` → `payload.count()`** — `limit: 0` sets `pagination: false` in the Mongo adapter, so the query loads every matching row into memory to produce one number. `@wabbit/tome-org` documents this as a production incident in `hooks/attendance-count-sync.ts:6-11` and had reintroduced it in `collections/recruiting/JoinRequest.ts`'s one-pending-request guard; `@wabbit/tome-sc`'s squadron member recount had the same shape. **Independent lookups → `Promise.all` / `Promise.allSettled`** — org's `createGiverWindowAccess` (a per-request access check that took two serial round-trips), `EventAttendance`'s display-name composer and `AwardPresentation`'s, sc's RSI profile+org page fetches (the hot path for handle validation, against a third-party host), squadron recalcs, and accounts' three offboarding teardown callbacks. `allSettled` wherever a branch had its own fallback, so a failed member lookup still cannot stop the event title from resolving. sc's RSI adapter keeps its 404 short-circuit exactly, and ledger's per-leg balance guard decides in leg order so the thrown `NegativeBalanceError` still names the same wallet the serial version did. **Independent per-row writes → `batchWrite`** (`@wabbit/tome-core/utilities/batch`) — org's notification open/resolve fan-outs, the division/team cleanup and sunset cascades (up to 1,000 rows each), the event cascade-delete, the non-atomic `memberCount` fallback; lms's certification-expiry sweep (now paced in `WRITE_CHUNK` chunks like its sibling reconciler) and the course-delete enrollment drop; workflow's deadline sweep; crowdfund's tier-claim reconcile. **Same `data` for every row → one bulk `payload.update({ where, data })`** — sc's asset-assignment auto-close and the transfer-request GDPR redaction, matching `sc/src/gdpr.ts`'s `makeNullRefHandler`. The auto-close also drops a latent correctness hazard: its page cursor advanced while its own writes removed rows from the filter it was paging over, so a page boundary could skip assignments. **Two collection-level fixes.** sc's Fleet had two field-level `beforeChange` hooks each issuing a `findByID` for the SAME ship on every write; they are now one collection-level hook that reads the ship once and sets both `chassisName` and `name`. lms's `checkCertificationExpiry` re-derived `recountHolders` once per expired award with no cache; it now recounts once per affected certification, after the sweep — which is also more correct, since only the final count was ever right. `@wabbit/tome-crowdfund`'s `settleCampaign` pledge loop is untouched and now carries an explicit `eslint-disable` plus the reason: it captures money one pledge at a time against a `maxCapturesPerRun` budget that only bounds anything if the iterations are serialized. `@wabbit/tome-org`, `@wabbit/tome-lms`, `@wabbit/tome-workflow` and `@wabbit/tome-crowdfund` raise their `@wabbit/tome-core` peer floor to `>=1.14.0`, the release that adds `./utilities/batch`. org and lms were also understating their floor before this change — both already imported `@wabbit/tome-core/jobs`, added in core 1.7.0, while declaring `>=1.2.0` / `>=1.0.0`.
  • Updated dependencies [4aeedad]
  • Updated dependencies [b01ca1f]
  • Updated dependencies [73081e6] - @wabbit/tome-gamification@0.3.2
v0.23.1patch

HOTFIX (out-of-band from the 0.23.0 tag; fixes a live production TypeError). `CERT_AWARD_SYSTEM_BYPASS_CONTEXT_FLAG` was the string `'internal'` — a key `@payloadcms/richtext-lexical` RESERVES as its own object namespace on `req.context` (`context.internal.richText = {}`). Setting it to a boolean bypass broke richText validation on any certification the holder-count recount’s nested `payload.update` touched (`Cannot create property 'richText' on boolean 'true'`). The constant’s VALUE is now `'certAwardSystemBypass'` (constant NAME unchanged — consumers importing it migrate automatically on bump; every in-package SET site migrated: `autoAwardCertification`, `awardGate`). Defense in depth: `updateCertificationHolderCount` and `updateMemberCerts` now run their nested writes under a fresh `req.context` (save/restore in try/finally) so a triggering op’s flags never reach a different collection’s hooks. Red-first regression test reproduces lexical’s reserved write and the full award→recount chain. Published off a hotfix branch because main was mid-release for the sale-readiness audit (unpublished core 1.14.0 peer); the same fix commit is already on main.

  • HOTFIX (out-of-band from the 0.23.0 tag; fixes a live production TypeError). `CERT_AWARD_SYSTEM_BYPASS_CONTEXT_FLAG` was the string `'internal'` — a key `@payloadcms/richtext-lexical` RESERVES as its own object namespace on `req.context` (`context.internal.richText = {}`). Setting it to a boolean bypass broke richText validation on any certification the holder-count recount’s nested `payload.update` touched (`Cannot create property 'richText' on boolean 'true'`). The constant’s VALUE is now `'certAwardSystemBypass'` (constant NAME unchanged — consumers importing it migrate automatically on bump; every in-package SET site migrated: `autoAwardCertification`, `awardGate`). Defense in depth: `updateCertificationHolderCount` and `updateMemberCerts` now run their nested writes under a fresh `req.context` (save/restore in try/finally) so a triggering op’s flags never reach a different collection’s hooks. Red-first regression test reproduces lexical’s reserved write and the full award→recount chain. Published off a hotfix branch because main was mid-release for the sale-readiness audit (unpublished core 1.14.0 peer); the same fix commit is already on main.
v0.23.0minor

32afcac: Aftercare — three ledgered gaps closed: `QuizAttempt.feedback`, a `'graded'` status option, and revocation-aware holder-count/member-cert derivations. **GAP 1 — `QuizAttempt.feedback`.** Instructor essay-grading comments were accepted by EDU's `gradeSubmission` (`wabbit-site-core src/data/lms-instructor.ts`, `grade.feedback`) but had nowhere on this row to persist — confirmed absent from the factory's field set. Adds `feedback` (textarea, optional, additive) alongside `gradedBy`/`gradedAt`, mirroring `AssignmentUpload`'s flat `feedback` field rather than a nested `grade` group. Nothing writes it yet — same posture `gradedBy`/`gradedAt` shipped with. **GAP 2 — `'graded'` status option.** The 2026-08-31 design doc intended a dedicated "graded" terminal state; the shipped factory's vocabulary was `in-progress`/`completed`/`timed-out`, and EDU's grader closes essays to `'completed'` (indistinguishable from auto-graded). Adds `'graded'` (label "Graded (instructor)") as a fourth, additive `status` option — zero-config default/write behavior is unchanged (`submitQuizAttempt` and `grading.ts` still write `'completed'` on close-out; adopting `'graded'` for an instructor close-out is a consumer's own later port). Package-internal status comparisons were swept: `enforceAttemptPolicy`'s `outcomeOf` now treats `'graded'` as a third terminal outcome alongside `'completed'`/`'timed-out'` (a graded row already carries a real `passed` verdict, so excluding it would let a graded-and-failed essay attempt escape the maxAttempts/cooldown policy). `saveQuizProgress`'s `status !== 'in-progress'` guard already excludes `'graded'` correctly by construction (exclusion, not enumeration) — no change needed there. **GAP 3 — revocation-aware derivations.** Proven live on staging: a `status: 'revoked'` `CertificationAward` kept its `approvalStatus: 'approved'` (revocation never touches that field) and so was still counted by `updateCertificationHolderCount` and still derived into `Member.certificationAwards[]`/`certifications[]` by `updateMemberCerts` — both hooks filtered ONLY on `EFFECTIVE_AWARD_STATUSES` (`approvalStatus`-vocabulary). New export `EXCLUDE_REVOKED_WHERE` (`utilities/awardStatus.ts`) — a separately-composed `{ status: { not_equals: 'revoked' } }` fragment, ANDed alongside `EFFECTIVE_AWARD_STATUSES` in both hooks' queries, never folded into that constant (which stays exactly what every existing reader already relies on it meaning). Companion predicate `isRevokedAwardStatus` for in-memory checks. Deliberately does NOT touch `'expired'` — the `renewalStatus` state machine (`checkCertificationExpiry` sweep) owns that transition and already self-selects via `status: 'valid'`; a consumer wanting both exclusions composes `{ status: { not_equals: 'expired' } }` alongside this fragment the same way `attemptChallengeMode` (`server/mutations/challenge.ts`) already does by hand for its own prerequisite check. **Analysis (no code changed in wabbit-site-core).** `getPendingGrading`'s quiz-attempts branch filters only `{ status: { equals: 'completed' } }` — not essay-specific. It returns every completed attempt on the instructor's courses regardless of whether it ever had an essay question, and (since `gradeSubmission`'s quiz-attempts close-out re-writes `status: 'completed'`) an already-graded essay resurfaces in this same query forever. No consumer currently even renders the `essays` bucket (`instructor/page.tsx` only uses `pendingAssignments`). `'graded'` gives a future revision of that query and grader a clean terminal state to adopt — this PR does not perform that adoption. **Tests.** Red-first throughout: `outcomeOf`'s three new `'graded'`-terminal cases and both hooks' revoked-exclusion cases were verified failing against the pre-fix code before the fix landed. New/updated: `tests/quiz-attempt-seams.test.ts` (feedback field, additive status options, graded-terminal policy cases), `tests/quiz-attempt-characterisation.test.ts` (status-options pin updated to the new 4-option list — the enumerated exception to byte-identity), `tests/award-status.test.ts` (`EFFECTIVE_AWARD_STATUSES` unchanged pin + `EXCLUDE_REVOKED_WHERE`/`isRevokedAwardStatus` coverage), `tests/cert-holder-count-recount.test.ts` and `tests/cert-member-sync.test.ts` (revoked-excluded / expired-unchanged cases; their mock `Where` matchers gained `not_equals` support to exercise the real fragment). Full lms suite green (934, up from 916 baseline); `assert-node-loadable --every-file` unchanged (238/14/0, no new skips).

  • 32afcac: Aftercare — three ledgered gaps closed: `QuizAttempt.feedback`, a `'graded'` status option, and revocation-aware holder-count/member-cert derivations. **GAP 1 — `QuizAttempt.feedback`.** Instructor essay-grading comments were accepted by EDU's `gradeSubmission` (`wabbit-site-core src/data/lms-instructor.ts`, `grade.feedback`) but had nowhere on this row to persist — confirmed absent from the factory's field set. Adds `feedback` (textarea, optional, additive) alongside `gradedBy`/`gradedAt`, mirroring `AssignmentUpload`'s flat `feedback` field rather than a nested `grade` group. Nothing writes it yet — same posture `gradedBy`/`gradedAt` shipped with. **GAP 2 — `'graded'` status option.** The 2026-08-31 design doc intended a dedicated "graded" terminal state; the shipped factory's vocabulary was `in-progress`/`completed`/`timed-out`, and EDU's grader closes essays to `'completed'` (indistinguishable from auto-graded). Adds `'graded'` (label "Graded (instructor)") as a fourth, additive `status` option — zero-config default/write behavior is unchanged (`submitQuizAttempt` and `grading.ts` still write `'completed'` on close-out; adopting `'graded'` for an instructor close-out is a consumer's own later port). Package-internal status comparisons were swept: `enforceAttemptPolicy`'s `outcomeOf` now treats `'graded'` as a third terminal outcome alongside `'completed'`/`'timed-out'` (a graded row already carries a real `passed` verdict, so excluding it would let a graded-and-failed essay attempt escape the maxAttempts/cooldown policy). `saveQuizProgress`'s `status !== 'in-progress'` guard already excludes `'graded'` correctly by construction (exclusion, not enumeration) — no change needed there. **GAP 3 — revocation-aware derivations.** Proven live on staging: a `status: 'revoked'` `CertificationAward` kept its `approvalStatus: 'approved'` (revocation never touches that field) and so was still counted by `updateCertificationHolderCount` and still derived into `Member.certificationAwards[]`/`certifications[]` by `updateMemberCerts` — both hooks filtered ONLY on `EFFECTIVE_AWARD_STATUSES` (`approvalStatus`-vocabulary). New export `EXCLUDE_REVOKED_WHERE` (`utilities/awardStatus.ts`) — a separately-composed `{ status: { not_equals: 'revoked' } }` fragment, ANDed alongside `EFFECTIVE_AWARD_STATUSES` in both hooks' queries, never folded into that constant (which stays exactly what every existing reader already relies on it meaning). Companion predicate `isRevokedAwardStatus` for in-memory checks. Deliberately does NOT touch `'expired'` — the `renewalStatus` state machine (`checkCertificationExpiry` sweep) owns that transition and already self-selects via `status: 'valid'`; a consumer wanting both exclusions composes `{ status: { not_equals: 'expired' } }` alongside this fragment the same way `attemptChallengeMode` (`server/mutations/challenge.ts`) already does by hand for its own prerequisite check. **Analysis (no code changed in wabbit-site-core).** `getPendingGrading`'s quiz-attempts branch filters only `{ status: { equals: 'completed' } }` — not essay-specific. It returns every completed attempt on the instructor's courses regardless of whether it ever had an essay question, and (since `gradeSubmission`'s quiz-attempts close-out re-writes `status: 'completed'`) an already-graded essay resurfaces in this same query forever. No consumer currently even renders the `essays` bucket (`instructor/page.tsx` only uses `pendingAssignments`). `'graded'` gives a future revision of that query and grader a clean terminal state to adopt — this PR does not perform that adoption. **Tests.** Red-first throughout: `outcomeOf`'s three new `'graded'`-terminal cases and both hooks' revoked-exclusion cases were verified failing against the pre-fix code before the fix landed. New/updated: `tests/quiz-attempt-seams.test.ts` (feedback field, additive status options, graded-terminal policy cases), `tests/quiz-attempt-characterisation.test.ts` (status-options pin updated to the new 4-option list — the enumerated exception to byte-identity), `tests/award-status.test.ts` (`EFFECTIVE_AWARD_STATUSES` unchanged pin + `EXCLUDE_REVOKED_WHERE`/`isRevokedAwardStatus` coverage), `tests/cert-holder-count-recount.test.ts` and `tests/cert-member-sync.test.ts` (revoked-excluded / expired-unchanged cases; their mock `Where` matchers gained `not_equals` support to exercise the real fragment). Full lms suite green (934, up from 916 baseline); `assert-node-loadable --every-file` unchanged (238/14/0, no new skips).
v0.22.0minor

a0b626a: A-4a — typed attempt-reference fields on `completedModules` + `createLmsLayer` collection passthrough (the cross-reference the A-3 STOP correctly refused to mis-type). **`completedModules[]` gains two typed back-references.** QuizAttempt/AssignmentUpload (0.21.0, A-1) are this package's canonical attempt rows, but nothing on `CourseEnrollment.completedModules[]` pointed back at them — the array had only `submissionReference` (the original form-submission pointer from the donor consumer, untouched, stays hard-typed to `formSubmissionRelationTo`). Two new ADDITIVE, OPTIONAL, single-target relationship fields — `quizAttemptRef` (→ `quizAttemptRelationTo`, default `'quiz-attempts'`) and `assignmentUploadRef` (→ `assignmentUploadRelationTo`, default `'assignment-uploads'`) — are inserted right after `submissionReference`, emitted ONLY in `completionStore: 'completed-modules'` mode. Zero-config (`'lesson-completions'` mode, the default) is byte-identical — pinned in `tests/course-enrollment-characterisation.test.ts`, unmodified by this PR. No migration: absence on existing rows is the same grandfather marker this package already uses everywhere else a field's meaning didn't exist yet when the row was written. **`createLmsLayer` gains a per-collection config passthrough.** `config.collections?.quizAttempt`/`.assignmentUpload` accept the same `QuizAttemptCollectionConfig`/`AssignmentUploadCollectionConfig` shape those factories already take directly, letting a site pass a non-default config (custom slug, `studentRelationTo`, `extraFields`, `requireBlockIds`, etc.) straight through `createLmsLayer` instead of filtering the returned `collections` array and appending its own re-built replacement afterward — EDU's prior pattern, retired by this seam. Zero-config (both keys omitted, or `collections: {}`) keeps the exact byte-identical static `QuizAttemptCollection`/`AssignmentUploadCollection` singletons — REFERENCE-identical, not a fresh `createXCollection({})` call — so a site that never touches this knob sees no behavior change at all. Registration order is preserved: the configured collection lands at the same array position the static default occupied. Only these two collections are exposed here; a general per-collection config seam for the rest of the layer's factory-backed collections is a 1.0 question, not decided by this PR. **`lms-ui`'s `QuizAttemptStartResult` root-barrel export** (the other open item from the EDU wave) was verified, not redone — it was fixed in 0.10.2 (`packages/lms-ui/src/index.ts` already re-exports it from `./types`, guarded by `quiz-attempt-start-result-export.pin.test.ts`). No lms-ui changes in this PR. **Tests.** New: `tests/layer-collections-passthrough.test.ts` (zero-config reference-identity + factory-reaching seam tests for both collections) and a new describe block in `tests/course-enrollment-completed-modules.test.ts` (field presence/position/shape/relationTo-seam coverage for `quizAttemptRef`/`assignmentUploadRef`). Full lms suite green; no new skips in `assert-node-loadable --every-file`.

  • a0b626a: A-4a — typed attempt-reference fields on `completedModules` + `createLmsLayer` collection passthrough (the cross-reference the A-3 STOP correctly refused to mis-type). **`completedModules[]` gains two typed back-references.** QuizAttempt/AssignmentUpload (0.21.0, A-1) are this package's canonical attempt rows, but nothing on `CourseEnrollment.completedModules[]` pointed back at them — the array had only `submissionReference` (the original form-submission pointer from the donor consumer, untouched, stays hard-typed to `formSubmissionRelationTo`). Two new ADDITIVE, OPTIONAL, single-target relationship fields — `quizAttemptRef` (→ `quizAttemptRelationTo`, default `'quiz-attempts'`) and `assignmentUploadRef` (→ `assignmentUploadRelationTo`, default `'assignment-uploads'`) — are inserted right after `submissionReference`, emitted ONLY in `completionStore: 'completed-modules'` mode. Zero-config (`'lesson-completions'` mode, the default) is byte-identical — pinned in `tests/course-enrollment-characterisation.test.ts`, unmodified by this PR. No migration: absence on existing rows is the same grandfather marker this package already uses everywhere else a field's meaning didn't exist yet when the row was written. **`createLmsLayer` gains a per-collection config passthrough.** `config.collections?.quizAttempt`/`.assignmentUpload` accept the same `QuizAttemptCollectionConfig`/`AssignmentUploadCollectionConfig` shape those factories already take directly, letting a site pass a non-default config (custom slug, `studentRelationTo`, `extraFields`, `requireBlockIds`, etc.) straight through `createLmsLayer` instead of filtering the returned `collections` array and appending its own re-built replacement afterward — EDU's prior pattern, retired by this seam. Zero-config (both keys omitted, or `collections: {}`) keeps the exact byte-identical static `QuizAttemptCollection`/`AssignmentUploadCollection` singletons — REFERENCE-identical, not a fresh `createXCollection({})` call — so a site that never touches this knob sees no behavior change at all. Registration order is preserved: the configured collection lands at the same array position the static default occupied. Only these two collections are exposed here; a general per-collection config seam for the rest of the layer's factory-backed collections is a 1.0 question, not decided by this PR. **`lms-ui`'s `QuizAttemptStartResult` root-barrel export** (the other open item from the EDU wave) was verified, not redone — it was fixed in 0.10.2 (`packages/lms-ui/src/index.ts` already re-exports it from `./types`, guarded by `quiz-attempt-start-result-export.pin.test.ts`). No lms-ui changes in this PR. **Tests.** New: `tests/layer-collections-passthrough.test.ts` (zero-config reference-identity + factory-reaching seam tests for both collections) and a new describe block in `tests/course-enrollment-completed-modules.test.ts` (field presence/position/shape/relationTo-seam coverage for `quizAttemptRef`/`assignmentUploadRef`). Full lms suite green; no new skips in `assert-node-loadable --every-file`.
v0.21.0minor

fd71b9a: A-1 — attempt-collection factories + exam cluster, the D-2b assessment-convergence arc's first port. Converts `QuizAttempt`/`AssignmentUpload` from static `CollectionConfig` exports to `createQuizAttemptCollection`/`createAssignmentUploadCollection` factories, per the fieldShape/mergeHooks/per-verb-access discipline `CertificationAward.ts` (L-P3) established, and ships a new opt-in exam cluster (`createExamTicketCollection`/`createExamBypassRequestCollection`, D-4) modeled on a production consumer's own collections. **Pins first.** `tests/{quiz-attempt,assignment-upload}-characterisation.test.ts` were written and verified green against the pre-conversion static exports BEFORE any factory code landed (see that commit in this PR's history), then updated post-conversion to drop only the assertions the D-2b additions deliberately supersede (an exhaustive negative-existence check for fields that now exist) — every other pin still passes unchanged. One confirmed pre-existing gap the pins prove: `submitAssignment` (server/mutations/assignment.ts) already writes `assignmentBlockId` onto every submission today, but the collection declared no such field — Payload silently dropped it on every write until this port. **QuizAttempt.** `quizBlockId`'s tightening to `required: true` (closing the ledgered gap where multi-quiz lessons were indistinguishable) is gated behind `requireBlockIds` (default `false` in 0.x — the field stays optional, byte-identical) — **the 1.0 flip to `required: true` by default is recorded here as the trigger for that release**. `gradedBy` (relationship, target configurable via `gradedByRelationTo`, default `'users'`) and `gradedAt` (date) are additive/optional fields for the manual-essay grading path (mirrors `AssignmentUpload.grade.gradedBy`/`gradedAt`) — nothing writes them yet; a grading UI has somewhere to put results now. Opt-in `enforceAttemptPolicy` (default `false`) wires a new blockId-scoped, policy-aware `beforeChange` hook (`hooks/attempt-workflow/enforceAttemptPolicy.ts`) that recomputes `{attempts, lockedOut, cooldownExpiry}` purely from prior QuizAttempt rows (folding every graded prior attempt through `utilities/attemptPolicy.ts`'s `applyAttemptOutcome`, called verbatim — never reimplemented), assigns `attemptNumber` from that state, and throws (blocking the create) on lockout or an active cooldown. This hook runs BEFORE the pre-existing `autoIncrementAttemptNumber` (still wired unconditionally, scoped by student+lesson+course, unchanged) — its already-positive guard makes the old hook a no-op whenever the new one already assigned a number. **AssignmentUpload.** Three additive fields, all new — `generate:types` will show exactly these on a consumer that regenerates: `assignmentBlockId` (text, indexed; the field `submitAssignment` was already trying to write — see the pins), `attemptNumber` (number, factory-assigned), `previousUpload` (self-relationship, resubmission lineage — the versioned-rows model the design doc calls for, replacing today's always-update-in-place `submitAssignment`; wiring the mutation to actually create a new row per resubmission is A-2/EDU adoption, out of this PR's scope). `assignmentBlockId`'s required-tightening is gated by the same `requireBlockIds` option (default `false`), same 1.0-flip note as QuizAttempt. `attemptNumber`/`previousUpload` auto-assignment (`hooks/attempt-workflow/assignAssignmentAttemptNumber.ts`, scoped by student+lesson+assignmentBlockId) is wired **on by default** (`assignAttemptNumber: true`) — unlike QuizAttempt's policy hook, this only populates brand-new fields, so there is no pre-A-1 behavior to preserve by defaulting it off. **Mutations are untouched.** `startQuizAttempt`/`submitQuizAttempt`/`submitAssignment`/`gradeAssignment` (server/mutations) are not modified in this PR. Reconciliation verdict: `startQuizAttempt` always pre-supplies a positive `attemptNumber` on every create it issues, so neither the old nor the new attemptNumber hook's recompute branch is ever reached on that path, regardless of `enforceAttemptPolicy`'s value — the flag only affects direct collection writes that omit `attemptNumber` (admin UI, seed scripts, out-of-band API calls). Neither mutation enforces maxAttempts/cooldown today (a TODO in `startQuizAttempt` notes the omission), so there is no double-enforcement risk to reconcile in 0.x either. No conflict found; no STOP triggered. **Exam cluster (D-4), opt-in.** `createExamTicketCollection`/`createExamBypassRequestCollection` reproduce a production consumer's field shape (`ExamTickets`/`ExamBypassRequests`) — including the full six-value ticket lifecycle and the complete escalation-ladder provenance trail on bypass requests (`proposerScope`/`approverTier`/`singleSignature`/`guardOverrides`/`soleAuthorityContext`/`soleAuthorityJustification`). That consumer's own hooks (real-time broadcast, pool-task-close notifications, the `@wabbit/tome-workflow` bypass-authority topology mirror) and access gates (role/scope-scoped authority) are NOT ported — both reach into that consumer's own app code this platform package cannot depend on — they attach via `config.hooks`/`config.access`, same seam every other factory in this package exposes. **Neither collection is registered in `createLmsLayer`'s `baseCollections`** — a consumer spreads the factory output into their own `payload.config.ts` collections array alongside `createLmsLayer(...)`'s, opt-in like every other collection a site composes in only when it runs that workflow. **Subpaths.** New `./collections/quizAttempt`, `./collections/assignmentUpload`, `./collections/examTicket`, `./collections/examBypassRequest` — same poisoned-barrel-alternate-door pattern as the eight prior subpaths (all four collections' own module graphs are clean — none touch gamification), each with its own spawn-node loadability test. **Tests.** 784 → 896 lms tests green (112 new): `quiz-attempt-characterisation.test.ts` (20), `assignment-upload-characterisation.test.ts` (21), `quiz-attempt-seams.test.ts` (17 — field/access/hook seams, `enforceAttemptPolicy`'s attemptNumber assignment + lockout/cooldown blocking, both on and off), `assignment-upload-seams.test.ts` (16 — field/access/hook seams, `assignAssignmentAttemptNumber`'s attemptNumber + `previousUpload` linking), `exam-cluster-characterisation.test.ts` (18 — zero-config defaults, consumer-emulation-shape relation targets, seam injection, confirms neither collection is in `createLmsLayer`), 4 new `*-subpath-loadable.test.ts` (20). Build clean, typecheck clean (`tsc --noEmit`, zero errors), `assert-node-loadable --every-file`: 238 pass / 14 pre-existing skip / 0 fail (no new skips — the 18 new targets, the four subpaths' `index.{js,cjs}` files, all pass).

  • fd71b9a: A-1 — attempt-collection factories + exam cluster, the D-2b assessment-convergence arc's first port. Converts `QuizAttempt`/`AssignmentUpload` from static `CollectionConfig` exports to `createQuizAttemptCollection`/`createAssignmentUploadCollection` factories, per the fieldShape/mergeHooks/per-verb-access discipline `CertificationAward.ts` (L-P3) established, and ships a new opt-in exam cluster (`createExamTicketCollection`/`createExamBypassRequestCollection`, D-4) modeled on a production consumer's own collections. **Pins first.** `tests/{quiz-attempt,assignment-upload}-characterisation.test.ts` were written and verified green against the pre-conversion static exports BEFORE any factory code landed (see that commit in this PR's history), then updated post-conversion to drop only the assertions the D-2b additions deliberately supersede (an exhaustive negative-existence check for fields that now exist) — every other pin still passes unchanged. One confirmed pre-existing gap the pins prove: `submitAssignment` (server/mutations/assignment.ts) already writes `assignmentBlockId` onto every submission today, but the collection declared no such field — Payload silently dropped it on every write until this port. **QuizAttempt.** `quizBlockId`'s tightening to `required: true` (closing the ledgered gap where multi-quiz lessons were indistinguishable) is gated behind `requireBlockIds` (default `false` in 0.x — the field stays optional, byte-identical) — **the 1.0 flip to `required: true` by default is recorded here as the trigger for that release**. `gradedBy` (relationship, target configurable via `gradedByRelationTo`, default `'users'`) and `gradedAt` (date) are additive/optional fields for the manual-essay grading path (mirrors `AssignmentUpload.grade.gradedBy`/`gradedAt`) — nothing writes them yet; a grading UI has somewhere to put results now. Opt-in `enforceAttemptPolicy` (default `false`) wires a new blockId-scoped, policy-aware `beforeChange` hook (`hooks/attempt-workflow/enforceAttemptPolicy.ts`) that recomputes `{attempts, lockedOut, cooldownExpiry}` purely from prior QuizAttempt rows (folding every graded prior attempt through `utilities/attemptPolicy.ts`'s `applyAttemptOutcome`, called verbatim — never reimplemented), assigns `attemptNumber` from that state, and throws (blocking the create) on lockout or an active cooldown. This hook runs BEFORE the pre-existing `autoIncrementAttemptNumber` (still wired unconditionally, scoped by student+lesson+course, unchanged) — its already-positive guard makes the old hook a no-op whenever the new one already assigned a number. **AssignmentUpload.** Three additive fields, all new — `generate:types` will show exactly these on a consumer that regenerates: `assignmentBlockId` (text, indexed; the field `submitAssignment` was already trying to write — see the pins), `attemptNumber` (number, factory-assigned), `previousUpload` (self-relationship, resubmission lineage — the versioned-rows model the design doc calls for, replacing today's always-update-in-place `submitAssignment`; wiring the mutation to actually create a new row per resubmission is A-2/EDU adoption, out of this PR's scope). `assignmentBlockId`'s required-tightening is gated by the same `requireBlockIds` option (default `false`), same 1.0-flip note as QuizAttempt. `attemptNumber`/`previousUpload` auto-assignment (`hooks/attempt-workflow/assignAssignmentAttemptNumber.ts`, scoped by student+lesson+assignmentBlockId) is wired **on by default** (`assignAttemptNumber: true`) — unlike QuizAttempt's policy hook, this only populates brand-new fields, so there is no pre-A-1 behavior to preserve by defaulting it off. **Mutations are untouched.** `startQuizAttempt`/`submitQuizAttempt`/`submitAssignment`/`gradeAssignment` (server/mutations) are not modified in this PR. Reconciliation verdict: `startQuizAttempt` always pre-supplies a positive `attemptNumber` on every create it issues, so neither the old nor the new attemptNumber hook's recompute branch is ever reached on that path, regardless of `enforceAttemptPolicy`'s value — the flag only affects direct collection writes that omit `attemptNumber` (admin UI, seed scripts, out-of-band API calls). Neither mutation enforces maxAttempts/cooldown today (a TODO in `startQuizAttempt` notes the omission), so there is no double-enforcement risk to reconcile in 0.x either. No conflict found; no STOP triggered. **Exam cluster (D-4), opt-in.** `createExamTicketCollection`/`createExamBypassRequestCollection` reproduce a production consumer's field shape (`ExamTickets`/`ExamBypassRequests`) — including the full six-value ticket lifecycle and the complete escalation-ladder provenance trail on bypass requests (`proposerScope`/`approverTier`/`singleSignature`/`guardOverrides`/`soleAuthorityContext`/`soleAuthorityJustification`). That consumer's own hooks (real-time broadcast, pool-task-close notifications, the `@wabbit/tome-workflow` bypass-authority topology mirror) and access gates (role/scope-scoped authority) are NOT ported — both reach into that consumer's own app code this platform package cannot depend on — they attach via `config.hooks`/`config.access`, same seam every other factory in this package exposes. **Neither collection is registered in `createLmsLayer`'s `baseCollections`** — a consumer spreads the factory output into their own `payload.config.ts` collections array alongside `createLmsLayer(...)`'s, opt-in like every other collection a site composes in only when it runs that workflow. **Subpaths.** New `./collections/quizAttempt`, `./collections/assignmentUpload`, `./collections/examTicket`, `./collections/examBypassRequest` — same poisoned-barrel-alternate-door pattern as the eight prior subpaths (all four collections' own module graphs are clean — none touch gamification), each with its own spawn-node loadability test. **Tests.** 784 → 896 lms tests green (112 new): `quiz-attempt-characterisation.test.ts` (20), `assignment-upload-characterisation.test.ts` (21), `quiz-attempt-seams.test.ts` (17 — field/access/hook seams, `enforceAttemptPolicy`'s attemptNumber assignment + lockout/cooldown blocking, both on and off), `assignment-upload-seams.test.ts` (16 — field/access/hook seams, `assignAssignmentAttemptNumber`'s attemptNumber + `previousUpload` linking), `exam-cluster-characterisation.test.ts` (18 — zero-config defaults, consumer-emulation-shape relation targets, seam injection, confirms neither collection is in `createLmsLayer`), 4 new `*-subpath-loadable.test.ts` (20). Build clean, typecheck clean (`tsc --noEmit`, zero errors), `assert-node-loadable --every-file`: 238 pass / 14 pre-existing skip / 0 fail (no new skips — the 18 new targets, the four subpaths' `index.{js,cjs}` files, all pass).
v0.20.0minor

ef97756: L-P8 — Course/Topic/CourseItem convergence, the LMS convergence program's LAST pair port. Ports the field-seam discipline established by L-P3 through L-P7 onto Course, Topic, and CourseItem — the largest and last of the seven pairs, deliberately sequenced last because it carries the donor consumer's biggest migration cost (array→junction rewrite). That consumer's own `Courses`/`Topics` collections are unmodified (read-only reference). **The structural decision, reaffirmed, not re-litigated.** CourseItem junction rows are canonical course structure — this was already true pre-port (Course carried no `topics`/`lessons` arrays, CourseItem already existed) and remains true post-port. This pair's actual work was porting the field-shape/access/mergeHooks discipline onto all three collections and re-expressing the donor consumer's incident-encoded Course invariants over junction rows, not a structural migration on this package's own side. **Field seams.** `collections/{Course,Topic,CourseItem}.ts` convert from static `CollectionConfig` exports to `createCourseCollection`/`createTopicCollection`/`createCourseItemCollection`, using the same `./collections/shared/{fieldShape,optionOverrides,mergeHooks}.ts` mechanism as every prior port. All three static exports remain `create*Collection()` with every default — byte-identical wiring, pinned by `tests/{course,topic,course-item}-characterisation.test.ts` (49 tests total, committed separately BEFORE any conversion landed, verified green against both the pre-port static collections and the post-port factory output). Course gains: `staffRelationTo` (owner/createdBy/instructors/maintainers together, default `'users'` unchanged — the donor consumer targets `'members'`), `mediaRelationTo`/`academyRelationTo`/`certificationRelationTo`/`courseRelationTo`/`skillPathRelationTo` (every remaining relationship target, per design principle 3), every select vocabulary made injectable, `versionsMode: 'none' | 'drafts'` (`'drafts'` wires the donor consumer's exact `versions: { drafts: true }` plus a `readVersions` default, matching the same trap `Lesson.ts`'s `versionsMode: 'snapshot-history'` already guards against), the standard field-shape pipeline, per-verb `access`, and a `hooks` merge seam. Topic gains the three polymorphic `prerequisites[].item` leg seams (`lessonRelationTo`/`topicRelationTo`/`courseRelationTo`) plus the standard pipeline — no built-in hooks (none pre-port). CourseItem gains `courseRelationTo`/`topicRelationTo`/`lessonRelationTo`, an injectable `itemTypeOptions` vocabulary, and the standard pipeline; `parent`/`unlockAfter` now self-reference the collection's own configured `slug` rather than a hardcoded string, so a consumer's slug rename carries them automatically. CourseItem's unique/secondary indexes are field-name-based and were already rename-safe pre-port. **Org-generic invariants from the donor consumer ship as opt-in factory hooks, default OFF.** `requirePublishedLessonsOnPublish` (`hooks/workflow/requirePublishedLessonsOnPublish.ts`) re-expresses that consumer's "a course cannot publish with zero published lessons" gate over CourseItem rows instead of direct arrays — walks every lesson-type CourseItem for the course (both top-level and nested under a topic), checks `Lesson.isPublished`, blocks the transition into `status: 'published'` when the published count is zero. Update-only by construction (a create-as-published course has no CourseItem rows referencing it yet, so it always fails the check the same way the pre-port donor's own update-only gate did). `dropEnrollmentsOnDelete` (`hooks/workflow/dropEnrollmentsOnDelete.ts`) drops (not deletes) a deleted course's live enrollments, paginated with the same 50-pass/zero-progress-break guard as the donor's original. Both take injectable relation-slug options (`courseItemRelationTo`/`lessonRelationTo`/`enrollmentRelationTo`) and both share this package's unified `req.context.internal` system-bypass convention (`../cert-workflow/enforceApprovalStatus.ts`'s constant) rather than the donor's own `migrationBackfill` flag name. Enabled via `invariantHooks: { requirePublishedLessonsOnPublish: true, dropEnrollmentsOnDelete: true }` — both default `false`, so zero-config output is unaffected. **Per-built-in-hook opt-outs from birth (the L-P5.1 lesson, applied on day one).** `promoteOwnerToMaintainerOnApproval` — which already existed on Course pre-port, unconditionally wired — is now opt-outable via `builtInHooks.promoteOwnerToMaintainerOnApproval` (default `true`, byte-identical). This exists because `mergeHooks` only appends: the donor consumer's real hook order requires `requireReapprovalOnCertChange` to run BEFORE `promoteOwnerToMaintainerOnApproval` (both mutate `workflowStatus` in the same write), which append-only ordering cannot express when the built-in always runs first. The opt-out is the escape hatch — a consumer disables the factory's copy and supplies its own fully-ordered `beforeChange` sequence. `tests/course-seams.test.ts` proves this exact case functionally. **Donor-consumer-specific invariants stay consumer-side — verified expressible, not ported.** `requireReapprovalOnCertChange`, `validateSMEInstructors` (needs that consumer's own `sme-designations` collection), the deletion-request workflow (that consumer's own reviewer/reason group), and the notify fan-outs attach via the `hooks`/`extraFields` seams, never promoted to the factory. A dedicated consumer-adoption-shape emulation test in `tests/course-seams.test.ts` proves the full shape end-to-end: `staffRelationTo: 'members'`, `instructorRoles`/`deletionRequest`/legacy `topics`/`lessons` arrays via `extraFields`, `versionsMode: 'drafts'`, the two opt-in invariants enabled, and the three consumer-specific hooks attached in that consumer's required order via the `builtInHooks` opt-out. **Coexistence: junction rows AND legacy arrays, simultaneously, on the same document.** The design doc requires the factory to tolerate a consumer carrying both the donor consumer's legacy `Course.topics`/`Course.lessons`/`Topic.course`/`Topic.lessons`/`Topic.order` arrays AND real CourseItem junction rows at once, until that consumer's own data migration retires the arrays. Both `course-seams.test.ts` and `topic-seams.test.ts` include a dedicated coexistence test: the legacy fields land as ordinary `extraFields` the factory neither reads nor writes, and — for Course — the opted-in `requirePublishedLessonsOnPublish` invariant is proven to resolve curriculum EXCLUSIVELY through CourseItem rows even when stale legacy array data is present on the same write. Nothing in either factory constrains a consumer from carrying both shapes at once. **Academy labels seam added (L-P8's one allowed touch outside Course/Topic/CourseItem — closes ledgered #361).** `AcademyCollectionConfig` gains an injectable `labels` option (default unchanged: `{ singular: 'Academy', plural: 'Academies' }`) — every other converted collection in this package already carried this seam; Academy's own L-P7 port omitted it. `tests/academy-seams.test.ts` gains two tests covering the default and the override. **Subpaths.** New `./collections/course`, `./collections/topic`, `./collections/courseItem` exports — same poisoned-barrel-alternate-door pattern as the five prior subpaths (all three collections' own module graphs were already clean: `payload` types, `../access`, `../hooks/workflow`, `./shared/*` — none touch gamification) plus their loadability tests. **Tests.** `tests/{course,topic,course-item}-characterisation.test.ts` (49 — identity, access wiring, every top-level field default, full-field-set snapshots), `tests/course-seams.test.ts` (32 — field-shape seams, versions seam, access injection, hook merge + opt-outs, both opt-in invariants' functional correctness including bypass/ordering/seam-slug tests, the consumer emulation, the coexistence proof, default-export stability), `tests/topic-seams.test.ts` (10 — polymorphic-leg seams, field pipeline, access, hooks, legacy-field coexistence), `tests/course-item-seams.test.ts` (13 — relation seams, itemType vocabulary, self-reference slug tracking, filterOptions narrowing, index rename-safety, access, hooks), `tests/{course,topic,course-item}-subpath-loadable.test.ts` (15 — built-dist loadability under raw Node, ESM+CJS, exports-map entries, symbol surfaces). `tests/academy-seams.test.ts` gains 2 (labels seam). 663 → 784 lms tests green (main's pre-port baseline was 663 passing; this branch adds 121 new tests across the files above). Build clean, typecheck clean (`tsc --noEmit`, zero errors), `assert-node-loadable --every-file`: 220 pass / 14 pre-existing skip / 0 fail (no new skips — the skip set is unchanged from L-P7's baseline; the 10 new files, `collections/{course,topic,courseItem}/index.{js,cjs}` and `hooks/workflow/{requirePublishedLessonsOnPublish,dropEnrollmentsOnDelete}.{js,cjs}`, all pass). Default exports-map mode: 38 pass / 4 pre-existing skip (barrel + `./server`, both intentionally poisoned doors) / 0 fail. `LMS_LAYER_VERSION` and `package.json` version are NOT bumped in this changeset — versioning is the release process's job, not the builder's.

  • ef97756: L-P8 — Course/Topic/CourseItem convergence, the LMS convergence program's LAST pair port. Ports the field-seam discipline established by L-P3 through L-P7 onto Course, Topic, and CourseItem — the largest and last of the seven pairs, deliberately sequenced last because it carries the donor consumer's biggest migration cost (array→junction rewrite). That consumer's own `Courses`/`Topics` collections are unmodified (read-only reference). **The structural decision, reaffirmed, not re-litigated.** CourseItem junction rows are canonical course structure — this was already true pre-port (Course carried no `topics`/`lessons` arrays, CourseItem already existed) and remains true post-port. This pair's actual work was porting the field-shape/access/mergeHooks discipline onto all three collections and re-expressing the donor consumer's incident-encoded Course invariants over junction rows, not a structural migration on this package's own side. **Field seams.** `collections/{Course,Topic,CourseItem}.ts` convert from static `CollectionConfig` exports to `createCourseCollection`/`createTopicCollection`/`createCourseItemCollection`, using the same `./collections/shared/{fieldShape,optionOverrides,mergeHooks}.ts` mechanism as every prior port. All three static exports remain `create*Collection()` with every default — byte-identical wiring, pinned by `tests/{course,topic,course-item}-characterisation.test.ts` (49 tests total, committed separately BEFORE any conversion landed, verified green against both the pre-port static collections and the post-port factory output). Course gains: `staffRelationTo` (owner/createdBy/instructors/maintainers together, default `'users'` unchanged — the donor consumer targets `'members'`), `mediaRelationTo`/`academyRelationTo`/`certificationRelationTo`/`courseRelationTo`/`skillPathRelationTo` (every remaining relationship target, per design principle 3), every select vocabulary made injectable, `versionsMode: 'none' | 'drafts'` (`'drafts'` wires the donor consumer's exact `versions: { drafts: true }` plus a `readVersions` default, matching the same trap `Lesson.ts`'s `versionsMode: 'snapshot-history'` already guards against), the standard field-shape pipeline, per-verb `access`, and a `hooks` merge seam. Topic gains the three polymorphic `prerequisites[].item` leg seams (`lessonRelationTo`/`topicRelationTo`/`courseRelationTo`) plus the standard pipeline — no built-in hooks (none pre-port). CourseItem gains `courseRelationTo`/`topicRelationTo`/`lessonRelationTo`, an injectable `itemTypeOptions` vocabulary, and the standard pipeline; `parent`/`unlockAfter` now self-reference the collection's own configured `slug` rather than a hardcoded string, so a consumer's slug rename carries them automatically. CourseItem's unique/secondary indexes are field-name-based and were already rename-safe pre-port. **Org-generic invariants from the donor consumer ship as opt-in factory hooks, default OFF.** `requirePublishedLessonsOnPublish` (`hooks/workflow/requirePublishedLessonsOnPublish.ts`) re-expresses that consumer's "a course cannot publish with zero published lessons" gate over CourseItem rows instead of direct arrays — walks every lesson-type CourseItem for the course (both top-level and nested under a topic), checks `Lesson.isPublished`, blocks the transition into `status: 'published'` when the published count is zero. Update-only by construction (a create-as-published course has no CourseItem rows referencing it yet, so it always fails the check the same way the pre-port donor's own update-only gate did). `dropEnrollmentsOnDelete` (`hooks/workflow/dropEnrollmentsOnDelete.ts`) drops (not deletes) a deleted course's live enrollments, paginated with the same 50-pass/zero-progress-break guard as the donor's original. Both take injectable relation-slug options (`courseItemRelationTo`/`lessonRelationTo`/`enrollmentRelationTo`) and both share this package's unified `req.context.internal` system-bypass convention (`../cert-workflow/enforceApprovalStatus.ts`'s constant) rather than the donor's own `migrationBackfill` flag name. Enabled via `invariantHooks: { requirePublishedLessonsOnPublish: true, dropEnrollmentsOnDelete: true }` — both default `false`, so zero-config output is unaffected. **Per-built-in-hook opt-outs from birth (the L-P5.1 lesson, applied on day one).** `promoteOwnerToMaintainerOnApproval` — which already existed on Course pre-port, unconditionally wired — is now opt-outable via `builtInHooks.promoteOwnerToMaintainerOnApproval` (default `true`, byte-identical). This exists because `mergeHooks` only appends: the donor consumer's real hook order requires `requireReapprovalOnCertChange` to run BEFORE `promoteOwnerToMaintainerOnApproval` (both mutate `workflowStatus` in the same write), which append-only ordering cannot express when the built-in always runs first. The opt-out is the escape hatch — a consumer disables the factory's copy and supplies its own fully-ordered `beforeChange` sequence. `tests/course-seams.test.ts` proves this exact case functionally. **Donor-consumer-specific invariants stay consumer-side — verified expressible, not ported.** `requireReapprovalOnCertChange`, `validateSMEInstructors` (needs that consumer's own `sme-designations` collection), the deletion-request workflow (that consumer's own reviewer/reason group), and the notify fan-outs attach via the `hooks`/`extraFields` seams, never promoted to the factory. A dedicated consumer-adoption-shape emulation test in `tests/course-seams.test.ts` proves the full shape end-to-end: `staffRelationTo: 'members'`, `instructorRoles`/`deletionRequest`/legacy `topics`/`lessons` arrays via `extraFields`, `versionsMode: 'drafts'`, the two opt-in invariants enabled, and the three consumer-specific hooks attached in that consumer's required order via the `builtInHooks` opt-out. **Coexistence: junction rows AND legacy arrays, simultaneously, on the same document.** The design doc requires the factory to tolerate a consumer carrying both the donor consumer's legacy `Course.topics`/`Course.lessons`/`Topic.course`/`Topic.lessons`/`Topic.order` arrays AND real CourseItem junction rows at once, until that consumer's own data migration retires the arrays. Both `course-seams.test.ts` and `topic-seams.test.ts` include a dedicated coexistence test: the legacy fields land as ordinary `extraFields` the factory neither reads nor writes, and — for Course — the opted-in `requirePublishedLessonsOnPublish` invariant is proven to resolve curriculum EXCLUSIVELY through CourseItem rows even when stale legacy array data is present on the same write. Nothing in either factory constrains a consumer from carrying both shapes at once. **Academy labels seam added (L-P8's one allowed touch outside Course/Topic/CourseItem — closes ledgered #361).** `AcademyCollectionConfig` gains an injectable `labels` option (default unchanged: `{ singular: 'Academy', plural: 'Academies' }`) — every other converted collection in this package already carried this seam; Academy's own L-P7 port omitted it. `tests/academy-seams.test.ts` gains two tests covering the default and the override. **Subpaths.** New `./collections/course`, `./collections/topic`, `./collections/courseItem` exports — same poisoned-barrel-alternate-door pattern as the five prior subpaths (all three collections' own module graphs were already clean: `payload` types, `../access`, `../hooks/workflow`, `./shared/*` — none touch gamification) plus their loadability tests. **Tests.** `tests/{course,topic,course-item}-characterisation.test.ts` (49 — identity, access wiring, every top-level field default, full-field-set snapshots), `tests/course-seams.test.ts` (32 — field-shape seams, versions seam, access injection, hook merge + opt-outs, both opt-in invariants' functional correctness including bypass/ordering/seam-slug tests, the consumer emulation, the coexistence proof, default-export stability), `tests/topic-seams.test.ts` (10 — polymorphic-leg seams, field pipeline, access, hooks, legacy-field coexistence), `tests/course-item-seams.test.ts` (13 — relation seams, itemType vocabulary, self-reference slug tracking, filterOptions narrowing, index rename-safety, access, hooks), `tests/{course,topic,course-item}-subpath-loadable.test.ts` (15 — built-dist loadability under raw Node, ESM+CJS, exports-map entries, symbol surfaces). `tests/academy-seams.test.ts` gains 2 (labels seam). 663 → 784 lms tests green (main's pre-port baseline was 663 passing; this branch adds 121 new tests across the files above). Build clean, typecheck clean (`tsc --noEmit`, zero errors), `assert-node-loadable --every-file`: 220 pass / 14 pre-existing skip / 0 fail (no new skips — the skip set is unchanged from L-P7's baseline; the 10 new files, `collections/{course,topic,courseItem}/index.{js,cjs}` and `hooks/workflow/{requirePublishedLessonsOnPublish,dropEnrollmentsOnDelete}.{js,cjs}`, all pass). Default exports-map mode: 38 pass / 4 pre-existing skip (barrel + `./server`, both intentionally poisoned doors) / 0 fail. `LMS_LAYER_VERSION` and `package.json` version are NOT bumped in this changeset — versioning is the release process's job, not the builder's.
v0.19.0minor

f9b479c: Fixed a defect where both CourseEnrollment progress-recompute hooks (`createRecomputeCompletedModulesProgressHook` for `completionStore: 'completed-modules'`, and `computeProgress`/`createComputeProgressHook` for the default `'lesson-completions'` mode) fired unconditionally on every `afterChange` and re-derived progress/status regardless of what the triggering write actually touched. Proven executable against real call sites at a production consumer (a status-only `{ status: 'dropped' }` write, and a bare derived-field write): the very same afterChange invocation that persisted the drop immediately re-derived a live status from the unchanged `completedModules` array and fired a corrective update reverting it — a dropped enrollment did not survive its own drop. `createRecomputeCompletedModulesProgressHook` now compares `completedModulesFieldName` between `doc` and `previousDoc` and no-ops when it did not change (a `create`, with no `previousDoc`, always derives — there is no baseline to diff against). Both hooks also gained a `preserveStatuses` option (default `['dropped']`): when the enrollment's current status is in the list, progress can still update from a genuine `completedModules`/`LessonCompletion` change, but status (and the completion-date stamp) is never overwritten. Re-activating a preserved enrollment requires the writer to set the new status explicitly — it is never a side effect of a modules/completions write. `CourseEnrollmentCollectionConfig.preserveStatuses` threads the same list to whichever hook `completionStore` selects; omitting it keeps each hook's own default and the byte-identical `computeProgress` singleton reference for zero-config `'lesson-completions'` sites.

  • f9b479c: Fixed a defect where both CourseEnrollment progress-recompute hooks (`createRecomputeCompletedModulesProgressHook` for `completionStore: 'completed-modules'`, and `computeProgress`/`createComputeProgressHook` for the default `'lesson-completions'` mode) fired unconditionally on every `afterChange` and re-derived progress/status regardless of what the triggering write actually touched. Proven executable against real call sites at a production consumer (a status-only `{ status: 'dropped' }` write, and a bare derived-field write): the very same afterChange invocation that persisted the drop immediately re-derived a live status from the unchanged `completedModules` array and fired a corrective update reverting it — a dropped enrollment did not survive its own drop. `createRecomputeCompletedModulesProgressHook` now compares `completedModulesFieldName` between `doc` and `previousDoc` and no-ops when it did not change (a `create`, with no `previousDoc`, always derives — there is no baseline to diff against). Both hooks also gained a `preserveStatuses` option (default `['dropped']`): when the enrollment's current status is in the list, progress can still update from a genuine `completedModules`/`LessonCompletion` change, but status (and the completion-date stamp) is never overwritten. Re-activating a preserved enrollment requires the writer to set the new status explicitly — it is never a side effect of a modules/completions write. `CourseEnrollmentCollectionConfig.preserveStatuses` threads the same list to whichever hook `completionStore` selects; omitting it keeps each hook's own default and the byte-identical `computeProgress` singleton reference for zero-config `'lesson-completions'` sites.
v0.18.0minor

a556cd9: Add three rename/opt-out seams to close hook-shape gaps found by consumer adapters testing against 0.17.0 (L-P5.1), all zero-config byte-identical: - `createRecomputeCompletedModulesProgressHook` (and `createCourseEnrollmentCollection`) gain `progressFieldName`/`statusFieldName`/`completionDateFieldName`/`completedModulesFieldName` options (all default to today's literal property names) — every read/write in the hook now goes through these, so a consumer that renames one of those fields via `fieldOverrides` (e.g. `overallProgress` -> `progress`) no longer gets a hook silently writing to a dead key. - `createCourseEnrollmentCollection({ completionStore: 'completed-modules' })` gains `completedModulesHooks: { autoAwardCertification?: boolean; awardCourseCompletionBadges?: boolean }` (both default `true`) — a consumer running its own gated award pipeline can now drop the factory's unconditional award hooks from the `afterChange` chain. The progress-recompute hook itself is not optional. - `createLessonCollection` gains `syncDisplayContent?: boolean` (default `true`) — a consumer whose own same-named hook does something else entirely can omit the factory's built-in from `beforeChange` instead of positionally slicing the merged array. - `createLessonCollection`, `createSkillPathCollection`, `createCertificationAwardCollection`, and `createCourseEnrollmentCollection` gain a `labels` passthrough option (each defaulting to its existing hardcoded `{ singular, plural }`) — previously only reachable by patching the constructed `CollectionConfig` object consumer-side. `createAcademyCollection` shares the same `labels` hardcode with no knob; left untouched this pass (the Academy conversion is in flight) — flagged for a follow-up.

  • a556cd9: Add three rename/opt-out seams to close hook-shape gaps found by consumer adapters testing against 0.17.0 (L-P5.1), all zero-config byte-identical: - `createRecomputeCompletedModulesProgressHook` (and `createCourseEnrollmentCollection`) gain `progressFieldName`/`statusFieldName`/`completionDateFieldName`/`completedModulesFieldName` options (all default to today's literal property names) — every read/write in the hook now goes through these, so a consumer that renames one of those fields via `fieldOverrides` (e.g. `overallProgress` -> `progress`) no longer gets a hook silently writing to a dead key. - `createCourseEnrollmentCollection({ completionStore: 'completed-modules' })` gains `completedModulesHooks: { autoAwardCertification?: boolean; awardCourseCompletionBadges?: boolean }` (both default `true`) — a consumer running its own gated award pipeline can now drop the factory's unconditional award hooks from the `afterChange` chain. The progress-recompute hook itself is not optional. - `createLessonCollection` gains `syncDisplayContent?: boolean` (default `true`) — a consumer whose own same-named hook does something else entirely can omit the factory's built-in from `beforeChange` instead of positionally slicing the merged array. - `createLessonCollection`, `createSkillPathCollection`, `createCertificationAwardCollection`, and `createCourseEnrollmentCollection` gain a `labels` passthrough option (each defaulting to its existing hardcoded `{ singular, plural }`) — previously only reachable by patching the constructed `CollectionConfig` object consumer-side. `createAcademyCollection` shares the same `labels` hardcode with no knob; left untouched this pass (the Academy conversion is in flight) — flagged for a follow-up.
  • e542ef4: L-P7 — Academy convergence, the LMS convergence program's seventh port. Ports the field-seam discipline established by L-P3/L-P4/L-P5/L-P6 onto Academy; the donor consumer's own `Academies` collection is unmodified (read-only reference). **Field seams.** `collections/Academy.ts` converts from a static `CollectionConfig` export to `createAcademyCollection(config)`, using the same `./collections/shared/{fieldShape,optionOverrides,mergeHooks}.ts` mechanism as the prior four ports. `AcademyCollection` remains exported as `createAcademyCollection()` with every default — byte-identical wiring for `createLmsLayer`, pinned by `tests/academy-characterisation.test.ts` (21 tests, committed separately BEFORE the conversion, verified green against both the pre-port static collection and the post-port factory output — a first pass over-eagerly added a forward `courses` array field to match the donor consumer's shape, and the pins caught it immediately as a byte-identical regression before it shipped). New seams: `mediaRelationTo` (`featuredImage`, default `'media'`), `staffRelationTo` (`directors`/`instructors`/`maintainers` together, default `'members'` — both sides already agree, the seam exists for a future divergent consumer, not to resolve one today), `categoryOptions`/`statusOptions` vocabulary overrides, `extraFields`/`extraFieldsAfter`/`fieldOverrides`/`omitFields`/`fieldOrder`, per-verb `access` override, and a `hooks` seam (merged via `mergeHooks`; the factory ships no built-in hooks — neither side had behavior hooks on the converged core set pre-port). **Small true overlap, the donor consumer's org-specifics stay on seams — neither wins.** Per the design doc, the true overlap is name/slug/staff trio/status/displayOrder — identical, and this is the one pair where MVP already targeted `members`. Everything else is that consumer's org-specifics (rank-gated visibility group, promotion/cert grants, `onboardingPhase`, unit/wing scope) vs MVP's tier-gating (`minimumTierThreshold`/`requiresOrgMembership`/`scopedOrg`) — both survive as optional field groups reachable via seams; neither is promoted to the converged default. `tests/academy-seams.test.ts` includes a dedicated consumer-adoption-shape test that emulates dropping MVP's tier-gating trio via `omitFields` and injecting `onboardingPhase`/`grantsPromotion`/`grantsCertification` via `extraFields` plus a wholesale `categoryOptions` replacement — zero factory changes needed beyond what already ships. **`category` vocabulary — replace, not extend.** MVP's four-value default (onboarding/leadership/specialist/general) and the donor consumer's five-value set (onboarding/wing/unit/leadership/specialist) disagree on `general` vs `wing`/`unit` — not a clean union. `categoryOptions` supports `{ mode: 'replace', options }` for that consumer's wholesale five-value swap, alongside `{ mode: 'extend' }` for additive cases. **Structure call: the reverse `Course.academy` relationship is canonical — MVP already implements it.** Course-scout finding worth recording precisely: MVP's `Course.ts` already carries the reverse `academy` relationship, and `Academy` itself carries NO forward `courses` array — the "reverse relationship over forward array" decision (same junction-vs-array logic as P3/P4, smaller stakes) was already satisfied pre-port; there was nothing to migrate on MVP's side. The donor consumer's OWN forward `courses` hasMany array (`Academies/index.ts:310-317`) does **NOT** come upstream — the design doc explicitly rejects it as the pattern this call replaces. That consumer's adapter carries its forward array via `extraFields` (proven reachable in `tests/academy-seams.test.ts`) until **L-P8 (Courses)** resolves structure and a migration can retire the array in favor of querying through `Course.academy`. Deferred-capability trigger: that retirement (and any read-path rewrite the consumer needs) happens when L-P8 lands Course as a factory, not in this port. **`featuredImage` upload-vs-relationship — no dedicated mechanism, same as SkillPath's icon/badge.** The donor consumer's `featuredImage` is `type: 'upload'`; this factory's default is `type: 'relationship'`. Verified reachable via `fieldOverrides: { featuredImage: { type: 'upload', relationTo: 'media' } }` — no new seam invented. **Academy.js poison status — checked, already clean.** Unlike the five gamification-adjacent siblings (`Achievement`/`Badge`/`Points`/`CourseEnrollment`/`LessonCompletion`) whose barrel-import poisoning motivated the L-P3.1/L-P4/L-P6 subpath remedies, `Academy.ts`'s own module graph has zero gamification dependency and was already loadable under raw Node pre-port (`assert-node-loadable --every-file` shows `./collections/Academy.js`/`.cjs` PASS both before and after this change) — no call-time-import edge fix was needed. **Subpath.** New `./collections/academy` export — exposes `createAcademyCollection`/`AcademyCollection`/`DEFAULT_CATEGORY_OPTIONS` without evaluating the package barrel (same poisoned-barrel remedy as the prior three subpaths, offered as an alternate door since Academy's own graph was already clean). **Tests.** `tests/academy-characterisation.test.ts` (21 — identity, access wiring, every top-level field default including the staff trio and the tier-gating/scopedOrg stub, plus a full-field-set snapshot), `tests/academy-seams.test.ts` (17 — every config seam reaches the constructed `CollectionConfig`, including the consumer-adoption-shape stand-in and the featuredImage upload-reachability proof), `tests/academy-subpath-loadable.test.ts` (5 — built-dist loadability under raw Node, ESM+CJS, exports-map entry, symbol surface). 558 → 622 lms tests green (main's pre-port baseline was 558 passed / 17 skipped; this branch's 622-pass, 0-skip figure also reflects the other subpath-loadable suites resolving from their unbuilt-dist SKIP placeholder to their real dynamic pass count once `dist/` exists locally — not a regression, an artifact of running against a freshly built package). This port's own new tests: `tests/academy-characterisation.test.ts` (21), `tests/academy-seams.test.ts` (17), `tests/academy-subpath-loadable.test.ts` (5 once built). Build clean, typecheck clean, `assert-node-loadable --every-file`: 210 pass / 14 pre-existing skip / 0 fail (no new skips — both new `collections/academy` subpath dist files pass clean). Default exports-map mode: 32 pass / 4 pre-existing skip (barrel + `./server`, both intentionally poisoned doors) / 0 fail. `LMS_LAYER_VERSION` and `package.json` version are NOT bumped in this changeset — versioning is the release process's job, not the builder's.
v0.17.0minor

eaa9d4e: L-P4 — SkillPath convergence, the LMS convergence program's second port. Ports the field-seam discipline established by L-P3's CertificationAward conversion onto SkillPath; the donor consumer's own `SpecialistPaths` collection is unmodified (read-only reference). **Field seams.** `collections/SkillPath.ts` converts from a static `CollectionConfig` export to `createSkillPathCollection(config)`, using the same `./collections/shared/{fieldShape,optionOverrides,mergeHooks}.ts` mechanism as `createCertificationAwardCollection`. `SkillPathCollection` remains exported as `createSkillPathCollection()` with every default — byte-identical wiring for `createLmsLayer`, pinned by `tests/skill-path-characterisation.test.ts` (21 tests, committed separately BEFORE the conversion, verified green against both the pre-port static collection and the post-port factory output). New seams: `mediaRelationTo` (icon + tiers[].badge, default `'media'`), `certificationRelationTo`/`courseRelationTo` (tiers[].requirements[], defaults `'certifications'`/`'courses'`), `statusOptions`/`requirementTypeOptions` vocabulary overrides, `extraFields`/`extraFieldsAfter`/`fieldOverrides`/`omitFields`/`fieldOrder`, per-verb `access` override, and a `hooks` seam (merged via `mergeHooks`, though the factory ships no built-in hooks — neither side had behavior hooks on this pair pre-port). **Read-access default — NOT flipped in this release.** The design doc's specified end-state is the donor consumer's `read: authenticated` posture (`publicRead` as an explicit opt-down). This port does NOT flip the zero-config default: EDU currently consumes `skill-paths` with public read and no site-side access override, so flipping the default here would be a breaking, silent behavior change for an existing zero-config consumer. `createSkillPathCollection` keeps `read: publicRead` as the 0.x default and adds the standard per-verb `access` seam — a consumer reaches the specified posture via `access: { read: authenticatedOnly }` with zero factory changes. **`@wabbit/tome-lms` 1.0.0 will flip this default to `authenticatedOnly`** per the design doc — tracked as a deliberate, deferred breaking change for the 1.0.0 cut (L-P10), not a rejection of that decision. **Relation/scoping seam — no new mechanism invented.** The design doc frames the donor consumer's `unit` (relationship → units, required, indexed) as generalizing MVP's `scopedOrg` composition stub. A required indexed relationship is just a `Field` object: that consumer's adoption is `omitFields: ['scopedOrg']` + `extraFields: [{ name: 'unit', type: 'relationship', relationTo: 'units', required: true, index: true }]` — no dedicated `scopedOrgField`-style factory option was added. `expertTierThreshold` (the donor consumer renames at adoption; its SMEDesignations collection keeps consuming the threshold through its own hook, unaffected by this port) was already a plain named field and is override-reachable today via `fieldOverrides: { expertTierThreshold: {...} }` — no new seam needed. The donor consumer's `icon`/`tiers[].badge` fields are `type: 'upload'` rather than this package's `type: 'relationship'` — a structural divergence the design doc does not call a convergence target, left as a `fieldOverrides` job for that consumer's adoption pass, not a new seam. **Subpath.** New `./collections/skillPath` export — exposes `createSkillPathCollection`/`SkillPathCollection` without evaluating the package barrel (same poisoned-barrel remedy as L-P3.1's `./collections/certificationAward`; SkillPath's own module graph has no gamification dependency and was already clean). **Tests.** `tests/skill-path-characterisation.test.ts` (21 — identity, access wiring, every top-level field default, the tiers[].requirements[] sub-schema, plus a full-field-set snapshot), `tests/skill-path-seams.test.ts` (15 — every config seam reaches the constructed `CollectionConfig`, including a stand-in proving the donor consumer's unit-scoping needs no dedicated mechanism, and the read-access seam reaching `authenticatedOnly`), `tests/skill-path-subpath-loadable.test.ts` (5 — built-dist loadability under raw Node, ESM+CJS, exports-map entry, symbol surface). 420 → 461 lms tests green (41 new). Build clean, typecheck clean, `assert-node-loadable --every-file`: 192 pass / 24 pre-existing skip / 0 fail (no new skips; the two new `skillPath` subpath dist files both pass clean). Default exports-map mode: 26 pass / 4 pre-existing skip / 0 fail. `LMS_LAYER_VERSION` and `package.json` version are NOT bumped in this changeset — versioning is the release process's job, not the builder's (per the two-double-bump incident on prior ports).

  • eaa9d4e: L-P4 — SkillPath convergence, the LMS convergence program's second port. Ports the field-seam discipline established by L-P3's CertificationAward conversion onto SkillPath; the donor consumer's own `SpecialistPaths` collection is unmodified (read-only reference). **Field seams.** `collections/SkillPath.ts` converts from a static `CollectionConfig` export to `createSkillPathCollection(config)`, using the same `./collections/shared/{fieldShape,optionOverrides,mergeHooks}.ts` mechanism as `createCertificationAwardCollection`. `SkillPathCollection` remains exported as `createSkillPathCollection()` with every default — byte-identical wiring for `createLmsLayer`, pinned by `tests/skill-path-characterisation.test.ts` (21 tests, committed separately BEFORE the conversion, verified green against both the pre-port static collection and the post-port factory output). New seams: `mediaRelationTo` (icon + tiers[].badge, default `'media'`), `certificationRelationTo`/`courseRelationTo` (tiers[].requirements[], defaults `'certifications'`/`'courses'`), `statusOptions`/`requirementTypeOptions` vocabulary overrides, `extraFields`/`extraFieldsAfter`/`fieldOverrides`/`omitFields`/`fieldOrder`, per-verb `access` override, and a `hooks` seam (merged via `mergeHooks`, though the factory ships no built-in hooks — neither side had behavior hooks on this pair pre-port). **Read-access default — NOT flipped in this release.** The design doc's specified end-state is the donor consumer's `read: authenticated` posture (`publicRead` as an explicit opt-down). This port does NOT flip the zero-config default: EDU currently consumes `skill-paths` with public read and no site-side access override, so flipping the default here would be a breaking, silent behavior change for an existing zero-config consumer. `createSkillPathCollection` keeps `read: publicRead` as the 0.x default and adds the standard per-verb `access` seam — a consumer reaches the specified posture via `access: { read: authenticatedOnly }` with zero factory changes. **`@wabbit/tome-lms` 1.0.0 will flip this default to `authenticatedOnly`** per the design doc — tracked as a deliberate, deferred breaking change for the 1.0.0 cut (L-P10), not a rejection of that decision. **Relation/scoping seam — no new mechanism invented.** The design doc frames the donor consumer's `unit` (relationship → units, required, indexed) as generalizing MVP's `scopedOrg` composition stub. A required indexed relationship is just a `Field` object: that consumer's adoption is `omitFields: ['scopedOrg']` + `extraFields: [{ name: 'unit', type: 'relationship', relationTo: 'units', required: true, index: true }]` — no dedicated `scopedOrgField`-style factory option was added. `expertTierThreshold` (the donor consumer renames at adoption; its SMEDesignations collection keeps consuming the threshold through its own hook, unaffected by this port) was already a plain named field and is override-reachable today via `fieldOverrides: { expertTierThreshold: {...} }` — no new seam needed. The donor consumer's `icon`/`tiers[].badge` fields are `type: 'upload'` rather than this package's `type: 'relationship'` — a structural divergence the design doc does not call a convergence target, left as a `fieldOverrides` job for that consumer's adoption pass, not a new seam. **Subpath.** New `./collections/skillPath` export — exposes `createSkillPathCollection`/`SkillPathCollection` without evaluating the package barrel (same poisoned-barrel remedy as L-P3.1's `./collections/certificationAward`; SkillPath's own module graph has no gamification dependency and was already clean). **Tests.** `tests/skill-path-characterisation.test.ts` (21 — identity, access wiring, every top-level field default, the tiers[].requirements[] sub-schema, plus a full-field-set snapshot), `tests/skill-path-seams.test.ts` (15 — every config seam reaches the constructed `CollectionConfig`, including a stand-in proving the donor consumer's unit-scoping needs no dedicated mechanism, and the read-access seam reaching `authenticatedOnly`), `tests/skill-path-subpath-loadable.test.ts` (5 — built-dist loadability under raw Node, ESM+CJS, exports-map entry, symbol surface). 420 → 461 lms tests green (41 new). Build clean, typecheck clean, `assert-node-loadable --every-file`: 192 pass / 24 pre-existing skip / 0 fail (no new skips; the two new `skillPath` subpath dist files both pass clean). Default exports-map mode: 26 pass / 4 pre-existing skip / 0 fail. `LMS_LAYER_VERSION` and `package.json` version are NOT bumped in this changeset — versioning is the release process's job, not the builder's (per the two-double-bump incident on prior ports).
  • a2048a3: L-P5 — CourseEnrollment convergence, the LMS convergence program's D-1 model swap. Ports the donor consumer's battle-tested `completedModules[]` completion model onto this package's field seams, additively; that consumer's own collections are unmodified (read-only reference). **Poison-edge fix.** `hooks/progress/awardCourseCompletionBadges.ts` and `hooks/progress/onLessonCompletion.ts` statically imported `../../utilities/gamification`, which re-exports `@wabbit/tome-gamification`'s main barrel — whose own `utilities/gamification.ts` does `import { getPointsBalance } from '../server'`, a relative import that reaches its `server-only`-guarded module unconditionally, throwing under plain Node regardless of entry point (the defect is inside `@wabbit/tome-gamification`, untouched here). Both imports are now deferred call-time `import()`s. `CourseEnrollment.js`/`.cjs`, `LessonCompletion.js`/`.cjs`, and the `hooks/progress` barrel move SKIP → PASS under `assert-node-loadable --every-file` (package-wide: 24 → 14 pre-existing skips, all now isolated to `Achievement`/`Badge`/`Points`/`server/learnerShell`/the root barrel — all inside `@wabbit/tome-gamification`'s poisoned import, out of scope). **Field seams.** `collections/CourseEnrollment.ts` converts from a static `CollectionConfig` export to `createCourseEnrollmentCollection(config)`, following the same factory + fieldShape discipline as `createCertificationAwardCollection` (L-P3). `CourseEnrollmentCollection` remains exported as `createCourseEnrollmentCollection()` with every default — byte-identical to the pre-port collection (pinned in `tests/course-enrollment-characterisation.test.ts`, written and verified green against the _unmodified_ collection before this port landed). New seams: `studentRelationTo`/`courseRelationTo`/`lessonRelationTo`; `extraFields`/`extraFieldsAfter`/`fieldOverrides`/`omitFields`/`fieldOrder`; per-verb `access` override merged onto the factory's own defaults; `hooks` override APPENDED (never replacing) via `mergeHooks`. **The model swap (additive in 0.x).** New `completionStore: 'lesson-completions' | 'completed-modules'` option, default `'lesson-completions'` — unchanged: `computeProgress` still recomputes `overallProgress` from the global `LessonCompletion` collection, and `completedModules` is NOT added to the schema (that omission is what keeps the zero-config default byte-identical). `'completed-modules'` mode adds the donor consumer's `completedModules[]` array field, ported field-for-field: `moduleId`, `type` (7-value enum including `path_choice` and `ojt_signoff`, injectable via `completedModuleTypeOptions`), `pathChoiceLabel`/`pathChoiceCourse`, `submissionStatus` (default `pass`), `score`, `instructorFeedback`, `submissionReference`, `questionResults`, `completionDate`, per-module `cooldownExpiry`, `attemptCount`, `lockedOut`, `overriddenBy`/`overrideReason`, `bypassApproved`/`bypassRequest` — with relation seams for `pathChoiceCourseRelationTo`/`formSubmissionRelationTo`/`moduleOverriddenByRelationTo`/`examBypassRequestRelationTo`. The top-level MVP-only fields (`completionMode`, `accessTier`, `expiresAt`, `lastAccessDate`, enrollment-wide `cooldownExpiry`, `status: 'expired'`) stay in both modes — different granularity from the per-module cooldown, both real, no collision. **Leaf-routed recompute.** In `'completed-modules'` mode, the progress-recompute hook (`createRecomputeCompletedModulesProgressHook`, `hooks/progress/`) REPLACES `computeProgress` and routes its math through the Wave 8 `enrollmentProgress` leaf (`utilities/enrollmentProgress.ts`) instead of querying `LessonCompletion` — same never-regress-a-completion hold and zero-denominator hold the leaf already carried. No leaf signature change was needed: the upstream `recomputeEnrollmentProgress.ts` math was already a flat pass-count/total ratio (no per-module weighting), which the leaf already expresses exactly. The completable-lesson-id resolver defaults to this package's CourseItem-junction curriculum walk (`flattenCurriculumTree`) intersected with `Lesson.isPublished`, narrower than the donor consumer's three-flag (required/published/non-archived) filter since this package's `Lesson` schema doesn't yet carry the other two flags — documented as an honest divergence with a trigger condition, and overridable via `resolveCompletableLessonIds`. **Enforcement (design principle 2: "the donor consumer's model under MVP's enforcement").** Collection-level `update` access is `systemOrAdmin` in BOTH modes (unchanged from pre-port). `completedModules` additionally carries a field-level ACL (new `systemOrAdminField` export in `access/index.ts`, a `FieldAccess`-typed sibling of `systemOrAdmin`) — that consumer's `isAdminFieldLevel` convention re-expressed as this package's system-or-admin pattern, making its convention-based 8-write-site contract structural instead of conventional. A consumer's own server actions write via `overrideAccess: true` or `req.context.internal = true`, per this package's existing system-write convention. **Subpath.** `./collections/courseEnrollment` — a clean leaf subpath mirroring the L-P3.1 `certificationAward` precedent, re-exporting the factory, the static default, `DEFAULT_COMPLETED_MODULE_TYPE_OPTIONS`, and `createRecomputeCompletedModulesProgressHook`. Kept as a barrel-independent door even though `CourseEnrollment.js` itself now loads cleanly without it (the poison-edge fix was applied at its source, not routed around) — the package's root barrel is still poisoned by unrelated siblings. **Tests.** `tests/course-enrollment-characterisation.test.ts` (26, committed separately BEFORE any behavior change, verified green against both the pre-port static collection and the post-port factory's zero-config output) pins identity, access wiring, the `uniquePair` hook, `computeProgress`'s LessonCompletion-derived recompute + loop guard + completion stamp, and every field's shape. New-behavior suites: `course-enrollment-completed-modules.test.ts` (19 — field shape/relation-seam/option-override coverage for `completedModules`, the afterChange hook swap, and the leaf-routed recompute's basic correctness/never-regress/zero-denominator/loop-guard/custom-slug/error-swallow behavior) and `course-enrollment-subpath-loadable.test.ts` (6 — every built subpath file loads under raw Node, plus confirms the non-subpath `CourseEnrollment.js` door is independently clean). 420 → 471 lms tests green (51 new). Build clean, typecheck clean, `assert-node-loadable --every-file`: 204 pass / 14 pre-existing skip / 0 fail (no new skips; the known-24 shrank to 14).
  • 2284a7b: L-P6 — Lesson convergence, the LMS convergence program's fourth port. Ports the donor consumer's field-seam-reachable union fields onto this package's factory discipline, additively; that consumer's own `Lessons` collection and its site-specific access implementations are unmodified (read-only reference). **Factory conversion.** `collections/Lesson.ts` converts from a static `CollectionConfig` export to `createLessonCollection(config)`, using the same `./collections/shared/{fieldShape,optionOverrides,mergeHooks}.ts` mechanism as `createCertificationAwardCollection` (L-P3) and `createSkillPathCollection` (L-P4). `LessonCollection` remains exported as `createLessonCollection()` with every default, pinned by `tests/lesson-characterisation.test.ts` (31 tests, committed separately BEFORE the conversion, verified green against both the pre-port static collection and the post-port factory output) — with one deliberate, documented exception: the `lessonType` default vocabulary. New seams: `formCollectionRelationTo` (`quizForm`/`assignmentForm` + draft counterparts, default `'lessons'` self-reference pending D-2b), `sourceDocumentsRelationTo` (default `'media'`), `reviewedByRelationTo` (default `'users'`), `certificationRelationTo`/`courseRelationTo`/`topicRelationTo`/`lessonRelationTo` (default this package's own slugs), `extraFields`/`extraFieldsAfter`/`fieldOverrides`/`omitFields`/`fieldOrder`, per-verb `access` override, and a `hooks` seam merged via `mergeHooks`. **Enum-union default (breaking-in-shape, not breaking-in-behavior).** `lessonTypeOptions` is now injectable, defaulting to the UNION of MVP's six values and the donor consumer's `path_choice` — the design doc's explicit call ("MVP's enum is missing an option the donor consumer load-bears on"). Unlike every other L-P3/L-P4/L-P5 default, this one is NOT byte-identical to pre-port: the zero-config `lessonType` field now offers seven options where it offered six. `tests/lesson-type.test.ts`'s pre-existing closed-set pin was updated in this port to include `path_choice` (documented inline as the one intentional exception). `pathOptions`/`draftPathOptions` (the donor consumer's "Choose Your Path" fields) join the field set only when `path_choice` is present in the resolved vocabulary — a consumer that replaces the vocabulary and excludes `path_choice` gets no dangling path-choice UI. `requiredCertification` and `instructorNotes` (from the donor consumer) join UNCONDITIONALLY — both optional/harmless-when-unused, per the design doc's explicit call not to gate them. **Field-level access — no new mechanism invented.** The design doc asks that content-body fields' access be injectable so the donor consumer's `canReadLessonBody`/`canReadLessonInstructorContent` gates are expressible. L-P4 already established that the shared `fieldOverrides` seam (post-construction, per-field, merges `access` one level deep) is sufficient for this without a bespoke option — `fieldOverrides: { content: { access: { read: canReadLessonBody } }, instructorNotes: { access: { read: canReadLessonInstructorContent } } }` reaches every body field this way. No field on this collection carries a field-level access default pre- or post-port (byte-identical: nothing was gated before, nothing is gated now, unless a consumer opts in). **`workflowStatus` — folded (0.x default) vs orthogonal.** New `publishStateMode: 'folded' | 'orthogonal'` option, default `'folded'` — the current 5-value enum (`draft/pending_review/changes_requested/approved/published`) is UNCHANGED, and `workflowStatusOptions` is independently injectable on top of it. `'orthogonal'` swaps to the donor consumer's 4-value enum (`'archived'` replaces `'published'`) and decouples `isPublished` from the enum entirely — the design doc's specified 1.0.0 end-state ("MVP's 5th `published` value conflates workflow with publish state and loses the distinction the donor consumer's invariant hooks depend on"). **`@wabbit/tome-lms` 1.0.0 will flip `publishStateMode`'s default to `'orthogonal'`** — the same deferred-breaking-change pattern as SkillPath's read-access default flip (L-P4) and CourseEnrollment's model default (L-P5), tracked here, not executed in this port. `syncDisplayContent`'s publish TRIGGER is mode-dependent, since orthogonal mode has no `'published'` value to key off: folded mode keeps the byte-identical `workflowStatus === 'published'` trigger; orthogonal mode triggers on `isPublished` transitioning to `true` in the same write. This is an honest ADAPTATION, not a literal port — the donor consumer's real publish path is `syncDraftToLive()`, an imperative function called by a site-layer publish action, not a collection hook; porting that action is out of scope for a collection-factory port. That consumer's `enforceArchiveInvariant`/`enforcePendingReviewInvariant` cross-field invariants stay consumer-side, attached via the `hooks` merge seam — they encode its own terminal-state policy on top of the converged schema, not a converged-schema requirement. **`versions` — none (default) vs snapshot-history.** New `versionsMode: 'none' | 'snapshot-history'` option, default `'none'` (current — Payload versions stay off, unchanged). `'snapshot-history'` emits the donor consumer's hand-rolled history posture verbatim: `versions: { drafts: false, maxPerDoc: 25 }` (constant `LESSON_VERSIONS_MAX_PER_DOC`, that consumer's production-measurement rationale comment ported forward in full — see the constant's header), tunable per-site via `versionsMaxPerDoc`. `drafts: false` stays load-bearing for the same reason on this side of the fork: Lesson already runs its own hand-rolled draft rail on the `draft*` shadow fields, and Payload drafts would stand up a second, competing draft concept. When `versionsMode: 'snapshot-history'`, a `readVersions` access default (`instructorOrHigher`) is wired — Payload resolves an undefined `readVersions` to "any authenticated user" once versions are enabled, a trap the donor consumer's own code comments call out — independently injectable via `access.readVersions` so its `canReadLessonVersions` reaches it without a factory change. **Access — public read kept as the 0.x default.** Every verb (`read`/`create`/`update`/`delete`/`readVersions`) is injectable via `config.access`, merged on top of the factory's current defaults (`lessonRead` — public — for read, `instructorOrHigher` for create/update, `adminOnly` for delete). The design doc's specified end-state is the donor consumer's layered row+field posture (`canReadLesson` + `canReadLessonBody`/`canReadLessonInstructorContent`) as the factory DEFAULT; this port does NOT flip it — that consumer's access implementations are deeply site-specific (its own course-staffing/enrollment resolvers) and stay consumer-side entirely, reachable today via `access: { read: canReadLesson }` + the `fieldOverrides` seam with zero factory changes. **`@wabbit/tome-lms` 1.0.0 will flip the `read` default to a layered posture** — tracked here for the 1.0.0 cut, not executed in this port. `createLmsLayer`'s existing `lessonReadAccess: 'enrollment-gated'` registration-time rewrite (`applyLessonReadAccess`) is unaffected — it still finds a `lessons`-slug collection with a `read` key to swap. **Course/topic/order fields do NOT join the converged set.** The donor consumer's direct `Lesson.course`/`Lesson.topic`/`Lesson.order` fields are superseded by the already-settled P3 design call (CourseItem junction as canonical course structure) — Lesson stays course-agnostic both pre- and post-port; course membership lives on CourseItem rows. **Subpath.** New `./collections/lesson` export — exposes `createLessonCollection`/`LessonCollection` plus the option-vocabulary constants without evaluating the package barrel (same poisoned-barrel remedy as L-P3.1's `certificationAward` and L-P4's `skillPath` subpaths; Lesson's own module graph has no gamification dependency and was already clean). **Tests.** `tests/lesson-characterisation.test.ts` (31 — identity, access wiring incl. public read, hook wiring, dual-track shadow-field pairing, relationship targets, compass/visibility fields, and `syncDisplayContent`'s publish-transition behavior; committed separately before the conversion, verified green pre- and post-port), `tests/lesson-seams.test.ts` (31 — the enum-union default and its gated `pathOptions`, `requiredCertification`/`instructorNotes` unconditional joins, field-level access via `fieldOverrides`, every relation-target seam, per-verb access injection, `publishStateMode: 'orthogonal'`'s 4-value enum + decoupled publish trigger, `versionsMode: 'snapshot-history'`'s config + `readVersions` default, and hook-array merging), `tests/lesson-subpath-loadable.test.ts` (5 — built-dist loadability under raw Node, ESM+CJS, exports-map entry, symbol surface). `tests/lesson-type.test.ts`'s pre-existing closed-set pin was updated (documented inline) for the one intentional default change. 420 → 487 lms tests green (67 new). Build clean, typecheck clean, `assert-node-loadable --every-file`: 192 pass / 24 pre-existing skip / 0 fail (no new skips; the two new `lesson` subpath dist files both pass clean). Default exports-map mode: 26 pass / 4 pre-existing skip / 0 fail. `LMS_LAYER_VERSION` and `package.json` version are NOT bumped in this changeset — versioning is the release process's job, not the builder's.
v0.16.0minor

99889d1: Three additive seams for the consumer cert-adoption leg (L-P3.1): - New `./collections/certificationAward` subpath — exposes `createCertificationAwardCollection`, `CertificationAwardCollection`, both cert-award context-flag constants, and every cert-workflow hook factory, without evaluating the package barrel. The barrel transitively evaluates `server-only` (via the `Achievement`/`Badge`/`Points`/`CourseEnrollment`/`LessonCompletion` re-exports reaching `@wabbit/tome-gamification`'s own poisoned-barrel leaf), which throws under plain Node/tsx — killing `payload generate:types` and any other config-graph tooling that needs CertificationAward without a react-server condition. `CertificationAward`'s own module graph was already clean; this subpath is the door that proves it and keeps it that way. - `calculateExpiryDate` / `createCalculateExpiryDate` gain a `validityPath` option — a dot-path prefix (e.g. `'validity'`) resolving `expiresField`/`validityPeriodField` off a nested object instead of the certification doc's top level, for a consumer whose Certification schema nests them under `validity.{expires,validityPeriod}`. Unset (default) is byte-identical to the pre-existing flat-field behavior; an unresolvable path is a safe no-op (treated as non-expiring), never a throw. - `createUpdateMemberCerts` / `createAfterDeleteMemberCerts` gain an `onSynced(memberId, req)` callback, invoked once a member-cert sync succeeds (never on failure, alongside — not instead of — `onSyncFailure`). Lets a consumer bust its own cache (or run any other success-side effect) after the roster denormalization writes, without forking the hook.

  • 99889d1: Three additive seams for the consumer cert-adoption leg (L-P3.1): - New `./collections/certificationAward` subpath — exposes `createCertificationAwardCollection`, `CertificationAwardCollection`, both cert-award context-flag constants, and every cert-workflow hook factory, without evaluating the package barrel. The barrel transitively evaluates `server-only` (via the `Achievement`/`Badge`/`Points`/`CourseEnrollment`/`LessonCompletion` re-exports reaching `@wabbit/tome-gamification`'s own poisoned-barrel leaf), which throws under plain Node/tsx — killing `payload generate:types` and any other config-graph tooling that needs CertificationAward without a react-server condition. `CertificationAward`'s own module graph was already clean; this subpath is the door that proves it and keeps it that way. - `calculateExpiryDate` / `createCalculateExpiryDate` gain a `validityPath` option — a dot-path prefix (e.g. `'validity'`) resolving `expiresField`/`validityPeriodField` off a nested object instead of the certification doc's top level, for a consumer whose Certification schema nests them under `validity.{expires,validityPeriod}`. Unset (default) is byte-identical to the pre-existing flat-field behavior; an unresolvable path is a safe no-op (treated as non-expiring), never a throw. - `createUpdateMemberCerts` / `createAfterDeleteMemberCerts` gain an `onSynced(memberId, req)` callback, invoked once a member-cert sync succeeds (never on failure, alongside — not instead of — `onSyncFailure`). Lets a consumer bust its own cache (or run any other success-side effect) after the roster denormalization writes, without forking the hook.
v0.15.0minor

d0e5b36: L-P3 — CertificationAward convergence, the LMS convergence program's first port. Ports the donor consumer's battle-tested CertificationAwards behavior onto this package's field seams; that consumer's own collections are unmodified (read-only reference). **Field seams.** `collections/CertificationAward.ts` converts from a static `CollectionConfig` export to `createCertificationAwardCollection(config)`, following the org layer's factory + fieldShape discipline (`./collections/shared/{fieldShape,optionOverrides,mergeHooks}.ts` — local ports of `@wabbit/tome-org`'s mechanism, not a cross-package dependency, since LMS treats org as an optional composition peer). `CertificationAwardCollection` remains exported as `createCertificationAwardCollection()` with every default — byte-identical wiring for `createLmsLayer`. New seams: `defaultApprovalStatus` (factory default stays `'pending_approval'`); `awardingMethodOptions` (default: the UNION of MVP's and the donor consumer's vocabularies — course_completion, academy_completion, manual_grant, legacy_import, founding_instructor, exam_pass); `recipientRelationTo`/`awardedByRelationTo`/`revokedByRelationTo`/`certificationRelationTo`/`courseEnrollmentRelationTo`/`courseRelationTo`/`trainingEventRelationTo` (awardedBy defaults `'users'`; the donor consumer points it at `'members'`); `extraFields`/`extraFieldsAfter`/`fieldOverrides`/`omitFields`/`fieldOrder`; per-verb `access` override merged onto the factory's own MVP-tier defaults; `hooks` override APPENDED (never replacing) via `mergeHooks`. `awardDate` is now REQUIRED (the donor consumer's call — an award without a date is a data bug). MVP's `status` + revocation trio (`revokedBy`/`revokedAt`/`revocationReason`) is kept — that consumer gains a revocation model it lacked. **Behavior ports.** - `updateCertificationHolderCount` (afterChange AND afterDelete — the afterDelete hook did not exist before) REPLACES the ±1 row-delta with the donor consumer's distinct-recipient full recount: paginated `payload.find` (never `limit: 0`), counting DISTINCT recipients not award rows. Incident rationale ported verbatim in the hook's header (a certification read 822 against 774 actual holders — the ±1 delta reproduces this class of bug the moment any recipient holds more than one award row for the same certification, e.g. a retake or a legacy import alongside a later completion). Failure handling does NOT import Sentry — an injectable `onSyncFailure` callback is the seam a consumer wires to its own error tracking. - `updateMemberCerts` REPLACES the debug-log stub with the donor consumer's real re-derivation (full effective-award-set recompute, not a delta), shipped as an OPT-IN factory (`memberCertSync`, default `false` — a site whose Member collection lacks `certificationAwards`/`certifications` array fields pays nothing). Honors a `reconcilerPass` context-flag bypass (`CERT_AWARD_RECONCILER_PASS_CONTEXT_FLAG`, afterChange only — matches the reference implementation, since a delete is never part of a bulk-insert storm) so a migration/reconciliation script can batch its own sync instead of triggering one write per inserted row. Same no-Sentry `onSyncFailure` seam. - `calculateExpiryDate` is now a REAL beforeChange hook — the `expiresAt` field comment claimed this behavior since before this package existed as a factory; nothing implemented it. Adapted to this package's own Certification schema (flat `expires`/`validityPeriod`, not the donor consumer's nested `validity` group) with a field-name seam for a consumer whose shape diverges further. - `enforceApprovalStatus` merges trivially (same logic both sides); the bypass context-flag names unify onto ONE factory-exported constant (`CERT_AWARD_SYSTEM_BYPASS_CONTEXT_FLAG = 'internal'`, this package's pre-existing single-flag convention — the donor consumer's two flags, `systemAutoAward`/`migrationBackfill`, converge onto it at that consumer's adoption pass). Create-only semantics unchanged. - `autoNominateExpert` stays wired unconditionally (MVP-only, already a safe no-op absent a matching SkillPath). - The donor consumer's own fan-outs (notifyCertApproval, cancelObsoleteExamTickets, resolveRenewalTasks, SME nomination) do NOT come upstream — a consumer appends them via `config.hooks`, verified to merge rather than replace. **Tests.** `tests/cert-award-characterisation.test.ts` (17 tests, committed separately BEFORE any behavior change, verified green against both the pre-port static collection and the post-port factory output) pins what's kept: access wiring, relationship-target defaults, the status+revocation trio, approvalStatus/renewalStatus defaults, enforceApprovalStatus's create-only gate. New-behavior suites: `cert-holder-count-recount.test.ts` (12 — multi-row-per-recipient recount correctness, afterDelete recount, pagination/truncation, failure-callback isolation), `cert-member-sync.test.ts` (9 — full re-derivation, reconcilerPass bypass scoped to afterChange only, field-name seam), `cert-expiry-calc.test.ts` (12 — computation, idempotency guard, schema seam), `cert-award-seams.test.ts` (19 — every config option reaches the constructed `CollectionConfig`, including a stand-in proving a wing/unit-scoped instructor `access.update` override is reachable with ZERO factory changes, and that hook-array merging appends rather than replaces). 335 → 404 lms tests green (69 new). Build clean, typecheck clean, `assert-node-loadable --every-file`: 188 pass / 24 pre-existing skip / 0 fail (no new skips). `LMS_LAYER_VERSION` bumped to 0.15.0 in the same commit as this changeset, per the layer-version pin test.

  • d0e5b36: L-P3 — CertificationAward convergence, the LMS convergence program's first port. Ports the donor consumer's battle-tested CertificationAwards behavior onto this package's field seams; that consumer's own collections are unmodified (read-only reference). **Field seams.** `collections/CertificationAward.ts` converts from a static `CollectionConfig` export to `createCertificationAwardCollection(config)`, following the org layer's factory + fieldShape discipline (`./collections/shared/{fieldShape,optionOverrides,mergeHooks}.ts` — local ports of `@wabbit/tome-org`'s mechanism, not a cross-package dependency, since LMS treats org as an optional composition peer). `CertificationAwardCollection` remains exported as `createCertificationAwardCollection()` with every default — byte-identical wiring for `createLmsLayer`. New seams: `defaultApprovalStatus` (factory default stays `'pending_approval'`); `awardingMethodOptions` (default: the UNION of MVP's and the donor consumer's vocabularies — course_completion, academy_completion, manual_grant, legacy_import, founding_instructor, exam_pass); `recipientRelationTo`/`awardedByRelationTo`/`revokedByRelationTo`/`certificationRelationTo`/`courseEnrollmentRelationTo`/`courseRelationTo`/`trainingEventRelationTo` (awardedBy defaults `'users'`; the donor consumer points it at `'members'`); `extraFields`/`extraFieldsAfter`/`fieldOverrides`/`omitFields`/`fieldOrder`; per-verb `access` override merged onto the factory's own MVP-tier defaults; `hooks` override APPENDED (never replacing) via `mergeHooks`. `awardDate` is now REQUIRED (the donor consumer's call — an award without a date is a data bug). MVP's `status` + revocation trio (`revokedBy`/`revokedAt`/`revocationReason`) is kept — that consumer gains a revocation model it lacked. **Behavior ports.** - `updateCertificationHolderCount` (afterChange AND afterDelete — the afterDelete hook did not exist before) REPLACES the ±1 row-delta with the donor consumer's distinct-recipient full recount: paginated `payload.find` (never `limit: 0`), counting DISTINCT recipients not award rows. Incident rationale ported verbatim in the hook's header (a certification read 822 against 774 actual holders — the ±1 delta reproduces this class of bug the moment any recipient holds more than one award row for the same certification, e.g. a retake or a legacy import alongside a later completion). Failure handling does NOT import Sentry — an injectable `onSyncFailure` callback is the seam a consumer wires to its own error tracking. - `updateMemberCerts` REPLACES the debug-log stub with the donor consumer's real re-derivation (full effective-award-set recompute, not a delta), shipped as an OPT-IN factory (`memberCertSync`, default `false` — a site whose Member collection lacks `certificationAwards`/`certifications` array fields pays nothing). Honors a `reconcilerPass` context-flag bypass (`CERT_AWARD_RECONCILER_PASS_CONTEXT_FLAG`, afterChange only — matches the reference implementation, since a delete is never part of a bulk-insert storm) so a migration/reconciliation script can batch its own sync instead of triggering one write per inserted row. Same no-Sentry `onSyncFailure` seam. - `calculateExpiryDate` is now a REAL beforeChange hook — the `expiresAt` field comment claimed this behavior since before this package existed as a factory; nothing implemented it. Adapted to this package's own Certification schema (flat `expires`/`validityPeriod`, not the donor consumer's nested `validity` group) with a field-name seam for a consumer whose shape diverges further. - `enforceApprovalStatus` merges trivially (same logic both sides); the bypass context-flag names unify onto ONE factory-exported constant (`CERT_AWARD_SYSTEM_BYPASS_CONTEXT_FLAG = 'internal'`, this package's pre-existing single-flag convention — the donor consumer's two flags, `systemAutoAward`/`migrationBackfill`, converge onto it at that consumer's adoption pass). Create-only semantics unchanged. - `autoNominateExpert` stays wired unconditionally (MVP-only, already a safe no-op absent a matching SkillPath). - The donor consumer's own fan-outs (notifyCertApproval, cancelObsoleteExamTickets, resolveRenewalTasks, SME nomination) do NOT come upstream — a consumer appends them via `config.hooks`, verified to merge rather than replace. **Tests.** `tests/cert-award-characterisation.test.ts` (17 tests, committed separately BEFORE any behavior change, verified green against both the pre-port static collection and the post-port factory output) pins what's kept: access wiring, relationship-target defaults, the status+revocation trio, approvalStatus/renewalStatus defaults, enforceApprovalStatus's create-only gate. New-behavior suites: `cert-holder-count-recount.test.ts` (12 — multi-row-per-recipient recount correctness, afterDelete recount, pagination/truncation, failure-callback isolation), `cert-member-sync.test.ts` (9 — full re-derivation, reconcilerPass bypass scoped to afterChange only, field-name seam), `cert-expiry-calc.test.ts` (12 — computation, idempotency guard, schema seam), `cert-award-seams.test.ts` (19 — every config option reaches the constructed `CollectionConfig`, including a stand-in proving a wing/unit-scoped instructor `access.update` override is reachable with ZERO factory changes, and that hook-array merging appends rather than replaces). 335 → 404 lms tests green (69 new). Build clean, typecheck clean, `assert-node-loadable --every-file`: 188 pass / 24 pre-existing skip / 0 fail (no new skips). `LMS_LAYER_VERSION` bumped to 0.15.0 in the same commit as this changeset, per the layer-version pin test.
v0.14.0minor

57b7a43: Wave 8 I1 — new `./utilities/enrollmentProgress` leaf subpath: `computeEnrollmentProgressFields(completableLessonIds, completedModules, stored)`, a pure, synchronous recompute of a course enrollment's `progress` (%) and `status` from a `completedModules` array against a caller-resolved completable-lesson-id set. Ported from a consumer's single-writer helper that three separate mutators (quiz auto-grade, instructor grading, an inbox grading action, plus seven more sites since ground-truthed) share so `completedModules`, `progress`, and `status` can never drift apart. This leaf keeps only the pure derivation — the curriculum lookup that resolves the completable set (a Payload query intersecting curriculum ∩ required/published/non-archived lessons) stays consumer-side; callers pass in the resolved `Set<string>`. Two incident-driven guards port verbatim, with their incident-citing comments generalized off consumer-internal names but keeping the mechanism: - **zero-denominator hold** — an empty completable set returns the stored `progress`/`status` unchanged (`held: 'zero-denominator'`), never recomputes to 0%/`'enrolled'`. Prevents a course whose curriculum goes fully unpublished from silently erasing a finished enrollment. - **completion-never-regresses** — once `stored.status === 'completed'`, a recompute that would drop below `'completed'` (e.g. the course gained a required lesson after the student finished) holds status at `'completed'` and returns `progress: Math.max(stored, recomputed)` — it can rise, never fall. Prevents a completed enrollment from reopening itself when the curriculum grows. Exported ONLY via the new `./utilities/enrollmentProgress` subpath plus the root barrel (mirrors the existing `./utilities/awardStatus` leaf pattern exactly — see the 0.13.1 changelog entry on the poisoned-barrel lesson: the root barrel still evaluates `server-only` transitively via the gamification re-exports, so consumers outside a react-server context should keep importing the leaf subpath directly). Zero Payload import, zero I/O, zero side effects — `sideEffects: false` already covers it. Distinct from this package's existing `./utilities/progress` (`computeProgress`/`getCompletedLessonIds`), which derives progress from tome-lms's native `LessonCompletion` collection for consumers whose enrollment does not denormalize a `completedModules` array. Pick the model matching your enrollment shape. Additive-only: new file (`src/utilities/enrollmentProgress.ts`), new exports-map entry, new root-barrel re-export, new test file (`tests/enrollment-progress.test.ts`, 20 tests — 12 ported 1:1 from the consumer's pinned characterisation suite plus 8 new edge cases: null/undefined `completedModules`, non-string `moduleId`, invalid stored status, out-of-range stored progress clamping, `'dropped'` status handling on both guard paths, and a purity/idempotency check). Zero existing files' behavior changed.

  • 57b7a43: Wave 8 I1 — new `./utilities/enrollmentProgress` leaf subpath: `computeEnrollmentProgressFields(completableLessonIds, completedModules, stored)`, a pure, synchronous recompute of a course enrollment's `progress` (%) and `status` from a `completedModules` array against a caller-resolved completable-lesson-id set. Ported from a consumer's single-writer helper that three separate mutators (quiz auto-grade, instructor grading, an inbox grading action, plus seven more sites since ground-truthed) share so `completedModules`, `progress`, and `status` can never drift apart. This leaf keeps only the pure derivation — the curriculum lookup that resolves the completable set (a Payload query intersecting curriculum ∩ required/published/non-archived lessons) stays consumer-side; callers pass in the resolved `Set<string>`. Two incident-driven guards port verbatim, with their incident-citing comments generalized off consumer-internal names but keeping the mechanism: - **zero-denominator hold** — an empty completable set returns the stored `progress`/`status` unchanged (`held: 'zero-denominator'`), never recomputes to 0%/`'enrolled'`. Prevents a course whose curriculum goes fully unpublished from silently erasing a finished enrollment. - **completion-never-regresses** — once `stored.status === 'completed'`, a recompute that would drop below `'completed'` (e.g. the course gained a required lesson after the student finished) holds status at `'completed'` and returns `progress: Math.max(stored, recomputed)` — it can rise, never fall. Prevents a completed enrollment from reopening itself when the curriculum grows. Exported ONLY via the new `./utilities/enrollmentProgress` subpath plus the root barrel (mirrors the existing `./utilities/awardStatus` leaf pattern exactly — see the 0.13.1 changelog entry on the poisoned-barrel lesson: the root barrel still evaluates `server-only` transitively via the gamification re-exports, so consumers outside a react-server context should keep importing the leaf subpath directly). Zero Payload import, zero I/O, zero side effects — `sideEffects: false` already covers it. Distinct from this package's existing `./utilities/progress` (`computeProgress`/`getCompletedLessonIds`), which derives progress from tome-lms's native `LessonCompletion` collection for consumers whose enrollment does not denormalize a `completedModules` array. Pick the model matching your enrollment shape. Additive-only: new file (`src/utilities/enrollmentProgress.ts`), new exports-map entry, new root-barrel re-export, new test file (`tests/enrollment-progress.test.ts`, 20 tests — 12 ported 1:1 from the consumer's pinned characterisation suite plus 8 new edge cases: null/undefined `completedModules`, non-string `moduleId`, invalid stored status, out-of-range stored progress clamping, `'dropped'` status handling on both guard paths, and a purity/idempotency check). Zero existing files' behavior changed.
  • fa9c30b: Wave 8 I2 — new `./utilities/attemptPolicy` leaf subpath: `resolveAttemptPolicy(progressionRules?)` and `applyAttemptOutcome(prev, outcome, policy, now)`, a pure derivation of a graded module's attempt/cooldown/lockout state on `CourseEnrollment.completedModules[]`. Ported from the same consumer's `syncTrainingToEnrollment.ts` EventAttendance afterChange hook (the sibling I1 leaf's neighbor step, ~lines 203-250, pinned in that repo's `tests/unit/lms-charact/syncTrainingCooldown.spec.ts`) that builds the upserted `completedModules` row on a training outcome. `resolveAttemptPolicy` reads `{cooldownHours, maxAttempts}` off a course's `progressionRules`, defaulting to 48/3 via nullish coalescing (not falsy coalescing — `0` and negative values are honored verbatim, mirroring the source's lack of validation). `applyAttemptOutcome` takes the previous `{attempts, lockedOut, cooldownExpiry}`, a `'passed' | 'failed'` outcome, the resolved policy, and a caller-supplied clock reading, and returns the next state. Four source oddities are preserved rather than fixed, documented in the file header and exercised in the test suite: (1) `attempts` increments on every graded outcome, pass or fail — it counts attempts, not failures; (2) `0`/negative `cooldownHours`/`maxAttempts` pass through unvalidated (a `maxAttempts: 0` course locks out on the first fail); (3) a below-cap FAIL leaves a prior `lockedOut` value untouched rather than clearing it; (4) a PASS unconditionally clears both `lockedOut` and `cooldownExpiry`, releasing even a prior lockout (the documented instructor-override-then-pass path). A grep across the reference consumer found this logic duplicated with varying fidelity at four more `completedModules` writers, inventoried in the Wave 8 I2 build report (not absorbed here — this leaf only ports the canonical shape so a future absorption pass has one place to delegate to): an OJT sign-off action and an out-of-band grading route both reuse the increment-and-clear-on-pass shape without the fail/cooldown branch (they only ever pass); an exam-waiver action explicitly leaves `attempts` untouched with a comment that a waiver is not an attempt; and an override-module-lock helper clears `lockedOut`/`cooldownExpiry` as part of an admin override without touching `attempts` at all. Two more sites (a challenge-mode gate and an exam-ticket gate) read `cooldownExpiry`/`lockedOut`/`maxAttempts` to gate an action but do not write these fields, and diverge from this policy's default: the challenge-mode gate has no `?? 3` fallback, so an unset `maxAttempts` gates on `lockedOut` alone. Exported ONLY via the new `./utilities/attemptPolicy` subpath plus the root barrel (mirrors the `./utilities/awardStatus` and `./utilities/enrollmentProgress` leaf pattern — see the I1 changeset on the poisoned-barrel lesson). Zero Payload import, zero I/O, zero side effects. Additive-only: new file (`src/utilities/attemptPolicy.ts`), new exports-map entry, new root-barrel re-export, new test file (`tests/attempt-policy.test.ts`, ported-behavior pins plus cap-boundary, already-locked-re-fail, pass-after-lock, and zero/negative-rule-value edge cases). Zero existing files' behavior changed.
  • fc30bf6: Wave 8 I3/I4 — two new pure-utility leaf subpaths. `./utilities/gradingCalibration` (I3): `gradeMultiSelect(selected, correct, mode?)`, a "select all that apply" scorer ported from a reference consumer's `utilities/academy/gradeMultiSelect.ts` — supports `'all_or_nothing'` (default) and `'partial'` modes, the latter charging `(correctPicked - incorrectPicked) / totalCorrect` (floored at 0) specifically to close the "tick every box" exploit naive partial credit allows. A distinct question-type contract from this package's existing `scoreQuizAnswers`'s `'multiple-choice'` case (exact-set match only) — the two are not interchangeable. Plus four calibration-audit deviation functions ported from that consumer's `data/academy/calibrationSnapshot.ts` and `features/academy/calibration/actions/submitCalibrationGrade.ts`: `classifyCalibrationDeviation` (tri-state clear/watch/flag, thresholds now overridable, defaulting to the source's 7/10), `summarizeCalibrationDeviations` (per-instructor count/average/status rollup from an already-grouped deviation array), `classifyCalibrationAuditOutcome` (concordant/discordant — note its `>` boundary deliberately differs from `classifyCalibrationDeviation`'s `>=` flag boundary, preserved not fixed), and `computeCalibrationDeviation` (the organizer-vs-co-host grade comparison across three supported payload shapes: `{score}`, `{criteria: {...}}`, flat numeric map). The consumer's Payload-querying calibration aggregator (`getCalibrationSnapshot`) is NOT ported — no cohesive pure core beyond the pieces above; that stays consumer-side. `./utilities/heldCertifications` (I4): `isHeldAwardStatus` and `heldCertKey`, extending the `./utilities/awardStatus` family (new sibling file, not an edit to `awardStatus.ts`) with the WIDER "does this member hold this certification" tolerance a production audit required upstream — an explicit empty-string `approvalStatus` (a manually-granted/legacy-imported award row written that way rather than left unset) also counts as held, alongside the existing approved/null/undefined agreement with `isEffectiveAwardStatus`. Most of the I4 candidate surface (`certAwardGate.ts`'s dedup-key builders, `resolveRelationId`, the linked-course gate, the idempotent award-create) was found ALREADY PRESENT in this package's own `src/server/awardGate.ts` (a prior wave's native port) and is not duplicated here. `effectiveCompletion.ts` was evaluated and NOT ported — every exported function is Payload-I/O end to end with no isolable pure math, only trivial relation-id/empty-shape helpers duplicated by every other file in this family. Audit finding (documented in the new file's header, not acted on — no pre-existing file changed): this package's own internal held-checks already disagree with each other the way the source consumer's three surfaces did. `hooks/cert-workflow/autoNominateExpert.ts`'s local `memberHoldsCertification` filters `approvalStatus: { equals: 'approved' }` only (stricter than `EFFECTIVE_AWARD_STATUSES`, excludes legacy-null); `server/jobs/reconcileCourseCompletionAwards.ts`'s inline `certKey` uses a single-colon separator, not this leaf's `::`. Flagged for a follow-up convergence pass, not fixed here. `utilities/academy/deriveEnrollmentProgress.ts` was evaluated for I3 and NOT ported: its exported function does Payload I/O directly (a `getCompletableLessonIdsMap` query) with no pure top-level entry point, and its one pure fragment (`countsAsComplete`, a pass-or-no-status predicate) is a numerator convention already covered by the Wave 8 I1 `./utilities/enrollmentProgress` leaf's completedModules handling — not identical, but not cohesive enough on its own to justify a separate export. Both leaves are exported ONLY via their new subpaths plus the root barrel (mirrors the `./utilities/awardStatus` / `./utilities/enrollmentProgress` / `./utilities/attemptPolicy` leaf pattern). Zero Payload import, zero I/O, zero side effects in either leaf. Additive-only: two new files (`src/utilities/gradingCalibration.ts`, `src/utilities/heldCertifications.ts`), two new test files (`tests/grading-calibration.test.ts` — 40 tests, 18 ported 1:1 from the reference consumer's `gradeMultiSelect.spec.ts` confirmed green there first, 22 new for the calibration functions and the boundary/threshold-override edge cases; `tests/held-certifications.test.ts` — 12 tests, written fresh from the source doc comment's contract, no dedicated upstream spec existed), append-only exports-map entries, append-only root-barrel re-exports. 293/293 lms tests green (was 241). Build clean, typecheck clean, every-file guard 0 fail (176 pass / 24 pre-existing skip), standalone ESM+CJS import verified for both new subpaths post-build. Zero existing files' behavior changed.
v0.13.1patch

a644dc6: Leaf subpath exports for the pure utilities (awardStatus, prerequisites, instructorRoles, ownerAuthority, certRenewalKeys, version). The root barrel evaluates `server-only` (transitively via the gamification re-exports), which throws under plain Node and tsx — killing any consumer that imports a pure predicate inside its payload-config graph (payload generate:types runs under tsx). Same poisoned-barrel class as core's /auth; same remedy: import the leaf, never the barrel, when outside a react-server context. All six subpaths PASS assert-node-loadable in both conditions.

  • a644dc6: Leaf subpath exports for the pure utilities (awardStatus, prerequisites, instructorRoles, ownerAuthority, certRenewalKeys, version). The root barrel evaluates `server-only` (transitively via the gamification re-exports), which throws under plain Node and tsx — killing any consumer that imports a pure predicate inside its payload-config graph (payload generate:types runs under tsx). Same poisoned-barrel class as core's /auth; same remedy: import the leaf, never the barrel, when outside a react-server context. All six subpaths PASS assert-node-loadable in both conditions.
v0.13.0minor

Wave 3 LMS absorbs + platform fixes. lms: certificate-helper enum repairs (valid vs active — verification could never succeed), own-record access resolves MEMBER id, effectiveAwardStatus predicates, award-chain gate + converging reconciler job, certificate expiry sweep (fixes the stalled T-7/T-0 progression), instructorRoles via rolesSlug knob, createdBy/owner authority split, prerequisiteStrictness (G1), ojt_signoff lessonType with approveOJT guard enforced, packaging guards wired. gamification: dist ships extensioned specifiers (raw-Node loadable; lms barrel dependency).

  • Wave 3 LMS absorbs + platform fixes. lms: certificate-helper enum repairs (valid vs active — verification could never succeed), own-record access resolves MEMBER id, effectiveAwardStatus predicates, award-chain gate + converging reconciler job, certificate expiry sweep (fixes the stalled T-7/T-0 progression), instructorRoles via rolesSlug knob, createdBy/owner authority split, prerequisiteStrictness (G1), ojt_signoff lessonType with approveOJT guard enforced, packaging guards wired. gamification: dist ships extensioned specifiers (raw-Node loadable; lms barrel dependency).
  • Updated dependencies - @wabbit/tome-gamification@0.3.1
v0.12.1patch

71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (prior client-name and SC-universe strings, e.g. "LOG-2954-0847", "Stanton // Crusader Orbit", "UEES STALWART" → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client-lore/SC references; config examples de-lored. Pack positioning (SC-tier bundling) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named the client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched. - @wabbit/tome-gamification@0.3.0

  • 71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (prior client-name and SC-universe strings, e.g. "LOG-2954-0847", "Stanton // Crusader Orbit", "UEES STALWART" → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client-lore/SC references; config examples de-lored. Pack positioning (SC-tier bundling) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named the client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched. - @wabbit/tome-gamification@0.3.0
v0.12.0minor

47a8d78: LMS follow-ups surfaced by the tome-starter demo's real LMS handlers: - `assignment-uploads` now sets `filesRequiredOnCreate: false` so text/url/multiple-choice submissions persist without a synthesized placeholder file; a new `beforeValidate` hook still rejects `submissionType: 'file'` writes that carry no actual upload. - `gradeQuizAttempt` grades against the quiz block matching the attempt's `quizBlockId` (returning null when the id matches no quiz block on the lesson) instead of always grading the first quiz block; attempts without a `quizBlockId` keep the first-quiz-block fallback. - `student-notes` gains an optional `sectionId` text field backing the NotesPanel content-section anchor, surfaced via `getStudentNotes` / `StudentNoteData`. Consumers on 0.11.x are unaffected at runtime: the new `sectionId` field is optional, the loosened upload contract only removes a create-time rejection, and legacy quiz attempts (no `quizBlockId`) grade exactly as before.

  • 47a8d78: LMS follow-ups surfaced by the tome-starter demo's real LMS handlers: - `assignment-uploads` now sets `filesRequiredOnCreate: false` so text/url/multiple-choice submissions persist without a synthesized placeholder file; a new `beforeValidate` hook still rejects `submissionType: 'file'` writes that carry no actual upload. - `gradeQuizAttempt` grades against the quiz block matching the attempt's `quizBlockId` (returning null when the id matches no quiz block on the lesson) instead of always grading the first quiz block; attempts without a `quizBlockId` keep the first-quiz-block fallback. - `student-notes` gains an optional `sectionId` text field backing the NotesPanel content-section anchor, surfaced via `getStudentNotes` / `StudentNoteData`. Consumers on 0.11.x are unaffected at runtime: the new `sectionId` field is optional, the loosened upload contract only removes a create-time rejection, and legacy quiz attempts (no `quizBlockId`) grade exactly as before.
  • @wabbit/tome-gamification@0.3.0
v0.11.0minor

68465b3: Role checks now understand a `roles` RELATIONSHIP, not just flat strings — unblocking admin gates that were silently shut. Three packages read `req.user.roles` by collecting only entries where `typeof entry === 'string'`, then comparing them to literal tier names (`'admin'`, `'instructor'`, …). On a site whose roles are a relationship to a Roles collection, that read produced `[]` and **every** tier check returned false. In tome-lms that closed `enrollmentCreate`, so a site's own super admin had no "Create" button on Course Enrollments; in tome-gamification it closed the Points/Badge/Achievement write gates; in tome-ai it scoped an admin to only their own credentials. The failure is silent — an access denial renders as a missing button, not an error. Two things made it worse than a simple shape mismatch: - **Payload binds `req.user` at `collection.auth.depth`, which defaults to `0`**, so a relationship arrives as raw ID strings. A site that also installs a custom auth strategy may populate it deeper — meaning the SAME deployment presents different shapes on different login paths. Widening the synchronous read alone would have fixed one path and left the other silently broken. - **`super-admin` matched nothing.** The tier lists hold literal role names, and `super-admin` is not one of them, so the highest-privilege role failed every check. Fixed in tome-lms and tome-gamification: - `readRoles` accepts flat names, populated Role docs (`{slug}`), the `_populatedRoles` enricher shape, and a flat singular `role` field. - `super-admin` now satisfies every tier, matching the platform-wide implicit `'*'` grant. - New `resolveRoleSlugs(req)` / `hasAnyRoleAsync` / `isAdminAsync` / `isDirectorAsync` / `isInstructorRoleAsync` / `isMaintainerRoleAsync` hydrate unresolved IDs through `req.payload`, memoized on `req.context` so a request running many access checks fetches at most once. Hydration never throws: a flat-name site keeps its synchronous result, so this is a strict widening for every shape. - Every collection access gate in both packages now uses the async resolvers. The synchronous helpers remain exported unchanged for hook call sites that already hold a populated user. Fixed in tome-ai: `AiCredentials`' admin check accepts populated Role docs and `_populatedRoles`, and recognises the canonical `super-admin` slug (it previously matched only camelCase `superAdmin`). It stays synchronous by design — a field-level credential gate is the wrong place for a per-check DB round-trip. No behaviour change for sites already using flat role strings: every previously-passing check still passes. Also pays the test-floor debt for all three packages: each gains its first suite — 35 cases covering every user shape, the super-admin rule, hydration, single-fetch memoization, failure tolerance and anonymous denial — and is removed from the `assert-test-floor` allowlist.

  • 68465b3: Role checks now understand a `roles` RELATIONSHIP, not just flat strings — unblocking admin gates that were silently shut. Three packages read `req.user.roles` by collecting only entries where `typeof entry === 'string'`, then comparing them to literal tier names (`'admin'`, `'instructor'`, …). On a site whose roles are a relationship to a Roles collection, that read produced `[]` and **every** tier check returned false. In tome-lms that closed `enrollmentCreate`, so a site's own super admin had no "Create" button on Course Enrollments; in tome-gamification it closed the Points/Badge/Achievement write gates; in tome-ai it scoped an admin to only their own credentials. The failure is silent — an access denial renders as a missing button, not an error. Two things made it worse than a simple shape mismatch: - **Payload binds `req.user` at `collection.auth.depth`, which defaults to `0`**, so a relationship arrives as raw ID strings. A site that also installs a custom auth strategy may populate it deeper — meaning the SAME deployment presents different shapes on different login paths. Widening the synchronous read alone would have fixed one path and left the other silently broken. - **`super-admin` matched nothing.** The tier lists hold literal role names, and `super-admin` is not one of them, so the highest-privilege role failed every check. Fixed in tome-lms and tome-gamification: - `readRoles` accepts flat names, populated Role docs (`{slug}`), the `_populatedRoles` enricher shape, and a flat singular `role` field. - `super-admin` now satisfies every tier, matching the platform-wide implicit `'*'` grant. - New `resolveRoleSlugs(req)` / `hasAnyRoleAsync` / `isAdminAsync` / `isDirectorAsync` / `isInstructorRoleAsync` / `isMaintainerRoleAsync` hydrate unresolved IDs through `req.payload`, memoized on `req.context` so a request running many access checks fetches at most once. Hydration never throws: a flat-name site keeps its synchronous result, so this is a strict widening for every shape. - Every collection access gate in both packages now uses the async resolvers. The synchronous helpers remain exported unchanged for hook call sites that already hold a populated user. Fixed in tome-ai: `AiCredentials`' admin check accepts populated Role docs and `_populatedRoles`, and recognises the canonical `super-admin` slug (it previously matched only camelCase `superAdmin`). It stays synchronous by design — a field-level credential gate is the wrong place for a per-check DB round-trip. No behaviour change for sites already using flat role strings: every previously-passing check still passes. Also pays the test-floor debt for all three packages: each gains its first suite — 35 cases covering every user shape, the super-admin rule, hydration, single-fetch memoization, failure tolerance and anonymous denial — and is removed from the `assert-test-floor` allowlist.
  • Updated dependencies [68465b3] - @wabbit/tome-gamification@0.3.0
v0.10.1patch

1173d00: Two fixes from the wabbit EDU Phase 5 prod dogfood (2026-07-18): - **lms-ui:** CurriculumSidebar now derives per-row effective access via `deriveAccessState` (enrollment/tier-aware) instead of disabling every `locked`-visibility row — enrolled members can navigate locked lessons from the rail, matching what the content pane already grants. `CurriculumTree` gains an optional `resolveLessonAccess` prop; without it the visibility-tier fallback (anon/landing behavior) is unchanged. - **lms:** the `course-completion` badge check moved out of `onLessonCompletion` (it fired per LESSON, awarding course-completion badges on a student's first completed lesson) into a new `awardCourseCompletionBadges` CourseEnrollment afterChange hook guarded on the status transition into `completed` — the same guard `autoAwardCertification` uses. Per-lesson points (and the points-threshold badge cascade inside `awardPoints`) are unchanged.

  • 1173d00: Two fixes from the wabbit EDU Phase 5 prod dogfood (2026-07-18): - **lms-ui:** CurriculumSidebar now derives per-row effective access via `deriveAccessState` (enrollment/tier-aware) instead of disabling every `locked`-visibility row — enrolled members can navigate locked lessons from the rail, matching what the content pane already grants. `CurriculumTree` gains an optional `resolveLessonAccess` prop; without it the visibility-tier fallback (anon/landing behavior) is unchanged. - **lms:** the `course-completion` badge check moved out of `onLessonCompletion` (it fired per LESSON, awarding course-completion badges on a student's first completed lesson) into a new `awardCourseCompletionBadges` CourseEnrollment afterChange hook guarded on the status transition into `completed` — the same guard `autoAwardCertification` uses. Per-lesson points (and the points-threshold badge cascade inside `awardPoints`) are unchanged.
v0.10.0minor

6bc419c: Naming convergence (all additive; every old name keeps working as a `@deprecated` alias until that package's next major). `create*` is canonical for collection/layer factories (`define*` stays reserved for the blocks descriptor system): crm/deals/marketing/intake/forms gain `create*Collection` names for their former `define*Collection` factories. Layer entries converge on `createXLayer(config?) → bundle`: `createCrmLayer`/`createDealsLayer`/`createMarketingLayer`/`createCatalogLayer`/`createEconomyLayer`/`createChromeLayer`/`createLmsLayer`/`createAiLayer` (+ `createFormsLayer`/`createIntakeLayer`), returning bare `CollectionConfig[]` where the layer contributes only collections or an honest named bundle where it hands back more (chrome: `{ globals }`; lms/ai: `{ collections, hooks }`); void-returning `initCatalog`/`initEconomy` stay as the single registration call sites, delegated to internally. Naming note for forms consumers: `createFormsCollection` (singular factory) vs `createFormsCollections` (plural composer) vs `createFormsLayer` (layer entry) — each docblock states the distinction.

  • 6bc419c: Naming convergence (all additive; every old name keeps working as a `@deprecated` alias until that package's next major). `create*` is canonical for collection/layer factories (`define*` stays reserved for the blocks descriptor system): crm/deals/marketing/intake/forms gain `create*Collection` names for their former `define*Collection` factories. Layer entries converge on `createXLayer(config?) → bundle`: `createCrmLayer`/`createDealsLayer`/`createMarketingLayer`/`createCatalogLayer`/`createEconomyLayer`/`createChromeLayer`/`createLmsLayer`/`createAiLayer` (+ `createFormsLayer`/`createIntakeLayer`), returning bare `CollectionConfig[]` where the layer contributes only collections or an honest named bundle where it hands back more (chrome: `{ globals }`; lms/ai: `{ collections, hooks }`); void-returning `initCatalog`/`initEconomy` stay as the single registration call sites, delegated to internally. Naming note for forms consumers: `createFormsCollection` (singular factory) vs `createFormsCollections` (plural composer) vs `createFormsLayer` (layer entry) — each docblock states the distinction.
  • 36e537a: `registerLayer` is now statically imported (forms/intake pattern) instead of lazily `require()`d in ten layer packages' init/register paths. The lazy pattern silently no-ops under Payload's native-ESM CLI (`generate:types` / `generate:importmap`), so layer registration could vanish without error. Packages whose tome-core peer is genuinely optional (economy, ai, gamification) deliberately keep the guarded lazy path; tome-core's `admin-nav/self-register.ts` deliberately keeps its subpath `require()` (documented ESM/CJS dual-cache fix — do not convert).
  • 36e537a: Every package now declares an explicit `sideEffects` field (38 added; motion/engine/forms already correct). Registration-bearing modules (render files' `registerRenderer`, `blocks/*/index.ts` `defineBlock` self-registration, widget `register.ts` files, productHooks, permission self-registrations, print templates, chrome built-in variants) are listed so bundlers can tree-shake everything else WITHOUT dropping import-time registrations — previously the field was unset, which blocked cross-module tree-shaking through the barrels entirely. Never blanket `false` on a package with registration or CSS.
  • a93f478: `getStudentDashboard` and `getCourseLandingData` no longer fetch sequentially: the dashboard's nine member-scoped queries run in one `Promise.all` batch, the course landing page runs course-by-slug then a five-way parallel batch (outline, reviews, enrollment, eligibility, related courses). Per-call error semantics preserved exactly (independently-guarded calls keep their own try/catch fallbacks; previously-unguarded calls still propagate). Verified safe: no `req`/transaction is threaded into these reads, so there is no session-concurrency hazard.
  • Updated dependencies [6bc419c]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [aef2725] - @wabbit/tome-core@1.4.0 - @wabbit/tome-gamification@0.2.1
v0.9.3patch

Updated dependencies [66f394b] - @wabbit/tome-core@1.3.4 - @wabbit/tome-gamification@0.2.0

  • Updated dependencies [66f394b] - @wabbit/tome-core@1.3.4 - @wabbit/tome-gamification@0.2.0
v0.9.2patch

Updated dependencies - @wabbit/tome-core@1.3.3 - @wabbit/tome-gamification@0.2.0

  • Updated dependencies - @wabbit/tome-core@1.3.3 - @wabbit/tome-gamification@0.2.0
v0.9.1patch

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

  • bed3f90: Docs-manifest emitter pipeline (W3 ship-readiness). `@wabbit/tome-blocks-core` now ships a standalone Node ESM CLI at `scripts/emit-docs-manifests.mjs` that emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free `<pkg>/meta` block-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild: `node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>`. To let the emitter import block metadata uniformly without dragging Payload config into a build script, the `./meta` payload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block's `BlockMeta` was relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed. Every supported-core package additionally adds `CHANGELOG.md` to its published `files` array so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.
  • 850d51c: Fix `assignment-uploads` upload collection rejecting every file. It set `mimeTypes: ['*/*']`, but Payload's `validateMimeType` strips only the first `*` (`'*/*'` → `'/*'`), so the wildcard matched no detected MIME type and the upload guard blocked all student file submissions. Removed the broken config — omitting `mimeTypes` is the correct "accept any file" setting, and Payload still blocks dangerous executable types via its built-in `checkFileRestrictions` allowlist.
  • Updated dependencies [bed3f90]
  • Updated dependencies [850d51c] - @wabbit/tome-core@1.2.1
v0.9.0minor

03865f0: Fix: LMS enrollment/completion access checks now resolve the authenticated user → member profile before querying `student`-keyed rows. `CourseEnrollment.student`, `LessonCompletion.student`, and `GradebookEntry.student` all relate to the **member** collection, but the guards (`isEnrolled`, `hasActiveAccess`, `canAccessLesson`, `canAccessCourseItem`) and the `enrollment-gated` lesson-read access function previously queried them by the auth **user** id (`getUserId(req.user)`). In the canonical Tome identity model — `users` is the auth collection, `members` is a separate profile (`member.user → user`) — those ids differ, so every enrollment/completion check silently failed for genuinely enrolled members: locked lesson bodies were stripped server-side and the API gate denied them. Resolution now routes through `resolveMemberFromSession` (the same platform resolver consumers use for their auth context) via a new `resolveStudentId` helper, cached per request/guard-context. Course **staff** fields (`owner`/`instructors`/`maintainers`) relate to `users`, so those queries correctly keep using the user id — that split is the actual correctness boundary. `GuardContext` gains an optional `memberSlug` (default `'members'`), and `registerLmsLayer` threads the configured member slug into the gated access function. No consumer code change required for the default `'members'` slug. Consumers in a users-auth + members-profile model gain correct enrolled-member access (SSR body-stripping and the `lessonReadAccess: 'enrollment-gated'` API gate both now grant locked lessons to actually-enrolled members).

  • 03865f0: Fix: LMS enrollment/completion access checks now resolve the authenticated user → member profile before querying `student`-keyed rows. `CourseEnrollment.student`, `LessonCompletion.student`, and `GradebookEntry.student` all relate to the **member** collection, but the guards (`isEnrolled`, `hasActiveAccess`, `canAccessLesson`, `canAccessCourseItem`) and the `enrollment-gated` lesson-read access function previously queried them by the auth **user** id (`getUserId(req.user)`). In the canonical Tome identity model — `users` is the auth collection, `members` is a separate profile (`member.user → user`) — those ids differ, so every enrollment/completion check silently failed for genuinely enrolled members: locked lesson bodies were stripped server-side and the API gate denied them. Resolution now routes through `resolveMemberFromSession` (the same platform resolver consumers use for their auth context) via a new `resolveStudentId` helper, cached per request/guard-context. Course **staff** fields (`owner`/`instructors`/`maintainers`) relate to `users`, so those queries correctly keep using the user id — that split is the actual correctness boundary. `GuardContext` gains an optional `memberSlug` (default `'members'`), and `registerLmsLayer` threads the configured member slug into the gated access function. No consumer code change required for the default `'members'` slug. Consumers in a users-auth + members-profile model gain correct enrolled-member access (SSR body-stripping and the `lessonReadAccess: 'enrollment-gated'` API gate both now grant locked lessons to actually-enrolled members).
v0.8.0minor

d2d0b0d: Extract the generic gamification primitives (the `points` append-only ledger, `badges`, `achievements`, plus `awardPoints`/`checkAndAwardBadges`) into a new standalone `@wabbit/tome-gamification` package. `@wabbit/tome-lms` now depends on it and re-exports the three collections + utilities from their original import paths — fully non-breaking for existing consumers (`registerLmsLayer`, the barrel, the access module, and the `onLessonCompletion` hook are unchanged). The points collection gains one additive, optional `source` group (polymorphic `sourceType`/`sourceId`) for non-course consumers. In the new package, `reason` options and the `course`/`media` relations are configurable via `createPointsCollection`/`createBadgeCollection`/`createAchievementCollection` factories (the static `PointsCollection`/`BadgeCollection`/`AchievementCollection` exports preserve the exact LMS shape). New server helpers `getPointsBalance`/`getPointsSince` ship at `@wabbit/tome-gamification/server` for honest windowed totals (e.g. "points this week").

  • d2d0b0d: Extract the generic gamification primitives (the `points` append-only ledger, `badges`, `achievements`, plus `awardPoints`/`checkAndAwardBadges`) into a new standalone `@wabbit/tome-gamification` package. `@wabbit/tome-lms` now depends on it and re-exports the three collections + utilities from their original import paths — fully non-breaking for existing consumers (`registerLmsLayer`, the barrel, the access module, and the `onLessonCompletion` hook are unchanged). The points collection gains one additive, optional `source` group (polymorphic `sourceType`/`sourceId`) for non-course consumers. In the new package, `reason` options and the `course`/`media` relations are configurable via `createPointsCollection`/`createBadgeCollection`/`createAchievementCollection` factories (the static `PointsCollection`/`BadgeCollection`/`AchievementCollection` exports preserve the exact LMS shape). New server helpers `getPointsBalance`/`getPointsSince` ship at `@wabbit/tome-gamification/server` for honest windowed totals (e.g. "points this week").
v0.7.0minor

d5d81ce: Add `lessonReadAccess: 'public' | 'enrollment-gated'` config knob. Under `'enrollment-gated'`, the lessons collection's read access becomes an async Where-filter Access: locked lessons are excluded from REST/GraphQL reads unless the caller has an active enrollment in a course containing the lesson (resolved through the CourseItem junction, cached per request on `req.context.tomeLms`), is owner/instructor/maintainer of such a course, holds a maintainer-tier-or-higher role, or is an internal/system call. Free and preview lessons stay publicly readable. Default `'public'` preserves the as-shipped behavior exactly; consumer SSR via the Local API is unaffected either way (`overrideAccess` default). New exports: `enrollmentGatedLessonRead`, `buildLessonRead`. Also corrects the stale `registerLayer` version literal (0.3.4 → 0.7.0).

  • d5d81ce: Add `lessonReadAccess: 'public' | 'enrollment-gated'` config knob. Under `'enrollment-gated'`, the lessons collection's read access becomes an async Where-filter Access: locked lessons are excluded from REST/GraphQL reads unless the caller has an active enrollment in a course containing the lesson (resolved through the CourseItem junction, cached per request on `req.context.tomeLms`), is owner/instructor/maintainer of such a course, holds a maintainer-tier-or-higher role, or is an internal/system call. Free and preview lessons stay publicly readable. Default `'public'` preserves the as-shipped behavior exactly; consumer SSR via the Local API is unaffected either way (`overrideAccess` default). New exports: `enrollmentGatedLessonRead`, `buildLessonRead`. Also corrects the stale `registerLayer` version literal (0.3.4 → 0.7.0).
v0.6.2patch

Updated dependencies [a9801fe]

  • Updated dependencies [a9801fe]
  • Updated dependencies [baf401e] - @wabbit/tome-core@1.1.0
v0.6.1patch

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

  • Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12 - @wabbit/tome-catalog@1.1.3
v0.6.0minor

56fc8d5: Add `getCompletedLessonIds()` — the read-side counterpart to `computeProgress`. `computeProgress` already collapses the canonical completion source (global `lesson-completions` rows for `honor-prior`; the enrollment's own `completedItems` for `fresh-start`) into a 0–100 percentage. UI surfaces (`MarkCompleteButton`, `CurriculumSidebar` completion markers) need the actual id _set_, not the percentage, and the platform deliberately keeps no denormalized `completedLessons` array on the enrollment — so there was no supported way to ask "which lessons has this student completed in this course?". `getCompletedLessonIds({ payload, studentId, courseId, enrollment? })` returns that set as `string[]`, reading from the exact same source `computeProgress` uses (so the two can never disagree), scoped to the course's flattened curriculum. Returns `[]` on lookup failure or empty curriculum — never throws. Consumers pass the result into `<CourseShell completedLessonIds>`.

  • 56fc8d5: Add `getCompletedLessonIds()` — the read-side counterpart to `computeProgress`. `computeProgress` already collapses the canonical completion source (global `lesson-completions` rows for `honor-prior`; the enrollment's own `completedItems` for `fresh-start`) into a 0–100 percentage. UI surfaces (`MarkCompleteButton`, `CurriculumSidebar` completion markers) need the actual id _set_, not the percentage, and the platform deliberately keeps no denormalized `completedLessons` array on the enrollment — so there was no supported way to ask "which lessons has this student completed in this course?". `getCompletedLessonIds({ payload, studentId, courseId, enrollment? })` returns that set as `string[]`, reading from the exact same source `computeProgress` uses (so the two can never disagree), scoped to the course's flattened curriculum. Returns `[]` on lookup failure or empty curriculum — never throws. Consumers pass the result into `<CourseShell completedLessonIds>`.
  • Updated dependencies [36dc023]
  • Updated dependencies [2612799] - @wabbit/tome-core@1.0.11 - @wabbit/tome-catalog@1.1.2
v0.4.0minor

**NEW: `autoAwardCertification` afterChange hook on CourseEnrollment.** Fires on status transition to `'completed'`. Reads `Course.certificationAwarded` (relationship → certifications) and creates a CertificationAward against that certification for the enrolled student. Skips silently when `certificationAwarded` is null (course awards no cert). Idempotent (existence-check on `recipient + relatedCourseEnrollment` pair). Sets `req.context.internal = true` around the create so `enforceApprovalStatus` bypasses its gate — system writes already have authority to set `approvalStatus: 'approved'` directly. Wired into `CourseEnrollment.afterChange` after `computeProgress`. Promotes the consumer-side auto-award pattern from `wabbit-site-core/dal/lms-completion.ts` to a single platform-owned hook. Per convergence follow-up 4c.

  • **NEW: `autoAwardCertification` afterChange hook on CourseEnrollment.** Fires on status transition to `'completed'`. Reads `Course.certificationAwarded` (relationship → certifications) and creates a CertificationAward against that certification for the enrolled student. Skips silently when `certificationAwarded` is null (course awards no cert). Idempotent (existence-check on `recipient + relatedCourseEnrollment` pair). Sets `req.context.internal = true` around the create so `enforceApprovalStatus` bypasses its gate — system writes already have authority to set `approvalStatus: 'approved'` directly. Wired into `CourseEnrollment.afterChange` after `computeProgress`. Promotes the consumer-side auto-award pattern from `wabbit-site-core/dal/lms-completion.ts` to a single platform-owned hook. Per convergence follow-up 4c.
  • **`enforceApprovalStatus` now respects `req.context.internal === true`.** System writes (hook-initiated, internal jobs) bypass the approval gate. The auto-award hook above uses this. Sites that want to mirror the pattern from their own code can set `req.context.internal = true` before a `payload.create` to the certification-awards collection.
  • **BEHAVIOR CHANGE: `flattenCurriculumTree` now throws on lookup failure.** The prior silent `return []` on caught exception was an anti-pattern that hid production bugs as "empty curriculum" (progress always 0%, dashboard shows nothing) with no log signal. Failures now propagate with the courseId in the message + the original error chained via `cause`. Consumers wanting the old behavior can wrap the call in their own try/catch. Per convergence follow-up 4a.
v0.2.0minor

Initial publish to npm.wabbit.com — first registry release for the 5 non-block-pack packages. Companion to the existing sprint-3-blocks-split changeset (which handles the 11 linked block packages). Together these two changesets bring all 8 publish-pipeline-Phase-2-remediated packages to a coherent first-release cohort: - `@wabbit/tome-core` 0.1.0 → 0.2.0 - `@wabbit/tome-ui` 0.2.0 → 0.3.0 - `@wabbit/tome-motion` 0.1.0 → 0.2.0 - `@wabbit/tome-lms` 0.1.0 → 0.2.0 - `@wabbit/tome-lms-ui` 0.1.0 → 0.2.0 - `@wabbit/tome-blocks-core` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-extras` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-marketing-starter` 0.1.0 → 0.2.0 (via sprint-3) All 8 packages ship with metadata, dist/ output, exports map verified by P6 scratch-consumer smoke (35/35 resolutions), 'use client' + 'server-only' directives preserved through tsup bundle:false. Verdaccio v0 live since 2026-04-18 at npm.wabbit.com.

  • Initial publish to npm.wabbit.com — first registry release for the 5 non-block-pack packages. Companion to the existing sprint-3-blocks-split changeset (which handles the 11 linked block packages). Together these two changesets bring all 8 publish-pipeline-Phase-2-remediated packages to a coherent first-release cohort: - `@wabbit/tome-core` 0.1.0 → 0.2.0 - `@wabbit/tome-ui` 0.2.0 → 0.3.0 - `@wabbit/tome-motion` 0.1.0 → 0.2.0 - `@wabbit/tome-lms` 0.1.0 → 0.2.0 - `@wabbit/tome-lms-ui` 0.1.0 → 0.2.0 - `@wabbit/tome-blocks-core` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-extras` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-marketing-starter` 0.1.0 → 0.2.0 (via sprint-3) All 8 packages ship with metadata, dist/ output, exports map verified by P6 scratch-consumer smoke (35/35 resolutions), 'use client' + 'server-only' directives preserved through tsup bundle:false. Verdaccio v0 live since 2026-04-18 at npm.wabbit.com.
  • Updated dependencies - @wabbit/tome-core@0.2.0