@happyvertical/smrt-education 0.57.52 → 0.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/AGENTS.md +18 -8
  2. package/README.md +36 -2
  3. package/agents/class-crunchlabs.md +83 -0
  4. package/agents/course-bundle-import.md +108 -18
  5. package/agents/course-work-design.md +144 -0
  6. package/agents/group-sharing.md +5 -1
  7. package/agents/learner-plan.md +3 -3
  8. package/agents/learner-service.md +1 -1
  9. package/agents/lesson-materials.md +52 -0
  10. package/agents/{workbook-service.md → lesson-work-service.md} +164 -95
  11. package/agents/{workbook-test-matrix.md → lesson-work-test-matrix.md} +3 -3
  12. package/agents/photo-intake.md +37 -50
  13. package/agents/question-records.md +1 -1
  14. package/agents/submission-service.md +22 -6
  15. package/dist/catalog-contracts.d.ts +6 -2
  16. package/dist/catalog-contracts.d.ts.map +1 -1
  17. package/dist/catalog-materialization-service.d.ts.map +1 -1
  18. package/dist/catalog-policy.d.ts +11 -1
  19. package/dist/catalog-policy.d.ts.map +1 -1
  20. package/dist/catalog-preview.d.ts +6 -1
  21. package/dist/catalog-preview.d.ts.map +1 -1
  22. package/dist/catalog-service.d.ts +1 -1
  23. package/dist/catalog-service.d.ts.map +1 -1
  24. package/dist/catalog.d.ts +11 -0
  25. package/dist/catalog.d.ts.map +1 -1
  26. package/dist/chunks/course-authoring-contracts-C4Hqba-e.js +131 -0
  27. package/dist/chunks/course-authoring-contracts-C4Hqba-e.js.map +1 -0
  28. package/dist/chunks/course-bundle-zip-5KUe3C5H.js +323 -0
  29. package/dist/chunks/course-bundle-zip-5KUe3C5H.js.map +1 -0
  30. package/dist/chunks/{workbook-photo-service-BtMQsbhq.js → workbook-photo-service-CjcB0y8Y.js} +6151 -5091
  31. package/dist/chunks/workbook-photo-service-CjcB0y8Y.js.map +1 -0
  32. package/dist/course-authoring-contracts.d.ts +6 -2
  33. package/dist/course-authoring-contracts.d.ts.map +1 -1
  34. package/dist/course-authoring-service.d.ts.map +1 -1
  35. package/dist/course-bundle-contracts.d.ts +21 -3
  36. package/dist/course-bundle-contracts.d.ts.map +1 -1
  37. package/dist/course-bundle-manifest-util.d.ts +15 -0
  38. package/dist/course-bundle-manifest-util.d.ts.map +1 -0
  39. package/dist/course-bundle-manifest-v2.d.ts +66 -0
  40. package/dist/course-bundle-manifest-v2.d.ts.map +1 -0
  41. package/dist/course-bundle-models.d.ts +29 -1
  42. package/dist/course-bundle-models.d.ts.map +1 -1
  43. package/dist/course-bundle-service.d.ts +104 -5
  44. package/dist/course-bundle-service.d.ts.map +1 -1
  45. package/dist/course-bundle.d.ts +81 -2
  46. package/dist/course-bundle.d.ts.map +1 -1
  47. package/dist/course-work-migration.d.ts +27 -0
  48. package/dist/course-work-migration.d.ts.map +1 -0
  49. package/dist/document-converter.d.ts +30 -18
  50. package/dist/document-converter.d.ts.map +1 -1
  51. package/dist/group-sharing-contracts.d.ts +3 -3
  52. package/dist/group-sharing-contracts.d.ts.map +1 -1
  53. package/dist/group-sharing-policy.d.ts +4 -4
  54. package/dist/group-sharing-policy.d.ts.map +1 -1
  55. package/dist/group-sharing-reader.d.ts.map +1 -1
  56. package/dist/hearth-catalog-providers.d.ts +1 -1
  57. package/dist/hearth-catalog-providers.d.ts.map +1 -1
  58. package/dist/hearth-class-crunchlabs.d.ts +94 -0
  59. package/dist/hearth-class-crunchlabs.d.ts.map +1 -0
  60. package/dist/index.d.ts +18 -13
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +1968 -419
  63. package/dist/index.js.map +1 -1
  64. package/dist/ingestion.d.ts +12 -11
  65. package/dist/ingestion.d.ts.map +1 -1
  66. package/dist/ingestion.js +63 -56
  67. package/dist/ingestion.js.map +1 -1
  68. package/dist/learner-plan-service.d.ts +1 -1
  69. package/dist/learner-plan-service.d.ts.map +1 -1
  70. package/dist/learner-service.d.ts +19 -5
  71. package/dist/learner-service.d.ts.map +1 -1
  72. package/dist/lesson-assets.d.ts +66 -2
  73. package/dist/lesson-assets.d.ts.map +1 -1
  74. package/dist/lesson-pages.d.ts +36 -0
  75. package/dist/lesson-pages.d.ts.map +1 -0
  76. package/dist/{workbook-completion.d.ts → lesson-work-completion.d.ts} +10 -10
  77. package/dist/lesson-work-completion.d.ts.map +1 -0
  78. package/dist/{workbook-contracts.d.ts → lesson-work-contracts.d.ts} +100 -52
  79. package/dist/lesson-work-contracts.d.ts.map +1 -0
  80. package/dist/lesson-work-models.d.ts +118 -0
  81. package/dist/lesson-work-models.d.ts.map +1 -0
  82. package/dist/lesson-work-service.d.ts +239 -0
  83. package/dist/lesson-work-service.d.ts.map +1 -0
  84. package/dist/manifest.json +6830 -5333
  85. package/dist/node.d.ts +3 -0
  86. package/dist/node.d.ts.map +1 -0
  87. package/dist/node.js +134 -0
  88. package/dist/node.js.map +1 -0
  89. package/dist/smrt-knowledge.json +2260 -1705
  90. package/dist/sources/class-crunchlabs-files.d.ts +8 -0
  91. package/dist/sources/class-crunchlabs-files.d.ts.map +1 -0
  92. package/dist/sources/class-crunchlabs.d.ts +217 -0
  93. package/dist/sources/class-crunchlabs.d.ts.map +1 -0
  94. package/dist/submission-service.d.ts +135 -5
  95. package/dist/submission-service.d.ts.map +1 -1
  96. package/dist/submissions.d.ts +2 -0
  97. package/dist/submissions.d.ts.map +1 -1
  98. package/dist/work-answer-regions.d.ts +11 -0
  99. package/dist/work-answer-regions.d.ts.map +1 -0
  100. package/dist/work-layout.d.ts +13 -0
  101. package/dist/work-layout.d.ts.map +1 -0
  102. package/dist/workbook-photo-contracts.d.ts +9 -7
  103. package/dist/workbook-photo-contracts.d.ts.map +1 -1
  104. package/dist/workbook-photo-ingestion.d.ts +1 -1
  105. package/dist/workbook-photo-ingestion.d.ts.map +1 -1
  106. package/dist/workbook-photo-models.d.ts +5 -5
  107. package/dist/workbook-photo-models.d.ts.map +1 -1
  108. package/dist/workbook-photo-service.d.ts +13 -10
  109. package/dist/workbook-photo-service.d.ts.map +1 -1
  110. package/package.json +12 -8
  111. package/dist/chunks/workbook-photo-service-BtMQsbhq.js.map +0 -1
  112. package/dist/workbook-answer-regions.d.ts +0 -11
  113. package/dist/workbook-answer-regions.d.ts.map +0 -1
  114. package/dist/workbook-completion.d.ts.map +0 -1
  115. package/dist/workbook-contracts.d.ts.map +0 -1
  116. package/dist/workbook-layout.d.ts +0 -13
  117. package/dist/workbook-layout.d.ts.map +0 -1
  118. package/dist/workbook-models.d.ts +0 -129
  119. package/dist/workbook-models.d.ts.map +0 -1
  120. package/dist/workbook-service.d.ts +0 -163
  121. package/dist/workbook-service.d.ts.map +0 -1
package/AGENTS.md CHANGED
@@ -8,10 +8,12 @@ executor; live session/permission checks must use that executor. That read scope
8
8
  collection's `list` to self, ancestors, and globals; it never authorizes a get,
9
9
  write, raw query, sibling, or descendant read.
10
10
 
11
- `personal-use-copy` courses and all `answer-key` lesson assets are private
12
- family material. Do not publish them, promote them, or expose them through a
13
- learner-facing result. `LessonAsset` is the sole owner join for lesson files;
14
- do not add asset-id fields to lesson subclasses.
11
+ `personal-use-copy` courses and all `answer-key`/`assessment-guidance` material
12
+ are private family material. Do not publish them, promote them, or expose them
13
+ through a learner-facing result. `LessonAsset` (lesson) and `CourseAsset` (course)
14
+ are the sole owner joins for material files; do not add asset-id fields to lesson
15
+ subclasses. Material audience rules and external videos: [lesson materials](agents/lesson-materials.md);
16
+ a learner never sees guardian-audience material.
15
17
 
16
18
  Learner objects require exact tenant ownership and close generated surfaces.
17
19
  `LearnerService` owns actor/resource authorization and transactional planning and
@@ -28,9 +30,12 @@ files after rollback; never expose source URIs or accept learner grade patches.
28
30
  Validation: `pnpm --filter @happyvertical/smrt-education test`,
29
31
  `test:postgres`, `typecheck`, and `build`.
30
32
 
31
- Physical workbook capture, immutable assessment/feedback history and descriptive
32
- evidence use [WorkbookService](agents/workbook-service.md). Keep provisional
33
- sharing separate from guardian Grade authority. Photo-first receipt/context
33
+ Every lesson hand-in is lesson work (activity, attempt, revisions, release,
34
+ completion decision); books are courses with `bookKey`, pages are `LessonPage`s,
35
+ and loose work is placed by filing. Use [LessonWorkService](agents/lesson-work-service.md)
36
+ and the [design record](agents/course-work-design.md); `migrateWorkbooksToCourses`
37
+ is the one-time host migration. Keep provisional sharing separate from guardian
38
+ Grade authority; never move filed work's IDs. Photo-first receipt/context
34
39
  capture uses [WorkbookPhotoService](agents/photo-intake.md); covers/references
35
40
  never create learning evidence, and blank exercises need explicit work intent.
36
41
 
@@ -52,11 +57,16 @@ Structured questions from scans and imported course files use
52
57
  ingestion handler. Anchors come from provider region catalogs only; match by worksheet
53
58
  id and layout, never text; ambiguous matches stay unattached; source-keyed replays.
54
59
 
55
- Course bundles (zip with a closed manifest, or a file set; Word/PowerPoint members render through an injected `DocumentConverter`, fail closed) use
60
+ Course bundles (zip with a closed manifest v1 or v2, or a file set; Word/PowerPoint members render through an injected `DocumentConverter`, fail closed; v2 carries material roles, audience, lesson pages, course members and YouTube references) use
56
61
  [CourseBundleImportService](agents/course-bundle-import.md). Parse only with the
57
62
  bounded reader; refuse, never trim, hostile input; unsupported members stay
58
63
  visible; proposals only, nothing auto-published.
59
64
 
65
+ Class CrunchLabs (a download read into a manifest v2 bundle; an operator-only
66
+ global catalog provider until the operator's single public switch):
67
+ [source contract](agents/class-crunchlabs.md). Never host its files; `operator`
68
+ visibility needs the host's `operatorTenantIds`.
69
+
60
70
  Source import: [host boundary and transaction contract](agents/source-import.md).
61
71
  `SourceImportService` requires host opt-in, matching active actor/tenant and
62
72
  explicit licensing authorization; never add a system/global write bypass.
package/README.md CHANGED
@@ -36,13 +36,17 @@ Runtime catalog operations do not create application schema.
36
36
  | Model | Purpose |
37
37
  | --- | --- |
38
38
  | `CourseSource` | Host-managed adapter policy and sharing ceiling |
39
- | `Course` | Course metadata, license, status, and optional owning tenant |
39
+ | `Course` | Course metadata, license, status, optional owning tenant, optional book identity (`bookKey`, `bookEdition`) and work policy |
40
40
  | `ExternalCourse` | External course subtype sharing the Course table |
41
41
  | `AuthoredCourse` | Authored course subtype sharing the Course table |
42
42
  | `CourseSection` | Ordered section belonging to a course |
43
43
  | `Lesson` | Ordered lesson belonging to a section |
44
44
  | `Reading`, `Video`, `Worksheet`, `Exercise` | Lesson subtypes |
45
- | `LessonAsset` | Lesson-owned file join with a relationship such as printable or answer-key |
45
+ | `LessonAsset` | Lesson-owned file join with a role (teacher-guide, slides, learner-pages, assessment, assessment-guidance, handout, printable, answer-key, video, attachment) and an audience |
46
+ | `CourseAsset` | Course-level material (supply list, posters) with the same roles and audience |
47
+ | `LessonExternalVideo` | Allowlisted YouTube reference with title and step order; never downloaded |
48
+ | `LessonPage` | A lesson's printed page: key, label, order, optional source file and page |
49
+ | `LessonActivity`, `WorkAttempt`, `WorkAssessmentRevision`, `WorkFeedbackRelease`, `WorkFeedbackDelivery`, `WorkCompletionDecision`, `LessonWorkFiling` | The one lesson work pipeline (see below) |
46
50
  | `Learner` | A required User/Person identity and guarded settings in one family tenant |
47
51
  | `Curriculum` | A learner plan for a distinct period |
48
52
  | `Enrollment` | One course in a curriculum |
@@ -162,6 +166,36 @@ guardian then confirms. It reuses learner ownership and transaction checks.
162
166
  See the [submission integration contract](agents/submission-service.md) for
163
167
  inputs, retry rules, signing requirements and external-storage compensation.
164
168
 
169
+ ## Lesson work, books and materials
170
+
171
+ Every hand-in on every lesson is lesson work: `SubmissionService` capture and
172
+ correction record the learner's `LessonActivity` and an immutable
173
+ `WorkAttempt`, and `LessonWorkService` (formerly `WorkbookService`) owns the
174
+ timeline, revisioned AI and guardian marking, feedback release, delivery and
175
+ the parent completion decision. A guardian grade on an ordinary lesson
176
+ records its completion decision; book and loose-page work complete only by
177
+ `completeLessonActivity`. Submission pages carry a `pageKey`, so a correction
178
+ may replace individual pages (`WorkAttemptView.pages` folds the versions).
179
+
180
+ A physical book is a course: `Course.bookKey`/`bookEdition` (one course per
181
+ family per key) with the course work policy (`setCourseWorkPolicy`).
182
+ `Workbook` is retired; run `migrateWorkbooksToCourses(db)` once after the
183
+ schema migration. `resolveBookPageActivity` matches book key and page key to a
184
+ `LessonPage`; `fileLessonWork` places loose photographed work in a course.
185
+
186
+ Materials carry an audience defaulted by role; learners see only learner
187
+ material (`listLessonMaterials`, `listCourseMaterials`, `signLessonMaterial`,
188
+ `signCourseMaterial`, `setMaterialAudience`, `addLessonVideo`).
189
+
190
+ Renames: `WorkbookService` → `LessonWorkService`, `WorkbookActivity` →
191
+ `LessonActivity`, `Workbook{Attempt,AssessmentRevision,FeedbackRelease,FeedbackDelivery,CompletionDecision}`
192
+ → `Work…`, `Workbook…` contracts → `Work…` (`WORK_MATH_VOCABULARY`), and the
193
+ methods listed in the [design record](agents/course-work-design.md#renames-public-api).
194
+ Removed: `Workbook`, `WorkbookWorkFiling`, `WorkbookWorkflowRequiredError`
195
+ (now `LessonWorkWorkflowRequiredError`) and `assertNonWorkbookSubmissionScope`
196
+ (now `assertOrdinarySubmissionScope`). See [lesson work](agents/lesson-work-service.md)
197
+ and [lesson materials](agents/lesson-materials.md).
198
+
165
199
  ## Source imports
166
200
 
167
201
  `SourceImportService` imports Easy Peasy pages as private family pointers after
@@ -0,0 +1,83 @@
1
+ # Class CrunchLabs source (SMRT #4616)
2
+
3
+ Class CrunchLabs is a global catalog provider (`class-crunchlabs`) whose units are
4
+ listed under Science, with the @ClassCrunchLabs YouTube course playlists as
5
+ previews. "Add to a course" reads the family's own download of the unit and runs
6
+ the [bundle import](course-bundle-import.md) with a manifest version 2 built from
7
+ it. Files come from CrunchLabs' own downloads and YouTube; **nothing is hosted
8
+ here** and the listing has no acquirable formats.
9
+
10
+ ## Operator-only until permission, one public switch
11
+
12
+ `CatalogVisibility` gains `operator`. A `CatalogPolicy` built with
13
+ `operatorTenantIds` lets only those families (host configuration, never a
14
+ request) see `operator` records; the default policy shows them to nobody, and
15
+ `authorize` answers `operator-only`. Materialization refuses them.
16
+
17
+ `createClassCrunchLabsCatalogAdapter({ playlists, publicListing? })` is a snapshot
18
+ `CatalogSyncAdapter` (one terminal page, no cursor). By default
19
+ (`CLASS_CRUNCHLABS_PUBLIC_LISTING_DEFAULT = false`) every record is `operator`
20
+ visibility with licence state `unknown`, no permitted operations and the text
21
+ "free curriculum, permission pending". Only the literal `publicListing: true`,
22
+ set by the operator in host configuration, makes records `catalog` visibility
23
+ with `verified` evidence permitting `link` only. The switch is part of each
24
+ record's revision, so the next sync rewrites the rows when it is flipped.
25
+ `classCrunchLabsPreviewEmbedUrl(record)` returns the youtube-nocookie playlist
26
+ embed for a listed unit (null for anything else). The playlist data is an
27
+ injected `ClassCrunchLabsPlaylistSource`; the adapter never touches the network.
28
+
29
+ `addClassCrunchLabsUnit({ service, actor, catalogActor, record, listing, policy,
30
+ files, playlists, pageCounter?, receiveMember? })` re-checks
31
+ `canAddClassCrunchLabsUnit(actor, policy, listing)`: visibility is derived from the
32
+ host's own `listing.publicListing` (operator unless the literal `true`), never from
33
+ the record or any caller-supplied visibility or licence; an operator-only listing
34
+ is refused to every other family, and the course actor must be the catalog actor, builds the bundle and imports each part. Then propose
35
+ structure and `applyMaterials` per part as for any bundle. Courses are
36
+ `personal-use-copy` (`build.licenseKind`).
37
+
38
+ ## Reading a download
39
+
40
+ `buildClassCrunchLabsBundle({ files, playlists?, unit?, pageCounter?,
41
+ includeUnitWideAggregates?, maxPartBytes? })` takes a `ClassCrunchLabsFileSource`:
42
+ `folderClassCrunchLabsSource(dir)` (Node only, from `@happyvertical/smrt-education/node`, never the root: a folder, the Drive zip parts, or both;
43
+ zips are read lazily from their central directory, zip64/encrypted/symlinks are
44
+ skipped) or `memoryClassCrunchLabsSource(files)`. It never reads video files.
45
+
46
+ - Layout: `<Unit>_All Unit Wide Materials` and `<Unit>_Lesson <n>`; letters A
47
+ teacher manual, B slides, C student mission log, D status check assessment
48
+ (plus guidance and the editable master), E handouts, F extras, G videos. A
49
+ lettered directory wins over a file's own letter. Several units need `unit`.
50
+ - Mapping to manifest v2: A `teacher-guide`, B `slides`, C `learner-pages`, D
51
+ `assessment` / `assessment-guidance`, E `handout`, F `printable`, the unit-wide
52
+ Supply List `attachment`. Audiences are the role defaults (teacher manual and
53
+ guidance guardian, slides guardian, mission log learner); the editable
54
+ assessment master is guardian. Unit-wide files become course members.
55
+ - The unit-wide "(all lessons)" combined files repeat the per-lesson files and
56
+ are large, so they are left out unless `includeUnitWideAggregates` (reported as
57
+ `aggregate-of-lessons`); the supply list, posters and other extras are kept.
58
+ - Lesson pages: the first mission log of a lesson gets a `Mission log` range
59
+ from `pageCounter` (`createClassCrunchLabsPageCounter({ converter? })` counts
60
+ PDFs with pdf-lib and Word files by rendering them through the converter).
61
+ Without a count the lesson gets no pages and the report says so.
62
+ - Videos: names `Lnn.step_Video_Kind_Title` are matched, by unit (playlist
63
+ title), lesson and step (`L03.6` or `Lesson 3 ... Step 6` in the item title),
64
+ to the unit playlist; two videos sharing a step are told apart by the item
65
+ title naming their title or kind, else both stay unmatched (`ambiguous`).
66
+ Matches become `videos` references with a title and a step order (materials
67
+ prep videos are guardian audience); unit-level videos (a teaser) and unmatched
68
+ ones are only reported.
69
+ - De-duplication: repeated exports of one logical path collapse (CRC and size
70
+ when known; the newer export wins, a changed file is `superseded`); the same
71
+ bytes under another name or place keep one copy (the lesson-named file in its
72
+ lesson, posters unit wide, never a `copy`).
73
+ - A unit larger than one archive (bundle cap 100 MiB) is split by lesson into
74
+ `build.parts` (default 90 MiB each; the first part carries the course members and
75
+ any lesson that fits beside them, others go to the first part with room; if no
76
+ lesson fits beside the course members the build refuses with `too_large`); `build.bundle`
77
+ and `build.manifest` are the first part.
78
+
79
+ The `report` lists lessons, members, parts, pages, matched and unmatched videos,
80
+ duplicates, ignored files and warnings. Fixtures in tests are synthetic files
81
+ with the same naming; no real download is ever copied into the repository.
82
+
83
+ Tests: `src/__tests__/class-crunchlabs.test.ts`.
@@ -84,7 +84,7 @@ too long or deep). An **honestly** oversize member (declared over
84
84
 
85
85
  ## Members and statuses
86
86
 
87
- By content (never by extension alone): `docx`/`pptx` (see below), `pdf` (`%PDF-` in the first 1 KiB),
87
+ By content (never by extension alone): `docx`/`pptx`/`xlsx` (see below), `pdf` (`%PDF-` in the first 1 KiB),
88
88
  `image` (PNG, JPEG, WebP), `text` (`.txt`/`.md`, strict UTF-8, no control
89
89
  characters, body-sized), `manifest` (root `manifest.json`), else `unsupported`.
90
90
  Status: `accepted` (handed to extraction), `duplicate` (same sha256 as an earlier
@@ -97,9 +97,11 @@ on literal text inside an uncompressed stream is refused, never read.
97
97
 
98
98
  ## Word and PowerPoint members (SMRT #4615)
99
99
 
100
- A `.docx`/`.pptx` member is an OOXML package only when the name and the content
101
- agree: a zip whose entries include `[Content_Types].xml` and `word/document.xml`
102
- (docx) or `ppt/presentation.xml` (pptx). Before the bytes go anywhere, the
100
+ A `.docx`/`.pptx`/`.xlsx` member is an OOXML package only when the name and the
101
+ content agree: a zip whose entries include `[Content_Types].xml` and
102
+ `word/document.xml` (docx), `ppt/presentation.xml` (pptx) or `xl/workbook.xml`
103
+ (xlsx). A workbook (a supply list) is rendered and kept like the others but has
104
+ no text reader. Before the bytes go anywhere, the
103
105
  package itself passes the same bounded zip reader (ratio, overlap, paths, CRC,
104
106
  100 MiB inflated); a failure is `refused / unsafe_office`, never a refusal of the
105
107
  whole bundle. Other zips stay `unsupported / nested_archive`.
@@ -158,10 +160,15 @@ network**, its own size/time limits and no shared volume with the app; this
158
160
  package assumes untrusted files never run next to the application. Tests use
159
161
  `FakeDocumentConverter` (records calls; `failWith`, `failWhen`, `delayMs`, `output`).
160
162
 
161
- `OfficeTextReader` is deliberately not wired to the SDK here, so this package
162
- does not depend on an unreleased `@happyvertical/documents`. Once the Office
163
- processors ship (sdk#1485, `extractOffice(bytes, 'docx' | 'pptx')`), implement
164
- `read` with it; the snippet is in the `OfficeTextReader` doc comment.
163
+ `OfficeTextReader` reads Office text through the SDK without this package
164
+ depending on `@happyvertical/documents`: the host imports `extractOffice`
165
+ (released in `@happyvertical/documents` 0.111.4, sdk#1486, Kreuzberg) and passes
166
+ it to `createOfficeTextReader(extractOffice)`, which returns whole-document
167
+ text for Word and text plus speaker notes per slide for PowerPoint (images are
168
+ never requested). An absent engine reads as `null`; a corrupt file rejects and
169
+ the importer treats that as "no text". The repository's current `catalog:`
170
+ pin is 0.111.3, which predates the Office processors, so the host bumps the SDK
171
+ set (and its minimum release age) before it can import `extractOffice`.
165
172
 
166
173
  ## Manifest (`manifest.json`, version 1, closed)
167
174
 
@@ -186,10 +193,96 @@ are never inlined. Members whose declared role is `answer_key` or `mixed` never
186
193
  contribute text to a lesson body (they remain recorded evidence members); the
187
194
  proposal rationale only counts how many were withheld.
188
195
 
196
+ ## Manifest version 2 (SMRT #4615)
197
+
198
+ A root `manifest.json` with `"version": 2` is laid out by the `manifest-v2`
199
+ reader; version 1 keeps its own reader and page roles. The schema is closed in
200
+ the same way: an unknown key anywhere is `manifest_unknown_key` (with its path in
201
+ `entry`), another version is `manifest_version`.
202
+
203
+ ```json
204
+ { "version": 2, "course": { "subject": "Science" },
205
+ "courseMembers": [ { "path": "unit/supplies.pdf", "role": "attachment", "title": "Supply list" } ],
206
+ "sections": [ { "key": "unit", "title": "Unit", "lessons": [ {
207
+ "key": "l1", "title": "Lesson 1",
208
+ "members": [ { "path": "l1/log.docx", "role": "learner-pages" },
209
+ { "path": "l1/deck.pptx", "role": "slides" },
210
+ { "path": "l1/guidance.pdf", "role": "assessment-guidance" } ],
211
+ "pages": [ { "member": "l1/log.docx", "from": 1, "to": 4, "label": "Mission log" } ],
212
+ "videos": [ { "reference": "dQw4w9WgXcQ", "title": "Recap", "step": 1 } ] } ] } ] }
213
+ ```
214
+
215
+ - **Members** (lesson `members` and root `courseMembers`): `path` (must exist, listed
216
+ once), `role` (required) one of `teacher-guide`, `slides`, `learner-pages`,
217
+ `assessment`, `assessment-guidance`, `handout`, `printable`, `answer-key`,
218
+ `attachment` (`manifest_role` otherwise; a video is a reference, never a file),
219
+ optional `audience` and `title`. The audience is the role default
220
+ (`LESSON_ASSET_DEFAULT_AUDIENCE`) unless stated; `answer-key` and
221
+ `assessment-guidance` are always guardian (a learner audience is
222
+ `manifest_audience`). Members not listed stay recorded evidence with no role.
223
+ - **Pages**: `{ member, from, to, label? }` ranges over a member of the same lesson
224
+ (PDF, Word or PowerPoint; the rendered PDF for Office). Each page becomes a
225
+ `LessonPage` keyed `p<n>`, labelled `<label> <n>` (default `Page <n>`), ordered by
226
+ declaration. Bad, overlapping or unpageable ranges are `manifest_pages`; at most
227
+ 500 pages per lesson.
228
+ - **Videos**: `{ reference, title, step, audience? }` where `reference` is a bare id
229
+ or an allowlisted https YouTube URL (`parseYouTubeReference`; anything else is
230
+ `manifest_video`), `step` an integer 0-9999. They become `LessonExternalVideo`
231
+ rows; nothing is downloaded.
232
+ - **Course members** become `CourseAsset` rows, not tied to any lesson.
233
+
234
+ Guardian-audience text (teacher guides, guidance, keys) never enters a proposed
235
+ lesson body; only learner-audience text members do. Under v2 `role` on the member
236
+ evidence row is the material role and `audience` the effective audience (NULL on
237
+ rows recorded before v2). The plan carries `materials` (also on
238
+ `CourseBundleImportResult.materials`): the resolved materials, pages and videos,
239
+ plus `skipped` for declared members that were unsupported, refused or failed
240
+ rendering. A duplicate member resolves to the earlier identical member's bytes
241
+ (`assetPath`) and one material per (place, role, content).
242
+
243
+ `receiveMember` also gets `audience` and `courseLevel`. **Slides** keep the original
244
+ deck (hand `bytes` to the host as a private asset: "Open in PowerPoint") and its
245
+ rendered pages (`rendered`: "Present").
246
+
247
+ ### Applying materials
248
+
249
+ `applyMaterials({ ...actor, importId, bundle, courseId, lessons, assets })`
250
+ (`education.course.author`, operation `apply-course-bundle-materials`) writes the
251
+ rows in one transaction. The host has stored each accepted member as a private
252
+ asset and created the course's lessons: `lessons` maps manifest lesson key to the
253
+ `Lesson` id, `assets` maps member path to `{ original, rendered }` asset ids.
254
+ Slides join both the original deck and the rendered PDF; the original join is always
255
+ guardian-only whatever the deck's declared audience (the audience applies to the
256
+ rendered PDF), and a join written earlier with a wider audience is repaired on
257
+ re-apply (`repaired`); any other Word/PowerPoint
258
+ member joins only its rendered PDF, so a learner is never handed an editable
259
+ original. `LessonAsset`/`CourseAsset` saves keep their private-asset rule (guardian-
260
+ only roles and personal-use-copy courses need private assets). It targets
261
+ family-owned courses; an authored course is `authored-course` because authored
262
+ material changes through course authoring. Idempotent: an identical row is
263
+ `unchanged`, a row with other details is `CourseBundleMaterialsError('conflict')`,
264
+ a missing asset or lesson is `asset-missing`/`lesson-missing`; any error writes
265
+ nothing.
266
+
267
+ ### Retrying a failed rendering
268
+
269
+ `retryRendering({ ...actor, importId, bundle, paths?, receiveMember? })`
270
+ (`education.course.author`, operation `retry-course-bundle-rendering`, needs a
271
+ converter) renders the Word/PowerPoint members whose first import recorded a
272
+ *transient* failure (`converter_unavailable`, `converter_failed`,
273
+ `converter_timeout`; `COURSE_BUNDLE_RETRYABLE_REASONS`). The first import's member
274
+ rows are never rewritten. A success appends one `course_bundle_renderings` row
275
+ per (import, content hash); every reader (`readImport`, replayed imports,
276
+ proposals) overlays it, so the member reads `accepted` (identical copies as
277
+ duplicates) with its rendered hash. A failure records nothing, so asking again is
278
+ always allowed; a second retry of a repaired member is `unchanged` and renders
279
+ nothing. Converter rejections, size limits and the Office member cap are not
280
+ retryable: change the bundle. The bundle must be the imported one.
281
+
189
282
  ## Readers
190
283
 
191
284
  `BundleReader { formatId, detect, prepare }` in a `BundleReaderRegistry`;
192
- built-ins are `manifest-v1` and `plain-files`. SCORM and Common Cartridge are
285
+ built-ins are `manifest-v2`, `manifest-v1` and `plain-files`. SCORM and Common Cartridge are
193
286
  **not implemented** (their manifests describe launch resources and activity
194
287
  trees that do not map to sections/lessons without design); a host can register
195
288
  a reader ahead of the built-ins (`register(reader, { first: true })`).
@@ -203,20 +296,17 @@ re-zipping, reordering and timestamps replay. One `CourseBundleImport` per
203
296
  ## Models (closed, required tenant, no REST/CLI/MCP/AI, append-only)
204
297
 
205
298
  `CourseBundleImport` (`course_bundle_imports`: identity, format, reader,
206
- manifest version, counts, actor) and `CourseBundleMember` (`course_bundle_members`:
299
+ manifest version, counts, actor), `CourseBundleMember` (`course_bundle_members`:
207
300
  unique tenant/import/path; ordinal, sha256, size, media type, kind, status,
208
- reason, duplicate-of, role, section and lesson key). Hashes and placements only:
301
+ reason, duplicate-of, role, audience, section and lesson key) and
302
+ `CourseBundleRendering` (`course_bundle_renderings`: a deliberate retry's success). Hashes and placements only:
209
303
  bytes stay with the host's ingestion receipts. Rewrites and deletes throw.
210
- Normal manifest migrations create both tables.
304
+ Normal manifest migrations create the tables and add `audience` (NULL on older rows).
211
305
 
212
306
  ## Not in this release
213
307
 
214
- - Manifest version 2 (material roles with audience, lesson pages, course-level
215
- members, external video references) waits for the phase 1 role vocabulary
216
- (smrt#4614). The page-role vocabulary and the closed v1 schema are unchanged;
217
- see the hand-off note on smrt#4615.
218
- - Re-rendering a member whose first import recorded a converter failure (outcomes
219
- are append-only per bundle identity).
308
+ - Material rows for authored courses: a proposal still carries lesson text only;
309
+ authored course material goes through `CourseMaterialInput` and release.
220
310
  - Ingestion's receipt contract is unchanged: there is no `application/zip` part
221
311
  type or `parentEvidenceId` in ingestion. The education side unpacks and the
222
312
  host receives members one by one, so archive-to-member lineage lives in
@@ -0,0 +1,144 @@
1
+ # Course work design (SMRT #4614, folds in #4613)
2
+
3
+ Phase 1 of hearthed.org#125. Breaking changes are allowed: Hearth is the only
4
+ consumer. This note is the design record; the current contracts live in
5
+ [lesson work](lesson-work-service.md), [photo intake](photo-intake.md),
6
+ [submission](submission-service.md) and [lesson materials](lesson-materials.md).
7
+
8
+ ## Model
9
+
10
+ - **Course absorbs Workbook.** `Course` gains optional book identity: `bookKey`
11
+ (`''` = not a book), `bookEdition`, and the course work policy
12
+ `workReviewRequired` (default true) / `workPolicyVersion` (default 1). A
13
+ book key requires an owning tenant and is unique per family (one course per
14
+ tenant per book key, enforced at save). `Workbook` and its learner-scoped
15
+ rows are retired; the model is deleted.
16
+ - **Lessons have pages.** `LessonPage` (tenant, lesson, `pageKey`, `label`,
17
+ `sortOrder`, nullable `sourceAssetId` + `sourcePageNumber`) is family-scoped
18
+ course structure, unique per (tenant, lesson, page key). Photo identification
19
+ resolves book key → course, page key → lesson page; a book page without a
20
+ lesson gets a one-page Worksheet lesson in the book course.
21
+ - **One work pipeline.** Every hand-in on every lesson is a `LessonActivity`
22
+ (one per learner enrollment and lesson) with immutable `WorkAttempt`s,
23
+ revisioned `WorkAssessmentRevision`s, `WorkFeedbackRelease`s,
24
+ `WorkFeedbackDelivery`s and `WorkCompletionDecision`s. `SubmissionService`
25
+ capture and correction write the activity and attempt in the same
26
+ transaction, so ordinary lessons, book pages and loose photos share one
27
+ timeline, review, release and completion contract. Completion differs by
28
+ kind: on an ordinary lesson the guardian grade records the completion
29
+ decision on the graded attempt (and the learner sees the grade); book and
30
+ loose-page work is release-gated and completes only by the parent decision,
31
+ so it stays on `LessonWorkService` (generic capture refuses it with
32
+ `LessonWorkWorkflowRequiredError`).
33
+ - **Per-page versions (#4613).** `SubmissionAsset.pageKey` names the lesson page
34
+ a file belongs to (default `p<n>` by position). A correction may carry only
35
+ the pages it replaces; earlier pages stay at their version. Attempt views
36
+ expose `pages: [{pageKey, versions:[{submissionId, attemptId, assetId}]}]`
37
+ folded over the correction chain. Grades stay bound to their submission.
38
+ - **Filing places work.** `LessonWorkFiling` (new table) appends a placement for
39
+ unfiled work: target course, the learner's enrollment, lesson and lesson page.
40
+ A book course appends a Worksheet lesson when no lesson has the page key; an
41
+ authored course needs the lesson to exist (the host appends it first with
42
+ `CourseAuthoringService.appendLesson`, which owns revisions and releases).
43
+ Placement is the activity's effective location: lists by course include it,
44
+ the unfiled list drops it, and the target lesson's progress follows the
45
+ activity's completion. Activity, attempt, submission and assessment IDs never
46
+ move. Replays are no-ops; a changed replay or stale head conflicts.
47
+ - **Material roles and audience.** `LessonAssetRelationship` is `teacher-guide`,
48
+ `slides`, `learner-pages`, `assessment`, `assessment-guidance`, `handout`,
49
+ `printable`, `answer-key`, `video`, `attachment`. Each `LessonAsset` carries
50
+ `audience: 'learner' | 'guardian'` with a per-role default
51
+ (`LESSON_ASSET_DEFAULT_AUDIENCE`): guardian for teacher-guide, slides,
52
+ assessment-guidance, answer-key; learner otherwise. `answer-key` and
53
+ `assessment-guidance` are always guardian. `CourseAsset` holds course-level
54
+ materials with the same roles and audience.
55
+ - **External videos.** A `video` material may be an external reference instead
56
+ of a stored asset: `externalProvider: 'youtube'`, an 11-character video id
57
+ (from an id or an allowlisted youtube.com / youtu.be URL), a title and a step
58
+ order. It is never downloaded; no Asset row exists for it.
59
+
60
+ ## Audience rule
61
+
62
+ Learner-facing reads (`listLessonMaterials`/`listCourseMaterials` for the learner
63
+ themself, `signLessonMaterial`/`signCourseMaterial`, and every catalog projection
64
+ without answer keys) return only rows whose effective audience is `learner`.
65
+ Guardian-only material, keys, rubrics, notes and storage URIs never reach a
66
+ learner. A guardian may change a family-owned row's audience within the role's
67
+ bounds through `setMaterialAudience`; authored courses change it through course
68
+ authoring (`CourseMaterialInput.audience`) and the next release.
69
+
70
+ ## Renames (public API)
71
+
72
+ Storage names that are persisted identifiers keep their value: tables
73
+ `workbook_activities`, `workbook_attempts`, `workbook_assessment_revisions`,
74
+ `workbook_feedback_releases`, `workbook_feedback_deliveries`,
75
+ `workbook_completion_decisions`, `workbook_photo_*`, and the ingestion
76
+ handler/operation IDs captured in receipt ceilings. Renaming them would copy
77
+ append-only history and re-point group-sharing foreign keys for no behaviour
78
+ change. The photo-intake models (`WorkbookPhoto*`) keep their class names for
79
+ the same reason (their names derive persisted operation IDs and grants).
80
+
81
+ | Before | After |
82
+ | --- | --- |
83
+ | `WorkbookService` | `LessonWorkService` |
84
+ | `WorkbookActivity` / `WorkbookActivityView` | `LessonActivity` / `LessonActivityView` |
85
+ | `WorkbookAttempt`, `WorkbookAssessmentRevision`, `WorkbookFeedbackRelease`, `WorkbookFeedbackDelivery`, `WorkbookCompletionDecision` | `WorkAttempt`, `WorkAssessmentRevision`, `WorkFeedbackRelease`, `WorkFeedbackDelivery`, `WorkCompletionDecision` |
86
+ | `WorkbookAssessment`, `WorkbookQuestion`, … contracts, `WORKBOOK_MATH_VOCABULARY` | `WorkAssessment`, `WorkQuestion`, …, `WORK_MATH_VOCABULARY` |
87
+ | `resolveWorkbookActivity({workbookKey,title,edition,pageKey,pageLabel})` | `resolveBookPageActivity({bookKey,title,edition,pageKey,pageLabel})` |
88
+ | `getWorkbookActivity`, `listWorkbookActivities` | `getLessonActivity`, `listLessonActivities({courseId?, filing?})` |
89
+ | `setWorkbookFeedbackPolicy({activityId,…})` | `setCourseWorkPolicy({courseId,expectedPolicyVersion,reviewRequired})` |
90
+ | `captureWorkbookWork` | `captureLessonWork` (pages may carry `pageKey`) |
91
+ | `recordWorkbookAnalysis`, `reviewWorkbookAssessment`, `releaseWorkbookFeedback`, `acknowledgeWorkbookFeedbackDelivery`, `completeWorkbookActivity`, `getWorkbookTimeline` | `recordWorkAnalysis`, `reviewWorkAssessment`, `releaseWorkFeedback`, `acknowledgeWorkFeedbackDelivery`, `completeLessonActivity`, `getWorkTimeline` |
92
+ | `getWorkbook{AttemptBySubmission,PreservationState,SourceAnchors,QuestionIdentity,QuestionContext}` | `getWork…` |
93
+ | `fileWorkbookWork({workbookId \| newWorkbook, canonicalActivityId})` | `fileLessonWork({courseId \| newBook, lessonId?, pageKey?, pageLabel?})` |
94
+ | `listWorkbookPhotoWorkbooks` | `listPhotoBookCourses` |
95
+ | `WorkbookContextRevision` (`workbookId`) | `BookContextRevision` (`courseId`), table `book_context_revisions` |
96
+ | `WorkbookWorkflowRequiredError`, `assertNonWorkbookSubmissionScope` | `LessonWorkWorkflowRequiredError`, `assertOrdinarySubmissionScope` (book and loose-page work only) |
97
+ | `WorkbookPhotoIntakeView.workbookId` | `bookCourseId` |
98
+ | `Workbook`, `WorkbookWorkFiling` | removed (`Course` book identity, `LessonWorkFiling`) |
99
+
100
+ DTO fields: `workbookKey`/`edition`/`title` on activity views become
101
+ `courseId`, `courseTitle`, `bookKey`, `bookEdition`, `lessonTitle` and `pages`
102
+ (the effective placement; `enrollmentId`/`lessonId` stay the capture routing);
103
+ `filing.workbookId`/`canonicalActivityId` become `filing.placement`. Submission
104
+ pages and `WorkAttemptView` gain `pageKey`/`pages`.
105
+
106
+ ## Migration
107
+
108
+ `migrateWorkbooksToCourses(db)` (exported, host-run after the schema migration,
109
+ idempotent, transactional). It reads the retired `workbooks`,
110
+ `workbook_work_filings` and `workbook_context_revisions` tables when they exist
111
+ and is a no-op otherwise. It first drops the retired unique index
112
+ `workbook_activities_tenant_id_workbook_id_idx` (tenant, workbook, page key): new
113
+ activities carry no book link and PostgreSQL's NULLS NOT DISTINCT would make them
114
+ collide. The schema diff leaves the retired `workbook_id`/`page_key`/`page_label`
115
+ columns as reported orphans; drop them later with `--drop-columns`.
116
+ The comparer's "rename data pending" note for `workbook_activities.context`
117
+ (it guesses the orphan `page_key`/`page_label` data was renamed) is report-only
118
+ and must not be acted on. `LessonActivity` declares `onDelete: 'NO ACTION'` on
119
+ `enrollmentId`/`lessonId`: as its new natural key they would otherwise default to
120
+ CASCADE and differ from the deployed foreign keys, which SQLite can only change
121
+ by a table rebuild (`lesson-work-schema-contract.test.ts`). The opt-in
122
+ `course-work-real-data.optional.test.ts` (`HEARTH_LEGACY_DB=<copy>`) runs the
123
+ schema step, the data step and new hand-ins on a copy of a real database.
124
+
125
+ 1. Group workbooks by (tenant, workbook key). The canonical course is the
126
+ earliest (created_at, id) learner's `physical-workbook` scaffold course; it
127
+ receives `bookKey`, `bookEdition`, title and the strictest policy (review
128
+ required if any duplicate required it; policy version = max).
129
+ 2. Each other learner's scaffold course is merged: its sections and its
130
+ enrollment are re-pointed to the canonical course, and the emptied course is
131
+ archived with `sourceKey: 'merged-book'`, `sourceRef: <canonical id>`.
132
+ Learner history rows (progress, submissions, attempts, grades) are not
133
+ rewritten; their lessons move with their section.
134
+ 3. Every legacy activity's page key/label becomes a `LessonPage` on its lesson.
135
+ 4. Legacy filings become `LessonWorkFiling` rows with the same IDs, predecessors
136
+ first, placing the work in the canonical course: on the named canonical
137
+ activity's lesson and page, else on a new `Captured work` lesson. The target
138
+ lesson's progress row is created from the work's completion.
139
+ 5. Context revisions are copied into `book_context_revisions` with the same IDs
140
+ and `courseId` = canonical course.
141
+
142
+ Each step skips rows already migrated (same IDs or existing book key), so a
143
+ re-run changes nothing. Retired tables are left in place for audit; dropping
144
+ them is a later deliberate operation.
@@ -104,7 +104,7 @@ The authorization callback is `GroupSharingAuthorizer(request)` where request ca
104
104
 
105
105
  ## Owning workbook/media seams
106
106
 
107
- Current `WorkbookService` activity/timeline APIs require learner/guardian authority; replacing the authorizer with unconditional true would create a broad guardian backdoor. Add an explicit read-only owning projection seam with a required exact-resource authorization callback, invoked on its active executor before loading private data and again at final projection/delivery where appropriate. It must not expose mutation methods or accept an externally cached authorization boolean. This seam can live in a dedicated education sharing reader that uses owned collections with exact predicates; private workbook algorithms should be shared through an owning helper rather than copied or private-state access.
107
+ Current `LessonWorkService` activity/timeline APIs require learner/guardian authority; replacing the authorizer with unconditional true would create a broad guardian backdoor. Add an explicit read-only owning projection seam with a required exact-resource authorization callback, invoked on its active executor before loading private data and again at final projection/delivery where appropriate. It must not expose mutation methods or accept an externally cached authorization boolean. This seam can live in a dedicated education sharing reader that uses owned collections with exact predicates; private workbook algorithms should be shared through an owning helper rather than copied or private-state access.
108
108
 
109
109
  The selected attempt must belong to exact family/learner/activity/submission; selected revision must belong to that attempt. Original bytes must be owned through `SubmissionAsset`, match the retained source ID/hash and belong to the learner's private asset owner. Photo covers/reference-only receipts are not work. The ordinary `signSubmissionAsset` returns a signed URL after learner authorization; it is insufficient for this sharing contract. `readSharedMedia` must run current policy/role/session/edge checks for every HTTP delivery and open the exact `withPrivateAssetAccess` scope only within that request. Return bytes or execute a scoped streaming callback; never return public/presigned storage URLs or a long-lived capability. The host route must invoke it even for a replayed/direct URL and configure private storage/cache headers. No framework can revoke bytes already delivered; successful reads linearize at their final authority check. A revocation completed before a new request must always deny that request.
110
110
 
@@ -296,3 +296,7 @@ label only; no reviewed cross-tenant read path exists for it, so it grants nothi
296
296
  Non-authored courses, answer keys and personal-use rules are untouched, and a
297
297
  database without the visibility column or release tables reads as never recorded.
298
298
  The gate is `authoredVisibilityPermitsGroups(db, tenantId, releaseCourseId)`.
299
+
300
+ Filed loose work is attributed to its current filing placement (target lesson,
301
+ enrollment and course, hence subject) under the same consent, offered-metadata and
302
+ pin-lineage rules as routed work; unplaced loose work stays unknown metadata.
@@ -19,16 +19,16 @@ Technical unfiled capture scaffolding has sourceKey exactly `unfiled-work`;
19
19
  consumers exclude it by identity, never by title. The DTO does not invent a cover
20
20
  or claim any source/asset preview binding. It returns no teacher or answer-key
21
21
  assets. Lessons preserve catalog section/lesson order and existing role-filtered
22
- progress, including private workbook feedback rules. Optional workbookActivityId
22
+ progress, including private workbook feedback rules. Optional lessonActivityId
23
23
  is resolved for the exact tenant/learner/enrollment/lesson; consumers route that
24
24
  work to their workbook interface, not generic scan capture.
25
25
 
26
- Workbook progress uses the same explicit parent completion projection as Work.
26
+ Book and loose-page progress uses the same explicit parent completion projection as Work.
27
27
  Old grade-derived complete/skipped rows without a current decision project in
28
28
  progress with completedAt null; they cannot contribute a completion timestamp to
29
29
  progressEvidence. Later analysis of the same attempt retains a parent's decision;
30
30
  a new correction or practice attempt reopens it. Ordinary lesson progress is
31
- unchanged. See [the workbook contract](workbook-service.md).
31
+ unchanged. See [the workbook contract](lesson-work-service.md).
32
32
 
33
33
  For required-online lessons, optional sourceUrl comes only from the same
34
34
  catalog-authorized Lesson.externalUrl and must be HTTPS without credentials.
@@ -115,7 +115,7 @@ Same-tenant membership alone never grants sibling access.
115
115
  returns `{ changed: false }`; one complete any other way (guardian
116
116
  `attestLesson`/grade, workbook completion) throws `LessonAlreadyCompleteError`.
117
117
  Also `EnrollmentNotActiveError` (`.status`; only `active` enrollments),
118
- `LessonSkippedError`, `WorkbookOwnedLessonError` (a workbook lesson completes
118
+ `LessonSkippedError`, `LessonWorkOwnedError` (a workbook lesson completes
119
119
  through its workbook decision), and `LearnerHistoryUnavailableError` without
120
120
  `lesson_completion_records`. Guardian `attestLesson` is unchanged.
121
121
  - `setEnrollmentPace({ enrollmentId, pace: { lessonsPerWeek } | null, policyBasis })`
@@ -0,0 +1,52 @@
1
+ # Lesson materials and audience
2
+
3
+ Design record: [course work](course-work-design.md). Models are closed (no REST,
4
+ CLI, MCP or AI surface); reads and writes go through `SubmissionService` (and its
5
+ `LessonWorkService` subclass) with the usual actor, learner and enrollment checks.
6
+
7
+ ## Roles and audience
8
+
9
+ `LessonAssetRelationship` (`LESSON_ASSET_RELATIONSHIPS`): `teacher-guide`, `slides`,
10
+ `learner-pages`, `assessment`, `assessment-guidance`, `handout`, `printable`,
11
+ `answer-key`, `video`, `attachment`. Each `LessonAsset`, `CourseAsset` and
12
+ `LessonExternalVideo` row has `audience: 'learner' | 'guardian'`; `''` means the role
13
+ default (`LESSON_ASSET_DEFAULT_AUDIENCE`): guardian for teacher-guide, slides,
14
+ assessment-guidance and answer-key, learner otherwise. `materialAudience(role,
15
+ audience?)` resolves and validates it. `answer-key` and `assessment-guidance`
16
+ (`GUARDIAN_ONLY_RELATIONSHIPS`) can never be learner-audience and, like any join in
17
+ a personal-use-copy course, require a private asset under the host's exact
18
+ actor/owner/asset capability.
19
+
20
+ `CourseAsset` holds course-level material (supply list, posters) with the same
21
+ roles, audience and private-asset rule, plus an optional title.
22
+
23
+ ## Learner never sees guardian material
24
+
25
+ `listLessonMaterials({enrollmentId, lessonId})` and `listCourseMaterials({enrollmentId})`
26
+ return `LessonMaterialView`s (`id`, `kind` file or external-video, `scope`, role,
27
+ audience, title, order, `assetId` or provider/videoId/`embedUrl`). The learner
28
+ themself sees only learner-audience rows; an authorized guardian sees all. The
29
+ catalog projection without answer keys (every learner-facing catalog read) applies
30
+ the same rule. `signLessonMaterial`/`signCourseMaterial` sign only a file in the
31
+ actor's own list (claim scope `lesson:<id>` / `course:<id>`); a guessed id of
32
+ guardian material is denied. `signLessonPrintable` applies the same audience
33
+ rule to the learner. Storage URIs never appear.
34
+
35
+ `setMaterialAudience({enrollmentId, scope, kind, materialId, audience})` lets a
36
+ guardian with plan authority change a family-owned row within the role's bounds.
37
+ Authored courses change audience through course authoring (`CourseMaterialInput.audience`,
38
+ carried to the released join); shared or other families' rows are denied.
39
+
40
+ ## External videos
41
+
42
+ `LessonExternalVideo` references a video at an allowlisted provider (YouTube only):
43
+ `videoId` (11 characters), title, `stepOrder` and audience. It is never downloaded
44
+ and has no Asset row. `parseYouTubeReference` accepts a bare id or an https
45
+ youtube.com / m. / music. / youtu.be / youtube-nocookie.com URL (watch, embed,
46
+ shorts, live) and nothing else; `externalVideoEmbedUrl` returns the
47
+ youtube-nocookie embed URL. `addLessonVideo({enrollmentId, lessonId, reference,
48
+ title, stepOrder, audience?})` adds one to a family-owned lesson (plan authority);
49
+ the same video with the same details replays, other details conflict, and a
50
+ non-allowlisted reference throws `ExternalVideoReferenceError`.
51
+
52
+ Tests: `src/__tests__/lesson-materials.test.ts`.