Lms

Capabilities
@wabbit/tome-lmsv0.23.1

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

Installnpm install @wabbit/tome-lms

Overview

@wabbit/tome-lms

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

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

`package.json` has no `description` field (verified — checked directly, not assumed). The summary above is compiled from the barrel's own docblock (src/index.ts).

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 — see docs/superpowers/specs/2026-04-26-tome-lms-catalog-integration-design.md. New LMS work lands in this package, not tome-core/lms.

Install

pnpm add @wabbit/tome-lms

Peer ranges, copied from package.json (v0.9.3):

| Peer | Range | Optional? | |---|---|---| | 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 |

Not a peer range — @wabbit/tome-core and @wabbit/tome-gamification ship as regular dependencies (workspace:*, rewritten to a pinned version at publish), not peerDependencies, unlike every other package in this batch. A peerDependenciesMeta["@wabbit/tome-core"] entry exists in package.json but has no matching peerDependencies key — a dangling/orphaned entry, verified by reading the manifest directly. Practical consequence: don't install a separate @wabbit/tome-core range for this package; check the resolved dependencies version in the installed tarball (the CHANGELOG shows it tracking @wabbit/tome-core@1.3.4 as of 0.9.3).

60-second quickstart

The current API is createLmsLayer(config?) (registerLmsLayer is a deprecated pure alias — R4 ruling #3, 2026-07):

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

R4 ruling #3 (2026-07) converged every layer entry in this batch on createXLayer(config?): createCrmLayer/createDealsLayer/createMarketingLayer and this package's createLmsLayer, matching the pre-existing createOrgLayer/createAccountsLayer shape. The pre-R4 names (initCrm, initDeals, initMarketing, registerLmsLayer) all survive as @deprecated pure aliases, removed at each package's next major.

API surface

Single subpath other than ., ./server, and ./scripts/migrate-lms-products-to-catalog.

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

| Group | Exports | |---|---| | Layer entry | createLmsLayer, LmsLayerConfig (registerLmsLayer is a @deprecated pure alias — R4 ruling #3, 2026-07) | | 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 (22) | AcademyCollection, CourseCollection, TopicCollection, LessonCollection, CourseItemCollection, CourseEnrollmentCollection, LessonCompletionCollection, StudentProfileCollection, QuizAttemptCollection, AssignmentUploadCollection, CertificationCollection, CertificationAwardCollection, ExpertCredentialCollection, SkillPathCollection, TrainingEventCollection, TrainingEventAttendanceCollection, InstructorAvailabilityCollection, BadgeCollection, AchievementCollection, PointsCollection, GradebookEntryCollection, CourseReviewCollection, StudentNoteCollection — all re-exported individually so a site can swap one collection and pass its own replacement | | Hooks | Content-sync (syncDisplayContent, syncParentRelationship, cleanupParentReferences), progress (computeProgress, onLessonCompletion), cert-workflow (enforceApprovalStatus, updateCertificationHolderCount, updateMemberCerts, autoNominateExpert), scheduling (recountTrainingEventAttendees, assignInstructorFromUser), uniquePair | | Utilities | Gamification (checkAndAwardBadges, awardPoints), grading (scoreQuizAnswers, gradeQuizAttempt, syncGradebookEntry), progress (computeEnrollmentProgress, flattenCurriculumTree, getCompletedLessonIds), prerequisites (hasCompletedPrerequisites, isPhaseUnlocked) | | Types | 25+ type exports — see src/types.ts for the full list |

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

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

Server / client posture

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

Links

  • Data layer design: docs/superpowers/specs/2026-04-14-tome-lms-v2-1-data-layer-design.md
  • Lesson player: 2026-04-14-tome-lms-v2-2-lesson-player-design.md
  • Public student surfaces: 2026-04-14-tome-lms-v2-3-public-student-surfaces-design.md
  • Instructor/faculty surfaces: 2026-04-14-tome-lms-v2-4-instructor-faculty-surfaces-design.md
  • Credentials/exams: 2026-04-14-tome-lms-v2-5-credentials-exams-design.md
  • Catalog integration: 2026-04-26-tome-lms-catalog-integration-design.md
  • Enrollment-gated read amendment: 2026-06-11-tome-lms-enrollment-gated-read-amendment.md
  • CHANGELOG

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

See docs/specs-map.md in this repo for the full package→spec routing table, and the Business wiki's docs/superpowers/specs/ for the source specs cited below.

  • v1→v2 carve-out: `@wabbit/tome-core/lms` is deprecated in place, all new LMS work lands here — the entire core/lms surface is @deprecated (not just the one factory that used to carry the tag), coexistence is a documented cutover window not a permanent split, and removal at core 2.0 is gated on a CI check for zero remaining @wabbit/tome-core/lms/* importers — R4 brief ruling #4, 2026-07-12-tome-r4-convergence-decisions-brief.md.
  • 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 — 2026-04-14-tome-lms-v2-1-data-layer-design.md.
  • LMS owns Course, catalog owns Product — a course is sellable only through a `catalog-products` wrapper, never a course-local mini-catalog — closes the old lms-products duplicate-product-table (deprecated collection) so search/marketplace/reporting can enumerate "all products" through one registry instead of a sharded one; createCourseProduct/getCourseProduct (confirmed live in src/server/courseProduct.ts) are the wiring — 2026-04-26-tome-lms-catalog-integration-design.md.
  • `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 — 2026-06-11-tome-lms-enrollment-gated-read-amendment.md (code: src/types.ts lessonReadAccess field + enrollmentGatedLessonRead/buildLessonRead exports, confirmed present).
  • `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 — R4 brief ruling #3, 2026-07-12-tome-r4-convergence-decisions-brief.md (code: src/index.ts — export const registerLmsLayer = createLmsLayer, confirmed present).

Exports

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

Changelog

v0.23.1patch

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

71d3b09: Purge Vanguard/VNGD client 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 ("VANGUARD COMMAND", "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 Vanguard/SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping VNGD; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named Vanguard/VNGD as a client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched. - @wabbit/tome-gamification@0.3.0

  • 71d3b09: Purge Vanguard/VNGD client 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 ("VANGUARD COMMAND", "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 Vanguard/SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping VNGD; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named Vanguard/VNGD as a client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched. - @wabbit/tome-gamification@0.3.0
v0.12.0minor

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Updated dependencies [a9801fe]

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

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

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

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

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

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

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

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

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