@happyvertical/smrt-education 0.57.51 → 0.58.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 (93) hide show
  1. package/AGENTS.md +13 -8
  2. package/README.md +36 -2
  3. package/agents/course-bundle-import.md +80 -2
  4. package/agents/course-work-design.md +144 -0
  5. package/agents/group-sharing.md +5 -1
  6. package/agents/learner-plan.md +3 -3
  7. package/agents/learner-service.md +1 -1
  8. package/agents/lesson-materials.md +52 -0
  9. package/agents/{workbook-service.md → lesson-work-service.md} +164 -95
  10. package/agents/{workbook-test-matrix.md → lesson-work-test-matrix.md} +3 -3
  11. package/agents/photo-intake.md +37 -50
  12. package/agents/question-records.md +1 -1
  13. package/agents/submission-service.md +22 -6
  14. package/dist/catalog-service.d.ts +1 -1
  15. package/dist/catalog-service.d.ts.map +1 -1
  16. package/dist/catalog.d.ts +11 -0
  17. package/dist/catalog.d.ts.map +1 -1
  18. package/dist/chunks/{workbook-photo-service-BtMQsbhq.js → workbook-photo-service-COtyQL1y.js} +5927 -4963
  19. package/dist/chunks/workbook-photo-service-COtyQL1y.js.map +1 -0
  20. package/dist/course-authoring-contracts.d.ts +5 -1
  21. package/dist/course-authoring-contracts.d.ts.map +1 -1
  22. package/dist/course-authoring-service.d.ts.map +1 -1
  23. package/dist/course-bundle-contracts.d.ts +23 -3
  24. package/dist/course-bundle-contracts.d.ts.map +1 -1
  25. package/dist/course-bundle-models.d.ts +3 -0
  26. package/dist/course-bundle-models.d.ts.map +1 -1
  27. package/dist/course-bundle-service.d.ts +45 -1
  28. package/dist/course-bundle-service.d.ts.map +1 -1
  29. package/dist/course-bundle.d.ts +32 -0
  30. package/dist/course-bundle.d.ts.map +1 -1
  31. package/dist/course-work-migration.d.ts +27 -0
  32. package/dist/course-work-migration.d.ts.map +1 -0
  33. package/dist/document-converter.d.ts +138 -0
  34. package/dist/document-converter.d.ts.map +1 -0
  35. package/dist/group-sharing-contracts.d.ts +3 -3
  36. package/dist/group-sharing-contracts.d.ts.map +1 -1
  37. package/dist/group-sharing-policy.d.ts +4 -4
  38. package/dist/group-sharing-policy.d.ts.map +1 -1
  39. package/dist/group-sharing-reader.d.ts.map +1 -1
  40. package/dist/index.d.ts +15 -12
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +737 -49
  43. package/dist/index.js.map +1 -1
  44. package/dist/ingestion.d.ts +12 -11
  45. package/dist/ingestion.d.ts.map +1 -1
  46. package/dist/ingestion.js +63 -56
  47. package/dist/ingestion.js.map +1 -1
  48. package/dist/learner-plan-service.d.ts +1 -1
  49. package/dist/learner-plan-service.d.ts.map +1 -1
  50. package/dist/learner-service.d.ts +19 -5
  51. package/dist/learner-service.d.ts.map +1 -1
  52. package/dist/lesson-assets.d.ts +66 -2
  53. package/dist/lesson-assets.d.ts.map +1 -1
  54. package/dist/lesson-pages.d.ts +36 -0
  55. package/dist/lesson-pages.d.ts.map +1 -0
  56. package/dist/{workbook-completion.d.ts → lesson-work-completion.d.ts} +10 -10
  57. package/dist/lesson-work-completion.d.ts.map +1 -0
  58. package/dist/{workbook-contracts.d.ts → lesson-work-contracts.d.ts} +100 -52
  59. package/dist/lesson-work-contracts.d.ts.map +1 -0
  60. package/dist/lesson-work-models.d.ts +118 -0
  61. package/dist/lesson-work-models.d.ts.map +1 -0
  62. package/dist/lesson-work-service.d.ts +239 -0
  63. package/dist/lesson-work-service.d.ts.map +1 -0
  64. package/dist/manifest.json +6420 -5299
  65. package/dist/smrt-knowledge.json +2156 -1740
  66. package/dist/submission-service.d.ts +135 -5
  67. package/dist/submission-service.d.ts.map +1 -1
  68. package/dist/submissions.d.ts +2 -0
  69. package/dist/submissions.d.ts.map +1 -1
  70. package/dist/work-answer-regions.d.ts +11 -0
  71. package/dist/work-answer-regions.d.ts.map +1 -0
  72. package/dist/work-layout.d.ts +13 -0
  73. package/dist/work-layout.d.ts.map +1 -0
  74. package/dist/workbook-photo-contracts.d.ts +9 -7
  75. package/dist/workbook-photo-contracts.d.ts.map +1 -1
  76. package/dist/workbook-photo-ingestion.d.ts +1 -1
  77. package/dist/workbook-photo-ingestion.d.ts.map +1 -1
  78. package/dist/workbook-photo-models.d.ts +5 -5
  79. package/dist/workbook-photo-models.d.ts.map +1 -1
  80. package/dist/workbook-photo-service.d.ts +13 -10
  81. package/dist/workbook-photo-service.d.ts.map +1 -1
  82. package/package.json +8 -8
  83. package/dist/chunks/workbook-photo-service-BtMQsbhq.js.map +0 -1
  84. package/dist/workbook-answer-regions.d.ts +0 -11
  85. package/dist/workbook-answer-regions.d.ts.map +0 -1
  86. package/dist/workbook-completion.d.ts.map +0 -1
  87. package/dist/workbook-contracts.d.ts.map +0 -1
  88. package/dist/workbook-layout.d.ts +0 -13
  89. package/dist/workbook-layout.d.ts.map +0 -1
  90. package/dist/workbook-models.d.ts +0 -129
  91. package/dist/workbook-models.d.ts.map +0 -1
  92. package/dist/workbook-service.d.ts +0 -163
  93. 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,7 +57,7 @@ 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) use
60
+ Course bundles (zip with a closed manifest, or a file set; Word/PowerPoint members render through an injected `DocumentConverter`, fail closed) 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.
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
@@ -38,7 +38,8 @@ authorizer: `importBundle` needs `education.course.author`, `readImport`
38
38
  4. `readImport({ ...actor, importId })` returns the recorded evidence.
39
39
 
40
40
  Progress is an observer callback (`onProgress({ stage, completed, total })`,
41
- stages `unpack`, `classify`, `extract`, `propose`; each starts at `completed: 0`).
41
+ stages `unpack`, `classify`, `render`, `extract`, `propose`; each starts at `completed: 0`;
42
+ `render` is emitted only when the bundle has Word/PowerPoint members).
42
43
  Ingestion has no public job-progress seam, so none is invented. A throwing
43
44
  listener never affects the import.
44
45
 
@@ -55,6 +56,9 @@ listener never affects the import.
55
56
  | `maxManifestBytes` | 256 KiB |
56
57
  | `maxLessons` (the proposal schema's cap) | 50 |
57
58
  | `maxLessonBodyChars` | 100 000 |
59
+ | `maxOfficeMembers` (Word/PowerPoint members rendered per bundle) | 50 |
60
+ | `maxRenderedBytes` (PDF accepted back from the converter) | 50 MiB |
61
+ | `conversionTimeoutMs` (one Office member) | 60 000 |
58
62
  | archive nesting | 1: a zip inside a zip is never expanded |
59
63
 
60
64
  A lesson whose combined text members exceed `maxLessonBodyChars` refuses the
@@ -80,7 +84,7 @@ too long or deep). An **honestly** oversize member (declared over
80
84
 
81
85
  ## Members and statuses
82
86
 
83
- By content (never by extension alone): `pdf` (`%PDF-` in the first 1 KiB),
87
+ By content (never by extension alone): `docx`/`pptx` (see below), `pdf` (`%PDF-` in the first 1 KiB),
84
88
  `image` (PNG, JPEG, WebP), `text` (`.txt`/`.md`, strict UTF-8, no control
85
89
  characters, body-sized), `manifest` (root `manifest.json`), else `unsupported`.
86
90
  Status: `accepted` (handed to extraction), `duplicate` (same sha256 as an earlier
@@ -91,6 +95,74 @@ extracted again), `unsupported` (`unsupported_type`, `nested_archive`,
91
95
  `/Encrypt` token anywhere in its bytes (trailer, xref stream): a false positive
92
96
  on literal text inside an uncompressed stream is refused, never read.
93
97
 
98
+ ## Word and PowerPoint members (SMRT #4615)
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
103
+ package itself passes the same bounded zip reader (ratio, overlap, paths, CRC,
104
+ 100 MiB inflated); a failure is `refused / unsafe_office`, never a refusal of the
105
+ whole bundle. Other zips stay `unsupported / nested_archive`.
106
+
107
+ Two separate things happen to an accepted Office member, both after
108
+ authorization and before the evidence is written:
109
+
110
+ - **Pages**: a `DocumentConverter` (`document-converter.ts`) renders the original
111
+ to PDF. The importer enforces the byte and time limits itself (input
112
+ `maxEntryBytes`, output `maxRenderedBytes`, `conversionTimeoutMs` with an abort
113
+ signal and a hard race, so an adapter that ignores the signal still times out),
114
+ and rejects output that is not a PDF or is encrypted. The converter is told a
115
+ neutral file name (`document.docx`), never the bundle path. Identical documents
116
+ are rendered once.
117
+ - **Text**: an injectable `OfficeTextReader` (best effort; a failure or a `null`
118
+ costs the member nothing) returns whole-document text and, for decks, text and
119
+ speaker notes per slide. Office text is handed to the host on the accepted
120
+ member (`officeText`) and is **never** inlined into a proposed lesson body:
121
+ teacher guides and notes carry answers, and audience belongs to manifest v2.
122
+
123
+ `receiveMember` gets the original `bytes` (store them as the private original
124
+ asset: "Open in PowerPoint" for decks) and `rendered` (`{ bytes, sha256, mediaType:
125
+ 'application/pdf', converterId }`, store as a private asset beside the original:
126
+ the pages for "Present"/reading). The member row records `renderedSha256` and
127
+ `renderedSizeBytes`; bytes are still not retained here.
128
+
129
+ **Fail closed.** Any converter problem records that member `unsupported` with a
130
+ reason and writes the rest of the bundle normally: `converter_unavailable` (none
131
+ configured), `converter_failed` (unreachable or 5xx), `converter_timeout`,
132
+ `converter_too_large`, `converter_rejected` (HTTP 4xx, not a PDF, encrypted),
133
+ `office_limit` (beyond `maxOfficeMembers`, in natural path order). A member that
134
+ failed is not a lesson in the proposal, and identical copies of a failed document
135
+ fail the same way rather than becoming duplicates of nothing. Outcomes are part
136
+ of the append-only evidence, so a replay does not retry a recorded failure (to
137
+ retry, change the bundle). A replay does re-render recorded-accepted members so the
138
+ host receives the bytes again; if it can no longer render one it throws
139
+ `DocumentConversionError` rather than silently dropping an accepted member.
140
+
141
+ ### Host configuration
142
+
143
+ ```ts
144
+ const service = new CourseBundleImportService(db, authorizer, {
145
+ converter: new GotenbergDocumentConverter({
146
+ baseUrl: process.env.GOTENBERG_URL, // for example http://gotenberg:3000
147
+ timeoutMs: 60_000,
148
+ maxBytes: 50 * 1024 * 1024,
149
+ }),
150
+ officeTextReader, // optional, see below
151
+ });
152
+ ```
153
+
154
+ `GotenbergDocumentConverter` posts to `<baseUrl>/forms/libreoffice/convert`, talks
155
+ to nothing else, refuses redirects and URLs with credentials, and caps the response
156
+ as it streams. Run Gotenberg as an internal-only service with **no outbound
157
+ network**, its own size/time limits and no shared volume with the app; this
158
+ package assumes untrusted files never run next to the application. Tests use
159
+ `FakeDocumentConverter` (records calls; `failWith`, `failWhen`, `delayMs`, `output`).
160
+
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.
165
+
94
166
  ## Manifest (`manifest.json`, version 1, closed)
95
167
 
96
168
  ```json
@@ -139,6 +211,12 @@ Normal manifest migrations create both tables.
139
211
 
140
212
  ## Not in this release
141
213
 
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).
142
220
  - Ingestion's receipt contract is unchanged: there is no `application/zip` part
143
221
  type or `parentEvidenceId` in ingestion. The education side unpacks and the
144
222
  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`.