@happyvertical/smrt-education 0.57.46 → 0.57.47

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 (41) hide show
  1. package/AGENTS.md +5 -0
  2. package/README.md +7 -1
  3. package/agents/learner-service.md +31 -2
  4. package/agents/question-extraction.md +108 -0
  5. package/agents/submission-service.md +15 -0
  6. package/dist/chunks/{workbook-photo-service-DKgoUHeb.js → workbook-photo-service-B7Tujv__.js} +6494 -1153
  7. package/dist/chunks/workbook-photo-service-B7Tujv__.js.map +1 -0
  8. package/dist/collections.d.ts +4 -1
  9. package/dist/collections.d.ts.map +1 -1
  10. package/dist/course-authoring-contracts.d.ts +1 -1
  11. package/dist/course-authoring-contracts.d.ts.map +1 -1
  12. package/dist/index.d.ts +11 -5
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1049 -4628
  15. package/dist/index.js.map +1 -1
  16. package/dist/ingestion.d.ts +2 -0
  17. package/dist/ingestion.d.ts.map +1 -1
  18. package/dist/ingestion.js +197 -2
  19. package/dist/ingestion.js.map +1 -1
  20. package/dist/learner-history.d.ts +50 -0
  21. package/dist/learner-history.d.ts.map +1 -0
  22. package/dist/learner-service.d.ts +78 -2
  23. package/dist/learner-service.d.ts.map +1 -1
  24. package/dist/manifest.json +4255 -1444
  25. package/dist/question-extraction-contracts.d.ts +231 -0
  26. package/dist/question-extraction-contracts.d.ts.map +1 -0
  27. package/dist/question-extraction-ingestion.d.ts +44 -0
  28. package/dist/question-extraction-ingestion.d.ts.map +1 -0
  29. package/dist/question-extraction-models.d.ts +56 -0
  30. package/dist/question-extraction-models.d.ts.map +1 -0
  31. package/dist/question-extraction-service.d.ts +71 -0
  32. package/dist/question-extraction-service.d.ts.map +1 -0
  33. package/dist/question-extraction.d.ts +317 -0
  34. package/dist/question-extraction.d.ts.map +1 -0
  35. package/dist/smrt-knowledge.json +961 -143
  36. package/dist/submission-service.d.ts +34 -1
  37. package/dist/submission-service.d.ts.map +1 -1
  38. package/dist/submissions.d.ts +25 -1
  39. package/dist/submissions.d.ts.map +1 -1
  40. package/package.json +10 -10
  41. package/dist/chunks/workbook-photo-service-DKgoUHeb.js.map +0 -1
@@ -3,20 +3,21 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-education",
6
- "packageVersion": "0.57.46",
6
+ "packageVersion": "0.57.47",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "c5e585b8bbf4ba004fa381349a5bbd77cb834ad41b40ea452c188a319870c46a",
11
- "packageJson": "94831ed17f24489215be11ef71781fd0add57cbb2192d3658b8ae2e30c7c96bf",
12
- "agents": "57c43cd254f4d22602c8829fb01699a3cea56c9717ca0ef2a571c61bffac5507",
13
- "moduleDoc:agents/learner-service.md": "8ad25eb6afd23d7ba04ecccbed02b154be83c24d3a54bd172b19faae30601f98",
14
- "moduleDoc:agents/submission-service.md": "ae22d789d5e6a695787a2c64bf6720430c91196aab2bbc2892f831b3155bb1ce",
10
+ "manifest": "7a282ef48121d8974c20caf03da98bb6fe75a94b335a4457efc635c9d875a3e4",
11
+ "packageJson": "c62d4a8c38d361751bf6e154967bf8f6fef8541769ed181413ddc1c53ffe4b0c",
12
+ "agents": "19156c524f18604ae422a663606ecaff4c3c0d077fd88cce2dc47a3a43f98d1a",
13
+ "moduleDoc:agents/learner-service.md": "936d8bbe10f6be53353fc6a0e2b8c53b398f0810477147c82139309b62e8fe63",
14
+ "moduleDoc:agents/submission-service.md": "b5c1cb3863e06530c0b575ba5598c8a329c0e907ea42226267faa7317680eaa1",
15
15
  "moduleDoc:agents/workbook-service.md": "ae3a0f208b42766e417eac226e4a24ec15fe17c1ba9cde26a325ded982614278",
16
16
  "moduleDoc:agents/photo-intake.md": "3b7057201ee30b2361aad23ea52e8a55c8ed5354e7a7e4a8193b7de17ca2f0db",
17
17
  "moduleDoc:agents/course-authoring-service.md": "9ac4b8d85974e0dd3e43457fd18c4befd2c42c865bb26446cffdfe2712febab8",
18
18
  "moduleDoc:agents/course-generation-review.md": "579b960994e96a9c17a935ae18920a323e95dfbf968b25e6628f424e75ebab7f",
19
19
  "moduleDoc:agents/question-records.md": "335fe247f7294c6b891885d43d4eaccb20885bfb4a861cf66ae245e8be1fa781",
20
+ "moduleDoc:agents/question-extraction.md": "68ce7d627304fbb39e35f34db6e8caad9ca480147f7f1d8e74a5b82079ef648e",
20
21
  "moduleDoc:agents/source-import.md": "30ee823589d655442bcf1abcd86a2132172065b4bc5e3c4d89b1e8b7afcc66bc",
21
22
  "moduleDoc:agents/family-intake.md": "540b6d37f438496acb0208b6ac7258264e2142bce3d2f986967b7741606fe4f7",
22
23
  "moduleDoc:agents/catalog-previews.md": "5f4e0352274009b3ce00408017a16666036d24df695a7078c27317ba4e66f0a7",
@@ -2371,6 +2372,23 @@
2371
2372
  "tags": [],
2372
2373
  "risks": []
2373
2374
  },
2375
+ {
2376
+ "name": "LessonSelfAttestationCollection",
2377
+ "qualifiedName": "@happyvertical/smrt-education:LessonSelfAttestationCollection",
2378
+ "collection": "lessonselfattestations",
2379
+ "tableName": "lesson_self_attestations",
2380
+ "packageName": "@happyvertical/smrt-education",
2381
+ "extends": "SmrtCollection",
2382
+ "fields": [],
2383
+ "relationships": [],
2384
+ "methods": [],
2385
+ "surfaces": [],
2386
+ "relationshipFeatures": [
2387
+ "uuidColumns"
2388
+ ],
2389
+ "tags": [],
2390
+ "risks": []
2391
+ },
2374
2392
  {
2375
2393
  "name": "GradeCollection",
2376
2394
  "qualifiedName": "@happyvertical/smrt-education:GradeCollection",
@@ -7786,11 +7804,11 @@
7786
7804
  "risks": []
7787
7805
  },
7788
7806
  {
7789
- "name": "Learner",
7790
- "qualifiedName": "@happyvertical/smrt-education:Learner",
7791
- "collection": "learners",
7792
- "description": "A learner is a real user/profile identity with active family membership.",
7793
- "tableName": "learners",
7807
+ "name": "LessonProgressTransition",
7808
+ "qualifiedName": "@happyvertical/smrt-education:LessonProgressTransition",
7809
+ "collection": "lessonprogresstransitions",
7810
+ "description": "Append-only status history for one LessonProgress row. The progress row keeps one identity per enrollment and lesson, so earlier states live here.",
7811
+ "tableName": "lesson_progress_transitions",
7794
7812
  "packageName": "@happyvertical/smrt-education",
7795
7813
  "extends": "SmrtObject",
7796
7814
  "fields": [
@@ -7801,40 +7819,62 @@
7801
7819
  "columnType": "UUID"
7802
7820
  },
7803
7821
  {
7804
- "name": "userId",
7805
- "type": "crossPackageRef",
7822
+ "name": "progressId",
7823
+ "type": "foreignKey",
7806
7824
  "required": true,
7807
- "related": "@happyvertical/smrt-users:User",
7825
+ "related": "LessonProgress",
7808
7826
  "columnType": "UUID"
7809
7827
  },
7810
7828
  {
7811
- "name": "profileId",
7812
- "type": "crossPackageRef",
7829
+ "name": "enrollmentId",
7830
+ "type": "foreignKey",
7813
7831
  "required": true,
7814
- "related": "@happyvertical/smrt-profiles:Profile",
7832
+ "related": "Enrollment",
7815
7833
  "columnType": "UUID"
7816
7834
  },
7817
7835
  {
7818
- "name": "displayName",
7836
+ "name": "learnerId",
7837
+ "type": "foreignKey",
7838
+ "required": true,
7839
+ "related": "Learner",
7840
+ "columnType": "UUID"
7841
+ },
7842
+ {
7843
+ "name": "lessonId",
7844
+ "type": "foreignKey",
7845
+ "required": true,
7846
+ "related": "Lesson",
7847
+ "columnType": "UUID"
7848
+ },
7849
+ {
7850
+ "name": "fromStatus",
7819
7851
  "type": "text",
7820
7852
  "required": false,
7821
- "columnType": "TEXT"
7853
+ "columnType": "TEXT",
7854
+ "default": "not_started"
7822
7855
  },
7823
7856
  {
7824
- "name": "birthDate",
7825
- "type": "datetime",
7857
+ "name": "toStatus",
7858
+ "type": "text",
7826
7859
  "required": false,
7827
- "columnType": "TIMESTAMP"
7860
+ "columnType": "TEXT",
7861
+ "default": "not_started"
7828
7862
  },
7829
7863
  {
7830
- "name": "level",
7831
- "type": "integer",
7832
- "required": true,
7833
- "columnType": "INTEGER",
7834
- "default": 0
7864
+ "name": "kind",
7865
+ "type": "text",
7866
+ "required": false,
7867
+ "columnType": "TEXT",
7868
+ "default": "put-back"
7835
7869
  },
7836
7870
  {
7837
- "name": "settings",
7871
+ "name": "actorUserId",
7872
+ "type": "text",
7873
+ "required": false,
7874
+ "columnType": "TEXT"
7875
+ },
7876
+ {
7877
+ "name": "recordedAt",
7838
7878
  "type": "text",
7839
7879
  "required": false,
7840
7880
  "columnType": "TEXT"
@@ -7842,29 +7882,43 @@
7842
7882
  ],
7843
7883
  "relationships": [
7844
7884
  {
7845
- "name": "userId",
7846
- "type": "crossPackageRef",
7885
+ "name": "progressId",
7886
+ "type": "foreignKey",
7847
7887
  "required": true,
7848
- "related": "@happyvertical/smrt-users:User",
7888
+ "related": "LessonProgress",
7849
7889
  "columnType": "UUID"
7850
7890
  },
7851
7891
  {
7852
- "name": "profileId",
7853
- "type": "crossPackageRef",
7892
+ "name": "enrollmentId",
7893
+ "type": "foreignKey",
7854
7894
  "required": true,
7855
- "related": "@happyvertical/smrt-profiles:Profile",
7895
+ "related": "Enrollment",
7896
+ "columnType": "UUID"
7897
+ },
7898
+ {
7899
+ "name": "learnerId",
7900
+ "type": "foreignKey",
7901
+ "required": true,
7902
+ "related": "Learner",
7903
+ "columnType": "UUID"
7904
+ },
7905
+ {
7906
+ "name": "lessonId",
7907
+ "type": "foreignKey",
7908
+ "required": true,
7909
+ "related": "Lesson",
7856
7910
  "columnType": "UUID"
7857
7911
  }
7858
7912
  ],
7859
7913
  "methods": [
7860
- "getSettings",
7861
- "save",
7862
- "setSettings"
7914
+ "delete",
7915
+ "save"
7863
7916
  ],
7864
7917
  "methodSignatures": [
7865
7918
  {
7866
- "name": "getSettings",
7867
- "returns": "Record<string, unknown>"
7919
+ "name": "delete",
7920
+ "async": true,
7921
+ "returns": "Promise<void>"
7868
7922
  },
7869
7923
  {
7870
7924
  "name": "save",
@@ -7873,13 +7927,6 @@
7873
7927
  "options?: SmrtSaveOptions"
7874
7928
  ],
7875
7929
  "returns": "Promise<this>"
7876
- },
7877
- {
7878
- "name": "setSettings",
7879
- "params": [
7880
- "value: Record<string, unknown>"
7881
- ],
7882
- "returns": "void"
7883
7930
  }
7884
7931
  ],
7885
7932
  "tenant": {
@@ -7896,40 +7943,49 @@
7896
7943
  "withheldSurfaces": [
7897
7944
  {
7898
7945
  "kind": "api",
7899
- "operation": "getSettings",
7946
+ "operation": "delete",
7900
7947
  "code": "api-disabled",
7901
7948
  "reason": "api is disabled",
7902
- "objectName": "@happyvertical/smrt-education:Learner"
7949
+ "objectName": "@happyvertical/smrt-education:LessonProgressTransition"
7903
7950
  },
7904
7951
  {
7905
7952
  "kind": "api",
7906
7953
  "operation": "save",
7907
7954
  "code": "api-disabled",
7908
7955
  "reason": "api is disabled",
7909
- "objectName": "@happyvertical/smrt-education:Learner"
7910
- },
7911
- {
7912
- "kind": "api",
7913
- "operation": "setSettings",
7914
- "code": "api-disabled",
7915
- "reason": "api is disabled",
7916
- "objectName": "@happyvertical/smrt-education:Learner"
7956
+ "objectName": "@happyvertical/smrt-education:LessonProgressTransition"
7917
7957
  }
7918
7958
  ],
7919
7959
  "relationshipFeatures": [
7920
- "crossPackageRef",
7960
+ "foreignKey",
7921
7961
  "uuidColumns"
7922
7962
  ],
7923
7963
  "tags": [],
7924
7964
  "risks": []
7925
7965
  },
7926
7966
  {
7927
- "name": "Curriculum",
7928
- "qualifiedName": "@happyvertical/smrt-education:Curriculum",
7929
- "displayLabelField": "title",
7930
- "collection": "curriculums",
7931
- "description": "A distinct plan retains its own enrollment history.",
7932
- "tableName": "curricula",
7967
+ "name": "LessonProgressTransitionCollection",
7968
+ "qualifiedName": "@happyvertical/smrt-education:LessonProgressTransitionCollection",
7969
+ "collection": "lessonprogresstransitions",
7970
+ "tableName": "lesson_progress_transitions",
7971
+ "packageName": "@happyvertical/smrt-education",
7972
+ "extends": "SmrtCollection",
7973
+ "fields": [],
7974
+ "relationships": [],
7975
+ "methods": [],
7976
+ "surfaces": [],
7977
+ "relationshipFeatures": [
7978
+ "uuidColumns"
7979
+ ],
7980
+ "tags": [],
7981
+ "risks": []
7982
+ },
7983
+ {
7984
+ "name": "EnrollmentStatusChange",
7985
+ "qualifiedName": "@happyvertical/smrt-education:EnrollmentStatusChange",
7986
+ "collection": "enrollmentstatuschanges",
7987
+ "description": "Append-only audit of one enrollment status change made through LearnerService.setEnrollmentStatus: who changed it and the host policy setting relied on, in the same transaction as the change.",
7988
+ "tableName": "enrollment_status_changes",
7933
7989
  "packageName": "@happyvertical/smrt-education",
7934
7990
  "extends": "SmrtObject",
7935
7991
  "fields": [
@@ -7939,6 +7995,13 @@
7939
7995
  "required": true,
7940
7996
  "columnType": "UUID"
7941
7997
  },
7998
+ {
7999
+ "name": "enrollmentId",
8000
+ "type": "foreignKey",
8001
+ "required": true,
8002
+ "related": "Enrollment",
8003
+ "columnType": "UUID"
8004
+ },
7942
8005
  {
7943
8006
  "name": "learnerId",
7944
8007
  "type": "foreignKey",
@@ -7947,32 +8010,46 @@
7947
8010
  "columnType": "UUID"
7948
8011
  },
7949
8012
  {
7950
- "name": "title",
8013
+ "name": "fromStatus",
7951
8014
  "type": "text",
7952
- "required": true,
7953
- "columnType": "TEXT"
8015
+ "required": false,
8016
+ "columnType": "TEXT",
8017
+ "default": "planned"
7954
8018
  },
7955
8019
  {
7956
- "name": "startsOn",
7957
- "type": "datetime",
8020
+ "name": "toStatus",
8021
+ "type": "text",
7958
8022
  "required": false,
7959
- "columnType": "TIMESTAMP"
8023
+ "columnType": "TEXT",
8024
+ "default": "planned"
7960
8025
  },
7961
8026
  {
7962
- "name": "endsOn",
7963
- "type": "datetime",
8027
+ "name": "actorUserId",
8028
+ "type": "text",
7964
8029
  "required": false,
7965
- "columnType": "TIMESTAMP"
8030
+ "columnType": "TEXT"
7966
8031
  },
7967
8032
  {
7968
- "name": "status",
8033
+ "name": "policyBasis",
7969
8034
  "type": "text",
7970
8035
  "required": false,
7971
- "columnType": "TEXT",
7972
- "default": "planned"
8036
+ "columnType": "TEXT"
8037
+ },
8038
+ {
8039
+ "name": "recordedAt",
8040
+ "type": "text",
8041
+ "required": false,
8042
+ "columnType": "TEXT"
7973
8043
  }
7974
8044
  ],
7975
8045
  "relationships": [
8046
+ {
8047
+ "name": "enrollmentId",
8048
+ "type": "foreignKey",
8049
+ "required": true,
8050
+ "related": "Enrollment",
8051
+ "columnType": "UUID"
8052
+ },
7976
8053
  {
7977
8054
  "name": "learnerId",
7978
8055
  "type": "foreignKey",
@@ -7982,9 +8059,15 @@
7982
8059
  }
7983
8060
  ],
7984
8061
  "methods": [
8062
+ "delete",
7985
8063
  "save"
7986
8064
  ],
7987
8065
  "methodSignatures": [
8066
+ {
8067
+ "name": "delete",
8068
+ "async": true,
8069
+ "returns": "Promise<void>"
8070
+ },
7988
8071
  {
7989
8072
  "name": "save",
7990
8073
  "async": true,
@@ -8006,12 +8089,19 @@
8006
8089
  ],
8007
8090
  "surfaces": [],
8008
8091
  "withheldSurfaces": [
8092
+ {
8093
+ "kind": "api",
8094
+ "operation": "delete",
8095
+ "code": "api-disabled",
8096
+ "reason": "api is disabled",
8097
+ "objectName": "@happyvertical/smrt-education:EnrollmentStatusChange"
8098
+ },
8009
8099
  {
8010
8100
  "kind": "api",
8011
8101
  "operation": "save",
8012
8102
  "code": "api-disabled",
8013
8103
  "reason": "api is disabled",
8014
- "objectName": "@happyvertical/smrt-education:Curriculum"
8104
+ "objectName": "@happyvertical/smrt-education:EnrollmentStatusChange"
8015
8105
  }
8016
8106
  ],
8017
8107
  "relationshipFeatures": [
@@ -8022,11 +8112,28 @@
8022
8112
  "risks": []
8023
8113
  },
8024
8114
  {
8025
- "name": "Enrollment",
8026
- "qualifiedName": "@happyvertical/smrt-education:Enrollment",
8027
- "collection": "enrollments",
8028
- "description": "One course in one curriculum; a new curriculum means a new enrollment.",
8029
- "tableName": "enrollments",
8115
+ "name": "EnrollmentStatusChangeCollection",
8116
+ "qualifiedName": "@happyvertical/smrt-education:EnrollmentStatusChangeCollection",
8117
+ "collection": "enrollmentstatuschanges",
8118
+ "tableName": "enrollment_status_changes",
8119
+ "packageName": "@happyvertical/smrt-education",
8120
+ "extends": "SmrtCollection",
8121
+ "fields": [],
8122
+ "relationships": [],
8123
+ "methods": [],
8124
+ "surfaces": [],
8125
+ "relationshipFeatures": [
8126
+ "uuidColumns"
8127
+ ],
8128
+ "tags": [],
8129
+ "risks": []
8130
+ },
8131
+ {
8132
+ "name": "Learner",
8133
+ "qualifiedName": "@happyvertical/smrt-education:Learner",
8134
+ "collection": "learners",
8135
+ "description": "A learner is a real user/profile identity with active family membership.",
8136
+ "tableName": "learners",
8030
8137
  "packageName": "@happyvertical/smrt-education",
8031
8138
  "extends": "SmrtObject",
8032
8139
  "fields": [
@@ -8037,45 +8144,281 @@
8037
8144
  "columnType": "UUID"
8038
8145
  },
8039
8146
  {
8040
- "name": "curriculumId",
8041
- "type": "foreignKey",
8042
- "required": true,
8043
- "related": "Curriculum",
8044
- "columnType": "UUID"
8045
- },
8046
- {
8047
- "name": "learnerId",
8048
- "type": "foreignKey",
8147
+ "name": "userId",
8148
+ "type": "crossPackageRef",
8049
8149
  "required": true,
8050
- "related": "Learner",
8150
+ "related": "@happyvertical/smrt-users:User",
8051
8151
  "columnType": "UUID"
8052
8152
  },
8053
8153
  {
8054
- "name": "courseId",
8055
- "type": "foreignKey",
8154
+ "name": "profileId",
8155
+ "type": "crossPackageRef",
8056
8156
  "required": true,
8057
- "related": "Course",
8157
+ "related": "@happyvertical/smrt-profiles:Profile",
8058
8158
  "columnType": "UUID"
8059
8159
  },
8060
8160
  {
8061
- "name": "status",
8161
+ "name": "displayName",
8062
8162
  "type": "text",
8063
8163
  "required": false,
8064
- "columnType": "TEXT",
8065
- "default": "planned"
8164
+ "columnType": "TEXT"
8066
8165
  },
8067
8166
  {
8068
- "name": "startedAt",
8167
+ "name": "birthDate",
8069
8168
  "type": "datetime",
8070
8169
  "required": false,
8071
8170
  "columnType": "TIMESTAMP"
8072
8171
  },
8073
8172
  {
8074
- "name": "completedAt",
8075
- "type": "datetime",
8076
- "required": false,
8077
- "columnType": "TIMESTAMP"
8078
- },
8173
+ "name": "level",
8174
+ "type": "integer",
8175
+ "required": true,
8176
+ "columnType": "INTEGER",
8177
+ "default": 0
8178
+ },
8179
+ {
8180
+ "name": "settings",
8181
+ "type": "text",
8182
+ "required": false,
8183
+ "columnType": "TEXT"
8184
+ }
8185
+ ],
8186
+ "relationships": [
8187
+ {
8188
+ "name": "userId",
8189
+ "type": "crossPackageRef",
8190
+ "required": true,
8191
+ "related": "@happyvertical/smrt-users:User",
8192
+ "columnType": "UUID"
8193
+ },
8194
+ {
8195
+ "name": "profileId",
8196
+ "type": "crossPackageRef",
8197
+ "required": true,
8198
+ "related": "@happyvertical/smrt-profiles:Profile",
8199
+ "columnType": "UUID"
8200
+ }
8201
+ ],
8202
+ "methods": [
8203
+ "getSettings",
8204
+ "save",
8205
+ "setSettings"
8206
+ ],
8207
+ "methodSignatures": [
8208
+ {
8209
+ "name": "getSettings",
8210
+ "returns": "Record<string, unknown>"
8211
+ },
8212
+ {
8213
+ "name": "save",
8214
+ "async": true,
8215
+ "params": [
8216
+ "options?: SmrtSaveOptions"
8217
+ ],
8218
+ "returns": "Promise<this>"
8219
+ },
8220
+ {
8221
+ "name": "setSettings",
8222
+ "params": [
8223
+ "value: Record<string, unknown>"
8224
+ ],
8225
+ "returns": "void"
8226
+ }
8227
+ ],
8228
+ "tenant": {
8229
+ "scoped": true,
8230
+ "mode": "required",
8231
+ "field": "tenantId"
8232
+ },
8233
+ "conflictColumns": [
8234
+ "tenant_id",
8235
+ "slug",
8236
+ "context"
8237
+ ],
8238
+ "surfaces": [],
8239
+ "withheldSurfaces": [
8240
+ {
8241
+ "kind": "api",
8242
+ "operation": "getSettings",
8243
+ "code": "api-disabled",
8244
+ "reason": "api is disabled",
8245
+ "objectName": "@happyvertical/smrt-education:Learner"
8246
+ },
8247
+ {
8248
+ "kind": "api",
8249
+ "operation": "save",
8250
+ "code": "api-disabled",
8251
+ "reason": "api is disabled",
8252
+ "objectName": "@happyvertical/smrt-education:Learner"
8253
+ },
8254
+ {
8255
+ "kind": "api",
8256
+ "operation": "setSettings",
8257
+ "code": "api-disabled",
8258
+ "reason": "api is disabled",
8259
+ "objectName": "@happyvertical/smrt-education:Learner"
8260
+ }
8261
+ ],
8262
+ "relationshipFeatures": [
8263
+ "crossPackageRef",
8264
+ "uuidColumns"
8265
+ ],
8266
+ "tags": [],
8267
+ "risks": []
8268
+ },
8269
+ {
8270
+ "name": "Curriculum",
8271
+ "qualifiedName": "@happyvertical/smrt-education:Curriculum",
8272
+ "displayLabelField": "title",
8273
+ "collection": "curriculums",
8274
+ "description": "A distinct plan retains its own enrollment history.",
8275
+ "tableName": "curricula",
8276
+ "packageName": "@happyvertical/smrt-education",
8277
+ "extends": "SmrtObject",
8278
+ "fields": [
8279
+ {
8280
+ "name": "tenantId",
8281
+ "type": "text",
8282
+ "required": true,
8283
+ "columnType": "UUID"
8284
+ },
8285
+ {
8286
+ "name": "learnerId",
8287
+ "type": "foreignKey",
8288
+ "required": true,
8289
+ "related": "Learner",
8290
+ "columnType": "UUID"
8291
+ },
8292
+ {
8293
+ "name": "title",
8294
+ "type": "text",
8295
+ "required": true,
8296
+ "columnType": "TEXT"
8297
+ },
8298
+ {
8299
+ "name": "startsOn",
8300
+ "type": "datetime",
8301
+ "required": false,
8302
+ "columnType": "TIMESTAMP"
8303
+ },
8304
+ {
8305
+ "name": "endsOn",
8306
+ "type": "datetime",
8307
+ "required": false,
8308
+ "columnType": "TIMESTAMP"
8309
+ },
8310
+ {
8311
+ "name": "status",
8312
+ "type": "text",
8313
+ "required": false,
8314
+ "columnType": "TEXT",
8315
+ "default": "planned"
8316
+ }
8317
+ ],
8318
+ "relationships": [
8319
+ {
8320
+ "name": "learnerId",
8321
+ "type": "foreignKey",
8322
+ "required": true,
8323
+ "related": "Learner",
8324
+ "columnType": "UUID"
8325
+ }
8326
+ ],
8327
+ "methods": [
8328
+ "save"
8329
+ ],
8330
+ "methodSignatures": [
8331
+ {
8332
+ "name": "save",
8333
+ "async": true,
8334
+ "params": [
8335
+ "options?: SmrtSaveOptions"
8336
+ ],
8337
+ "returns": "Promise<this>"
8338
+ }
8339
+ ],
8340
+ "tenant": {
8341
+ "scoped": true,
8342
+ "mode": "required",
8343
+ "field": "tenantId"
8344
+ },
8345
+ "conflictColumns": [
8346
+ "tenant_id",
8347
+ "slug",
8348
+ "context"
8349
+ ],
8350
+ "surfaces": [],
8351
+ "withheldSurfaces": [
8352
+ {
8353
+ "kind": "api",
8354
+ "operation": "save",
8355
+ "code": "api-disabled",
8356
+ "reason": "api is disabled",
8357
+ "objectName": "@happyvertical/smrt-education:Curriculum"
8358
+ }
8359
+ ],
8360
+ "relationshipFeatures": [
8361
+ "foreignKey",
8362
+ "uuidColumns"
8363
+ ],
8364
+ "tags": [],
8365
+ "risks": []
8366
+ },
8367
+ {
8368
+ "name": "Enrollment",
8369
+ "qualifiedName": "@happyvertical/smrt-education:Enrollment",
8370
+ "collection": "enrollments",
8371
+ "description": "One course in one curriculum; a new curriculum means a new enrollment.",
8372
+ "tableName": "enrollments",
8373
+ "packageName": "@happyvertical/smrt-education",
8374
+ "extends": "SmrtObject",
8375
+ "fields": [
8376
+ {
8377
+ "name": "tenantId",
8378
+ "type": "text",
8379
+ "required": true,
8380
+ "columnType": "UUID"
8381
+ },
8382
+ {
8383
+ "name": "curriculumId",
8384
+ "type": "foreignKey",
8385
+ "required": true,
8386
+ "related": "Curriculum",
8387
+ "columnType": "UUID"
8388
+ },
8389
+ {
8390
+ "name": "learnerId",
8391
+ "type": "foreignKey",
8392
+ "required": true,
8393
+ "related": "Learner",
8394
+ "columnType": "UUID"
8395
+ },
8396
+ {
8397
+ "name": "courseId",
8398
+ "type": "foreignKey",
8399
+ "required": true,
8400
+ "related": "Course",
8401
+ "columnType": "UUID"
8402
+ },
8403
+ {
8404
+ "name": "status",
8405
+ "type": "text",
8406
+ "required": false,
8407
+ "columnType": "TEXT",
8408
+ "default": "planned"
8409
+ },
8410
+ {
8411
+ "name": "startedAt",
8412
+ "type": "datetime",
8413
+ "required": false,
8414
+ "columnType": "TIMESTAMP"
8415
+ },
8416
+ {
8417
+ "name": "completedAt",
8418
+ "type": "datetime",
8419
+ "required": false,
8420
+ "columnType": "TIMESTAMP"
8421
+ },
8079
8422
  {
8080
8423
  "name": "targetMinutesPerWeek",
8081
8424
  "type": "integer",
@@ -8287,75 +8630,379 @@
8287
8630
  }
8288
8631
  ],
8289
8632
  "relationshipFeatures": [
8290
- "foreignKey",
8633
+ "foreignKey",
8634
+ "uuidColumns"
8635
+ ],
8636
+ "tags": [],
8637
+ "risks": []
8638
+ },
8639
+ {
8640
+ "name": "LessonAsset",
8641
+ "qualifiedName": "@happyvertical/smrt-education:LessonAsset",
8642
+ "collection": "lessonassets",
8643
+ "tableName": "lesson_assets",
8644
+ "packageName": "@happyvertical/smrt-education",
8645
+ "extends": "SmrtObject",
8646
+ "fields": [
8647
+ {
8648
+ "name": "tenantId",
8649
+ "type": "text",
8650
+ "required": false,
8651
+ "columnType": "UUID"
8652
+ },
8653
+ {
8654
+ "name": "lessonId",
8655
+ "type": "foreignKey",
8656
+ "required": true,
8657
+ "related": "Lesson",
8658
+ "columnType": "UUID"
8659
+ },
8660
+ {
8661
+ "name": "assetId",
8662
+ "type": "crossPackageRef",
8663
+ "required": true,
8664
+ "related": "@happyvertical/smrt-assets:Asset",
8665
+ "columnType": "UUID"
8666
+ },
8667
+ {
8668
+ "name": "relationship",
8669
+ "type": "text",
8670
+ "required": true,
8671
+ "columnType": "TEXT",
8672
+ "default": "attachment"
8673
+ },
8674
+ {
8675
+ "name": "sortOrder",
8676
+ "type": "integer",
8677
+ "required": true,
8678
+ "columnType": "INTEGER",
8679
+ "default": 0
8680
+ }
8681
+ ],
8682
+ "relationships": [
8683
+ {
8684
+ "name": "lessonId",
8685
+ "type": "foreignKey",
8686
+ "required": true,
8687
+ "related": "Lesson",
8688
+ "columnType": "UUID"
8689
+ },
8690
+ {
8691
+ "name": "assetId",
8692
+ "type": "crossPackageRef",
8693
+ "required": true,
8694
+ "related": "@happyvertical/smrt-assets:Asset",
8695
+ "columnType": "UUID"
8696
+ }
8697
+ ],
8698
+ "methods": [
8699
+ "save"
8700
+ ],
8701
+ "methodSignatures": [
8702
+ {
8703
+ "name": "save",
8704
+ "async": true,
8705
+ "params": [
8706
+ "options?: SmrtSaveOptions"
8707
+ ],
8708
+ "returns": "Promise<this>"
8709
+ }
8710
+ ],
8711
+ "tenant": {
8712
+ "scoped": true,
8713
+ "mode": "optional",
8714
+ "field": "tenantId"
8715
+ },
8716
+ "conflictColumns": [
8717
+ "tenant_id",
8718
+ "lesson_id",
8719
+ "asset_id",
8720
+ "relationship"
8721
+ ],
8722
+ "surfaces": [],
8723
+ "withheldSurfaces": [
8724
+ {
8725
+ "kind": "api",
8726
+ "operation": "save",
8727
+ "code": "api-disabled",
8728
+ "reason": "api is disabled",
8729
+ "objectName": "@happyvertical/smrt-education:LessonAsset"
8730
+ }
8731
+ ],
8732
+ "relationshipFeatures": [
8733
+ "crossPackageRef",
8734
+ "foreignKey",
8735
+ "uuidColumns"
8736
+ ],
8737
+ "tags": [],
8738
+ "risks": []
8739
+ },
8740
+ {
8741
+ "name": "QuestionExtractionRecord",
8742
+ "qualifiedName": "@happyvertical/smrt-education:QuestionExtractionRecord",
8743
+ "collection": "questionextractionrecords",
8744
+ "description": "Append-only preserved question extraction: one row per tenant, intake item and ingestion analysis attempt.",
8745
+ "tableName": "question_extractions",
8746
+ "packageName": "@happyvertical/smrt-education",
8747
+ "extends": "SmrtObject",
8748
+ "fields": [
8749
+ {
8750
+ "name": "tenantId",
8751
+ "type": "text",
8752
+ "required": true,
8753
+ "columnType": "UUID"
8754
+ },
8755
+ {
8756
+ "name": "intakeItemId",
8757
+ "type": "text",
8758
+ "required": false,
8759
+ "columnType": "TEXT"
8760
+ },
8761
+ {
8762
+ "name": "analysisAttemptId",
8763
+ "type": "text",
8764
+ "required": false,
8765
+ "columnType": "TEXT"
8766
+ },
8767
+ {
8768
+ "name": "sourceIdentity",
8769
+ "type": "text",
8770
+ "required": false,
8771
+ "columnType": "TEXT"
8772
+ },
8773
+ {
8774
+ "name": "sourceKind",
8775
+ "type": "text",
8776
+ "required": false,
8777
+ "columnType": "TEXT",
8778
+ "default": "worksheet_scan"
8779
+ },
8780
+ {
8781
+ "name": "proposalDigest",
8782
+ "type": "text",
8783
+ "required": false,
8784
+ "columnType": "TEXT"
8785
+ },
8786
+ {
8787
+ "name": "inputDigest",
8788
+ "type": "text",
8789
+ "required": false,
8790
+ "columnType": "TEXT"
8791
+ },
8792
+ {
8793
+ "name": "extractorJson",
8794
+ "type": "text",
8795
+ "required": false,
8796
+ "columnType": "TEXT"
8797
+ },
8798
+ {
8799
+ "name": "extractionJson",
8800
+ "type": "text",
8801
+ "required": false,
8802
+ "columnType": "TEXT"
8803
+ },
8804
+ {
8805
+ "name": "matchJson",
8806
+ "type": "text",
8807
+ "required": false,
8808
+ "columnType": "TEXT"
8809
+ },
8810
+ {
8811
+ "name": "recordedByUserId",
8812
+ "type": "text",
8813
+ "required": false,
8814
+ "columnType": "TEXT"
8815
+ },
8816
+ {
8817
+ "name": "recordedAt",
8818
+ "type": "text",
8819
+ "required": false,
8820
+ "columnType": "TEXT"
8821
+ }
8822
+ ],
8823
+ "relationships": [],
8824
+ "methods": [
8825
+ "delete",
8826
+ "save"
8827
+ ],
8828
+ "methodSignatures": [
8829
+ {
8830
+ "name": "delete",
8831
+ "async": true,
8832
+ "returns": "Promise<void>"
8833
+ },
8834
+ {
8835
+ "name": "save",
8836
+ "async": true,
8837
+ "params": [
8838
+ "options?: SmrtSaveOptions"
8839
+ ],
8840
+ "returns": "Promise<this>"
8841
+ }
8842
+ ],
8843
+ "tenant": {
8844
+ "scoped": true,
8845
+ "mode": "required",
8846
+ "field": "tenantId"
8847
+ },
8848
+ "conflictColumns": [
8849
+ "tenant_id",
8850
+ "intake_item_id",
8851
+ "analysis_attempt_id"
8852
+ ],
8853
+ "surfaces": [],
8854
+ "withheldSurfaces": [
8855
+ {
8856
+ "kind": "api",
8857
+ "operation": "delete",
8858
+ "code": "api-disabled",
8859
+ "reason": "api is disabled",
8860
+ "objectName": "@happyvertical/smrt-education:QuestionExtractionRecord"
8861
+ },
8862
+ {
8863
+ "kind": "api",
8864
+ "operation": "save",
8865
+ "code": "api-disabled",
8866
+ "reason": "api is disabled",
8867
+ "objectName": "@happyvertical/smrt-education:QuestionExtractionRecord"
8868
+ }
8869
+ ],
8870
+ "relationshipFeatures": [
8871
+ "uuidColumns"
8872
+ ],
8873
+ "tags": [],
8874
+ "risks": []
8875
+ },
8876
+ {
8877
+ "name": "QuestionExtractionRecordCollection",
8878
+ "qualifiedName": "@happyvertical/smrt-education:QuestionExtractionRecordCollection",
8879
+ "collection": "questionextractionrecords",
8880
+ "tableName": "question_extractions",
8881
+ "packageName": "@happyvertical/smrt-education",
8882
+ "extends": "SmrtCollection",
8883
+ "fields": [],
8884
+ "relationships": [],
8885
+ "methods": [],
8886
+ "surfaces": [],
8887
+ "relationshipFeatures": [
8291
8888
  "uuidColumns"
8292
8889
  ],
8293
8890
  "tags": [],
8294
8891
  "risks": []
8295
8892
  },
8296
8893
  {
8297
- "name": "LessonAsset",
8298
- "qualifiedName": "@happyvertical/smrt-education:LessonAsset",
8299
- "collection": "lessonassets",
8300
- "tableName": "lesson_assets",
8894
+ "name": "ExtractedQuestionLink",
8895
+ "qualifiedName": "@happyvertical/smrt-education:ExtractedQuestionLink",
8896
+ "collection": "extractedquestionlinks",
8897
+ "description": "Append-only applied outcome of one extracted question: which question record it created or matched (or why none), with its source anchors and media refs.",
8898
+ "tableName": "extracted_question_links",
8301
8899
  "packageName": "@happyvertical/smrt-education",
8302
8900
  "extends": "SmrtObject",
8303
8901
  "fields": [
8304
8902
  {
8305
8903
  "name": "tenantId",
8306
8904
  "type": "text",
8307
- "required": false,
8905
+ "required": true,
8308
8906
  "columnType": "UUID"
8309
8907
  },
8310
8908
  {
8311
- "name": "lessonId",
8909
+ "name": "extractionId",
8312
8910
  "type": "foreignKey",
8313
8911
  "required": true,
8314
- "related": "Lesson",
8912
+ "related": "QuestionExtractionRecord",
8315
8913
  "columnType": "UUID"
8316
8914
  },
8317
8915
  {
8318
- "name": "assetId",
8319
- "type": "crossPackageRef",
8320
- "required": true,
8321
- "related": "@happyvertical/smrt-assets:Asset",
8322
- "columnType": "UUID"
8916
+ "name": "questionKey",
8917
+ "type": "text",
8918
+ "required": false,
8919
+ "columnType": "TEXT"
8323
8920
  },
8324
8921
  {
8325
- "name": "relationship",
8922
+ "name": "sourceIdentity",
8326
8923
  "type": "text",
8327
- "required": true,
8924
+ "required": false,
8925
+ "columnType": "TEXT"
8926
+ },
8927
+ {
8928
+ "name": "outcome",
8929
+ "type": "text",
8930
+ "required": false,
8328
8931
  "columnType": "TEXT",
8329
- "default": "attachment"
8932
+ "default": "needs_review"
8330
8933
  },
8331
8934
  {
8332
- "name": "sortOrder",
8333
- "type": "integer",
8334
- "required": true,
8335
- "columnType": "INTEGER",
8336
- "default": 0
8935
+ "name": "questionId",
8936
+ "type": "foreignKey",
8937
+ "required": false,
8938
+ "related": "Question",
8939
+ "columnType": "UUID"
8940
+ },
8941
+ {
8942
+ "name": "questionRevisionId",
8943
+ "type": "foreignKey",
8944
+ "required": false,
8945
+ "related": "QuestionRevision",
8946
+ "columnType": "UUID"
8947
+ },
8948
+ {
8949
+ "name": "anchorsJson",
8950
+ "type": "text",
8951
+ "required": false,
8952
+ "columnType": "TEXT"
8953
+ },
8954
+ {
8955
+ "name": "mediaJson",
8956
+ "type": "text",
8957
+ "required": false,
8958
+ "columnType": "TEXT"
8959
+ },
8960
+ {
8961
+ "name": "appliedByUserId",
8962
+ "type": "text",
8963
+ "required": false,
8964
+ "columnType": "TEXT"
8965
+ },
8966
+ {
8967
+ "name": "recordedAt",
8968
+ "type": "text",
8969
+ "required": false,
8970
+ "columnType": "TEXT"
8337
8971
  }
8338
8972
  ],
8339
8973
  "relationships": [
8340
8974
  {
8341
- "name": "lessonId",
8975
+ "name": "extractionId",
8342
8976
  "type": "foreignKey",
8343
8977
  "required": true,
8344
- "related": "Lesson",
8978
+ "related": "QuestionExtractionRecord",
8345
8979
  "columnType": "UUID"
8346
8980
  },
8347
8981
  {
8348
- "name": "assetId",
8349
- "type": "crossPackageRef",
8350
- "required": true,
8351
- "related": "@happyvertical/smrt-assets:Asset",
8982
+ "name": "questionId",
8983
+ "type": "foreignKey",
8984
+ "required": false,
8985
+ "related": "Question",
8986
+ "columnType": "UUID"
8987
+ },
8988
+ {
8989
+ "name": "questionRevisionId",
8990
+ "type": "foreignKey",
8991
+ "required": false,
8992
+ "related": "QuestionRevision",
8352
8993
  "columnType": "UUID"
8353
8994
  }
8354
8995
  ],
8355
8996
  "methods": [
8997
+ "delete",
8356
8998
  "save"
8357
8999
  ],
8358
9000
  "methodSignatures": [
9001
+ {
9002
+ "name": "delete",
9003
+ "async": true,
9004
+ "returns": "Promise<void>"
9005
+ },
8359
9006
  {
8360
9007
  "name": "save",
8361
9008
  "async": true,
@@ -8367,33 +9014,55 @@
8367
9014
  ],
8368
9015
  "tenant": {
8369
9016
  "scoped": true,
8370
- "mode": "optional",
9017
+ "mode": "required",
8371
9018
  "field": "tenantId"
8372
9019
  },
8373
9020
  "conflictColumns": [
8374
9021
  "tenant_id",
8375
- "lesson_id",
8376
- "asset_id",
8377
- "relationship"
9022
+ "extraction_id",
9023
+ "question_key"
8378
9024
  ],
8379
9025
  "surfaces": [],
8380
9026
  "withheldSurfaces": [
9027
+ {
9028
+ "kind": "api",
9029
+ "operation": "delete",
9030
+ "code": "api-disabled",
9031
+ "reason": "api is disabled",
9032
+ "objectName": "@happyvertical/smrt-education:ExtractedQuestionLink"
9033
+ },
8381
9034
  {
8382
9035
  "kind": "api",
8383
9036
  "operation": "save",
8384
9037
  "code": "api-disabled",
8385
9038
  "reason": "api is disabled",
8386
- "objectName": "@happyvertical/smrt-education:LessonAsset"
9039
+ "objectName": "@happyvertical/smrt-education:ExtractedQuestionLink"
8387
9040
  }
8388
9041
  ],
8389
9042
  "relationshipFeatures": [
8390
- "crossPackageRef",
8391
9043
  "foreignKey",
8392
9044
  "uuidColumns"
8393
9045
  ],
8394
9046
  "tags": [],
8395
9047
  "risks": []
8396
9048
  },
9049
+ {
9050
+ "name": "ExtractedQuestionLinkCollection",
9051
+ "qualifiedName": "@happyvertical/smrt-education:ExtractedQuestionLinkCollection",
9052
+ "collection": "extractedquestionlinks",
9053
+ "tableName": "extracted_question_links",
9054
+ "packageName": "@happyvertical/smrt-education",
9055
+ "extends": "SmrtCollection",
9056
+ "fields": [],
9057
+ "relationships": [],
9058
+ "methods": [],
9059
+ "surfaces": [],
9060
+ "relationshipFeatures": [
9061
+ "uuidColumns"
9062
+ ],
9063
+ "tags": [],
9064
+ "risks": []
9065
+ },
8397
9066
  {
8398
9067
  "name": "LearningObjective",
8399
9068
  "qualifiedName": "@happyvertical/smrt-education:LearningObjective",
@@ -10084,7 +10753,7 @@
10084
10753
  "name": "Attestation",
10085
10754
  "qualifiedName": "@happyvertical/smrt-education:Attestation",
10086
10755
  "collection": "submissions",
10087
- "description": "A guardian's statement that work was completed.",
10756
+ "description": "A statement that work was completed.",
10088
10757
  "tableName": "submissions",
10089
10758
  "packageName": "@happyvertical/smrt-education",
10090
10759
  "extends": "Submission",
@@ -10241,6 +10910,150 @@
10241
10910
  "tags": [],
10242
10911
  "risks": []
10243
10912
  },
10913
+ {
10914
+ "name": "LessonSelfAttestation",
10915
+ "qualifiedName": "@happyvertical/smrt-education:LessonSelfAttestation",
10916
+ "collection": "lessonselfattestations",
10917
+ "description": "Marks an Attestation as self-reported by the learner (Reading/Video \"I read it\" / \"I watched it\"), with the host policy setting relied on.",
10918
+ "tableName": "lesson_self_attestations",
10919
+ "packageName": "@happyvertical/smrt-education",
10920
+ "extends": "SmrtObject",
10921
+ "fields": [
10922
+ {
10923
+ "name": "tenantId",
10924
+ "type": "text",
10925
+ "required": true,
10926
+ "columnType": "UUID"
10927
+ },
10928
+ {
10929
+ "name": "submissionId",
10930
+ "type": "foreignKey",
10931
+ "required": true,
10932
+ "related": "Submission",
10933
+ "columnType": "UUID"
10934
+ },
10935
+ {
10936
+ "name": "progressId",
10937
+ "type": "foreignKey",
10938
+ "required": true,
10939
+ "related": "LessonProgress",
10940
+ "columnType": "UUID"
10941
+ },
10942
+ {
10943
+ "name": "learnerId",
10944
+ "type": "foreignKey",
10945
+ "required": true,
10946
+ "related": "Learner",
10947
+ "columnType": "UUID"
10948
+ },
10949
+ {
10950
+ "name": "lessonId",
10951
+ "type": "foreignKey",
10952
+ "required": true,
10953
+ "related": "Lesson",
10954
+ "columnType": "UUID"
10955
+ },
10956
+ {
10957
+ "name": "actorUserId",
10958
+ "type": "text",
10959
+ "required": false,
10960
+ "columnType": "TEXT"
10961
+ },
10962
+ {
10963
+ "name": "policyBasis",
10964
+ "type": "text",
10965
+ "required": false,
10966
+ "columnType": "TEXT"
10967
+ },
10968
+ {
10969
+ "name": "recordedAt",
10970
+ "type": "text",
10971
+ "required": false,
10972
+ "columnType": "TEXT"
10973
+ }
10974
+ ],
10975
+ "relationships": [
10976
+ {
10977
+ "name": "submissionId",
10978
+ "type": "foreignKey",
10979
+ "required": true,
10980
+ "related": "Submission",
10981
+ "columnType": "UUID"
10982
+ },
10983
+ {
10984
+ "name": "progressId",
10985
+ "type": "foreignKey",
10986
+ "required": true,
10987
+ "related": "LessonProgress",
10988
+ "columnType": "UUID"
10989
+ },
10990
+ {
10991
+ "name": "learnerId",
10992
+ "type": "foreignKey",
10993
+ "required": true,
10994
+ "related": "Learner",
10995
+ "columnType": "UUID"
10996
+ },
10997
+ {
10998
+ "name": "lessonId",
10999
+ "type": "foreignKey",
11000
+ "required": true,
11001
+ "related": "Lesson",
11002
+ "columnType": "UUID"
11003
+ }
11004
+ ],
11005
+ "methods": [
11006
+ "delete",
11007
+ "save"
11008
+ ],
11009
+ "methodSignatures": [
11010
+ {
11011
+ "name": "delete",
11012
+ "async": true,
11013
+ "returns": "Promise<void>"
11014
+ },
11015
+ {
11016
+ "name": "save",
11017
+ "async": true,
11018
+ "params": [
11019
+ "options?: SmrtSaveOptions"
11020
+ ],
11021
+ "returns": "Promise<this>"
11022
+ }
11023
+ ],
11024
+ "tenant": {
11025
+ "scoped": true,
11026
+ "mode": "required",
11027
+ "field": "tenantId"
11028
+ },
11029
+ "conflictColumns": [
11030
+ "tenant_id",
11031
+ "submission_id"
11032
+ ],
11033
+ "surfaces": [],
11034
+ "withheldSurfaces": [
11035
+ {
11036
+ "kind": "api",
11037
+ "operation": "delete",
11038
+ "code": "api-disabled",
11039
+ "reason": "api is disabled",
11040
+ "objectName": "@happyvertical/smrt-education:LessonSelfAttestation"
11041
+ },
11042
+ {
11043
+ "kind": "api",
11044
+ "operation": "save",
11045
+ "code": "api-disabled",
11046
+ "reason": "api is disabled",
11047
+ "objectName": "@happyvertical/smrt-education:LessonSelfAttestation"
11048
+ }
11049
+ ],
11050
+ "relationshipFeatures": [
11051
+ "foreignKey",
11052
+ "uuidColumns"
11053
+ ],
11054
+ "tags": [],
11055
+ "risks": []
11056
+ },
10244
11057
  {
10245
11058
  "name": "SubmissionAsset",
10246
11059
  "qualifiedName": "@happyvertical/smrt-education:SubmissionAsset",
@@ -12426,24 +13239,24 @@
12426
13239
  "surfaces": [],
12427
13240
  "prompts": [],
12428
13241
  "relationshipsV2": {
12429
- "foreignKeyFields": 154,
13242
+ "foreignKeyFields": 167,
12430
13243
  "crossPackageRefFields": 22,
12431
13244
  "junctionCollections": 2,
12432
13245
  "hierarchicalObjects": 0,
12433
13246
  "polymorphicAssociations": 0,
12434
- "uuidColumns": 650
13247
+ "uuidColumns": 696
12435
13248
  },
12436
- "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\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",
13249
+ "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\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",
12437
13250
  "moduleDocs": [
12438
13251
  {
12439
13252
  "path": "agents/learner-service.md",
12440
13253
  "module": "learner-service",
12441
- "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.\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,\ncompletion transitions 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"
13254
+ "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- `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"
12442
13255
  },
12443
13256
  {
12444
13257
  "path": "agents/submission-service.md",
12445
13258
  "module": "submission-service",
12446
- "content": "# Submission and paper workflow\n\nConstruct `SubmissionService(db, authorizeLearner, authorizeCatalog, {storage,\nstoreOptions?, signAsset?})` from trusted server configuration. It extends\n`LearnerService`: the same active actor/tenant, memberships, linked person,\ndirected guardian relationship and host permission checks apply. The host must\nallow `education.learner.submit`, `education.learner.grade` or\n`education.learner.packet` as appropriate. Grade and packet actions always reject\nthe learner's own identity, even if the host authorizer returns true.\n\nThe protected transaction/auth hooks are internal owning-service extension\npoints. They are not handler APIs. All public service results remain data-only.\nUse the callback's service in `withTransaction`; escaped services are closed.\nSubmission operations share its serial queue, poison-on-failure behavior and\nPostgreSQL enrollment lock. No runtime schema creation occurs.\n\n## Capture and grading\n\n`captureSubmission` takes the learner request, `enrollmentId`, `lessonId`, a\nnonempty stable `requestKey`, and `pages` containing `name`, `mimeType` and Buffer\n`data`. Each upload becomes a fresh Asset owned by the learner's profile and\nexact tenant, joined solely through `SubmissionAsset`. Up to 100 uploads of\n25 MiB each are accepted (JPEG, PNG, WebP, or PDF). It atomically creates the\nsubmission/asset metadata and moves only that enrollment's progress to\n`submitted`, incrementing attempts once. Caller status, score, grader, asset ID,\nand answer-key flags are never patches. Equal request-key/content retries return\nthe existing submission; altered retries fail. Submitted/review/complete/skipped\nprogress rejects new captures. Content includes ordered filenames, MIME types,\nbytes and notes. Hosts should impose tighter request/body limits as needed.\n\n`captureBlankReference` records a workbook-page scan as immutable\n`blank_reference` evidence without advancing completion or creating a grade.\nTrusted ingestion scopes each receipt to the exact `enrollmentId` and calls\n`assertLessonAssignment` before reading retained evidence; it proves the active\ntenant, actor, learner, enrollment, and lesson without creating progress. A\nstable `intakeItemId` is claimed once in the durable tenant/item binding table,\nthen bound to the tenant, learner, lesson, progress, kind and predecessor; an\naltered retry fails. The binding is database-unique across enrollments while\nordinary uploads remain unbound. `resubmitSubmission` requires the current completed-work predecessor and\ncreates a new immutable `completed_work` attempt. Old pages, grades and feedback\nremain history; stale predecessors cannot be graded or replaced. A correction may\nreopen submitted, review or complete progress, while an AI proposal remains a\nnon-authoritative Grade until guardian review.\n\n`reviewSubmission` accepts an existing authorized submission and optional score\nand feedback. `attestLesson` creates/reuses an Attestation for a matching progress\nitem without requiring uploads. Both require a verified guardian and create a\nnew guardian Grade and set submission to `graded`. Ordinary non-workbook progress\nbecomes `complete`. Workbook-backed progress instead uses its explicit parent\ncompletion decision; grading alone leaves it in progress, including through a\nplain SubmissionService instance. Existing explicit completion on the current\nattempt is retained. The owning workbook join determines this policy, never a\ncaller flag.\nOmitted/null score is unscored; zero is a real score. The latest guardian grade\nwins over auto/AI/instructor proposals, and its score replaces progress's score.\nReview history is retained. ExternalCompletion is available for trusted server\npersistence; phase-zero learner handlers cannot forge reported scores. Online\nsubmissions and automatic grading are not implemented.\n\n`getSubmission` returns only submission metadata, page asset IDs and the selected\ngrade. It does not include catalog answers, extracted answers, storage paths,\nmodel objects, or database handles.\n\n`getLatestLessonSubmission` takes the learner request, `enrollmentId` and\n`lessonId`, repeats live read authorization and exact enrollment/catalog lesson\nscope, and returns the same safe view or `null` when no eligible work exists.\nIt reads at most one submission for that exact progress, choosing `completed_work`\nin `captured`, `needs_review` or `graded` status by `submittedAt DESC, id DESC`.\nBlank references, extracting and rejected work are excluded. Committed capture\nis retained evidence; it does not assert lesson completion. Hosts select completed\nlessons from authorized progress separately. An empty safe page list does not\nfall back to older work; asset signing still requires its own live authorization.\n\n## Storage and signed routes\n\nThe service creates an AssetRuntime bound to the operation's transaction. It\nrequires a resolved URI whose filename retains the fresh asset UUID and MIME\nextension; custom resolvers may choose its provider/directory but must route\nthat URI to the corresponding unique file. Shared existing locators are\nrejected before preservation or cleanup registration. It plans and persists\nthat unique asset URI before writing, registering that new\nasset for cleanup before the provider can partially write. After rollback,\nfiles are removed through the runtime store. Before deletion, the root database\nmust confirm that no asset owner committed: uncertain commit/read failures never\ncause committed bytes to be removed. Only this operation's newly created assets\nare candidates; pre-existing files are never adopted into its cleanup ledger.\n\nDatabase metadata is atomic; external storage uses compensation, not a database\ntransaction. If cleanup cannot complete, `SubmissionStorageCleanupError` contains\nfresh asset IDs and the original cause. Operators must reconcile those IDs in\nthe configured `education-page/<id>.<extension>` storage namespace (custom\nresolvers must retain their routing audit). A successful retry uses fresh IDs\nand cannot delete or reuse orphaned bytes. Process termination may also leave\nunreferenced files; hosts need storage orphan reconciliation. Do not suppress\ncleanup failures or claim crash-safe distributed atomicity.\n\n`signSubmissionAsset` verifies the submission/enrollment/learner ownership and\nits noun join, then verifies the Asset's tenant and learner profile before\ncalling the configured trusted signer. Claims contain actor, tenant, learner,\nenrollment, submission, asset and a five-minute expiry; no static source URI is\nreturned. The host signs every claim and its serving endpoint must verify the\nsignature, expiry, current actor/tenant and object authorization before serving.\nA bearer URL must never become a cross-family or sibling access bypass.\n\n`signLessonPrintable` signs only the exact authorized `printable` LessonAsset\njoin for an enrolled lesson. It never accepts an answer-key join and carries the\nsame tenant, actor, learner and enrollment scope as a submission claim.\n\n## Personal worksheet packets\n\n`splitWorksheetPacket` takes the guardian learner request, exact-owned\n`personal-use-copy` `courseId`, PDF bytes and ranges `{lessonId, firstPage,\nlastPage}`. Page bounds are inclusive and one-based. Ranges must be in bounds,\ndisjoint and target distinct Worksheet lessons in that private course. The\n50 MiB / 100-range limits are enforced before PDF parsing. `pdf-lib` extracts\nactual pages into independent PDFs and records private printable LessonAssets.\nAll resulting metadata commits together; storage failure compensates new PDFs.\nThe source packet is not retained. No shared/global course is accepted and no\nanswer-key join is created. A successful repeated packet upload intentionally\nadds another printable version; a failed upload can be retried safely.\n\nShared integration workflows run on SQLite and real PostgreSQL. Full DuckDB\nworkflow support remains outside the existing supported schema boundary.\n\n## Private asset boundary\n\nCaptured pages and split packet pages use `Asset.accessMode: 'private'` from their\ninitial insert. Ordinary generated Asset REST/MCP operations cannot list, hydrate,\nupdate or delete them, including through the parent STI model. The service opens\nan exact tenant/actor/learner-owner/asset-ID capability only after its normal learner\nrelationship checks. A host's signed serving route must repeat authorization and\nopen that exact capability before loading the asset. See the owning\n[private asset contract](../../assets/agents/private-assets.md).\n\nStorage must be private (including provider ACLs). Public HTTP(S) source locators\nare rejected; a hidden URL alone cannot protect publicly accessible bytes. Apply\nthe manifest migration adding `assets.access_mode` before deploying this runtime;\nlegacy ordinary assets retain the public default. Runtime creates no schema.\n\nPrivate lesson files must already be private Assets in the same tenant. Saving\nan answer-key join, or any join under a personal-use-copy course, requires the\nhost's exact actor/owner/asset capability; a public asset is never automatically\npromoted. Packet splitting retains this capability while creating each join.\nPersisted course tenant/license and section/lesson parent identity cannot change;\ncreate new structure and explicitly re-author its joins instead. Mandatory\nwrite predicates enforce this even for natural-key adoption and concurrent writes.\nOrdinary content and metadata refresh remains supported. This\nprevents existing public material being reclassified by moving its parent.\n\nFor pre-existing invalid joins, an authorized host must inventory private-material\njoins and remove invalid associations, recreate the bytes in private storage as a\nnew privately classified Asset, then re-author the join under its exact capability.\nDo not merely hide the URI, mutate access_mode with raw SQL, or delete shared\npublic bytes. This repair requires explicit storage/ownership authorization;\nnormal runtime does not migrate existing records.\n"
13259
+ "content": "# Submission and paper workflow\n\nConstruct `SubmissionService(db, authorizeLearner, authorizeCatalog, {storage,\nstoreOptions?, signAsset?})` from trusted server configuration. It extends\n`LearnerService`: the same active actor/tenant, memberships, linked person,\ndirected guardian relationship and host permission checks apply. The host must\nallow `education.learner.submit`, `education.learner.grade` or\n`education.learner.packet` as appropriate. Grade and packet actions always reject\nthe learner's own identity, even if the host authorizer returns true.\n\nThe protected transaction/auth hooks are internal owning-service extension\npoints. They are not handler APIs. All public service results remain data-only.\nUse the callback's service in `withTransaction`; escaped services are closed.\nSubmission operations share its serial queue, poison-on-failure behavior and\nPostgreSQL enrollment lock. No runtime schema creation occurs.\n\n## Capture and grading\n\n`captureSubmission` takes the learner request, `enrollmentId`, `lessonId`, a\nnonempty stable `requestKey`, and `pages` containing `name`, `mimeType` and Buffer\n`data`. Each upload becomes a fresh Asset owned by the learner's profile and\nexact tenant, joined solely through `SubmissionAsset`. Up to 100 uploads of\n25 MiB each are accepted (JPEG, PNG, WebP, or PDF). It atomically creates the\nsubmission/asset metadata and moves only that enrollment's progress to\n`submitted`, incrementing attempts once. Caller status, score, grader, asset ID,\nand answer-key flags are never patches. Equal request-key/content retries return\nthe existing submission; altered retries fail. Submitted/review/complete/skipped\nprogress rejects new captures. Content includes ordered filenames, MIME types,\nbytes and notes. Hosts should impose tighter request/body limits as needed.\n\n`captureBlankReference` records a workbook-page scan as immutable\n`blank_reference` evidence without advancing completion or creating a grade.\nTrusted ingestion scopes each receipt to the exact `enrollmentId` and calls\n`assertLessonAssignment` before reading retained evidence; it proves the active\ntenant, actor, learner, enrollment, and lesson without creating progress. A\nstable `intakeItemId` is claimed once in the durable tenant/item binding table,\nthen bound to the tenant, learner, lesson, progress, kind and predecessor; an\naltered retry fails. The binding is database-unique across enrollments while\nordinary uploads remain unbound. `resubmitSubmission` requires the current completed-work predecessor and\ncreates a new immutable `completed_work` attempt. Old pages, grades and feedback\nremain history; stale predecessors cannot be graded or replaced. A correction may\nreopen submitted, review or complete progress, while an AI proposal remains a\nnon-authoritative Grade until guardian review.\n\n`reviewSubmission` accepts an existing authorized submission and optional score\nand feedback. `attestLesson` creates/reuses an Attestation for a matching progress\nitem without requiring uploads. Both require a verified guardian and create a\nnew guardian Grade and set submission to `graded`. Ordinary non-workbook progress\nbecomes `complete`. Workbook-backed progress instead uses its explicit parent\ncompletion decision; grading alone leaves it in progress, including through a\nplain SubmissionService instance. Existing explicit completion on the current\nattempt is retained. The owning workbook join determines this policy, never a\ncaller flag.\n`attestOwnLesson({ enrollmentId, lessonId, policyBasis, notes? })` is the\nlearner's own \"I read it\" / \"I watched it\" for `SELF_ATTESTABLE_LESSON_KINDS`\n(Reading, Video) under `education.learner.attest-own`. Any other actor is\nrefused before the host authorizer; the host decides from its own setting\n(passed as `policyBasis`, 1-200 characters) whether the learner may. It writes\nan ungraded `Attestation` and an insert-only `LessonSelfAttestation` (learner\nactor, basis), moves progress to `submitted` with one attempt, and creates no\nGrade: it is not a grade and never completes the lesson. A repeat returns the\nsame attestation. Other kinds throw `OwnAttestationLessonKindError`;\nsubmitted, review, complete, skipped or already-attested progress throws\n`OwnAttestationConflictError`. Submission views carry `selfReported: true` for\nit; a guardian confirms with `attestLesson` (which grades that attestation) or\n`reviewSubmission`, and the guardian grade then distinguishes confirmed from\nself-reported evidence. A host without `lesson_self_attestations` gets\n`LearnerHistoryUnavailableError`.\nOmitted/null score is unscored; zero is a real score. The latest guardian grade\nwins over auto/AI/instructor proposals, and its score replaces progress's score.\nReview history is retained. ExternalCompletion is available for trusted server\npersistence; phase-zero learner handlers cannot forge reported scores. Online\nsubmissions and automatic grading are not implemented.\n\n`getSubmission` returns only submission metadata, page asset IDs and the selected\ngrade. It does not include catalog answers, extracted answers, storage paths,\nmodel objects, or database handles.\n\n`getLatestLessonSubmission` takes the learner request, `enrollmentId` and\n`lessonId`, repeats live read authorization and exact enrollment/catalog lesson\nscope, and returns the same safe view or `null` when no eligible work exists.\nIt reads at most one submission for that exact progress, choosing `completed_work`\nin `captured`, `needs_review` or `graded` status by `submittedAt DESC, id DESC`.\nBlank references, extracting and rejected work are excluded. Committed capture\nis retained evidence; it does not assert lesson completion. Hosts select completed\nlessons from authorized progress separately. An empty safe page list does not\nfall back to older work; asset signing still requires its own live authorization.\n\n## Storage and signed routes\n\nThe service creates an AssetRuntime bound to the operation's transaction. It\nrequires a resolved URI whose filename retains the fresh asset UUID and MIME\nextension; custom resolvers may choose its provider/directory but must route\nthat URI to the corresponding unique file. Shared existing locators are\nrejected before preservation or cleanup registration. It plans and persists\nthat unique asset URI before writing, registering that new\nasset for cleanup before the provider can partially write. After rollback,\nfiles are removed through the runtime store. Before deletion, the root database\nmust confirm that no asset owner committed: uncertain commit/read failures never\ncause committed bytes to be removed. Only this operation's newly created assets\nare candidates; pre-existing files are never adopted into its cleanup ledger.\n\nDatabase metadata is atomic; external storage uses compensation, not a database\ntransaction. If cleanup cannot complete, `SubmissionStorageCleanupError` contains\nfresh asset IDs and the original cause. Operators must reconcile those IDs in\nthe configured `education-page/<id>.<extension>` storage namespace (custom\nresolvers must retain their routing audit). A successful retry uses fresh IDs\nand cannot delete or reuse orphaned bytes. Process termination may also leave\nunreferenced files; hosts need storage orphan reconciliation. Do not suppress\ncleanup failures or claim crash-safe distributed atomicity.\n\n`signSubmissionAsset` verifies the submission/enrollment/learner ownership and\nits noun join, then verifies the Asset's tenant and learner profile before\ncalling the configured trusted signer. Claims contain actor, tenant, learner,\nenrollment, submission, asset and a five-minute expiry; no static source URI is\nreturned. The host signs every claim and its serving endpoint must verify the\nsignature, expiry, current actor/tenant and object authorization before serving.\nA bearer URL must never become a cross-family or sibling access bypass.\n\n`signLessonPrintable` signs only the exact authorized `printable` LessonAsset\njoin for an enrolled lesson. It never accepts an answer-key join and carries the\nsame tenant, actor, learner and enrollment scope as a submission claim.\n\n## Personal worksheet packets\n\n`splitWorksheetPacket` takes the guardian learner request, exact-owned\n`personal-use-copy` `courseId`, PDF bytes and ranges `{lessonId, firstPage,\nlastPage}`. Page bounds are inclusive and one-based. Ranges must be in bounds,\ndisjoint and target distinct Worksheet lessons in that private course. The\n50 MiB / 100-range limits are enforced before PDF parsing. `pdf-lib` extracts\nactual pages into independent PDFs and records private printable LessonAssets.\nAll resulting metadata commits together; storage failure compensates new PDFs.\nThe source packet is not retained. No shared/global course is accepted and no\nanswer-key join is created. A successful repeated packet upload intentionally\nadds another printable version; a failed upload can be retried safely.\n\nShared integration workflows run on SQLite and real PostgreSQL. Full DuckDB\nworkflow support remains outside the existing supported schema boundary.\n\n## Private asset boundary\n\nCaptured pages and split packet pages use `Asset.accessMode: 'private'` from their\ninitial insert. Ordinary generated Asset REST/MCP operations cannot list, hydrate,\nupdate or delete them, including through the parent STI model. The service opens\nan exact tenant/actor/learner-owner/asset-ID capability only after its normal learner\nrelationship checks. A host's signed serving route must repeat authorization and\nopen that exact capability before loading the asset. See the owning\n[private asset contract](../../assets/agents/private-assets.md).\n\nStorage must be private (including provider ACLs). Public HTTP(S) source locators\nare rejected; a hidden URL alone cannot protect publicly accessible bytes. Apply\nthe manifest migration adding `assets.access_mode` before deploying this runtime;\nlegacy ordinary assets retain the public default. Runtime creates no schema.\n\nPrivate lesson files must already be private Assets in the same tenant. Saving\nan answer-key join, or any join under a personal-use-copy course, requires the\nhost's exact actor/owner/asset capability; a public asset is never automatically\npromoted. Packet splitting retains this capability while creating each join.\nPersisted course tenant/license and section/lesson parent identity cannot change;\ncreate new structure and explicitly re-author its joins instead. Mandatory\nwrite predicates enforce this even for natural-key adoption and concurrent writes.\nOrdinary content and metadata refresh remains supported. This\nprevents existing public material being reclassified by moving its parent.\n\nFor pre-existing invalid joins, an authorized host must inventory private-material\njoins and remove invalid associations, recreate the bytes in private storage as a\nnew privately classified Asset, then re-author the join under its exact capability.\nDo not merely hide the URI, mutate access_mode with raw SQL, or delete shared\npublic bytes. This repair requires explicit storage/ownership authorization;\nnormal runtime does not migrate existing records.\n"
12447
13260
  },
12448
13261
  {
12449
13262
  "path": "agents/workbook-service.md",
@@ -12470,6 +13283,11 @@
12470
13283
  "module": "question-records",
12471
13284
  "content": "# Question and learning-objective records\n\n`QuestionAuthoringService` (SMRT #4515, extends `CourseAuthoringService`, consumed\nby Hearth #68) keeps authored questions and learning objectives. Risk is high:\nschema and public contract. It reuses the course authorizer: writes need\n`education.course.author` (plus `generate` for generated provenance), reads that\nreturn answer keys need `education.course.read-draft`; every call receives the\ntransaction executor and the operation (`create-question`, `save-question`,\n`link-objectives`, `create-objective`, `save-objective`, `read-question`,\n`read-worksheet`, `resolve-question-revisions`, `attach-printable`). Tenant is required; foreign ids are not-found.\n\n## Models (closed, required tenant, no REST/CLI/MCP/AI, append-only)\n\n`Question` (identity, optional authored `courseId`), `QuestionRevision`,\n`LearningObjective` (identity with tenant-unique stable `externalKey`, for\ncoordination with hearthed.org#35), `LearningObjectiveRevision`,\n`QuestionResponse`. `save()` on an existing row and `delete()` throw. There is no\nhead row: the head is the highest `sequence`, and writes take an expected-head\ntoken under a row lock and a unique `(identity, sequence)` index.\n\nA revision holds prompt, response type, choices, subparts, media (tenant assets),\nobjective identity ids, concept ids, answer key, rubric and points. A missing key\nor rubric is stored NULL and read back `null`; an empty key is invalid and is never\ninferred. Provenance (`supplied|inferred|generated|edited|extracted`) is recorded\nfor each present field, defaulting to the revision's; provenance for an absent\nfield is refused. Objectives are linked by identity id, so wording changes, variants\nand themes keep the same objective.\n\n## Writes\n\n`createQuestion`, `saveQuestionRevision`, `linkObjectives`,\n`createLearningObjective`, `saveLearningObjectiveRevision`. Every write needs a\nrequest key; a retry replays its original result and the same key with another\npayload throws `CourseRequestKeyConflictError`. A stale head throws\n`CourseStaleHeadError`; a reused objective key throws\n`LearningObjectiveKeyConflictError`. Errors keep stable `code`s.\n\n`resolveQuestionRevisions({ revisionIds })` (`read-draft`, no course; the executor and delegation fields\nare passed) returns `{ items, missing }`: per found id `revisionId`, `questionId`, `sequence`,\n`responseType`, `promptSummary` (plain text, whitespace collapsed, at most 200 characters) and, when\nlinked, `learningObjectiveKeys`. Never keys, rubrics, choices, media or provenance. 1-200 ids (else\nvalidation error), de-duplicated, request order. An id with no revision in this tenant, foreign or\nunknown alike, appears only in `missing`.\n\n## Lessons, attempts, grades\n\nA lesson input may carry ordered `questions` (revision id, section, column, source\npage/rect anchor) and flat `layoutParams`; they are part of the lesson revision\ndigest, and saving requires each id to be an existing question revision of the\ntenant (`QuestionReferenceError`). Newer question revisions never move a pin.\n\n`WorkbookQuestion.questionRevisionId`/`lessonRevisionId` and the nullable\n`Grade.questionRevisionId` are additive. A referenced revision must exist in the\ntenant; `Grade.rubricResults` must then fit its rubric (criterion id, number or\n`{points}` up to `maxPoints`; empty stays unscored; results need a rubric).\nUnreferenced legacy records are unchanged. `QuestionResponse` is an append-only\nlearner response to one exact revision with completion mode\n(`printable|online|interactive`), assistance, a correction chain, `episodeId` and\nthe `worksheetVersionId` seen, a numbered `attemptNumber` and the writing request's\n`payloadDigest`. Writes go through `QuestionResponseService` below.\n\n## Responses (`QuestionResponseService`)\n\nExtends `LearnerService`, so it uses the learner authorizer, active-actor/tenant\nchecks and executor-bound policy reads. New authorizer actions:\n`education.learner.respond` (the learner's own enrollment; a guardian never starts\nan attempt) and `education.learner.review-response` (guardian only; a learner\nclaiming it is refused before the host is asked).\n\n- `recordResponse`: online attempt (new episode, next `attemptNumber`) or, with\n `correctsResponseId`, the learner's correction in the same episode.\n- `recordGuardianCorrection`: names the response it corrects; `response` omitted\n records assistance only and keeps the answer. Both append; nothing is rewritten.\n- `readLessonResponses`: the audience follows the actor. The learner gets the\n keyless bundle (keys never leave the database call), the guardian the keyed one.\n\nThe question revision must be in the enrollment's effective lesson set\n(`resolveEnrollmentLessons`, then the lesson revision's `questions`); otherwise\n`QuestionResponseScopeError`. Unpinned enrollments have no authored questions.\nThe response is a closed shape (`text`, `numeric`, `choiceIds`, `parts`) checked\nagainst the revision's choices and subparts (`QuestionResponseValidationError`).\nEvery write needs a request key (unique per learner); a replay returns the stored\nrow, another payload throws `CourseRequestKeyConflictError`. Other errors:\n`QuestionResponseNotFoundError`, `LearnerAuthorizationError`.\n\n## Rendering\n\n`getWorksheet` (or the pure `buildLearnerWorksheet`/`buildGuardianWorksheet`)\nbuilds one bundle from a release lesson's pinned revisions. `worksheetVersionId` is\na digest of ordered revision ids, placement and layout params, so printable and\nonline renders of the same revision match and a scan can be matched later. The\nlearner bundle is built from an allowlist and has no key, rubric or provenance\ntype members; only the guardian bundle carries them. Both service audiences need\n`read-draft`; learner delivery belongs to the host.\n\n## Generated printables (`attachGeneratedPrintable`)\n\nHosts render worksheets from `QuestionRevision` records but cannot write `LessonAsset`\nrows. `attachGeneratedPrintable({ ...actor, lessonId, worksheetVersionId, audience })`\n(`audience`: `learner` or `guardian`) renders the released lesson's worksheet to a PDF\nupstream with `pdf-lib` (no host PDF dependency), stores it as a private education-owned\nasset and links it to the lesson, so the durable print queue can serve it. It returns the\nclosed `{ lessonId, worksheetVersionId, audience, assetId, relationship, created }`; never keys,\nanswers or source URIs.\n\n- `lessonId` is the materialised `lessons` row id of a released lesson (`released_lessons.lesson_id`),\n resolved in the actor's tenant; unknown and foreign ids alike are `QuestionNotFoundError`.\n `worksheetVersionId` must equal the digest of that lesson revision's refs and layout, else\n `WorksheetVersionMismatchError` (`worksheet-version-mismatch`), so a later scan matches. A lesson with no\n questions is a validation error.\n- Relationship rules: the learner copy is built from the keyless learner bundle only and linked\n `printable`; the guardian copy is built from the keyed bundle (answer keys, explanations, rubric summaries)\n and linked `answer-key`. `SubmissionService.signLessonPrintable` matches only `printable` joins\n (explicit filter, test-pinned), so an answer-key copy is never signed for a learner.\n- Authorization: `education.course.author` for either audience, plus `education.course.read-draft`\n for `guardian` (the audience only picks the artefact and never widens authority). A learner of the\n tenant, or a delegation naming one as actor or delegating guardian, is refused with\n `CourseAuthoringAuthorizationError` before the host authorizer is asked. Delegated agents use\n `verifyDelegation` like every authoring call (refused without it).\n- Idempotent per (tenant, lesson, worksheet version, audience) via the append-only closed\n `GeneratedPrintable` model (unique index). A repeat returns the same asset with `created: false` and\n renders and stores nothing. The lesson row is locked before the check; a lost unique-index race is\n compensated and replayed once, so concurrent first calls converge on one asset with no orphan file.\n- Storage: pass `printableAssets: { storage, storeOptions? }` (same shape as `SubmissionService`) to the\n service. Without it the call throws `PrintableStorageNotConfiguredError`. The PDF is a `private` asset\n (`typeSlug` `education-page`) owned by the acting guardian's profile, or for a delegated agent the delegating\n guardian's; none available is `PrintableOwnerError`. Asset rows, the join and the model row commit in one\n transaction; newly planned files are removed after rollback unless the asset row committed\n (`PrintableStorageCleanupError` carries fresh asset ids if removal fails).\n- Renderer limits: standard Helvetica fonts only, Letter pages. Characters outside WinAnsi are replaced\n with `?` (never an error); media refs print as a short placeholder line, never the media. Bounded:\n 200 items, 10,000 characters per text value, 400,000 in total, 150 pages (`CourseAuthoringValidationError`).\n Output is deterministic: fixed creation/modification dates, footer on every page with the worksheet\n version id and audience, PDF title/subject/keywords carrying the version id.\n\n## Migration\n\nSchema ships through normal manifest migrations: six new tables (`question_responses` and `generated_printables` included) plus nullable\ncolumns on `grades` (`question_revision_id`) and `lesson_revisions` (`questions_json`,\n`layout_params_json`); test-pinned as additive. The runtime never creates tables.\n"
12472
13285
  },
13286
+ {
13287
+ "path": "agents/question-extraction.md",
13288
+ "module": "question-extraction",
13289
+ "content": "# Question extraction from scans and imported courses\n\n`QuestionExtractionService` (SMRT #4517, extends `QuestionAuthoringService`,\nconsumed by Hearth #80 and #94) turns ingestion proposals into structured,\nversioned question records. Risk is high: new schema, a public contract and\nidempotent writes. It reuses the course authorizer: preserve and apply need\n`education.course.author`; `readExtraction`, `findExtraction` and\n`resolveWorksheet` need `education.course.read-draft`. Operations:\n`preserve-question-extraction`, `apply-question-extraction`,\n`read-question-extraction`, `read-worksheet`.\n\n## Flow\n\n1. Receive the scan or course file through ingestion and extract it (OCR or\n vision, with `observeAnswerRegions` for image region catalogs).\n2. Offer `educationQuestionExtractionHandler({ extractions, request })` (from\n `@happyvertical/smrt-education/ingestion`) in the execution catalog and\n generate proposals. Its closed `QUESTION_EXTRACTION_ARGS_SCHEMA` is the\n extracted question set. Keep `requireReview: true` on every policy layer.\n Size the policy `maxBytes` and proposal output limits for whole worksheets:\n up to 200 questions, 400 student-work entries and 1000 pages.\n3. `preserveQuestionExtractionAnalysis({ ingestion, extractions, request,\n itemId, expectedWorksheetVersionId?, worksheets? })` reads the completed\n proposal, validates it against retained evidence, matches it and records a\n `QuestionExtractionRecord`. It returns `QuestionExtractionView` for\n extraction review. Not ready, no proposal or competing proposals throw\n `QuestionExtractionPendingError` (`reason`); invalid output throws\n `QuestionExtractionValidationError`. No question records are written.\n4. The reviewer previews and decides through ingestion. The handler apply\n calls `applyExtraction` on the ingestion transaction and returns the\n per-question outcomes (`ExtractedQuestionResult[]`, also on `view.applied`).\n\nHosts without an ingestion proposal (for example a trusted bundle importer)\ncall `preserveExtraction` directly with their own grounding and extractor\nidentity, then `applyExtraction`.\n\n## Extraction contract\n\n- Every retained source, and each page the provider reported, has a `pages`\n entry with a role (`exercise`, `example`, `instructions`, `cover`,\n `answer_key`, `mixed`, `unknown`) and readability. A missing page is invalid,\n so unreadable or unsupported content is marked, never dropped. Items that\n cannot be read keep their key and numbering with `status` `unreadable` or\n `unsupported` and a null prompt; other gaps go in `omissions`.\n- What ingestion itself did not extract stays visible. A retained original with\n no result, and pages that ingestion omitted or failed, are kept in the\n grounding: the extractor may cite them, and any it does not cover are added as\n `unreadable` pages with a `not_extracted` omission. Truncation and\n `source_incomplete` warnings are carried to `extraction.sourceWarnings` and\n the handler preview. More than 1000 pages is refused, never trimmed.\n- Questions hold printed text only. Learner handwriting goes in `studentWork`\n (null `observed` when illegible) and never becomes question content.\n- Anchors cite an original `evidenceId`, a physical page the provider\n reported (null when unknown) and a `regionId` from that evidence's\n `observedRegions` catalog. The rect comes from the catalog only; an unknown\n region is invalid. Coordinates are never accepted from the extractor.\n- `origin: 'source' | 'inferred'` marks response type, keys, rubrics and points.\n On apply, printed facts get `extracted` provenance and inferred facts get\n `inferred`. An empty key is invalid; a missing key stays null.\n- Diagram and media references are kept on the link with their anchor. They\n are not question `media`, which must name a tenant asset.\n\n## Matching\n\n`matchExtractedQuestions(extraction, worksheets, { expectedWorksheetVersionId })`\nis pure. A match needs a worksheet identity (the `wsv1-` id generated printables\nprint in their footer, or a host-supplied expectation), plus the printed\nposition (`1`, `1.`, `Q1`) of an item on that worksheet. Every claimant of a\nposition counts, including one that fails a layout check. Section label, a\nsource-printed response type, and choice and subpart counts must agree. Prompt\ntext is never used. A printed id that disagrees with the expectation, any layout\nconflict, or two items claiming one position is `ambiguous` and stays\nunattached. Examples are never matched. `resolveWorksheet` finds a worksheet by\nid through `generated_printables`; host-supplied worksheets must be this\ntenant's revisions. Student work gets a `questionRevisionId` only when its\nquestion matched. Recording attempts and grades stays with the workbook and\nresponse services.\n\n## Idempotency\n\n`sourceIdentity` is a digest of the source kind and the sorted retained evidence\nhashes, never text. Preserve is idempotent per (tenant, intake item, analysis\nattempt). A different payload for the same attempt throws\n`CourseRequestKeyConflictError`. The payload includes the grounding, the\nextractor identity and the expected and supplied worksheets. Apply writes questions through `createQuestion`\nwith request key `qx:<sourceIdentity>:<questionKey>`, so re-ingesting the same\nbytes replays the same questions (`replayed`). Identical prompts under different\nkeys stay separate records. If the same source is extracted again with different\ncontent, the outcome is `source_changed`: nothing is overwritten. A new scan has\nnew bytes, so it is a new source, and it links to known questions only by\nmatching. A repeated apply replays the stored `ExtractedQuestionLink` rows.\n\nOutcomes: `created`, `replayed`, `matched` (these carry a revision),\n`needs_review` (ambiguous), `source_changed`, `invalid_content` (fails question\nvalidation), `unreadable`, `unsupported`, `not_an_exercise`.\n\n## Models (closed, required tenant, no REST/CLI/MCP/AI, append-only)\n\n`QuestionExtractionRecord` (`question_extractions`: unique tenant/item/attempt,\nindexed source identity, extractor identity, validated extraction and match JSON,\nproposal digest) and `ExtractedQuestionLink` (`extracted_question_links`: unique\ntenant/extraction/key; outcome, question and revision ids, anchors and media).\nBoth ship through normal manifest migrations. The originals stay in ingestion\nand are never copied. Imported material keeps its course licence; extraction\ndoes not change sharing.\n\nNot in this release: zip/bundle manifests (SMRT #4578), automatic new\nrevisions on `source_changed`, and media assets for extracted diagrams.\n"
13290
+ },
12473
13291
  {
12474
13292
  "path": "agents/source-import.md",
12475
13293
  "module": "source-import",
@@ -12574,7 +13392,7 @@
12574
13392
  "description": "Courses and lessons for learning."
12575
13393
  },
12576
13394
  "help": {
12577
- "markdown": "# Learners\n\nUse `LearnerService` in an authenticated exact-tenant context to read a learner,\nplan a curriculum as an authorized guardian, enroll in a visible course, and\nstart a lesson. Recipe metadata does not expose model CRUD. Hosts authenticate\nusers, provision family membership, and validate guardian ProfileRelationship\ntypes and effective terms. See `agents/learner-service.md` for the boundary.\n",
13395
+ "markdown": "# Learners\n\nUse `LearnerService` in an authenticated exact-tenant context to read a learner,\nplan a curriculum as an authorized guardian, enroll in a visible course, start\nor put back a lesson, and start, pause, resume or drop an enrollment. Recipe\nmetadata does not expose model CRUD. Hosts authenticate users, provision family\nmembership, and validate guardian ProfileRelationship types and effective\nterms. See `agents/learner-service.md` for the boundary.\n",
12578
13396
  "fieldRefs": []
12579
13397
  },
12580
13398
  "runtime": "both",