@happyvertical/smrt-education 0.57.8 → 0.57.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/agents/catalog-sync.md +35 -0
  2. package/agents/ingestion.md +93 -0
  3. package/agents/learner-service.md +9 -0
  4. package/agents/source-import.md +9 -0
  5. package/agents/submission-service.md +18 -0
  6. package/dist/catalog-acquisition-service.d.ts +58 -0
  7. package/dist/catalog-acquisition-service.d.ts.map +1 -0
  8. package/dist/catalog-collections.d.ts +18 -0
  9. package/dist/catalog-collections.d.ts.map +1 -0
  10. package/dist/catalog-contracts.d.ts +88 -0
  11. package/dist/catalog-contracts.d.ts.map +1 -0
  12. package/dist/catalog-database-sync-store.d.ts +13 -0
  13. package/dist/catalog-database-sync-store.d.ts.map +1 -0
  14. package/dist/catalog-materialization-service.d.ts +78 -0
  15. package/dist/catalog-materialization-service.d.ts.map +1 -0
  16. package/dist/catalog-models.d.ts +63 -0
  17. package/dist/catalog-models.d.ts.map +1 -0
  18. package/dist/catalog-policy.d.ts +12 -0
  19. package/dist/catalog-policy.d.ts.map +1 -0
  20. package/dist/catalog-read-service.d.ts +44 -0
  21. package/dist/catalog-read-service.d.ts.map +1 -0
  22. package/dist/catalog-sync-service.d.ts +19 -0
  23. package/dist/catalog-sync-service.d.ts.map +1 -0
  24. package/dist/collections.d.ts +4 -1
  25. package/dist/collections.d.ts.map +1 -1
  26. package/dist/hearth-catalog-providers.d.ts +74 -0
  27. package/dist/hearth-catalog-providers.d.ts.map +1 -0
  28. package/dist/hearth-easy-peasy-grade4.d.ts +40 -0
  29. package/dist/hearth-easy-peasy-grade4.d.ts.map +1 -0
  30. package/dist/index.d.ts +28 -6
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +2279 -175
  33. package/dist/index.js.map +1 -1
  34. package/dist/ingestion-isolation.d.ts +8 -0
  35. package/dist/ingestion-isolation.d.ts.map +1 -0
  36. package/dist/ingestion.d.ts +103 -0
  37. package/dist/ingestion.d.ts.map +1 -0
  38. package/dist/ingestion.js +364 -0
  39. package/dist/ingestion.js.map +1 -0
  40. package/dist/learner-service.d.ts +74 -1
  41. package/dist/learner-service.d.ts.map +1 -1
  42. package/dist/manifest.json +2686 -481
  43. package/dist/smrt-knowledge.json +713 -14
  44. package/dist/source-import-service.d.ts +11 -1
  45. package/dist/source-import-service.d.ts.map +1 -1
  46. package/dist/sources/easy-peasy-parser.d.ts.map +1 -1
  47. package/dist/sources/google-drive-adapter.d.ts +125 -0
  48. package/dist/sources/google-drive-adapter.d.ts.map +1 -0
  49. package/dist/sources/illustrative-mathematics-parser.d.ts +8 -0
  50. package/dist/sources/illustrative-mathematics-parser.d.ts.map +1 -0
  51. package/dist/sources/oer-commons-rss-parser.d.ts +8 -0
  52. package/dist/sources/oer-commons-rss-parser.d.ts.map +1 -0
  53. package/dist/submission-service.d.ts +48 -1
  54. package/dist/submission-service.d.ts.map +1 -1
  55. package/dist/submissions.d.ts +18 -0
  56. package/dist/submissions.d.ts.map +1 -1
  57. package/package.json +16 -10
@@ -3,19 +3,20 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-education",
6
- "packageVersion": "0.57.8",
6
+ "packageVersion": "0.57.10",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "dd5f39d6ae77e04a945f39e73f9490dffa69c0fe092210b8fe8a4b89c001f4ae",
11
- "packageJson": "1dd9e9ba1dd699bc47236e809c5d86c3d14f1a4756397a7da62327348923d5e7",
10
+ "manifest": "0a0f4fb7382d3b414bf4bf98e41c667afa346376a6d9760d8f9fec5a6dd2e6fb",
11
+ "packageJson": "d047c3106951d83a21b176ecbaad1d7e008382704f35a259702dbbd033e60ef5",
12
12
  "agents": "bad10d5dcf79f6a2afbe9b81eb945397c9e7b7c2ca2385304e15a30939bc8f3a",
13
- "moduleDoc:agents/learner-service.md": "74a1cf0496ae234691eef827854dd5eb51bb2d6d4a90de6875f2f327457283a7",
14
- "moduleDoc:agents/submission-service.md": "405ca1c7c2cc8dbda32033a6b71c375fea048519119ce11da1127d399c60985c",
15
- "moduleDoc:agents/source-import.md": "efdc436bd2a410230df5aa89669fa82d571d36085d8c9be744c2d1621de25d70"
13
+ "moduleDoc:agents/learner-service.md": "7da70d214547eb78fbf058e997599cc80ab86f5bb55748bd158a9a4b30070d3b",
14
+ "moduleDoc:agents/submission-service.md": "8e63b83ac84628d5e5ddacbb6a47077c0e65eee3da87518d9bf6e30fb8348336",
15
+ "moduleDoc:agents/source-import.md": "30ee823589d655442bcf1abcd86a2132172065b4bc5e3c4d89b1e8b7afcc66bc"
16
16
  },
17
17
  "exports": [
18
18
  ".",
19
+ "./ingestion",
19
20
  "./manifest",
20
21
  "./manifest.json"
21
22
  ],
@@ -25,8 +26,10 @@
25
26
  "@happyvertical/smrt-profiles": "workspace:*",
26
27
  "@happyvertical/smrt-tenancy": "workspace:*",
27
28
  "@happyvertical/smrt-users": "workspace:*",
29
+ "@happyvertical/files": "catalog:",
28
30
  "@happyvertical/sql": "catalog:",
29
31
  "pdf-lib": "1.17.1",
32
+ "@happyvertical/smrt-ingestion": "workspace:*",
30
33
  "@happyvertical/smrt-vitest": "workspace:*",
31
34
  "@types/node": "24.13.6",
32
35
  "typescript": "6.0.3",
@@ -36,12 +39,14 @@
36
39
  "smrtDependencies": [
37
40
  "@happyvertical/smrt-assets",
38
41
  "@happyvertical/smrt-core",
42
+ "@happyvertical/smrt-ingestion",
39
43
  "@happyvertical/smrt-profiles",
40
44
  "@happyvertical/smrt-tenancy",
41
45
  "@happyvertical/smrt-users",
42
46
  "@happyvertical/smrt-vitest"
43
47
  ],
44
48
  "sdkDependencies": [
49
+ "@happyvertical/files",
45
50
  "@happyvertical/sql"
46
51
  ],
47
52
  "tags": [
@@ -57,6 +62,494 @@
57
62
  "sti-inheritance"
58
63
  ],
59
64
  "objects": [
65
+ {
66
+ "name": "CatalogProviderCollection",
67
+ "qualifiedName": "@happyvertical/smrt-education:CatalogProviderCollection",
68
+ "collection": "catalogproviders",
69
+ "tableName": "catalog_providers",
70
+ "packageName": "@happyvertical/smrt-education",
71
+ "extends": "SmrtCollection",
72
+ "fields": [],
73
+ "relationships": [],
74
+ "methods": [],
75
+ "surfaces": [],
76
+ "relationshipFeatures": [
77
+ "uuidColumns"
78
+ ],
79
+ "tags": [],
80
+ "risks": []
81
+ },
82
+ {
83
+ "name": "CatalogResourceCollection",
84
+ "qualifiedName": "@happyvertical/smrt-education:CatalogResourceCollection",
85
+ "collection": "catalogresources",
86
+ "tableName": "catalog_resources",
87
+ "packageName": "@happyvertical/smrt-education",
88
+ "extends": "SmrtCollection",
89
+ "fields": [],
90
+ "relationships": [],
91
+ "methods": [],
92
+ "surfaces": [],
93
+ "relationshipFeatures": [
94
+ "uuidColumns"
95
+ ],
96
+ "tags": [],
97
+ "risks": []
98
+ },
99
+ {
100
+ "name": "CatalogSyncStateCollection",
101
+ "qualifiedName": "@happyvertical/smrt-education:CatalogSyncStateCollection",
102
+ "collection": "catalogsyncstates",
103
+ "tableName": "catalog_sync_states",
104
+ "packageName": "@happyvertical/smrt-education",
105
+ "extends": "SmrtCollection",
106
+ "fields": [],
107
+ "relationships": [],
108
+ "methods": [],
109
+ "surfaces": [],
110
+ "relationshipFeatures": [
111
+ "uuidColumns"
112
+ ],
113
+ "tags": [],
114
+ "risks": []
115
+ },
116
+ {
117
+ "name": "CatalogSelectionCollection",
118
+ "qualifiedName": "@happyvertical/smrt-education:CatalogSelectionCollection",
119
+ "collection": "catalogselections",
120
+ "tableName": "catalog_selections",
121
+ "packageName": "@happyvertical/smrt-education",
122
+ "extends": "SmrtCollection",
123
+ "fields": [],
124
+ "relationships": [],
125
+ "methods": [],
126
+ "surfaces": [],
127
+ "relationshipFeatures": [
128
+ "uuidColumns"
129
+ ],
130
+ "tags": [],
131
+ "risks": []
132
+ },
133
+ {
134
+ "name": "CatalogAcquisitionCollection",
135
+ "qualifiedName": "@happyvertical/smrt-education:CatalogAcquisitionCollection",
136
+ "collection": "catalogacquisitions",
137
+ "tableName": "catalog_acquisitions",
138
+ "packageName": "@happyvertical/smrt-education",
139
+ "extends": "SmrtCollection",
140
+ "fields": [],
141
+ "relationships": [],
142
+ "methods": [],
143
+ "surfaces": [],
144
+ "relationshipFeatures": [
145
+ "uuidColumns"
146
+ ],
147
+ "tags": [],
148
+ "risks": []
149
+ },
150
+ {
151
+ "name": "CatalogProvider",
152
+ "qualifiedName": "@happyvertical/smrt-education:CatalogProvider",
153
+ "displayLabelField": "name",
154
+ "collection": "catalogproviders",
155
+ "description": "Globally visible provider registration. Hosts configure adapters and credentials out of band.",
156
+ "tableName": "catalog_providers",
157
+ "packageName": "@happyvertical/smrt-education",
158
+ "extends": "SmrtObject",
159
+ "fields": [
160
+ {
161
+ "name": "providerId",
162
+ "type": "text",
163
+ "required": true,
164
+ "columnType": "TEXT"
165
+ },
166
+ {
167
+ "name": "name",
168
+ "type": "text",
169
+ "required": true,
170
+ "columnType": "TEXT"
171
+ },
172
+ {
173
+ "name": "active",
174
+ "type": "boolean",
175
+ "required": true,
176
+ "columnType": "BOOLEAN",
177
+ "default": true
178
+ }
179
+ ],
180
+ "relationships": [],
181
+ "methods": [],
182
+ "surfaces": [],
183
+ "relationshipFeatures": [
184
+ "uuidColumns"
185
+ ],
186
+ "tags": [],
187
+ "risks": []
188
+ },
189
+ {
190
+ "name": "CatalogResource",
191
+ "qualifiedName": "@happyvertical/smrt-education:CatalogResource",
192
+ "displayLabelField": "title",
193
+ "collection": "catalogresources",
194
+ "description": "A provider-native record. Rows are intentionally not merged across providers.",
195
+ "tableName": "catalog_resources",
196
+ "packageName": "@happyvertical/smrt-education",
197
+ "extends": "SmrtObject",
198
+ "fields": [
199
+ {
200
+ "name": "providerId",
201
+ "type": "text",
202
+ "required": true,
203
+ "columnType": "TEXT"
204
+ },
205
+ {
206
+ "name": "stableId",
207
+ "type": "text",
208
+ "required": true,
209
+ "columnType": "TEXT"
210
+ },
211
+ {
212
+ "name": "revision",
213
+ "type": "text",
214
+ "required": true,
215
+ "columnType": "TEXT"
216
+ },
217
+ {
218
+ "name": "canonicalUrl",
219
+ "type": "text",
220
+ "required": false,
221
+ "columnType": "TEXT"
222
+ },
223
+ {
224
+ "name": "title",
225
+ "type": "text",
226
+ "required": true,
227
+ "columnType": "TEXT"
228
+ },
229
+ {
230
+ "name": "suppliedDescription",
231
+ "type": "text",
232
+ "required": false,
233
+ "columnType": "TEXT"
234
+ },
235
+ {
236
+ "name": "author",
237
+ "type": "text",
238
+ "required": false,
239
+ "columnType": "TEXT"
240
+ },
241
+ {
242
+ "name": "subjects",
243
+ "type": "text",
244
+ "required": false,
245
+ "columnType": "TEXT"
246
+ },
247
+ {
248
+ "name": "grades",
249
+ "type": "text",
250
+ "required": false,
251
+ "columnType": "TEXT"
252
+ },
253
+ {
254
+ "name": "language",
255
+ "type": "text",
256
+ "required": false,
257
+ "columnType": "TEXT"
258
+ },
259
+ {
260
+ "name": "resourceType",
261
+ "type": "text",
262
+ "required": false,
263
+ "columnType": "TEXT"
264
+ },
265
+ {
266
+ "name": "parentStableId",
267
+ "type": "text",
268
+ "required": false,
269
+ "columnType": "TEXT"
270
+ },
271
+ {
272
+ "name": "availableFormats",
273
+ "type": "text",
274
+ "required": false,
275
+ "columnType": "TEXT"
276
+ },
277
+ {
278
+ "name": "licenseEvidence",
279
+ "type": "text",
280
+ "required": false,
281
+ "columnType": "TEXT"
282
+ },
283
+ {
284
+ "name": "licenseState",
285
+ "type": "text",
286
+ "required": false,
287
+ "columnType": "TEXT",
288
+ "default": "unknown"
289
+ },
290
+ {
291
+ "name": "visibility",
292
+ "type": "text",
293
+ "required": false,
294
+ "columnType": "TEXT",
295
+ "default": "catalog"
296
+ },
297
+ {
298
+ "name": "lastSeenRunId",
299
+ "type": "text",
300
+ "required": false,
301
+ "columnType": "TEXT"
302
+ },
303
+ {
304
+ "name": "withdrawnAt",
305
+ "type": "text",
306
+ "required": false,
307
+ "columnType": "TEXT"
308
+ }
309
+ ],
310
+ "relationships": [],
311
+ "methods": [],
312
+ "surfaces": [],
313
+ "relationshipFeatures": [
314
+ "uuidColumns"
315
+ ],
316
+ "tags": [],
317
+ "risks": []
318
+ },
319
+ {
320
+ "name": "CatalogSyncState",
321
+ "qualifiedName": "@happyvertical/smrt-education:CatalogSyncState",
322
+ "collection": "catalogsyncstates",
323
+ "description": "Durable per-provider enumeration state. A failed or partial feed is never complete.",
324
+ "tableName": "catalog_sync_states",
325
+ "packageName": "@happyvertical/smrt-education",
326
+ "extends": "SmrtObject",
327
+ "fields": [
328
+ {
329
+ "name": "providerId",
330
+ "type": "text",
331
+ "required": true,
332
+ "columnType": "TEXT"
333
+ },
334
+ {
335
+ "name": "cursor",
336
+ "type": "text",
337
+ "required": false,
338
+ "columnType": "TEXT"
339
+ },
340
+ {
341
+ "name": "runId",
342
+ "type": "text",
343
+ "required": false,
344
+ "columnType": "TEXT"
345
+ },
346
+ {
347
+ "name": "complete",
348
+ "type": "boolean",
349
+ "required": true,
350
+ "columnType": "BOOLEAN",
351
+ "default": false
352
+ },
353
+ {
354
+ "name": "paused",
355
+ "type": "boolean",
356
+ "required": true,
357
+ "columnType": "BOOLEAN",
358
+ "default": false
359
+ },
360
+ {
361
+ "name": "pauseReason",
362
+ "type": "text",
363
+ "required": false,
364
+ "columnType": "TEXT"
365
+ },
366
+ {
367
+ "name": "importedCount",
368
+ "type": "integer",
369
+ "required": true,
370
+ "columnType": "INTEGER",
371
+ "default": 0
372
+ },
373
+ {
374
+ "name": "failures",
375
+ "type": "text",
376
+ "required": false,
377
+ "columnType": "TEXT"
378
+ },
379
+ {
380
+ "name": "lastSuccessfulAt",
381
+ "type": "text",
382
+ "required": false,
383
+ "columnType": "TEXT"
384
+ }
385
+ ],
386
+ "relationships": [],
387
+ "methods": [],
388
+ "surfaces": [],
389
+ "relationshipFeatures": [
390
+ "uuidColumns"
391
+ ],
392
+ "tags": [],
393
+ "risks": []
394
+ },
395
+ {
396
+ "name": "CatalogSelection",
397
+ "qualifiedName": "@happyvertical/smrt-education:CatalogSelection",
398
+ "collection": "catalogselections",
399
+ "description": "Tenant-owned intent to use a metadata record, distinct from acquiring bytes.",
400
+ "tableName": "catalog_selections",
401
+ "packageName": "@happyvertical/smrt-education",
402
+ "extends": "SmrtObject",
403
+ "fields": [
404
+ {
405
+ "name": "tenantId",
406
+ "type": "text",
407
+ "required": true,
408
+ "columnType": "UUID"
409
+ },
410
+ {
411
+ "name": "resourceId",
412
+ "type": "foreignKey",
413
+ "required": true,
414
+ "related": "CatalogResource",
415
+ "columnType": "UUID"
416
+ },
417
+ {
418
+ "name": "selectedByUserId",
419
+ "type": "text",
420
+ "required": true,
421
+ "columnType": "TEXT"
422
+ },
423
+ {
424
+ "name": "selectedRevision",
425
+ "type": "text",
426
+ "required": true,
427
+ "columnType": "TEXT"
428
+ }
429
+ ],
430
+ "relationships": [
431
+ {
432
+ "name": "resourceId",
433
+ "type": "foreignKey",
434
+ "required": true,
435
+ "related": "CatalogResource",
436
+ "columnType": "UUID"
437
+ }
438
+ ],
439
+ "methods": [],
440
+ "tenant": {
441
+ "scoped": true,
442
+ "mode": "required",
443
+ "field": "tenantId"
444
+ },
445
+ "conflictColumns": [
446
+ "tenant_id",
447
+ "slug",
448
+ "context"
449
+ ],
450
+ "surfaces": [],
451
+ "relationshipFeatures": [
452
+ "foreignKey",
453
+ "uuidColumns"
454
+ ],
455
+ "tags": [],
456
+ "risks": []
457
+ },
458
+ {
459
+ "name": "CatalogAcquisition",
460
+ "qualifiedName": "@happyvertical/smrt-education:CatalogAcquisition",
461
+ "collection": "catalogacquisitions",
462
+ "description": "A pinned, tenant-owned acquisition record. Bytes belong in a host storage service.",
463
+ "tableName": "catalog_acquisitions",
464
+ "packageName": "@happyvertical/smrt-education",
465
+ "extends": "SmrtObject",
466
+ "fields": [
467
+ {
468
+ "name": "tenantId",
469
+ "type": "text",
470
+ "required": true,
471
+ "columnType": "UUID"
472
+ },
473
+ {
474
+ "name": "resourceId",
475
+ "type": "foreignKey",
476
+ "required": true,
477
+ "related": "CatalogResource",
478
+ "columnType": "UUID"
479
+ },
480
+ {
481
+ "name": "selectionId",
482
+ "type": "foreignKey",
483
+ "required": true,
484
+ "related": "CatalogSelection",
485
+ "columnType": "UUID"
486
+ },
487
+ {
488
+ "name": "revision",
489
+ "type": "text",
490
+ "required": true,
491
+ "columnType": "TEXT"
492
+ },
493
+ {
494
+ "name": "format",
495
+ "type": "text",
496
+ "required": true,
497
+ "columnType": "TEXT"
498
+ },
499
+ {
500
+ "name": "sha256",
501
+ "type": "text",
502
+ "required": true,
503
+ "columnType": "TEXT"
504
+ },
505
+ {
506
+ "name": "attribution",
507
+ "type": "text",
508
+ "required": false,
509
+ "columnType": "TEXT"
510
+ },
511
+ {
512
+ "name": "notices",
513
+ "type": "text",
514
+ "required": false,
515
+ "columnType": "TEXT"
516
+ }
517
+ ],
518
+ "relationships": [
519
+ {
520
+ "name": "resourceId",
521
+ "type": "foreignKey",
522
+ "required": true,
523
+ "related": "CatalogResource",
524
+ "columnType": "UUID"
525
+ },
526
+ {
527
+ "name": "selectionId",
528
+ "type": "foreignKey",
529
+ "required": true,
530
+ "related": "CatalogSelection",
531
+ "columnType": "UUID"
532
+ }
533
+ ],
534
+ "methods": [],
535
+ "tenant": {
536
+ "scoped": true,
537
+ "mode": "required",
538
+ "field": "tenantId"
539
+ },
540
+ "conflictColumns": [
541
+ "tenant_id",
542
+ "slug",
543
+ "context"
544
+ ],
545
+ "surfaces": [],
546
+ "relationshipFeatures": [
547
+ "foreignKey",
548
+ "uuidColumns"
549
+ ],
550
+ "tags": [],
551
+ "risks": []
552
+ },
60
553
  {
61
554
  "name": "Course",
62
555
  "qualifiedName": "@happyvertical/smrt-education:Course",
@@ -1703,6 +2196,23 @@
1703
2196
  "tags": [],
1704
2197
  "risks": []
1705
2198
  },
2199
+ {
2200
+ "name": "SubmissionIntakeBindingCollection",
2201
+ "qualifiedName": "@happyvertical/smrt-education:SubmissionIntakeBindingCollection",
2202
+ "collection": "submissionintakebindings",
2203
+ "tableName": "submission_intake_bindings",
2204
+ "packageName": "@happyvertical/smrt-education",
2205
+ "extends": "SmrtCollection",
2206
+ "fields": [],
2207
+ "relationships": [],
2208
+ "methods": [],
2209
+ "surfaces": [],
2210
+ "relationshipFeatures": [
2211
+ "uuidColumns"
2212
+ ],
2213
+ "tags": [],
2214
+ "risks": []
2215
+ },
1706
2216
  {
1707
2217
  "name": "ScannedSubmissionCollection",
1708
2218
  "qualifiedName": "@happyvertical/smrt-education:ScannedSubmissionCollection",
@@ -2690,6 +3200,26 @@
2690
3200
  "type": "text",
2691
3201
  "required": false,
2692
3202
  "columnType": "TEXT"
3203
+ },
3204
+ {
3205
+ "name": "kind",
3206
+ "type": "text",
3207
+ "required": true,
3208
+ "columnType": "TEXT",
3209
+ "default": "completed_work"
3210
+ },
3211
+ {
3212
+ "name": "previousSubmissionId",
3213
+ "type": "foreignKey",
3214
+ "required": false,
3215
+ "related": "Submission",
3216
+ "columnType": "UUID"
3217
+ },
3218
+ {
3219
+ "name": "intakeItemId",
3220
+ "type": "text",
3221
+ "required": false,
3222
+ "columnType": "TEXT"
2693
3223
  }
2694
3224
  ],
2695
3225
  "relationships": [
@@ -2713,6 +3243,13 @@
2713
3243
  "required": true,
2714
3244
  "related": "LessonProgress",
2715
3245
  "columnType": "UUID"
3246
+ },
3247
+ {
3248
+ "name": "previousSubmissionId",
3249
+ "type": "foreignKey",
3250
+ "required": false,
3251
+ "related": "Submission",
3252
+ "columnType": "UUID"
2716
3253
  }
2717
3254
  ],
2718
3255
  "methods": [
@@ -2754,6 +3291,80 @@
2754
3291
  "tags": [],
2755
3292
  "risks": []
2756
3293
  },
3294
+ {
3295
+ "name": "SubmissionIntakeBinding",
3296
+ "qualifiedName": "@happyvertical/smrt-education:SubmissionIntakeBinding",
3297
+ "collection": "submissionintakebindings",
3298
+ "description": "Durable tenant/item claim for trusted ingestion receipts. This separate table keeps ordinary uploads (which have no intake item) out of the uniqueness key.",
3299
+ "tableName": "submission_intake_bindings",
3300
+ "packageName": "@happyvertical/smrt-education",
3301
+ "extends": "SmrtObject",
3302
+ "fields": [
3303
+ {
3304
+ "name": "tenantId",
3305
+ "type": "text",
3306
+ "required": true,
3307
+ "columnType": "UUID"
3308
+ },
3309
+ {
3310
+ "name": "intakeItemId",
3311
+ "type": "text",
3312
+ "required": true,
3313
+ "columnType": "TEXT"
3314
+ },
3315
+ {
3316
+ "name": "submissionId",
3317
+ "type": "foreignKey",
3318
+ "required": true,
3319
+ "related": "Submission",
3320
+ "columnType": "UUID"
3321
+ }
3322
+ ],
3323
+ "relationships": [
3324
+ {
3325
+ "name": "submissionId",
3326
+ "type": "foreignKey",
3327
+ "required": true,
3328
+ "related": "Submission",
3329
+ "columnType": "UUID"
3330
+ }
3331
+ ],
3332
+ "methods": [
3333
+ "save"
3334
+ ],
3335
+ "methodSignatures": [
3336
+ {
3337
+ "name": "save",
3338
+ "async": true,
3339
+ "returns": "Promise<this>"
3340
+ }
3341
+ ],
3342
+ "tenant": {
3343
+ "scoped": true,
3344
+ "mode": "required",
3345
+ "field": "tenantId"
3346
+ },
3347
+ "conflictColumns": [
3348
+ "tenant_id",
3349
+ "intake_item_id"
3350
+ ],
3351
+ "surfaces": [],
3352
+ "withheldSurfaces": [
3353
+ {
3354
+ "kind": "api",
3355
+ "operation": "save",
3356
+ "code": "api-disabled",
3357
+ "reason": "api is disabled",
3358
+ "objectName": "@happyvertical/smrt-education:SubmissionIntakeBinding"
3359
+ }
3360
+ ],
3361
+ "relationshipFeatures": [
3362
+ "foreignKey",
3363
+ "uuidColumns"
3364
+ ],
3365
+ "tags": [],
3366
+ "risks": []
3367
+ },
2757
3368
  {
2758
3369
  "name": "ScannedSubmission",
2759
3370
  "qualifiedName": "@happyvertical/smrt-education:ScannedSubmission",
@@ -2833,6 +3444,26 @@
2833
3444
  "required": false,
2834
3445
  "columnType": "TEXT"
2835
3446
  },
3447
+ {
3448
+ "name": "kind",
3449
+ "type": "text",
3450
+ "required": true,
3451
+ "columnType": "TEXT",
3452
+ "default": "completed_work"
3453
+ },
3454
+ {
3455
+ "name": "previousSubmissionId",
3456
+ "type": "foreignKey",
3457
+ "required": false,
3458
+ "related": "Submission",
3459
+ "columnType": "UUID"
3460
+ },
3461
+ {
3462
+ "name": "intakeItemId",
3463
+ "type": "text",
3464
+ "required": false,
3465
+ "columnType": "TEXT"
3466
+ },
2836
3467
  {
2837
3468
  "name": "pageCount",
2838
3469
  "type": "integer",
@@ -2874,6 +3505,13 @@
2874
3505
  "required": true,
2875
3506
  "related": "LessonProgress",
2876
3507
  "columnType": "UUID"
3508
+ },
3509
+ {
3510
+ "name": "previousSubmissionId",
3511
+ "type": "foreignKey",
3512
+ "required": false,
3513
+ "related": "Submission",
3514
+ "columnType": "UUID"
2877
3515
  }
2878
3516
  ],
2879
3517
  "methods": [
@@ -3010,6 +3648,26 @@
3010
3648
  "required": false,
3011
3649
  "columnType": "TEXT"
3012
3650
  },
3651
+ {
3652
+ "name": "kind",
3653
+ "type": "text",
3654
+ "required": true,
3655
+ "columnType": "TEXT",
3656
+ "default": "completed_work"
3657
+ },
3658
+ {
3659
+ "name": "previousSubmissionId",
3660
+ "type": "foreignKey",
3661
+ "required": false,
3662
+ "related": "Submission",
3663
+ "columnType": "UUID"
3664
+ },
3665
+ {
3666
+ "name": "intakeItemId",
3667
+ "type": "text",
3668
+ "required": false,
3669
+ "columnType": "TEXT"
3670
+ },
3013
3671
  {
3014
3672
  "name": "reportedBy",
3015
3673
  "type": "text",
@@ -3051,6 +3709,13 @@
3051
3709
  "required": true,
3052
3710
  "related": "LessonProgress",
3053
3711
  "columnType": "UUID"
3712
+ },
3713
+ {
3714
+ "name": "previousSubmissionId",
3715
+ "type": "foreignKey",
3716
+ "required": false,
3717
+ "related": "Submission",
3718
+ "columnType": "UUID"
3054
3719
  }
3055
3720
  ],
3056
3721
  "methods": [
@@ -3159,6 +3824,26 @@
3159
3824
  "type": "text",
3160
3825
  "required": false,
3161
3826
  "columnType": "TEXT"
3827
+ },
3828
+ {
3829
+ "name": "kind",
3830
+ "type": "text",
3831
+ "required": true,
3832
+ "columnType": "TEXT",
3833
+ "default": "completed_work"
3834
+ },
3835
+ {
3836
+ "name": "previousSubmissionId",
3837
+ "type": "foreignKey",
3838
+ "required": false,
3839
+ "related": "Submission",
3840
+ "columnType": "UUID"
3841
+ },
3842
+ {
3843
+ "name": "intakeItemId",
3844
+ "type": "text",
3845
+ "required": false,
3846
+ "columnType": "TEXT"
3162
3847
  }
3163
3848
  ],
3164
3849
  "relationships": [
@@ -3182,6 +3867,13 @@
3182
3867
  "required": true,
3183
3868
  "related": "LessonProgress",
3184
3869
  "columnType": "UUID"
3870
+ },
3871
+ {
3872
+ "name": "previousSubmissionId",
3873
+ "type": "foreignKey",
3874
+ "required": false,
3875
+ "related": "Submission",
3876
+ "columnType": "UUID"
3185
3877
  }
3186
3878
  ],
3187
3879
  "methods": [
@@ -3469,29 +4161,29 @@
3469
4161
  "surfaces": [],
3470
4162
  "prompts": [],
3471
4163
  "relationshipsV2": {
3472
- "foreignKeyFields": 28,
4164
+ "foreignKeyFields": 36,
3473
4165
  "crossPackageRefFields": 5,
3474
4166
  "junctionCollections": 2,
3475
4167
  "hierarchicalObjects": 0,
3476
4168
  "polymorphicAssociations": 0,
3477
- "uuidColumns": 150
4169
+ "uuidColumns": 184
3478
4170
  },
3479
4171
  "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. 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\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",
3480
4172
  "moduleDocs": [
3481
4173
  {
3482
4174
  "path": "agents/learner-service.md",
3483
4175
  "module": "learner-service",
3484
- "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.\nSame-tenant membership alone never grants sibling access.\n\n## Supported workflow\n\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- `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\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"
4176
+ "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- `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\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"
3485
4177
  },
3486
4178
  {
3487
4179
  "path": "agents/submission-service.md",
3488
4180
  "module": "submission-service",
3489
- "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`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, set submission to `graded` and progress to `complete`.\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## 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## 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"
4181
+ "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, set submission to `graded` and progress to `complete`.\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## 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"
3490
4182
  },
3491
4183
  {
3492
4184
  "path": "agents/source-import.md",
3493
4185
  "module": "source-import",
3494
- "content": "# Source import integration\n\n`SourceImportService` is a server-side host boundary, with no generated REST,\nCLI, MCP or AI operation. Recipes remain metadata only. Provision a closed\n`CourseSource` in the owning tenant, then construct the service with the root\nSQL database and an actor-bound `SourceImportAuthorizer`. Enable tenancy and\ncall within `withTenant({ tenantId, userId })`; the request must match both.\nMissing context, a different actor/tenant and super-admin bypass fail closed.\n\nRegister Easy Peasy only after the host operator accepts its terms, using\n`registerEasyPeasy({ termsAccepted: true, fetchPage })`. Do not derive that\nboolean from an HTTP request. `fetchPage(url, { redirect: 'error' })` must reject\nredirects, bound time/size and return `{ html, url }`. It fetches the page only,\nnever a PDF. The service checks the approved HTTPS URL before fetching, checks\nthe response URL, limits HTML to 2 million characters, then parses the complete\npage before any write. There is no automatic registration or network access. Structural h1–h6 headings delimit day content, including trailing Resources\nsections; ordinary non-lesson bold instructions do not. Supported\nheading blocks are h1–h6 or a lesson marker as the first bold run in a\nparagraph/list item.\nRecognizable markers outside that layout, including multiple markers in one\nparagraph, fail closed rather than silently assigning a PDF to the wrong day.\n\nThe authorizer must verify the authenticated actor's management grant and the\nprovisioned tenant's family/group classification. Tenant has no built-in kind;\nneither hierarchy position nor client input establishes a family. Authorization\nis required before fetch and again inside the transaction; callbacks must be\nread-only decisions, since a rolled-back contention retry can call them again.\n\n`sharing` is a ceiling: private permits private-family import only; group and\nglobal permit private-family or group import. Group imports additionally require\nnonblank host-recorded `config.licenseEvidence` and an explicit host grant that\nvalidates that permission. Global/null destinations are unsupported and rejected;\nthere is no global/system bypass. Widening a source never promotes existing\nimports. Private imports are draft `personal-use-copy` courses and retain the\ncatalog's immutable owner/license and no-publication protection. A host may\ncreate a new licensed group source and import there after receiving permission.\n\nUse a stable `courseRef` independent of source URLs. Identities derive from the\nowning tenant, source row ID, course reference and lesson day; retain the source\nrow when updating policy. Changes to titles/page/PDF URLs update in place.\nMissing days are retained, not deleted, so enrollment, progress, submission and\nasset foreign keys remain valid. A changed lesson kind fails the whole import\nrather than replacing a row. Only title and pointer fields are refreshed; body,\nanswer keys, learner state and manually authored settings are never replaced.\nThe packet URL is returned as a source link and is not downloaded or mirrored.\n\nAll source policy rechecks and catalog mutations use collections bound to the\nsame SQL transaction. Embedded writes use the public write queue; PostgreSQL\nserializes an import identity with a transaction-scoped advisory lock. Only\nrolled-back busy/serialization/deadlock errors retry (at most three retries),\nwithout repeating fetch. Connection-invalidated and other failures propagate.\nDo not wrap the service in a caller transaction if the caller expects a durable\ncommit before returning; any outer transaction remains the caller's boundary.\n\nSQLite and PostgreSQL integration tests exercise real transactions and rollback.\nFull catalog children remain unavailable on DuckDB due to core's existing\nON UPDATE CASCADE/self-referential schema limitations documented in\n[catalog-service.md](catalog-service.md); no alternate schema is substituted.\nTests use synthetic local HTML and an injected fetch callback, never the network.\n\nGeneric “Printable” and “Printables” labels identify lesson PDFs; course packets\nrequire explicit packet/whole-course/complete-course wording. Only actual href\nattributes supply pointers; data-href metadata does not.\n"
4186
+ "content": "# Source import integration\n\n`SourceImportService` is a server-side host boundary, with no generated REST,\nCLI, MCP or AI operation. Recipes remain metadata only. Provision a closed\n`CourseSource` in the owning tenant, then construct the service with the root\nSQL database and an actor-bound `SourceImportAuthorizer`. Enable tenancy and\ncall within `withTenant({ tenantId, userId })`; the request must match both.\nMissing context, a different actor/tenant and super-admin bypass fail closed.\n\nRegister Easy Peasy only after the host operator accepts its terms, using\n`registerEasyPeasy({ termsAccepted: true, fetchPage })`. Do not derive that\nboolean from an HTTP request. `fetchPage(url, { redirect: 'error' })` must reject\nredirects, bound time/size and return `{ html, url }`. It fetches the page only,\nnever a PDF. The service checks the approved HTTPS URL before fetching, checks\nthe response URL, limits HTML to 2 million characters, then parses the complete\npage before any write. There is no automatic registration or network access. Structural h1–h6 headings delimit day content, including trailing Resources\nsections; ordinary non-lesson bold instructions do not. Supported\nheading blocks are h1–h6 or a lesson marker as the first bold run in a\nparagraph/list item.\nRecognizable markers outside that layout, including multiple markers in one\nparagraph, fail closed rather than silently assigning a PDF to the wrong day.\n\nThe authorizer must verify the authenticated actor's management grant and the\nprovisioned tenant's family/group classification. Tenant has no built-in kind;\nneither hierarchy position nor client input establishes a family. Authorization\nis required before fetch and again inside the transaction; callbacks must be\nread-only decisions, since a rolled-back contention retry can call them again.\n\n`sharing` is a ceiling: private permits private-family import only; group and\nglobal permit private-family or group import. Group imports additionally require\nnonblank host-recorded `config.licenseEvidence` and an explicit host grant that\nvalidates that permission. Global/null destinations are unsupported and rejected;\nthere is no global/system bypass. Widening a source never promotes existing\nimports. Private imports are draft `personal-use-copy` courses and retain the\ncatalog's immutable owner/license and no-publication protection. A host may\ncreate a new licensed group source and import there after receiving permission.\n\nUse a stable `courseRef` independent of source URLs. Identities derive from the\nowning tenant, source row ID, course reference and lesson day; retain the source\nrow when updating policy. Changes to titles/page/PDF URLs update in place.\nMissing days are retained, not deleted, so enrollment, progress, submission and\nasset foreign keys remain valid. A changed lesson kind fails the whole import\nrather than replacing a row. Only title and pointer fields are refreshed; body,\nanswer keys, learner state and manually authored settings are never replaced.\nThe packet URL is returned as a source link and is not downloaded or mirrored.\n\nAll source policy rechecks and catalog mutations use collections bound to the\nsame SQL transaction. Embedded writes use the public write queue; PostgreSQL\nserializes an import identity with a transaction-scoped advisory lock. Only\nrolled-back busy/serialization/deadlock errors retry (at most three retries),\nwithout repeating fetch. Connection-invalidated and other failures propagate.\nDo not wrap the service in a caller transaction if the caller expects a durable\ncommit before returning; any outer transaction remains the caller's boundary.\n\nSQLite and PostgreSQL integration tests exercise real transactions and rollback.\nFull catalog children remain unavailable on DuckDB due to core's existing\nON UPDATE CASCADE/self-referential schema limitations documented in\n[catalog-service.md](catalog-service.md); no alternate schema is substituted.\nTests use synthetic local HTML and an injected fetch callback, never the network.\n\nGoogle Drive discovery defaults every record to `teacher`. A trusted host may\nmake only an exact reviewed file ID visible by passing `studentSafeFileIds`;\nfolder IDs and descendant membership never grant visibility. A teacher-marked\nfolder or name still wins over that allowlist. File shortcuts are excluded: a\nshortcut does not prove the target remains under a reviewed root or retains the\ntarget's teacher-path classification. Resync after changing the allowlist: the\ndurable sync upsert rewrites existing visibility rather than retaining a\nprevious learner classification.\n\nGeneric “Printable” and “Printables” labels identify lesson PDFs; course packets\nrequire explicit packet/whole-course/complete-course wording. Only actual href\nattributes supply pointers; data-href metadata does not.\n"
3495
4187
  }
3496
4188
  ],
3497
4189
  "recipes": [
@@ -3535,7 +4227,7 @@
3535
4227
  "demo": {
3536
4228
  "mode": "server",
3537
4229
  "reasons": [
3538
- "Needs a server because its package depends on @happyvertical/smrt-profiles, @happyvertical/smrt-tenancy, @happyvertical/smrt-users, which do not build for a browser (#3617, #3624, #3626)."
4230
+ "Needs a server because its package depends on @happyvertical/smrt-agents, @happyvertical/smrt-jobs, @happyvertical/smrt-profiles, @happyvertical/smrt-reports, @happyvertical/smrt-secrets, @happyvertical/smrt-tenancy, @happyvertical/smrt-users, which do not build for a browser (#3615, #3617, #3624, #3625, #3626, #3627)."
3539
4231
  ]
3540
4232
  }
3541
4233
  },
@@ -3579,7 +4271,7 @@
3579
4271
  "demo": {
3580
4272
  "mode": "server",
3581
4273
  "reasons": [
3582
- "Needs a server because its package depends on @happyvertical/smrt-profiles, @happyvertical/smrt-tenancy, @happyvertical/smrt-users, which do not build for a browser (#3617, #3624, #3626)."
4274
+ "Needs a server because its package depends on @happyvertical/smrt-agents, @happyvertical/smrt-jobs, @happyvertical/smrt-profiles, @happyvertical/smrt-reports, @happyvertical/smrt-secrets, @happyvertical/smrt-tenancy, @happyvertical/smrt-users, which do not build for a browser (#3615, #3617, #3624, #3625, #3626, #3627)."
3583
4275
  ]
3584
4276
  }
3585
4277
  }
@@ -3587,12 +4279,19 @@
3587
4279
  "browser": {
3588
4280
  "status": "server-only",
3589
4281
  "issues": [
4282
+ "#3615",
3590
4283
  "#3617",
3591
4284
  "#3624",
3592
- "#3626"
4285
+ "#3625",
4286
+ "#3626",
4287
+ "#3627"
3593
4288
  ],
3594
4289
  "via": [
4290
+ "@happyvertical/smrt-agents",
4291
+ "@happyvertical/smrt-jobs",
3595
4292
  "@happyvertical/smrt-profiles",
4293
+ "@happyvertical/smrt-reports",
4294
+ "@happyvertical/smrt-secrets",
3596
4295
  "@happyvertical/smrt-tenancy",
3597
4296
  "@happyvertical/smrt-users"
3598
4297
  ]