@happyvertical/smrt-education 0.58.0 → 0.59.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/AGENTS.md +6 -1
  2. package/agents/class-crunchlabs.md +83 -0
  3. package/agents/course-bundle-import.md +108 -18
  4. package/agents/group-sharing.md +7 -0
  5. package/dist/catalog-contracts.d.ts +6 -2
  6. package/dist/catalog-contracts.d.ts.map +1 -1
  7. package/dist/catalog-materialization-service.d.ts.map +1 -1
  8. package/dist/catalog-policy.d.ts +11 -1
  9. package/dist/catalog-policy.d.ts.map +1 -1
  10. package/dist/catalog-preview.d.ts +6 -1
  11. package/dist/catalog-preview.d.ts.map +1 -1
  12. package/dist/chunks/course-authoring-contracts-C4Hqba-e.js +131 -0
  13. package/dist/chunks/course-authoring-contracts-C4Hqba-e.js.map +1 -0
  14. package/dist/chunks/course-bundle-zip-5KUe3C5H.js +323 -0
  15. package/dist/chunks/course-bundle-zip-5KUe3C5H.js.map +1 -0
  16. package/dist/chunks/{workbook-photo-service-COtyQL1y.js → workbook-photo-service-CjcB0y8Y.js} +226 -130
  17. package/dist/chunks/workbook-photo-service-CjcB0y8Y.js.map +1 -0
  18. package/dist/course-authoring-contracts.d.ts +1 -1
  19. package/dist/course-authoring-contracts.d.ts.map +1 -1
  20. package/dist/course-bundle-contracts.d.ts +21 -3
  21. package/dist/course-bundle-contracts.d.ts.map +1 -1
  22. package/dist/course-bundle-manifest-util.d.ts +15 -0
  23. package/dist/course-bundle-manifest-util.d.ts.map +1 -0
  24. package/dist/course-bundle-manifest-v2.d.ts +66 -0
  25. package/dist/course-bundle-manifest-v2.d.ts.map +1 -0
  26. package/dist/course-bundle-models.d.ts +29 -1
  27. package/dist/course-bundle-models.d.ts.map +1 -1
  28. package/dist/course-bundle-service.d.ts +104 -5
  29. package/dist/course-bundle-service.d.ts.map +1 -1
  30. package/dist/course-bundle.d.ts +81 -2
  31. package/dist/course-bundle.d.ts.map +1 -1
  32. package/dist/document-converter.d.ts +30 -18
  33. package/dist/document-converter.d.ts.map +1 -1
  34. package/dist/group-sharing-contracts.d.ts +4 -0
  35. package/dist/group-sharing-contracts.d.ts.map +1 -1
  36. package/dist/group-sharing-reader.d.ts.map +1 -1
  37. package/dist/hearth-catalog-providers.d.ts +1 -1
  38. package/dist/hearth-catalog-providers.d.ts.map +1 -1
  39. package/dist/hearth-class-crunchlabs.d.ts +94 -0
  40. package/dist/hearth-class-crunchlabs.d.ts.map +1 -0
  41. package/dist/index.d.ts +5 -2
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +1654 -400
  44. package/dist/index.js.map +1 -1
  45. package/dist/ingestion.js +1 -1
  46. package/dist/manifest.json +418 -6
  47. package/dist/node.d.ts +3 -0
  48. package/dist/node.d.ts.map +1 -0
  49. package/dist/node.js +134 -0
  50. package/dist/node.js.map +1 -0
  51. package/dist/smrt-knowledge.json +164 -12
  52. package/dist/sources/class-crunchlabs-files.d.ts +8 -0
  53. package/dist/sources/class-crunchlabs-files.d.ts.map +1 -0
  54. package/dist/sources/class-crunchlabs.d.ts +217 -0
  55. package/dist/sources/class-crunchlabs.d.ts.map +1 -0
  56. package/package.json +12 -8
  57. package/dist/chunks/workbook-photo-service-COtyQL1y.js.map +0 -1
@@ -3,13 +3,13 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-education",
6
- "packageVersion": "0.58.0",
6
+ "packageVersion": "0.59.1",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "75b8165b6abf95186fad6f02c6dbbe42c121520c2317621c6013426ad64ca0e3",
11
- "packageJson": "0f19188d8f43c8cc001d6f752dbdfac617e1f4a06616cfc950e6c8909bebb6b1",
12
- "agents": "2bce7f0cbfc77e210c15341ccca74da23ec63cc3e826c5fee1fc03c7bf640db6",
10
+ "manifest": "e12ad63483f8603eafb0bfc8b63a48ca972a1ebca33f24e2f6478fa6889060bc",
11
+ "packageJson": "474110dd446c3d295b233c04b548c4973553731487b1e51872b6e3e687a766f6",
12
+ "agents": "6f6c8fbb6bd5e2fa28c992987c889d7cc3b067566f1415863228908494a57ca3",
13
13
  "moduleDoc:agents/lesson-materials.md": "f103812b0f5ebfda446747ae9e4d7a97254e22b12aebc2ec43a865c5bd56e56a",
14
14
  "moduleDoc:agents/learner-service.md": "d7edf68000aec96b52920fb5e8dc4ff4e20b1d81b34413809e8212a885e1c3f9",
15
15
  "moduleDoc:agents/submission-service.md": "0bb827a62848a3e0708fe2545eac7b64f829a6c9be96eee849c8f16c1fa631d0",
@@ -20,19 +20,21 @@
20
20
  "moduleDoc:agents/course-generation-review.md": "7f6d5e67ed56ec9923bd00df05650b363fb0db30c1b1f004c8b203f9c62cd117",
21
21
  "moduleDoc:agents/question-records.md": "c1648cccc27ed46b1b60cded93b20c7f3812cbcd2c37f183fd727ac030364703",
22
22
  "moduleDoc:agents/question-extraction.md": "75972dda9b2f837471fb42f963ef143a098005c919a1a49db7ce395a5e5e50a7",
23
- "moduleDoc:agents/course-bundle-import.md": "4ae9ec59d1ad91cbd7019f73cf38dd788cdd28375c8964c3b08b91feddfc5086",
23
+ "moduleDoc:agents/course-bundle-import.md": "9829211a6af7f600ea41a4143b4355b510bcb04c720d4b1011d3d98845c08377",
24
+ "moduleDoc:agents/class-crunchlabs.md": "3ce60bed13802d482fa7ba330363c6bed2c777eff803cdca6429330722ed63a8",
24
25
  "moduleDoc:agents/source-import.md": "30ee823589d655442bcf1abcd86a2132172065b4bc5e3c4d89b1e8b7afcc66bc",
25
26
  "moduleDoc:agents/family-intake.md": "540b6d37f438496acb0208b6ac7258264e2142bce3d2f986967b7741606fe4f7",
26
27
  "moduleDoc:agents/catalog-previews.md": "5f4e0352274009b3ce00408017a16666036d24df695a7078c27317ba4e66f0a7",
27
28
  "moduleDoc:agents/learner-plan.md": "b4691c3ec541be921ba8c550f5e080c8b351e736ba50c29cf3a2a8674c642af6",
28
- "moduleDoc:agents/group-sharing.md": "7967d999c092d193b5c81cd598a9dbb71fb7de2fe42ca34dca83ba7b650f0253",
29
+ "moduleDoc:agents/group-sharing.md": "e669510ec47392b88315a84ffa34dd64020aeb799d7bcbf213f2f07ede02ea92",
29
30
  "moduleDoc:agents/tutor-context.md": "7207a4051eeb77af0a161ae17caf724375d3d5e30d3bf52a4c06793cfc6c5507"
30
31
  },
31
32
  "exports": [
32
33
  ".",
33
34
  "./ingestion",
34
35
  "./manifest",
35
- "./manifest.json"
36
+ "./manifest.json",
37
+ "./node"
36
38
  ],
37
39
  "dependencies": {
38
40
  "@happyvertical/smrt-assets": "workspace:*",
@@ -5283,6 +5285,12 @@
5283
5285
  "required": false,
5284
5286
  "columnType": "TEXT"
5285
5287
  },
5288
+ {
5289
+ "name": "audience",
5290
+ "type": "text",
5291
+ "required": false,
5292
+ "columnType": "TEXT"
5293
+ },
5286
5294
  {
5287
5295
  "name": "sectionKey",
5288
5296
  "type": "text",
@@ -5394,6 +5402,145 @@
5394
5402
  "tags": [],
5395
5403
  "risks": []
5396
5404
  },
5405
+ {
5406
+ "name": "CourseBundleRendering",
5407
+ "qualifiedName": "@happyvertical/smrt-education:CourseBundleRendering",
5408
+ "collection": "coursebundlerenderings",
5409
+ "description": "Append-only record that a Word/PowerPoint member whose first rendering failed was rendered later by a deliberate retry (SMRT #4615).",
5410
+ "tableName": "course_bundle_renderings",
5411
+ "packageName": "@happyvertical/smrt-education",
5412
+ "extends": "SmrtObject",
5413
+ "fields": [
5414
+ {
5415
+ "name": "tenantId",
5416
+ "type": "text",
5417
+ "required": true,
5418
+ "columnType": "UUID"
5419
+ },
5420
+ {
5421
+ "name": "importId",
5422
+ "type": "foreignKey",
5423
+ "required": true,
5424
+ "related": "CourseBundleImport",
5425
+ "columnType": "UUID"
5426
+ },
5427
+ {
5428
+ "name": "sha256",
5429
+ "type": "text",
5430
+ "required": false,
5431
+ "columnType": "TEXT"
5432
+ },
5433
+ {
5434
+ "name": "renderedSha256",
5435
+ "type": "text",
5436
+ "required": false,
5437
+ "columnType": "TEXT"
5438
+ },
5439
+ {
5440
+ "name": "renderedSizeBytes",
5441
+ "type": "integer",
5442
+ "required": true,
5443
+ "columnType": "INTEGER",
5444
+ "default": 0
5445
+ },
5446
+ {
5447
+ "name": "converterId",
5448
+ "type": "text",
5449
+ "required": false,
5450
+ "columnType": "TEXT"
5451
+ },
5452
+ {
5453
+ "name": "retriedByUserId",
5454
+ "type": "text",
5455
+ "required": false,
5456
+ "columnType": "TEXT"
5457
+ },
5458
+ {
5459
+ "name": "recordedAt",
5460
+ "type": "text",
5461
+ "required": false,
5462
+ "columnType": "TEXT"
5463
+ }
5464
+ ],
5465
+ "relationships": [
5466
+ {
5467
+ "name": "importId",
5468
+ "type": "foreignKey",
5469
+ "required": true,
5470
+ "related": "CourseBundleImport",
5471
+ "columnType": "UUID"
5472
+ }
5473
+ ],
5474
+ "methods": [
5475
+ "delete",
5476
+ "save"
5477
+ ],
5478
+ "methodSignatures": [
5479
+ {
5480
+ "name": "delete",
5481
+ "async": true,
5482
+ "returns": "Promise<void>"
5483
+ },
5484
+ {
5485
+ "name": "save",
5486
+ "async": true,
5487
+ "params": [
5488
+ "options?: SmrtSaveOptions"
5489
+ ],
5490
+ "returns": "Promise<this>"
5491
+ }
5492
+ ],
5493
+ "tenant": {
5494
+ "scoped": true,
5495
+ "mode": "required",
5496
+ "field": "tenantId"
5497
+ },
5498
+ "conflictColumns": [
5499
+ "tenant_id",
5500
+ "import_id",
5501
+ "sha256"
5502
+ ],
5503
+ "surfaces": [],
5504
+ "withheldSurfaces": [
5505
+ {
5506
+ "kind": "api",
5507
+ "operation": "delete",
5508
+ "code": "api-disabled",
5509
+ "reason": "api is disabled",
5510
+ "objectName": "@happyvertical/smrt-education:CourseBundleRendering"
5511
+ },
5512
+ {
5513
+ "kind": "api",
5514
+ "operation": "save",
5515
+ "code": "api-disabled",
5516
+ "reason": "api is disabled",
5517
+ "objectName": "@happyvertical/smrt-education:CourseBundleRendering"
5518
+ }
5519
+ ],
5520
+ "relationshipFeatures": [
5521
+ "foreignKey",
5522
+ "uuidColumns"
5523
+ ],
5524
+ "tags": [],
5525
+ "risks": []
5526
+ },
5527
+ {
5528
+ "name": "CourseBundleRenderingCollection",
5529
+ "qualifiedName": "@happyvertical/smrt-education:CourseBundleRenderingCollection",
5530
+ "collection": "coursebundlerenderings",
5531
+ "tableName": "course_bundle_renderings",
5532
+ "packageName": "@happyvertical/smrt-education",
5533
+ "extends": "SmrtCollection",
5534
+ "fields": [],
5535
+ "relationships": [],
5536
+ "methods": [],
5537
+ "surfaces": [],
5538
+ "relationshipFeatures": [
5539
+ "uuidColumns"
5540
+ ],
5541
+ "tags": [],
5542
+ "risks": []
5543
+ },
5397
5544
  {
5398
5545
  "name": "CourseGenerationJob",
5399
5546
  "qualifiedName": "@happyvertical/smrt-education:CourseGenerationJob",
@@ -15484,14 +15631,14 @@
15484
15631
  "surfaces": [],
15485
15632
  "prompts": [],
15486
15633
  "relationshipsV2": {
15487
- "foreignKeyFields": 188,
15634
+ "foreignKeyFields": 189,
15488
15635
  "crossPackageRefFields": 24,
15489
15636
  "junctionCollections": 2,
15490
15637
  "hierarchicalObjects": 0,
15491
15638
  "polymorphicAssociations": 0,
15492
- "uuidColumns": 794
15639
+ "uuidColumns": 800
15493
15640
  },
15494
- "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`/`assessment-guidance` material\nare private family material. Do not publish them, promote them, or expose them\nthrough a learner-facing result. `LessonAsset` (lesson) and `CourseAsset` (course)\nare the sole owner joins for material files; do not add asset-id fields to lesson\nsubclasses. Material audience rules and external videos: [lesson materials](agents/lesson-materials.md);\na learner never sees guardian-audience material.\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\nEvery lesson hand-in is lesson work (activity, attempt, revisions, release,\ncompletion decision); books are courses with `bookKey`, pages are `LessonPage`s,\nand loose work is placed by filing. Use [LessonWorkService](agents/lesson-work-service.md)\nand the [design record](agents/course-work-design.md); `migrateWorkbooksToCourses`\nis the one-time host migration. Keep provisional sharing separate from guardian\nGrade authority; never move filed work's IDs. Photo-first receipt/context\ncapture uses [WorkbookPhotoService](agents/photo-intake.md); covers/references\nnever create learning evidence, and blank exercises need explicit work intent.\n\nRevisioned authored courses, release, archive, enrollment pins and licence use\n[CourseAuthoringService](agents/course-authoring-service.md). Keep history\nappend-only, require request keys, and never weaken personal-use-copy rules.\n\nGeneration proposals into new draft revisions and revision-bound educational\nreview use [CourseGenerationReviewService](agents/course-generation-review.md).\nKeep supplied content unless a decision is recorded; reviews carry no score.\n\nImmutable questions, answer keys/rubrics, stable learning objectives, worksheet\nrender ids and exact-revision references use [QuestionAuthoringService](agents/question-records.md).\nKeep keys out of learner bundles; a missing key stays null. `attachGeneratedPrintable`\nstores the rendered PDFs; the guardian copy is always an `answer-key`, never `printable`.\n\nStructured questions from scans and imported course files use\n[QuestionExtractionService](agents/question-extraction.md) and the `extract-questions`\ningestion handler. Anchors come from provider region catalogs only; match by worksheet\nid and layout, never text; ambiguous matches stay unattached; source-keyed replays.\n\nCourse bundles (zip with a closed manifest, or a file set; Word/PowerPoint members render through an injected `DocumentConverter`, fail closed) use\n[CourseBundleImportService](agents/course-bundle-import.md). Parse only with the\nbounded reader; refuse, never trim, hostile input; unsupported members stay\nvisible; proposals only, nothing auto-published.\n\nSource import: [host boundary and transaction contract](agents/source-import.md).\n`SourceImportService` requires host opt-in, matching active actor/tenant and\nexplicit licensing authorization; never add a system/global write bypass.\n\nFamily-owned unassigned originals and audited learner adoption use\n[FamilyIntakeService](agents/family-intake.md). Keep family source scope immutable;\nassignment alone never creates learning history or grants learner inbox access.\n\nPublisher catalog previews use [the preview contract](agents/catalog-previews.md).\nKeep independent image rights and source revisions; serve via `CatalogPreviewService`\nwith live actor/tenant authorization, never direct provider image URLs.\n\nEnrollment Plan projections and guardian-only parent notes use\n[the Plan contract](agents/learner-plan.md). Keep awaiting review separate from\nContinue; note authority/provenance never grants learner access or learning evidence.\n\nHomeschool group sharing uses [GroupSharingService](agents/group-sharing.md),\nits read-only reader and revision comment service. Group tenants never become\nfamily ancestors or guardians. Enumerate only the group-owned opaque roster index,\nthen revalidate each exact family edge and current policy. Media delivery must call\nthe reader on every request; signed storage URLs are not a sharing grant. Saved\nparent comment visibility applies on every child read. Progress with feedback\ndisabled contains counts only, never correctness, scores or concept labels.\n\nLearner-safe tutor context and append-only tutor transcripts use\n[TutorContextService and TutorSessionService](agents/tutor-context.md). Learner\naudience never carries keys, rubrics, notes or storage URIs; no AI call here.\n",
15641
+ "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`/`assessment-guidance` material\nare private family material. Do not publish them, promote them, or expose them\nthrough a learner-facing result. `LessonAsset` (lesson) and `CourseAsset` (course)\nare the sole owner joins for material files; do not add asset-id fields to lesson\nsubclasses. Material audience rules and external videos: [lesson materials](agents/lesson-materials.md);\na learner never sees guardian-audience material.\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\nEvery lesson hand-in is lesson work (activity, attempt, revisions, release,\ncompletion decision); books are courses with `bookKey`, pages are `LessonPage`s,\nand loose work is placed by filing. Use [LessonWorkService](agents/lesson-work-service.md)\nand the [design record](agents/course-work-design.md); `migrateWorkbooksToCourses`\nis the one-time host migration. Keep provisional sharing separate from guardian\nGrade authority; never move filed work's IDs. Photo-first receipt/context\ncapture uses [WorkbookPhotoService](agents/photo-intake.md); covers/references\nnever create learning evidence, and blank exercises need explicit work intent.\n\nRevisioned authored courses, release, archive, enrollment pins and licence use\n[CourseAuthoringService](agents/course-authoring-service.md). Keep history\nappend-only, require request keys, and never weaken personal-use-copy rules.\n\nGeneration proposals into new draft revisions and revision-bound educational\nreview use [CourseGenerationReviewService](agents/course-generation-review.md).\nKeep supplied content unless a decision is recorded; reviews carry no score.\n\nImmutable questions, answer keys/rubrics, stable learning objectives, worksheet\nrender ids and exact-revision references use [QuestionAuthoringService](agents/question-records.md).\nKeep keys out of learner bundles; a missing key stays null. `attachGeneratedPrintable`\nstores the rendered PDFs; the guardian copy is always an `answer-key`, never `printable`.\n\nStructured questions from scans and imported course files use\n[QuestionExtractionService](agents/question-extraction.md) and the `extract-questions`\ningestion handler. Anchors come from provider region catalogs only; match by worksheet\nid and layout, never text; ambiguous matches stay unattached; source-keyed replays.\n\nCourse bundles (zip with a closed manifest v1 or v2, or a file set; Word/PowerPoint members render through an injected `DocumentConverter`, fail closed; v2 carries material roles, audience, lesson pages, course members and YouTube references) use\n[CourseBundleImportService](agents/course-bundle-import.md). Parse only with the\nbounded reader; refuse, never trim, hostile input; unsupported members stay\nvisible; proposals only, nothing auto-published.\n\nClass CrunchLabs (a download read into a manifest v2 bundle; an operator-only\nglobal catalog provider until the operator's single public switch):\n[source contract](agents/class-crunchlabs.md). Never host its files; `operator`\nvisibility needs the host's `operatorTenantIds`.\n\nSource import: [host boundary and transaction contract](agents/source-import.md).\n`SourceImportService` requires host opt-in, matching active actor/tenant and\nexplicit licensing authorization; never add a system/global write bypass.\n\nFamily-owned unassigned originals and audited learner adoption use\n[FamilyIntakeService](agents/family-intake.md). Keep family source scope immutable;\nassignment alone never creates learning history or grants learner inbox access.\n\nPublisher catalog previews use [the preview contract](agents/catalog-previews.md).\nKeep independent image rights and source revisions; serve via `CatalogPreviewService`\nwith live actor/tenant authorization, never direct provider image URLs.\n\nEnrollment Plan projections and guardian-only parent notes use\n[the Plan contract](agents/learner-plan.md). Keep awaiting review separate from\nContinue; note authority/provenance never grants learner access or learning evidence.\n\nHomeschool group sharing uses [GroupSharingService](agents/group-sharing.md),\nits read-only reader and revision comment service. Group tenants never become\nfamily ancestors or guardians. Enumerate only the group-owned opaque roster index,\nthen revalidate each exact family edge and current policy. Media delivery must call\nthe reader on every request; signed storage URLs are not a sharing grant. Saved\nparent comment visibility applies on every child read. Progress with feedback\ndisabled contains counts only, never correctness, scores or concept labels.\n\nLearner-safe tutor context and append-only tutor transcripts use\n[TutorContextService and TutorSessionService](agents/tutor-context.md). Learner\naudience never carries keys, rubrics, notes or storage URIs; no AI call here.\n",
15495
15642
  "moduleDocs": [
15496
15643
  {
15497
15644
  "path": "agents/lesson-materials.md",
@@ -15546,7 +15693,12 @@
15546
15693
  {
15547
15694
  "path": "agents/course-bundle-import.md",
15548
15695
  "module": "course-bundle-import",
15549
- "content": "# Course bundle import\n\n`CourseBundleImportService` (SMRT #4578, extends `CourseAuthoringService`,\nconsumed by Hearth #94) imports a course bundle, a zip with an optional manifest\nor a plain file set, as bounded evidence, hands its supported members to the\nhost for ingestion extraction and proposes sections and lessons for review.\nRisk is high: untrusted archive parsing and new schema. It reuses the course\nauthorizer: `importBundle` needs `education.course.author`, `readImport`\n`education.course.read-draft`, `proposeStructure` `education.course.generate`\n(via `CourseGenerationReviewService`). Operations: `import-course-bundle`,\n`read-course-bundle`. Server only (Node `zlib`/`crypto`); no new dependency.\n\n## Flow\n\n1. `importBundle({ ...actor, bundle, limits?, onProgress?, receiveMember? })`\n authorizes **before parsing**, unpacks (stage `unpack`), classifies and lays\n out the structure (`classify`), writes the evidence in one transaction and\n then calls `receiveMember` for each accepted member (`extract`), outside any\n transaction. `bundle` is `{ archive: Uint8Array }` or `{ files: [{ path,\n bytes }] }`. Hostile or damaged input throws `CourseBundleRefusedError`\n (`reason`, optional `entry`) and writes nothing.\n2. `receiveMember(member, { replayed })` is the host's ingestion seam: call\n `IngestionService.receive` with `sourceKind: 'course_import'` and the member\n bytes, then queue extraction and `preserveQuestionExtractionAnalysis` as in\n [question extraction](question-extraction.md). Members carry `role`\n (manifest page role), `lessonKey`, `sectionKey`. It runs on a replay too\n (ingestion dedups by content hash), so an interrupted first run resumes. Its\n return values are in `result.receipts`.\n3. `proposeStructure({ ...actor, importId, bundle, courseId,\n expectedHeadRevisionId })` (stage `propose`) re-supplies the bundle (bytes\n are never retained), checks it is the imported one, and runs a deterministic\n `CourseMaterialGenerator` (`smrt-bundle-import`) through\n `requestGeneration`/`runGeneration`. The result is an ordinary proposal: a\n new draft revision of additions and fills; conflicts wait for\n `decideProposal`. Nothing is released, assigned or published. The request key\n is `bundle:<courseId>:<bundleIdentity>`, so a retry returns the same job; the\n same key with another head is `CourseRequestKeyConflictError`.\n4. `readImport({ ...actor, importId })` returns the recorded evidence.\n\nProgress is an observer callback (`onProgress({ stage, completed, total })`,\nstages `unpack`, `classify`, `render`, `extract`, `propose`; each starts at `completed: 0`;\n`render` is emitted only when the bundle has Word/PowerPoint members).\nIngestion has no public job-progress seam, so none is invented. A throwing\nlistener never affects the import.\n\n## Hard bounds (`COURSE_BUNDLE_LIMITS`; a host may only lower them)\n\n| limit | value |\n|---|---|\n| `maxArchiveBytes` (input) | 100 MiB |\n| `maxEntries` | 200 |\n| `maxEntryBytes` (inflated) | 50 MiB |\n| `maxTotalBytes` (inflated) | 200 MiB |\n| `maxCompressionRatio` (members over `ratioFloorBytes` = 64 KiB) | 100 |\n| `maxPathBytes` / `maxSegmentBytes` / `maxPathDepth` | 512 / 255 / 8 |\n| `maxManifestBytes` | 256 KiB |\n| `maxLessons` (the proposal schema's cap) | 50 |\n| `maxLessonBodyChars` | 100 000 |\n| `maxOfficeMembers` (Word/PowerPoint members rendered per bundle) | 50 |\n| `maxRenderedBytes` (PDF accepted back from the converter) | 50 MiB |\n| `conversionTimeoutMs` (one Office member) | 60 000 |\n| archive nesting | 1: a zip inside a zip is never expanded |\n\nA lesson whose combined text members exceed `maxLessonBodyChars` refuses the\nbundle (`lesson_too_large`, `entry` = lesson key) at import; text is never\ntrimmed or dropped. A plain file set enforces `maxTotalBytes` on its member\nbytes exactly as an archive does on inflated bytes.\n\nThe reader (`course-bundle-zip.ts`) parses the end record and central directory,\nvalidates every offset and size against the real buffer, inflates with\n`maxOutputLength` equal to the declared size (declared sizes are never trusted:\na mismatch either way is `size_mismatch`), checks CRC32 and re-checks the total\non actual output. Stored and deflate only. Refusals (`CourseBundleRefusalReason`)\ncover: not a zip, truncated, ambiguous end record (two end records that both fit\nthe file, for example one forged in a comment), zip64, multi-disk, central\ndirectory not ending at the end record, entries out of range or overlapping,\nlocal header name/method mismatch, encrypted entries (flags 0/6/13, AES method),\nsymlink and special files (unix mode; DOS reparse flag), unsupported method,\nbad CRC, ratio, entry count, totals, duplicate names (case/NFC-insensitive,\nbackslashes normalised first) and bad paths (`..`, `.`, empty segments, absolute,\ndrive letter or `:` anywhere, NUL/control/bidi characters, lone surrogates,\ntoo long or deep). An **honestly** oversize member (declared over\n`maxEntryBytes`) is not inflated and is recorded `refused / entry_too_large`.\n\n## Members and statuses\n\nBy content (never by extension alone): `docx`/`pptx` (see below), `pdf` (`%PDF-` in the first 1 KiB),\n`image` (PNG, JPEG, WebP), `text` (`.txt`/`.md`, strict UTF-8, no control\ncharacters, body-sized), `manifest` (root `manifest.json`), else `unsupported`.\nStatus: `accepted` (handed to extraction), `duplicate` (same sha256 as an earlier\nmember in canonical order; recorded once as evidence, `duplicateOfPath`, never\nextracted again), `unsupported` (`unsupported_type`, `nested_archive`,\n`unreadable_text`), `refused` (`encrypted_pdf`, `entry_too_large`), `metadata`\n(the manifest). Nothing is dropped. An encrypted PDF is any PDF with an\n`/Encrypt` token anywhere in its bytes (trailer, xref stream): a false positive\non literal text inside an uncompressed stream is refused, never read.\n\n## Word and PowerPoint members (SMRT #4615)\n\nA `.docx`/`.pptx` member is an OOXML package only when the name and the content\nagree: a zip whose entries include `[Content_Types].xml` and `word/document.xml`\n(docx) or `ppt/presentation.xml` (pptx). Before the bytes go anywhere, the\npackage itself passes the same bounded zip reader (ratio, overlap, paths, CRC,\n100 MiB inflated); a failure is `refused / unsafe_office`, never a refusal of the\nwhole bundle. Other zips stay `unsupported / nested_archive`.\n\nTwo separate things happen to an accepted Office member, both after\nauthorization and before the evidence is written:\n\n- **Pages**: a `DocumentConverter` (`document-converter.ts`) renders the original\n to PDF. The importer enforces the byte and time limits itself (input\n `maxEntryBytes`, output `maxRenderedBytes`, `conversionTimeoutMs` with an abort\n signal and a hard race, so an adapter that ignores the signal still times out),\n and rejects output that is not a PDF or is encrypted. The converter is told a\n neutral file name (`document.docx`), never the bundle path. Identical documents\n are rendered once.\n- **Text**: an injectable `OfficeTextReader` (best effort; a failure or a `null`\n costs the member nothing) returns whole-document text and, for decks, text and\n speaker notes per slide. Office text is handed to the host on the accepted\n member (`officeText`) and is **never** inlined into a proposed lesson body:\n teacher guides and notes carry answers, and audience belongs to manifest v2.\n\n`receiveMember` gets the original `bytes` (store them as the private original\nasset: \"Open in PowerPoint\" for decks) and `rendered` (`{ bytes, sha256, mediaType:\n'application/pdf', converterId }`, store as a private asset beside the original:\nthe pages for \"Present\"/reading). The member row records `renderedSha256` and\n`renderedSizeBytes`; bytes are still not retained here.\n\n**Fail closed.** Any converter problem records that member `unsupported` with a\nreason and writes the rest of the bundle normally: `converter_unavailable` (none\nconfigured), `converter_failed` (unreachable or 5xx), `converter_timeout`,\n`converter_too_large`, `converter_rejected` (HTTP 4xx, not a PDF, encrypted),\n`office_limit` (beyond `maxOfficeMembers`, in natural path order). A member that\nfailed is not a lesson in the proposal, and identical copies of a failed document\nfail the same way rather than becoming duplicates of nothing. Outcomes are part\nof the append-only evidence, so a replay does not retry a recorded failure (to\nretry, change the bundle). A replay does re-render recorded-accepted members so the\nhost receives the bytes again; if it can no longer render one it throws\n`DocumentConversionError` rather than silently dropping an accepted member.\n\n### Host configuration\n\n```ts\nconst service = new CourseBundleImportService(db, authorizer, {\n converter: new GotenbergDocumentConverter({\n baseUrl: process.env.GOTENBERG_URL, // for example http://gotenberg:3000\n timeoutMs: 60_000,\n maxBytes: 50 * 1024 * 1024,\n }),\n officeTextReader, // optional, see below\n});\n```\n\n`GotenbergDocumentConverter` posts to `<baseUrl>/forms/libreoffice/convert`, talks\nto nothing else, refuses redirects and URLs with credentials, and caps the response\nas it streams. Run Gotenberg as an internal-only service with **no outbound\nnetwork**, its own size/time limits and no shared volume with the app; this\npackage assumes untrusted files never run next to the application. Tests use\n`FakeDocumentConverter` (records calls; `failWith`, `failWhen`, `delayMs`, `output`).\n\n`OfficeTextReader` is deliberately not wired to the SDK here, so this package\ndoes not depend on an unreleased `@happyvertical/documents`. Once the Office\nprocessors ship (sdk#1485, `extractOffice(bytes, 'docx' | 'pptx')`), implement\n`read` with it; the snippet is in the `OfficeTextReader` doc comment.\n\n## Manifest (`manifest.json`, version 1, closed)\n\n```json\n{ \"version\": 1, \"course\": { \"subject\": \"Math\" },\n \"sections\": [ { \"key\": \"unit-1\", \"title\": \"Unit One\",\n \"lessons\": [ { \"key\": \"sums\", \"title\": \"Sums\",\n \"members\": [ { \"path\": \"sheets/sums.pdf\", \"role\": \"exercise\" } ] } ] } ] }\n```\nEvery object is closed: an unknown key anywhere is `manifest_unknown_key` (with\nits path in `entry`); another version is `manifest_version`. Keys match\n`[A-Za-z0-9._:-]{1,120}` and are unique; roles are the extraction page roles\n(`exercise`, `example`, `instructions`, `cover`, `answer_key`, `mixed`,\n`unknown`); a listed path must exist and be listed once. The manifest is a source\nfact: roles are `source`, anything the extractor infers stays `inferred`, and a\nmanifest/extraction disagreement belongs in extraction review, not here. Without\na manifest each accepted member is one lesson, sections come from the top-level\ndirectory and order is natural path order (digit runs compare numerically, no\nlocale). Manifest order: the manifest, declared members, then the rest by path.\nLesson bodies are the readable text members of a lesson, joined; PDFs and images\nare never inlined. Members whose declared role is `answer_key` or `mixed` never\ncontribute text to a lesson body (they remain recorded evidence members); the\nproposal rationale only counts how many were withheld.\n\n## Readers\n\n`BundleReader { formatId, detect, prepare }` in a `BundleReaderRegistry`;\nbuilt-ins are `manifest-v1` and `plain-files`. SCORM and Common Cartridge are\n**not implemented** (their manifests describe launch resources and activity\ntrees that do not map to sections/lessons without design); a host can register\na reader ahead of the built-ins (`register(reader, { first: true })`).\n\n## Identity and replay\n\n`bundleIdentity` (`cbi1-...`) digests the sorted (path, sha256) pairs, so\nre-zipping, reordering and timestamps replay. One `CourseBundleImport` per\n(tenant, identity); a concurrent first import replays once on the unique index.\n\n## Models (closed, required tenant, no REST/CLI/MCP/AI, append-only)\n\n`CourseBundleImport` (`course_bundle_imports`: identity, format, reader,\nmanifest version, counts, actor) and `CourseBundleMember` (`course_bundle_members`:\nunique tenant/import/path; ordinal, sha256, size, media type, kind, status,\nreason, duplicate-of, role, section and lesson key). Hashes and placements only:\nbytes stay with the host's ingestion receipts. Rewrites and deletes throw.\nNormal manifest migrations create both tables.\n\n## Not in this release\n\n- Manifest version 2 (material roles with audience, lesson pages, course-level\n members, external video references) waits for the phase 1 role vocabulary\n (smrt#4614). The page-role vocabulary and the closed v1 schema are unchanged;\n see the hand-off note on smrt#4615.\n- Re-rendering a member whose first import recorded a converter failure (outcomes\n are append-only per bundle identity).\n- Ingestion's receipt contract is unchanged: there is no `application/zip` part\n type or `parentEvidenceId` in ingestion. The education side unpacks and the\n host receives members one by one, so archive-to-member lineage lives in\n `course_bundle_members` (and the host's receipts), not in ingestion evidence.\n Changing ingestion's published receipt is a separate design decision.\n- A manifest/extraction disagreement is not compared automatically.\n- SCORM / Common Cartridge readers; zip64; nested archives; encrypted zips.\n"
15696
+ "content": "# Course bundle import\n\n`CourseBundleImportService` (SMRT #4578, extends `CourseAuthoringService`,\nconsumed by Hearth #94) imports a course bundle, a zip with an optional manifest\nor a plain file set, as bounded evidence, hands its supported members to the\nhost for ingestion extraction and proposes sections and lessons for review.\nRisk is high: untrusted archive parsing and new schema. It reuses the course\nauthorizer: `importBundle` needs `education.course.author`, `readImport`\n`education.course.read-draft`, `proposeStructure` `education.course.generate`\n(via `CourseGenerationReviewService`). Operations: `import-course-bundle`,\n`read-course-bundle`. Server only (Node `zlib`/`crypto`); no new dependency.\n\n## Flow\n\n1. `importBundle({ ...actor, bundle, limits?, onProgress?, receiveMember? })`\n authorizes **before parsing**, unpacks (stage `unpack`), classifies and lays\n out the structure (`classify`), writes the evidence in one transaction and\n then calls `receiveMember` for each accepted member (`extract`), outside any\n transaction. `bundle` is `{ archive: Uint8Array }` or `{ files: [{ path,\n bytes }] }`. Hostile or damaged input throws `CourseBundleRefusedError`\n (`reason`, optional `entry`) and writes nothing.\n2. `receiveMember(member, { replayed })` is the host's ingestion seam: call\n `IngestionService.receive` with `sourceKind: 'course_import'` and the member\n bytes, then queue extraction and `preserveQuestionExtractionAnalysis` as in\n [question extraction](question-extraction.md). Members carry `role`\n (manifest page role), `lessonKey`, `sectionKey`. It runs on a replay too\n (ingestion dedups by content hash), so an interrupted first run resumes. Its\n return values are in `result.receipts`.\n3. `proposeStructure({ ...actor, importId, bundle, courseId,\n expectedHeadRevisionId })` (stage `propose`) re-supplies the bundle (bytes\n are never retained), checks it is the imported one, and runs a deterministic\n `CourseMaterialGenerator` (`smrt-bundle-import`) through\n `requestGeneration`/`runGeneration`. The result is an ordinary proposal: a\n new draft revision of additions and fills; conflicts wait for\n `decideProposal`. Nothing is released, assigned or published. The request key\n is `bundle:<courseId>:<bundleIdentity>`, so a retry returns the same job; the\n same key with another head is `CourseRequestKeyConflictError`.\n4. `readImport({ ...actor, importId })` returns the recorded evidence.\n\nProgress is an observer callback (`onProgress({ stage, completed, total })`,\nstages `unpack`, `classify`, `render`, `extract`, `propose`; each starts at `completed: 0`;\n`render` is emitted only when the bundle has Word/PowerPoint members).\nIngestion has no public job-progress seam, so none is invented. A throwing\nlistener never affects the import.\n\n## Hard bounds (`COURSE_BUNDLE_LIMITS`; a host may only lower them)\n\n| limit | value |\n|---|---|\n| `maxArchiveBytes` (input) | 100 MiB |\n| `maxEntries` | 200 |\n| `maxEntryBytes` (inflated) | 50 MiB |\n| `maxTotalBytes` (inflated) | 200 MiB |\n| `maxCompressionRatio` (members over `ratioFloorBytes` = 64 KiB) | 100 |\n| `maxPathBytes` / `maxSegmentBytes` / `maxPathDepth` | 512 / 255 / 8 |\n| `maxManifestBytes` | 256 KiB |\n| `maxLessons` (the proposal schema's cap) | 50 |\n| `maxLessonBodyChars` | 100 000 |\n| `maxOfficeMembers` (Word/PowerPoint members rendered per bundle) | 50 |\n| `maxRenderedBytes` (PDF accepted back from the converter) | 50 MiB |\n| `conversionTimeoutMs` (one Office member) | 60 000 |\n| archive nesting | 1: a zip inside a zip is never expanded |\n\nA lesson whose combined text members exceed `maxLessonBodyChars` refuses the\nbundle (`lesson_too_large`, `entry` = lesson key) at import; text is never\ntrimmed or dropped. A plain file set enforces `maxTotalBytes` on its member\nbytes exactly as an archive does on inflated bytes.\n\nThe reader (`course-bundle-zip.ts`) parses the end record and central directory,\nvalidates every offset and size against the real buffer, inflates with\n`maxOutputLength` equal to the declared size (declared sizes are never trusted:\na mismatch either way is `size_mismatch`), checks CRC32 and re-checks the total\non actual output. Stored and deflate only. Refusals (`CourseBundleRefusalReason`)\ncover: not a zip, truncated, ambiguous end record (two end records that both fit\nthe file, for example one forged in a comment), zip64, multi-disk, central\ndirectory not ending at the end record, entries out of range or overlapping,\nlocal header name/method mismatch, encrypted entries (flags 0/6/13, AES method),\nsymlink and special files (unix mode; DOS reparse flag), unsupported method,\nbad CRC, ratio, entry count, totals, duplicate names (case/NFC-insensitive,\nbackslashes normalised first) and bad paths (`..`, `.`, empty segments, absolute,\ndrive letter or `:` anywhere, NUL/control/bidi characters, lone surrogates,\ntoo long or deep). An **honestly** oversize member (declared over\n`maxEntryBytes`) is not inflated and is recorded `refused / entry_too_large`.\n\n## Members and statuses\n\nBy content (never by extension alone): `docx`/`pptx`/`xlsx` (see below), `pdf` (`%PDF-` in the first 1 KiB),\n`image` (PNG, JPEG, WebP), `text` (`.txt`/`.md`, strict UTF-8, no control\ncharacters, body-sized), `manifest` (root `manifest.json`), else `unsupported`.\nStatus: `accepted` (handed to extraction), `duplicate` (same sha256 as an earlier\nmember in canonical order; recorded once as evidence, `duplicateOfPath`, never\nextracted again), `unsupported` (`unsupported_type`, `nested_archive`,\n`unreadable_text`), `refused` (`encrypted_pdf`, `entry_too_large`), `metadata`\n(the manifest). Nothing is dropped. An encrypted PDF is any PDF with an\n`/Encrypt` token anywhere in its bytes (trailer, xref stream): a false positive\non literal text inside an uncompressed stream is refused, never read.\n\n## Word and PowerPoint members (SMRT #4615)\n\nA `.docx`/`.pptx`/`.xlsx` member is an OOXML package only when the name and the\ncontent agree: a zip whose entries include `[Content_Types].xml` and\n`word/document.xml` (docx), `ppt/presentation.xml` (pptx) or `xl/workbook.xml`\n(xlsx). A workbook (a supply list) is rendered and kept like the others but has\nno text reader. Before the bytes go anywhere, the\npackage itself passes the same bounded zip reader (ratio, overlap, paths, CRC,\n100 MiB inflated); a failure is `refused / unsafe_office`, never a refusal of the\nwhole bundle. Other zips stay `unsupported / nested_archive`.\n\nTwo separate things happen to an accepted Office member, both after\nauthorization and before the evidence is written:\n\n- **Pages**: a `DocumentConverter` (`document-converter.ts`) renders the original\n to PDF. The importer enforces the byte and time limits itself (input\n `maxEntryBytes`, output `maxRenderedBytes`, `conversionTimeoutMs` with an abort\n signal and a hard race, so an adapter that ignores the signal still times out),\n and rejects output that is not a PDF or is encrypted. The converter is told a\n neutral file name (`document.docx`), never the bundle path. Identical documents\n are rendered once.\n- **Text**: an injectable `OfficeTextReader` (best effort; a failure or a `null`\n costs the member nothing) returns whole-document text and, for decks, text and\n speaker notes per slide. Office text is handed to the host on the accepted\n member (`officeText`) and is **never** inlined into a proposed lesson body:\n teacher guides and notes carry answers, and audience belongs to manifest v2.\n\n`receiveMember` gets the original `bytes` (store them as the private original\nasset: \"Open in PowerPoint\" for decks) and `rendered` (`{ bytes, sha256, mediaType:\n'application/pdf', converterId }`, store as a private asset beside the original:\nthe pages for \"Present\"/reading). The member row records `renderedSha256` and\n`renderedSizeBytes`; bytes are still not retained here.\n\n**Fail closed.** Any converter problem records that member `unsupported` with a\nreason and writes the rest of the bundle normally: `converter_unavailable` (none\nconfigured), `converter_failed` (unreachable or 5xx), `converter_timeout`,\n`converter_too_large`, `converter_rejected` (HTTP 4xx, not a PDF, encrypted),\n`office_limit` (beyond `maxOfficeMembers`, in natural path order). A member that\nfailed is not a lesson in the proposal, and identical copies of a failed document\nfail the same way rather than becoming duplicates of nothing. Outcomes are part\nof the append-only evidence, so a replay does not retry a recorded failure (to\nretry, change the bundle). A replay does re-render recorded-accepted members so the\nhost receives the bytes again; if it can no longer render one it throws\n`DocumentConversionError` rather than silently dropping an accepted member.\n\n### Host configuration\n\n```ts\nconst service = new CourseBundleImportService(db, authorizer, {\n converter: new GotenbergDocumentConverter({\n baseUrl: process.env.GOTENBERG_URL, // for example http://gotenberg:3000\n timeoutMs: 60_000,\n maxBytes: 50 * 1024 * 1024,\n }),\n officeTextReader, // optional, see below\n});\n```\n\n`GotenbergDocumentConverter` posts to `<baseUrl>/forms/libreoffice/convert`, talks\nto nothing else, refuses redirects and URLs with credentials, and caps the response\nas it streams. Run Gotenberg as an internal-only service with **no outbound\nnetwork**, its own size/time limits and no shared volume with the app; this\npackage assumes untrusted files never run next to the application. Tests use\n`FakeDocumentConverter` (records calls; `failWith`, `failWhen`, `delayMs`, `output`).\n\n`OfficeTextReader` reads Office text through the SDK without this package\ndepending on `@happyvertical/documents`: the host imports `extractOffice`\n(released in `@happyvertical/documents` 0.111.4, sdk#1486, Kreuzberg) and passes\nit to `createOfficeTextReader(extractOffice)`, which returns whole-document\ntext for Word and text plus speaker notes per slide for PowerPoint (images are\nnever requested). An absent engine reads as `null`; a corrupt file rejects and\nthe importer treats that as \"no text\". The repository's current `catalog:`\npin is 0.111.3, which predates the Office processors, so the host bumps the SDK\nset (and its minimum release age) before it can import `extractOffice`.\n\n## Manifest (`manifest.json`, version 1, closed)\n\n```json\n{ \"version\": 1, \"course\": { \"subject\": \"Math\" },\n \"sections\": [ { \"key\": \"unit-1\", \"title\": \"Unit One\",\n \"lessons\": [ { \"key\": \"sums\", \"title\": \"Sums\",\n \"members\": [ { \"path\": \"sheets/sums.pdf\", \"role\": \"exercise\" } ] } ] } ] }\n```\nEvery object is closed: an unknown key anywhere is `manifest_unknown_key` (with\nits path in `entry`); another version is `manifest_version`. Keys match\n`[A-Za-z0-9._:-]{1,120}` and are unique; roles are the extraction page roles\n(`exercise`, `example`, `instructions`, `cover`, `answer_key`, `mixed`,\n`unknown`); a listed path must exist and be listed once. The manifest is a source\nfact: roles are `source`, anything the extractor infers stays `inferred`, and a\nmanifest/extraction disagreement belongs in extraction review, not here. Without\na manifest each accepted member is one lesson, sections come from the top-level\ndirectory and order is natural path order (digit runs compare numerically, no\nlocale). Manifest order: the manifest, declared members, then the rest by path.\nLesson bodies are the readable text members of a lesson, joined; PDFs and images\nare never inlined. Members whose declared role is `answer_key` or `mixed` never\ncontribute text to a lesson body (they remain recorded evidence members); the\nproposal rationale only counts how many were withheld.\n\n## Manifest version 2 (SMRT #4615)\n\nA root `manifest.json` with `\"version\": 2` is laid out by the `manifest-v2`\nreader; version 1 keeps its own reader and page roles. The schema is closed in\nthe same way: an unknown key anywhere is `manifest_unknown_key` (with its path in\n`entry`), another version is `manifest_version`.\n\n```json\n{ \"version\": 2, \"course\": { \"subject\": \"Science\" },\n \"courseMembers\": [ { \"path\": \"unit/supplies.pdf\", \"role\": \"attachment\", \"title\": \"Supply list\" } ],\n \"sections\": [ { \"key\": \"unit\", \"title\": \"Unit\", \"lessons\": [ {\n \"key\": \"l1\", \"title\": \"Lesson 1\",\n \"members\": [ { \"path\": \"l1/log.docx\", \"role\": \"learner-pages\" },\n { \"path\": \"l1/deck.pptx\", \"role\": \"slides\" },\n { \"path\": \"l1/guidance.pdf\", \"role\": \"assessment-guidance\" } ],\n \"pages\": [ { \"member\": \"l1/log.docx\", \"from\": 1, \"to\": 4, \"label\": \"Mission log\" } ],\n \"videos\": [ { \"reference\": \"dQw4w9WgXcQ\", \"title\": \"Recap\", \"step\": 1 } ] } ] } ] }\n```\n\n- **Members** (lesson `members` and root `courseMembers`): `path` (must exist, listed\n once), `role` (required) one of `teacher-guide`, `slides`, `learner-pages`,\n `assessment`, `assessment-guidance`, `handout`, `printable`, `answer-key`,\n `attachment` (`manifest_role` otherwise; a video is a reference, never a file),\n optional `audience` and `title`. The audience is the role default\n (`LESSON_ASSET_DEFAULT_AUDIENCE`) unless stated; `answer-key` and\n `assessment-guidance` are always guardian (a learner audience is\n `manifest_audience`). Members not listed stay recorded evidence with no role.\n- **Pages**: `{ member, from, to, label? }` ranges over a member of the same lesson\n (PDF, Word or PowerPoint; the rendered PDF for Office). Each page becomes a\n `LessonPage` keyed `p<n>`, labelled `<label> <n>` (default `Page <n>`), ordered by\n declaration. Bad, overlapping or unpageable ranges are `manifest_pages`; at most\n 500 pages per lesson.\n- **Videos**: `{ reference, title, step, audience? }` where `reference` is a bare id\n or an allowlisted https YouTube URL (`parseYouTubeReference`; anything else is\n `manifest_video`), `step` an integer 0-9999. They become `LessonExternalVideo`\n rows; nothing is downloaded.\n- **Course members** become `CourseAsset` rows, not tied to any lesson.\n\nGuardian-audience text (teacher guides, guidance, keys) never enters a proposed\nlesson body; only learner-audience text members do. Under v2 `role` on the member\nevidence row is the material role and `audience` the effective audience (NULL on\nrows recorded before v2). The plan carries `materials` (also on\n`CourseBundleImportResult.materials`): the resolved materials, pages and videos,\nplus `skipped` for declared members that were unsupported, refused or failed\nrendering. A duplicate member resolves to the earlier identical member's bytes\n(`assetPath`) and one material per (place, role, content).\n\n`receiveMember` also gets `audience` and `courseLevel`. **Slides** keep the original\ndeck (hand `bytes` to the host as a private asset: \"Open in PowerPoint\") and its\nrendered pages (`rendered`: \"Present\").\n\n### Applying materials\n\n`applyMaterials({ ...actor, importId, bundle, courseId, lessons, assets })`\n(`education.course.author`, operation `apply-course-bundle-materials`) writes the\nrows in one transaction. The host has stored each accepted member as a private\nasset and created the course's lessons: `lessons` maps manifest lesson key to the\n`Lesson` id, `assets` maps member path to `{ original, rendered }` asset ids.\nSlides join both the original deck and the rendered PDF; the original join is always\nguardian-only whatever the deck's declared audience (the audience applies to the\nrendered PDF), and a join written earlier with a wider audience is repaired on\nre-apply (`repaired`); any other Word/PowerPoint\nmember joins only its rendered PDF, so a learner is never handed an editable\noriginal. `LessonAsset`/`CourseAsset` saves keep their private-asset rule (guardian-\nonly roles and personal-use-copy courses need private assets). It targets\nfamily-owned courses; an authored course is `authored-course` because authored\nmaterial changes through course authoring. Idempotent: an identical row is\n`unchanged`, a row with other details is `CourseBundleMaterialsError('conflict')`,\na missing asset or lesson is `asset-missing`/`lesson-missing`; any error writes\nnothing.\n\n### Retrying a failed rendering\n\n`retryRendering({ ...actor, importId, bundle, paths?, receiveMember? })`\n(`education.course.author`, operation `retry-course-bundle-rendering`, needs a\nconverter) renders the Word/PowerPoint members whose first import recorded a\n*transient* failure (`converter_unavailable`, `converter_failed`,\n`converter_timeout`; `COURSE_BUNDLE_RETRYABLE_REASONS`). The first import's member\nrows are never rewritten. A success appends one `course_bundle_renderings` row\nper (import, content hash); every reader (`readImport`, replayed imports,\nproposals) overlays it, so the member reads `accepted` (identical copies as\nduplicates) with its rendered hash. A failure records nothing, so asking again is\nalways allowed; a second retry of a repaired member is `unchanged` and renders\nnothing. Converter rejections, size limits and the Office member cap are not\nretryable: change the bundle. The bundle must be the imported one.\n\n## Readers\n\n`BundleReader { formatId, detect, prepare }` in a `BundleReaderRegistry`;\nbuilt-ins are `manifest-v2`, `manifest-v1` and `plain-files`. SCORM and Common Cartridge are\n**not implemented** (their manifests describe launch resources and activity\ntrees that do not map to sections/lessons without design); a host can register\na reader ahead of the built-ins (`register(reader, { first: true })`).\n\n## Identity and replay\n\n`bundleIdentity` (`cbi1-...`) digests the sorted (path, sha256) pairs, so\nre-zipping, reordering and timestamps replay. One `CourseBundleImport` per\n(tenant, identity); a concurrent first import replays once on the unique index.\n\n## Models (closed, required tenant, no REST/CLI/MCP/AI, append-only)\n\n`CourseBundleImport` (`course_bundle_imports`: identity, format, reader,\nmanifest version, counts, actor), `CourseBundleMember` (`course_bundle_members`:\nunique tenant/import/path; ordinal, sha256, size, media type, kind, status,\nreason, duplicate-of, role, audience, section and lesson key) and\n`CourseBundleRendering` (`course_bundle_renderings`: a deliberate retry's success). Hashes and placements only:\nbytes stay with the host's ingestion receipts. Rewrites and deletes throw.\nNormal manifest migrations create the tables and add `audience` (NULL on older rows).\n\n## Not in this release\n\n- Material rows for authored courses: a proposal still carries lesson text only;\n authored course material goes through `CourseMaterialInput` and release.\n- Ingestion's receipt contract is unchanged: there is no `application/zip` part\n type or `parentEvidenceId` in ingestion. The education side unpacks and the\n host receives members one by one, so archive-to-member lineage lives in\n `course_bundle_members` (and the host's receipts), not in ingestion evidence.\n Changing ingestion's published receipt is a separate design decision.\n- A manifest/extraction disagreement is not compared automatically.\n- SCORM / Common Cartridge readers; zip64; nested archives; encrypted zips.\n"
15697
+ },
15698
+ {
15699
+ "path": "agents/class-crunchlabs.md",
15700
+ "module": "class-crunchlabs",
15701
+ "content": "# Class CrunchLabs source (SMRT #4616)\n\nClass CrunchLabs is a global catalog provider (`class-crunchlabs`) whose units are\nlisted under Science, with the @ClassCrunchLabs YouTube course playlists as\npreviews. \"Add to a course\" reads the family's own download of the unit and runs\nthe [bundle import](course-bundle-import.md) with a manifest version 2 built from\nit. Files come from CrunchLabs' own downloads and YouTube; **nothing is hosted\nhere** and the listing has no acquirable formats.\n\n## Operator-only until permission, one public switch\n\n`CatalogVisibility` gains `operator`. A `CatalogPolicy` built with\n`operatorTenantIds` lets only those families (host configuration, never a\nrequest) see `operator` records; the default policy shows them to nobody, and\n`authorize` answers `operator-only`. Materialization refuses them.\n\n`createClassCrunchLabsCatalogAdapter({ playlists, publicListing? })` is a snapshot\n`CatalogSyncAdapter` (one terminal page, no cursor). By default\n(`CLASS_CRUNCHLABS_PUBLIC_LISTING_DEFAULT = false`) every record is `operator`\nvisibility with licence state `unknown`, no permitted operations and the text\n\"free curriculum, permission pending\". Only the literal `publicListing: true`,\nset by the operator in host configuration, makes records `catalog` visibility\nwith `verified` evidence permitting `link` only. The switch is part of each\nrecord's revision, so the next sync rewrites the rows when it is flipped.\n`classCrunchLabsPreviewEmbedUrl(record)` returns the youtube-nocookie playlist\nembed for a listed unit (null for anything else). The playlist data is an\ninjected `ClassCrunchLabsPlaylistSource`; the adapter never touches the network.\n\n`addClassCrunchLabsUnit({ service, actor, catalogActor, record, listing, policy,\nfiles, playlists, pageCounter?, receiveMember? })` re-checks\n`canAddClassCrunchLabsUnit(actor, policy, listing)`: visibility is derived from the\nhost's own `listing.publicListing` (operator unless the literal `true`), never from\nthe record or any caller-supplied visibility or licence; an operator-only listing\nis refused to every other family, and the course actor must be the catalog actor, builds the bundle and imports each part. Then propose\nstructure and `applyMaterials` per part as for any bundle. Courses are\n`personal-use-copy` (`build.licenseKind`).\n\n## Reading a download\n\n`buildClassCrunchLabsBundle({ files, playlists?, unit?, pageCounter?,\nincludeUnitWideAggregates?, maxPartBytes? })` takes a `ClassCrunchLabsFileSource`:\n`folderClassCrunchLabsSource(dir)` (Node only, from `@happyvertical/smrt-education/node`, never the root: a folder, the Drive zip parts, or both;\nzips are read lazily from their central directory, zip64/encrypted/symlinks are\nskipped) or `memoryClassCrunchLabsSource(files)`. It never reads video files.\n\n- Layout: `<Unit>_All Unit Wide Materials` and `<Unit>_Lesson <n>`; letters A\n teacher manual, B slides, C student mission log, D status check assessment\n (plus guidance and the editable master), E handouts, F extras, G videos. A\n lettered directory wins over a file's own letter. Several units need `unit`.\n- Mapping to manifest v2: A `teacher-guide`, B `slides`, C `learner-pages`, D\n `assessment` / `assessment-guidance`, E `handout`, F `printable`, the unit-wide\n Supply List `attachment`. Audiences are the role defaults (teacher manual and\n guidance guardian, slides guardian, mission log learner); the editable\n assessment master is guardian. Unit-wide files become course members.\n- The unit-wide \"(all lessons)\" combined files repeat the per-lesson files and\n are large, so they are left out unless `includeUnitWideAggregates` (reported as\n `aggregate-of-lessons`); the supply list, posters and other extras are kept.\n- Lesson pages: the first mission log of a lesson gets a `Mission log` range\n from `pageCounter` (`createClassCrunchLabsPageCounter({ converter? })` counts\n PDFs with pdf-lib and Word files by rendering them through the converter).\n Without a count the lesson gets no pages and the report says so.\n- Videos: names `Lnn.step_Video_Kind_Title` are matched, by unit (playlist\n title), lesson and step (`L03.6` or `Lesson 3 ... Step 6` in the item title),\n to the unit playlist; two videos sharing a step are told apart by the item\n title naming their title or kind, else both stay unmatched (`ambiguous`).\n Matches become `videos` references with a title and a step order (materials\n prep videos are guardian audience); unit-level videos (a teaser) and unmatched\n ones are only reported.\n- De-duplication: repeated exports of one logical path collapse (CRC and size\n when known; the newer export wins, a changed file is `superseded`); the same\n bytes under another name or place keep one copy (the lesson-named file in its\n lesson, posters unit wide, never a `copy`).\n- A unit larger than one archive (bundle cap 100 MiB) is split by lesson into\n `build.parts` (default 90 MiB each; the first part carries the course members and\n any lesson that fits beside them, others go to the first part with room; if no\n lesson fits beside the course members the build refuses with `too_large`); `build.bundle`\n and `build.manifest` are the first part.\n\nThe `report` lists lessons, members, parts, pages, matched and unmatched videos,\nduplicates, ignored files and warnings. Fixtures in tests are synthetic files\nwith the same naming; no real download is ever copied into the repository.\n\nTests: `src/__tests__/class-crunchlabs.test.ts`.\n"
15550
15702
  },
15551
15703
  {
15552
15704
  "path": "agents/source-import.md",
@@ -15571,7 +15723,7 @@
15571
15723
  {
15572
15724
  "path": "agents/group-sharing.md",
15573
15725
  "module": "group-sharing",
15574
- "content": "# Homeschool group sharing contract\n\nArchitecture and pre-implementation test design for [SMRT #4469](https://github.com/happyvertical/smrt/issues/4469), consumed by Hearth #41. This document defines the intended contract; it is not a claim that implementation or validation has passed. Risk is high: explicit cross-tenant authorization, private child media, concurrent consent mutation and new persistent schema.\n\n## Ownership and boundary\n\nA group has an independent users `Tenant`, created with `TenantService` in the caller transaction, and ordinary users memberships/roles/permissions for its supervisors. Family tenants remain independent; do not make them descendants of the group. A supervisor never receives family membership, an inherited family role, a guardian relationship, or a family resource grant. Profiles remain canonical identities. Users `Group` is tenant-local RBAC, not the homeschool relationship.\n\nEducation owns a per-child group relationship in the child's exact family tenant. Its identity is `(familyTenantId, learnerId, groupTenantId)`, allowing several children and several groups without copying work. All work, immutable attempts/revisions, source files, releases, marks and comments stay family-owned. Revoking participation or narrowing a policy changes access, never deletes learning history or attribution.\n\n`ResourceGrantService.checkResourceOperationPermission` first requires permission in the resource's tenant. It cannot authorize these family reads without broadening supervisor authority. Reuse users permission checks in the group tenant; do not use ResourceGrant as a cache of policy matches. `TenantService` seeds roles during initialization and does not itself wrap create+ownership in a transaction; initialize/provision under the service's executor and reject partial creation.\n\nThe only cross-tenant entry is an explicit `GroupSharingService` operation. A trusted host authorizer receives the exact active executor and verifies current stored session, user, group membership and operation permission. Education then validates the persisted group, exact family/child edge and current policy before using exact family-scoped collection reads. No user-supplied SQL, cross-family list, system context, `allowCrossTenant` escape, synthesized guardian authorization, or direct collection handle is exposed. A reviewed scope switch may select only the already validated exact family and restore the caller context; it never turns a family collection into an unscoped query.\n\nGroup roster discovery uses only `GroupLearnerRosterEntry` enumerated with exact group tenant and group ID predicates. Its opaque selectors route to each exact family-owned membership and current policy, which are independently checked before any learner name or work is projected. A roster row is not an access grant: stale, revoked, corrupt or mismatched edges are denied. Never discover group participants by an unscoped query over family-owned memberships, invitations or work. Acceptance writes the roster index in the same transaction as the family edge/policy and invitation status. Revocation can retain that index for stable opaque history; it must confer no continued access. Concurrent acceptance/retry creates one index row, and rollback leaves none.\n\n## Closed persistence\n\nAll models disable API, MCP, CLI and AI mutation/discovery surfaces. Exports/manifests/recipe registration must preserve that closure. Use qualified cross-package references to users/profiles and local foreign keys inside education. Models are root-importable for generated registration. Tenant predicates are explicit on every family lookup, including foreign-key validation. Do not silently upsert immutable identities.\n\n| Model | Ownership and minimum fields | Invariants |\n| --- | --- | --- |\n| `EducationGroup` | Group tenant; name, active flag, creator user/profile | One record per group tenant. No family data. Group creation and users owner membership commit together. |\n| `GroupLearnerInvitation` | Group tenant; group, opaque familyTenantId/learnerId selectors, exact invited parent user, creator, expiry, status, accepted edge ID, accepted policy digest | Status pending/accepted/revoked/declined; expiry required; immutable scope. Group staff creates a targeted invitation without reading or confirming family/learner data. Validate the exact family/learner only at parent acceptance. ID selects an invitation, never proves parent identity. |\n| `GroupLearnerMembership` | Family tenant; group, learner, consent parent, status active/revoked, policyVersion | Unique family/learner/group tuple. Revocation is durable. A fresh invitation is required to reactivate a revoked edge; old accepted invitation cannot reactivate it. No one-child-only uniqueness. |\n| `GroupLearnerRosterEntry` | Group tenant; groupId, familyTenantId, learnerId, membershipId | Unique group/membership tuple; immutable opaque scope index, populated atomically with acceptance. Stores no names, work or policy payload and confers no access. |\n| `GroupSharingPolicy` | Family tenant; membership, monotonically increasing version, schemaVersion=1, canonical policy JSON, parent actor/time | Immutable append-only snapshots; edge points at current version. Unique membership/version. Explicit policy at acceptance; no implicit share-all. |\n| `GroupRevisionComment` | Family tenant; edge, learner, activity, attempt, exact revision, author user/profile, body, createdAt, childVisible=false, visibility parent/time/version=0 | Stable author and revision binding; cannot grade, alter assessment/evidence or silently move to a newer revision. Only parent changes child visibility. Group departure retains row. |\n\nUse guarded JSON getters/setters and runtime validation for policy documents. Enforce tuple uniqueness with database schema constraints. Validate status/version on model writes and revalidate persisted values on service reads, failing closed on corrupt or unknown values. The public model decorator does not expose SQL CHECK constraints; do not broaden core or claim database CHECK enforcement for these fields. Use normal manifest migrations; never create schema at runtime. Invitation delivery/authentication is host-owned; group invitations do not mint identity sessions, and acceptance requires an independently authenticated current parent. Initial release may use exact known parent IDs; mailbox identity onboarding is not implied by an invitation ID.\n\n## Service API and policy\n\nAll requests carry authenticated `actorUserId`, actor session/context binding and explicit resource selectors; none of those are accepted as authority merely because they are present. Proposed public operations:\n\n- `createGroup`, `getGroup`: current group administration/reading through users permissions; provision atomically.\n- `inviteLearner`: current group invitation authority creates a group-owned invitation for an exact known parent and opaque preknown family/learner selectors; it does not read family data, prove those selectors exist or require the inviter to be a guardian. `revokeInvitation` requires current group invitation authority. `declineInvitation` requires the exact currently authenticated invited parent and makes a pending invitation terminal without revealing learner data. `acceptInvitation({ invitationId, policy })`: the exact named parent must currently be guardian of the selected child in the selected family; acceptance requires explicit valid policy. Accept locks the invitation and child/group edge, rechecks active group, parent authority and expiry, inserts edge/policy/group-owned roster index and marks accepted atomically. The parent need not be a group member or supervisor.\n- `setSharingPolicy({ membershipId, expectedVersion, policy })`, `revokeMembership`: current family guardian only. An accepted retry returns the same edge only when parent and original canonical policy digest match and current edge remains active; it never resets policy changed later. Wrong-parent retries fail. Changed acceptance payload conflicts. Expired, revoked or declined pending invitations fail. Accepted historical retry may return original result after expiry only while its current edge is active and parent remains authorized.\n- `previewSharing({ membershipId, policy? })`: current parent; evaluates saved or explicitly proposed policy using exactly the evaluator and projection used for supervisor reads. Proposed preview does not persist or grant access. Return per-item allowed facets and parent-facing reasons.\n- `listSharedWork`, `getSharedWork`, `getSharedRevision`, `readSharedMedia`, `getSharedProgress`: current group supervisor, exact active edge and current policy, with bounded pagination. Reauthorize each call and each selected resource; no reusable authorization result escapes the invocation.\n- `addRevisionComment`, `listRevisionComments`: supervisor comment write requires currently shared exact work/revision and group comment permission; parent reads retained family comments independently of group revocation. Child reads require own learner identity and the current saved parent comment-visibility policy; version-zero comments inherit that policy. `setCommentChildVisibility({ commentId, expectedVersion, visible })` is parent-only.\n\nPolicy v1 has explicit booleans `shareWork`, `shareMedia`, `shareFeedback`, `shareProgress`, `parentReviewedOnly`; `historyFrom` is a required valid UTC instant; `commentVisibility` is required and accepts only `parent-only` or `learner-visible`; the host presents `parent-only` as the initial default but the service requires an explicit saved choice. `subjects` and `courseIds` are explicit selector objects (`all` or nonempty `include` values). Unknown version/keys/enums, malformed or missing required fields fail closed. Empty include selects nothing or is rejected consistently; it never means all. An explicit all selector intentionally allows unknown metadata; any restriction requires matching authoritative metadata. Subject comes from the retained activity's authorized course metadata, not a browser label or inferred assessment text. Course metadata is resolved through the same authorized self/ancestor/global catalog scope for options, preview and supervisor reads, after validating the child's exact family-owned enrollment. Catalog scope conveys no family or guardian authority to a supervisor. Synthetic routing courses for unfiled captures are excluded from selector options; unfiled capture facts retain unknown course and subject even if a technical placeholder has a label or subject. Genuine enrolled courses remain selectable before any capture. An unfiled activity or missing course/subject does not match a restricted selector. Both restrictions intersect when present.\n\nOne pure evaluator takes normalized policy and verified work/revision facts. All public reads and preview call it. History is based on immutable attempt capture time, inclusive at the cutoff; reanalysis/re-filing does not make old work new. Each correction attempt is evaluated separately. `parentReviewedOnly` requires a valid guardian revision of the selected attempt; AI analysis, child release acknowledgement or merely having some other reviewed attempt is insufficient. Feedback is projected only when enabled and the exact selected revision satisfies the review gate; work and media DTOs never incidentally contain feedback, marks or hidden historical question text. Progress may be enabled independently of feedback/work payloads: derive it only from permitted attempts/revisions, never from the whole-family summary. Media is allowed only for an allowed work attempt and the explicit media flag. New matching work appears automatically through evaluation, without per-work grants or background grant materialization.\n\nSupervisor projections contain stable activity/attempt/submission/revision IDs and authorized data only. They expose no writable models, family settings, sibling counts, source storage URLs, answer keys, personal-use catalog assets or ingestion scratch. Work identity does not authorize all revisions. Default omitted revision selects a deterministic permitted current revision; explicit unavailable revision refuses, never silently falls back. Policy flags remain independent; test every combination where one facet could leak through another.\n\n### Fixed exported contracts for implementation\n\nPut shared types and validators in `src/group-sharing-contracts.ts`; models import them, while the pure evaluator imports types only. The following names are the consumer contract, not alternate spellings for workers to invent:\n\n```ts\ntype GroupSharingSelector =\n | { mode: 'all' }\n | { mode: 'include'; values: string[] };\ninterface GroupSharingPolicyV1 {\n schemaVersion: 1;\n shareWork: boolean;\n shareMedia: boolean;\n shareFeedback: boolean;\n shareProgress: boolean;\n parentReviewedOnly: boolean;\n commentVisibility: 'parent-only' | 'learner-visible';\n historyFrom: string;\n subjects: GroupSharingSelector;\n courseIds: GroupSharingSelector;\n}\ninterface GroupSharingActor {\n actorUserId: string;\n sessionId: string;\n}\ninterface GroupSharingScope extends GroupSharingActor {\n groupId: string;\n familyTenantId: string;\n learnerId: string;\n}\ninterface GroupSharedWorkSelector extends GroupSharingScope {\n activityId: string;\n attemptId: string;\n revisionId?: string;\n}\ninterface GroupSharingWorkFacts {\n capturedAt: string;\n courseId: string | null;\n subject: string | null;\n parentReviewed: boolean;\n}\ninterface GroupSharingDecision {\n eligible: boolean;\n work: boolean;\n media: boolean;\n feedback: boolean;\n progress: boolean;\n reasons: string[];\n}\n```\n\nExport `validateGroupSharingPolicy`, `evaluateGroupSharingPolicy(policy, facts)`, `GroupSharingAuthorizationError`, and `GroupSharingConflictError`. `GroupSharingWorkFacts` is populated only by the owning reader, never request JSON. Return names are `EducationGroupView`, `GroupLearnerInvitationView`, `GroupLearnerMembershipView`, `GroupSharedWorkView`, `GroupSharedRevisionView`, `GroupSharingPreview`, `GroupSharedProgressView`, and `GroupRevisionCommentView`. Views are immutable data DTOs, with no model instances or executor. Use `familyTenantId` in requests; family-owned models store that as their required `tenantId`. Invitation and roster-index ownership are the explicit exceptions: its `tenantId` is the group tenant and `familyTenantId` is a distinct opaque scope field. Invitation group staff DTOs disclose only submitted selectors/status, never dereferenced family names, existence or child data. `groupId` references EducationGroup, whose `tenantId` is the users group tenant. Do not confuse these two tenant selectors.\n\nEligibility means course/subject/history/review gates pass; facets intersect eligibility with their explicit flag. `media` additionally requires `shareWork`, so the first implementation exposes original media only with work enabled. Progress can remain enabled while work/feedback DTOs are disabled. Assessment-derived text, answers, scores, computed answer regions and annotated renditions count as feedback. With feedback disabled but work/media enabled, the authorized original child submission bytes remain accessible: marks physically present in those original bytes are intrinsic source content, not a hidden assessment projection. Hosts must make that parent choice clear. A hidden guardian-origin reference/cover or answer-key file is never promoted to an original child submission merely by this toggle.\n\nThe authorization callback is `GroupSharingAuthorizer(request)` where request carries actor, exact group/family/learner selectors as applicable, a discriminated action and `executor: DatabaseInterface`. Actions are `education.group.create`, `education.group.manage`, `education.group.read`, `education.group.invite`, `education.group.share`, `education.group.review`, `education.group.comment`, and `education.group.child-comments`. The host verifies live identity and its effective permissions/guardian terms; the service independently verifies persisted resource scope, current edge/policy and exact selectors. Callback success never bypasses those checks. The reader accepts this callback, never a boolean token, and exposes only read/projection methods. No generic `authorizeAsGuardian` or arbitrary collection callback belongs to this API.\n\n## Owning workbook/media seams\n\nCurrent `LessonWorkService` activity/timeline APIs require learner/guardian authority; replacing the authorizer with unconditional true would create a broad guardian backdoor. Add an explicit read-only owning projection seam with a required exact-resource authorization callback, invoked on its active executor before loading private data and again at final projection/delivery where appropriate. It must not expose mutation methods or accept an externally cached authorization boolean. This seam can live in a dedicated education sharing reader that uses owned collections with exact predicates; private workbook algorithms should be shared through an owning helper rather than copied or private-state access.\n\nThe selected attempt must belong to exact family/learner/activity/submission; selected revision must belong to that attempt. Original bytes must be owned through `SubmissionAsset`, match the retained source ID/hash and belong to the learner's private asset owner. Photo covers/reference-only receipts are not work. The ordinary `signSubmissionAsset` returns a signed URL after learner authorization; it is insufficient for this sharing contract. `readSharedMedia` must run current policy/role/session/edge checks for every HTTP delivery and open the exact `withPrivateAssetAccess` scope only within that request. Return bytes or execute a scoped streaming callback; never return public/presigned storage URLs or a long-lived capability. The host route must invoke it even for a replayed/direct URL and configure private storage/cache headers. No framework can revoke bytes already delivered; successful reads linearize at their final authority check. A revocation completed before a new request must always deny that request.\n\nShared progress recomputes from the permitted subset of current guardian evidence and never calls the full guardian summary then merely redacts its citations. With `shareProgress=true` and `shareFeedback=false`, its strict field allowlist is counts of shared completed work, distinct practice episodes, and assistance categories (independent/assisted/unknown) only. Completion here means a permitted work attempt exists as completed/captured work, never a correctness threshold or private Grade-derived completion decision. Do not expose correctness, points, possible points, scores, concept labels, question identities/text/answers, feedback or outcome-derived aggregates. No question-level evidence citations appear in this counts-only shape. This restriction must hold even for a single attempt, where an aggregate would reveal the entire withheld outcome. When feedback is also enabled, authorized descriptive concept evidence may additionally be projected with permitted citations and existing episode deduplication; it still cannot include hidden subject/history/sibling evidence.\n\nComments remain outside Grades, assessment revision bodies, scoring and progress evidence. Parent-only comments can be shown to their currently authorized author/supervisors and guardians; this means hidden from children by default, not anonymous. Parent can retrieve original author/time after revocation. Child comment access always checks the current saved policy: `parent-only` denies all child comment reads immediately, including previously visible comments; `learner-visible` permits default comments whose `visibilityVersion` is zero. A parent may additionally set an individual override: when `visibilityVersion > 0`, `childVisible` must be true as well as the saved policy being `learner-visible`. Thus a saved learner-visible choice needs no per-comment opt-in, an individual false override narrows it, and an individual true override can never broaden parent-only. Policy changes apply on the next read and never rewrite the original comment or its attribution.\n\n## Transaction and race semantics\n\nUse one active executor for current users/session checks, guardian relationship terms, group state, edge/policy and owned data. Authorization hooks must not capture the outer pool. Mutations run in the education transaction/composite queue with deterministic lock ordering (learner, group/edge, invitation/comment as applicable); acquire the learner lock in the same order as workbook capture/review. PostgreSQL must arbitrate with row locks or conditional writes plus unique keys; process-local locks alone are insufficient. SQLite serialization must not overlap statements on a single native handle. Failed nested operations poison/roll back the enclosing operation even if a callback catches an error.\n\nConcurrent identical acceptance converges to one active edge, one first policy, one group-owned roster entry and one accepted invitation result. Competing policies cannot both win; one conflicts. Concurrent policy updates using one expectedVersion produce one successor. Education-owned edge/policy/invitation mutations participate in the same lock/guard protocol: acceptance versus invitation revoke and comment writes versus edge revoke/policy narrowing are serialized, so the losing mutation cannot commit on stale education-owned state. External users sessions, roles/memberships and guardian-term writers do not participate in that protocol. Their authority linearizes at the final successful live authority read on the active executor, not at transaction commit. An operation authorized before a concurrent external revocation may finish; a new request starting after that revocation committed must deny. A same-executor callback or second read alone cannot close the final-check-to-commit window, and this contract does not claim otherwise. Do not use a transaction snapshot established before the request to satisfy a new request's current-authority check. Stronger commit-time exclusion would require an actual owning users/profiles writer protocol and is outside this contract. Media reads use final-check linearization; bytes already delivered cannot be recalled. Database, authorizer or asset failures propagate as failures, never authorize on fallback. Failed group creation, acceptance and policy append leave no partial tenant/owner/edge/policy/roster/acceptance state. No service starts containers.\n\n## Behavior-to-test matrix\n\nThis is the test-design-contract matrix. Every row requires recorded execution evidence before release. Common context abbreviations: P = independently authenticated current family guardian; S = authenticated group supervisor; C = own child; X = outsider, sibling or other-group actor. TX means the same exact executor for authorization and persistence. SQ/PG means in-process SQLite and PostgreSQL; Node is the supported server runtime. Browser is intentionally not an authorization runtime. Command S: `pnpm --filter @happyvertical/smrt-education exec vitest run src/__tests__/group-sharing-lifecycle.test.ts src/__tests__/group-sharing-adversarial.test.ts src/__tests__/group-sharing-reader.test.ts src/__tests__/group-sharing-comments.test.ts`; command P: `pnpm --filter @happyvertical/smrt-education test:postgres`; command M: `pnpm --filter @happyvertical/smrt-education exec vitest run src/__tests__/group-sharing-models.test.ts src/__tests__/group-sharing-manifest.test.ts`; command B: package `typecheck`, `build`, `verify:pack`. The shared behavioral harness must be imported by both SQLite and `group-sharing.optional.test.ts`; a skipped optional PG suite is not passing PG evidence.\n\n| ID / behavior | Reachable trigger | Positive | Negative / failure | Actor/context | Executor / transaction | Runtime / dialect | External edge | Level / command |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n| G01 isolated group | createGroup | owner membership and group in distinct tenant | rollback after tenant insert; no family membership/parent hierarchy | P, X | TX provision atomic | Node SQ/PG | TenantService failure, unknown permission | integration S/P |\n| G02 closed persistence | generated registration/discovery | package imports models/recipe | no REST/CLI/MCP/AI CRUD advertised | untrusted caller | manifest, no DB needed | Node; schema SQ/PG | packed root exports | contract M/B |\n| G03 edge cardinality | invite/accept multiple edges | two children × two groups coexist | duplicate tuple or rebinding learner/family | P, X | TX unique constraints | Node SQ/PG | malformed IDs | integration S/P |\n| G04 invite targeting | inviteLearner/accept | group inviter creates without family lookup; exact parent accepts with current effective guardian terms; exact parent declines | other parent, expired relationship, wrong family/group/sibling, expired/revoked/declined invite | P/X | TX current session + relationships | Node SQ/PG | absent/expired session, authorizer throw | adversarial S/P |\n| G05 explicit policy | acceptInvitation | valid explicit v1 choice | omitted policy, unknown version/key, malformed date/selector/boolean | P | TX no partial writes | Node SQ/PG | untrusted JSON | service S/P |\n| G06 retry | accept same invite | same edge/policy/result, even after later policy narrowing | changed payload/parent; revoked edge never reactivated | P/X | TX immutable digest | Node SQ/PG | request replay | integration S/P |\n| G07 acceptance race | two accepts | identical requests converge | differing policy, revoke/expiry wins; injected failure rolls all back | P | independent root connections PG, serialized SQ; unique arbiter | Node SQ/PG | failure between edge/policy/roster/accepted writes; retry leaves one index row | concurrency S/P |\n| G08 policy version | setSharingPolicy | append version and preserve previous snapshots | stale expected version, supervisor write, invalid payload | P/S | TX compare-and-set | Node SQ/PG | concurrent updates, upstream auth failure | concurrency S/P |\n| G09 automatic evaluator | new capture/list | matching new work appears without grant creation | unknown restricted subject/course; course and subject mismatch | S/P preview | TX exact family queries | Node SQ/PG | unfiled/missing metadata | unit + service S/P |\n| G10 history/review | get work/revision | inclusive cutoff and reviewed exact attempt | old attempt reanalysis, other attempt's review, provisional-only revision, malformed ancestry | S | TX immutable facts | Node SQ/PG | future/invalid dates, stale revision ID | unit + adversarial S/P |\n| G11 facet independence | every public DTO | explicit work/media/feedback/progress combinations | disabled feedback leaks through marks/status/comment or progress; disabled media via anchors | S | TX projection | Node SQ/PG | omitted optional payload fields | table-driven S/P |\n| G12 preview parity | preview and subsequent reads | identical allowed IDs/facets under same saved/proposed policy | unauthorized preview; proposal creates grant/write | P/S | same evaluator/TX | Node SQ/PG | policy parse failure | contract S/P |\n| G13 current authority | list/detail/revision/media/comment/summary | active S exact group+edge | stale session, removed role, inactive membership/group, wrong active context, sibling/group edge | S/X | current TX reads, cache:false | Node SQ/PG | every public service path | adversarial parameterized S/P |\n| G14 narrowing/revoke | reuse IDs/direct route after change | parent retains originals/history | previously allowed S denied after policy/edge/group/role/session revoke | P/S | TX; final check | Node SQ/PG | stale URL and request replay | service + host contract S/P |\n| G15 exact source ownership | readSharedMedia | exact SubmissionAsset retained hash and private owner | foreign asset/submission/revision, source cover, answer key, tampered hash, public URL | S/X | TX + exact private scope | Node SQ/PG | storage missing/failure; no leaked locator | integration S/P |\n| G16 immutable work IDs | list/get/correction/reanalysis | stable attempt/revision IDs and source citation | foreign revision, selector fallback, reanalysis counted as new practice | S | TX read-only | Node SQ/PG | workbook reader seam | integration S/P |\n| G17 scoped progress | getSharedProgress | counts-only allowlist without feedback; descriptive evidence with feedback; episodes deduped | single-attempt or multi-attempt withheld correctness/score/concept/question leakage; hidden evidence contributes counts | S | TX filtered evidence before aggregate | Node SQ/PG | nullable scores/unknown assistance | integration S/P |\n| G18 comment author/binding | add/list comments | exact visible revision, stored current author | forged author, hidden/foreign revision, supervisor grade/evidence mutation | S/P | TX immutable bind | Node SQ/PG | malformed/empty/oversize text | adversarial S/P |\n| G19 comment visibility | set visibility/child list | saved learner-visible exposes version-zero defaults; explicit false narrows; parent-only immediately hides all | missing/unknown policy field, default parent-only C read, sibling, supervisor visibility update, stale version | P/C/S/X | TX guarded visibility version | Node SQ/PG | visibilityVersion=0 inherits; >0 requires childVisible plus saved learner-visible | integration S/P |\n| G20 retained family records | revoke then family read | originals, revisions, comments/attribution remain | supervisor retains old access; cascade removes learning rows | P/S | TX family authority independent of group | Node SQ/PG | no byte cleanup on revoke | integration S/P |\n| G21 transaction affinity | host hooks in operation | all callbacks observe exact TX including nested reads | outer-pool hook usage, swallowed failure commits, closed service reuse | P/S | instrument executor; rollback injection | Node SQ/PG | callback failure | integration S/P |\n| G22 interleaving authorization | comment/media vs revoke/narrow | own edge/invite/policy mutations serialize; pre-external-revoke authorized operation may finish | stale own-edge check commits after winning revoke; new post-commit session/role/guardian-revoke request succeeds | P/S | independent PG transactions/barriers; SQ serial equivalent | Node SQ/PG | deterministic barriers for own-edge race and external final-check/new-request semantics | race S/P |\n| G23 migration compatibility | manifest upgrade + normal schema apply | six closed models migrate on empty/existing DB; old workbook history unchanged | missing schema fails without runtime DDL; duplicate edge/roster tuple blocked; corrupt status/version read denied | deploy | explicit migration TX | Node SQ/PG | native UUID FKs, migration failure | schema U/P/B |\n| G24 recipe/plugin contract | recipe discovery and packed import | group recipe registered, service/types usable | generated CRUD exposed or missing root symbol | host | N/A: static registration, no persistence | Node | downstream manifest/recipe consumer | contract M/B |\n| G25 bounded query scope | list pagination/direct service | exact group-owned roster enumeration then each exact family edge/policy check; bounded results/stable cursors | forged/stale roster grants access; caller limit hides deny; cursor reveals foreign work; raw cross-family edge query | S/X | TX group index read then exact family predicates | Node SQ/PG | invalid cursor/limit | adversarial S/P |\n| G26 staff group discovery | listMyGroups after login | own active groups; inactive group only for current manager | other user/session, removed membership, inactive nonmanager group | S/X with original session context | TX users own-membership enumeration then exact group scope | Node SQ/PG | unknown group tenant and authorizer error | integration S/P |\n| G27 family/child navigation | listLearnerMemberships access guardian/child | parent retained summaries; own child active group names | sibling, wrong actor, child guardian-policy/history escalation; revoked/disabled child edge | P/C/X | TX exact family/learner, current own-user proof | Node SQ/PG | invalid access selector | adversarial S/P |\n| G28 invitation preview/list | getInvitation/listInvitations | named guardian preview without group membership; staff opaque selectors | wrong invitee, missing guardian proof, staff family dereference | P/S/X | TX exact group invite; parent exact family proof | Node SQ/PG | parent/staff explicit access discriminator | integration S/P |\n| G29 retained policy history | getSharingPolicyHistory after leave | immutable versions/actors/time and scoped numeric pagination | supervisor/child read, malformed cursor/limit, foreign edge | P/S/C/X | TX exact family edge, guardian before/after | Node SQ/PG | unknown stored policy or version fails closed | integration S/P |\n| G30 opaque revision binding | feedback withheld; new revision before comment | work exposes immutable comment ID; comment remains on opened revision | foreign/provisional assessment under selected-review gate, hidden assessment payload | S | TX exact attempt/revision and current policy | Node SQ/PG | opaque comment selector independent of feedback | reader/comments S/P |\n\n| G31 pre-consent policy options | getSharingPolicyOptions exact family/learner before invite acceptance | current guardian receives labeled learner course/subject options without a group edge | child/supervisor/outsider, wrong family/learner, stale guardian/session, private answer/catalog data | P/S/C/X | TX exact family/learner guardian before/after; no policy mutation | Node SQ/PG | groupId omitted; current scoped metadata only | reader S/P |\n| G32 metadata/private capability isolation | ordinary member group home vs roster/work | education.group.read permits group metadata/discovery only; education.group.review permits authorized private scope | metadata-only member roster/work/revision/media/progress, role/action escalation | current stored ordinary member/S | TX role/session/group plus exact edge/policy before/after | Node SQ/PG | guardian review remains separate exact guardian proof | adversarial S/P |\n\n## Implementation ownership partitions\n\nCoordinate these partitions before editing; integrations are serial, not concurrent edits to the same file.\n\n1. Persistence/contracts worker: `src/group-sharing-models.ts`, `src/group-sharing-contracts.ts`, `src/__tests__/group-sharing-models.test.ts`. Own validation/types/closed models and unique schema; freeze exported interfaces first.\n2. Lifecycle worker: `src/group-sharing-service.ts`, `src/group-sharing-authorization.ts`, `src/__tests__/group-sharing-lifecycle-workflows.ts`. Own group/invitation/edge/policy state machine, shared transaction and current-authority boundary. Do not implement a second workbook reader.\n3. Read/projection worker: `src/group-sharing-reader.ts`, `src/group-sharing-policy.ts`, `src/__tests__/group-sharing-read-workflows.ts`. Own same evaluator for preview/reads, exact work/media projection and scoped summary. Any changes needed to `src/workbook-service.ts` or `src/submission-service.ts` belong exclusively to this worker after parent approves the owning seam. Do not widen LearnerAuthorizer.\n4. Integration owner: `src/group-sharing-comments.ts`, shared test runner `src/__tests__/group-sharing-workflows.ts`, SQLite/PG entrypoints, `src/index.ts`, `src/recipes.ts`, recipe help, package docs/AGENTS and generated artifacts. Integrate comment API after lifecycle/read interfaces stabilize, run full validation/review once. Could delegate comments as a separate non-overlapping worker after contracts freeze.\n5. Architecture owner: this document only. Record executable evidence/results through the parent session; proposed matrix rows do not substitute for executed tests.\n\n## Upstream/host contract gaps to resolve before release\n\n- Existing workbook guardian projections and signed asset API are not a sharing boundary. The new owning read seam and host authenticated media route are mandatory, not optional integration notes.\n- Host current-session/group permission and current guardian-term verification must use the provided transaction executor. No external app is permitted to supply an always-allow callback or stale role snapshot. Document exact action names/role seed in implementation.\n- Course.subject is a mutable string; define whether policy uses current authoritative metadata (recommended for immediate narrowing) or immutable metadata snapshots. This design uses current authoritative course metadata and immutable capture time. Missing/re-filed metadata is reevaluated each read.\n- PostgreSQL concurrent tests need independent connections and deterministic barriers. One connection or in-process mutex does not establish multi-process acceptance/revocation safety. Integration owner supplies the isolated existing test service; workers do not provision containers.\n- Public consumer contract must record the published SMRT version, exported types/actions, migration/recipe activation and media route obligations. Hearth consumes that release only; no local tarball or host shim.\n\n## Host integration and migrations\n\nThe public `GroupSharingService(db, authorize, options?)` facade owns lifecycle,\nreader and comment operations. Internal reader/comment classes are not root\nexports: their borrowed-executor flags are owning implementation details.\n`options` uses the normal `SubmissionServiceOptions` private storage adapter.\nThe facade supports atomic `withTransaction` composition; any failed queued\noperation poisons the complete transaction, and escaped services reject reuse.\n\n`updateGroup` changes the bounded display name or active flag under live\n`education.group.manage` authority. Management can reactivate an inactive group;\nnormal read, invitation and supervisor operations cannot use an inactive group.\nDisabling does not delete family rows. `removeLearner` is group-management\nrevocation, `leaveGroup`/`revokeMembership` are guardian revocation.\n`listMemberships` enumerates only the active group's roster index and rechecks\neach authoritative family edge under `education.group.review`. `getGroup` and\nactive `listMyGroups` use `education.group.read` for group metadata only; that\ncapability never authorizes child roster or shared work, revisions, media or\nprogress. Those private supervisor operations require `education.group.review`\nand the current exact family edge/policy. Hosts may grant ordinary members group\nmetadata reading without any private learner access. `listLearnerMemberships` is exact-family guardian\naccess. Neither method offers arbitrary cross-family education discovery.\n\nShared work includes an opaque `commentRevisionId` even when assessment feedback\nis withheld. Bind the comment form to that exact viewed revision and submit it\nunchanged; never resolve the latest assessment again when saving a comment.\nThe identifier grants no assessment access. Feedback and child comment visibility\nstill pass their independent current policy checks on every read.\n\nActivate `education.supervisor` through the host cookbook, rebuild registration\nand apply the host's normal manifest migrations before serving group routes.\nThe six additive tables use generated native UUID references and unique keys.\nDo not alter existing workbook rows or remove family data when a group leaves.\nA missing table is a migration error, never permission to create schema during\na request. Test schema generation uses the same structured manifest on SQLite\nand PostgreSQL; deployments retain their normal migration backup/rollback policy.\n\nHosts refresh the stored session and effective group permissions/guardian terms\non the callback's exact executor. Action strings above are capability names,\nnot a promise that any ordinary users role receives them automatically. Seed\nonly the desired group supervisor/admin roles and never grant family guardian\npermissions. Invitation delivery is host-owned; an invitation identifier alone\nis not a bearer credential. Parents can use only existing currently authorized\nlearners named by the immutable invitation.\n\nServe original bytes through `readSharedMedia` on every authenticated HTTP\nrequest, including direct bookmarks. The result carries `private, no-store`;\nhosts must also set safe MIME/disposition, sandbox CSP and `nosniff`, and never\ncache responses in a service worker or expose underlying asset paths. Uploaded\noriginals can contain marks physically present on paper even when generated\nassessment feedback sharing is disabled.\n\nHost navigation uses `listMyGroups(actor)` for the actor's current users memberships\nand `listLearnerMemberships({ ..., access: 'child' })` for an authenticated child's\nown active group edges. Child navigation uses `education.group.child-comments`\nand never grants guardian access to consent or policy history. Group discovery\nkeeps the original authenticated session context and derives exact group scopes\nfrom stored memberships; inactive groups require current management authority.\nStaff role assignment/removal uses the existing public users collections under\nhost authorization on the same executor. Education sharing never substitutes\nfor those users permission checks or gives staff family authority.\n\n`getSharingPolicyOptions({ actorUserId, sessionId, familyTenantId, learnerId })`\nreturns current course labels/subjects for a currently authorized guardian before\nconsent, without requiring a group or accepted invitation. It uses the same\nactive executor and exact learner ownership checks; it creates no grants or\npolicy history. Host guardian decisions must validate the supplied exact current\nrelationships/terms separately from a group's `education.group.review` role.\n\n## Trusted workspace-kind metadata\n\n`isGroupTenant(db, tenantId): Promise<boolean>` is a trusted host-only metadata\nlookup for an exact server-selected resource tenant. It uses the supplied current\nexecutor, verifies that the Tenant exists freshly, and checks the owning\n`education_groups` table for that exact tenant. Blank, malformed, missing or\nambiguous scope fails closed. The result is independent of group activation,\nTenant status, memberships and staff roles: disabling a group or removing a staff\nmembership never turns it into a family. The result contains no name, roster,\npermission or grant and creates no session or tenant context. It has no generated\nREST, MCP or CLI surface.\n\nOn PostgreSQL, the exact bounded group count and `row_security_active` check\nshare one statement on the caller's executor. One visible row establishes group\nkind; an ambiguous result fails closed. Zero rows establishes family kind only\nwhen PostgreSQL reports row security inactive for that relation and caller.\nUnder active RLS, zero rows is indeterminate and throws `Tenant kind is unavailable`,\nincluding for an actual family tenant. The host needs an already trusted metadata\nexecutor with provable unrestricted visibility to classify families. Policy-level\nbypass settings alone do not prove that visibility. The helper never changes\nroles, permissions, session settings or tenant context to obtain visibility.\n\nThis result is not authorization. Hosts must separately require a freshly ACTIVE\nTenant plus current signed-session, background-job or independent-proof authority\non the same relevant executor. Never pass a browser-selected tenant as authority,\ninterpret a missing Tenant as family, or use absence of group membership to infer\nfamily kind. The helper provides no arbitrary cross-tenant directory.\n\n| Behavior/invariant | Trigger | Positive | Negative/failure | Actor/context | Executor/transaction | Runtime/dialect | External edge | Level | Command |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n| G33 workspace kind | trusted exact resource lookup | existing family false; active/inactive group true | blank/malformed/absent tenant; ambiguous group rows; PostgreSQL RLS-hidden or indeterminate zero refuses family; database failures propagate | host-selected exact tenant, no synthetic session/context | caller-supplied executor, fresh reads; no writes | Node SQLite/PostgreSQL | status/membership/role absence never implies family or grants | integration | `pnpm --filter @happyvertical/smrt-education exec vitest run src/__tests__/group-sharing-workspace.test.ts` (same file with TEST_DB_ADAPTER=postgres and isolated DATABASE_URL) |\n\n## Authored course visibility\n\nAuthored-course `visibility` (see [course authoring](course-authoring-service.md))\nonly ever narrows group sharing; it never adds a reader, a tenant or a facet.\n\nBefore: every authored release that was not personal-use was eligible (policy\noptions, `listSharedWork`, preview) subject to the policy, edge and review rules.\nAfter: the same, except an authored release whose identity is explicitly recorded\nas `family` (including any unrecognized stored value) is `course-private`: it is\nabsent from the policy options and its work evaluates `course-private`, also when\nonly another release of that course is in the enrollment's pin lineage.\n`groups` and `public` change nothing relative to before. A course whose visibility\nwas never recorded (everything authored before visibility existed) also behaves as\nbefore, so existing contracts and tests are unchanged; hosts that want a default\nof \"family only\" call `setVisibility('family')` explicitly. `public` is a listing\nlabel only; no reviewed cross-tenant read path exists for it, so it grants nothing.\nNon-authored courses, answer keys and personal-use rules are untouched, and a\ndatabase without the visibility column or release tables reads as never recorded.\nThe gate is `authoredVisibilityPermitsGroups(db, tenantId, releaseCourseId)`.\n\nFiled loose work is attributed to its current filing placement (target lesson,\nenrollment and course, hence subject) under the same consent, offered-metadata and\npin-lineage rules as routed work; unplaced loose work stays unknown metadata.\n"
15726
+ "content": "# Homeschool group sharing contract\n\nArchitecture and pre-implementation test design for [SMRT #4469](https://github.com/happyvertical/smrt/issues/4469), consumed by Hearth #41. This document defines the intended contract; it is not a claim that implementation or validation has passed. Risk is high: explicit cross-tenant authorization, private child media, concurrent consent mutation and new persistent schema.\n\n## Ownership and boundary\n\nA group has an independent users `Tenant`, created with `TenantService` in the caller transaction, and ordinary users memberships/roles/permissions for its supervisors. Family tenants remain independent; do not make them descendants of the group. A supervisor never receives family membership, an inherited family role, a guardian relationship, or a family resource grant. Profiles remain canonical identities. Users `Group` is tenant-local RBAC, not the homeschool relationship.\n\nEducation owns a per-child group relationship in the child's exact family tenant. Its identity is `(familyTenantId, learnerId, groupTenantId)`, allowing several children and several groups without copying work. All work, immutable attempts/revisions, source files, releases, marks and comments stay family-owned. Revoking participation or narrowing a policy changes access, never deletes learning history or attribution.\n\n`ResourceGrantService.checkResourceOperationPermission` first requires permission in the resource's tenant. It cannot authorize these family reads without broadening supervisor authority. Reuse users permission checks in the group tenant; do not use ResourceGrant as a cache of policy matches. `TenantService` seeds roles during initialization and does not itself wrap create+ownership in a transaction; initialize/provision under the service's executor and reject partial creation.\n\nThe only cross-tenant entry is an explicit `GroupSharingService` operation. A trusted host authorizer receives the exact active executor and verifies current stored session, user, group membership and operation permission. Education then validates the persisted group, exact family/child edge and current policy before using exact family-scoped collection reads. No user-supplied SQL, cross-family list, system context, `allowCrossTenant` escape, synthesized guardian authorization, or direct collection handle is exposed. A reviewed scope switch may select only the already validated exact family and restore the caller context; it never turns a family collection into an unscoped query.\n\nGroup roster discovery uses only `GroupLearnerRosterEntry` enumerated with exact group tenant and group ID predicates. Its opaque selectors route to each exact family-owned membership and current policy, which are independently checked before any learner name or work is projected. A roster row is not an access grant: stale, revoked, corrupt or mismatched edges are denied. Never discover group participants by an unscoped query over family-owned memberships, invitations or work. Acceptance writes the roster index in the same transaction as the family edge/policy and invitation status. Revocation can retain that index for stable opaque history; it must confer no continued access. Concurrent acceptance/retry creates one index row, and rollback leaves none.\n\n## Closed persistence\n\nAll models disable API, MCP, CLI and AI mutation/discovery surfaces. Exports/manifests/recipe registration must preserve that closure. Use qualified cross-package references to users/profiles and local foreign keys inside education. Models are root-importable for generated registration. Tenant predicates are explicit on every family lookup, including foreign-key validation. Do not silently upsert immutable identities.\n\n| Model | Ownership and minimum fields | Invariants |\n| --- | --- | --- |\n| `EducationGroup` | Group tenant; name, active flag, creator user/profile | One record per group tenant. No family data. Group creation and users owner membership commit together. |\n| `GroupLearnerInvitation` | Group tenant; group, opaque familyTenantId/learnerId selectors, exact invited parent user, creator, expiry, status, accepted edge ID, accepted policy digest | Status pending/accepted/revoked/declined; expiry required; immutable scope. Group staff creates a targeted invitation without reading or confirming family/learner data. Validate the exact family/learner only at parent acceptance. ID selects an invitation, never proves parent identity. |\n| `GroupLearnerMembership` | Family tenant; group, learner, consent parent, status active/revoked, policyVersion | Unique family/learner/group tuple. Revocation is durable. A fresh invitation is required to reactivate a revoked edge; old accepted invitation cannot reactivate it. No one-child-only uniqueness. |\n| `GroupLearnerRosterEntry` | Group tenant; groupId, familyTenantId, learnerId, membershipId | Unique group/membership tuple; immutable opaque scope index, populated atomically with acceptance. Stores no names, work or policy payload and confers no access. |\n| `GroupSharingPolicy` | Family tenant; membership, monotonically increasing version, schemaVersion=1, canonical policy JSON, parent actor/time | Immutable append-only snapshots; edge points at current version. Unique membership/version. Explicit policy at acceptance; no implicit share-all. |\n| `GroupRevisionComment` | Family tenant; edge, learner, activity, attempt, exact revision, author user/profile, body, createdAt, childVisible=false, visibility parent/time/version=0 | Stable author and revision binding; cannot grade, alter assessment/evidence or silently move to a newer revision. Only parent changes child visibility. Group departure retains row. |\n\nUse guarded JSON getters/setters and runtime validation for policy documents. Enforce tuple uniqueness with database schema constraints. Validate status/version on model writes and revalidate persisted values on service reads, failing closed on corrupt or unknown values. The public model decorator does not expose SQL CHECK constraints; do not broaden core or claim database CHECK enforcement for these fields. Use normal manifest migrations; never create schema at runtime. Invitation delivery/authentication is host-owned; group invitations do not mint identity sessions, and acceptance requires an independently authenticated current parent. Initial release may use exact known parent IDs; mailbox identity onboarding is not implied by an invitation ID.\n\n## Service API and policy\n\nAll requests carry authenticated `actorUserId`, actor session/context binding and explicit resource selectors; none of those are accepted as authority merely because they are present. Proposed public operations:\n\n- `createGroup`, `getGroup`: current group administration/reading through users permissions; provision atomically.\n- `inviteLearner`: current group invitation authority creates a group-owned invitation for an exact known parent and opaque preknown family/learner selectors; it does not read family data, prove those selectors exist or require the inviter to be a guardian. `revokeInvitation` requires current group invitation authority. `declineInvitation` requires the exact currently authenticated invited parent and makes a pending invitation terminal without revealing learner data. `acceptInvitation({ invitationId, policy })`: the exact named parent must currently be guardian of the selected child in the selected family; acceptance requires explicit valid policy. Accept locks the invitation and child/group edge, rechecks active group, parent authority and expiry, inserts edge/policy/group-owned roster index and marks accepted atomically. The parent need not be a group member or supervisor.\n- `setSharingPolicy({ membershipId, expectedVersion, policy })`, `revokeMembership`: current family guardian only. An accepted retry returns the same edge only when parent and original canonical policy digest match and current edge remains active; it never resets policy changed later. Wrong-parent retries fail. Changed acceptance payload conflicts. Expired, revoked or declined pending invitations fail. Accepted historical retry may return original result after expiry only while its current edge is active and parent remains authorized.\n- `previewSharing({ membershipId, policy? })`: current parent; evaluates saved or explicitly proposed policy using exactly the evaluator and projection used for supervisor reads. Proposed preview does not persist or grant access. Return per-item allowed facets and parent-facing reasons.\n- `listSharedWork`, `getSharedWork`, `getSharedRevision`, `readSharedMedia`, `getSharedProgress`: current group supervisor, exact active edge and current policy, with bounded pagination. Reauthorize each call and each selected resource; no reusable authorization result escapes the invocation.\n- `addRevisionComment`, `listRevisionComments`: supervisor comment write requires currently shared exact work/revision and group comment permission; parent reads retained family comments independently of group revocation. Child reads require own learner identity and the current saved parent comment-visibility policy; version-zero comments inherit that policy. `setCommentChildVisibility({ commentId, expectedVersion, visible })` is parent-only.\n\nPolicy v1 has explicit booleans `shareWork`, `shareMedia`, `shareFeedback`, `shareProgress`, `parentReviewedOnly`; `historyFrom` is a required valid UTC instant; `commentVisibility` is required and accepts only `parent-only` or `learner-visible`; the host presents `parent-only` as the initial default but the service requires an explicit saved choice. `subjects` and `courseIds` are explicit selector objects (`all` or nonempty `include` values). Unknown version/keys/enums, malformed or missing required fields fail closed. Empty include selects nothing or is rejected consistently; it never means all. An explicit all selector intentionally allows unknown metadata; any restriction requires matching authoritative metadata. Subject comes from the retained activity's authorized course metadata, not a browser label or inferred assessment text. Course metadata is resolved through the same authorized self/ancestor/global catalog scope for options, preview and supervisor reads, after validating the child's exact family-owned enrollment. Catalog scope conveys no family or guardian authority to a supervisor. Synthetic routing courses for unfiled captures are excluded from selector options; unfiled capture facts retain unknown course and subject even if a technical placeholder has a label or subject. Genuine enrolled courses remain selectable before any capture. An unfiled activity or missing course/subject does not match a restricted selector. Both restrictions intersect when present.\n\nOne pure evaluator takes normalized policy and verified work/revision facts. All public reads and preview call it. History is based on immutable attempt capture time, inclusive at the cutoff; reanalysis/re-filing does not make old work new. Each correction attempt is evaluated separately. `parentReviewedOnly` requires a valid guardian revision of the selected attempt; AI analysis, child release acknowledgement or merely having some other reviewed attempt is insufficient. Feedback is projected only when enabled and the exact selected revision satisfies the review gate; work and media DTOs never incidentally contain feedback, marks or hidden historical question text. Progress may be enabled independently of feedback/work payloads: derive it only from permitted attempts/revisions, never from the whole-family summary. Media is allowed only for an allowed work attempt and the explicit media flag. New matching work appears automatically through evaluation, without per-work grants or background grant materialization.\n\nSupervisor projections contain stable activity/attempt/submission/revision IDs and authorized data only. They expose no writable models, family settings, sibling counts, source storage URLs, answer keys, personal-use catalog assets or ingestion scratch. Work identity does not authorize all revisions. Default omitted revision selects a deterministic permitted current revision; explicit unavailable revision refuses, never silently falls back. Policy flags remain independent; test every combination where one facet could leak through another.\n\n### Fixed exported contracts for implementation\n\nPut shared types and validators in `src/group-sharing-contracts.ts`; models import them, while the pure evaluator imports types only. The following names are the consumer contract, not alternate spellings for workers to invent:\n\n```ts\ntype GroupSharingSelector =\n | { mode: 'all' }\n | { mode: 'include'; values: string[] };\ninterface GroupSharingPolicyV1 {\n schemaVersion: 1;\n shareWork: boolean;\n shareMedia: boolean;\n shareFeedback: boolean;\n shareProgress: boolean;\n parentReviewedOnly: boolean;\n commentVisibility: 'parent-only' | 'learner-visible';\n historyFrom: string;\n subjects: GroupSharingSelector;\n courseIds: GroupSharingSelector;\n}\ninterface GroupSharingActor {\n actorUserId: string;\n sessionId: string;\n}\ninterface GroupSharingScope extends GroupSharingActor {\n groupId: string;\n familyTenantId: string;\n learnerId: string;\n}\ninterface GroupSharedWorkSelector extends GroupSharingScope {\n activityId: string;\n attemptId: string;\n revisionId?: string;\n}\ninterface GroupSharingWorkFacts {\n capturedAt: string;\n courseId: string | null;\n subject: string | null;\n parentReviewed: boolean;\n}\ninterface GroupSharingDecision {\n eligible: boolean;\n work: boolean;\n media: boolean;\n feedback: boolean;\n progress: boolean;\n reasons: string[];\n}\n```\n\nExport `validateGroupSharingPolicy`, `evaluateGroupSharingPolicy(policy, facts)`, `GroupSharingAuthorizationError`, and `GroupSharingConflictError`. `GroupSharingWorkFacts` is populated only by the owning reader, never request JSON. Return names are `EducationGroupView`, `GroupLearnerInvitationView`, `GroupLearnerMembershipView`, `GroupSharedWorkView`, `GroupSharedRevisionView`, `GroupSharingPreview`, `GroupSharedProgressView`, and `GroupRevisionCommentView`. Views are immutable data DTOs, with no model instances or executor. Use `familyTenantId` in requests; family-owned models store that as their required `tenantId`. Invitation and roster-index ownership are the explicit exceptions: its `tenantId` is the group tenant and `familyTenantId` is a distinct opaque scope field. Invitation group staff DTOs disclose only submitted selectors/status, never dereferenced family names, existence or child data. `groupId` references EducationGroup, whose `tenantId` is the users group tenant. Do not confuse these two tenant selectors.\n\nEligibility means course/subject/history/review gates pass; facets intersect eligibility with their explicit flag. `media` additionally requires `shareWork`, so the first implementation exposes original media only with work enabled. Progress can remain enabled while work/feedback DTOs are disabled. Assessment-derived text, answers, scores, computed answer regions and annotated renditions count as feedback. With feedback disabled but work/media enabled, the authorized original child submission bytes remain accessible: marks physically present in those original bytes are intrinsic source content, not a hidden assessment projection. Hosts must make that parent choice clear. A hidden guardian-origin reference/cover or answer-key file is never promoted to an original child submission merely by this toggle.\n\nThe authorization callback is `GroupSharingAuthorizer(request)` where request carries actor, exact group/family/learner selectors as applicable, a discriminated action and `executor: DatabaseInterface`. Actions are `education.group.create`, `education.group.manage`, `education.group.read`, `education.group.invite`, `education.group.share`, `education.group.review`, `education.group.comment`, and `education.group.child-comments`. The host verifies live identity and its effective permissions/guardian terms; the service independently verifies persisted resource scope, current edge/policy and exact selectors. Callback success never bypasses those checks. The reader accepts this callback, never a boolean token, and exposes only read/projection methods. No generic `authorizeAsGuardian` or arbitrary collection callback belongs to this API.\n\n## Owning workbook/media seams\n\nCurrent `LessonWorkService` activity/timeline APIs require learner/guardian authority; replacing the authorizer with unconditional true would create a broad guardian backdoor. Add an explicit read-only owning projection seam with a required exact-resource authorization callback, invoked on its active executor before loading private data and again at final projection/delivery where appropriate. It must not expose mutation methods or accept an externally cached authorization boolean. This seam can live in a dedicated education sharing reader that uses owned collections with exact predicates; private workbook algorithms should be shared through an owning helper rather than copied or private-state access.\n\nThe selected attempt must belong to exact family/learner/activity/submission; selected revision must belong to that attempt. Original bytes must be owned through `SubmissionAsset`, match the retained source ID/hash and belong to the learner's private asset owner. Photo covers/reference-only receipts are not work. The ordinary `signSubmissionAsset` returns a signed URL after learner authorization; it is insufficient for this sharing contract. `readSharedMedia` must run current policy/role/session/edge checks for every HTTP delivery and open the exact `withPrivateAssetAccess` scope only within that request. Return bytes or execute a scoped streaming callback; never return public/presigned storage URLs or a long-lived capability. The host route must invoke it even for a replayed/direct URL and configure private storage/cache headers. No framework can revoke bytes already delivered; successful reads linearize at their final authority check. A revocation completed before a new request must always deny that request.\n\nShared progress recomputes from the permitted subset of current guardian evidence and never calls the full guardian summary then merely redacts its citations. With `shareProgress=true` and `shareFeedback=false`, its strict field allowlist is counts of shared completed work, distinct practice episodes, and assistance categories (independent/assisted/unknown) only. Completion here means a permitted work attempt exists as completed/captured work, never a correctness threshold or private Grade-derived completion decision. Do not expose correctness, points, possible points, scores, concept labels, question identities/text/answers, feedback or outcome-derived aggregates. No question-level evidence citations appear in this counts-only shape. This restriction must hold even for a single attempt, where an aggregate would reveal the entire withheld outcome. When feedback is also enabled, authorized descriptive concept evidence may additionally be projected with permitted citations and existing episode deduplication; it still cannot include hidden subject/history/sibling evidence.\n\nComments remain outside Grades, assessment revision bodies, scoring and progress evidence. Parent-only comments can be shown to their currently authorized author/supervisors and guardians; this means hidden from children by default, not anonymous. Parent can retrieve original author/time after revocation. Child comment access always checks the current saved policy: `parent-only` denies all child comment reads immediately, including previously visible comments; `learner-visible` permits default comments whose `visibilityVersion` is zero. A parent may additionally set an individual override: when `visibilityVersion > 0`, `childVisible` must be true as well as the saved policy being `learner-visible`. Thus a saved learner-visible choice needs no per-comment opt-in, an individual false override narrows it, and an individual true override can never broaden parent-only. Policy changes apply on the next read and never rewrite the original comment or its attribution.\n\n## Transaction and race semantics\n\nUse one active executor for current users/session checks, guardian relationship terms, group state, edge/policy and owned data. Authorization hooks must not capture the outer pool. Mutations run in the education transaction/composite queue with deterministic lock ordering (learner, group/edge, invitation/comment as applicable); acquire the learner lock in the same order as workbook capture/review. PostgreSQL must arbitrate with row locks or conditional writes plus unique keys; process-local locks alone are insufficient. SQLite serialization must not overlap statements on a single native handle. Failed nested operations poison/roll back the enclosing operation even if a callback catches an error.\n\nConcurrent identical acceptance converges to one active edge, one first policy, one group-owned roster entry and one accepted invitation result. Competing policies cannot both win; one conflicts. Concurrent policy updates using one expectedVersion produce one successor. Education-owned edge/policy/invitation mutations participate in the same lock/guard protocol: acceptance versus invitation revoke and comment writes versus edge revoke/policy narrowing are serialized, so the losing mutation cannot commit on stale education-owned state. External users sessions, roles/memberships and guardian-term writers do not participate in that protocol. Their authority linearizes at the final successful live authority read on the active executor, not at transaction commit. An operation authorized before a concurrent external revocation may finish; a new request starting after that revocation committed must deny. A same-executor callback or second read alone cannot close the final-check-to-commit window, and this contract does not claim otherwise. Do not use a transaction snapshot established before the request to satisfy a new request's current-authority check. Stronger commit-time exclusion would require an actual owning users/profiles writer protocol and is outside this contract. Media reads use final-check linearization; bytes already delivered cannot be recalled. Database, authorizer or asset failures propagate as failures, never authorize on fallback. Failed group creation, acceptance and policy append leave no partial tenant/owner/edge/policy/roster/acceptance state. No service starts containers.\n\n## Behavior-to-test matrix\n\nThis is the test-design-contract matrix. Every row requires recorded execution evidence before release. Common context abbreviations: P = independently authenticated current family guardian; S = authenticated group supervisor; C = own child; X = outsider, sibling or other-group actor. TX means the same exact executor for authorization and persistence. SQ/PG means in-process SQLite and PostgreSQL; Node is the supported server runtime. Browser is intentionally not an authorization runtime. Command S: `pnpm --filter @happyvertical/smrt-education exec vitest run src/__tests__/group-sharing-lifecycle.test.ts src/__tests__/group-sharing-adversarial.test.ts src/__tests__/group-sharing-reader.test.ts src/__tests__/group-sharing-comments.test.ts`; command P: `pnpm --filter @happyvertical/smrt-education test:postgres`; command M: `pnpm --filter @happyvertical/smrt-education exec vitest run src/__tests__/group-sharing-models.test.ts src/__tests__/group-sharing-manifest.test.ts`; command B: package `typecheck`, `build`, `verify:pack`. The shared behavioral harness must be imported by both SQLite and `group-sharing.optional.test.ts`; a skipped optional PG suite is not passing PG evidence.\n\n| ID / behavior | Reachable trigger | Positive | Negative / failure | Actor/context | Executor / transaction | Runtime / dialect | External edge | Level / command |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n| G01 isolated group | createGroup | owner membership and group in distinct tenant | rollback after tenant insert; no family membership/parent hierarchy | P, X | TX provision atomic | Node SQ/PG | TenantService failure, unknown permission | integration S/P |\n| G02 closed persistence | generated registration/discovery | package imports models/recipe | no REST/CLI/MCP/AI CRUD advertised | untrusted caller | manifest, no DB needed | Node; schema SQ/PG | packed root exports | contract M/B |\n| G03 edge cardinality | invite/accept multiple edges | two children × two groups coexist | duplicate tuple or rebinding learner/family | P, X | TX unique constraints | Node SQ/PG | malformed IDs | integration S/P |\n| G04 invite targeting | inviteLearner/accept | group inviter creates without family lookup; exact parent accepts with current effective guardian terms; exact parent declines | other parent, expired relationship, wrong family/group/sibling, expired/revoked/declined invite | P/X | TX current session + relationships | Node SQ/PG | absent/expired session, authorizer throw | adversarial S/P |\n| G05 explicit policy | acceptInvitation | valid explicit v1 choice | omitted policy, unknown version/key, malformed date/selector/boolean | P | TX no partial writes | Node SQ/PG | untrusted JSON | service S/P |\n| G06 retry | accept same invite | same edge/policy/result, even after later policy narrowing | changed payload/parent; revoked edge never reactivated | P/X | TX immutable digest | Node SQ/PG | request replay | integration S/P |\n| G07 acceptance race | two accepts | identical requests converge | differing policy, revoke/expiry wins; injected failure rolls all back | P | independent root connections PG, serialized SQ; unique arbiter | Node SQ/PG | failure between edge/policy/roster/accepted writes; retry leaves one index row | concurrency S/P |\n| G08 policy version | setSharingPolicy | append version and preserve previous snapshots | stale expected version, supervisor write, invalid payload | P/S | TX compare-and-set | Node SQ/PG | concurrent updates, upstream auth failure | concurrency S/P |\n| G09 automatic evaluator | new capture/list | matching new work appears without grant creation | unknown restricted subject/course; course and subject mismatch | S/P preview | TX exact family queries | Node SQ/PG | unfiled/missing metadata | unit + service S/P |\n| G10 history/review | get work/revision | inclusive cutoff and reviewed exact attempt | old attempt reanalysis, other attempt's review, provisional-only revision, malformed ancestry | S | TX immutable facts | Node SQ/PG | future/invalid dates, stale revision ID | unit + adversarial S/P |\n| G11 facet independence | every public DTO | explicit work/media/feedback/progress combinations | disabled feedback leaks through marks/status/comment or progress; disabled media via anchors | S | TX projection | Node SQ/PG | omitted optional payload fields | table-driven S/P |\n| G12 preview parity | preview and subsequent reads | identical allowed IDs/facets under same saved/proposed policy | unauthorized preview; proposal creates grant/write | P/S | same evaluator/TX | Node SQ/PG | policy parse failure | contract S/P |\n| G13 current authority | list/detail/revision/media/comment/summary | active S exact group+edge | stale session, removed role, inactive membership/group, wrong active context, sibling/group edge | S/X | current TX reads, cache:false | Node SQ/PG | every public service path | adversarial parameterized S/P |\n| G14 narrowing/revoke | reuse IDs/direct route after change | parent retains originals/history | previously allowed S denied after policy/edge/group/role/session revoke | P/S | TX; final check | Node SQ/PG | stale URL and request replay | service + host contract S/P |\n| G15 exact source ownership | readSharedMedia | exact SubmissionAsset retained hash and private owner | foreign asset/submission/revision, source cover, answer key, tampered hash, public URL | S/X | TX + exact private scope | Node SQ/PG | storage missing/failure; no leaked locator | integration S/P |\n| G16 immutable work IDs | list/get/correction/reanalysis | stable attempt/revision IDs and source citation | foreign revision, selector fallback, reanalysis counted as new practice | S | TX read-only | Node SQ/PG | workbook reader seam | integration S/P |\n| G17 scoped progress | getSharedProgress | counts-only allowlist without feedback; descriptive evidence with feedback; episodes deduped | single-attempt or multi-attempt withheld correctness/score/concept/question leakage; hidden evidence contributes counts | S | TX filtered evidence before aggregate | Node SQ/PG | nullable scores/unknown assistance | integration S/P |\n| G18 comment author/binding | add/list comments | exact visible revision, stored current author | forged author, hidden/foreign revision, supervisor grade/evidence mutation | S/P | TX immutable bind | Node SQ/PG | malformed/empty/oversize text | adversarial S/P |\n| G19 comment visibility | set visibility/child list | saved learner-visible exposes version-zero defaults; explicit false narrows; parent-only immediately hides all | missing/unknown policy field, default parent-only C read, sibling, supervisor visibility update, stale version | P/C/S/X | TX guarded visibility version | Node SQ/PG | visibilityVersion=0 inherits; >0 requires childVisible plus saved learner-visible | integration S/P |\n| G20 retained family records | revoke then family read | originals, revisions, comments/attribution remain | supervisor retains old access; cascade removes learning rows | P/S | TX family authority independent of group | Node SQ/PG | no byte cleanup on revoke | integration S/P |\n| G21 transaction affinity | host hooks in operation | all callbacks observe exact TX including nested reads | outer-pool hook usage, swallowed failure commits, closed service reuse | P/S | instrument executor; rollback injection | Node SQ/PG | callback failure | integration S/P |\n| G22 interleaving authorization | comment/media vs revoke/narrow | own edge/invite/policy mutations serialize; pre-external-revoke authorized operation may finish | stale own-edge check commits after winning revoke; new post-commit session/role/guardian-revoke request succeeds | P/S | independent PG transactions/barriers; SQ serial equivalent | Node SQ/PG | deterministic barriers for own-edge race and external final-check/new-request semantics | race S/P |\n| G23 migration compatibility | manifest upgrade + normal schema apply | six closed models migrate on empty/existing DB; old workbook history unchanged | missing schema fails without runtime DDL; duplicate edge/roster tuple blocked; corrupt status/version read denied | deploy | explicit migration TX | Node SQ/PG | native UUID FKs, migration failure | schema U/P/B |\n| G24 recipe/plugin contract | recipe discovery and packed import | group recipe registered, service/types usable | generated CRUD exposed or missing root symbol | host | N/A: static registration, no persistence | Node | downstream manifest/recipe consumer | contract M/B |\n| G25 bounded query scope | list pagination/direct service | exact group-owned roster enumeration then each exact family edge/policy check; bounded results/stable cursors | forged/stale roster grants access; caller limit hides deny; cursor reveals foreign work; raw cross-family edge query | S/X | TX group index read then exact family predicates | Node SQ/PG | invalid cursor/limit | adversarial S/P |\n| G26 staff group discovery | listMyGroups after login | own active groups; inactive group only for current manager | other user/session, removed membership, inactive nonmanager group | S/X with original session context | TX users own-membership enumeration then exact group scope | Node SQ/PG | unknown group tenant and authorizer error | integration S/P |\n| G27 family/child navigation | listLearnerMemberships access guardian/child | parent retained summaries; own child active group names | sibling, wrong actor, child guardian-policy/history escalation; revoked/disabled child edge | P/C/X | TX exact family/learner, current own-user proof | Node SQ/PG | invalid access selector | adversarial S/P |\n| G28 invitation preview/list | getInvitation/listInvitations | named guardian preview without group membership; staff opaque selectors | wrong invitee, missing guardian proof, staff family dereference | P/S/X | TX exact group invite; parent exact family proof | Node SQ/PG | parent/staff explicit access discriminator | integration S/P |\n| G29 retained policy history | getSharingPolicyHistory after leave | immutable versions/actors/time and scoped numeric pagination | supervisor/child read, malformed cursor/limit, foreign edge | P/S/C/X | TX exact family edge, guardian before/after | Node SQ/PG | unknown stored policy or version fails closed | integration S/P |\n| G30 opaque revision binding | feedback withheld; new revision before comment | work exposes immutable comment ID; comment remains on opened revision | foreign/provisional assessment under selected-review gate, hidden assessment payload | S | TX exact attempt/revision and current policy | Node SQ/PG | opaque comment selector independent of feedback | reader/comments S/P |\n\n| G31 pre-consent policy options | getSharingPolicyOptions exact family/learner before invite acceptance | current guardian receives labeled learner course/subject options without a group edge | child/supervisor/outsider, wrong family/learner, stale guardian/session, private answer/catalog data | P/S/C/X | TX exact family/learner guardian before/after; no policy mutation | Node SQ/PG | groupId omitted; current scoped metadata only | reader S/P |\n| G32 metadata/private capability isolation | ordinary member group home vs roster/work | education.group.read permits group metadata/discovery only; education.group.review permits authorized private scope | metadata-only member roster/work/revision/media/progress, role/action escalation | current stored ordinary member/S | TX role/session/group plus exact edge/policy before/after | Node SQ/PG | guardian review remains separate exact guardian proof | adversarial S/P |\n\n## Implementation ownership partitions\n\nCoordinate these partitions before editing; integrations are serial, not concurrent edits to the same file.\n\n1. Persistence/contracts worker: `src/group-sharing-models.ts`, `src/group-sharing-contracts.ts`, `src/__tests__/group-sharing-models.test.ts`. Own validation/types/closed models and unique schema; freeze exported interfaces first.\n2. Lifecycle worker: `src/group-sharing-service.ts`, `src/group-sharing-authorization.ts`, `src/__tests__/group-sharing-lifecycle-workflows.ts`. Own group/invitation/edge/policy state machine, shared transaction and current-authority boundary. Do not implement a second workbook reader.\n3. Read/projection worker: `src/group-sharing-reader.ts`, `src/group-sharing-policy.ts`, `src/__tests__/group-sharing-read-workflows.ts`. Own same evaluator for preview/reads, exact work/media projection and scoped summary. Any changes needed to `src/workbook-service.ts` or `src/submission-service.ts` belong exclusively to this worker after parent approves the owning seam. Do not widen LearnerAuthorizer.\n4. Integration owner: `src/group-sharing-comments.ts`, shared test runner `src/__tests__/group-sharing-workflows.ts`, SQLite/PG entrypoints, `src/index.ts`, `src/recipes.ts`, recipe help, package docs/AGENTS and generated artifacts. Integrate comment API after lifecycle/read interfaces stabilize, run full validation/review once. Could delegate comments as a separate non-overlapping worker after contracts freeze.\n5. Architecture owner: this document only. Record executable evidence/results through the parent session; proposed matrix rows do not substitute for executed tests.\n\n## Upstream/host contract gaps to resolve before release\n\n- Existing workbook guardian projections and signed asset API are not a sharing boundary. The new owning read seam and host authenticated media route are mandatory, not optional integration notes.\n- Host current-session/group permission and current guardian-term verification must use the provided transaction executor. No external app is permitted to supply an always-allow callback or stale role snapshot. Document exact action names/role seed in implementation.\n- Course.subject is a mutable string; define whether policy uses current authoritative metadata (recommended for immediate narrowing) or immutable metadata snapshots. This design uses current authoritative course metadata and immutable capture time. Missing/re-filed metadata is reevaluated each read.\n- PostgreSQL concurrent tests need independent connections and deterministic barriers. One connection or in-process mutex does not establish multi-process acceptance/revocation safety. Integration owner supplies the isolated existing test service; workers do not provision containers.\n- Public consumer contract must record the published SMRT version, exported types/actions, migration/recipe activation and media route obligations. Hearth consumes that release only; no local tarball or host shim.\n\n## Host integration and migrations\n\nThe public `GroupSharingService(db, authorize, options?)` facade owns lifecycle,\nreader and comment operations. Internal reader/comment classes are not root\nexports: their borrowed-executor flags are owning implementation details.\n`options` uses the normal `SubmissionServiceOptions` private storage adapter.\nThe facade supports atomic `withTransaction` composition; any failed queued\noperation poisons the complete transaction, and escaped services reject reuse.\n\n`updateGroup` changes the bounded display name or active flag under live\n`education.group.manage` authority. Management can reactivate an inactive group;\nnormal read, invitation and supervisor operations cannot use an inactive group.\nDisabling does not delete family rows. `removeLearner` is group-management\nrevocation, `leaveGroup`/`revokeMembership` are guardian revocation.\n`listMemberships` enumerates only the active group's roster index and rechecks\neach authoritative family edge under `education.group.review`. `getGroup` and\nactive `listMyGroups` use `education.group.read` for group metadata only; that\ncapability never authorizes child roster or shared work, revisions, media or\nprogress. Those private supervisor operations require `education.group.review`\nand the current exact family edge/policy. Hosts may grant ordinary members group\nmetadata reading without any private learner access. `listLearnerMemberships` is exact-family guardian\naccess. Neither method offers arbitrary cross-family education discovery.\n\nShared work includes an opaque `commentRevisionId` even when assessment feedback\nis withheld. Bind the comment form to that exact viewed revision and submit it\nunchanged; never resolve the latest assessment again when saving a comment.\nThe identifier grants no assessment access. Feedback and child comment visibility\nstill pass their independent current policy checks on every read.\n\nActivate `education.supervisor` through the host cookbook, rebuild registration\nand apply the host's normal manifest migrations before serving group routes.\nThe six additive tables use generated native UUID references and unique keys.\nDo not alter existing workbook rows or remove family data when a group leaves.\nA missing table is a migration error, never permission to create schema during\na request. Test schema generation uses the same structured manifest on SQLite\nand PostgreSQL; deployments retain their normal migration backup/rollback policy.\n\nHosts refresh the stored session and effective group permissions/guardian terms\non the callback's exact executor. Action strings above are capability names,\nnot a promise that any ordinary users role receives them automatically. Seed\nonly the desired group supervisor/admin roles and never grant family guardian\npermissions. Invitation delivery is host-owned; an invitation identifier alone\nis not a bearer credential. Parents can use only existing currently authorized\nlearners named by the immutable invitation.\n\nServe original bytes through `readSharedMedia` on every authenticated HTTP\nrequest, including direct bookmarks. The result carries `private, no-store`;\nhosts must also set safe MIME/disposition, sandbox CSP and `nosniff`, and never\ncache responses in a service worker or expose underlying asset paths. Uploaded\noriginals can contain marks physically present on paper even when generated\nassessment feedback sharing is disabled.\n\nHost navigation uses `listMyGroups(actor)` for the actor's current users memberships\nand `listLearnerMemberships({ ..., access: 'child' })` for an authenticated child's\nown active group edges. Child navigation uses `education.group.child-comments`\nand never grants guardian access to consent or policy history. Group discovery\nkeeps the original authenticated session context and derives exact group scopes\nfrom stored memberships; inactive groups require current management authority.\nStaff role assignment/removal uses the existing public users collections under\nhost authorization on the same executor. Education sharing never substitutes\nfor those users permission checks or gives staff family authority.\n\n`getSharingPolicyOptions({ actorUserId, sessionId, familyTenantId, learnerId })`\nreturns current course labels/subjects for a currently authorized guardian before\nconsent, without requiring a group or accepted invitation. It uses the same\nactive executor and exact learner ownership checks; it creates no grants or\npolicy history. Host guardian decisions must validate the supplied exact current\nrelationships/terms separately from a group's `education.group.review` role.\n\n## Trusted workspace-kind metadata\n\n`isGroupTenant(db, tenantId): Promise<boolean>` is a trusted host-only metadata\nlookup for an exact server-selected resource tenant. It uses the supplied current\nexecutor, verifies that the Tenant exists freshly, and checks the owning\n`education_groups` table for that exact tenant. Blank, malformed, missing or\nambiguous scope fails closed. The result is independent of group activation,\nTenant status, memberships and staff roles: disabling a group or removing a staff\nmembership never turns it into a family. The result contains no name, roster,\npermission or grant and creates no session or tenant context. It has no generated\nREST, MCP or CLI surface.\n\nOn PostgreSQL, the exact bounded group count and `row_security_active` check\nshare one statement on the caller's executor. One visible row establishes group\nkind; an ambiguous result fails closed. Zero rows establishes family kind only\nwhen PostgreSQL reports row security inactive for that relation and caller.\nUnder active RLS, zero rows is indeterminate and throws `Tenant kind is unavailable`,\nincluding for an actual family tenant. The host needs an already trusted metadata\nexecutor with provable unrestricted visibility to classify families. Policy-level\nbypass settings alone do not prove that visibility. The helper never changes\nroles, permissions, session settings or tenant context to obtain visibility.\n\nThis result is not authorization. Hosts must separately require a freshly ACTIVE\nTenant plus current signed-session, background-job or independent-proof authority\non the same relevant executor. Never pass a browser-selected tenant as authority,\ninterpret a missing Tenant as family, or use absence of group membership to infer\nfamily kind. The helper provides no arbitrary cross-tenant directory.\n\n| Behavior/invariant | Trigger | Positive | Negative/failure | Actor/context | Executor/transaction | Runtime/dialect | External edge | Level | Command |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n| G33 workspace kind | trusted exact resource lookup | existing family false; active/inactive group true | blank/malformed/absent tenant; ambiguous group rows; PostgreSQL RLS-hidden or indeterminate zero refuses family; database failures propagate | host-selected exact tenant, no synthetic session/context | caller-supplied executor, fresh reads; no writes | Node SQLite/PostgreSQL | status/membership/role absence never implies family or grants | integration | `pnpm --filter @happyvertical/smrt-education exec vitest run src/__tests__/group-sharing-workspace.test.ts` (same file with TEST_DB_ADAPTER=postgres and isolated DATABASE_URL) |\n\n## Authored course visibility\n\nAuthored-course `visibility` (see [course authoring](course-authoring-service.md))\nonly ever narrows group sharing; it never adds a reader, a tenant or a facet.\n\nBefore: every authored release that was not personal-use was eligible (policy\noptions, `listSharedWork`, preview) subject to the policy, edge and review rules.\nAfter: the same, except an authored release whose identity is explicitly recorded\nas `family` (including any unrecognized stored value) is `course-private`: it is\nabsent from the policy options and its work evaluates `course-private`, also when\nonly another release of that course is in the enrollment's pin lineage.\n`groups` and `public` change nothing relative to before. A course whose visibility\nwas never recorded (everything authored before visibility existed) also behaves as\nbefore, so existing contracts and tests are unchanged; hosts that want a default\nof \"family only\" call `setVisibility('family')` explicitly. `public` is a listing\nlabel only; no reviewed cross-tenant read path exists for it, so it grants nothing.\nNon-authored courses, answer keys and personal-use rules are untouched, and a\ndatabase without the visibility column or release tables reads as never recorded.\nThe gate is `authoredVisibilityPermitsGroups(db, tenantId, releaseCourseId)`.\n\nFiled loose work is attributed to its current filing placement (target lesson,\nenrollment and course, hence subject) under the same consent, offered-metadata and\npin-lineage rules as routed work; unplaced loose work stays unknown metadata.\n\nShared work also carries `courseTitle`, `lessonTitle` and `pageLabel` (#4619) so a\nsupervisor can relate samples to the family's program without reading family\ncourses. They are read from the same resolved course, placement and gates as\n`courseId`/`subject`: only for items the policy shares, null where the course is\nunknown, and the lesson title only when the lesson belongs to that course. Filed\nwork names its placement's page; routed work reads the lesson's first page.\n"
15575
15727
  },
15576
15728
  {
15577
15729
  "path": "agents/tutor-context.md",
@@ -0,0 +1,8 @@
1
+ import type { ClassCrunchLabsFileSource } from './class-crunchlabs.js';
2
+ /**
3
+ * Every unit file under `directory`: plain files, and the entries of any `.zip`
4
+ * (a zip is not expanded into the filesystem, and a zip inside a zip is not
5
+ * opened). Symbolic links are skipped; hidden files are ignored.
6
+ */
7
+ export declare function folderClassCrunchLabsSource(directory: string): ClassCrunchLabsFileSource;
8
+ //# sourceMappingURL=class-crunchlabs-files.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"class-crunchlabs-files.d.ts","sourceRoot":"","sources":["../../src/sources/class-crunchlabs-files.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAEV,yBAAyB,EAC1B,MAAM,uBAAuB,CAAC;AAiJ/B;;;;GAIG;AACH,wBAAgB,2BAA2B,CACzC,SAAS,EAAE,MAAM,GAChB,yBAAyB,CAgD3B"}