Documentation menu

Documentation / Learning engine / lms

Lms

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

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

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

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

Overview

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 the repo's ARCHITECTURE.md § The Layer Model) — 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.

Peer dependencies

Peer ranges, copied from package.json:

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

`@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.

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.

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: ., @wabbit/tome-lms/server, @wabbit/tome-lms/scripts/migrate-lms-products-to-catalog, @wabbit/tome-lms/version, 9× @wabbit/tome-lms/utilities/*, and 12× @wabbit/tome-lms/collections/*:

SubpathPurpose
@wabbit/tome-lms/versionLayer version constant (createLmsLayer reports it to the layer registry — must equal package.json version)
@wabbit/tome-lms/utilities/awardStatusCanonical approvalStatus groupings for CertificationAwards ("does this award count")
@wabbit/tome-lms/utilities/enrollmentProgressPure derivation of a course enrollment's progress/status from completedModules
@wabbit/tome-lms/utilities/prerequisiteshasCompletedPrerequisites / isPhaseUnlocked — prerequisite evaluation against LessonCompletion rows
@wabbit/tome-lms/utilities/instructorRolesPure role-derived instructor authority comparison
@wabbit/tome-lms/utilities/ownerAuthorityPure check of whether the owner stamp still confers authority
@wabbit/tome-lms/utilities/certRenewalKeysNotification key conventions for the certification renewal lifecycle
@wabbit/tome-lms/utilities/attemptPolicyAttempt/cooldown/lockout policy math for a graded module
@wabbit/tome-lms/utilities/gradingCalibrationPure scoring/classification math — quiz "select all that apply" + instructor calibration audits
@wabbit/tome-lms/utilities/heldCertifications"Does this member already hold this certification?" predicate + composite key
@wabbit/tome-lms/collections/certificationAwardStandalone CertificationAwardCollection export
@wabbit/tome-lms/collections/lessonStandalone LessonCollection export
@wabbit/tome-lms/collections/courseEnrollmentStandalone CourseEnrollmentCollection export
@wabbit/tome-lms/collections/skillPathStandalone SkillPathCollection export
@wabbit/tome-lms/collections/academyStandalone AcademyCollection export
@wabbit/tome-lms/collections/courseStandalone CourseCollection export
@wabbit/tome-lms/collections/topicStandalone TopicCollection export
@wabbit/tome-lms/collections/courseItemStandalone CourseItemCollection export
@wabbit/tome-lms/collections/quizAttemptStandalone QuizAttemptCollection export
@wabbit/tome-lms/collections/assignmentUploadStandalone AssignmentUploadCollection export
@wabbit/tome-lms/collections/examTicketStandalone ExamTicketCollection export
@wabbit/tome-lms/collections/examBypassRequestStandalone ExamBypassRequestCollection export

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

GroupExports
Layer entrycreateLmsLayer, LmsLayerConfig (registerLmsLayer is a @deprecated pure alias)
CompositiondetectCatalog, detectEconomy, detectOrg, resolveOrgSlug, LmsComposition; resolveMemberSlug (re-exported from @wabbit/tome-core/identity)
CapabilitiesTOME_LMS_CAPABILITIES, TOME_LMS_DEFAULT_GRANTS, seedTomeLmsCapabilities
AccessLMS_ROLE_TIERS, LmsRoleTier, and ~35 access predicates/builders (courseRead, enrollmentRead, buildLessonRead, isAdmin, hasPrivilegedRole, etc.)
GuardscanAccessCourseItem, 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)
HooksContent-sync (syncDisplayContent, syncParentRelationship, cleanupParentReferences), progress (computeProgress, onLessonCompletion), cert-workflow (enforceApprovalStatus, updateCertificationHolderCount, updateMemberCerts, autoNominateExpert), scheduling (recountTrainingEventAttendees, assignInstructorFromUser), uniquePair
Attempt-workflow hookscreateEnforceAttemptPolicy(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 hookspromoteOwnerToMaintainerOnApproval — 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
UtilitiesGamification (checkAndAwardBadges, awardPoints), grading (scoreQuizAnswers, gradeQuizAttempt, syncGradebookEntry), progress (computeEnrollmentProgress, flattenCurriculumTree, getCompletedLessonIds), prerequisites (hasCompletedPrerequisites, isPhaseUnlocked)
Types25+ type exports — see src/types.ts for the full list

`@wabbit/tome-lms/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).

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

Certificate verification codes

Certificate awards now carry a verificationCode used by getPublicCertificateVerification (/verify/{code}). Awards created before this release get one from @wabbit/tome-lms/scripts/backfillVerificationCodes (dry run by default; idempotent).

Server / client posture

Fully server-side: Payload collections, access predicates, and @wabbit/tome-lms/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.

  • CHANGELOG.md

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

Import path
@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/backfillVerificationCodes
@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

0.26.xMinor

  • Courses can carry outcomes, subjects and summaries, with a public outline, an activity feed and visit tracking.

    Adds opt-in course fields, a public course outline with totals, an activity feed, previous-visit tracking and a latest-assignments reader; every default schema except AssignmentUpload is unchanged.

    Additive changes, all off by default unless noted:

    • `Course.subject`: createCourseCollection({ subjectOptions }) (or createLmsLayer({ course: { subjectOptions } })) adds an optional, indexed subject select after level. With no options the field is not added at all. An option value containing : throws at construction.
    • `Course.outcomes`: outcomes: true (factory, or createLmsLayer({ course: { outcomes: true } })) adds an outcomes array of { text } rows (up to 12) after description.
    • `Lesson.summary`: createLessonCollection({ summary: true }) (or createLmsLayer({ lesson: { summary: true } })) adds an optional summary textarea after title.
    • `AssignmentUpload.reviewedAt` (on by default): a read-only date at the end of the field list, stamped on update when an instructor adds feedback or sets a reviewer status (graded, revision-required; configurable with reviewedStatuses). A student resubmission never stamps it, create never stamps it, and existing rows stay null (no backfill). The stamp is a field-level hook on reviewedAt, so the collection's hook arrays are unchanged. Opt out of the stamp with stampReviewedAt: false.
    • Learner visits: createLmsLayer({ learnerVisits: true }) adds two read-only dates to StudentProfile, lastVisitedAt and previousVisitedAt. recordVisit(payload, memberId) writes them (a visit is a run of requests with gaps under 30 minutes, configurable with idleMinutes) and getPreviousVisit(payload, memberId) returns the end of the previous visit. With the option off, both helpers are inert.
    • `@wabbit/tome-lms/server` exports buildCourseItemTree(payload, courseId, options?) (options: completedLessonIds, includeUnlockAfter, courseItemsSlug, overrideAccess, req; defaults match the previous internal behavior), getCourseTotals(tree), getActivitySince(payload, memberId, since, options?) (replies, uploads sent back for another go, and certificates after a time, newest first), getPreviousVisit, recordVisit, learnerVisitsEnabled and getStudentAssignments(payload, memberId, options?) (the latest upload per lesson and assignment block), with their types.
    • `@wabbit/tome-lms` (root) also exports the pure getCourseTotals and CourseTotals, plus createStampReviewedAt, isReviewWrite, DEFAULT_REVIEWED_STATUSES, createLearnerVisitFields, withLearnerVisitFields, LAST_VISITED_AT_FIELD and PREVIOUS_VISITED_AT_FIELD.

    New columns: assignment-uploads.reviewedAt (date, nullable) always; courses.subject (text/enum, nullable), courses.outcomes (array of text), lessons.summary (text, nullable), student-profiles.lastVisitedAt and student-profiles.previousVisitedAt (date, nullable) only when their options are on. MongoDB needs no migration; a Postgres or SQLite site generates a migration for each column it turns on, and for reviewedAt on upgrade.

  • Fixes level labels, active enrollments, the certificate count and instructor avatars in the LMS layer.

    Schoolroom platform fixes: level labels reach the Course collection, active enrollments and the certificate count return real data, and course instructors carry their avatars.

    BREAKING: the EnrollmentStatus type exported from @wabbit/tome-lms/server drops active and suspended (values CourseEnrollment never writes) and lists the real five; code comparing against them fails to typecheck. Behavior that returned nothing now returns data (see Consumer impact).

    • createLmsLayer({ course: { levelOptions } }) now reaches the Course collection (it was dropped before, so a site could not set level labels such as Start, Core, Build through the layer). Zero-config, and a course object without any key set, still keep the static CourseCollection reference; the default level vocabulary is unchanged.
    • Correctness fix: "active" enrollments are now enrolled and in-progress (new ACTIVE_ENROLLMENT_STATUSES, the set the access guard, the course access hooks and the expiry sweep already use). getActiveEnrollments and the enrollments.active list and suggested next lessons of getStudentDashboard filtered on a status: 'active' value CourseEnrollment never writes, so they always returned nothing. The EnrollmentStatus type exported from @wabbit/tome-lms/server now lists the collection's real values (enrolled | in-progress | completed | dropped | expired); code comparing against 'active' or 'suspended' never matched and now fails to typecheck. A missing status is read as the collection default enrolled instead of active.
    • Correctness fix: getStudentDashboard's stats.totalCertificates counted awards with status: 'active', which no award has, so it was always 0. It now counts held awards: status: 'valid' and approvalStatus in EFFECTIVE_AWARD_STATUSES (approved, or a legacy unset row), the predicate the holder count and the activity feed use.
    • getCourseLandingData instructors now carry avatar from the populated staff user: an avatar upload, else image (a populated upload or an absolute or root-relative URL string, the Better Auth shape). Users without one still get avatar: null; the shape is unchanged.

    Consumer impact: sites will now see data where they saw none. The learner sidebar's active quest (getActiveQuestData in @wabbit/tome-lms-ui/server, which reads getActiveEnrollments) starts listing the member's live courses, and dashboard pages built on getStudentDashboard start showing active enrollments, suggested lessons and a non-zero certificate count. wabbit.com pins @wabbit/tome-lms ^0.24.0, so nothing changes there until it bumps; check its learner pages (the authenticated layout's active quest section in the rail) at that bump.

3 fixes (0.26.1 – 0.26.2)
  • 0.26.2
    Canceled statuses now accept both spellings.

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

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

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

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

    Writers still store the old values.

    • @wabbit/tome-gamification@0.3.6
  • 0.26.1

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

  • 0.26.1
    Updated dependencies [deea397]
    • @wabbit/tome-gamification@0.3.6

0.25.xMinor

  • BREAKING: in enrollInCourse, an expired certification no longer satisfies a certification prerequisite.

    A prerequisite course is now also met when the member holds the certification that course awards.

    Migration: members whose only qualifying award has renewalStatus: 'expired' are now blocked on hard courses (or warned on soft ones) until it is renewed. To keep the old behavior, renew those awards or switch the course's prerequisiteStrictness to soft.

    Previously only a completed enrollment counted, so legacy imports with the award but no enrollment were blocked (or warned, on soft courses). Completion is derived on read: no enrollment rows are written.

    This is a read-side derivation. This check is expiry-blind: an expired certification still bridges course completion.

    A CERT prerequisite is stricter and now also excludes expired awards: an award satisfies it when approvalStatus is approved, null, missing or '' and renewalStatus is not expired. Pending and rejected awards never satisfy either kind of prerequisite.

    prerequisiteStrictness (hard blocks with not-authorized, soft emits lms.course-enrollments.prereq-warning) and the result and error shapes are unchanged.

    New exports: getEffectiveCourseCompletion(payload, memberId, courseIds, slugs?) and getSatisfiedPrereqCertificationIds(payload, memberId, certificationIds, slugs?) from @wabbit/tome-lms/server (batched, paginated, constant query count; the optional EffectiveCompletionSlugs lets a consumer that registered the enrollment, course or award collections under its own slugs point the reads at them), plus PREREQ_CERT_AWARD_WHERE (query form) and awardSatisfiesPrereq (in-memory form) from the package root and @wabbit/tome-lms/utilities/heldCertifications. Consumers that carry their own copies of this logic can switch to these.

0.24.xMinor

  • BREAKING: Certificate lookups now hide the holder's name unless the learner opted in, and new certificate and enrollment fields need a database migration.

    Migration: Pass { holderName: 'always' } to getCertificateByVerificationId to keep the old behavior. On SQL adapters generate and run a migration for the new fields (MongoDB needs none).

    Run backfillVerificationCodes once per consumer after migrating. Refunds now drop the active course enrollment; set commerce: { courseAccessHooks: false } to opt out.

    • certification-awards gains verificationCode (12 characters of Crockford Base32, read-only, generated on create, indexed non-unique in this release) and showNameOnVerify (the holder's opt-in, default off).
    • certifications gains signatoryName and signatoryTitle. course-enrollments gains endedAt, endedReason (refund / admin / learner / expired) and sourceOrderId. A learner-initiated dropFromCourse now stamps endedAt and endedReason: 'learner'.
    • @wabbit/tome-lms/scripts/backfillVerificationCodes gives existing awards a code. It is dry-run by default, writes through the database adapter without running collection hooks, and is idempotent.
    • @wabbit/tome-lms/server adds configureCertificateVerification, getPublicCertificateVerification (allow-list projection with valid / expired / revoked / not-found states), getStudentCertificate, setCertificateNameVisibility, normalizeVerificationCode and formatVerificationCode.
    • createLmsLayer({ verification: { baseUrl, path } }) configures URLs, so certificate records now carry verificationUrl and a code-based verificationId (the document id only before the backfill).
    • createLmsLayer registers getAccessSummary and revokeAccess for the course product type. A refund drops the learner's active enrollment (status: 'dropped', endedReason: 'refund', progress kept) via economy's refund access policy.
    • getCertificateByVerificationId now accepts an options object. It looks up by code first, then by document id while allowDocumentId stays on (default true for this minor), and returns the recipient only when the holder opted in.
  • @wabbit/tome-gamification@0.3.4

2 fixes (0.24.1)
  • 0.24.1

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

  • 0.24.1
    Updated dependencies [d51ad89]
    • @wabbit/tome-gamification@0.3.5

0.23.xMinor

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

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

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

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

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

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

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

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

  • 0.23.6
    Updated dependencies [775f90a]
    • @wabbit/tome-gamification@0.3.4
  • 0.23.5
    Existence and count checks now use payload.count() instead of reading every matching row; behavior 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.

  • 0.23.5

    Updated dependencies [c8827e7]

  • 0.23.5
    Updated dependencies [bcdf9e5]
    • @wabbit/tome-gamification@0.3.3
  • 0.23.4
    Local relationship-id helpers are replaced by @wabbit/tome-core/utilities/relationId.

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

    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.

  • 0.23.4
    The duck-typed role readers in access/roles.ts (readRoles, the tier match, and resolveRoleSlugs) now delegate to @wabbit/tome-core/auth/rbac.

    Behavior 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
  • 0.23.3
    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.

  • 0.23.2
    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 characterization 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-normalizing 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 BEHAVIOR (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 behavioral 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 behavior 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.

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

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

    Deleted, with every call site repointed:

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

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

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

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

  • 0.23.2
    One LayerFactoryConfig every layer factory's config extends, and one factory verb.

    Fourteen layer packages end in the same one call a consumer writes into payload.config.ts, and no two agreed on what config may contain: full seam vocabulary in three (org, lms, ledger), partial in six, NONE in six (2026-09-01 sale-readiness audit §5.3). A site that learned adminGroup from org and hooks from sc discovered, package by package, that six factories accept neither — not because the seam had been rejected, but because nothing said it existed.

    New in core (a NEW exports-map subpath, hence the minor): @wabbit/tome-core/utilities/layerFactoryConfig exports the LayerFactoryConfig interface — adminGroup, access (per-collection override map), hooks (appended via mergeHooks, never replacing), extraFields, fieldOverrides, omitFields, fieldOrder, slugs — and applyLayerFactoryConfig(collections, config), which honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to fields/fieldShape's applyFieldShape so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config.

    It is a separate subpath from ./utilities/layerRegistry deliberately — that module is in core's sideEffects array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph.

    The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed slugs?: Partial<XSlugs> map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's Record<string, string | undefined>), and named <name>Slug?: string scalars for relationship targets in OTHER layers (memberSlug, mediaSlug, eventSlug, rolesSlug) — those are pointers out of a layer, not entries in its key set.

    *Every `createLayer config now extends it.** Twelve extend LayerFactoryConfig directly and APPLY it through applyLayerFactoryConfig (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted.

    Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). economy and gamification both declare @wabbit/tome-core as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches registerLayer through a lazy require() in a try/catch for exactly this reason.

    applyLayerFactoryConfig is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by assert:no-forked-primitives. The trigger is stated: the day core becomes a required peer, delete the note and add one line.

    Both take the type via import type, which is erased at runtime.

    Two packages drop seams EXPLICITLY rather than accept-and-ignore. chrome extends Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'> because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against CollectionConfig, and chrome's slugs already have direct per-surface knobs (header.slug, footer.slug) a parallel map could contradict.

    The one seam it keeps, adminGroup, IS applied: globals carry admin.group exactly as collections do. rpg extends Omit<LayerFactoryConfig, 'access'> because CharacterSheetsConfig is a single collection's config that doubles as the layer factory's config, and its own access already means "this collection's access object" — one level shallower than the base's slug-keyed map.

    Two meanings under one name is the confusion this interface exists to end.

    Factory-verb convergence. Three verbs were live. createWorkflowLayer(config?) is new in @wabbit/tome-workflow (a new export — hence the minor) and returns a spreadable, deliberately EMPTY CollectionConfig[]: this layer is an engine, not a collection set, so the empty array is the honest answer and lets ...createWorkflowLayer() compose exactly like every sibling.

    Its WorkflowLayerConfig omits every seam for the same reason, and exists as the stable place a real option will land. createGamificationLayer and createRpgLayer are pure aliases of registerGamificationLayer / registerRpgLayer.

    initWorkflow, registerGamificationLayer and registerRpgLayer are all @deprecated with sunset at each package's next major; none is removed.

    Forcing function: scripts/assert-layer-factory-contract.mjs + pnpm assert:layer-factory-contract, wired into platform-discipline.yml after assert:layer-version (source reading only, pre-build). Every exported create*Layer must take a config parameter whose type resolves to LayerFactoryConfig — through extends, an intersection, or an explicit Omit<…> — with verb aliases followed to their register*/init* target.

    Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. docs/guides/create-a-new-layer-package.md gains a "The factory contract" section stating the rule and the three permitted responses.

    Three factories are ALLOWLISTED with a reason each: createAiLayer returns credential wiring and owns no collections, so every seam is meaningless to it; createFormsLayer and createIntakeLayer are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files.

    Peer floors: accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise @wabbit/tome-core to >=1.14.0 <2.0.0. The new subpaths do not exist below that, and a too-low floor is how ERR_PACKAGE_PATH_NOT_EXPORTED reached crowdfund's consumers once already.

    These are marked patch because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.

  • 0.23.2
    `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' behavior too, it is the obvious next thing to tighten, and tightening it would change behavior for existing callers — so it has a test pinning it instead of an unreviewed edit.

  • 0.23.2
    Manifest metadata: homepage, bugs, engines.

    All 46 publishable manifests were missing the three fields a consumer sees before any code (2026-09-01 sale-readiness audit §6). Metadata only — no source, no build, no runtime change.

    • homepage deep-links to that package README on GitHub (.../tree/main/packages/<dir>#readme). Without it a registry page links to the monorepo root and the reader has to guess which of 46 folders they want.
    • bugs.url points at the repo issue tracker, so a paying customer has a place to report a defect that is not email.
    • engines.node is >=22, matching the root engines and .nvmrc set the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use for node --test, and a package installed on Node 20 fails at a runtime the installer cannot connect back to the version.

    The forcing function ships with the change: scripts/assert-manifest-metadata.mjs (root pnpm assert:manifest-metadata, wired into platform-discipline.yml beside assert:license-metadata) fails when any publishable manifest lacks description, repository.directory matching its own folder, homepage, bugs, engines.node equal to the repo floor, license, files or sideEffects. It reported 138 violations before this change and 0 after.

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

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

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

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

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

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

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

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

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

  • 0.23.2

    Updated dependencies [4aeedad]

  • 0.23.2

    Updated dependencies [b01ca1f]

  • 0.23.2
    Updated dependencies [73081e6]
    • @wabbit/tome-gamification@0.3.2
  • 0.23.1
    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.

0.22.xMinor

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

0.21.xMinor

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

0.20.xMinor

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

0.19.xMinor

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

0.18.xMinor

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

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

0.17.xMinor

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

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

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

0.16.xMinor

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

0.15.xMinor

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

0.14.xMinor

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

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

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

0.13.xMinor

  • 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
1 fix (0.13.1)
  • 0.13.1
    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.

0.12.xMinor

  • 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

1 fix (0.12.1)
  • 0.12.1
    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

0.11.xMinor

  • 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 recognizes 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 behavior 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

0.10.xMinor

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

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

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

  • 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
1 fix (0.10.1)
  • 0.10.1
    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.

0.9.xMinor

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

6 fixes (0.9.1 – 0.9.3)
  • 0.9.3
    Updated dependencies [66f394b]
    • @wabbit/tome-core@1.3.4
    • @wabbit/tome-gamification@0.2.0
  • 0.9.2
    Updated dependencies
    • @wabbit/tome-core@1.3.3
    • @wabbit/tome-gamification@0.2.0
  • 0.9.1
    Docs-manifest emitter pipeline (W3 ship-readiness).

    @wabbit/tome-blocks-core now ships a standalone Node ESM CLI at scripts/emit-docs-manifests.mjs that emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free <pkg>/meta block-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild: node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>.

    To let the emitter import block metadata uniformly without dragging Payload config into a build script, the ./meta payload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block's BlockMeta was relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed.

    Every supported-core package additionally adds CHANGELOG.md to its published files array so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.

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

  • 0.9.1

    Updated dependencies [bed3f90]

  • 0.9.1
    Updated dependencies [850d51c]
    • @wabbit/tome-core@1.2.1

0.8.xMinor

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

0.7.xMinor

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

0.6.xMinor

  • 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
3 fixes (0.6.1 – 0.6.2)
  • 0.6.2

    Updated dependencies [a9801fe]

  • 0.6.2
    Updated dependencies [baf401e]
    • @wabbit/tome-core@1.1.0
  • 0.6.1
    Updated dependencies [8947ff1]
    • @wabbit/tome-core@1.0.12
    • @wabbit/tome-catalog@1.1.3

0.4.xMinor

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

0.2.xMinor

  • 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

Full changelog across all packages