Documentation menu
Documentation / Learning engine / lms
Lms
npm install @wabbit/tome-lmsFirst install from our registry?
Get a registry token from your credentials page. You need a purchase that includes this package, or a Craft Library membership.
Add the registry and your token to the
.npmrcat the root of your project, with your token in place ofYOUR_TOKEN:@wabbit:registry=https://npm.wabbit.com/ //npm.wabbit.com/:_authToken=YOUR_TOKEN
Tome 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:
| Peer | Range | Optional? |
|---|---|---|
@wabbit/tome-core | >=1.17.0 <2.0.0 | no |
payload | >=3.67.0 | no |
@payloadcms/richtext-lexical | >=3.67.0 | no |
@wabbit/tome-catalog | >=1.1.3 | yes |
lucide-react | >=0.460.0 | yes |
`@wabbit/tome-core` is a required peer, not a regular dependency — peerDependenciesMeta["@wabbit/tome-core"] sets optional: false, which matches (not dangling). `@wabbit/tome-gamification` is the one that ships as a regular `dependencies` entry (workspace:^, rewritten to a pinned version at publish), not a peer — it installs automatically with the package, so nothing to add for it separately.
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/*:
| Subpath | Purpose |
|---|---|
@wabbit/tome-lms/version | Layer version constant (createLmsLayer reports it to the layer registry — must equal package.json version) |
@wabbit/tome-lms/utilities/awardStatus | Canonical approvalStatus groupings for CertificationAwards ("does this award count") |
@wabbit/tome-lms/utilities/enrollmentProgress | Pure derivation of a course enrollment's progress/status from completedModules |
@wabbit/tome-lms/utilities/prerequisites | hasCompletedPrerequisites / isPhaseUnlocked — prerequisite evaluation against LessonCompletion rows |
@wabbit/tome-lms/utilities/instructorRoles | Pure role-derived instructor authority comparison |
@wabbit/tome-lms/utilities/ownerAuthority | Pure check of whether the owner stamp still confers authority |
@wabbit/tome-lms/utilities/certRenewalKeys | Notification key conventions for the certification renewal lifecycle |
@wabbit/tome-lms/utilities/attemptPolicy | Attempt/cooldown/lockout policy math for a graded module |
@wabbit/tome-lms/utilities/gradingCalibration | Pure 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/certificationAward | Standalone CertificationAwardCollection export |
@wabbit/tome-lms/collections/lesson | Standalone LessonCollection export |
@wabbit/tome-lms/collections/courseEnrollment | Standalone CourseEnrollmentCollection export |
@wabbit/tome-lms/collections/skillPath | Standalone SkillPathCollection export |
@wabbit/tome-lms/collections/academy | Standalone AcademyCollection export |
@wabbit/tome-lms/collections/course | Standalone CourseCollection export |
@wabbit/tome-lms/collections/topic | Standalone TopicCollection export |
@wabbit/tome-lms/collections/courseItem | Standalone CourseItemCollection export |
@wabbit/tome-lms/collections/quizAttempt | Standalone QuizAttemptCollection export |
@wabbit/tome-lms/collections/assignmentUpload | Standalone AssignmentUploadCollection export |
@wabbit/tome-lms/collections/examTicket | Standalone ExamTicketCollection export |
@wabbit/tome-lms/collections/examBypassRequest | Standalone ExamBypassRequestCollection export |
`.` highlights (100+ exports total — grouped by the barrel's own section comments):
| Group | Exports |
|---|---|
| Layer entry | createLmsLayer, LmsLayerConfig (registerLmsLayer is a @deprecated pure alias) |
| Composition | detectCatalog, detectEconomy, detectOrg, resolveOrgSlug, LmsComposition; resolveMemberSlug (re-exported from @wabbit/tome-core/identity) |
| Capabilities | TOME_LMS_CAPABILITIES, TOME_LMS_DEFAULT_GRANTS, seedTomeLmsCapabilities |
| Access | LMS_ROLE_TIERS, LmsRoleTier, and ~35 access predicates/builders (courseRead, enrollmentRead, buildLessonRead, isAdmin, hasPrivilegedRole, etc.) |
| Guards | canAccessCourseItem, canAccessLesson, isEnrolled, isInstructor, isCourseOwner, hasActiveAccess, GuardContext |
| Collections (25) | AcademyCollection, CourseCollection, TopicCollection, LessonCollection, CourseItemCollection, CourseEnrollmentCollection, LessonCompletionCollection, StudentProfileCollection, QuizAttemptCollection, AssignmentUploadCollection, CertificationCollection, CertificationAwardCollection, ExpertCredentialCollection, SkillPathCollection, TrainingEventCollection, TrainingEventAttendanceCollection, InstructorAvailabilityCollection, BadgeCollection, AchievementCollection, PointsCollection, GradebookEntryCollection, CourseReviewCollection, StudentNoteCollection, ExamTicketCollection, ExamBypassRequestCollection — all re-exported individually so a site can swap one collection and pass its own replacement (ExamTicketCollection/ExamBypassRequestCollection are opt-in: not part of createLmsLayer's baseCollections, per the barrel's own comment) |
| Hooks | Content-sync (syncDisplayContent, syncParentRelationship, cleanupParentReferences), progress (computeProgress, onLessonCompletion), cert-workflow (enforceApprovalStatus, updateCertificationHolderCount, updateMemberCerts, autoNominateExpert), scheduling (recountTrainingEventAttendees, assignInstructorFromUser), uniquePair |
| Attempt-workflow hooks | createEnforceAttemptPolicy(options?) — QuizAttempt beforeChange that rebuilds attempt count, lockout and cooldown from prior attempts and blocks a create with AttemptPolicyBlockedError (instanceof-checkable, carries .reason); opt in with createQuizAttemptCollection's enforceAttemptPolicy option (default off; defaults 3 attempts, 48 h cooldown, override via progressionRules). createAssignAssignmentAttemptNumber(options?) — AssignmentUpload beforeChange that sets attemptNumber and links previousUpload per student, lesson and assignment block; wired by default. Types AttemptPolicyOptions, AssignmentAttemptNumberOptions |
| Course workflow hooks | promoteOwnerToMaintainerOnApproval — on the transition into workflowStatus: 'approved', moves owner into maintainers (backfilling createdBy first) so the creator keeps a revocable grant; on by default, builtInHooks.promoteOwnerToMaintainerOnApproval: false turns it off. createRequirePublishedLessonsOnPublishHook(options?) — refuses to publish a course with no published lessons (checked through CourseItem rows). createDropEnrollmentsOnDeleteHook(options?) — on course delete, sets its open enrollments to dropped instead of leaving them dangling. The last two are off by default; enable them with createCourseCollection({ invariantHooks: { requirePublishedLessonsOnPublish: true, dropEnrollmentsOnDelete: true } }). Option types RequirePublishedLessonsOnPublishOptions, DropEnrollmentsOnDeleteOptions |
| Utilities | Gamification (checkAndAwardBadges, awardPoints), grading (scoreQuizAnswers, gradeQuizAttempt, syncGradebookEntry), progress (computeEnrollmentProgress, flattenCurriculumTree, getCompletedLessonIds), prerequisites (hasCompletedPrerequisites, isPhaseUnlocked) |
| Types | 25+ type exports — see src/types.ts for the full list |
`@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.
Links
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/lmssurface 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-productsduplicate-product-table (deprecated collection) so search/marketplace/reporting can enumerate "all products" through one registry instead of a sharded one;createCourseProduct/getCourseProduct(confirmed live insrc/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
AssignmentUploadis unchanged.Additive changes, all off by default unless noted:
- `Course.subject`:
createCourseCollection({ subjectOptions })(orcreateLmsLayer({ course: { subjectOptions } })) adds an optional, indexedsubjectselect afterlevel. With no options the field is not added at all. An option value containing:throws at construction. - `Course.outcomes`:
outcomes: true(factory, orcreateLmsLayer({ course: { outcomes: true } })) adds anoutcomesarray of{ text }rows (up to 12) afterdescription. - `Lesson.summary`:
createLessonCollection({ summary: true })(orcreateLmsLayer({ lesson: { summary: true } })) adds an optionalsummarytextarea aftertitle. - `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 withreviewedStatuses). A student resubmission never stamps it, create never stamps it, and existing rows staynull(no backfill). The stamp is a field-level hook onreviewedAt, so the collection's hook arrays are unchanged. Opt out of the stamp withstampReviewedAt: false. - Learner visits:
createLmsLayer({ learnerVisits: true })adds two read-only dates to StudentProfile,lastVisitedAtandpreviousVisitedAt.recordVisit(payload, memberId)writes them (a visit is a run of requests with gaps under 30 minutes, configurable withidleMinutes) andgetPreviousVisit(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,learnerVisitsEnabledandgetStudentAssignments(payload, memberId, options?)(the latest upload per lesson and assignment block), with their types. - `@wabbit/tome-lms` (root) also exports the pure
getCourseTotalsandCourseTotals, pluscreateStampReviewedAt,isReviewWrite,DEFAULT_REVIEWED_STATUSES,createLearnerVisitFields,withLearnerVisitFields,LAST_VISITED_AT_FIELDandPREVIOUS_VISITED_AT_FIELD.
New columns:
assignment-uploads.reviewedAt(date, nullable) always;courses.subject(text/enum, nullable),courses.outcomes(array oftext),lessons.summary(text, nullable),student-profiles.lastVisitedAtandstudent-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 forreviewedAton upgrade.- `Course.subject`:
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
EnrollmentStatustype exported from@wabbit/tome-lms/serverdropsactiveandsuspended(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 acourseobject without any key set, still keep the staticCourseCollectionreference; the default level vocabulary is unchanged.- Correctness fix: "active" enrollments are now
enrolledandin-progress(newACTIVE_ENROLLMENT_STATUSES, the set the access guard, the course access hooks and the expiry sweep already use).getActiveEnrollmentsand theenrollments.activelist and suggested next lessons ofgetStudentDashboardfiltered on astatus: 'active'value CourseEnrollment never writes, so they always returned nothing. TheEnrollmentStatustype exported from@wabbit/tome-lms/servernow 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 defaultenrolledinstead ofactive. - Correctness fix:
getStudentDashboard'sstats.totalCertificatescounted awards withstatus: 'active', which no award has, so it was always 0. It now counts held awards:status: 'valid'andapprovalStatusinEFFECTIVE_AWARD_STATUSES(approved, or a legacy unset row), the predicate the holder count and the activity feed use. getCourseLandingDatainstructors now carryavatarfrom the populated staff user: anavatarupload, elseimage(a populated upload or an absolute or root-relative URL string, the Better Auth shape). Users without one still getavatar: null; the shape is unchanged.
Consumer impact: sites will now see data where they saw none. The learner sidebar's active quest (
getActiveQuestDatain@wabbit/tome-lms-ui/server, which readsgetActiveEnrollments) starts listing the member's live courses, and dashboard pages built ongetStudentDashboardstart 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(CANCELEDon the status board) next to the existing value. Reads, queries, the closed-event lock, event board state and selection, the calendar export (STATUS:CANCELLEDis unchanged, it is the iCalendar literal), the status board badge, theme selectors fordata-event-statusand the erasure trail treat the two spellings as one meaning.The event list board labels gain a
canceledkey that is read before the oldcancelledkey. The old field is hidden, not removed, so stored text is kept on save.The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)".
Writers still store the old values.
- @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'sprerequisiteStrictnesstosoft.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
approvalStatusisapproved,null, missing or''andrenewalStatusis notexpired. Pending and rejected awards never satisfy either kind of prerequisite.prerequisiteStrictness(hard blocks withnot-authorized, soft emitslms.course-enrollments.prereq-warning) and the result and error shapes are unchanged.New exports:
getEffectiveCourseCompletion(payload, memberId, courseIds, slugs?)andgetSatisfiedPrereqCertificationIds(payload, memberId, certificationIds, slugs?)from@wabbit/tome-lms/server(batched, paginated, constant query count; the optionalEffectiveCompletionSlugslets a consumer that registered the enrollment, course or award collections under its own slugs point the reads at them), plusPREREQ_CERT_AWARD_WHERE(query form) andawardSatisfiesPrereq(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' }togetCertificateByVerificationIdto keep the old behavior. On SQL adapters generate and run a migration for the new fields (MongoDB needs none).Run
backfillVerificationCodesonce per consumer after migrating. Refunds now drop the active course enrollment; setcommerce: { courseAccessHooks: false }to opt out.certification-awardsgainsverificationCode(12 characters of Crockford Base32, read-only, generated on create, indexed non-unique in this release) andshowNameOnVerify(the holder's opt-in, default off).certificationsgainssignatoryNameandsignatoryTitle.course-enrollmentsgainsendedAt,endedReason(refund/admin/learner/expired) andsourceOrderId. A learner-initiateddropFromCoursenow stampsendedAtandendedReason: 'learner'.@wabbit/tome-lms/scripts/backfillVerificationCodesgives 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/serveraddsconfigureCertificateVerification,getPublicCertificateVerification(allow-list projection withvalid/expired/revoked/not-foundstates),getStudentCertificate,setCertificateNameVisibility,normalizeVerificationCodeandformatVerificationCode.createLmsLayer({ verification: { baseUrl, path } })configures URLs, so certificate records now carryverificationUrland a code-basedverificationId(the document id only before the backfill).createLmsLayerregistersgetAccessSummaryandrevokeAccessfor thecourseproduct type. A refund drops the learner's active enrollment (status: 'dropped',endedReason: 'refund', progress kept) via economy's refund access policy.getCertificateByVerificationIdnow accepts an options object. It looks up by code first, then by document id whileallowDocumentIdstays on (defaulttruefor 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. Addsfeedback(textarea, optional, additive) alongsidegradedBy/gradedAt, mirroringAssignmentUpload's flatfeedbackfield rather than a nestedgradegroup.Nothing writes it yet — same posture
gradedBy/gradedAtshipped 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, additivestatusoption — zero-config default/write behavior is unchanged (submitQuizAttemptandgrading.tsstill 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'soutcomeOfnow treats'graded'as a third terminal outcome alongside'completed'/'timed-out'(a graded row already carries a realpassedverdict, so excluding it would let a graded-and-failed essay attempt escape the maxAttempts/cooldown policy).saveQuizProgress'sstatus !== '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'CertificationAwardkept itsapprovalStatus: 'approved'(revocation never touches that field) and so was still counted byupdateCertificationHolderCountand still derived intoMember.certificationAwards[]/certifications[]byupdateMemberCerts— both hooks filtered ONLY onEFFECTIVE_AWARD_STATUSES(approvalStatus-vocabulary). New exportEXCLUDE_REVOKED_WHERE(utilities/awardStatus.ts) — a separately-composed{ status: { not_equals: 'revoked' } }fragment, ANDed alongsideEFFECTIVE_AWARD_STATUSESin both hooks' queries, never folded into that constant (which stays exactly what every existing reader already relies on it meaning).Companion predicate
isRevokedAwardStatusfor in-memory checks. Deliberately does NOT touch'expired'— therenewalStatusstate machine (checkCertificationExpirysweep) owns that transition and already self-selects viastatus: 'valid'; a consumer wanting both exclusions composes{ status: { not_equals: 'expired' } }alongside this fragment the same wayattemptChallengeMode(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 (sincegradeSubmission's quiz-attempts close-out re-writesstatus: 'completed') an already-graded essay resurfaces in this same query forever.No consumer currently even renders the
essaysbucket (instructor/page.tsxonly usespendingAssignments).'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_STATUSESunchanged pin +EXCLUDE_REVOKED_WHERE/isRevokedAwardStatuscoverage),tests/cert-holder-count-recount.test.tsandtests/cert-member-sync.test.ts(revoked-excluded / expired-unchanged cases; their mockWherematchers gainednot_equalssupport to exercise the real fragment).Full lms suite green (934, up from 916 baseline);
assert-node-loadable --every-fileunchanged (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.ctsdeclarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license:/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */.The
.mapfiles and thesourceMappingURLcomments that pointed at them are gone, which roughly halves the size of each tarball.Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line.
A
'use client'directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged.No API change, no runtime behavior change, and nothing to do on upgrade.
In
@wabbit/tome-blocks-gallery, the source snapshotsextractGallerySourcewrites from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code. - 0.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,
nullvs.undefined, polymorphic), so behavior is unchanged. The@wabbit/tome-corepeer floor goes up to>=1.17.0because that is the first core version exportingrelationIdRaw,relationIdsandrelationIdsRaw.relationshipIdandserver'sresolveRelationId(public) are kept as deprecated delegates torelationIdRaw(v, { polymorphic: true }), with identical semantics. The internalserver/mutationsextractIdis removed. - 0.23.4
The duck-typed role readers in
access/roles.ts(readRoles, the tier match, andresolveRoleSlugs) now delegate to@wabbit/tome-core/auth/rbac.Behavior is unchanged: lms still memoizes under its own
req.contextkey, andreadRoles/resolveRoleSlugs(req, userCollection?)keep their signatures.@wabbit/tome-gamificationkeeps its own copy because core is an optional peer there.- @wabbit/tome-gamification@0.3.2
- 0.23.3
gradeQuizAttemptnow reads quiz fields from the row'sblockData, the shapeLesson.blocksactually persists ({ blockType, blockData: json }— Payload strips any other top-level key on write).Previously it read
questions/passingScoreflat off the row, so every stored quiz graded as 0/0 through the one-step path. It also accepts the two-step mutation'squizBlockblock type alongsidequiz.Flat in-memory rows still work.
- 0.23.2
Adopt
@wabbit/tome-core/fields/addressand@wabbit/tome-core/utilities/relationIdat 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—accountsandcontactseach carried a byte-identical seven-fieldaddressgroup. Both now spreadpostalAddressFields({ vocabulary: 'legacy-crm' })after their ownnameline (nameis the company/contact line, not a postal line).tests/address-characterisation.test.tspins both groups whole.@wabbit/tome-deals—billingAddressandshippingAddressinside the frozen Customer Snapshot were copies three and four. They now come from onebuildSnapshotAddressGrouphelper:name+companyprepended locally, the six postal lines from core, and the eight per-field labels plus the'US'country default passed through core'sfieldOverridesseam. 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.tspins 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 validatedcountry. Its postal lines stay FLAT at collection top level (they are stored columns with PII rows and a GDPR registration behind them), now viapostalAddressFields({ 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.tspins the whole top-level field ORDER plus the validator's and hook's BEHAVIOR (acceptsUS, rejectsusa, rewrites' us 'to'US'), not their identity.
`relationId` — the four-return-types problem.
@wabbit/tome-lms— twelve modules undersrc/server(academy,catalog,certificates,course,dashboard,enrollment,grades,leaderboard,learnerShell,notes,profile,reviews) carried a byte-identicalstring | nullcopy. They importrelationIdfrom 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 returnsnull.tests/relation-id-adoption.test.tspins 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.tsandsrc/server/awardGate.tsreturnstring | numberorundefined. 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.tsre-exports core'srelationId, keepingextractIdas a local alias (the module is private to that sub-cluster). This one WIDENS: the sc copy returnedstring | 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 topayload.find/updateare unaffected, since Payload accepts either form in awhereclause. sc's 179 tests stay green.@wabbit/tome-crm— the inline ternary inintegration/deals.ts(typeof oppRaw === 'object' ? oppRaw.id : oppRaw) was the fifth shape and had the same numeric-id asymmetry; it is onerelationId(deal.opportunity)call now.
`fetchMemberId` ×4 — one implementation (sc).
asset-availability,fleet-logsandfleeteach carried a verbatim copy of the auth-user → Member-row lookup, andresource-requestscarried its projecting twin. All four now import fromsrc/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 awhereclause, not a relationship read.tests/fleet-shared-helpers.test.tspins the adoption, the three knobs, and the null-for-anonymous contract. - 0.23.2
Import
mergeHooks,fieldShapeand the select-option override contract from@wabbit/tome-coreinstead of keeping local copies (2026-09-01 sale-readiness audit §5.1, T3(a)).No API change: every symbol these packages exported before is still exported, now re-exported from core, and every factory produces byte-identical output.
Deleted, with every call site repointed:
org/src/hooks/mergeHooks.ts,lms/src/collections/shared/mergeHooks.ts,sc/src/extensions/mergeHooks.ts→@wabbit/tome-core/hooks/mergeHooks. Core has exported this since July; sc's copy still carried a header claiming "Neither @wabbit/tome-core nor @wabbit/tome-org exports this."lms/src/server/jobs/paginate.ts,crowdfund/src/server/jobs/paginate.ts,workflow/src/server/paginate.ts→@wabbit/tome-core/jobs.org/src/fieldShape.ts+org/src/insertFieldsAfter.ts,lms/src/collections/shared/fieldShape.ts→@wabbit/tome-core/fields/fieldShape. Org'sresolveFieldDescription/FieldDescriptionOverridewere NOT part of the duplicated set and stay in the package, moved toorg/src/fieldDescriptions.ts.org/src/optionOverrides.ts,lms/src/collections/shared/optionOverrides.ts→@wabbit/tome-core/fields/selectOptions;sc/src/collections/registry/shared.tsnow re-exports it (itsextractIdis a separate audit item and is untouched).
Peer floor raised to `@wabbit/tome-core` `>=1.14.0 <2.0.0` in all five packages, because each now imports a subpath or a named export that first exists in that core minor:
./fields/fieldShapeand./fields/selectOptionsare new subpaths, andfindPaged/chunk/readPositiveNumberare new named exports on the pre-existing./jobs. Crowdfund's floor moves from>=1.7.0even though./jobsitself shipped in 1.7.0 — the subpath resolving is not the same thing as the export existing, which is the sharper version of the lesson its own 0.1.1 CHANGELOG records (ERR_PACKAGE_PATH_NOT_EXPORTED).Org moves from
>=1.2.0, lms from>=1.0.0, sc from>=1.11.0, workflow from>=1.7.0.Header comments that pointed at the deleted files, or asserted core did not export these, were corrected rather than left dangling.
- 0.23.2
One
LayerFactoryConfigevery layer factory's config extends, and one factory verb.Fourteen layer packages end in the same one call a consumer writes into
payload.config.ts, and no two agreed on whatconfigmay contain: full seam vocabulary in three (org, lms, ledger), partial in six, NONE in six (2026-09-01 sale-readiness audit §5.3). A site that learnedadminGroupfrom org andhooksfrom sc discovered, package by package, that six factories accept neither — not because the seam had been rejected, but because nothing said it existed.New in core (a NEW exports-map subpath, hence the minor):
@wabbit/tome-core/utilities/layerFactoryConfigexports theLayerFactoryConfiginterface —adminGroup,access(per-collection override map),hooks(appended viamergeHooks, never replacing),extraFields,fieldOverrides,omitFields,fieldOrder,slugs— andapplyLayerFactoryConfig(collections, config), which honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated tofields/fieldShape'sapplyFieldShapeso the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config.It is a separate subpath from
./utilities/layerRegistrydeliberately — that module is in core'ssideEffectsarray, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph.The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed
slugs?: Partial<XSlugs>map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base'sRecord<string, string | undefined>), and named<name>Slug?: stringscalars for relationship targets in OTHER layers (memberSlug,mediaSlug,eventSlug,rolesSlug) — those are pointers out of a layer, not entries in its key set.*Every `createLayer
config now extends it.** Twelve extendLayerFactoryConfigdirectly and APPLY it throughapplyLayerFactoryConfig(accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest,adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted.Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). economy and gamification both declare
@wabbit/tome-coreas an OPTIONAL peer and hold zero runtime imports of it — gamification reachesregisterLayerthrough a lazyrequire()in a try/catch for exactly this reason.applyLayerFactoryConfigis a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred byassert:no-forked-primitives. The trigger is stated: the day core becomes a required peer, delete the note and add one line.Both take the type via
import type, which is erased at runtime.Two packages drop seams EXPLICITLY rather than accept-and-ignore. chrome extends
Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed againstCollectionConfig, and chrome's slugs already have direct per-surface knobs (header.slug,footer.slug) a parallel map could contradict.The one seam it keeps,
adminGroup, IS applied: globals carryadmin.groupexactly as collections do. rpg extendsOmit<LayerFactoryConfig, 'access'>becauseCharacterSheetsConfigis a single collection's config that doubles as the layer factory's config, and its ownaccessalready means "this collection's access object" — one level shallower than the base's slug-keyed map.Two meanings under one name is the confusion this interface exists to end.
Factory-verb convergence. Three verbs were live.
createWorkflowLayer(config?)is new in@wabbit/tome-workflow(a new export — hence the minor) and returns a spreadable, deliberately EMPTYCollectionConfig[]: this layer is an engine, not a collection set, so the empty array is the honest answer and lets...createWorkflowLayer()compose exactly like every sibling.Its
WorkflowLayerConfigomits every seam for the same reason, and exists as the stable place a real option will land.createGamificationLayerandcreateRpgLayerare pure aliases ofregisterGamificationLayer/registerRpgLayer.initWorkflow,registerGamificationLayerandregisterRpgLayerare all@deprecatedwith sunset at each package's next major; none is removed.Forcing function:
scripts/assert-layer-factory-contract.mjs+pnpm assert:layer-factory-contract, wired intoplatform-discipline.ymlafterassert:layer-version(source reading only, pre-build). Every exportedcreate*Layermust take a config parameter whose type resolves toLayerFactoryConfig— throughextends, an intersection, or an explicitOmit<…>— with verb aliases followed to theirregister*/init*target.Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral.
docs/guides/create-a-new-layer-package.mdgains a "The factory contract" section stating the rule and the three permitted responses.Three factories are ALLOWLISTED with a reason each:
createAiLayerreturns credential wiring and owns no collections, so every seam is meaningless to it;createFormsLayerandcreateIntakeLayerare owned by the forms+intake access wave running in parallel, whose changes rewrite the same files.Peer floors: accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise
@wabbit/tome-coreto>=1.14.0 <2.0.0. The new subpaths do not exist below that, and a too-low floor is howERR_PACKAGE_PATH_NOT_EXPORTEDreached crowdfund's consumers once already.These are marked
patchbecause the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead. - 0.23.2
`approveOJT` gains the in-body capability check its siblings already had.
2026-09-01 sale-readiness audit §4.2:
approveOJTwas the only instructor-gated mutation with no in-body capability assertion —cooldown.tsandtraining-event.tsboth have one.runMutation's capability step only fires when the CALLER declaredrequiredCapabilities; 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 declaredinstructororadmin, or wired a resolver — the same shape and the same fail-closed rationale as the two siblings. The rejection happens before anypayloadread 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
requiredCapabilitiesarray (each verified to touch nothing); acceptance forinstructorand foradmin; 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.
homepagedeep-links to that package README on GitHub (.../tree/main/packages/<dir>#readme). Without it a registry page links to the monorepo root and the reader has to guess which of 46 folders they want.bugs.urlpoints at the repo issue tracker, so a paying customer has a place to report a defect that is not email.engines.nodeis>=22, matching the rootenginesand.nvmrcset the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use fornode --test, and a package installed on Node 20 fails at a runtime the installer cannot connect back to the version.
The forcing function ships with the change:
scripts/assert-manifest-metadata.mjs(rootpnpm assert:manifest-metadata, wired intoplatform-discipline.ymlbesideassert:license-metadata) fails when any publishable manifest lacksdescription,repository.directorymatching its own folder,homepage,bugs,engines.nodeequal to the repo floor,license,filesorsideEffects. It reported 138 violations before this change and 0 after. - 0.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
overrideAccessjustification is preserved; only the number of round-trips changes.`find({ limit: 0 })` → `payload.count()` —
limit: 0setspagination: falsein the Mongo adapter, so the query loads every matching row into memory to produce one number.@wabbit/tome-orgdocuments this as a production incident inhooks/attendance-count-sync.ts:6-11and had reintroduced it incollections/recruiting/JoinRequest.ts's one-pending-request guard;@wabbit/tome-sc's squadron member recount had the same shape.Independent lookups → `Promise.all` / `Promise.allSettled` — org's
createGiverWindowAccess(a per-request access check that took two serial round-trips),EventAttendance's display-name composer andAwardPresentation's, sc's RSI profile+org page fetches (the hot path for handle validation, against a third-party host), squadron recalcs, and accounts' three offboarding teardown callbacks.allSettledwherever a branch had its own fallback, so a failed member lookup still cannot stop the event title from resolving. sc's RSI adapter keeps its 404 short-circuit exactly, and ledger's per-leg balance guard decides in leg order so the thrownNegativeBalanceErrorstill names the same wallet the serial version did.Independent per-row writes → `batchWrite` (
@wabbit/tome-core/utilities/batch) — org's notification open/resolve fan-outs, the division/team cleanup and sunset cascades (up to 1,000 rows each), the event cascade-delete, the non-atomicmemberCountfallback; lms's certification-expiry sweep (now paced inWRITE_CHUNKchunks like its sibling reconciler) and the course-delete enrollment drop; workflow's deadline sweep; crowdfund's tier-claim reconcile.Same `data` for every row → one bulk `payload.update({ where, data })` — sc's asset-assignment auto-close and the transfer-request GDPR redaction, matching
sc/src/gdpr.ts'smakeNullRefHandler. The auto-close also drops a latent correctness hazard: its page cursor advanced while its own writes removed rows from the filter it was paging over, so a page boundary could skip assignments.Two collection-level fixes. sc's Fleet had two field-level
beforeChangehooks each issuing afindByIDfor the SAME ship on every write; they are now one collection-level hook that reads the ship once and sets bothchassisNameandname. lms'scheckCertificationExpiryre-derivedrecountHoldersonce per expired award with no cache; it now recounts once per affected certification, after the sweep — which is also more correct, since only the final count was ever right.@wabbit/tome-crowdfund'ssettleCampaignpledge loop is untouched and now carries an expliciteslint-disableplus the reason: it captures money one pledge at a time against amaxCapturesPerRunbudget that only bounds anything if the iterations are serialized.@wabbit/tome-org,@wabbit/tome-lms,@wabbit/tome-workflowand@wabbit/tome-crowdfundraise their@wabbit/tome-corepeer floor to>=1.14.0, the release that adds./utilities/batch. org and lms were also understating their floor before this change — both already imported@wabbit/tome-core/jobs, added in core 1.7.0, while declaring>=1.2.0/>=1.0.0. - 0.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_FLAGwas the string'internal'— a key@payloadcms/richtext-lexicalRESERVES as its own object namespace onreq.context(context.internal.richText = {}). Setting it to a boolean bypass broke richText validation on any certification the holder-count recount’s nestedpayload.updatetouched (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:updateCertificationHolderCountandupdateMemberCertsnow run their nested writes under a freshreq.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+createLmsLayercollection 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 onlysubmissionReference(the original form-submission pointer from the donor consumer, untouched, stays hard-typed toformSubmissionRelationTo). Two new ADDITIVE, OPTIONAL, single-target relationship fields —quizAttemptRef(→quizAttemptRelationTo, default'quiz-attempts') andassignmentUploadRef(→assignmentUploadRelationTo, default'assignment-uploads') — are inserted right aftersubmissionReference, emitted ONLY incompletionStore: 'completed-modules'mode.Zero-config (
'lesson-completions'mode, the default) is byte-identical — pinned intests/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/.assignmentUploadaccept the sameQuizAttemptCollectionConfig/AssignmentUploadCollectionConfigshape those factories already take directly, letting a site pass a non-default config (custom slug,studentRelationTo,extraFields,requireBlockIds, etc.) straight throughcreateLmsLayerinstead of filtering the returnedcollectionsarray and appending its own re-built replacement afterward — EDU's prior pattern, retired by this seam. Zero-config (both keys omitted, orcollections: {}) keeps the exact byte-identical staticQuizAttemptCollection/AssignmentUploadCollectionsingletons — REFERENCE-identical, not a freshcreateXCollection({})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.tsalready re-exports it from./types, guarded byquiz-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 intests/course-enrollment-completed-modules.test.ts(field presence/position/shape/relationTo-seam coverage forquizAttemptRef/assignmentUploadRef). Full lms suite green; no new skips inassert-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/AssignmentUploadfrom staticCollectionConfigexports tocreateQuizAttemptCollection/createAssignmentUploadCollectionfactories, per the fieldShape/mergeHooks/per-verb-access disciplineCertificationAward.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.tswere 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 writesassignmentBlockIdonto every submission today, but the collection declared no such field — Payload silently dropped it on every write until this port.QuizAttempt.
quizBlockId's tightening torequired: true(closing the ledgered gap where multi-quiz lessons were indistinguishable) is gated behindrequireBlockIds(defaultfalsein 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 viagradedByRelationTo, default'users') andgradedAt(date) are additive/optional fields for the manual-essay grading path (mirrorsAssignmentUpload.grade.gradedBy/gradedAt) — nothing writes them yet; a grading UI has somewhere to put results now.Opt-in
enforceAttemptPolicy(defaultfalse) wires a new blockId-scoped, policy-awarebeforeChangehook (hooks/attempt-workflow/enforceAttemptPolicy.ts) that recomputes{attempts, lockedOut, cooldownExpiry}purely from prior QuizAttempt rows (folding every graded prior attempt throughutilities/attemptPolicy.ts'sapplyAttemptOutcome, called verbatim — never reimplemented), assignsattemptNumberfrom that state, and throws (blocking the create) on lockout or an active cooldown. This hook runs BEFORE the pre-existingautoIncrementAttemptNumber(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:typeswill show exactly these on a consumer that regenerates:assignmentBlockId(text, indexed; the fieldsubmitAssignmentwas 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-placesubmitAssignment; 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 samerequireBlockIdsoption (defaultfalse), same 1.0-flip note as QuizAttempt.attemptNumber/previousUploadauto-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:startQuizAttemptalways pre-supplies a positiveattemptNumberon every create it issues, so neither the old nor the new attemptNumber hook's recompute branch is ever reached on that path, regardless ofenforceAttemptPolicy's value — the flag only affects direct collection writes that omitattemptNumber(admin UI, seed scripts, out-of-band API calls).Neither mutation enforces maxAttempts/cooldown today (a TODO in
startQuizAttemptnotes 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/createExamBypassRequestCollectionreproduce 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-workflowbypass-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 viaconfig.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.tscollections array alongsidecreateLmsLayer(...)'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 +previousUploadlinking),exam-cluster-characterisation.test.ts(18 — zero-config defaults, consumer-emulation-shape relation targets, seam injection, confirms neither collection is increateLmsLayer), 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/Topicscollections 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/lessonsarrays, 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}.tsconvert from staticCollectionConfigexports tocreateCourseCollection/createTopicCollection/createCourseItemCollection, using the same./collections/shared/{fieldShape,optionOverrides,mergeHooks}.tsmechanism as every prior port. All three static exports remaincreate*Collection()with every default — byte-identical wiring, pinned bytests/{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 exactversions: { drafts: true }plus areadVersionsdefault, matching the same trapLesson.ts'sversionsMode: 'snapshot-history'already guards against), the standard field-shape pipeline, per-verbaccess, and ahooksmerge seam. Topic gains the three polymorphicprerequisites[].itemleg seams (lessonRelationTo/topicRelationTo/courseRelationTo) plus the standard pipeline — no built-in hooks (none pre-port).CourseItem gains
courseRelationTo/topicRelationTo/lessonRelationTo, an injectableitemTypeOptionsvocabulary, and the standard pipeline;parent/unlockAfternow self-reference the collection's own configuredslugrather 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), checksLesson.isPublished, blocks the transition intostatus: '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 unifiedreq.context.internalsystem-bypass convention (../cert-workflow/enforceApprovalStatus.ts's constant) rather than the donor's ownmigrationBackfillflag name.Enabled via
invariantHooks: { requirePublishedLessonsOnPublish: true, dropEnrollmentsOnDelete: true }— both defaultfalse, 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 viabuiltInHooks.promoteOwnerToMaintainerOnApproval(defaulttrue, byte-identical). This exists becausemergeHooksonly appends: the donor consumer's real hook order requiresrequireReapprovalOnCertChangeto run BEFOREpromoteOwnerToMaintainerOnApproval(both mutateworkflowStatusin 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
beforeChangesequence.tests/course-seams.test.tsproves this exact case functionally.Donor-consumer-specific invariants stay consumer-side — verified expressible, not ported.
requireReapprovalOnCertChange,validateSMEInstructors(needs that consumer's ownsme-designationscollection), the deletion-request workflow (that consumer's own reviewer/reason group), and the notify fan-outs attach via thehooks/extraFieldsseams, never promoted to the factory. A dedicated consumer-adoption-shape emulation test intests/course-seams.test.tsproves the full shape end-to-end:staffRelationTo: 'members',instructorRoles/deletionRequest/legacytopics/lessonsarrays viaextraFields,versionsMode: 'drafts', the two opt-in invariants enabled, and the three consumer-specific hooks attached in that consumer's required order via thebuiltInHooksopt-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.orderarrays AND real CourseItem junction rows at once, until that consumer's own data migration retires the arrays. Bothcourse-seams.test.tsandtopic-seams.test.tsinclude a dedicated coexistence test: the legacy fields land as ordinaryextraFieldsthe factory neither reads nor writes, and — for Course — the opted-inrequirePublishedLessonsOnPublishinvariant 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).
AcademyCollectionConfiggains an injectablelabelsoption (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.tsgains two tests covering the default and the override.Subpaths. New
./collections/course,./collections/topic,./collections/courseItemexports — same poisoned-barrel-alternate-door pattern as the five prior subpaths (all three collections' own module graphs were already clean:payloadtypes,../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.tsgains 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}andhooks/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_VERSIONandpackage.jsonversion 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 (
createRecomputeCompletedModulesProgressHookforcompletionStore: 'completed-modules', andcomputeProgress/createComputeProgressHookfor the default'lesson-completions'mode) fired unconditionally on everyafterChangeand 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 unchangedcompletedModulesarray and fired a corrective update reverting it — a dropped enrollment did not survive its own drop.createRecomputeCompletedModulesProgressHooknow comparescompletedModulesFieldNamebetweendocandpreviousDocand no-ops when it did not change (acreate, with nopreviousDoc, always derives — there is no baseline to diff against). Both hooks also gained apreserveStatusesoption (default['dropped']): when the enrollment's current status is in the list, progress can still update from a genuinecompletedModules/LessonCompletionchange, 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.preserveStatusesthreads the same list to whichever hookcompletionStoreselects; omitting it keeps each hook's own default and the byte-identicalcomputeProgresssingleton 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(andcreateCourseEnrollmentCollection) gainprogressFieldName/statusFieldName/completionDateFieldName/completedModulesFieldNameoptions (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 viafieldOverrides(e.g.overallProgress->progress) no longer gets a hook silently writing to a dead key.createCourseEnrollmentCollection({ completionStore: 'completed-modules' })gainscompletedModulesHooks: { autoAwardCertification?: boolean; awardCourseCompletionBadges?: boolean }(both defaulttrue) — a consumer running its own gated award pipeline can now drop the factory's unconditional award hooks from theafterChangechain. The progress-recompute hook itself is not optional.createLessonCollectiongainssyncDisplayContent?: boolean(defaulttrue) — a consumer whose own same-named hook does something else entirely can omit the factory's built-in frombeforeChangeinstead of positionally slicing the merged array.createLessonCollection,createSkillPathCollection,createCertificationAwardCollection, andcreateCourseEnrollmentCollectiongain alabelspassthrough option (each defaulting to its existing hardcoded{ singular, plural }) — previously only reachable by patching the constructedCollectionConfigobject consumer-side.
createAcademyCollectionshares the samelabelshardcode 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
Academiescollection is unmodified (read-only reference).Field seams.
collections/Academy.tsconverts from a staticCollectionConfigexport tocreateAcademyCollection(config), using the same./collections/shared/{fieldShape,optionOverrides,mergeHooks}.tsmechanism as the prior four ports.AcademyCollectionremains exported ascreateAcademyCollection()with every default — byte-identical wiring forcreateLmsLayer, pinned bytests/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 forwardcoursesarray 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/maintainerstogether, default'members'— both sides already agree, the seam exists for a future divergent consumer, not to resolve one today),categoryOptions/statusOptionsvocabulary overrides,extraFields/extraFieldsAfter/fieldOverrides/omitFields/fieldOrder, per-verbaccessoverride, and ahooksseam (merged viamergeHooks; 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.tsincludes a dedicated consumer-adoption-shape test that emulates dropping MVP's tier-gating trio viaomitFieldsand injectingonboardingPhase/grantsPromotion/grantsCertificationviaextraFieldsplus a wholesalecategoryOptionsreplacement — 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
generalvswing/unit— not a clean union.categoryOptionssupports{ 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.tsalready carries the reverseacademyrelationship, andAcademyitself carries NO forwardcoursesarray — 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 forwardcourseshasMany 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 intests/academy-seams.test.ts) until L-P8 (Courses) resolves structure and a migration can retire the array in favor of querying throughCourse.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
featuredImageistype: 'upload'; this factory's default istype: 'relationship'. Verified reachable viafieldOverrides: { 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-fileshows./collections/Academy.js/.cjsPASS both before and after this change) — no call-time-import edge fix was needed.Subpath. New
./collections/academyexport — exposescreateAcademyCollection/AcademyCollection/DEFAULT_CATEGORY_OPTIONSwithout 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 constructedCollectionConfig, 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 newcollections/academysubpath dist files pass clean). Default exports-map mode: 32 pass / 4 pre-existing skip (barrel +./server, both intentionally poisoned doors) / 0 fail.LMS_LAYER_VERSIONandpackage.jsonversion 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
SpecialistPathscollection is unmodified (read-only reference).Field seams.
collections/SkillPath.tsconverts from a staticCollectionConfigexport tocreateSkillPathCollection(config), using the same./collections/shared/{fieldShape,optionOverrides,mergeHooks}.tsmechanism ascreateCertificationAwardCollection.SkillPathCollectionremains exported ascreateSkillPathCollection()with every default — byte-identical wiring forcreateLmsLayer, pinned bytests/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/requirementTypeOptionsvocabulary overrides,extraFields/extraFieldsAfter/fieldOverrides/omitFields/fieldOrder, per-verbaccessoverride, and ahooksseam (merged viamergeHooks, 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: authenticatedposture (publicReadas an explicit opt-down). This port does NOT flip the zero-config default: EDU currently consumesskill-pathswith 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.createSkillPathCollectionkeepsread: publicReadas the 0.x default and adds the standard per-verbaccessseam — a consumer reaches the specified posture viaaccess: { 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'sscopedOrgcomposition stub. A required indexed relationship is just aFieldobject: that consumer's adoption isomitFields: ['scopedOrg']+extraFields: [{ name: 'unit', type: 'relationship', relationTo: 'units', required: true, index: true }]— no dedicatedscopedOrgField-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 viafieldOverrides: { expertTierThreshold: {...} }— no new seam needed. The donor consumer'sicon/tiers[].badgefields aretype: 'upload'rather than this package'stype: 'relationship'— a structural divergence the design doc does not call a convergence target, left as afieldOverridesjob for that consumer's adoption pass, not a new seam.Subpath. New
./collections/skillPathexport — exposescreateSkillPathCollection/SkillPathCollectionwithout 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 constructedCollectionConfig, including a stand-in proving the donor consumer's unit-scoping needs no dedicated mechanism, and the read-access seam reachingauthenticatedOnly),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 newskillPathsubpath dist files both pass clean).Default exports-map mode: 26 pass / 4 pre-existing skip / 0 fail.
LMS_LAYER_VERSIONandpackage.jsonversion 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.tsandhooks/progress/onLessonCompletion.tsstatically imported../../utilities/gamification, which re-exports@wabbit/tome-gamification's main barrel — whose ownutilities/gamification.tsdoesimport { getPointsBalance } from '../server', a relative import that reaches itsserver-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-timeimport()s.CourseEnrollment.js/.cjs,LessonCompletion.js/.cjs, and thehooks/progressbarrel move SKIP → PASS underassert-node-loadable --every-file(package-wide: 24 → 14 pre-existing skips, all now isolated toAchievement/Badge/Points/server/learnerShell/the root barrel — all inside@wabbit/tome-gamification's poisoned import, out of scope).Field seams.
collections/CourseEnrollment.tsconverts from a staticCollectionConfigexport tocreateCourseEnrollmentCollection(config), following the same factory + fieldShape discipline ascreateCertificationAwardCollection(L-P3).CourseEnrollmentCollectionremains exported ascreateCourseEnrollmentCollection()with every default — byte-identical to the pre-port collection (pinned intests/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-verbaccessoverride merged onto the factory's own defaults;hooksoverride APPENDED (never replacing) viamergeHooks.The model swap (additive in 0.x). New
completionStore: 'lesson-completions' | 'completed-modules'option, default'lesson-completions'— unchanged:computeProgressstill recomputesoverallProgressfrom the globalLessonCompletioncollection, andcompletedModulesis NOT added to the schema (that omission is what keeps the zero-config default byte-identical).'completed-modules'mode adds the donor consumer'scompletedModules[]array field, ported field-for-field:moduleId,type(7-value enum includingpath_choiceandojt_signoff, injectable viacompletedModuleTypeOptions),pathChoiceLabel/pathChoiceCourse,submissionStatus(defaultpass),score,instructorFeedback,submissionReference,questionResults,completionDate, per-modulecooldownExpiry,attemptCount,lockedOut,overriddenBy/overrideReason,bypassApproved/bypassRequest— with relation seams forpathChoiceCourseRelationTo/formSubmissionRelationTo/moduleOverriddenByRelationTo/examBypassRequestRelationTo.The top-level MVP-only fields (
completionMode,accessTier,expiresAt,lastAccessDate, enrollment-widecooldownExpiry,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/) REPLACEScomputeProgressand routes its math through the Wave 8enrollmentProgressleaf (utilities/enrollmentProgress.ts) instead of queryingLessonCompletion— same never-regress-a-completion hold and zero-denominator hold the leaf already carried. No leaf signature change was needed: the upstreamrecomputeEnrollmentProgress.tsmath 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 withLesson.isPublished, narrower than the donor consumer's three-flag (required/published/non-archived) filter since this package'sLessonschema doesn't yet carry the other two flags — documented as an honest divergence with a trigger condition, and overridable viaresolveCompletableLessonIds.Enforcement (design principle 2: "the donor consumer's model under MVP's enforcement"). Collection-level
updateaccess issystemOrAdminin BOTH modes (unchanged from pre-port).completedModulesadditionally carries a field-level ACL (newsystemOrAdminFieldexport inaccess/index.ts, aFieldAccess-typed sibling ofsystemOrAdmin) — that consumer'sisAdminFieldLevelconvention 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: trueorreq.context.internal = true, per this package's existing system-write convention.Subpath.
./collections/courseEnrollment— a clean leaf subpath mirroring the L-P3.1certificationAwardprecedent, re-exporting the factory, the static default,DEFAULT_COMPLETED_MODULE_TYPE_OPTIONS, andcreateRecomputeCompletedModulesProgressHook. Kept as a barrel-independent door even thoughCourseEnrollment.jsitself 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, theuniquePairhook,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 forcompletedModules, the afterChange hook swap, and the leaf-routed recompute's basic correctness/never-regress/zero-denominator/loop-guard/custom-slug/error-swallow behavior) andcourse-enrollment-subpath-loadable.test.ts(6 — every built subpath file loads under raw Node, plus confirms the non-subpathCourseEnrollment.jsdoor 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
Lessonscollection and its site-specific access implementations are unmodified (read-only reference).Factory conversion.
collections/Lesson.tsconverts from a staticCollectionConfigexport tocreateLessonCollection(config), using the same./collections/shared/{fieldShape,optionOverrides,mergeHooks}.tsmechanism ascreateCertificationAwardCollection(L-P3) andcreateSkillPathCollection(L-P4).LessonCollectionremains exported ascreateLessonCollection()with every default, pinned bytests/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: thelessonTypedefault 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-verbaccessoverride, and ahooksseam merged viamergeHooks.Enum-union default (breaking-in-shape, not breaking-in-behavior).
lessonTypeOptionsis now injectable, defaulting to the UNION of MVP's six values and the donor consumer'spath_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-configlessonTypefield 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 includepath_choice(documented inline as the one intentional exception).pathOptions/draftPathOptions(the donor consumer's "Choose Your Path" fields) join the field set only whenpath_choiceis present in the resolved vocabulary — a consumer that replaces the vocabulary and excludespath_choicegets no dangling path-choice UI.requiredCertificationandinstructorNotes(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/canReadLessonInstructorContentgates are expressible. L-P4 already established that the sharedfieldOverridesseam (post-construction, per-field, mergesaccessone 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, andworkflowStatusOptionsis independently injectable on top of it.'orthogonal'swaps to the donor consumer's 4-value enum ('archived'replaces'published') and decouplesisPublishedfrom the enum entirely — the design doc's specified 1.0.0 end-state ("MVP's 5thpublishedvalue 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-identicalworkflowStatus === 'published'trigger; orthogonal mode triggers onisPublishedtransitioning totruein the same write. This is an honest ADAPTATION, not a literal port — the donor consumer's real publish path issyncDraftToLive(), 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/enforcePendingReviewInvariantcross-field invariants stay consumer-side, attached via thehooksmerge 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 }(constantLESSON_VERSIONS_MAX_PER_DOC, that consumer's production-measurement rationale comment ported forward in full — see the constant's header), tunable per-site viaversionsMaxPerDoc.drafts: falsestays load-bearing for the same reason on this side of the fork: Lesson already runs its own hand-rolled draft rail on thedraft*shadow fields, and Payload drafts would stand up a second, competing draft concept. WhenversionsMode: 'snapshot-history', areadVersionsaccess default (instructorOrHigher) is wired — Payload resolves an undefinedreadVersionsto "any authenticated user" once versions are enabled, a trap the donor consumer's own code comments call out — independently injectable viaaccess.readVersionsso itscanReadLessonVersionsreaches it without a factory change.Access — public read kept as the 0.x default. Every verb (
read/create/update/delete/readVersions) is injectable viaconfig.access, merged on top of the factory's current defaults (lessonRead— public — for read,instructorOrHigherfor create/update,adminOnlyfor 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 viaaccess: { read: canReadLesson }+ thefieldOverridesseam 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 existinglessonReadAccess: 'enrollment-gated'registration-time rewrite (applyLessonReadAccess) is unaffected — it still finds alessons-slug collection with areadkey to swap.Course/topic/order fields do NOT join the converged set. The donor consumer's direct
Lesson.course/Lesson.topic/Lesson.orderfields 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/lessonexport — exposescreateLessonCollection/LessonCollectionplus the option-vocabulary constants without evaluating the package barrel (same poisoned-barrel remedy as L-P3.1'scertificationAwardand L-P4'sskillPathsubpaths; 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, andsyncDisplayContent'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 gatedpathOptions,requiredCertification/instructorNotesunconditional joins, field-level access viafieldOverrides, every relation-target seam, per-verb access injection,publishStateMode: 'orthogonal''s 4-value enum + decoupled publish trigger,versionsMode: 'snapshot-history''s config +readVersionsdefault, 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 newlessonsubpath dist files both pass clean).Default exports-map mode: 26 pass / 4 pre-existing skip / 0 fail.
LMS_LAYER_VERSIONandpackage.jsonversion 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/certificationAwardsubpath — exposescreateCertificationAwardCollection,CertificationAwardCollection, both cert-award context-flag constants, and every cert-workflow hook factory, without evaluating the package barrel. The barrel transitively evaluatesserver-only(via theAchievement/Badge/Points/CourseEnrollment/LessonCompletionre-exports reaching@wabbit/tome-gamification's own poisoned-barrel leaf), which throws under plain Node/tsx — killingpayload generate:typesand 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/createCalculateExpiryDategain avalidityPathoption — a dot-path prefix (e.g.'validity') resolvingexpiresField/validityPeriodFieldoff a nested object instead of the certification doc's top level, for a consumer whose Certification schema nests them undervalidity.{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/createAfterDeleteMemberCertsgain anonSynced(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.
- New
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.tsconverts from a staticCollectionConfigexport tocreateCertificationAwardCollection(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).CertificationAwardCollectionremains exported ascreateCertificationAwardCollection()with every default — byte-identical wiring forcreateLmsLayer.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-verbaccessoverride merged onto the factory's own MVP-tier defaults;hooksoverride APPENDED (never replacing) viamergeHooks.awardDateis 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: paginatedpayload.find(neverlimit: 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 injectableonSyncFailurecallback is the seam a consumer wires to its own error tracking.updateMemberCertsREPLACES 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, defaultfalse— a site whose Member collection lackscertificationAwards/certificationsarray fields pays nothing). Honors areconcilerPasscontext-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-SentryonSyncFailureseam.calculateExpiryDateis now a REAL beforeChange hook — theexpiresAtfield comment claimed this behavior since before this package existed as a factory; nothing implemented it. Adapted to this package's own Certification schema (flatexpires/validityPeriod, not the donor consumer's nestedvaliditygroup) with a field-name seam for a consumer whose shape diverges further.enforceApprovalStatusmerges 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.autoNominateExpertstays 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 constructedCollectionConfig, including a stand-in proving a wing/unit-scoped instructoraccess.updateoverride 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_VERSIONbumped 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/enrollmentProgressleaf subpath:computeEnrollmentProgressFields(completableLessonIds, completedModules, stored), a pure, synchronous recompute of a course enrollment'sprogress(%) andstatusfrom acompletedModulesarray 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, andstatuscan 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 resolvedSet<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/statusunchanged (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 returnsprogress: 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/enrollmentProgresssubpath plus the root barrel (mirrors the existing./utilities/awardStatusleaf pattern exactly — see the 0.13.1 changelog entry on the poisoned-barrel lesson: the root barrel still evaluatesserver-onlytransitively 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: falsealready covers it.Distinct from this package's existing
./utilities/progress(computeProgress/getCompletedLessonIds), which derives progress from tome-lms's nativeLessonCompletioncollection for consumers whose enrollment does not denormalize acompletedModulesarray. 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/undefinedcompletedModules, non-stringmoduleId, 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.- zero-denominator hold — an empty completable set returns the stored
Wave 8 I2 — new
./utilities/attemptPolicyleaf subpath:resolveAttemptPolicy(progressionRules?)andapplyAttemptOutcome(prev, outcome, policy, now), a pure derivation of a graded module's attempt/cooldown/lockout state onCourseEnrollment.completedModules[].Ported from the same consumer's
syncTrainingToEnrollment.tsEventAttendance afterChange hook (the sibling I1 leaf's neighbor step, ~lines 203-250, pinned in that repo'stests/unit/lms-charact/syncTrainingCooldown.spec.ts) that builds the upsertedcompletedModulesrow on a training outcome.resolveAttemptPolicyreads{cooldownHours, maxAttempts}off a course'sprogressionRules, defaulting to 48/3 via nullish coalescing (not falsy coalescing —0and negative values are honored verbatim, mirroring the source's lack of validation).applyAttemptOutcometakes 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)
attemptsincrements on every graded outcome, pass or fail — it counts attempts, not failures; (2)0/negativecooldownHours/maxAttemptspass through unvalidated (amaxAttempts: 0course locks out on the first fail); (3) a below-cap FAIL leaves a priorlockedOutvalue untouched rather than clearing it; (4) a PASS unconditionally clears bothlockedOutandcooldownExpiry, 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
completedModuleswriters, 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 leavesattemptsuntouched with a comment that a waiver is not an attempt; and an override-module-lock helper clearslockedOut/cooldownExpiryas part of an admin override without touchingattemptsat all. Two more sites (a challenge-mode gate and an exam-ticket gate) readcooldownExpiry/lockedOut/maxAttemptsto gate an action but do not write these fields, and diverge from this policy's default: the challenge-mode gate has no?? 3fallback, so an unsetmaxAttemptsgates onlockedOutalone.Exported ONLY via the new
./utilities/attemptPolicysubpath plus the root barrel (mirrors the./utilities/awardStatusand./utilities/enrollmentProgressleaf 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'sutilities/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 existingscoreQuizAnswers'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.tsandfeatures/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 fromclassifyCalibrationDeviation's>=flag boundary, preserved not fixed), andcomputeCalibrationDeviation(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):isHeldAwardStatusandheldCertKey, extending the./utilities/awardStatusfamily (new sibling file, not an edit toawardStatus.ts) with the WIDER "does this member hold this certification" tolerance a production audit required upstream — an explicit empty-stringapprovalStatus(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 withisEffectiveAwardStatus. 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 ownsrc/server/awardGate.ts(a prior wave's native port) and is not duplicated here.effectiveCompletion.tswas 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 localmemberHoldsCertificationfiltersapprovalStatus: { equals: 'approved' }only (stricter thanEFFECTIVE_AWARD_STATUSES, excludes legacy-null);server/jobs/reconcileCourseCompletionAwards.ts's inlinecertKeyuses a single-colon separator, not this leaf's::.Flagged for a follow-up convergence pass, not fixed here.
utilities/academy/deriveEnrollmentProgress.tswas evaluated for I3 and NOT ported: its exported function does Payload I/O directly (agetCompletableLessonIdsMapquery) 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/enrollmentProgressleaf'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/attemptPolicyleaf 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'sgradeMultiSelect.spec.tsconfirmed 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-uploadsnow setsfilesRequiredOnCreate: falseso text/url/multiple-choice submissions persist without a synthesized placeholder file; a newbeforeValidatehook still rejectssubmissionType: 'file'writes that carry no actual upload.gradeQuizAttemptgrades against the quiz block matching the attempt'squizBlockId(returning null when the id matches no quiz block on the lesson) instead of always grading the first quiz block; attempts without aquizBlockIdkeep the first-quiz-block fallback.student-notesgains an optionalsectionIdtext field backing the NotesPanel content-section anchor, surfaced viagetStudentNotes/StudentNoteData.
Consumers on 0.11.x are unaffected at runtime: the new
sectionIdfield is optional, the loosened upload contract only removes a create-time rejection, and legacy quiz attempts (noquizBlockId) 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
vngdsource value now renders "Legacy" (enum value unchanged). - accounts / core / lms / ui / org / admin / motion / longform / cop / blocks: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named the client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.
- @wabbit/tome-gamification@0.3.0
0.11.xMinor
Role checks now understand a
rolesRELATIONSHIP, not just flat strings — unblocking admin gates that were silently shut.Three packages read
req.user.rolesby collecting only entries wheretypeof 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-adminis not one of them, so the highest-privilege role failed every check.
Fixed in tome-lms and tome-gamification:
readRolesaccepts flat names, populated Role docs ({slug}), the_populatedRolesenricher shape, and a flat singularrolefield.super-adminnow satisfies every tier, matching the platform-wide implicit'*'grant.- New
resolveRoleSlugs(req)/hasAnyRoleAsync/isAdminAsync/isDirectorAsync/isInstructorRoleAsync/isMaintainerRoleAsynchydrate unresolved IDs throughreq.payload, memoized onreq.contextso 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 canonicalsuper-adminslug (it previously matched only camelCasesuperAdmin). 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-floorallowlist.Updated dependencies [68465b3]
- @wabbit/tome-gamification@0.3.0
0.10.xMinor
Naming convergence (all additive; every old name keeps working as a
@deprecatedalias 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 gaincreate*Collectionnames for their formerdefine*Collectionfactories. Layer entries converge oncreateXLayer(config?) → bundle:createCrmLayer/createDealsLayer/createMarketingLayer/createCatalogLayer/createEconomyLayer/createChromeLayer/createLmsLayer/createAiLayer(+createFormsLayer/createIntakeLayer), returning bareCollectionConfig[]where the layer contributes only collections or an honest named bundle where it hands back more (chrome:{ globals }; lms/ai:{ collections, hooks }); void-returninginitCatalog/initEconomystay as the single registration call sites, delegated to internally.Naming note for forms consumers:
createFormsCollection(singular factory) vscreateFormsCollections(plural composer) vscreateFormsLayer(layer entry) — each docblock states the distinction.registerLayeris now statically imported (forms/intake pattern) instead of lazilyrequire()d in ten layer packages' init/register paths.The lazy pattern silently no-ops under Payload's native-ESM CLI (
generate:types/generate:importmap), so layer registration could vanish without error. Packages whose tome-core peer is genuinely optional (economy, ai, gamification) deliberately keep the guarded lazy path; tome-core'sadmin-nav/self-register.tsdeliberately keeps its subpathrequire()(documented ESM/CJS dual-cache fix — do not convert).Every package now declares an explicit
sideEffectsfield (38 added; motion/engine/forms already correct).Registration-bearing modules (render files'
registerRenderer,blocks/*/index.tsdefineBlockself-registration, widgetregister.tsfiles, productHooks, permission self-registrations, print templates, chrome built-in variants) are listed so bundlers can tree-shake everything else WITHOUT dropping import-time registrations — previously the field was unset, which blocked cross-module tree-shaking through the barrels entirely. Never blanketfalseon a package with registration or CSS.getStudentDashboardandgetCourseLandingDatano longer fetch sequentially: the dashboard's nine member-scoped queries run in onePromise.allbatch, 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 everylocked-visibility row — enrolled members can navigate locked lessons from the rail, matching what the content pane already grants.CurriculumTreegains an optionalresolveLessonAccessprop; without it the visibility-tier fallback (anon/landing behavior) is unchanged. - lms: the
course-completionbadge check moved out ofonLessonCompletion(it fired per LESSON, awarding course-completion badges on a student's first completed lesson) into a newawardCourseCompletionBadgesCourseEnrollment afterChange hook guarded on the status transition intocompleted— the same guardautoAwardCertificationuses. Per-lesson points (and the points-threshold badge cascade insideawardPoints) are unchanged.
- lms-ui: CurriculumSidebar now derives per-row effective access via
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, andGradebookEntry.studentall relate to the member collection, but the guards (isEnrolled,hasActiveAccess,canAccessLesson,canAccessCourseItem) and theenrollment-gatedlesson-read access function previously queried them by the auth user id (getUserId(req.user)). In the canonical Tome identity model —usersis the auth collection,membersis 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 newresolveStudentIdhelper, cached per request/guard-context. Course staff fields (owner/instructors/maintainers) relate tousers, so those queries correctly keep using the user id — that split is the actual correctness boundary.GuardContextgains an optionalmemberSlug(default'members'), andregisterLmsLayerthreads 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 thelessonReadAccess: '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-corenow ships a standalone Node ESM CLI atscripts/emit-docs-manifests.mjsthat emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free<pkg>/metablock-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild:node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>.To let the emitter import block metadata uniformly without dragging Payload config into a build script, the
./metapayload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block'sBlockMetawas relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed.Every supported-core package additionally adds
CHANGELOG.mdto its publishedfilesarray so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild. - 0.9.1
Fix
assignment-uploadsupload collection rejecting every file.It set
mimeTypes: ['*/*'], but Payload'svalidateMimeTypestrips only the first*('*/*'→'/*'), so the wildcard matched no detected MIME type and the upload guard blocked all student file submissions. Removed the broken config — omittingmimeTypesis the correct "accept any file" setting, and Payload still blocks dangerous executable types via its built-incheckFileRestrictionsallowlist. - 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
pointsappend-only ledger,badges,achievements, plusawardPoints/checkAndAwardBadges) into a new standalone@wabbit/tome-gamificationpackage.@wabbit/tome-lmsnow 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 theonLessonCompletionhook are unchanged).The points collection gains one additive, optional
sourcegroup (polymorphicsourceType/sourceId) for non-course consumers. In the new package,reasonoptions and thecourse/mediarelations are configurable viacreatePointsCollection/createBadgeCollection/createAchievementCollectionfactories (the staticPointsCollection/BadgeCollection/AchievementCollectionexports preserve the exact LMS shape).New server helpers
getPointsBalance/getPointsSinceship at@wabbit/tome-gamification/serverfor 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 onreq.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 (overrideAccessdefault). New exports:enrollmentGatedLessonRead,buildLessonRead.Also corrects the stale
registerLayerversion literal (0.3.4 → 0.7.0).
0.6.xMinor
Add
getCompletedLessonIds()— the read-side counterpart tocomputeProgress.computeProgressalready collapses the canonical completion source (globallesson-completionsrows forhonor-prior; the enrollment's owncompletedItemsforfresh-start) into a 0–100 percentage. UI surfaces (MarkCompleteButton,CurriculumSidebarcompletion markers) need the actual id _set_, not the percentage, and the platform deliberately keeps no denormalizedcompletedLessonsarray 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 asstring[], reading from the exact same sourcecomputeProgressuses (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 whencertificationAwardedis null (course awards no cert).Idempotent (existence-check on
recipient + relatedCourseEnrollmentpair). Setsreq.context.internal = truearound the create soenforceApprovalStatusbypasses its gate — system writes already have authority to setapprovalStatus: 'approved'directly.Wired into
CourseEnrollment.afterChangeaftercomputeProgress. Promotes the consumer-side auto-award pattern fromwabbit-site-core/dal/lms-completion.tsto 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 = truebefore apayload.createto 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-core0.1.0 → 0.2.0@wabbit/tome-ui0.2.0 → 0.3.0@wabbit/tome-motion0.1.0 → 0.2.0@wabbit/tome-lms0.1.0 → 0.2.0@wabbit/tome-lms-ui0.1.0 → 0.2.0@wabbit/tome-blocks-core0.1.0 → 0.2.0 (via sprint-3)@wabbit/tome-blocks-extras0.1.0 → 0.2.0 (via sprint-3)@wabbit/tome-blocks-marketing-starter0.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