@happyvertical/smrt-education 0.57.50 → 0.57.52

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 (57) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +18 -1
  3. package/agents/course-authoring-service.md +39 -0
  4. package/agents/course-bundle-import.md +80 -2
  5. package/agents/course-generation-review.md +53 -0
  6. package/agents/learner-service.md +20 -0
  7. package/agents/tutor-context.md +2 -1
  8. package/dist/chunks/{workbook-photo-service-WwDDC5GF.js → workbook-photo-service-BtMQsbhq.js} +1036 -402
  9. package/dist/chunks/workbook-photo-service-BtMQsbhq.js.map +1 -0
  10. package/dist/course-authoring-contracts.d.ts +23 -1
  11. package/dist/course-authoring-contracts.d.ts.map +1 -1
  12. package/dist/course-authoring-models.d.ts +2 -0
  13. package/dist/course-authoring-models.d.ts.map +1 -1
  14. package/dist/course-authoring-service.d.ts +56 -2
  15. package/dist/course-authoring-service.d.ts.map +1 -1
  16. package/dist/course-bundle-contracts.d.ts +23 -3
  17. package/dist/course-bundle-contracts.d.ts.map +1 -1
  18. package/dist/course-bundle-models.d.ts +3 -0
  19. package/dist/course-bundle-models.d.ts.map +1 -1
  20. package/dist/course-bundle-service.d.ts +45 -1
  21. package/dist/course-bundle-service.d.ts.map +1 -1
  22. package/dist/course-bundle.d.ts +32 -0
  23. package/dist/course-bundle.d.ts.map +1 -1
  24. package/dist/course-generation-contracts.d.ts +91 -0
  25. package/dist/course-generation-contracts.d.ts.map +1 -1
  26. package/dist/course-generation-models.d.ts +29 -0
  27. package/dist/course-generation-models.d.ts.map +1 -1
  28. package/dist/course-generation-output.d.ts +9 -3
  29. package/dist/course-generation-output.d.ts.map +1 -1
  30. package/dist/course-generation-proposal-generator.d.ts +171 -1
  31. package/dist/course-generation-proposal-generator.d.ts.map +1 -1
  32. package/dist/course-generation-review-service.d.ts +15 -1
  33. package/dist/course-generation-review-service.d.ts.map +1 -1
  34. package/dist/document-converter.d.ts +138 -0
  35. package/dist/document-converter.d.ts.map +1 -0
  36. package/dist/index.d.ts +8 -5
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +750 -340
  39. package/dist/index.js.map +1 -1
  40. package/dist/ingestion.js +1 -1
  41. package/dist/learner-history.d.ts +23 -0
  42. package/dist/learner-history.d.ts.map +1 -1
  43. package/dist/learner-interest-theme.d.ts +25 -0
  44. package/dist/learner-interest-theme.d.ts.map +1 -0
  45. package/dist/learner-service.d.ts +31 -2
  46. package/dist/learner-service.d.ts.map +1 -1
  47. package/dist/learners.d.ts +4 -0
  48. package/dist/learners.d.ts.map +1 -1
  49. package/dist/manifest.json +3716 -2783
  50. package/dist/question-authoring-service.d.ts +12 -0
  51. package/dist/question-authoring-service.d.ts.map +1 -1
  52. package/dist/smrt-knowledge.json +380 -17
  53. package/dist/tutor-context-service.d.ts.map +1 -1
  54. package/dist/tutor-contracts.d.ts +8 -0
  55. package/dist/tutor-contracts.d.ts.map +1 -1
  56. package/package.json +8 -8
  57. package/dist/chunks/workbook-photo-service-WwDDC5GF.js.map +0 -1
@@ -3,28 +3,28 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-education",
6
- "packageVersion": "0.57.50",
6
+ "packageVersion": "0.57.52",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "f87390a5fb769a60cddbd363d68870838a8e86305e5554b7e4c97c96be92af5d",
11
- "packageJson": "f475e60d3122ff600ca6f5ec2cf8e7a2867cfa4468a7c409126790806f850975",
12
- "agents": "f95be1f04c4a93378d3397c972a204210b45efa72b3c7fb7d924232eedb0a265",
13
- "moduleDoc:agents/learner-service.md": "c73c54ea646bfdaa86cc7fc37013254aef74569ec582b14615f428009a299cab",
10
+ "manifest": "17ca2c9e8a00e15b0501d8f13e528e455809f7c3c9d55bb6e1c53ac1478bec21",
11
+ "packageJson": "25a063c5585e031763a80004925c7a0a6b90d711e1f3f653d0c4f91fdbf090ae",
12
+ "agents": "a7197fcc3a842cd27908c11d77ef00019be7aedb95ae7c6b0b4cf5958d9e5b0b",
13
+ "moduleDoc:agents/learner-service.md": "d05e2cc8f824505806540aa0b2e2158f9d7ccb218fd36671d21718d58a44908d",
14
14
  "moduleDoc:agents/submission-service.md": "b5c1cb3863e06530c0b575ba5598c8a329c0e907ea42226267faa7317680eaa1",
15
15
  "moduleDoc:agents/workbook-service.md": "ae3a0f208b42766e417eac226e4a24ec15fe17c1ba9cde26a325ded982614278",
16
16
  "moduleDoc:agents/photo-intake.md": "3b7057201ee30b2361aad23ea52e8a55c8ed5354e7a7e4a8193b7de17ca2f0db",
17
- "moduleDoc:agents/course-authoring-service.md": "28c594711b85c148b69c31cf8c307d3db6a3ff40bc041b842cbd69c75ba658f2",
18
- "moduleDoc:agents/course-generation-review.md": "579b960994e96a9c17a935ae18920a323e95dfbf968b25e6628f424e75ebab7f",
17
+ "moduleDoc:agents/course-authoring-service.md": "9a64ed17eb2a571cb811e75dd8727e3ec7e8a8dba6a7e406c6ca8b24b4820062",
18
+ "moduleDoc:agents/course-generation-review.md": "7f6d5e67ed56ec9923bd00df05650b363fb0db30c1b1f004c8b203f9c62cd117",
19
19
  "moduleDoc:agents/question-records.md": "335fe247f7294c6b891885d43d4eaccb20885bfb4a861cf66ae245e8be1fa781",
20
20
  "moduleDoc:agents/question-extraction.md": "75972dda9b2f837471fb42f963ef143a098005c919a1a49db7ce395a5e5e50a7",
21
- "moduleDoc:agents/course-bundle-import.md": "dd463bd79882c47446c71c9f3a1f5c97fa9db00b50a28f4598ee709caa8a3bb4",
21
+ "moduleDoc:agents/course-bundle-import.md": "4ae9ec59d1ad91cbd7019f73cf38dd788cdd28375c8964c3b08b91feddfc5086",
22
22
  "moduleDoc:agents/source-import.md": "30ee823589d655442bcf1abcd86a2132172065b4bc5e3c4d89b1e8b7afcc66bc",
23
23
  "moduleDoc:agents/family-intake.md": "540b6d37f438496acb0208b6ac7258264e2142bce3d2f986967b7741606fe4f7",
24
24
  "moduleDoc:agents/catalog-previews.md": "5f4e0352274009b3ce00408017a16666036d24df695a7078c27317ba4e66f0a7",
25
25
  "moduleDoc:agents/learner-plan.md": "2f2bc5b7e1f55a382c54c12187173790c14716029ad0b8ae7bbb04393bc2a2e5",
26
26
  "moduleDoc:agents/group-sharing.md": "3f3fde1320c183934c5b8186236a384dbe4a1ff2d22b51dbddc99cbcdb4e3eeb",
27
- "moduleDoc:agents/tutor-context.md": "9fcf96a9beb81ccbe203f1cabbcbce44f1b2fca76953b852a69688b5ce589011"
27
+ "moduleDoc:agents/tutor-context.md": "7207a4051eeb77af0a161ae17caf724375d3d5e30d3bf52a4c06793cfc6c5507"
28
28
  },
29
29
  "exports": [
30
30
  ".",
@@ -3221,6 +3221,12 @@
3221
3221
  "required": false,
3222
3222
  "columnType": "TEXT"
3223
3223
  },
3224
+ {
3225
+ "name": "policyBasis",
3226
+ "type": "text",
3227
+ "required": false,
3228
+ "columnType": "TEXT"
3229
+ },
3224
3230
  {
3225
3231
  "name": "requestKey",
3226
3232
  "type": "text",
@@ -5209,6 +5215,19 @@
5209
5215
  "required": false,
5210
5216
  "columnType": "TEXT"
5211
5217
  },
5218
+ {
5219
+ "name": "renderedSha256",
5220
+ "type": "text",
5221
+ "required": false,
5222
+ "columnType": "TEXT"
5223
+ },
5224
+ {
5225
+ "name": "renderedSizeBytes",
5226
+ "type": "integer",
5227
+ "required": true,
5228
+ "columnType": "INTEGER",
5229
+ "default": 0
5230
+ },
5212
5231
  {
5213
5232
  "name": "recordedAt",
5214
5233
  "type": "text",
@@ -6032,6 +6051,194 @@
6032
6051
  "tags": [],
6033
6052
  "risks": []
6034
6053
  },
6054
+ {
6055
+ "name": "CourseGenerationBriefRecord",
6056
+ "qualifiedName": "@happyvertical/smrt-education:CourseGenerationBriefRecord",
6057
+ "collection": "coursegenerationbriefrecords",
6058
+ "description": "The structured brief of one generation job and the theme it resolved to: written once with the job, never rewritten, so the guardian review can show the objective, the theme used and whether it was a…",
6059
+ "tableName": "course_generation_briefs",
6060
+ "packageName": "@happyvertical/smrt-education",
6061
+ "extends": "SmrtObject",
6062
+ "fields": [
6063
+ {
6064
+ "name": "tenantId",
6065
+ "type": "text",
6066
+ "required": true,
6067
+ "columnType": "UUID"
6068
+ },
6069
+ {
6070
+ "name": "jobId",
6071
+ "type": "foreignKey",
6072
+ "required": true,
6073
+ "related": "CourseGenerationJob",
6074
+ "columnType": "UUID"
6075
+ },
6076
+ {
6077
+ "name": "courseId",
6078
+ "type": "foreignKey",
6079
+ "required": true,
6080
+ "related": "Course",
6081
+ "columnType": "UUID"
6082
+ },
6083
+ {
6084
+ "name": "target",
6085
+ "type": "text",
6086
+ "required": false,
6087
+ "columnType": "TEXT"
6088
+ },
6089
+ {
6090
+ "name": "subject",
6091
+ "type": "text",
6092
+ "required": false,
6093
+ "columnType": "TEXT"
6094
+ },
6095
+ {
6096
+ "name": "category",
6097
+ "type": "text",
6098
+ "required": false,
6099
+ "columnType": "TEXT"
6100
+ },
6101
+ {
6102
+ "name": "area",
6103
+ "type": "text",
6104
+ "required": false,
6105
+ "columnType": "TEXT"
6106
+ },
6107
+ {
6108
+ "name": "grade",
6109
+ "type": "integer",
6110
+ "required": true,
6111
+ "columnType": "INTEGER",
6112
+ "default": -1
6113
+ },
6114
+ {
6115
+ "name": "objective",
6116
+ "type": "text",
6117
+ "required": false,
6118
+ "columnType": "TEXT"
6119
+ },
6120
+ {
6121
+ "name": "learningObjectiveId",
6122
+ "type": "text",
6123
+ "required": false,
6124
+ "columnType": "TEXT"
6125
+ },
6126
+ {
6127
+ "name": "learnerId",
6128
+ "type": "text",
6129
+ "required": false,
6130
+ "columnType": "TEXT"
6131
+ },
6132
+ {
6133
+ "name": "themeLabel",
6134
+ "type": "text",
6135
+ "required": false,
6136
+ "columnType": "TEXT"
6137
+ },
6138
+ {
6139
+ "name": "themeKey",
6140
+ "type": "text",
6141
+ "required": false,
6142
+ "columnType": "TEXT"
6143
+ },
6144
+ {
6145
+ "name": "themeSource",
6146
+ "type": "text",
6147
+ "required": false,
6148
+ "columnType": "TEXT"
6149
+ },
6150
+ {
6151
+ "name": "recordedAt",
6152
+ "type": "text",
6153
+ "required": false,
6154
+ "columnType": "TEXT"
6155
+ }
6156
+ ],
6157
+ "relationships": [
6158
+ {
6159
+ "name": "jobId",
6160
+ "type": "foreignKey",
6161
+ "required": true,
6162
+ "related": "CourseGenerationJob",
6163
+ "columnType": "UUID"
6164
+ },
6165
+ {
6166
+ "name": "courseId",
6167
+ "type": "foreignKey",
6168
+ "required": true,
6169
+ "related": "Course",
6170
+ "columnType": "UUID"
6171
+ }
6172
+ ],
6173
+ "methods": [
6174
+ "delete",
6175
+ "save"
6176
+ ],
6177
+ "methodSignatures": [
6178
+ {
6179
+ "name": "delete",
6180
+ "async": true,
6181
+ "returns": "Promise<void>"
6182
+ },
6183
+ {
6184
+ "name": "save",
6185
+ "async": true,
6186
+ "params": [
6187
+ "options?: SmrtSaveOptions"
6188
+ ],
6189
+ "returns": "Promise<this>"
6190
+ }
6191
+ ],
6192
+ "tenant": {
6193
+ "scoped": true,
6194
+ "mode": "required",
6195
+ "field": "tenantId"
6196
+ },
6197
+ "conflictColumns": [
6198
+ "tenant_id",
6199
+ "job_id"
6200
+ ],
6201
+ "surfaces": [],
6202
+ "withheldSurfaces": [
6203
+ {
6204
+ "kind": "api",
6205
+ "operation": "delete",
6206
+ "code": "api-disabled",
6207
+ "reason": "api is disabled",
6208
+ "objectName": "@happyvertical/smrt-education:CourseGenerationBriefRecord"
6209
+ },
6210
+ {
6211
+ "kind": "api",
6212
+ "operation": "save",
6213
+ "code": "api-disabled",
6214
+ "reason": "api is disabled",
6215
+ "objectName": "@happyvertical/smrt-education:CourseGenerationBriefRecord"
6216
+ }
6217
+ ],
6218
+ "relationshipFeatures": [
6219
+ "foreignKey",
6220
+ "uuidColumns"
6221
+ ],
6222
+ "tags": [],
6223
+ "risks": []
6224
+ },
6225
+ {
6226
+ "name": "CourseGenerationBriefRecordCollection",
6227
+ "qualifiedName": "@happyvertical/smrt-education:CourseGenerationBriefRecordCollection",
6228
+ "collection": "coursegenerationbriefrecords",
6229
+ "tableName": "course_generation_briefs",
6230
+ "packageName": "@happyvertical/smrt-education",
6231
+ "extends": "SmrtCollection",
6232
+ "fields": [],
6233
+ "relationships": [],
6234
+ "methods": [],
6235
+ "surfaces": [],
6236
+ "relationshipFeatures": [
6237
+ "uuidColumns"
6238
+ ],
6239
+ "tags": [],
6240
+ "risks": []
6241
+ },
6035
6242
  {
6036
6243
  "name": "CourseReview",
6037
6244
  "qualifiedName": "@happyvertical/smrt-education:CourseReview",
@@ -9119,6 +9326,150 @@
9119
9326
  "tags": [],
9120
9327
  "risks": []
9121
9328
  },
9329
+ {
9330
+ "name": "LearnerInterestThemeChange",
9331
+ "qualifiedName": "@happyvertical/smrt-education:LearnerInterestThemeChange",
9332
+ "collection": "learnerinterestthemechanges",
9333
+ "description": "Append-only audit of one learner interest theme change made through LearnerService.setInterestTheme: the actor, the host policy setting relied on and the previous and new theme (empty label = none).",
9334
+ "tableName": "learner_interest_theme_changes",
9335
+ "packageName": "@happyvertical/smrt-education",
9336
+ "extends": "SmrtObject",
9337
+ "fields": [
9338
+ {
9339
+ "name": "tenantId",
9340
+ "type": "text",
9341
+ "required": true,
9342
+ "columnType": "UUID"
9343
+ },
9344
+ {
9345
+ "name": "learnerId",
9346
+ "type": "foreignKey",
9347
+ "required": true,
9348
+ "related": "Learner",
9349
+ "columnType": "UUID"
9350
+ },
9351
+ {
9352
+ "name": "previousLabel",
9353
+ "type": "text",
9354
+ "required": false,
9355
+ "columnType": "TEXT"
9356
+ },
9357
+ {
9358
+ "name": "previousKey",
9359
+ "type": "text",
9360
+ "required": false,
9361
+ "columnType": "TEXT"
9362
+ },
9363
+ {
9364
+ "name": "newLabel",
9365
+ "type": "text",
9366
+ "required": false,
9367
+ "columnType": "TEXT"
9368
+ },
9369
+ {
9370
+ "name": "newKey",
9371
+ "type": "text",
9372
+ "required": false,
9373
+ "columnType": "TEXT"
9374
+ },
9375
+ {
9376
+ "name": "actorUserId",
9377
+ "type": "text",
9378
+ "required": false,
9379
+ "columnType": "TEXT"
9380
+ },
9381
+ {
9382
+ "name": "policyBasis",
9383
+ "type": "text",
9384
+ "required": false,
9385
+ "columnType": "TEXT"
9386
+ },
9387
+ {
9388
+ "name": "recordedAt",
9389
+ "type": "text",
9390
+ "required": false,
9391
+ "columnType": "TEXT"
9392
+ }
9393
+ ],
9394
+ "relationships": [
9395
+ {
9396
+ "name": "learnerId",
9397
+ "type": "foreignKey",
9398
+ "required": true,
9399
+ "related": "Learner",
9400
+ "columnType": "UUID"
9401
+ }
9402
+ ],
9403
+ "methods": [
9404
+ "delete",
9405
+ "save"
9406
+ ],
9407
+ "methodSignatures": [
9408
+ {
9409
+ "name": "delete",
9410
+ "async": true,
9411
+ "returns": "Promise<void>"
9412
+ },
9413
+ {
9414
+ "name": "save",
9415
+ "async": true,
9416
+ "params": [
9417
+ "options?: SmrtSaveOptions"
9418
+ ],
9419
+ "returns": "Promise<this>"
9420
+ }
9421
+ ],
9422
+ "tenant": {
9423
+ "scoped": true,
9424
+ "mode": "required",
9425
+ "field": "tenantId"
9426
+ },
9427
+ "conflictColumns": [
9428
+ "tenant_id",
9429
+ "slug",
9430
+ "context"
9431
+ ],
9432
+ "surfaces": [],
9433
+ "withheldSurfaces": [
9434
+ {
9435
+ "kind": "api",
9436
+ "operation": "delete",
9437
+ "code": "api-disabled",
9438
+ "reason": "api is disabled",
9439
+ "objectName": "@happyvertical/smrt-education:LearnerInterestThemeChange"
9440
+ },
9441
+ {
9442
+ "kind": "api",
9443
+ "operation": "save",
9444
+ "code": "api-disabled",
9445
+ "reason": "api is disabled",
9446
+ "objectName": "@happyvertical/smrt-education:LearnerInterestThemeChange"
9447
+ }
9448
+ ],
9449
+ "relationshipFeatures": [
9450
+ "foreignKey",
9451
+ "uuidColumns"
9452
+ ],
9453
+ "tags": [],
9454
+ "risks": []
9455
+ },
9456
+ {
9457
+ "name": "LearnerInterestThemeChangeCollection",
9458
+ "qualifiedName": "@happyvertical/smrt-education:LearnerInterestThemeChangeCollection",
9459
+ "collection": "learnerinterestthemechanges",
9460
+ "tableName": "learner_interest_theme_changes",
9461
+ "packageName": "@happyvertical/smrt-education",
9462
+ "extends": "SmrtCollection",
9463
+ "fields": [],
9464
+ "relationships": [],
9465
+ "methods": [],
9466
+ "surfaces": [],
9467
+ "relationshipFeatures": [
9468
+ "uuidColumns"
9469
+ ],
9470
+ "tags": [],
9471
+ "risks": []
9472
+ },
9122
9473
  {
9123
9474
  "name": "Learner",
9124
9475
  "qualifiedName": "@happyvertical/smrt-education:Learner",
@@ -9172,6 +9523,18 @@
9172
9523
  "type": "text",
9173
9524
  "required": false,
9174
9525
  "columnType": "TEXT"
9526
+ },
9527
+ {
9528
+ "name": "interestThemeLabel",
9529
+ "type": "text",
9530
+ "required": false,
9531
+ "columnType": "TEXT"
9532
+ },
9533
+ {
9534
+ "name": "interestThemeKey",
9535
+ "type": "text",
9536
+ "required": false,
9537
+ "columnType": "TEXT"
9175
9538
  }
9176
9539
  ],
9177
9540
  "relationships": [
@@ -14728,19 +15091,19 @@
14728
15091
  "surfaces": [],
14729
15092
  "prompts": [],
14730
15093
  "relationshipsV2": {
14731
- "foreignKeyFields": 181,
15094
+ "foreignKeyFields": 184,
14732
15095
  "crossPackageRefFields": 22,
14733
15096
  "junctionCollections": 2,
14734
15097
  "hierarchicalObjects": 0,
14735
15098
  "polymorphicAssociations": 0,
14736
- "uuidColumns": 760
15099
+ "uuidColumns": 774
14737
15100
  },
14738
- "agentDoc": "# smrt-education\n\nCatalog objects are optional-tenant scoped and have no generated REST, CLI, MCP,\nor AI surface. Applications use `CatalogService`, supplying their actor-bound\nauthorization decision and the trusted `TenantCollection.withTenantCatalogReadScope`\nadapter from smrt-users. Catalog authorization receives the exact shared collection\nexecutor; live session/permission checks must use that executor. That read scope may widen only an allowlisted catalog\ncollection's `list` to self, ancestors, and globals; it never authorizes a get,\nwrite, raw query, sibling, or descendant read.\n\n`personal-use-copy` courses and all `answer-key` lesson assets are private\nfamily material. Do not publish them, promote them, or expose them through a\nlearner-facing result. `LessonAsset` is the sole owner join for lesson files;\ndo not add asset-id fields to lesson subclasses.\n\nLearner objects require exact tenant ownership and close generated surfaces.\n`LearnerService` owns actor/resource authorization and transactional planning and\nstart-progress; see [learner contract](agents/learner-service.md). Guardian\nrelationships require a host decision about their type and effective terms.\nNever expose direct collection writes as learner handlers, reset progress on a\nretry, or treat an unscored nullable decimal as zero.\n\nSubmission capture, guardian grading and private PDF packet splitting use\n`SubmissionService`; see [submission contract](agents/submission-service.md).\nKeep asset metadata on the same transaction and compensate only newly planned\nfiles after rollback; never expose source URIs or accept learner grade patches.\n\nValidation: `pnpm --filter @happyvertical/smrt-education test`,\n`test:postgres`, `typecheck`, and `build`.\n\nPhysical workbook capture, immutable assessment/feedback history and descriptive\nevidence use [WorkbookService](agents/workbook-service.md). Keep provisional\nsharing separate from guardian Grade authority. Photo-first receipt/context\ncapture uses [WorkbookPhotoService](agents/photo-intake.md); covers/references\nnever create learning evidence, and blank exercises need explicit work intent.\n\nRevisioned authored courses, release, archive, enrollment pins and licence use\n[CourseAuthoringService](agents/course-authoring-service.md). Keep history\nappend-only, require request keys, and never weaken personal-use-copy rules.\n\nGeneration proposals into new draft revisions and revision-bound educational\nreview use [CourseGenerationReviewService](agents/course-generation-review.md).\nKeep supplied content unless a decision is recorded; reviews carry no score.\n\nImmutable questions, answer keys/rubrics, stable learning objectives, worksheet\nrender ids and exact-revision references use [QuestionAuthoringService](agents/question-records.md).\nKeep keys out of learner bundles; a missing key stays null. `attachGeneratedPrintable`\nstores the rendered PDFs; the guardian copy is always an `answer-key`, never `printable`.\n\nStructured questions from scans and imported course files use\n[QuestionExtractionService](agents/question-extraction.md) and the `extract-questions`\ningestion handler. Anchors come from provider region catalogs only; match by worksheet\nid and layout, never text; ambiguous matches stay unattached; source-keyed replays.\n\nCourse bundles (zip with a closed manifest, or a file set) use\n[CourseBundleImportService](agents/course-bundle-import.md). Parse only with the\nbounded reader; refuse, never trim, hostile input; unsupported members stay\nvisible; proposals only, nothing auto-published.\n\nSource import: [host boundary and transaction contract](agents/source-import.md).\n`SourceImportService` requires host opt-in, matching active actor/tenant and\nexplicit licensing authorization; never add a system/global write bypass.\n\nFamily-owned unassigned originals and audited learner adoption use\n[FamilyIntakeService](agents/family-intake.md). Keep family source scope immutable;\nassignment alone never creates learning history or grants learner inbox access.\n\nPublisher catalog previews use [the preview contract](agents/catalog-previews.md).\nKeep independent image rights and source revisions; serve via `CatalogPreviewService`\nwith live actor/tenant authorization, never direct provider image URLs.\n\nEnrollment Plan projections and guardian-only parent notes use\n[the Plan contract](agents/learner-plan.md). Keep awaiting review separate from\nContinue; note authority/provenance never grants learner access or learning evidence.\n\nHomeschool group sharing uses [GroupSharingService](agents/group-sharing.md),\nits read-only reader and revision comment service. Group tenants never become\nfamily ancestors or guardians. Enumerate only the group-owned opaque roster index,\nthen revalidate each exact family edge and current policy. Media delivery must call\nthe reader on every request; signed storage URLs are not a sharing grant. Saved\nparent comment visibility applies on every child read. Progress with feedback\ndisabled contains counts only, never correctness, scores or concept labels.\n\nLearner-safe tutor context and append-only tutor transcripts use\n[TutorContextService and TutorSessionService](agents/tutor-context.md). Learner\naudience never carries keys, rubrics, notes or storage URIs; no AI call here.\n",
15101
+ "agentDoc": "# smrt-education\n\nCatalog objects are optional-tenant scoped and have no generated REST, CLI, MCP,\nor AI surface. Applications use `CatalogService`, supplying their actor-bound\nauthorization decision and the trusted `TenantCollection.withTenantCatalogReadScope`\nadapter from smrt-users. Catalog authorization receives the exact shared collection\nexecutor; live session/permission checks must use that executor. That read scope may widen only an allowlisted catalog\ncollection's `list` to self, ancestors, and globals; it never authorizes a get,\nwrite, raw query, sibling, or descendant read.\n\n`personal-use-copy` courses and all `answer-key` lesson assets are private\nfamily material. Do not publish them, promote them, or expose them through a\nlearner-facing result. `LessonAsset` is the sole owner join for lesson files;\ndo not add asset-id fields to lesson subclasses.\n\nLearner objects require exact tenant ownership and close generated surfaces.\n`LearnerService` owns actor/resource authorization and transactional planning and\nstart-progress; see [learner contract](agents/learner-service.md). Guardian\nrelationships require a host decision about their type and effective terms.\nNever expose direct collection writes as learner handlers, reset progress on a\nretry, or treat an unscored nullable decimal as zero.\n\nSubmission capture, guardian grading and private PDF packet splitting use\n`SubmissionService`; see [submission contract](agents/submission-service.md).\nKeep asset metadata on the same transaction and compensate only newly planned\nfiles after rollback; never expose source URIs or accept learner grade patches.\n\nValidation: `pnpm --filter @happyvertical/smrt-education test`,\n`test:postgres`, `typecheck`, and `build`.\n\nPhysical workbook capture, immutable assessment/feedback history and descriptive\nevidence use [WorkbookService](agents/workbook-service.md). Keep provisional\nsharing separate from guardian Grade authority. Photo-first receipt/context\ncapture uses [WorkbookPhotoService](agents/photo-intake.md); covers/references\nnever create learning evidence, and blank exercises need explicit work intent.\n\nRevisioned authored courses, release, archive, enrollment pins and licence use\n[CourseAuthoringService](agents/course-authoring-service.md). Keep history\nappend-only, require request keys, and never weaken personal-use-copy rules.\n\nGeneration proposals into new draft revisions and revision-bound educational\nreview use [CourseGenerationReviewService](agents/course-generation-review.md).\nKeep supplied content unless a decision is recorded; reviews carry no score.\n\nImmutable questions, answer keys/rubrics, stable learning objectives, worksheet\nrender ids and exact-revision references use [QuestionAuthoringService](agents/question-records.md).\nKeep keys out of learner bundles; a missing key stays null. `attachGeneratedPrintable`\nstores the rendered PDFs; the guardian copy is always an `answer-key`, never `printable`.\n\nStructured questions from scans and imported course files use\n[QuestionExtractionService](agents/question-extraction.md) and the `extract-questions`\ningestion handler. Anchors come from provider region catalogs only; match by worksheet\nid and layout, never text; ambiguous matches stay unattached; source-keyed replays.\n\nCourse bundles (zip with a closed manifest, or a file set; Word/PowerPoint members render through an injected `DocumentConverter`, fail closed) use\n[CourseBundleImportService](agents/course-bundle-import.md). Parse only with the\nbounded reader; refuse, never trim, hostile input; unsupported members stay\nvisible; proposals only, nothing auto-published.\n\nSource import: [host boundary and transaction contract](agents/source-import.md).\n`SourceImportService` requires host opt-in, matching active actor/tenant and\nexplicit licensing authorization; never add a system/global write bypass.\n\nFamily-owned unassigned originals and audited learner adoption use\n[FamilyIntakeService](agents/family-intake.md). Keep family source scope immutable;\nassignment alone never creates learning history or grants learner inbox access.\n\nPublisher catalog previews use [the preview contract](agents/catalog-previews.md).\nKeep independent image rights and source revisions; serve via `CatalogPreviewService`\nwith live actor/tenant authorization, never direct provider image URLs.\n\nEnrollment Plan projections and guardian-only parent notes use\n[the Plan contract](agents/learner-plan.md). Keep awaiting review separate from\nContinue; note authority/provenance never grants learner access or learning evidence.\n\nHomeschool group sharing uses [GroupSharingService](agents/group-sharing.md),\nits read-only reader and revision comment service. Group tenants never become\nfamily ancestors or guardians. Enumerate only the group-owned opaque roster index,\nthen revalidate each exact family edge and current policy. Media delivery must call\nthe reader on every request; signed storage URLs are not a sharing grant. Saved\nparent comment visibility applies on every child read. Progress with feedback\ndisabled contains counts only, never correctness, scores or concept labels.\n\nLearner-safe tutor context and append-only tutor transcripts use\n[TutorContextService and TutorSessionService](agents/tutor-context.md). Learner\naudience never carries keys, rubrics, notes or storage URIs; no AI call here.\n",
14739
15102
  "moduleDocs": [
14740
15103
  {
14741
15104
  "path": "agents/learner-service.md",
14742
15105
  "module": "learner-service",
14743
- "content": "# Learner service boundary\n\n`Learner`, `Curriculum`, `Enrollment`, and `LessonProgress` require an exact\nowning tenant. Their generated API, CLI, MCP, and AI operations are closed.\nThe corresponding collections and model saves are trusted server persistence\nAPIs, not request handlers. Hosts must not wrap them in generic learner CRUD.\n\n## Identity and authority\n\nProvision a Person Profile, a linked User, and an active Membership before\ncreating a Learner with explicit `tenantId`, `userId`, and `profileId`. User\nemail is optional. Learner identity cannot be reassigned after creation.\nGuardianship is a directed `ProfileRelationship`, not a Learner `parentId`.\nThe shared Tenant has no family discriminator: “family” means a tenant that\nthe host has provisioned and authorized for this purpose.\n\nConstruct `LearnerService(db, authorizeLearner, authorizeCatalog)` with one\nroot database executor and trusted server-side authorizers. Enable tenancy\nand establish `withTenant({ tenantId, userId }, callback)` from authenticated\ncredentials. Every request must match that active actor and tenant exactly;\nmissing context, spoofed identifiers, and super-admin bypass contexts fail.\nBoth the actor and learner must hold active memberships in that exact tenant.\nA learner's User must still link to the learner's Person Profile.\n\nFor a different actor, supply `guardianRelationshipId`. The service checks\nthat this relationship is owned by the active tenant and directed from the\nactor's User Profile to the learner's Profile. `authorizeLearner` receives\n`own`, `action`, and the verified `relationshipTypeId`; the host must check\nthat the type represents guardianship, its terms are effective, and that\nits permission policy allows the action. An arbitrary relationship is not a\nguardian grant. Missing relationships and host denials/exceptions fail closed.\nThe authorizer also receives `executor`, the service's active database\nexecutor. Host policy reads, including relationship and revocation checks, must\nuse that executor so they share a `withTransaction` or `withExecutor` boundary.\nSame-tenant membership alone never grants sibling access.\n\n## Supported workflow\n\n- `getLearnerSetupStatus({ tenantId, actorUserId, learnerUserId })` is a\n host-authorized onboarding probe for an active tenant member. It returns only\n `{ needsSetup }`; it neither returns learner or relationship data nor grants\n relationships. The host authorizer receives `education.learner.setup`, the\n target user id, and the active executor. `provisionLearner` remains the write\n path and still requires a directed guardian relationship.\n- `getLearner` returns authorized learner metadata and parsed settings.\n- `createCurriculum` creates a **planned** curriculum for an authorized guardian.\n A learner cannot plan their own curriculum. Title is required.\n- `enroll` requires that guardian authority and a matching tenant curriculum.\n It returns the existing enrollment for the same curriculum/course unchanged.\n A delegated agent may enroll, only when the host opts in: construct\n `LearnerService(db, authorize, authorizeCatalog, { verifyDelegation })` where\n `verifyDelegation(executor, { tenantId, actorUserId, delegatingGuardianUserId,\n delegationId, learnerId, courseId? }) => boolean | { allowed, reason?, code? }` runs on the\n current executor. Without the port, or if it denies or throws, `enroll` refuses with\n `LearnerAuthorizationError` before the learner authorizer and before any write. Supply\n `delegatingGuardianUserId` and `delegationId` (both or neither, 1-200 printable characters,\n guardian not the agent). After the port, the service re-checks the DELEGATING guardian's\n active membership and `guardianRelationshipId` (that guardian's profile to the learner, same\n tenant) and the agent's active membership. The authorizer gets `education.learner.plan`,\n `own: false`, `delegated: true`, `relationshipTypeId` undefined (so a legacy\n `relationshipTypeId === guardian` rule denies), the guardian's type as\n `delegatingRelationshipTypeId`, `delegatingGuardianUserId` and `delegationId`, and decides.\n A new delegated enrollment appends one `EnrollmentAssignmentRecord` (`Enrollment` is\n unchanged). Only `enroll` and `createCurriculum` accept a delegation (on `LearnerService` itself; subclasses without\n the port refuse); every other operation ignores it, so the agent has no\n other learner authority. Catalog reads still use the agent as actor.\n `createCurriculum` accepts the same delegated actor (`operation: 'create-curriculum'`\n on the verifier request, `enroll` sends `'enroll'`), with identical verification, guardian\n relationship recheck, authorizer shape and stripping of caller authority keys; each new\n delegated curriculum appends one `CurriculumCreationRecord` (own table, `Curriculum` unchanged).\n- `startLesson` allows the learner or authorized guardian to create their own\n enrollment's lesson progress in `in_progress`. It derives learner identity\n from the validated enrollment. Repeated starts return existing state without\n resetting counters, scores, completion, or timestamps. A `not_started` row\n (a put-back lesson) moves to `in_progress` again with its counters, score and\n first `startedAt` kept.\n- `putBackLesson({ enrollmentId, lessonId })` takes `in_progress` work back to\n `not_started` for the learner or an authorized guardian under\n `education.progress.put-back` (the host decides; own is not blocked). The\n progress row keeps its one (enrollment, lesson) identity, so each change\n appends an immutable `LessonProgressTransition` (from, to, kind, actor,\n time) in the same transaction; restarting appends a `start` row when the\n table exists. Counters, score, submissions, grades, workbook and evidence\n rows are never touched. It returns `{ changed, progress }`: no progress or\n already `not_started` is an idempotent no-op. `submitted`, `needs_review`,\n `complete`, `skipped` (stored or workbook-projected) refuse with\n `LessonPutBackError` (`.status`); completion is undone only through guardian\n paths. A host without `lesson_progress_transitions` gets\n `LearnerHistoryUnavailableError` from put-back; other operations still run.\n- `setEnrollmentStatus({ enrollmentId, status, policyBasis })` moves an\n enrollment along `ENROLLMENT_STATUS_TRANSITIONS`: planned→active,\n active→paused|dropped, paused→active|dropped. `completed` is reached only by\n the completion path; completed and dropped are final. The authorizer gets\n `education.enrollment.set-status` and decides whether a learner may act on\n their own enrollment. `policyBasis` (1-200 characters, no control characters,\n `requirePolicyBasis`) names the host setting relied on; it and the actor are\n appended to an immutable `EnrollmentStatusChange` with the change, as\n `pinEnrollment` records its basis. The first activation sets `startedAt`.\n The current status is an idempotent `{ changed: false }`; any other move\n throws `EnrollmentStatusTransitionError` (`.from`, `.to`). Progress,\n submissions and history never cascade. Bad input throws\n `LearnerValidationError`; a host without `enrollment_status_changes` gets\n `LearnerHistoryUnavailableError`.\n- `completeOwnLesson({ enrollmentId, lessonId, policyBasis, evidence?: { submissionId } })`\n moves progress to `complete` (any lesson kind, `completedAt` set, a missing\n row created) under `education.progress.complete-own`; the host decides per\n setting and may allow a guardian too (own is not blocked). It appends an\n immutable `LessonCompletionRecord` (actor, `policyBasis`, prior status,\n optional evidence submission, at most one per progress) and a `complete`\n `LessonProgressTransition` when that optional table exists. `evidence`\n must be a non-rejected submission of the same tenant, learner, lesson and\n progress, else `LearnerAuthorizationError`. Counters, score and start time\n are untouched; no score is invented. Returns `{ changed, progress }`.\n Repeat rule: a lesson already completed through this path (a record exists)\n returns `{ changed: false }`; one complete any other way (guardian\n `attestLesson`/grade, workbook completion) throws `LessonAlreadyCompleteError`.\n Also `EnrollmentNotActiveError` (`.status`; only `active` enrollments),\n `LessonSkippedError`, `WorkbookOwnedLessonError` (a workbook lesson completes\n through its workbook decision), and `LearnerHistoryUnavailableError` without\n `lesson_completion_records`. Guardian `attestLesson` is unchanged.\n- `setEnrollmentPace({ enrollmentId, pace: { lessonsPerWeek } | null, policyBasis })`\n sets or clears the enrollment's planned lessons per week under\n `education.enrollment.set-pace` (the host decides whether a learner may act\n on their own enrollment). `lessonsPerWeek` is a whole number from 1 to 14\n (`MIN_/MAX_LESSONS_PER_WEEK`), else `EnrollmentPaceError`. It is stored as the\n integer column `enrollments.pace_lessons_per_week` (`0` = no pace, like\n `targetMinutesPerWeek`) and surfaces as `EnrollmentView.pace` (`null` when\n unset). Each change appends an immutable `EnrollmentPaceChange` (actor,\n `policyBasis`, previous and new value, `0` = none); an unchanged pace is\n `{ changed: false }` with no row. Status and progress are untouched. A host\n without `enrollment_pace_changes` gets `LearnerHistoryUnavailableError`.\n Apply the additive migration (new column plus table) before deploying writes;\n reads of an un-migrated table report no pace.\n- `nextLessons({ enrollmentId?, count? })`: `count` is an integer from 1 to\n 14 (`MAX_NEXT_LESSONS_COUNT`, default 1, else `LearnerValidationError`) and\n returns up to that many unfinished (not complete or skipped) lessons per\n enrollment in lesson order, as the same flat entries; the default is\n unchanged.\n- `listProgress` reads only the validated learner/enrollment and lessons still\n visible in that enrollment's course. Inconsistent persisted links fail\n closed or are omitted from the list.\n- An enrollment with an `EnrollmentReleasePin` uses its effective lesson set\n instead of `enrollment.courseId` alone (`resolveEnrollmentLessons`, shared\n with `CourseAuthoringService.getEnrollmentRelease`, on the current executor):\n lessons started (non-`not_started` status or attempts) stay on their original\n immutable rows in any release the enrollment followed; the rest follow the\n current pin, in that release's order. `nextLessons`, `assignmentLessons`,\n `listProgress`, plan reads, `startLesson` and submission/workbook lesson\n assertions all use it, so a lesson outside the set is rejected and progress\n rows never move. Unpinned enrollments (or hosts without the authoring\n tables) read their own course as before. Pins recorded for another learner\n fail closed. `LessonProgress` accepts a lesson on any release course the\n enrollment is pinned to; no data migration.\n\nService results are data-only views, with no persistence or database methods.\nExtra request fields are never model patches: a learner cannot set score,\nattempt count, or completion status through this API. Curriculum activation,\nenrollment completion and dashboard aggregation are not supported by this\nbase service. SubmissionService extends its owning transaction/auth boundary for\ncapture and guardian grading; see [submission contract](submission-service.md).\nDirect model writes require trusted authority.\n\nCatalog visibility and child ownership reuse `CatalogService` on the same\nexecutor. Learner operations request private-inclusive catalog access, so the\nhost catalog authorizer must permit both `education.catalog.read` and\n`education.catalog.read-private` for the supported enrollment workflow.\nPrivate copies remain restricted to the exact owner. The service constructs a\nwhitelisted catalog request; caller options and answer-key flags are discarded.\nAnswer keys are never requested or returned. A catalog grant does not grant learner authorization.\n\n## Transactions and history\n\n`withTransaction(async transaction => ...)` composes service operations on the\ncallback's transaction-bound service. Use that service for every operation in\nthe unit. Throwing rolls back its curriculum, enrollment, and progress writes.\nThe callback's service closes when the transaction ends and rejects later calls.\nA failed mutation poisons the unit even if the callback catches its rejection;\nqueued mutations are cancelled. Callback failure stops pending work and waits for\nalready-running mutations to settle before rollback releases the executor. On\nsuccess, already-enqueued mutations finish before commit. Do not retain the\ncallback's service beyond the callback. The constructor\naccepts a root database; callers composing work use this method instead of\npassing an unmanaged transaction handle.\n\nPostgreSQL locks the parent curriculum/enrollment row before find-or-create.\nEmbedded writes use the core transaction queue. Concurrent mutations inside\none callback transaction are ordered on that connection, so duplicate starts\nwithin a `Promise.all` also return the same row. Database natural-key constraints\narbitrate enrollment `(tenant_id, curriculum_id, course_id)` and progress\n`(tenant_id, enrollment_id, lesson_id)` identities. Explicit storage conflict\nkeys include the tenant prefix because core does not add it to explicit keys.\nThe logical keys required by #3968 remain curriculum/course and enrollment/lesson:\nimmutable parent ownership and parent-tenant checks reject any second tenant\nfor the same parent. The storage prefix cannot create a second valid logical row. Ordinary repeated and concurrent calls\nreturn one row; an aborted workflow can be retried. No in-memory process lock\nis used for PostgreSQL concurrency.\n\nA new year is a new curriculum and enrollment, producing independent progress.\nUnscored `bestScore` persists as null; score zero is a real score. Scores are\nnullable decimals in [0,1]; counters are nonnegative safe integers. Settings\nare stored as JSON text and accessed through guarded `getSettings`/`setSettings`.\n\nThe shared executable workflows in `src/__tests__/learner-workflows.ts` run\nagainst SQLite and PostgreSQL, including actor/resource/context denial,\nconcurrent creation, rollback, history preservation, and zero/null scores.\nFull DuckDB schemas remain blocked by existing upstream DDL restrictions;\nno full learner workflow support on DuckDB is claimed.\n"
15106
+ "content": "# Learner service boundary\n\n`Learner`, `Curriculum`, `Enrollment`, and `LessonProgress` require an exact\nowning tenant. Their generated API, CLI, MCP, and AI operations are closed.\nThe corresponding collections and model saves are trusted server persistence\nAPIs, not request handlers. Hosts must not wrap them in generic learner CRUD.\n\n## Identity and authority\n\nProvision a Person Profile, a linked User, and an active Membership before\ncreating a Learner with explicit `tenantId`, `userId`, and `profileId`. User\nemail is optional. Learner identity cannot be reassigned after creation.\nGuardianship is a directed `ProfileRelationship`, not a Learner `parentId`.\nThe shared Tenant has no family discriminator: “family” means a tenant that\nthe host has provisioned and authorized for this purpose.\n\nConstruct `LearnerService(db, authorizeLearner, authorizeCatalog)` with one\nroot database executor and trusted server-side authorizers. Enable tenancy\nand establish `withTenant({ tenantId, userId }, callback)` from authenticated\ncredentials. Every request must match that active actor and tenant exactly;\nmissing context, spoofed identifiers, and super-admin bypass contexts fail.\nBoth the actor and learner must hold active memberships in that exact tenant.\nA learner's User must still link to the learner's Person Profile.\n\nFor a different actor, supply `guardianRelationshipId`. The service checks\nthat this relationship is owned by the active tenant and directed from the\nactor's User Profile to the learner's Profile. `authorizeLearner` receives\n`own`, `action`, and the verified `relationshipTypeId`; the host must check\nthat the type represents guardianship, its terms are effective, and that\nits permission policy allows the action. An arbitrary relationship is not a\nguardian grant. Missing relationships and host denials/exceptions fail closed.\nThe authorizer also receives `executor`, the service's active database\nexecutor. Host policy reads, including relationship and revocation checks, must\nuse that executor so they share a `withTransaction` or `withExecutor` boundary.\nSame-tenant membership alone never grants sibling access.\n\n## Supported workflow\n\n- `getLearnerSetupStatus({ tenantId, actorUserId, learnerUserId })` is a\n host-authorized onboarding probe for an active tenant member. It returns only\n `{ needsSetup }`; it neither returns learner or relationship data nor grants\n relationships. The host authorizer receives `education.learner.setup`, the\n target user id, and the active executor. `provisionLearner` remains the write\n path and still requires a directed guardian relationship.\n- `getLearner` returns authorized learner metadata and parsed settings.\n- `createCurriculum` creates a **planned** curriculum for an authorized guardian.\n A learner cannot plan their own curriculum. Title is required.\n- `enroll` requires that guardian authority and a matching tenant curriculum.\n It returns the existing enrollment for the same curriculum/course unchanged.\n A delegated agent may enroll, only when the host opts in: construct\n `LearnerService(db, authorize, authorizeCatalog, { verifyDelegation })` where\n `verifyDelegation(executor, { tenantId, actorUserId, delegatingGuardianUserId,\n delegationId, learnerId, courseId? }) => boolean | { allowed, reason?, code? }` runs on the\n current executor. Without the port, or if it denies or throws, `enroll` refuses with\n `LearnerAuthorizationError` before the learner authorizer and before any write. Supply\n `delegatingGuardianUserId` and `delegationId` (both or neither, 1-200 printable characters,\n guardian not the agent). After the port, the service re-checks the DELEGATING guardian's\n active membership and `guardianRelationshipId` (that guardian's profile to the learner, same\n tenant) and the agent's active membership. The authorizer gets `education.learner.plan`,\n `own: false`, `delegated: true`, `relationshipTypeId` undefined (so a legacy\n `relationshipTypeId === guardian` rule denies), the guardian's type as\n `delegatingRelationshipTypeId`, `delegatingGuardianUserId` and `delegationId`, and decides.\n A new delegated enrollment appends one `EnrollmentAssignmentRecord` (`Enrollment` is\n unchanged). Only `enroll` and `createCurriculum` accept a delegation (on `LearnerService` itself; subclasses without\n the port refuse); every other operation ignores it, so the agent has no\n other learner authority. Catalog reads still use the agent as actor.\n `createCurriculum` accepts the same delegated actor (`operation: 'create-curriculum'`\n on the verifier request, `enroll` sends `'enroll'`), with identical verification, guardian\n relationship recheck, authorizer shape and stripping of caller authority keys; each new\n delegated curriculum appends one `CurriculumCreationRecord` (own table, `Curriculum` unchanged).\n- `startLesson` allows the learner or authorized guardian to create their own\n enrollment's lesson progress in `in_progress`. It derives learner identity\n from the validated enrollment. Repeated starts return existing state without\n resetting counters, scores, completion, or timestamps. A `not_started` row\n (a put-back lesson) moves to `in_progress` again with its counters, score and\n first `startedAt` kept.\n- `putBackLesson({ enrollmentId, lessonId })` takes `in_progress` work back to\n `not_started` for the learner or an authorized guardian under\n `education.progress.put-back` (the host decides; own is not blocked). The\n progress row keeps its one (enrollment, lesson) identity, so each change\n appends an immutable `LessonProgressTransition` (from, to, kind, actor,\n time) in the same transaction; restarting appends a `start` row when the\n table exists. Counters, score, submissions, grades, workbook and evidence\n rows are never touched. It returns `{ changed, progress }`: no progress or\n already `not_started` is an idempotent no-op. `submitted`, `needs_review`,\n `complete`, `skipped` (stored or workbook-projected) refuse with\n `LessonPutBackError` (`.status`); completion is undone only through guardian\n paths. A host without `lesson_progress_transitions` gets\n `LearnerHistoryUnavailableError` from put-back; other operations still run.\n- `setEnrollmentStatus({ enrollmentId, status, policyBasis })` moves an\n enrollment along `ENROLLMENT_STATUS_TRANSITIONS`: planned→active,\n active→paused|dropped, paused→active|dropped. `completed` is reached only by\n the completion path; completed and dropped are final. The authorizer gets\n `education.enrollment.set-status` and decides whether a learner may act on\n their own enrollment. `policyBasis` (1-200 characters, no control characters,\n `requirePolicyBasis`) names the host setting relied on; it and the actor are\n appended to an immutable `EnrollmentStatusChange` with the change, as\n `pinEnrollment` records its basis. The first activation sets `startedAt`.\n The current status is an idempotent `{ changed: false }`; any other move\n throws `EnrollmentStatusTransitionError` (`.from`, `.to`). Progress,\n submissions and history never cascade. Bad input throws\n `LearnerValidationError`; a host without `enrollment_status_changes` gets\n `LearnerHistoryUnavailableError`.\n- `completeOwnLesson({ enrollmentId, lessonId, policyBasis, evidence?: { submissionId } })`\n moves progress to `complete` (any lesson kind, `completedAt` set, a missing\n row created) under `education.progress.complete-own`; the host decides per\n setting and may allow a guardian too (own is not blocked). It appends an\n immutable `LessonCompletionRecord` (actor, `policyBasis`, prior status,\n optional evidence submission, at most one per progress) and a `complete`\n `LessonProgressTransition` when that optional table exists. `evidence`\n must be a non-rejected submission of the same tenant, learner, lesson and\n progress, else `LearnerAuthorizationError`. Counters, score and start time\n are untouched; no score is invented. Returns `{ changed, progress }`.\n Repeat rule: a lesson already completed through this path (a record exists)\n returns `{ changed: false }`; one complete any other way (guardian\n `attestLesson`/grade, workbook completion) throws `LessonAlreadyCompleteError`.\n Also `EnrollmentNotActiveError` (`.status`; only `active` enrollments),\n `LessonSkippedError`, `WorkbookOwnedLessonError` (a workbook lesson completes\n through its workbook decision), and `LearnerHistoryUnavailableError` without\n `lesson_completion_records`. Guardian `attestLesson` is unchanged.\n- `setEnrollmentPace({ enrollmentId, pace: { lessonsPerWeek } | null, policyBasis })`\n sets or clears the enrollment's planned lessons per week under\n `education.enrollment.set-pace` (the host decides whether a learner may act\n on their own enrollment). `lessonsPerWeek` is a whole number from 1 to 14\n (`MIN_/MAX_LESSONS_PER_WEEK`), else `EnrollmentPaceError`. It is stored as the\n integer column `enrollments.pace_lessons_per_week` (`0` = no pace, like\n `targetMinutesPerWeek`) and surfaces as `EnrollmentView.pace` (`null` when\n unset). Each change appends an immutable `EnrollmentPaceChange` (actor,\n `policyBasis`, previous and new value, `0` = none); an unchanged pace is\n `{ changed: false }` with no row. Status and progress are untouched. A host\n without `enrollment_pace_changes` gets `LearnerHistoryUnavailableError`.\n Apply the additive migration (new column plus table) before deploying writes;\n reads of an un-migrated table report no pace.\n- `setInterestTheme({ theme: { label, key? } | null, policyBasis })` stores the\n learner's interest theme under `education.learner.set-interest-theme`. The\n label is a short plain string (1-60 characters after whitespace is collapsed;\n control characters, `@`, `://` and `www.` are refused so it cannot carry\n contact details) and the optional key is lowercase `[a-z0-9-]` up to 40\n characters. The package ships no starter list: the host owns its keys\n (for Hearth `minecraft`, `roman`, `hockey`, `baseball`, `football`, `soccer`,\n `cars`) and family-added themes are free labels without a key. A guardian with\n an effective relationship may set any of their learners' themes; the learner\n may set their own when the host authorizer answers `own: true` as allowed;\n another learner's theme needs the actor's own guardian relationship, so a\n learner, sibling or stranger is refused by the same actor/tenant checks as\n every other operation. It is a skin for stories and examples only; it never\n changes an objective. Columns `learners.interest_theme_label` and\n `interest_theme_key` (additive; empty = none) hold the value and each change\n appends a `LearnerInterestThemeChange` (actor, `policyBasis`, previous and new\n value). An unchanged value is `{ changed: false }`; a bad value is\n `InterestThemeError`; a host without `learner_interest_theme_changes` gets\n `LearnerHistoryUnavailableError`. `getLearner` returns `interestTheme`\n (`{ label, key } | null`). Apply the additive migration before deploying.\n- `nextLessons({ enrollmentId?, count? })`: `count` is an integer from 1 to\n 14 (`MAX_NEXT_LESSONS_COUNT`, default 1, else `LearnerValidationError`) and\n returns up to that many unfinished (not complete or skipped) lessons per\n enrollment in lesson order, as the same flat entries; the default is\n unchanged.\n- `listProgress` reads only the validated learner/enrollment and lessons still\n visible in that enrollment's course. Inconsistent persisted links fail\n closed or are omitted from the list.\n- An enrollment with an `EnrollmentReleasePin` uses its effective lesson set\n instead of `enrollment.courseId` alone (`resolveEnrollmentLessons`, shared\n with `CourseAuthoringService.getEnrollmentRelease`, on the current executor):\n lessons started (non-`not_started` status or attempts) stay on their original\n immutable rows in any release the enrollment followed; the rest follow the\n current pin, in that release's order. `nextLessons`, `assignmentLessons`,\n `listProgress`, plan reads, `startLesson` and submission/workbook lesson\n assertions all use it, so a lesson outside the set is rejected and progress\n rows never move. Unpinned enrollments (or hosts without the authoring\n tables) read their own course as before. Pins recorded for another learner\n fail closed. `LessonProgress` accepts a lesson on any release course the\n enrollment is pinned to; no data migration.\n\nService results are data-only views, with no persistence or database methods.\nExtra request fields are never model patches: a learner cannot set score,\nattempt count, or completion status through this API. Curriculum activation,\nenrollment completion and dashboard aggregation are not supported by this\nbase service. SubmissionService extends its owning transaction/auth boundary for\ncapture and guardian grading; see [submission contract](submission-service.md).\nDirect model writes require trusted authority.\n\nCatalog visibility and child ownership reuse `CatalogService` on the same\nexecutor. Learner operations request private-inclusive catalog access, so the\nhost catalog authorizer must permit both `education.catalog.read` and\n`education.catalog.read-private` for the supported enrollment workflow.\nPrivate copies remain restricted to the exact owner. The service constructs a\nwhitelisted catalog request; caller options and answer-key flags are discarded.\nAnswer keys are never requested or returned. A catalog grant does not grant learner authorization.\n\n## Transactions and history\n\n`withTransaction(async transaction => ...)` composes service operations on the\ncallback's transaction-bound service. Use that service for every operation in\nthe unit. Throwing rolls back its curriculum, enrollment, and progress writes.\nThe callback's service closes when the transaction ends and rejects later calls.\nA failed mutation poisons the unit even if the callback catches its rejection;\nqueued mutations are cancelled. Callback failure stops pending work and waits for\nalready-running mutations to settle before rollback releases the executor. On\nsuccess, already-enqueued mutations finish before commit. Do not retain the\ncallback's service beyond the callback. The constructor\naccepts a root database; callers composing work use this method instead of\npassing an unmanaged transaction handle.\n\nPostgreSQL locks the parent curriculum/enrollment row before find-or-create.\nEmbedded writes use the core transaction queue. Concurrent mutations inside\none callback transaction are ordered on that connection, so duplicate starts\nwithin a `Promise.all` also return the same row. Database natural-key constraints\narbitrate enrollment `(tenant_id, curriculum_id, course_id)` and progress\n`(tenant_id, enrollment_id, lesson_id)` identities. Explicit storage conflict\nkeys include the tenant prefix because core does not add it to explicit keys.\nThe logical keys required by #3968 remain curriculum/course and enrollment/lesson:\nimmutable parent ownership and parent-tenant checks reject any second tenant\nfor the same parent. The storage prefix cannot create a second valid logical row. Ordinary repeated and concurrent calls\nreturn one row; an aborted workflow can be retried. No in-memory process lock\nis used for PostgreSQL concurrency.\n\nA new year is a new curriculum and enrollment, producing independent progress.\nUnscored `bestScore` persists as null; score zero is a real score. Scores are\nnullable decimals in [0,1]; counters are nonnegative safe integers. Settings\nare stored as JSON text and accessed through guarded `getSettings`/`setSettings`.\n\nThe shared executable workflows in `src/__tests__/learner-workflows.ts` run\nagainst SQLite and PostgreSQL, including actor/resource/context denial,\nconcurrent creation, rollback, history preservation, and zero/null scores.\nFull DuckDB schemas remain blocked by existing upstream DDL restrictions;\nno full learner workflow support on DuckDB is claimed.\n"
14744
15107
  },
14745
15108
  {
14746
15109
  "path": "agents/submission-service.md",
@@ -14760,12 +15123,12 @@
14760
15123
  {
14761
15124
  "path": "agents/course-authoring-service.md",
14762
15125
  "module": "course-authoring-service",
14763
- "content": "# Course authoring contract\n\n`CourseAuthoringService` (SMRT #4514, consumed by Hearth #68) writes revisioned,\nfamily-owned courses and releases them into the existing Course/Section/Lesson\nshape. Risk is high: tenancy, authorization, new schema and a public contract.\nIt is a standalone service (not a `LearnerService` subclass); every write is one\ntransaction and every authorizer call receives that transaction's executor.\n\n## Models (closed, required tenant, no REST/CLI/MCP/AI)\n\n`AuthoredCourse` (source key `authored`) stays the stable identity row; it never\nholds content, is never assigned and its licence is never edited. Append-only:\n`CourseRevision`, `LessonRevision` (stable `lessonKey` across versions; an\nunchanged lesson reuses its row), `CourseRelease`, `ReleasedLesson`,\n`CourseArchiveDecision`, `CourseDiscardTombstone`, `EnrollmentReleasePin`,\n`CourseUpgradeChoice`. `CourseDraftHead` is the only mutable row and moves only\nthrough the service's expected-head compare-and-swap. `save()` on an existing\nappend-only row and `delete()` throw; erasure belongs to a retention operation.\nSchema ships via normal manifest migrations (additive: new tables only, no\nchange to existing tables, test-pinned); the runtime never creates tables.\n\n## Actions and host mapping\n\n`education.course.read-draft|author|generate|review|release|assign|archive|set-visibility|set-cover`. The host\nmaps roles; the authorizer also receives `operation`, `courseId`,\n`enrollmentId`/`learnerId` and `delegatingGuardianUserId` (agent calls) and must\nre-validate that delegation on the supplied executor. The service checks the\nambient tenant context, active actor and delegating-guardian membership first.\n`generated` provenance additionally needs `generate`. Releasing and assigning\nare independent grants: `pinEnrollment`, `upgradeEnrollment` and `setAutoUpgrade`\n(operations `pin-enrollment`, `upgrade-enrollment`, `set-auto-upgrade`) use only\n`assign`, never `release`. A host maps `assign` per learner from its assignment\nsetting (guardians only, guardians plus agents, learner can ask) using\n`operation`, `enrollmentId`/`learnerId` and `delegatingGuardianUserId`; a\nlearner-initiated request is host policy, the service adds no learner authority.\nArchive and discard use `archive`. `generate` and `review` are used by the [generation and review service](course-generation-review.md).\nForeign or missing courses/enrollments are not-found/denied alike.\n\nDelegated actor: the authenticated actor is the agent; `delegatingGuardianUserId`\nand a host `delegationId` (opaque, 1-200 printable characters) are validated as a\nunit (one without the other is `invalid-request`; the guardian may not be the agent,\nwhich is denied). Validation grants nothing: the guardian keeps no authority via the\nagent, the host authorizer decides, and both users need active membership. One helper\n(`course-delegation.ts`) builds actor, authorizer and record fields. `delegationId` is\npassed to every authorizer call (all `education.course.*` actions, including the\nper-enrollment `auto-upgrade` call, which gets the opt-in's recorded id) and recorded\n(nullable on views) on revisions, releases, archive/discard, pins, opt-ins, skips\n(`optInDelegationId`; automatic pins also record the opt-in's) and generation, review\nand question records. Request-key replays compare it: another delegation id conflicts.\nDelegation is opt-in and fails closed: `verifyDelegation(executor, { tenantId,\nactorUserId, delegatingGuardianUserId, delegationId, action, operation, courseId?,\nenrollmentId?, learnerId? }) => boolean | { allowed, reason?, code? }` must be set in the\nservice options. A delegated request without it, or when it denies or throws, is a\n`CourseAuthoringAuthorizationError` before the host authorizer runs. The per-enrollment\n`auto-upgrade` check re-verifies the opt-in's recorded delegation through the same port\n(on the savepoint executor); a failure skips the move. Plain guardians never need it.\nAssignment (`LearnerService.enroll`) is described in the [learner contract](learner-service.md).\n\n## Writes\n\nEvery write requires a `requestKey`. A retried key replays its original result;\nthe same key with a different payload throws `CourseRequestKeyConflictError`.\n`saveDraft` takes `expectedHeadRevisionId` and throws `CourseStaleHeadError`\n(with the current head) when stale. Errors extend `CourseAuthoringError` with a\nstable `code`; never match on messages.\n\n## Release\n\n`release` requires an injected `releaseReviewGate(executor, {tenantId, courseId,\nrevisionId}) => {current, reviewId?, reason?}`. No gate configured throws\n`ReviewGateRequiredError`; `current !== true` throws `ReviewNotCurrentError`.\n`createReviewBackedReleaseGate()` is the review-backed implementation (the default of\n`CourseGenerationReviewService`; see [generation and review](course-generation-review.md)). The decision records actor,\ndelegating guardian and `reviewId`, one release per revision. It materializes a\nrelease-only `AuthoredCourse` row (source key `authored-release`), sections and\n`Lesson` subclasses whose ids derive deterministically from the revision id and\nlesson identity; `Lesson.externalRef` and `ReleasedLesson` carry the stable\nlesson identity for progress/reporting. Learners on different releases never\nshare mutable rows. Personal-use releases stay `draft` status (the existing rule).\n\n## Enrollment pin and upgrades\n\n`Enrollment` is untouched (its `courseId` stays immutable): enroll through\n`LearnerService` on a release's `releaseCourseId`, then `pinEnrollment` records\nthe pin. `upgradeEnrollment` appends an explicit move to a newer release.\n`setAutoUpgrade` records an opt-in/out at course or enrollment level (enrollment\nwins) for future releases; each automatic move is a recorded pin without actor. Pin, upgrade and\n`setAutoUpgrade` need a `policyBasis` (the assignment-setting value relied on, e.g.\n`guardians-only`; 1-200 printable chars, else `invalid-request`). The caller's is optional: the\n`assign` authorizer decision may return `policyBasis`, which then wins; both given must be equal\n(`CoursePolicyBasisMismatchError`, `policy-basis-mismatch`) and neither is `invalid-request`; an invalid\nauthorizer basis is a validation error, nothing written. The authorizer runs first, also on replays, and\nreplays compare the effective basis. Recorded with actor and delegating guardian on the pin or\nchoice row and exposed on the views. An automatic move records the per-enrollment `auto-upgrade`\ndecision's `policyBasis` (an invalid one denies the move), else the basis of the\nopt-in that governs it (the same for a skip), plus its `optInScope` (`course`|`enrollment`). Each\nautomatic move is authorized per enrollment during `release` with action\n`assign`, operation `auto-upgrade`, that `enrollmentId`/`learnerId`, the opt-in's\nrecorded actor and delegating guardian (current membership re-checked, the host\nre-evaluates the delegation or relationship) and the transaction executor; hosts\nmust map `auto-upgrade` per learner from the assignment setting. A denial or\nauthorizer failure (each call runs in a SAVEPOINT, rolled back on a throw, so a failing host statement cannot abort the release transaction) never blocks the release or other enrollments: that\nenrollment stays on its pin and an `EnrollmentAutoUpgradeSkip` row records\n`not-authorized` (or the authorizer's `reason`), the optional `code`, the opt-in scope, actor,\nguardian and `policyBasis`. A replayed\nrequest key with a different basis is a request-key conflict.\n`EnrollmentPinView` (from `pinEnrollment`, `upgradeEnrollment`, `getEnrollmentRelease`) carries\n`learnerId`, `courseId` (authored identity) and `releaseCourseId` (history rows carry `releaseCourseId`);\n`setAutoUpgrade` returns `learnerId` (null for a course choice). All derive from the enrollment row on the\nserver, with no new input: hosts must not read the `enrollments` table: `learnerId` and `courseId` (plus `releaseId`/`releaseCourseId` for pin and upgrade) arrive in the assign authorizer and `verifyDelegation` requests. `getEnrollmentRelease` returns\n`EnrollmentReleaseView` (`learnerId`, `courseId`, `releaseCourseId`, `pin`, `lessons`): after any move\n(automatic or explicit), started lessons stay on the immutable row they were\nstarted on, unstarted lessons follow the current release. `LearnerService`\nconsumes the same rule (see `learner-service.md`).\n\n## Authorizer answers\n\nAn authorizer returns `boolean` or `{ allowed, reason?, code? }` (only `allowed: true`\nallows). On an auto-upgrade denial `reason` (at most 200 characters, control characters\nstripped) and `code` (`[a-z0-9._-]`, at most 64; e.g. `assignment-setting`,\n`agent-permission`, `guardian-link`) are stored on the skip; invalid values fall back to\n`not-authorized` and an empty code. A thrown error's message is never recorded. The per-enrollment `verifyDelegation` refusal is recorded the\nsame way: an object refusal's `reason`/`code` (same sanitizing); a boolean refusal, a throw or no port\nrecords the generic `not-authorized` and an empty code.\n\n## Reads\n\nAll use `read-draft`, the executor and delegation fields, and are tenant-exact (a foreign\ncourse is not-found, a foreign enrollment a denial). Lists take `limit` (default 50, capped at\n100) and an opaque `cursor`, and return `{ items, nextCursor }` in a total order.\n- `listCourses({ includeDiscarded? })` (operation `list-courses`): `CourseSummaryView`\n per course: state, `head` (`revisionId` is the expected-head token), `latestRelease`,\n `archive`, `licenseKind` (most restrictive of head and every release), `groupShareable`\n (false for personal-use and until a release exists) and `releaseCount`; hosts need no\n `getCourse` per row. Oldest first; discarded courses only when requested.\n- `listUpgradeChoices({ courseId, enrollmentId? })` (`list-upgrade-choices`): opt-in/out\n history in sequence order; with an enrollment, its own rows plus `governing`.\n- `listAutoUpgradeSkips({ courseId?, enrollmentId? })` (`list-auto-upgrade-skips`, one\n required): `AutoUpgradeSkipView` with `reason`, `code`, opt-in actor/guardian/delegation.\n- `QuestionAuthoringService.readReleasedLesson({ releaseId, lessonKey })`\n (`read-released-lesson`): lesson revision, layout and the keyed worksheet bundle.\n- `QuestionResponseService.readReleasedLesson({ enrollmentId, lessonId })` (learner\n authorizer, `education.learner.read`; the learner or an authorized guardian): only the\n enrollment's effective lessons (else `QuestionResponseScopeError`), keyless bundle,\n no materials or provenance, personal-use body withheld (`bodyWithheld`).\n\n## Visibility and cover\n\nTwo optional attributes live on the stable `AuthoredCourse` identity row (added\ncolumns `visibility`, `cover_asset_id` on `courses`; additive, migration-tested on\na populated database). Each change is an append-only row (`CourseVisibilityChange`,\n`CourseCoverChange`: previous, new, actor, delegation, request key, time) and every\nwrite needs a `requestKey`; the same key with another payload is\n`CourseRequestKeyConflictError`, an unchanged value writes nothing. Reads degrade\nto `family` and no cover on a database without the columns; a write without the\nchange tables throws `schema-unavailable`.\n\n- `setVisibility({ courseId, visibility: 'family'|'groups'|'public', requestKey })`\n (action `education.course.set-visibility`, operation `set-visibility`). Default\n is `family`. A stored value of `''` (never set, including every course authored\n before this existed) reads as `family`; an explicit `setVisibility('family')` on\n such a course is recorded (it is how a host pins a course family-only).\n A course whose licence is `personal-use-copy` (head revision or any release,\n which never loosens) refuses `groups` and `public` with\n `CourseVisibilityRefusedError` (`visibility-refused`). A revision that turns a\n `groups`/`public` course personal-use narrows it to `family` in the same\n transaction with a change row (`reason: 'licence'`, request key\n `licence:<revisionId>`). `AuthoredCourse.save()` also refuses the combination.\n- `listCourses({ visibility })` filters by effective visibility; views\n (`CourseSummaryView`, `CourseStateView`) carry `visibility` and `coverAssetId`.\n- `setCover({ courseId, assetId: string | null, requestKey })` (action\n `education.course.set-cover`, operation `set-cover`; `null` clears). The asset\n must be a material this course copied into the tenant (`materialCopy`: the only\n education-owned provenance the service records), with image content type\n (png, jpeg, webp, gif), in the course's tenant, never `answer-key` in any lesson\n revision or released lesson link, and readable by the acting user through the\n asset guard (a private asset only when its owner is the actor's profile).\n Anything else is `CourseCoverInvalidError` (`cover-invalid`, `reason`:\n `missing`, `not-image`, `not-course-material`, `answer-key`, `unreadable`);\n fail closed. A host that wants a standalone cover adds the image as an\n attachment material of a lesson first.\n- `release` snapshots the current cover onto the immutable release Course row\n (re-checked for eligibility, not for the releasing actor's read; dropped if no\n longer eligible). `CourseReleaseView.coverAssetId` and\n `latestRelease.coverAssetId` report it; later cover changes never rewrite it.\n- Group sharing: see [group sharing](group-sharing.md#authored-course-visibility).\n `public` is a read-filter label: no marketplace, no payment, and no cross-tenant\n read exists for it.\n\n## Archive and discard\n\n`archive`/`unarchive` append reversible decisions, mirror release `Course.status`\nfor catalog listings only and never touch enrollments, progress, submissions,\ngrades, workbooks, notes or evidence. Archived courses refuse release, new pins\nand upgrades. `discardDraft` ends an unreferenced draft with a tombstone (history\nstays); it is refused (`CourseDiscardRefusedError.references`) for releases and\nenrollments, and for review/generation/variant rows via the injected `referenceGuard`\n(`createCourseReferenceGuard`).\n\n## Licence and sharing\n\nMost restrictive wins: own-work < open-license < licensed-for-sharing <\npersonal-use-copy (`mostRestrictiveLicense`). A lesson's own licence folds every restriction the course ever recorded\nfor its `lessonKey` or its normalized content, so rename, omitted keys and\nremove-then-re-add keep it; only dropping that content lifts it, and a\n`lessonKey` once restricted stays restricted. A reused asset keeps the most\nrestrictive licence ever recorded for it. Uploaded materials default to\n`personal-use-copy` unless the author states otherwise. Any personal-use part\nmakes the course tenant-private: `GroupSharingWorkFacts.courseShareable=false`\n(reason `course-private`) for authored releases, and the policy option list hides\nthem. Work is attributed to the course of its own lesson, and any personal-use\nrelease anywhere in the enrollment's pin lineage also makes it `course-private`\nand hides the course from the options; unpinned enrollments and databases without the pin tables are unchanged. Existing workbook personal-use scaffolds are unchanged.\n\n## Materials\n\nUploads are copied by the injected `materialCopy` port into education-owned\nassets (tenant-checked afterwards) with source/actor/time provenance, so\ningestion expiry never removes them; replays never copy twice, and a port must\nnot leave side effects outside the transaction it cannot compensate. Releases\nlink them with `LessonAsset` under private asset access for personal-use and\nanswer-key material.\n"
15126
+ "content": "# Course authoring contract\n\n`CourseAuthoringService` (SMRT #4514, consumed by Hearth #68) writes revisioned,\nfamily-owned courses and releases them into the existing Course/Section/Lesson\nshape. Risk is high: tenancy, authorization, new schema and a public contract.\nIt is a standalone service (not a `LearnerService` subclass); every write is one\ntransaction and every authorizer call receives that transaction's executor.\n\n## Models (closed, required tenant, no REST/CLI/MCP/AI)\n\n`AuthoredCourse` (source key `authored`) stays the stable identity row; it never\nholds content, is never assigned and its licence is never edited. Append-only:\n`CourseRevision`, `LessonRevision` (stable `lessonKey` across versions; an\nunchanged lesson reuses its row), `CourseRelease`, `ReleasedLesson`,\n`CourseArchiveDecision`, `CourseDiscardTombstone`, `EnrollmentReleasePin`,\n`CourseUpgradeChoice`. `CourseDraftHead` is the only mutable row and moves only\nthrough the service's expected-head compare-and-swap. `save()` on an existing\nappend-only row and `delete()` throw; erasure belongs to a retention operation.\nSchema ships via normal manifest migrations (additive: new tables only, no\nchange to existing tables, test-pinned); the runtime never creates tables.\n\n## Actions and host mapping\n\n`education.course.read-draft|author|generate|review|release|assign|archive|set-visibility|set-cover`. The host\nmaps roles; the authorizer also receives `operation`, `courseId`,\n`enrollmentId`/`learnerId` and `delegatingGuardianUserId` (agent calls) and must\nre-validate that delegation on the supplied executor. The service checks the\nambient tenant context, active actor and delegating-guardian membership first.\n`generated` provenance additionally needs `generate`. Releasing and assigning\nare independent grants: `pinEnrollment`, `upgradeEnrollment` and `setAutoUpgrade`\n(operations `pin-enrollment`, `upgrade-enrollment`, `set-auto-upgrade`) use only\n`assign`, never `release`. A host maps `assign` per learner from its assignment\nsetting (guardians only, guardians plus agents, learner can ask) using\n`operation`, `enrollmentId`/`learnerId` and `delegatingGuardianUserId`; a\nlearner-initiated request is host policy, the service adds no learner authority.\nArchive and discard use `archive`. `generate` and `review` are used by the [generation and review service](course-generation-review.md).\nForeign or missing courses/enrollments are not-found/denied alike.\n\nDelegated actor: the authenticated actor is the agent; `delegatingGuardianUserId`\nand a host `delegationId` (opaque, 1-200 printable characters) are validated as a\nunit (one without the other is `invalid-request`; the guardian may not be the agent,\nwhich is denied). Validation grants nothing: the guardian keeps no authority via the\nagent, the host authorizer decides, and both users need active membership. One helper\n(`course-delegation.ts`) builds actor, authorizer and record fields. `delegationId` is\npassed to every authorizer call (all `education.course.*` actions, including the\nper-enrollment `auto-upgrade` call, which gets the opt-in's recorded id) and recorded\n(nullable on views) on revisions, releases, archive/discard, pins, opt-ins, skips\n(`optInDelegationId`; automatic pins also record the opt-in's) and generation, review\nand question records. Request-key replays compare it: another delegation id conflicts.\nDelegation is opt-in and fails closed: `verifyDelegation(executor, { tenantId,\nactorUserId, delegatingGuardianUserId, delegationId, action, operation, courseId?,\nenrollmentId?, learnerId? }) => boolean | { allowed, reason?, code? }` must be set in the\nservice options. A delegated request without it, or when it denies or throws, is a\n`CourseAuthoringAuthorizationError` before the host authorizer runs. The per-enrollment\n`auto-upgrade` check re-verifies the opt-in's recorded delegation through the same port\n(on the savepoint executor); a failure skips the move. Plain guardians never need it.\nAssignment (`LearnerService.enroll`) is described in the [learner contract](learner-service.md).\n\n## Writes\n\nEvery write requires a `requestKey`. A retried key replays its original result;\nthe same key with a different payload throws `CourseRequestKeyConflictError`.\n`saveDraft` takes `expectedHeadRevisionId` and throws `CourseStaleHeadError`\n(with the current head) when stale. Errors extend `CourseAuthoringError` with a\nstable `code`; never match on messages.\n\n## Release\n\n`release` requires an injected `releaseReviewGate(executor, {tenantId, courseId,\nrevisionId}) => {current, reviewId?, reason?}`. No gate configured throws\n`ReviewGateRequiredError`; `current !== true` throws `ReviewNotCurrentError`.\n`createReviewBackedReleaseGate()` is the review-backed implementation (the default of\n`CourseGenerationReviewService`; see [generation and review](course-generation-review.md)). The decision records actor,\ndelegating guardian and `reviewId`, one release per revision. It materializes a\nrelease-only `AuthoredCourse` row (source key `authored-release`), sections and\n`Lesson` subclasses whose ids derive deterministically from the revision id and\nlesson identity; `Lesson.externalRef` and `ReleasedLesson` carry the stable\nlesson identity for progress/reporting. Learners on different releases never\nshare mutable rows. Personal-use releases stay `draft` status (the existing rule).\n\n## Append a lesson to a released course\n\n`appendLesson({ courseId, expectedHeadRevisionId, requestKey, sectionKey,\nsectionTitle?, lesson, release?, policyBasis? })` adds exactly one lesson (a\n`CourseLessonInput` with a required `lessonKey`, its `materials` and `questions`\nexactly as `saveDraft` takes them) to a course that already has a release, as one\nnew revision, and releases that revision in the same transaction (`release:\nfalse` only appends). Returns `CourseAppendLessonView` (`revisionId`, `version`,\n`lessonKey`, `release | null`). Host mapping: action `education.course.author`,\noperation `append-lesson` (also `generate` for generated provenance), and, when\nreleasing, `education.course.release` with the usual `policyBasis` and delegation\nrules. Hosts that grant `author` but not `release` use `release: false`.\n\n- **Append-only.** The new revision is the head plus the lesson at the end of\n `sectionKey` (a new section needs `sectionTitle`). No lesson, revision or release\n is changed; the release is a new Course row with the next release number.\n- **Refused.** Unknown or foreign-tenant course (`CourseNotFoundError`), discarded,\n archived (`CourseArchivedError`), no release yet (`CourseAppendRefusedError`\n `not-released`), head with changes that were never released (`unreleased-changes`:\n nothing unreviewed rides along; release or discard it first), `lessonKey` already\n in the course (`duplicate-lesson`), stale `expectedHeadRevisionId`\n (`CourseStaleHeadError`).\n- **Review gate.** Releasing goes through the injected `releaseReviewGate` unchanged.\n The default review-backed gate needs a current review of the new revision, which\n cannot exist before it does, so with that gate use `release: false`, `recordReview`,\n then `release`. A host whose gate honours its own approval (the guardian already\n approved the requested lesson) can release in the one call. A gate that is not\n current rolls the append back entirely.\n- **Idempotent.** The same `requestKey` and request replays the original result, also\n after the head moved (a no-op: no revision, release or lesson rows); a changed lesson\n or section or release intent (`release` true versus false) under the same key is\n `CourseRequestKeyConflictError`; publish a `release: false` append later with\n `release` and its own key.\n- **Enrollment pins.** Existing enrollments keep their pinned release and so never see\n the new lesson implicitly. The host advances them with `upgradeEnrollment` on the new\n release (explicit, recorded), or an enrollment/course that opted in with\n `setAutoUpgrade` is moved by the release exactly as for any release (unstarted\n lessons follow, started lessons stay on their row). Nothing else moves a pin.\n\n## Enrollment pin and upgrades\n\n`Enrollment` is untouched (its `courseId` stays immutable): enroll through\n`LearnerService` on a release's `releaseCourseId`, then `pinEnrollment` records\nthe pin. `upgradeEnrollment` appends an explicit move to a newer release.\n`setAutoUpgrade` records an opt-in/out at course or enrollment level (enrollment\nwins) for future releases; each automatic move is a recorded pin without actor. Pin, upgrade and\n`setAutoUpgrade` need a `policyBasis` (the assignment-setting value relied on, e.g.\n`guardians-only`; 1-200 printable chars, else `invalid-request`). The caller's is optional: the\n`assign` authorizer decision may return `policyBasis`, which then wins; both given must be equal\n(`CoursePolicyBasisMismatchError`, `policy-basis-mismatch`) and neither is `invalid-request`; an invalid\nauthorizer basis is a validation error, nothing written. The authorizer runs first, also on replays, and\nreplays compare the effective basis. Recorded with actor and delegating guardian on the pin or\nchoice row and exposed on the views. An automatic move records the per-enrollment `auto-upgrade`\ndecision's `policyBasis` (an invalid one denies the move), else the basis of the\nopt-in that governs it (the same for a skip), plus its `optInScope` (`course`|`enrollment`). Each\nautomatic move is authorized per enrollment during `release` with action\n`assign`, operation `auto-upgrade`, that `enrollmentId`/`learnerId`, the opt-in's\nrecorded actor and delegating guardian (current membership re-checked, the host\nre-evaluates the delegation or relationship) and the transaction executor; hosts\nmust map `auto-upgrade` per learner from the assignment setting. A denial or\nauthorizer failure (each call runs in a SAVEPOINT, rolled back on a throw, so a failing host statement cannot abort the release transaction) never blocks the release or other enrollments: that\nenrollment stays on its pin and an `EnrollmentAutoUpgradeSkip` row records\n`not-authorized` (or the authorizer's `reason`), the optional `code`, the opt-in scope, actor,\nguardian and `policyBasis`. A replayed\nrequest key with a different basis is a request-key conflict.\n`EnrollmentPinView` (from `pinEnrollment`, `upgradeEnrollment`, `getEnrollmentRelease`) carries\n`learnerId`, `courseId` (authored identity) and `releaseCourseId` (history rows carry `releaseCourseId`);\n`setAutoUpgrade` returns `learnerId` (null for a course choice). All derive from the enrollment row on the\nserver, with no new input: hosts must not read the `enrollments` table: `learnerId` and `courseId` (plus `releaseId`/`releaseCourseId` for pin and upgrade) arrive in the assign authorizer and `verifyDelegation` requests. `getEnrollmentRelease` returns\n`EnrollmentReleaseView` (`learnerId`, `courseId`, `releaseCourseId`, `pin`, `lessons`): after any move\n(automatic or explicit), started lessons stay on the immutable row they were\nstarted on, unstarted lessons follow the current release. `LearnerService`\nconsumes the same rule (see `learner-service.md`).\n\n## Authorizer answers\n\nAn authorizer returns `boolean` or `{ allowed, reason?, code? }` (only `allowed: true`\nallows). On an auto-upgrade denial `reason` (at most 200 characters, control characters\nstripped) and `code` (`[a-z0-9._-]`, at most 64; e.g. `assignment-setting`,\n`agent-permission`, `guardian-link`) are stored on the skip; invalid values fall back to\n`not-authorized` and an empty code. A thrown error's message is never recorded. The per-enrollment `verifyDelegation` refusal is recorded the\nsame way: an object refusal's `reason`/`code` (same sanitizing); a boolean refusal, a throw or no port\nrecords the generic `not-authorized` and an empty code.\n\n## Reads\n\nAll use `read-draft`, the executor and delegation fields, and are tenant-exact (a foreign\ncourse is not-found, a foreign enrollment a denial). Lists take `limit` (default 50, capped at\n100) and an opaque `cursor`, and return `{ items, nextCursor }` in a total order.\n- `listCourses({ includeDiscarded? })` (operation `list-courses`): `CourseSummaryView`\n per course: state, `head` (`revisionId` is the expected-head token), `latestRelease`,\n `archive`, `licenseKind` (most restrictive of head and every release), `groupShareable`\n (false for personal-use and until a release exists) and `releaseCount`; hosts need no\n `getCourse` per row. Oldest first; discarded courses only when requested.\n- `listUpgradeChoices({ courseId, enrollmentId? })` (`list-upgrade-choices`): opt-in/out\n history in sequence order; with an enrollment, its own rows plus `governing`.\n- `listAutoUpgradeSkips({ courseId?, enrollmentId? })` (`list-auto-upgrade-skips`, one\n required): `AutoUpgradeSkipView` with `reason`, `code`, opt-in actor/guardian/delegation.\n- `QuestionAuthoringService.readReleasedLesson({ releaseId, lessonKey })`\n (`read-released-lesson`): lesson revision, layout and the keyed worksheet bundle.\n- `QuestionResponseService.readReleasedLesson({ enrollmentId, lessonId })` (learner\n authorizer, `education.learner.read`; the learner or an authorized guardian): only the\n enrollment's effective lessons (else `QuestionResponseScopeError`), keyless bundle,\n no materials or provenance, personal-use body withheld (`bodyWithheld`).\n\n## Visibility and cover\n\nTwo optional attributes live on the stable `AuthoredCourse` identity row (added\ncolumns `visibility`, `cover_asset_id` on `courses`; additive, migration-tested on\na populated database). Each change is an append-only row (`CourseVisibilityChange`,\n`CourseCoverChange`: previous, new, actor, delegation, request key, time) and every\nwrite needs a `requestKey`; the same key with another payload is\n`CourseRequestKeyConflictError`, an unchanged value writes nothing. Reads degrade\nto `family` and no cover on a database without the columns; a write without the\nchange tables throws `schema-unavailable`.\n\n- `setVisibility({ courseId, visibility: 'family'|'groups'|'public', requestKey })`\n (action `education.course.set-visibility`, operation `set-visibility`). Default\n is `family`. A stored value of `''` (never set, including every course authored\n before this existed) reads as `family`; an explicit `setVisibility('family')` on\n such a course is recorded (it is how a host pins a course family-only).\n A course whose licence is `personal-use-copy` (head revision or any release,\n which never loosens) refuses `groups` and `public` with\n `CourseVisibilityRefusedError` (`visibility-refused`). A revision that turns a\n `groups`/`public` course personal-use narrows it to `family` in the same\n transaction with a change row (`reason: 'licence'`, request key\n `licence:<revisionId>`). `AuthoredCourse.save()` also refuses the combination.\n- `listCourses({ visibility })` filters by effective visibility; views\n (`CourseSummaryView`, `CourseStateView`) carry `visibility` and `coverAssetId`.\n- `setCover({ courseId, assetId: string | null, requestKey })` (action\n `education.course.set-cover`, operation `set-cover`; `null` clears). The asset\n must be a material this course copied into the tenant (`materialCopy`: the only\n education-owned provenance the service records), with image content type\n (png, jpeg, webp, gif), in the course's tenant, never `answer-key` in any lesson\n revision or released lesson link, and readable by the acting user through the\n asset guard (a private asset only when its owner is the actor's profile).\n Anything else is `CourseCoverInvalidError` (`cover-invalid`, `reason`:\n `missing`, `not-image`, `not-course-material`, `answer-key`, `unreadable`);\n fail closed. A host that wants a standalone cover adds the image as an\n attachment material of a lesson first.\n- `release` snapshots the current cover onto the immutable release Course row\n (re-checked for eligibility, not for the releasing actor's read; dropped if no\n longer eligible). `CourseReleaseView.coverAssetId` and\n `latestRelease.coverAssetId` report it; later cover changes never rewrite it.\n- Group sharing: see [group sharing](group-sharing.md#authored-course-visibility).\n `public` is a read-filter label: no marketplace, no payment, and no cross-tenant\n read exists for it.\n\n## Archive and discard\n\n`archive`/`unarchive` append reversible decisions, mirror release `Course.status`\nfor catalog listings only and never touch enrollments, progress, submissions,\ngrades, workbooks, notes or evidence. Archived courses refuse release, new pins\nand upgrades. `discardDraft` ends an unreferenced draft with a tombstone (history\nstays); it is refused (`CourseDiscardRefusedError.references`) for releases and\nenrollments, and for review/generation/variant rows via the injected `referenceGuard`\n(`createCourseReferenceGuard`).\n\n## Licence and sharing\n\nMost restrictive wins: own-work < open-license < licensed-for-sharing <\npersonal-use-copy (`mostRestrictiveLicense`). A lesson's own licence folds every restriction the course ever recorded\nfor its `lessonKey` or its normalized content, so rename, omitted keys and\nremove-then-re-add keep it; only dropping that content lifts it, and a\n`lessonKey` once restricted stays restricted. A reused asset keeps the most\nrestrictive licence ever recorded for it. Uploaded materials default to\n`personal-use-copy` unless the author states otherwise. Any personal-use part\nmakes the course tenant-private: `GroupSharingWorkFacts.courseShareable=false`\n(reason `course-private`) for authored releases, and the policy option list hides\nthem. Work is attributed to the course of its own lesson, and any personal-use\nrelease anywhere in the enrollment's pin lineage also makes it `course-private`\nand hides the course from the options; unpinned enrollments and databases without the pin tables are unchanged. Existing workbook personal-use scaffolds are unchanged.\n\n## Materials\n\nUploads are copied by the injected `materialCopy` port into education-owned\nassets (tenant-checked afterwards) with source/actor/time provenance, so\ningestion expiry never removes them; replays never copy twice, and a port must\nnot leave side effects outside the transaction it cannot compensate. Releases\nlink them with `LessonAsset` under private asset access for personal-use and\nanswer-key material.\n"
14764
15127
  },
14765
15128
  {
14766
15129
  "path": "agents/course-generation-review.md",
14767
15130
  "module": "course-generation-review",
14768
- "content": "# Material generation proposals and educational review\n\n`CourseGenerationReviewService` (SMRT #4516, extends `QuestionAuthoringService`,\nconsumed by Hearth #68) adds generation proposals and revision-bound review to the\ncourse contract. Risk is high: schema and public contract. It uses the same\nauthorizer, tenancy checks and compare-and-swap save path. Every call receives\nthe transaction executor and `operation` (`request-generation`, `run-generation`,\n`cancel-generation`, `accept-proposal`, `read-generation`, `record-review`,\n`resolve-finding`, `note-context-change`, `read-review`). Foreign ids are not found.\n\n## Models (closed, required tenant, no REST/CLI/MCP/AI, append-only)\n\n`CourseGenerationJob` (request plus retained input snapshot),\n`CourseGenerationEvent` (status log: queued, running, succeeded, failed,\ncancelled; provider, model, response model, nullable token/cost usage, error\ncode, outcome), `CourseProposal`, `CourseProposalDecision`, `CourseReview`,\n`CourseReviewFinding`, `CourseReviewOutdatedMarker`, `CourseFindingResolution`.\n`save()` on an existing row and `delete()` throw. Eight additive tables, test-pinned;\nthe runtime never creates tables. `CourseReview` has no score, rating or grade.\n\n## Generation\n\nThe host injects a `CourseMaterialGenerator` (`createProposalCourseGenerator`\nadapts an ingestion `ProposalGenerator`); the service never selects a provider.\n`requestGeneration` (action `education.course.generate`) records a job against the\nhead the caller saw; the request key is idempotent, a retry or duplicate returns\nthe existing job and proposal, and the same key with other input throws\n`CourseRequestKeyConflictError`. `runGeneration` calls the generator outside any\ntransaction (deadline and output cap), then validates the untrusted output against\na closed schema. Invalid output, provider error, timeout, cancellation or a moved\nhead record `failed`/`cancelled` and leave the head unchanged; the input stays.\nPersonal-use lesson bodies and assets are never sent to the provider.\n\nSuccess appends one new draft revision (`generate` plus `author`, provenance\n`generated`) holding only additions and fills of empty fields. A proposal that\ndiffers from supplied content is a `conflict` and is held in `CourseProposal`.\n`getProposal` returns the per-field comparison (supplied, proposed, current,\ndecision). `decideProposal` accepts or rejects conflict fields: an accept appends\na revision on `expectedHeadRevisionId` and records the decision; supplied content\nis replaced only that way. Never write generated content to a released revision or\nassign it to a learner.\n\n## Review\n\n`recordReview` (action `education.course.review`) binds to an exact revision and a\ncontext digest (level target and theme from the revision; learner plus evidence\nsnapshot reference when given). Findings point at a course, lesson or question\nrevision id plus field/region, with concern, explanation, suggested change,\n`uncertain`, `humanVerificationRequired` and `blocking`. Sections are separate:\n`general-level` and `individual-learner` (needs a learner context). Every review\nstores `COURSE_REVIEW_DISCLAIMER`; it is an aid, never certification.\n\nA review is outdated when a new draft revision is saved (a persisted marker is\nappended inside that save), when `noteContextChange` reports a new evidence\nsnapshot, or when the head differs or the digest no longer verifies (derived).\nHistory, authorship (actor plus delegating guardian) and findings are kept.\n`resolveFinding` records one resolution per finding; a delegated agent cannot\nresolve a `humanVerificationRequired` finding.\n\n## Release gate and discard guard\n\n`createReviewBackedReleaseGate()` (the service default) makes a revision releasable\nonly when its latest review is for that revision, is the draft head, has no marker,\na matching digest, and no unresolved blocking or human-verification finding on\nany review of it or of any ancestor revision (a newer or empty review never hides\none). Nothing drops a finding automatically: not an edit, rename, copied question\nor removal of its target. Only `resolveFinding` clears one (a guardian can, even\nwhen the target is gone; a delegated agent cannot resolve human-verification\nfindings); otherwise `ReviewNotCurrentError.reason` is `no-review`, `outdated`,\n`context-mismatch` or `unresolved-findings`. A plain `CourseAuthoringService`\nwithout a gate still fails closed. `createCourseReferenceGuard(extra)` blocks\ndiscard for review and generation rows; pass variant guards through `extra`.\n\n## Evaluation\n\n`evaluateReviewer(cases, reviewer)` scores a supplied reviewer function against\nseeded flaws and suitable material (precision, recall, false-alarm rate, per\ncategory). Fixtures live in `src/__tests__/course-review-fixtures.ts`; no live model\nis called by this package.\n"
15131
+ "content": "# Material generation proposals and educational review\n\n`CourseGenerationReviewService` (SMRT #4516, extends `QuestionAuthoringService`,\nconsumed by Hearth #68) adds generation proposals and revision-bound review to the\ncourse contract. Risk is high: schema and public contract. It uses the same\nauthorizer, tenancy checks and compare-and-swap save path. Every call receives\nthe transaction executor and `operation` (`request-generation`, `run-generation`,\n`cancel-generation`, `accept-proposal`, `read-generation`, `record-review`,\n`resolve-finding`, `note-context-change`, `read-review`). Foreign ids are not found.\n\n## Models (closed, required tenant, no REST/CLI/MCP/AI, append-only)\n\n`CourseGenerationJob` (request plus retained input snapshot),\n`CourseGenerationEvent` (status log: queued, running, succeeded, failed,\ncancelled; provider, model, response model, nullable token/cost usage, error\ncode, outcome), `CourseProposal`, `CourseProposalDecision`, `CourseReview`,\n`CourseReviewFinding`, `CourseReviewOutdatedMarker`, `CourseFindingResolution`.\n`save()` on an existing row and `delete()` throw. Eight additive tables, test-pinned;\nthe runtime never creates tables. `CourseReview` has no score, rating or grade.\n\n## Generation\n\nThe host injects a `CourseMaterialGenerator` (`createProposalCourseGenerator`\nadapts an ingestion `ProposalGenerator`); the service never selects a provider.\n`requestGeneration` (action `education.course.generate`) records a job against the\nhead the caller saw; the request key is idempotent, a retry or duplicate returns\nthe existing job and proposal, and the same key with other input throws\n`CourseRequestKeyConflictError`. `runGeneration` calls the generator outside any\ntransaction (deadline and output cap), then validates the untrusted output against\na closed schema. Invalid output, provider error, timeout, cancellation or a moved\nhead record `failed`/`cancelled` and leave the head unchanged; the input stays.\nPersonal-use lesson bodies and assets are never sent to the provider.\n\nSuccess appends one new draft revision (`generate` plus `author`, provenance\n`generated`) holding only additions and fills of empty fields. A proposal that\ndiffers from supplied content is a `conflict` and is held in `CourseProposal`.\n`getProposal` returns the per-field comparison (supplied, proposed, current,\ndecision). `decideProposal` accepts or rejects conflict fields: an accept appends\na revision on `expectedHeadRevisionId` and records the decision; supplied content\nis replaced only that way. Never write generated content to a released revision or\nassign it to a learner.\n\n## Structured requests and one-lesson generation\n\n`requestGeneration` also takes an optional `brief` (`CourseGenerationBriefInput`)\nnext to the free `instruction`: `target` (`'course'`, the default and today's\nbehaviour, or `'lesson'`), `subject`, `category`, `area`, `grade` (whole 0-12),\n`objective`, `learningObjectiveId`, `learnerId` and a per-request `theme`\n(`{ label, key? }`). Text is bounded and sent to the provider as data. A request\nwithout `brief` behaves exactly as before and `brief` reads back `null`.\n\n- **Objective.** A `lesson` target needs an objective (`objective`, else `area`);\n without one `CourseGenerationBriefError` is thrown before anything is written.\n The objective is stored on the job's `CourseGenerationBriefRecord`, sent in the\n generation brief as fixed, and copied verbatim onto the proposed lesson. The\n theme changes story and examples only: output that states a different\n objective fails the job (`CourseGenerationOutputError`).\n- **Theme.** The per-request `theme` is an override that wins for that request\n only. Without one the learner's saved `interestTheme` (read-only, from\n `brief.learnerId`) applies; with neither there is no theme. The brief records\n `theme` and `themeSource` (`'override' | 'saved' | 'none'`) at request time, and\n `CourseGenerationView.brief` / `CourseProposalView.brief` show it to the\n guardian reviewing. Nothing in generation writes to the learner, so the saved\n preference is never changed, on success or failure.\n- **Authority.** The host authorizer is asked for `education.course.generate`\n with `learnerId` in the context so it can enforce its own \"may this learner ask\n for a lesson\" policy; an unknown or foreign-tenant learner is `CourseNotFoundError`.\n The package does not check the requester's relationship to `brief.learnerId` (the\n brief echoes that learner's saved theme): the host authorizer MUST deny `generate`\n when the actor is not that learner or a guardian with an effective relationship, as\n it does for `assign`.\n Replaying a request key with the same brief returns the same job; a changed\n brief is `CourseRequestKeyConflictError`.\n- **Output.** For `lesson` the generator is given `request.brief` and\n `COURSE_LESSON_PROPOSAL_ARGS_SCHEMA` (via `createProposalCourseGenerator`):\n exactly one new lesson with `title`, `body` (the themed explanation) and\n `questions` (worksheet; every question needs an `answerKey`), and no course-level\n fields. The worksheet becomes immutable question records, linked to\n `learningObjectiveId`, in the same transaction as the draft revision; answer keys\n stay out of learner views. Review, `decideProposal` and release are unchanged.\n- **Approving requested lessons.** The package has no per-learner setting. To honour\n the host's own \"approve requested lessons\" setting without bypassing authority,\n the host calls `recordReview` then `release` under the guardian delegation as for\n any course, passing `policyBasis` (for example `'auto-approve-requested-lessons'`)\n to `release`, or returning `policyBasis` from its authorizer decision (the two\n must agree). The basis is recorded on `CourseRelease.policyBasis` and\n `CourseReleaseView.policyBasis`; it grants nothing and the review gate, the\n `education.course.release` grant and the delegation check still apply. The\n explicit alternative, a guardian approving each lesson, is the existing\n `recordReview` plus `release` by the guardian.\n\nAdditive schema: table `course_generation_briefs` and column\n`course_releases.policy_basis`. On a host without the table, plain requests still\nwork and structured ones fail closed with `schema-unavailable`.\n\n## Review\n\n`recordReview` (action `education.course.review`) binds to an exact revision and a\ncontext digest (level target and theme from the revision; learner plus evidence\nsnapshot reference when given). Findings point at a course, lesson or question\nrevision id plus field/region, with concern, explanation, suggested change,\n`uncertain`, `humanVerificationRequired` and `blocking`. Sections are separate:\n`general-level` and `individual-learner` (needs a learner context). Every review\nstores `COURSE_REVIEW_DISCLAIMER`; it is an aid, never certification.\n\nA review is outdated when a new draft revision is saved (a persisted marker is\nappended inside that save), when `noteContextChange` reports a new evidence\nsnapshot, or when the head differs or the digest no longer verifies (derived).\nHistory, authorship (actor plus delegating guardian) and findings are kept.\n`resolveFinding` records one resolution per finding; a delegated agent cannot\nresolve a `humanVerificationRequired` finding.\n\n## Release gate and discard guard\n\n`createReviewBackedReleaseGate()` (the service default) makes a revision releasable\nonly when its latest review is for that revision, is the draft head, has no marker,\na matching digest, and no unresolved blocking or human-verification finding on\nany review of it or of any ancestor revision (a newer or empty review never hides\none). Nothing drops a finding automatically: not an edit, rename, copied question\nor removal of its target. Only `resolveFinding` clears one (a guardian can, even\nwhen the target is gone; a delegated agent cannot resolve human-verification\nfindings); otherwise `ReviewNotCurrentError.reason` is `no-review`, `outdated`,\n`context-mismatch` or `unresolved-findings`. A plain `CourseAuthoringService`\nwithout a gate still fails closed. `createCourseReferenceGuard(extra)` blocks\ndiscard for review and generation rows; pass variant guards through `extra`.\n\n## Evaluation\n\n`evaluateReviewer(cases, reviewer)` scores a supplied reviewer function against\nseeded flaws and suitable material (precision, recall, false-alarm rate, per\ncategory). Fixtures live in `src/__tests__/course-review-fixtures.ts`; no live model\nis called by this package.\n"
14769
15132
  },
14770
15133
  {
14771
15134
  "path": "agents/question-records.md",
@@ -14780,7 +15143,7 @@
14780
15143
  {
14781
15144
  "path": "agents/course-bundle-import.md",
14782
15145
  "module": "course-bundle-import",
14783
- "content": "# Course bundle import\n\n`CourseBundleImportService` (SMRT #4578, extends `CourseAuthoringService`,\nconsumed by Hearth #94) imports a course bundle, a zip with an optional manifest\nor a plain file set, as bounded evidence, hands its supported members to the\nhost for ingestion extraction and proposes sections and lessons for review.\nRisk is high: untrusted archive parsing and new schema. It reuses the course\nauthorizer: `importBundle` needs `education.course.author`, `readImport`\n`education.course.read-draft`, `proposeStructure` `education.course.generate`\n(via `CourseGenerationReviewService`). Operations: `import-course-bundle`,\n`read-course-bundle`. Server only (Node `zlib`/`crypto`); no new dependency.\n\n## Flow\n\n1. `importBundle({ ...actor, bundle, limits?, onProgress?, receiveMember? })`\n authorizes **before parsing**, unpacks (stage `unpack`), classifies and lays\n out the structure (`classify`), writes the evidence in one transaction and\n then calls `receiveMember` for each accepted member (`extract`), outside any\n transaction. `bundle` is `{ archive: Uint8Array }` or `{ files: [{ path,\n bytes }] }`. Hostile or damaged input throws `CourseBundleRefusedError`\n (`reason`, optional `entry`) and writes nothing.\n2. `receiveMember(member, { replayed })` is the host's ingestion seam: call\n `IngestionService.receive` with `sourceKind: 'course_import'` and the member\n bytes, then queue extraction and `preserveQuestionExtractionAnalysis` as in\n [question extraction](question-extraction.md). Members carry `role`\n (manifest page role), `lessonKey`, `sectionKey`. It runs on a replay too\n (ingestion dedups by content hash), so an interrupted first run resumes. Its\n return values are in `result.receipts`.\n3. `proposeStructure({ ...actor, importId, bundle, courseId,\n expectedHeadRevisionId })` (stage `propose`) re-supplies the bundle (bytes\n are never retained), checks it is the imported one, and runs a deterministic\n `CourseMaterialGenerator` (`smrt-bundle-import`) through\n `requestGeneration`/`runGeneration`. The result is an ordinary proposal: a\n new draft revision of additions and fills; conflicts wait for\n `decideProposal`. Nothing is released, assigned or published. The request key\n is `bundle:<courseId>:<bundleIdentity>`, so a retry returns the same job; the\n same key with another head is `CourseRequestKeyConflictError`.\n4. `readImport({ ...actor, importId })` returns the recorded evidence.\n\nProgress is an observer callback (`onProgress({ stage, completed, total })`,\nstages `unpack`, `classify`, `extract`, `propose`; each starts at `completed: 0`).\nIngestion has no public job-progress seam, so none is invented. A throwing\nlistener never affects the import.\n\n## Hard bounds (`COURSE_BUNDLE_LIMITS`; a host may only lower them)\n\n| limit | value |\n|---|---|\n| `maxArchiveBytes` (input) | 100 MiB |\n| `maxEntries` | 200 |\n| `maxEntryBytes` (inflated) | 50 MiB |\n| `maxTotalBytes` (inflated) | 200 MiB |\n| `maxCompressionRatio` (members over `ratioFloorBytes` = 64 KiB) | 100 |\n| `maxPathBytes` / `maxSegmentBytes` / `maxPathDepth` | 512 / 255 / 8 |\n| `maxManifestBytes` | 256 KiB |\n| `maxLessons` (the proposal schema's cap) | 50 |\n| `maxLessonBodyChars` | 100 000 |\n| archive nesting | 1: a zip inside a zip is never expanded |\n\nA lesson whose combined text members exceed `maxLessonBodyChars` refuses the\nbundle (`lesson_too_large`, `entry` = lesson key) at import; text is never\ntrimmed or dropped. A plain file set enforces `maxTotalBytes` on its member\nbytes exactly as an archive does on inflated bytes.\n\nThe reader (`course-bundle-zip.ts`) parses the end record and central directory,\nvalidates every offset and size against the real buffer, inflates with\n`maxOutputLength` equal to the declared size (declared sizes are never trusted:\na mismatch either way is `size_mismatch`), checks CRC32 and re-checks the total\non actual output. Stored and deflate only. Refusals (`CourseBundleRefusalReason`)\ncover: not a zip, truncated, ambiguous end record (two end records that both fit\nthe file, for example one forged in a comment), zip64, multi-disk, central\ndirectory not ending at the end record, entries out of range or overlapping,\nlocal header name/method mismatch, encrypted entries (flags 0/6/13, AES method),\nsymlink and special files (unix mode; DOS reparse flag), unsupported method,\nbad CRC, ratio, entry count, totals, duplicate names (case/NFC-insensitive,\nbackslashes normalised first) and bad paths (`..`, `.`, empty segments, absolute,\ndrive letter or `:` anywhere, NUL/control/bidi characters, lone surrogates,\ntoo long or deep). An **honestly** oversize member (declared over\n`maxEntryBytes`) is not inflated and is recorded `refused / entry_too_large`.\n\n## Members and statuses\n\nBy content (never by extension alone): `pdf` (`%PDF-` in the first 1 KiB),\n`image` (PNG, JPEG, WebP), `text` (`.txt`/`.md`, strict UTF-8, no control\ncharacters, body-sized), `manifest` (root `manifest.json`), else `unsupported`.\nStatus: `accepted` (handed to extraction), `duplicate` (same sha256 as an earlier\nmember in canonical order; recorded once as evidence, `duplicateOfPath`, never\nextracted again), `unsupported` (`unsupported_type`, `nested_archive`,\n`unreadable_text`), `refused` (`encrypted_pdf`, `entry_too_large`), `metadata`\n(the manifest). Nothing is dropped. An encrypted PDF is any PDF with an\n`/Encrypt` token anywhere in its bytes (trailer, xref stream): a false positive\non literal text inside an uncompressed stream is refused, never read.\n\n## Manifest (`manifest.json`, version 1, closed)\n\n```json\n{ \"version\": 1, \"course\": { \"subject\": \"Math\" },\n \"sections\": [ { \"key\": \"unit-1\", \"title\": \"Unit One\",\n \"lessons\": [ { \"key\": \"sums\", \"title\": \"Sums\",\n \"members\": [ { \"path\": \"sheets/sums.pdf\", \"role\": \"exercise\" } ] } ] } ] }\n```\nEvery object is closed: an unknown key anywhere is `manifest_unknown_key` (with\nits path in `entry`); another version is `manifest_version`. Keys match\n`[A-Za-z0-9._:-]{1,120}` and are unique; roles are the extraction page roles\n(`exercise`, `example`, `instructions`, `cover`, `answer_key`, `mixed`,\n`unknown`); a listed path must exist and be listed once. The manifest is a source\nfact: roles are `source`, anything the extractor infers stays `inferred`, and a\nmanifest/extraction disagreement belongs in extraction review, not here. Without\na manifest each accepted member is one lesson, sections come from the top-level\ndirectory and order is natural path order (digit runs compare numerically, no\nlocale). Manifest order: the manifest, declared members, then the rest by path.\nLesson bodies are the readable text members of a lesson, joined; PDFs and images\nare never inlined. Members whose declared role is `answer_key` or `mixed` never\ncontribute text to a lesson body (they remain recorded evidence members); the\nproposal rationale only counts how many were withheld.\n\n## Readers\n\n`BundleReader { formatId, detect, prepare }` in a `BundleReaderRegistry`;\nbuilt-ins are `manifest-v1` and `plain-files`. SCORM and Common Cartridge are\n**not implemented** (their manifests describe launch resources and activity\ntrees that do not map to sections/lessons without design); a host can register\na reader ahead of the built-ins (`register(reader, { first: true })`).\n\n## Identity and replay\n\n`bundleIdentity` (`cbi1-...`) digests the sorted (path, sha256) pairs, so\nre-zipping, reordering and timestamps replay. One `CourseBundleImport` per\n(tenant, identity); a concurrent first import replays once on the unique index.\n\n## Models (closed, required tenant, no REST/CLI/MCP/AI, append-only)\n\n`CourseBundleImport` (`course_bundle_imports`: identity, format, reader,\nmanifest version, counts, actor) and `CourseBundleMember` (`course_bundle_members`:\nunique tenant/import/path; ordinal, sha256, size, media type, kind, status,\nreason, duplicate-of, role, section and lesson key). Hashes and placements only:\nbytes stay with the host's ingestion receipts. Rewrites and deletes throw.\nNormal manifest migrations create both tables.\n\n## Not in this release\n\n- Ingestion's receipt contract is unchanged: there is no `application/zip` part\n type or `parentEvidenceId` in ingestion. The education side unpacks and the\n host receives members one by one, so archive-to-member lineage lives in\n `course_bundle_members` (and the host's receipts), not in ingestion evidence.\n Changing ingestion's published receipt is a separate design decision.\n- A manifest/extraction disagreement is not compared automatically.\n- SCORM / Common Cartridge readers; zip64; nested archives; encrypted zips.\n"
15146
+ "content": "# Course bundle import\n\n`CourseBundleImportService` (SMRT #4578, extends `CourseAuthoringService`,\nconsumed by Hearth #94) imports a course bundle, a zip with an optional manifest\nor a plain file set, as bounded evidence, hands its supported members to the\nhost for ingestion extraction and proposes sections and lessons for review.\nRisk is high: untrusted archive parsing and new schema. It reuses the course\nauthorizer: `importBundle` needs `education.course.author`, `readImport`\n`education.course.read-draft`, `proposeStructure` `education.course.generate`\n(via `CourseGenerationReviewService`). Operations: `import-course-bundle`,\n`read-course-bundle`. Server only (Node `zlib`/`crypto`); no new dependency.\n\n## Flow\n\n1. `importBundle({ ...actor, bundle, limits?, onProgress?, receiveMember? })`\n authorizes **before parsing**, unpacks (stage `unpack`), classifies and lays\n out the structure (`classify`), writes the evidence in one transaction and\n then calls `receiveMember` for each accepted member (`extract`), outside any\n transaction. `bundle` is `{ archive: Uint8Array }` or `{ files: [{ path,\n bytes }] }`. Hostile or damaged input throws `CourseBundleRefusedError`\n (`reason`, optional `entry`) and writes nothing.\n2. `receiveMember(member, { replayed })` is the host's ingestion seam: call\n `IngestionService.receive` with `sourceKind: 'course_import'` and the member\n bytes, then queue extraction and `preserveQuestionExtractionAnalysis` as in\n [question extraction](question-extraction.md). Members carry `role`\n (manifest page role), `lessonKey`, `sectionKey`. It runs on a replay too\n (ingestion dedups by content hash), so an interrupted first run resumes. Its\n return values are in `result.receipts`.\n3. `proposeStructure({ ...actor, importId, bundle, courseId,\n expectedHeadRevisionId })` (stage `propose`) re-supplies the bundle (bytes\n are never retained), checks it is the imported one, and runs a deterministic\n `CourseMaterialGenerator` (`smrt-bundle-import`) through\n `requestGeneration`/`runGeneration`. The result is an ordinary proposal: a\n new draft revision of additions and fills; conflicts wait for\n `decideProposal`. Nothing is released, assigned or published. The request key\n is `bundle:<courseId>:<bundleIdentity>`, so a retry returns the same job; the\n same key with another head is `CourseRequestKeyConflictError`.\n4. `readImport({ ...actor, importId })` returns the recorded evidence.\n\nProgress is an observer callback (`onProgress({ stage, completed, total })`,\nstages `unpack`, `classify`, `render`, `extract`, `propose`; each starts at `completed: 0`;\n`render` is emitted only when the bundle has Word/PowerPoint members).\nIngestion has no public job-progress seam, so none is invented. A throwing\nlistener never affects the import.\n\n## Hard bounds (`COURSE_BUNDLE_LIMITS`; a host may only lower them)\n\n| limit | value |\n|---|---|\n| `maxArchiveBytes` (input) | 100 MiB |\n| `maxEntries` | 200 |\n| `maxEntryBytes` (inflated) | 50 MiB |\n| `maxTotalBytes` (inflated) | 200 MiB |\n| `maxCompressionRatio` (members over `ratioFloorBytes` = 64 KiB) | 100 |\n| `maxPathBytes` / `maxSegmentBytes` / `maxPathDepth` | 512 / 255 / 8 |\n| `maxManifestBytes` | 256 KiB |\n| `maxLessons` (the proposal schema's cap) | 50 |\n| `maxLessonBodyChars` | 100 000 |\n| `maxOfficeMembers` (Word/PowerPoint members rendered per bundle) | 50 |\n| `maxRenderedBytes` (PDF accepted back from the converter) | 50 MiB |\n| `conversionTimeoutMs` (one Office member) | 60 000 |\n| archive nesting | 1: a zip inside a zip is never expanded |\n\nA lesson whose combined text members exceed `maxLessonBodyChars` refuses the\nbundle (`lesson_too_large`, `entry` = lesson key) at import; text is never\ntrimmed or dropped. A plain file set enforces `maxTotalBytes` on its member\nbytes exactly as an archive does on inflated bytes.\n\nThe reader (`course-bundle-zip.ts`) parses the end record and central directory,\nvalidates every offset and size against the real buffer, inflates with\n`maxOutputLength` equal to the declared size (declared sizes are never trusted:\na mismatch either way is `size_mismatch`), checks CRC32 and re-checks the total\non actual output. Stored and deflate only. Refusals (`CourseBundleRefusalReason`)\ncover: not a zip, truncated, ambiguous end record (two end records that both fit\nthe file, for example one forged in a comment), zip64, multi-disk, central\ndirectory not ending at the end record, entries out of range or overlapping,\nlocal header name/method mismatch, encrypted entries (flags 0/6/13, AES method),\nsymlink and special files (unix mode; DOS reparse flag), unsupported method,\nbad CRC, ratio, entry count, totals, duplicate names (case/NFC-insensitive,\nbackslashes normalised first) and bad paths (`..`, `.`, empty segments, absolute,\ndrive letter or `:` anywhere, NUL/control/bidi characters, lone surrogates,\ntoo long or deep). An **honestly** oversize member (declared over\n`maxEntryBytes`) is not inflated and is recorded `refused / entry_too_large`.\n\n## Members and statuses\n\nBy content (never by extension alone): `docx`/`pptx` (see below), `pdf` (`%PDF-` in the first 1 KiB),\n`image` (PNG, JPEG, WebP), `text` (`.txt`/`.md`, strict UTF-8, no control\ncharacters, body-sized), `manifest` (root `manifest.json`), else `unsupported`.\nStatus: `accepted` (handed to extraction), `duplicate` (same sha256 as an earlier\nmember in canonical order; recorded once as evidence, `duplicateOfPath`, never\nextracted again), `unsupported` (`unsupported_type`, `nested_archive`,\n`unreadable_text`), `refused` (`encrypted_pdf`, `entry_too_large`), `metadata`\n(the manifest). Nothing is dropped. An encrypted PDF is any PDF with an\n`/Encrypt` token anywhere in its bytes (trailer, xref stream): a false positive\non literal text inside an uncompressed stream is refused, never read.\n\n## Word and PowerPoint members (SMRT #4615)\n\nA `.docx`/`.pptx` member is an OOXML package only when the name and the content\nagree: a zip whose entries include `[Content_Types].xml` and `word/document.xml`\n(docx) or `ppt/presentation.xml` (pptx). Before the bytes go anywhere, the\npackage itself passes the same bounded zip reader (ratio, overlap, paths, CRC,\n100 MiB inflated); a failure is `refused / unsafe_office`, never a refusal of the\nwhole bundle. Other zips stay `unsupported / nested_archive`.\n\nTwo separate things happen to an accepted Office member, both after\nauthorization and before the evidence is written:\n\n- **Pages**: a `DocumentConverter` (`document-converter.ts`) renders the original\n to PDF. The importer enforces the byte and time limits itself (input\n `maxEntryBytes`, output `maxRenderedBytes`, `conversionTimeoutMs` with an abort\n signal and a hard race, so an adapter that ignores the signal still times out),\n and rejects output that is not a PDF or is encrypted. The converter is told a\n neutral file name (`document.docx`), never the bundle path. Identical documents\n are rendered once.\n- **Text**: an injectable `OfficeTextReader` (best effort; a failure or a `null`\n costs the member nothing) returns whole-document text and, for decks, text and\n speaker notes per slide. Office text is handed to the host on the accepted\n member (`officeText`) and is **never** inlined into a proposed lesson body:\n teacher guides and notes carry answers, and audience belongs to manifest v2.\n\n`receiveMember` gets the original `bytes` (store them as the private original\nasset: \"Open in PowerPoint\" for decks) and `rendered` (`{ bytes, sha256, mediaType:\n'application/pdf', converterId }`, store as a private asset beside the original:\nthe pages for \"Present\"/reading). The member row records `renderedSha256` and\n`renderedSizeBytes`; bytes are still not retained here.\n\n**Fail closed.** Any converter problem records that member `unsupported` with a\nreason and writes the rest of the bundle normally: `converter_unavailable` (none\nconfigured), `converter_failed` (unreachable or 5xx), `converter_timeout`,\n`converter_too_large`, `converter_rejected` (HTTP 4xx, not a PDF, encrypted),\n`office_limit` (beyond `maxOfficeMembers`, in natural path order). A member that\nfailed is not a lesson in the proposal, and identical copies of a failed document\nfail the same way rather than becoming duplicates of nothing. Outcomes are part\nof the append-only evidence, so a replay does not retry a recorded failure (to\nretry, change the bundle). A replay does re-render recorded-accepted members so the\nhost receives the bytes again; if it can no longer render one it throws\n`DocumentConversionError` rather than silently dropping an accepted member.\n\n### Host configuration\n\n```ts\nconst service = new CourseBundleImportService(db, authorizer, {\n converter: new GotenbergDocumentConverter({\n baseUrl: process.env.GOTENBERG_URL, // for example http://gotenberg:3000\n timeoutMs: 60_000,\n maxBytes: 50 * 1024 * 1024,\n }),\n officeTextReader, // optional, see below\n});\n```\n\n`GotenbergDocumentConverter` posts to `<baseUrl>/forms/libreoffice/convert`, talks\nto nothing else, refuses redirects and URLs with credentials, and caps the response\nas it streams. Run Gotenberg as an internal-only service with **no outbound\nnetwork**, its own size/time limits and no shared volume with the app; this\npackage assumes untrusted files never run next to the application. Tests use\n`FakeDocumentConverter` (records calls; `failWith`, `failWhen`, `delayMs`, `output`).\n\n`OfficeTextReader` is deliberately not wired to the SDK here, so this package\ndoes not depend on an unreleased `@happyvertical/documents`. Once the Office\nprocessors ship (sdk#1485, `extractOffice(bytes, 'docx' | 'pptx')`), implement\n`read` with it; the snippet is in the `OfficeTextReader` doc comment.\n\n## Manifest (`manifest.json`, version 1, closed)\n\n```json\n{ \"version\": 1, \"course\": { \"subject\": \"Math\" },\n \"sections\": [ { \"key\": \"unit-1\", \"title\": \"Unit One\",\n \"lessons\": [ { \"key\": \"sums\", \"title\": \"Sums\",\n \"members\": [ { \"path\": \"sheets/sums.pdf\", \"role\": \"exercise\" } ] } ] } ] }\n```\nEvery object is closed: an unknown key anywhere is `manifest_unknown_key` (with\nits path in `entry`); another version is `manifest_version`. Keys match\n`[A-Za-z0-9._:-]{1,120}` and are unique; roles are the extraction page roles\n(`exercise`, `example`, `instructions`, `cover`, `answer_key`, `mixed`,\n`unknown`); a listed path must exist and be listed once. The manifest is a source\nfact: roles are `source`, anything the extractor infers stays `inferred`, and a\nmanifest/extraction disagreement belongs in extraction review, not here. Without\na manifest each accepted member is one lesson, sections come from the top-level\ndirectory and order is natural path order (digit runs compare numerically, no\nlocale). Manifest order: the manifest, declared members, then the rest by path.\nLesson bodies are the readable text members of a lesson, joined; PDFs and images\nare never inlined. Members whose declared role is `answer_key` or `mixed` never\ncontribute text to a lesson body (they remain recorded evidence members); the\nproposal rationale only counts how many were withheld.\n\n## Readers\n\n`BundleReader { formatId, detect, prepare }` in a `BundleReaderRegistry`;\nbuilt-ins are `manifest-v1` and `plain-files`. SCORM and Common Cartridge are\n**not implemented** (their manifests describe launch resources and activity\ntrees that do not map to sections/lessons without design); a host can register\na reader ahead of the built-ins (`register(reader, { first: true })`).\n\n## Identity and replay\n\n`bundleIdentity` (`cbi1-...`) digests the sorted (path, sha256) pairs, so\nre-zipping, reordering and timestamps replay. One `CourseBundleImport` per\n(tenant, identity); a concurrent first import replays once on the unique index.\n\n## Models (closed, required tenant, no REST/CLI/MCP/AI, append-only)\n\n`CourseBundleImport` (`course_bundle_imports`: identity, format, reader,\nmanifest version, counts, actor) and `CourseBundleMember` (`course_bundle_members`:\nunique tenant/import/path; ordinal, sha256, size, media type, kind, status,\nreason, duplicate-of, role, section and lesson key). Hashes and placements only:\nbytes stay with the host's ingestion receipts. Rewrites and deletes throw.\nNormal manifest migrations create both tables.\n\n## Not in this release\n\n- Manifest version 2 (material roles with audience, lesson pages, course-level\n members, external video references) waits for the phase 1 role vocabulary\n (smrt#4614). The page-role vocabulary and the closed v1 schema are unchanged;\n see the hand-off note on smrt#4615.\n- Re-rendering a member whose first import recorded a converter failure (outcomes\n are append-only per bundle identity).\n- Ingestion's receipt contract is unchanged: there is no `application/zip` part\n type or `parentEvidenceId` in ingestion. The education side unpacks and the\n host receives members one by one, so archive-to-member lineage lives in\n `course_bundle_members` (and the host's receipts), not in ingestion evidence.\n Changing ingestion's published receipt is a separate design decision.\n- A manifest/extraction disagreement is not compared automatically.\n- SCORM / Common Cartridge readers; zip64; nested archives; encrypted zips.\n"
14784
15147
  },
14785
15148
  {
14786
15149
  "path": "agents/source-import.md",
@@ -14810,7 +15173,7 @@
14810
15173
  {
14811
15174
  "path": "agents/tutor-context.md",
14812
15175
  "module": "tutor-context",
14813
- "content": "# Tutor context and sessions\n\nNo AI call, prompt text or provider lives here. The host builds prompts from\n`TutorContext` and owns the model call and spend ceiling; this package records\nwhat was said.\n\n## TutorContextService.compose\n\n`compose({ ...LearnerRequest, audience: 'learner' | 'guardian', enrollmentId?,\nlessonId?, questionRevisionId? })` extends `QuestionResponseService`; every read uses\nthe service executor (the transaction's inside `withTransaction`). The host\nauthorizer is asked `education.tutor.read` with `own` and `relationshipTypeId`\nas for `LearnerService`. A lesson needs its enrollment and must be in the\nenrollment's effective set; a focus question must belong to that lesson\n(otherwise `LearnerAuthorizationError`). The guardian audience is refused for\nthe learner's own actor whatever the host answers.\n\nResult: learner (name, level), enrollment (status, pace; no notes), course,\nsection and lesson titles, learner-facing lesson body (withheld for\npersonal-use-copy), objectives, own progress status, the learner-visible\nquestion rendering (prompt, response type, choices, subpart prompts, points; no\nmedia or anchors), submission count with latest status only, and the newest\n**released** feedback text (guardian: newest guardian-confirmed revision), plus\n`answerKeyAvailable`, `truncated` and `contextHash`.\n\nLearner audience never carries answer keys, rubrics, key assets or their ids,\nstorage URIs, enrollment parent notes, grades, other learners or grading\ninternals: it is built from an allowlist and keys are read only to set the\nboolean. The guardian audience adds capped answer-key and rubric text, still no\nstorage locators or notes. Do not add fields without a sentinel test in\n`tutor-context.test.ts`.\n\nBounds (exported constants): body 8000, prompt/choice/subpart 2000, key 2000,\nfeedback 2000, titles 300, 30 questions (the focus question is kept), 12 choices\nand subparts, 10 objectives, 48000 serialized characters overall (trailing\nquestions drop first). Any cut sets `truncated`.\n\n## TutorSessionService\n\nAppend-only `TutorSession`, `TutorSessionClosure` and `TutorExchange` (new\ntables `tutor_sessions`, `tutor_session_closures`, `tutor_exchanges`;\ntenant-scoped, closed generated surfaces, update/delete rejected). A session is\nopen until its single closure row exists. Sessions store ids, audience, mode,\nactor, voice consent and the context digest, never context text.\n\n- `open` (`education.tutor.exchange`): `requestKey` idempotent per learner; a\n replay with a changed payload is `TutorRequestKeyConflictError`. Voice needs\n `voiceConsent: true` the host recorded (`TutorConsentRequiredError`). Guardian\n audience is refused for the learner's own actor.\n- `append` (`education.tutor.exchange`): role `learner|tutor|system`; `learner`\n only from the learner themself. Text 1 to 4000 characters else\n `TutorExchangeError`. Idempotent per session `requestKey`; sequence is\n server-assigned from 1; closed session is `TutorSessionClosedError`. Optional\n `marker` (`none|filtered|redacted|flagged`), `toolSummary`, token counts.\n- `close`: appends the closure; closing twice returns `changed: false`.\n- `listForLearner` (`education.tutor.read`): sessions newest first, `limit`\n (<=50) and `cursor`, per-session exchanges bounded by `exchangeLimit` (<=200)\n with `exchangesTruncated`; `sessionId` + `afterSequence` page one transcript.\n A guardian reads through the host's decision on the relationship.\n\nMissing tables raise `LearnerHistoryUnavailableError`. Concurrent appends rely on\nthe unique `(tenant, session, sequence)` index.\n"
15176
+ "content": "# Tutor context and sessions\n\nNo AI call, prompt text or provider lives here. The host builds prompts from\n`TutorContext` and owns the model call and spend ceiling; this package records\nwhat was said.\n\n## TutorContextService.compose\n\n`compose({ ...LearnerRequest, audience: 'learner' | 'guardian', enrollmentId?,\nlessonId?, questionRevisionId? })` extends `QuestionResponseService`; every read uses\nthe service executor (the transaction's inside `withTransaction`). The host\nauthorizer is asked `education.tutor.read` with `own` and `relationshipTypeId`\nas for `LearnerService`. A lesson needs its enrollment and must be in the\nenrollment's effective set; a focus question must belong to that lesson\n(otherwise `LearnerAuthorizationError`). The guardian audience is refused for\nthe learner's own actor whatever the host answers.\n\nResult: learner (name, level, `interestTheme`: the learner-safe label and key or\nnull, read-only; never the free `settings`), enrollment (status, pace; no notes), course,\nsection and lesson titles, learner-facing lesson body (withheld for\npersonal-use-copy), objectives, own progress status, the learner-visible\nquestion rendering (prompt, response type, choices, subpart prompts, points; no\nmedia or anchors), submission count with latest status only, and the newest\n**released** feedback text (guardian: newest guardian-confirmed revision), plus\n`answerKeyAvailable`, `truncated` and `contextHash`.\n\nLearner audience never carries answer keys, rubrics, key assets or their ids,\nstorage URIs, enrollment parent notes, grades, other learners or grading\ninternals: it is built from an allowlist and keys are read only to set the\nboolean. The guardian audience adds capped answer-key and rubric text, still no\nstorage locators or notes. Do not add fields without a sentinel test in\n`tutor-context.test.ts`.\n\nBounds (exported constants): body 8000, prompt/choice/subpart 2000, key 2000,\nfeedback 2000, titles 300, 30 questions (the focus question is kept), 12 choices\nand subparts, 10 objectives, 48000 serialized characters overall (trailing\nquestions drop first). Any cut sets `truncated`.\n\n## TutorSessionService\n\nAppend-only `TutorSession`, `TutorSessionClosure` and `TutorExchange` (new\ntables `tutor_sessions`, `tutor_session_closures`, `tutor_exchanges`;\ntenant-scoped, closed generated surfaces, update/delete rejected). A session is\nopen until its single closure row exists. Sessions store ids, audience, mode,\nactor, voice consent and the context digest, never context text.\n\n- `open` (`education.tutor.exchange`): `requestKey` idempotent per learner; a\n replay with a changed payload is `TutorRequestKeyConflictError`. Voice needs\n `voiceConsent: true` the host recorded (`TutorConsentRequiredError`). Guardian\n audience is refused for the learner's own actor.\n- `append` (`education.tutor.exchange`): role `learner|tutor|system`; `learner`\n only from the learner themself. Text 1 to 4000 characters else\n `TutorExchangeError`. Idempotent per session `requestKey`; sequence is\n server-assigned from 1; closed session is `TutorSessionClosedError`. Optional\n `marker` (`none|filtered|redacted|flagged`), `toolSummary`, token counts.\n- `close`: appends the closure; closing twice returns `changed: false`.\n- `listForLearner` (`education.tutor.read`): sessions newest first, `limit`\n (<=50) and `cursor`, per-session exchanges bounded by `exchangeLimit` (<=200)\n with `exchangesTruncated`; `sessionId` + `afterSequence` page one transcript.\n A guardian reads through the host's decision on the relationship.\n\nMissing tables raise `LearnerHistoryUnavailableError`. Concurrent appends rely on\nthe unique `(tenant, session, sequence)` index.\n"
14814
15177
  }
14815
15178
  ],
14816
15179
  "recipes": [