Skip to main content

@happyvertical/smrt-education

Preview education catalog models and authorized catalog reads for s-m-r-t. The package represents courses, ordered sections, lessons, and lesson-owned files. It supplies catalog structure and authorized learner planning/progress workflows. Applications authenticate actors and decide guardian permissions.

Homeschool groups use GroupSharingService, with the optional education.supervisor recipe. Its read-only reader and comment services remain internal so hosts cannot accidentally bypass the owning transaction lifecycle. Groups have independent users tenants; parents approve links to existing family learners and choose a versioned sharing policy. The closed services recheck live session, role, membership and policy on the supplied database executor. Family work and revision-bound comments remain retained after leaving a group. See the group sharing contract for the required host authorizer, private media delivery, facet semantics and migration/test matrix.

Guardians author courses with CourseAuthoringService: append-only course and lesson revisions, a reviewed release that materializes immutable lessons, reversible archive, recorded enrollment pins and upgrades, a recorded visibility (family, groups, public) and an education-owned cover image. See the course authoring contract.

Install​

pnpm add @happyvertical/smrt-education

Use the package with the core, users, profiles, tenancy, and assets packages. Apply application migrations before using the catalog collections. Runtime catalog operations do not create application schema.

Models and collections​

ModelPurpose
CourseSourceHost-managed adapter policy and sharing ceiling
CourseCourse metadata, license, status, optional owning tenant, optional book identity (bookKey, bookEdition) and work policy
ExternalCourseExternal course subtype sharing the Course table
AuthoredCourseAuthored course subtype sharing the Course table
CourseSectionOrdered section belonging to a course
LessonOrdered lesson belonging to a section
Reading, Video, Worksheet, ExerciseLesson subtypes
LessonAssetLesson-owned file join with a role (teacher-guide, slides, learner-pages, assessment, assessment-guidance, handout, printable, answer-key, video, attachment) and an audience
CourseAssetCourse-level material (supply list, posters) with the same roles and audience
LessonExternalVideoAllowlisted YouTube reference with title and step order; never downloaded
LessonPageA lesson's printed page: key, label, order, optional source file and page
LessonActivity, WorkAttempt, WorkAssessmentRevision, WorkFeedbackRelease, WorkFeedbackDelivery, WorkCompletionDecision, LessonWorkFilingThe one lesson work pipeline (see below)
LearnerA required User/Person identity and guarded settings in one family tenant
CurriculumA learner plan for a distinct period
EnrollmentOne course in a curriculum
LessonProgressOne lesson in an enrollment, with nullable score and separate history

Each exported model has a corresponding exported collection. Course and lesson subtypes use single-table inheritance. Sections and lessons sort by sortOrder, with identifier ordering for ties. Lesson files belong in LessonAsset; lesson subtypes do not carry asset-id fields.

Basic integration​

  1. Create the course, section, lesson, and lesson-asset collections with the application's database executor.
  2. Supply those collections, a request-bound TenantCollection, and the host's actor-bound permission resolver to CatalogService.
  3. Enable tenancy and establish the authenticated actor's active tenant context.
  4. Call findVisible with that tenant's canonical identifier and the actor's user identifier to list the ordinary catalog.
  5. Call projectCourse with the same context and a visible course identifier to obtain its ordered sections, lessons, and allowed asset joins.

The catalog service integration tests provide executable examples of collection setup, withTenant, and service reads. See the integration contract for host responsibilities. The authorizer must make a trusted server-side permission decision for the actor; a caller-supplied boolean is not a substitute for authentication or authorization.

Privacy and ownership​

Ordinary catalog reads can include the active tenant, its ancestors, and globals. The supplied tenancy capability widens only the allowlisted catalog list reads. It does not grant generic get, write, raw query, sibling, or descendant access.

Personal-use copies require an owning tenant and cannot be published. Their owner and license kind cannot be changed after creation. includePrivate requires a separate host grant and returns only the active owner's personal copies, never an ancestor's or global private copy. The shared Tenant model has no family/group discriminator; hosts provision family tenants and authorize their guardian workflows.

Learner projections remove worksheet answer URLs and answer-key asset joins. includeAnswerKeys requires a separate host permission. Every projected child must have the selected course's exact owning tenant. Stored inconsistent section, lesson, and asset links are excluded. Generated REST, CLI, MCP, and AI operations are closed for these models.

Learner workflows​

Create LearnerService with the application's root database executor and trusted learner/catalog authorizers. In the authenticated actor's exact withTenant context, an authorized guardian can createCurriculum and enroll; a learner can getLearner, startLesson, and listProgress for their own work. Guardian access requires a same-tenant directed ProfileRelationship plus the host's permission decision. Sibling membership does not grant access.

putBackLesson returns started work to not_started and setEnrollmentStatus starts, pauses, resumes or drops an enrollment along a fixed transition table; both append immutable history (actor, and for status the host policy basis) and never touch submissions, grades or other progress. completeOwnLesson lets a learner mark their own lesson of any kind done when the host's education.progress.complete-own decision allows it, recording the actor, the host setting and optional evidence submission; guardian attestLesson is unchanged. setEnrollmentPace sets or clears an enrollment's planned lessons per week (1 to 14) under the host's education.enrollment.set-pace decision with an immutable change row, and nextLessons({ count }) returns a week's set of unfinished lessons (default one). setInterestTheme stores a learner's interest theme (a short plain label and an optional stable key such as minecraft; the host owns its starter list) under education.learner.set-interest-theme: a guardian may set any of their learners' themes, a learner only their own (the host decides), nobody another learner's. Each change appends an immutable row; getLearner and the tutor context read it back. TutorContextService.compose builds a bounded, learner-safe TutorContext (lesson text, learner-visible questions, own progress, released feedback; for the learner audience only answerKeyAvailable, never keys, rubrics, notes or storage URIs) under the host's education.tutor.read decision, and TutorSessionService (open, append, close, listForLearner; education.tutor.exchange and education.tutor.read) keeps append-only guardian-readable transcripts. No model call happens in the package; see tutor contract.

CourseBundleImportService imports a course zip (optional closed manifest.json) or file set under hard size, ratio, path and entry bounds, keeps unsupported and duplicate members as append-only evidence, hands accepted members to ingestion and proposes sections and lessons through CourseGenerationReviewService. attachCourse turns an import (or each part of a unit split across bundles, in any order) into one family course with lessons, materials, pages and videos; attachCourse({ supersede: true }) brings an attached unit up to a newer build keeping learner progress; chooseMaterialAudience records review choices before attach; see bundle import. requestGeneration also accepts a structured brief (subject, category, area, grade, objective, a course or one-lesson target and a per-request interest theme that beats the learner's saved theme for that request only); the theme used and whether it was an override show on the guardian's review, and release accepts a policyBasis so a host can honour its own approve-requested-lessons setting without bypassing the review gate; see generation and review. CourseAuthoringService.appendLesson adds one lesson (materials and question records included) to a released course as a new, optionally released revision: append-only, idempotent by request key, refused on archived, unreleased or foreign courses; enrollments stay on their pinned release until the host upgrades them; see course authoring. Repeated starts preserve the existing progress, including attempts and scores. A new curriculum/year creates a distinct enrollment and fresh progress history. An unscored lesson has bestScore: null, distinct from zero. The service does not accept score or completion patches; SubmissionService owns guardian grading. Use withTransaction and its callback service to compose atomic operations. Results are plain data views, so no transaction-bound model handle escapes.

The learner integration contract describes required identity provisioning, catalog permissions, supported workflows and limitations. The executable learner workflows test this integration on both SQLite and PostgreSQL.

Submissions and worksheet packets​

SubmissionService captures tenant-owned uploads, reviews or attests work with optional guardian scores, signs authorized asset claims, and splits private PDF packets by page range. attestOwnLesson lets a learner mark their own Reading or Video lesson done as a self-reported, ungraded attestation that a guardian then confirms. It reuses learner ownership and transaction checks. See the submission integration contract for inputs, retry rules, signing requirements and external-storage compensation.

Lesson work, books and materials​

Every hand-in on every lesson is lesson work: SubmissionService capture and correction record the learner's LessonActivity and an immutable WorkAttempt, and LessonWorkService (formerly WorkbookService) owns the timeline, revisioned AI and guardian marking, feedback release, delivery and the parent completion decision. A guardian grade on an ordinary lesson records its completion decision; book and loose-page work complete only by completeLessonActivity. Submission pages carry a pageKey, so a correction may replace individual pages (WorkAttemptView.pages folds the versions).

A physical book is a course: Course.bookKey/bookEdition (one course per family per key) with the course work policy (setCourseWorkPolicy). Workbook is retired; run migrateWorkbooksToCourses(db) once after the schema migration. resolveBookPageActivity matches book key and page key to a LessonPage; fileLessonWork places loose photographed work in a course.

Materials carry an audience defaulted by role; learners see only learner material (listLessonMaterials, listCourseMaterials, signLessonMaterial, signCourseMaterial, setMaterialAudience, addLessonVideo). A material view carries its MIME type, rendition (original / rendered), the paired material and an optional thumbnail (sign it with thumbnail: true).

Renames: WorkbookService → LessonWorkService, WorkbookActivity → LessonActivity, Workbook{Attempt,AssessmentRevision,FeedbackRelease,FeedbackDelivery,CompletionDecision} → Work…, Workbook… contracts → Work… (WORK_MATH_VOCABULARY), and the methods listed in the design record. Removed: Workbook, WorkbookWorkFiling, WorkbookWorkflowRequiredError (now LessonWorkWorkflowRequiredError) and assertNonWorkbookSubmissionScope (now assertOrdinarySubmissionScope). See lesson work and lesson materials.

Source imports​

SourceImportService imports Easy Peasy pages as private family pointers after explicit host terms acceptance. Hosts inject the page fetcher and authorize the active actor, family/group destination and licensing evidence. Re-import refreshes URLs in place without deleting lessons or learner history. No lesson text or PDFs are mirrored. See the source integration contract and source behavior matrix.

Recipes​

EducationCatalogRecipe declares education.catalog; EducationLearnerRecipe declares education.learner. Its course navigation and model declarations support application composition. A recipe does not grant permission to expose model CRUD or private material.

Database support and validation​

SQLite and PostgreSQL exercise catalog persistence and child ownership guards. The real ancestor hierarchy integration is exercised on SQLite. DuckDB tests cover Course-only persistence: existing core DDL gates reject catalog child update-cascade and Asset/Tenant self-referential foreign keys. Use SQLite or PostgreSQL for full catalog schemas; those gates remain intact.

Run the package's test, test:postgres, typecheck, and build scripts. The behavior matrix records regressions, dialect coverage, and the existing DuckDB setup limitations.

Public first-edition Illustrative Mathematics​

createPublicIllustrativeMathematicsFirstEditionAdapter({ fetch }) returns both CatalogSyncAdapter and CatalogAcquireAdapter; fetch is optional and defaults to the standard global Fetch API. Use the same factory for discovery and explicit private acquisition through CatalogAcquisitionService. No credential, mounted provider module or PDF fetch is needed for discovery. Apply the additive catalog representation column migration before using this version.

The declared metadata scope is public artifact links on the fixed official CMS host, with explicit student/family labels in the first-edition Kendall Hunt K–5, 6–8, and high-school teacher/family hierarchy, including Algebra 1 Supports and linked Spanish counterparts. Traversal starts at the official homepage and fixed curriculum roots; it follows direct course, unit, resources, family-materials, and lesson-preparation links. An icon or file-name label never establishes the observed format or grants role/rights. Teacher guides, answers, assessments, teacher presentations, login-only links, unknown roles, HTML-only student lessons, external-host artifacts and AccessIM/v.360 remain outside this metadata scope. External links are not followed or HEADed. Known PDF, ZIP and DOCX MIME types are retained. Otherwise eligible artifacts with an absent or unrecognized Content-Type remain explicit unknown metadata with acquisition denied; malformed MIME or invalid validators pause enumeration rather than silently omitting an artifact. Only PDF bytes can be acquired. Completion means this declared scope only. Policy omissions appear in the bounded, invocation-local CatalogSyncReport.exclusionSummary, rather than the durable failure ledger.

Each enumerate reads one HTML page (2 MB maximum) and at most four asset HEADs. It never GETs asset content. Known PDF/ZIP/DOCX HEAD metadata is retained even when the file exceeds the PDF acquisition cap. Missing/weak validators, malformed MIME or access/transport failures pause the page without advancing. Each observed record has optional representation metadata with actual format, mediaType, positive safe byteLength, strong ETag/Last-Modified validators, and acquisition capability. A PDF is supported or size-limit with its observed maxBytes; ZIP and DOCX are always unsupported-format. An unknown representation has format: 'unknown', mediaType: null for an absent header or the actual unrecognized canonical MIME, availableFormats: [], and acquisition.status: 'unknown-format' with a missing-content-type or unrecognized-content-type reason. Its observed size, validators and legal evidence remain intact; the evaluator denies new acquisition. Unknown v2 revisions encode MIME absence or the observed token explicitly. The source icon does not override the actual MIME. Version-1 JSON cursors contain the remaining validated hierarchy paths and an artifact offset plus source fingerprint when needed; persist the returned cursor unchanged. A fresh adapter can resume it. The cursor is capped at 64 KB and 384 pending paths; source pages are capped at 128 children and artifacts. Exceeding a bound, changing source metadata during a chunked page, or a provider failure pauses with the current cursor, never a terminal success. Normal CatalogSyncService invocation page budgets yield an unpaused, incomplete checkpoint without inventing a source failure; resume it to complete the same generation.

The source fingerprint is SHA-256 of canonical JSON containing the canonical path and parsed children, artifacts (asset ID, exact normalized label, allowed role), exclusions, source attribution, notices, license decision, and title, in source order. It excludes scripts, comments and transient CSRF markup. This is necessary because official page weak ETags change for each anonymous request. Acquisition rechecks that fingerprint, exact asset identity, role and license before requesting bytes. The PDF revision additionally pins the strong ETag, length and Last-Modified. A conditional PDF GET must return the identical validators, PDF content type, exact declared/streamed length and %PDF- signature; the adapter hashes the bytes with SHA-256. The generic service retains attribution and image/trademark notices and provides tenant, selection and idempotence checks.

timeoutMs bounds the entire call, including streaming (default 20 seconds, maximum 60 seconds). maxPdfBytes bounds acquisition only and defaults to 32 MiB (maximum 64 MiB). Changing it does not change an existing PDF content revision. Requests use fixed official hosts and strict paths, reject redirects, and carry no source credentials. Rate limiting returns a resumable pause with Retry-After information; hosts must honor that delay before resuming rather than loop immediately.

License evidence comes from each exact official source page's attribution and CC BY 4.0 footer, with retrieval time and retained exceptions. The official terms §7.1 distinguish the first edition's CC BY 4.0 curriculum from the separate v.360 terms in §7.2. No v.360 grant is inferred.

Public IM PDF roles are local: a table artifact must belong to the same closed row as its explicit first-cell label; a non-table artifact must be directly in an im-c-row__flex-resource block with exactly one local h3/h4 label. Missing, ambiguous, nested, or cross-section labels are excluded. A preceding student heading cannot authorize a separate teacher/unknown block. Structural parsing is bounded to 50,000 tags and 256 open elements. Family unit pages may follow their directed same-unit family-materials.html edge; family-materials leaves never follow reverse unit navigation.

Adapters use policyExclusions for normal role/license/format/scope omissions; IM emits page-wide omissions only on artifact offset zero and each artifact's omission only in its processed chunk. exclusions retains its operational error meaning: a current error/pause cannot advance the cursor, write returned partial resources, withdraw unseen rows, or report completion. The report's exclusionSummary: { total, byCode: [{ code, count }], otherCount } counts only pages processed in that invocation, retains at most 32 public code tokens, and is never stored in the checkpoint. Hosts may aggregate these invocation-local counts explicitly; they are not full-generation coverage totals.

Durable failures remain the existing JSON array, compacted to 31 recent code diagnostics plus an explicit overflow entry. Each entry's optional count preserves occurrence totals; omittedDetailCount explicitly counts occurrences whose diagnostic details were compacted/truncated. Messages and diagnostic cursors are bounded to 512/256 characters; the actual resume cursor is preserved. Missing legacy counts mean one; malformed counts are explicitly marked and counted once. Unsafe integer totals fail closed. failureSummary distinguishes historicalCount from this invocation's currentCount, with total and omitted detail counts. Historical mixed-policy/error entries are retained as audit history without guessed reclassification. An old coarse six-grade checkpoint with no cursor starts the new adapter at its root, preserves its imported count and history, and may complete/withdraw only at the new traversal's actual terminal page. History alone does not create an active pause.

Before creating a new selection or writing files, hosts call evaluateCatalogAcquisitionCapability(resource, format, { maxBytes }) on the fresh authoritative record inside their transaction, separately from legal CatalogPolicy authorization. The optional current limit can tighten the persisted limit, never loosen it. Denials distinguish unknown format, unsupported format, size limit, unavailable format and invalid representation metadata. It does not control printing already pinned bytes. CatalogAcquisitionService repeats the capability guard for new fetches after its existing pinned-acquisition return; current actor, selection, revision, available-format and legal checks still precede that return. The adapter independently revalidates format, size, validators and content bytes.

normalizeCatalogRepresentation(unknown) parses persisted JSON and returns a validated defensive copy. Missing, null and serialized JSON null mean legacy absence; the evaluator reports verification: 'legacy' without inventing measured support, and adapters still enforce their strict acquisition checks. Malformed present metadata throws CatalogRepresentationError; the evaluator explicitly denies invalid-representation-metadata. Never catch this error and replace it with absence in a read projection. CatalogResource.getRepresentation() and setRepresentation() use the same guard. CatalogDatabaseSyncStore persists the field and CatalogReadService preserves it in its metadata DTO. The nullable TEXT column migration leaves existing rows SQL NULL; new model/store values use serialized JSON null. Both mean legacy absence; runtime code does not create or alter schema.

Recovering an old partial inventory after a semantic scope change​

Including previously omitted non-PDF links changes the canonical page semantics. This causes a one-time catalog revision change even when the publisher's HTML and PDF validators did not change. PDF v1 encoding remains unchanged, but its page fingerprint can change. An old chunk cursor must pause on a fingerprint mismatch; never reinterpret its artifact offset against the expanded inventory, remove its fingerprint, or mark it complete. An old coarse checkpoint with no cursor already starts root discovery naturally and needs no reset.

There is no ordinary UI or cross-process restart operation. The following is an operator-only procedure requiring proven quiescence. Fence new catalog requests, disable/drain or terminate every catalog producer, job and automated continuation across every pod/process, and await all in-flight provider operations/writers, including successful and failing callbacks. Keep new writers fenced until the reset finishes. Browser Stop or a paused status alone is not quiescence: a late success or failure can overwrite a reset or withdraw old-run rows. The fresh run-ID check below does not provide concurrent CAS protection.

With trusted db, providerId and the operator's expectedRunId (explicit undefined only for a legacy generation without one), use the existing public store API after establishing that precondition:

const store = new CatalogDatabaseSyncStore(db);
const current = await store.getCheckpoint(providerId);
if (!current || current.complete || !current.paused ||
current.runId !== expectedRunId) {
throw new Error('Expected paused catalog generation is no longer current.');
}
await store.saveCheckpoint({
...current,
runId: globalThis.crypto.randomUUID(),
cursor: undefined,
importedCount: 0,
complete: false,
paused: false,
pauseReason: undefined,
failures: structuredClone(current.failures),
});

Record the quiescence and reset in the operator's audit. This changes traversal state only: retain all source rows, bounded failure history/counts, lastSuccessfulAt, selections, acquisitions and private files. Do not delete rows/bytes or claim completion. After writers are re-enabled, ordinary sync starts at the current adapter's root under the fresh run ID; rows remain until an actual successful terminal generation permits withdrawal. A subsequent real source failure still pauses and cannot authorize withdrawal.

Old catalog selection/acquisition retries may be stale after the one-time semantic revision change and still require current revision/rights checks. Previously assigned/materialized private bytes are not changed or deleted by catalog discovery; their private printing path is independent of later catalog revisions. A new selection through the current adapter keeps its revision and cached acquisition across a same-adapter refresh with unchanged source semantics.