@happyvertical/smrt-education 0.57.39 → 0.57.41

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.
@@ -3,20 +3,21 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-education",
6
- "packageVersion": "0.57.39",
6
+ "packageVersion": "0.57.41",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "5c6a35e0af4e4612477d70216ad90e72b2bdaec842ee8cf11d726588dad34486",
11
- "packageJson": "b7020102a3bac6fe5771314550029c5157fd9a68e47205dbe45210ea4192ff5f",
12
- "agents": "f06312dca0737d6487b4f310b22b6c24b3276644e9ccaf9b7a27b71ebc1ca2ff",
10
+ "manifest": "ae64f54ba8bea6d6e276d613d31da66eb2d14358126d88809f274c53fc2895a1",
11
+ "packageJson": "3e4ad63992ff5155f05771b97121b6ca198a2f72c25ac4ae7ffa1f777b3b7adb",
12
+ "agents": "317c3224df6c2fa916d58f1b6e51738738daead1d676aff4d2b6de5e0ccb6cd6",
13
13
  "moduleDoc:agents/learner-service.md": "7da70d214547eb78fbf058e997599cc80ab86f5bb55748bd158a9a4b30070d3b",
14
- "moduleDoc:agents/submission-service.md": "8e63b83ac84628d5e5ddacbb6a47077c0e65eee3da87518d9bf6e30fb8348336",
15
- "moduleDoc:agents/workbook-service.md": "3ca3fb60d39daf76b94ec668fc19dc67aa7e090b7f44412bed5c9d92e8efaf5a",
14
+ "moduleDoc:agents/submission-service.md": "cb03f51e1dabdb827ea049fa142a787eb71c61a406b7e812aae85d82453fb1de",
15
+ "moduleDoc:agents/workbook-service.md": "ae3a0f208b42766e417eac226e4a24ec15fe17c1ba9cde26a325ded982614278",
16
16
  "moduleDoc:agents/photo-intake.md": "3b7057201ee30b2361aad23ea52e8a55c8ed5354e7a7e4a8193b7de17ca2f0db",
17
17
  "moduleDoc:agents/source-import.md": "30ee823589d655442bcf1abcd86a2132172065b4bc5e3c4d89b1e8b7afcc66bc",
18
18
  "moduleDoc:agents/family-intake.md": "540b6d37f438496acb0208b6ac7258264e2142bce3d2f986967b7741606fe4f7",
19
- "moduleDoc:agents/catalog-previews.md": "5f4e0352274009b3ce00408017a16666036d24df695a7078c27317ba4e66f0a7"
19
+ "moduleDoc:agents/catalog-previews.md": "5f4e0352274009b3ce00408017a16666036d24df695a7078c27317ba4e66f0a7",
20
+ "moduleDoc:agents/learner-plan.md": "2f2bc5b7e1f55a382c54c12187173790c14716029ad0b8ae7bbb04393bc2a2e5"
20
21
  },
21
22
  "exports": [
22
23
  ".",
@@ -61,7 +62,7 @@
61
62
  "smrt"
62
63
  ],
63
64
  "risks": [
64
- "cross-package-refs:8",
65
+ "cross-package-refs:10",
65
66
  "sensitive-fields-excluded",
66
67
  "sti-inheritance"
67
68
  ],
@@ -2633,6 +2634,161 @@
2633
2634
  "tags": [],
2634
2635
  "risks": []
2635
2636
  },
2637
+ {
2638
+ "name": "EnrollmentParentNote",
2639
+ "qualifiedName": "@happyvertical/smrt-education:EnrollmentParentNote",
2640
+ "collection": "enrollmentparentnotes",
2641
+ "description": "Private guardian annotations, separate from learning and assessment history.",
2642
+ "tableName": "enrollment_parent_notes",
2643
+ "packageName": "@happyvertical/smrt-education",
2644
+ "extends": "SmrtObject",
2645
+ "fields": [
2646
+ {
2647
+ "name": "tenantId",
2648
+ "type": "text",
2649
+ "required": true,
2650
+ "columnType": "UUID"
2651
+ },
2652
+ {
2653
+ "name": "enrollmentId",
2654
+ "type": "foreignKey",
2655
+ "required": true,
2656
+ "related": "Enrollment",
2657
+ "columnType": "UUID"
2658
+ },
2659
+ {
2660
+ "name": "learnerId",
2661
+ "type": "foreignKey",
2662
+ "required": true,
2663
+ "related": "Learner",
2664
+ "columnType": "UUID"
2665
+ },
2666
+ {
2667
+ "name": "authorUserId",
2668
+ "type": "crossPackageRef",
2669
+ "required": true,
2670
+ "related": "@happyvertical/smrt-users:User",
2671
+ "columnType": "UUID"
2672
+ },
2673
+ {
2674
+ "name": "authorLabel",
2675
+ "type": "text",
2676
+ "required": false,
2677
+ "columnType": "TEXT"
2678
+ },
2679
+ {
2680
+ "name": "text",
2681
+ "type": "text",
2682
+ "required": false,
2683
+ "columnType": "TEXT"
2684
+ },
2685
+ {
2686
+ "name": "requestKey",
2687
+ "type": "text",
2688
+ "required": false,
2689
+ "columnType": "TEXT"
2690
+ },
2691
+ {
2692
+ "name": "recordedAt",
2693
+ "type": "text",
2694
+ "required": false,
2695
+ "columnType": "TEXT"
2696
+ }
2697
+ ],
2698
+ "relationships": [
2699
+ {
2700
+ "name": "enrollmentId",
2701
+ "type": "foreignKey",
2702
+ "required": true,
2703
+ "related": "Enrollment",
2704
+ "columnType": "UUID"
2705
+ },
2706
+ {
2707
+ "name": "learnerId",
2708
+ "type": "foreignKey",
2709
+ "required": true,
2710
+ "related": "Learner",
2711
+ "columnType": "UUID"
2712
+ },
2713
+ {
2714
+ "name": "authorUserId",
2715
+ "type": "crossPackageRef",
2716
+ "required": true,
2717
+ "related": "@happyvertical/smrt-users:User",
2718
+ "columnType": "UUID"
2719
+ }
2720
+ ],
2721
+ "methods": [
2722
+ "delete",
2723
+ "save"
2724
+ ],
2725
+ "methodSignatures": [
2726
+ {
2727
+ "name": "delete",
2728
+ "async": true,
2729
+ "returns": "Promise<void>"
2730
+ },
2731
+ {
2732
+ "name": "save",
2733
+ "async": true,
2734
+ "params": [
2735
+ "options?: SmrtSaveOptions"
2736
+ ],
2737
+ "returns": "Promise<this>"
2738
+ }
2739
+ ],
2740
+ "tenant": {
2741
+ "scoped": true,
2742
+ "mode": "required",
2743
+ "field": "tenantId"
2744
+ },
2745
+ "conflictColumns": [
2746
+ "tenant_id",
2747
+ "enrollment_id",
2748
+ "request_key"
2749
+ ],
2750
+ "surfaces": [],
2751
+ "withheldSurfaces": [
2752
+ {
2753
+ "kind": "api",
2754
+ "operation": "delete",
2755
+ "code": "api-disabled",
2756
+ "reason": "api is disabled",
2757
+ "objectName": "@happyvertical/smrt-education:EnrollmentParentNote"
2758
+ },
2759
+ {
2760
+ "kind": "api",
2761
+ "operation": "save",
2762
+ "code": "api-disabled",
2763
+ "reason": "api is disabled",
2764
+ "objectName": "@happyvertical/smrt-education:EnrollmentParentNote"
2765
+ }
2766
+ ],
2767
+ "relationshipFeatures": [
2768
+ "crossPackageRef",
2769
+ "foreignKey",
2770
+ "uuidColumns"
2771
+ ],
2772
+ "tags": [],
2773
+ "risks": []
2774
+ },
2775
+ {
2776
+ "name": "EnrollmentParentNoteCollection",
2777
+ "qualifiedName": "@happyvertical/smrt-education:EnrollmentParentNoteCollection",
2778
+ "collection": "enrollmentparentnotes",
2779
+ "tableName": "enrollment_parent_notes",
2780
+ "packageName": "@happyvertical/smrt-education",
2781
+ "extends": "SmrtCollection",
2782
+ "fields": [],
2783
+ "relationships": [],
2784
+ "methods": [],
2785
+ "surfaces": [],
2786
+ "relationshipFeatures": [
2787
+ "uuidColumns"
2788
+ ],
2789
+ "tags": [],
2790
+ "risks": []
2791
+ },
2636
2792
  {
2637
2793
  "name": "FamilyIntake",
2638
2794
  "qualifiedName": "@happyvertical/smrt-education:FamilyIntake",
@@ -4693,6 +4849,191 @@
4693
4849
  "tags": [],
4694
4850
  "risks": []
4695
4851
  },
4852
+ {
4853
+ "name": "WorkbookCompletionDecision",
4854
+ "qualifiedName": "@happyvertical/smrt-education:WorkbookCompletionDecision",
4855
+ "collection": "workbookcompletiondecisions",
4856
+ "description": "Immutable parent judgment; assessment and feedback release remain independent.",
4857
+ "tableName": "workbook_completion_decisions",
4858
+ "packageName": "@happyvertical/smrt-education",
4859
+ "extends": "SmrtObject",
4860
+ "fields": [
4861
+ {
4862
+ "name": "tenantId",
4863
+ "type": "text",
4864
+ "required": true,
4865
+ "columnType": "UUID"
4866
+ },
4867
+ {
4868
+ "name": "learnerId",
4869
+ "type": "foreignKey",
4870
+ "required": true,
4871
+ "related": "Learner",
4872
+ "columnType": "UUID"
4873
+ },
4874
+ {
4875
+ "name": "activityId",
4876
+ "type": "foreignKey",
4877
+ "required": true,
4878
+ "related": "WorkbookActivity",
4879
+ "columnType": "UUID"
4880
+ },
4881
+ {
4882
+ "name": "attemptId",
4883
+ "type": "foreignKey",
4884
+ "required": true,
4885
+ "related": "WorkbookAttempt",
4886
+ "columnType": "UUID"
4887
+ },
4888
+ {
4889
+ "name": "submissionId",
4890
+ "type": "foreignKey",
4891
+ "required": true,
4892
+ "related": "Submission",
4893
+ "columnType": "UUID"
4894
+ },
4895
+ {
4896
+ "name": "feedbackHeadId",
4897
+ "type": "foreignKey",
4898
+ "required": false,
4899
+ "related": "WorkbookAssessmentRevision",
4900
+ "columnType": "UUID"
4901
+ },
4902
+ {
4903
+ "name": "actorUserId",
4904
+ "type": "crossPackageRef",
4905
+ "required": true,
4906
+ "related": "@happyvertical/smrt-users:User",
4907
+ "columnType": "UUID"
4908
+ },
4909
+ {
4910
+ "name": "requestKey",
4911
+ "type": "text",
4912
+ "required": false,
4913
+ "columnType": "TEXT"
4914
+ },
4915
+ {
4916
+ "name": "recordedAt",
4917
+ "type": "text",
4918
+ "required": false,
4919
+ "columnType": "TEXT"
4920
+ }
4921
+ ],
4922
+ "relationships": [
4923
+ {
4924
+ "name": "learnerId",
4925
+ "type": "foreignKey",
4926
+ "required": true,
4927
+ "related": "Learner",
4928
+ "columnType": "UUID"
4929
+ },
4930
+ {
4931
+ "name": "activityId",
4932
+ "type": "foreignKey",
4933
+ "required": true,
4934
+ "related": "WorkbookActivity",
4935
+ "columnType": "UUID"
4936
+ },
4937
+ {
4938
+ "name": "attemptId",
4939
+ "type": "foreignKey",
4940
+ "required": true,
4941
+ "related": "WorkbookAttempt",
4942
+ "columnType": "UUID"
4943
+ },
4944
+ {
4945
+ "name": "submissionId",
4946
+ "type": "foreignKey",
4947
+ "required": true,
4948
+ "related": "Submission",
4949
+ "columnType": "UUID"
4950
+ },
4951
+ {
4952
+ "name": "feedbackHeadId",
4953
+ "type": "foreignKey",
4954
+ "required": false,
4955
+ "related": "WorkbookAssessmentRevision",
4956
+ "columnType": "UUID"
4957
+ },
4958
+ {
4959
+ "name": "actorUserId",
4960
+ "type": "crossPackageRef",
4961
+ "required": true,
4962
+ "related": "@happyvertical/smrt-users:User",
4963
+ "columnType": "UUID"
4964
+ }
4965
+ ],
4966
+ "methods": [
4967
+ "delete",
4968
+ "save"
4969
+ ],
4970
+ "methodSignatures": [
4971
+ {
4972
+ "name": "delete",
4973
+ "async": true,
4974
+ "returns": "Promise<void>"
4975
+ },
4976
+ {
4977
+ "name": "save",
4978
+ "async": true,
4979
+ "params": [
4980
+ "options?: SmrtSaveOptions"
4981
+ ],
4982
+ "returns": "Promise<this>"
4983
+ }
4984
+ ],
4985
+ "tenant": {
4986
+ "scoped": true,
4987
+ "mode": "required",
4988
+ "field": "tenantId"
4989
+ },
4990
+ "conflictColumns": [
4991
+ "tenant_id",
4992
+ "activity_id",
4993
+ "request_key"
4994
+ ],
4995
+ "surfaces": [],
4996
+ "withheldSurfaces": [
4997
+ {
4998
+ "kind": "api",
4999
+ "operation": "delete",
5000
+ "code": "api-disabled",
5001
+ "reason": "api is disabled",
5002
+ "objectName": "@happyvertical/smrt-education:WorkbookCompletionDecision"
5003
+ },
5004
+ {
5005
+ "kind": "api",
5006
+ "operation": "save",
5007
+ "code": "api-disabled",
5008
+ "reason": "api is disabled",
5009
+ "objectName": "@happyvertical/smrt-education:WorkbookCompletionDecision"
5010
+ }
5011
+ ],
5012
+ "relationshipFeatures": [
5013
+ "crossPackageRef",
5014
+ "foreignKey",
5015
+ "uuidColumns"
5016
+ ],
5017
+ "tags": [],
5018
+ "risks": []
5019
+ },
5020
+ {
5021
+ "name": "WorkbookCompletionDecisionCollection",
5022
+ "qualifiedName": "@happyvertical/smrt-education:WorkbookCompletionDecisionCollection",
5023
+ "collection": "workbookcompletiondecisions",
5024
+ "tableName": "workbook_completion_decisions",
5025
+ "packageName": "@happyvertical/smrt-education",
5026
+ "extends": "SmrtCollection",
5027
+ "fields": [],
5028
+ "relationships": [],
5029
+ "methods": [],
5030
+ "surfaces": [],
5031
+ "relationshipFeatures": [
5032
+ "uuidColumns"
5033
+ ],
5034
+ "tags": [],
5035
+ "risks": []
5036
+ },
4696
5037
  {
4697
5038
  "name": "Workbook",
4698
5039
  "qualifiedName": "@happyvertical/smrt-education:Workbook",
@@ -6428,14 +6769,14 @@
6428
6769
  "surfaces": [],
6429
6770
  "prompts": [],
6430
6771
  "relationshipsV2": {
6431
- "foreignKeyFields": 73,
6432
- "crossPackageRefFields": 8,
6772
+ "foreignKeyFields": 80,
6773
+ "crossPackageRefFields": 10,
6433
6774
  "junctionCollections": 2,
6434
6775
  "hierarchicalObjects": 0,
6435
6776
  "polymorphicAssociations": 0,
6436
- "uuidColumns": 320
6777
+ "uuidColumns": 346
6437
6778
  },
6438
- "agentDoc": "# smrt-education\n\nCatalog objects are optional-tenant scoped and have no generated REST, CLI, MCP,\nor AI surface. Applications use `CatalogService`, supplying their actor-bound\nauthorization decision and the trusted `TenantCollection.withTenantCatalogReadScope`\nadapter from smrt-users. Catalog authorization receives the exact shared collection\nexecutor; live session/permission checks must use that executor. That read scope may widen only an allowlisted catalog\ncollection's `list` to self, ancestors, and globals; it never authorizes a get,\nwrite, raw query, sibling, or descendant read.\n\n`personal-use-copy` courses and all `answer-key` lesson assets are private\nfamily material. Do not publish them, promote them, or expose them through a\nlearner-facing result. `LessonAsset` is the sole owner join for lesson files;\ndo not add asset-id fields to lesson subclasses.\n\nLearner objects require exact tenant ownership and close generated surfaces.\n`LearnerService` owns actor/resource authorization and transactional planning and\nstart-progress; see [learner contract](agents/learner-service.md). Guardian\nrelationships require a host decision about their type and effective terms.\nNever expose direct collection writes as learner handlers, reset progress on a\nretry, or treat an unscored nullable decimal as zero.\n\nSubmission capture, guardian grading and private PDF packet splitting use\n`SubmissionService`; see [submission contract](agents/submission-service.md).\nKeep asset metadata on the same transaction and compensate only newly planned\nfiles after rollback; never expose source URIs or accept learner grade patches.\n\nValidation: `pnpm --filter @happyvertical/smrt-education test`,\n`test:postgres`, `typecheck`, and `build`.\n\nPhysical workbook capture, immutable assessment/feedback history and descriptive\nevidence use [WorkbookService](agents/workbook-service.md). Keep provisional\nsharing separate from guardian Grade authority. Photo-first receipt/context\ncapture uses [WorkbookPhotoService](agents/photo-intake.md); covers/references\nnever create learning evidence, and blank exercises need explicit work intent.\n\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",
6779
+ "agentDoc": "# smrt-education\n\nCatalog objects are optional-tenant scoped and have no generated REST, CLI, MCP,\nor AI surface. Applications use `CatalogService`, supplying their actor-bound\nauthorization decision and the trusted `TenantCollection.withTenantCatalogReadScope`\nadapter from smrt-users. Catalog authorization receives the exact shared collection\nexecutor; live session/permission checks must use that executor. That read scope may widen only an allowlisted catalog\ncollection's `list` to self, ancestors, and globals; it never authorizes a get,\nwrite, raw query, sibling, or descendant read.\n\n`personal-use-copy` courses and all `answer-key` lesson assets are private\nfamily material. Do not publish them, promote them, or expose them through a\nlearner-facing result. `LessonAsset` is the sole owner join for lesson files;\ndo not add asset-id fields to lesson subclasses.\n\nLearner objects require exact tenant ownership and close generated surfaces.\n`LearnerService` owns actor/resource authorization and transactional planning and\nstart-progress; see [learner contract](agents/learner-service.md). Guardian\nrelationships require a host decision about their type and effective terms.\nNever expose direct collection writes as learner handlers, reset progress on a\nretry, or treat an unscored nullable decimal as zero.\n\nSubmission capture, guardian grading and private PDF packet splitting use\n`SubmissionService`; see [submission contract](agents/submission-service.md).\nKeep asset metadata on the same transaction and compensate only newly planned\nfiles after rollback; never expose source URIs or accept learner grade patches.\n\nValidation: `pnpm --filter @happyvertical/smrt-education test`,\n`test:postgres`, `typecheck`, and `build`.\n\nPhysical workbook capture, immutable assessment/feedback history and descriptive\nevidence use [WorkbookService](agents/workbook-service.md). Keep provisional\nsharing separate from guardian Grade authority. Photo-first receipt/context\ncapture uses [WorkbookPhotoService](agents/photo-intake.md); covers/references\nnever create learning evidence, and blank exercises need explicit work intent.\n\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",
6439
6780
  "moduleDocs": [
6440
6781
  {
6441
6782
  "path": "agents/learner-service.md",
@@ -6445,12 +6786,12 @@
6445
6786
  {
6446
6787
  "path": "agents/submission-service.md",
6447
6788
  "module": "submission-service",
6448
- "content": "# Submission and paper workflow\n\nConstruct `SubmissionService(db, authorizeLearner, authorizeCatalog, {storage,\nstoreOptions?, signAsset?})` from trusted server configuration. It extends\n`LearnerService`: the same active actor/tenant, memberships, linked person,\ndirected guardian relationship and host permission checks apply. The host must\nallow `education.learner.submit`, `education.learner.grade` or\n`education.learner.packet` as appropriate. Grade and packet actions always reject\nthe learner's own identity, even if the host authorizer returns true.\n\nThe protected transaction/auth hooks are internal owning-service extension\npoints. They are not handler APIs. All public service results remain data-only.\nUse the callback's service in `withTransaction`; escaped services are closed.\nSubmission operations share its serial queue, poison-on-failure behavior and\nPostgreSQL enrollment lock. No runtime schema creation occurs.\n\n## Capture and grading\n\n`captureSubmission` takes the learner request, `enrollmentId`, `lessonId`, a\nnonempty stable `requestKey`, and `pages` containing `name`, `mimeType` and Buffer\n`data`. Each upload becomes a fresh Asset owned by the learner's profile and\nexact tenant, joined solely through `SubmissionAsset`. Up to 100 uploads of\n25 MiB each are accepted (JPEG, PNG, WebP, or PDF). It atomically creates the\nsubmission/asset metadata and moves only that enrollment's progress to\n`submitted`, incrementing attempts once. Caller status, score, grader, asset ID,\nand answer-key flags are never patches. Equal request-key/content retries return\nthe existing submission; altered retries fail. Submitted/review/complete/skipped\nprogress rejects new captures. Content includes ordered filenames, MIME types,\nbytes and notes. Hosts should impose tighter request/body limits as needed.\n\n`captureBlankReference` records a workbook-page scan as immutable\n`blank_reference` evidence without advancing completion or creating a grade.\nTrusted ingestion scopes each receipt to the exact `enrollmentId` and calls\n`assertLessonAssignment` before reading retained evidence; it proves the active\ntenant, actor, learner, enrollment, and lesson without creating progress. A\nstable `intakeItemId` is claimed once in the durable tenant/item binding table,\nthen bound to the tenant, learner, lesson, progress, kind and predecessor; an\naltered retry fails. The binding is database-unique across enrollments while\nordinary uploads remain unbound. `resubmitSubmission` requires the current completed-work predecessor and\ncreates a new immutable `completed_work` attempt. Old pages, grades and feedback\nremain history; stale predecessors cannot be graded or replaced. A correction may\nreopen submitted, review or complete progress, while an AI proposal remains a\nnon-authoritative Grade until guardian review.\n\n`reviewSubmission` accepts an existing authorized submission and optional score\nand feedback. `attestLesson` creates/reuses an Attestation for a matching progress\nitem without requiring uploads. Both require a verified guardian and create a\nnew guardian Grade, set submission to `graded` and progress to `complete`.\nOmitted/null score is unscored; zero is a real score. The latest guardian grade\nwins over auto/AI/instructor proposals, and its score replaces progress's score.\nReview history is retained. ExternalCompletion is available for trusted server\npersistence; phase-zero learner handlers cannot forge reported scores. Online\nsubmissions and automatic grading are not implemented.\n\n`getSubmission` returns only submission metadata, page asset IDs and the selected\ngrade. It does not include catalog answers, extracted answers, storage paths,\nmodel objects, or database handles.\n\n## Storage and signed routes\n\nThe service creates an AssetRuntime bound to the operation's transaction. It\nrequires a resolved URI whose filename retains the fresh asset UUID and MIME\nextension; custom resolvers may choose its provider/directory but must route\nthat URI to the corresponding unique file. Shared existing locators are\nrejected before preservation or cleanup registration. It plans and persists\nthat unique asset URI before writing, registering that new\nasset for cleanup before the provider can partially write. After rollback,\nfiles are removed through the runtime store. Before deletion, the root database\nmust confirm that no asset owner committed: uncertain commit/read failures never\ncause committed bytes to be removed. Only this operation's newly created assets\nare candidates; pre-existing files are never adopted into its cleanup ledger.\n\nDatabase metadata is atomic; external storage uses compensation, not a database\ntransaction. If cleanup cannot complete, `SubmissionStorageCleanupError` contains\nfresh asset IDs and the original cause. Operators must reconcile those IDs in\nthe configured `education-page/<id>.<extension>` storage namespace (custom\nresolvers must retain their routing audit). A successful retry uses fresh IDs\nand cannot delete or reuse orphaned bytes. Process termination may also leave\nunreferenced files; hosts need storage orphan reconciliation. Do not suppress\ncleanup failures or claim crash-safe distributed atomicity.\n\n`signSubmissionAsset` verifies the submission/enrollment/learner ownership and\nits noun join, then verifies the Asset's tenant and learner profile before\ncalling the configured trusted signer. Claims contain actor, tenant, learner,\nenrollment, submission, asset and a five-minute expiry; no static source URI is\nreturned. The host signs every claim and its serving endpoint must verify the\nsignature, expiry, current actor/tenant and object authorization before serving.\nA bearer URL must never become a cross-family or sibling access bypass.\n\n`signLessonPrintable` signs only the exact authorized `printable` LessonAsset\njoin for an enrolled lesson. It never accepts an answer-key join and carries the\nsame tenant, actor, learner and enrollment scope as a submission claim.\n\n## Personal worksheet packets\n\n`splitWorksheetPacket` takes the guardian learner request, exact-owned\n`personal-use-copy` `courseId`, PDF bytes and ranges `{lessonId, firstPage,\nlastPage}`. Page bounds are inclusive and one-based. Ranges must be in bounds,\ndisjoint and target distinct Worksheet lessons in that private course. The\n50 MiB / 100-range limits are enforced before PDF parsing. `pdf-lib` extracts\nactual pages into independent PDFs and records private printable LessonAssets.\nAll resulting metadata commits together; storage failure compensates new PDFs.\nThe source packet is not retained. No shared/global course is accepted and no\nanswer-key join is created. A successful repeated packet upload intentionally\nadds another printable version; a failed upload can be retried safely.\n\nShared integration workflows run on SQLite and real PostgreSQL. Full DuckDB\nworkflow support remains outside the existing supported schema boundary.\n\n## Private asset boundary\n\nCaptured pages and split packet pages use `Asset.accessMode: 'private'` from their\ninitial insert. Ordinary generated Asset REST/MCP operations cannot list, hydrate,\nupdate or delete them, including through the parent STI model. The service opens\nan exact tenant/actor/learner-owner/asset-ID capability only after its normal learner\nrelationship checks. A host's signed serving route must repeat authorization and\nopen that exact capability before loading the asset. See the owning\n[private asset contract](../../assets/agents/private-assets.md).\n\nStorage must be private (including provider ACLs). Public HTTP(S) source locators\nare rejected; a hidden URL alone cannot protect publicly accessible bytes. Apply\nthe manifest migration adding `assets.access_mode` before deploying this runtime;\nlegacy ordinary assets retain the public default. Runtime creates no schema.\n\nPrivate lesson files must already be private Assets in the same tenant. Saving\nan answer-key join, or any join under a personal-use-copy course, requires the\nhost's exact actor/owner/asset capability; a public asset is never automatically\npromoted. Packet splitting retains this capability while creating each join.\nPersisted course tenant/license and section/lesson parent identity cannot change;\ncreate new structure and explicitly re-author its joins instead. Mandatory\nwrite predicates enforce this even for natural-key adoption and concurrent writes.\nOrdinary content and metadata refresh remains supported. This\nprevents existing public material being reclassified by moving its parent.\n\nFor pre-existing invalid joins, an authorized host must inventory private-material\njoins and remove invalid associations, recreate the bytes in private storage as a\nnew privately classified Asset, then re-author the join under its exact capability.\nDo not merely hide the URI, mutate access_mode with raw SQL, or delete shared\npublic bytes. This repair requires explicit storage/ownership authorization;\nnormal runtime does not migrate existing records.\n"
6789
+ "content": "# Submission and paper workflow\n\nConstruct `SubmissionService(db, authorizeLearner, authorizeCatalog, {storage,\nstoreOptions?, signAsset?})` from trusted server configuration. It extends\n`LearnerService`: the same active actor/tenant, memberships, linked person,\ndirected guardian relationship and host permission checks apply. The host must\nallow `education.learner.submit`, `education.learner.grade` or\n`education.learner.packet` as appropriate. Grade and packet actions always reject\nthe learner's own identity, even if the host authorizer returns true.\n\nThe protected transaction/auth hooks are internal owning-service extension\npoints. They are not handler APIs. All public service results remain data-only.\nUse the callback's service in `withTransaction`; escaped services are closed.\nSubmission operations share its serial queue, poison-on-failure behavior and\nPostgreSQL enrollment lock. No runtime schema creation occurs.\n\n## Capture and grading\n\n`captureSubmission` takes the learner request, `enrollmentId`, `lessonId`, a\nnonempty stable `requestKey`, and `pages` containing `name`, `mimeType` and Buffer\n`data`. Each upload becomes a fresh Asset owned by the learner's profile and\nexact tenant, joined solely through `SubmissionAsset`. Up to 100 uploads of\n25 MiB each are accepted (JPEG, PNG, WebP, or PDF). It atomically creates the\nsubmission/asset metadata and moves only that enrollment's progress to\n`submitted`, incrementing attempts once. Caller status, score, grader, asset ID,\nand answer-key flags are never patches. Equal request-key/content retries return\nthe existing submission; altered retries fail. Submitted/review/complete/skipped\nprogress rejects new captures. Content includes ordered filenames, MIME types,\nbytes and notes. Hosts should impose tighter request/body limits as needed.\n\n`captureBlankReference` records a workbook-page scan as immutable\n`blank_reference` evidence without advancing completion or creating a grade.\nTrusted ingestion scopes each receipt to the exact `enrollmentId` and calls\n`assertLessonAssignment` before reading retained evidence; it proves the active\ntenant, actor, learner, enrollment, and lesson without creating progress. A\nstable `intakeItemId` is claimed once in the durable tenant/item binding table,\nthen bound to the tenant, learner, lesson, progress, kind and predecessor; an\naltered retry fails. The binding is database-unique across enrollments while\nordinary uploads remain unbound. `resubmitSubmission` requires the current completed-work predecessor and\ncreates a new immutable `completed_work` attempt. Old pages, grades and feedback\nremain history; stale predecessors cannot be graded or replaced. A correction may\nreopen submitted, review or complete progress, while an AI proposal remains a\nnon-authoritative Grade until guardian review.\n\n`reviewSubmission` accepts an existing authorized submission and optional score\nand feedback. `attestLesson` creates/reuses an Attestation for a matching progress\nitem without requiring uploads. Both require a verified guardian and create a\nnew guardian Grade and set submission to `graded`. Ordinary non-workbook progress\nbecomes `complete`. Workbook-backed progress instead uses its explicit parent\ncompletion decision; grading alone leaves it in progress, including through a\nplain SubmissionService instance. Existing explicit completion on the current\nattempt is retained. The owning workbook join determines this policy, never a\ncaller flag.\nOmitted/null score is unscored; zero is a real score. The latest guardian grade\nwins over auto/AI/instructor proposals, and its score replaces progress's score.\nReview history is retained. ExternalCompletion is available for trusted server\npersistence; phase-zero learner handlers cannot forge reported scores. Online\nsubmissions and automatic grading are not implemented.\n\n`getSubmission` returns only submission metadata, page asset IDs and the selected\ngrade. It does not include catalog answers, extracted answers, storage paths,\nmodel objects, or database handles.\n\n## Storage and signed routes\n\nThe service creates an AssetRuntime bound to the operation's transaction. It\nrequires a resolved URI whose filename retains the fresh asset UUID and MIME\nextension; custom resolvers may choose its provider/directory but must route\nthat URI to the corresponding unique file. Shared existing locators are\nrejected before preservation or cleanup registration. It plans and persists\nthat unique asset URI before writing, registering that new\nasset for cleanup before the provider can partially write. After rollback,\nfiles are removed through the runtime store. Before deletion, the root database\nmust confirm that no asset owner committed: uncertain commit/read failures never\ncause committed bytes to be removed. Only this operation's newly created assets\nare candidates; pre-existing files are never adopted into its cleanup ledger.\n\nDatabase metadata is atomic; external storage uses compensation, not a database\ntransaction. If cleanup cannot complete, `SubmissionStorageCleanupError` contains\nfresh asset IDs and the original cause. Operators must reconcile those IDs in\nthe configured `education-page/<id>.<extension>` storage namespace (custom\nresolvers must retain their routing audit). A successful retry uses fresh IDs\nand cannot delete or reuse orphaned bytes. Process termination may also leave\nunreferenced files; hosts need storage orphan reconciliation. Do not suppress\ncleanup failures or claim crash-safe distributed atomicity.\n\n`signSubmissionAsset` verifies the submission/enrollment/learner ownership and\nits noun join, then verifies the Asset's tenant and learner profile before\ncalling the configured trusted signer. Claims contain actor, tenant, learner,\nenrollment, submission, asset and a five-minute expiry; no static source URI is\nreturned. The host signs every claim and its serving endpoint must verify the\nsignature, expiry, current actor/tenant and object authorization before serving.\nA bearer URL must never become a cross-family or sibling access bypass.\n\n`signLessonPrintable` signs only the exact authorized `printable` LessonAsset\njoin for an enrolled lesson. It never accepts an answer-key join and carries the\nsame tenant, actor, learner and enrollment scope as a submission claim.\n\n## Personal worksheet packets\n\n`splitWorksheetPacket` takes the guardian learner request, exact-owned\n`personal-use-copy` `courseId`, PDF bytes and ranges `{lessonId, firstPage,\nlastPage}`. Page bounds are inclusive and one-based. Ranges must be in bounds,\ndisjoint and target distinct Worksheet lessons in that private course. The\n50 MiB / 100-range limits are enforced before PDF parsing. `pdf-lib` extracts\nactual pages into independent PDFs and records private printable LessonAssets.\nAll resulting metadata commits together; storage failure compensates new PDFs.\nThe source packet is not retained. No shared/global course is accepted and no\nanswer-key join is created. A successful repeated packet upload intentionally\nadds another printable version; a failed upload can be retried safely.\n\nShared integration workflows run on SQLite and real PostgreSQL. Full DuckDB\nworkflow support remains outside the existing supported schema boundary.\n\n## Private asset boundary\n\nCaptured pages and split packet pages use `Asset.accessMode: 'private'` from their\ninitial insert. Ordinary generated Asset REST/MCP operations cannot list, hydrate,\nupdate or delete them, including through the parent STI model. The service opens\nan exact tenant/actor/learner-owner/asset-ID capability only after its normal learner\nrelationship checks. A host's signed serving route must repeat authorization and\nopen that exact capability before loading the asset. See the owning\n[private asset contract](../../assets/agents/private-assets.md).\n\nStorage must be private (including provider ACLs). Public HTTP(S) source locators\nare rejected; a hidden URL alone cannot protect publicly accessible bytes. Apply\nthe manifest migration adding `assets.access_mode` before deploying this runtime;\nlegacy ordinary assets retain the public default. Runtime creates no schema.\n\nPrivate lesson files must already be private Assets in the same tenant. Saving\nan answer-key join, or any join under a personal-use-copy course, requires the\nhost's exact actor/owner/asset capability; a public asset is never automatically\npromoted. Packet splitting retains this capability while creating each join.\nPersisted course tenant/license and section/lesson parent identity cannot change;\ncreate new structure and explicitly re-author its joins instead. Mandatory\nwrite predicates enforce this even for natural-key adoption and concurrent writes.\nOrdinary content and metadata refresh remains supported. This\nprevents existing public material being reclassified by moving its parent.\n\nFor pre-existing invalid joins, an authorized host must inventory private-material\njoins and remove invalid associations, recreate the bytes in private storage as a\nnew privately classified Asset, then re-author the join under its exact capability.\nDo not merely hide the URI, mutate access_mode with raw SQL, or delete shared\npublic bytes. This repair requires explicit storage/ownership authorization;\nnormal runtime does not migrate existing records.\n"
6449
6790
  },
6450
6791
  {
6451
6792
  "path": "agents/workbook-service.md",
6452
6793
  "module": "workbook-service",
6453
- "content": "# Physical workbook contract\n\n`WorkbookService` extends `SubmissionService` with the same actor-bound authorization,\nprivate asset runtime, transaction executor and guardian Grade boundary. Both\nLearnerAuthorizer and CatalogAuthorizer receive the active executor. Host session\nheartbeats and current permission checks must use that supplied executor, including\nnested guardian review; a captured outer pool can wait on its own transaction.\nCatalogService obtains the executor through the public collection.db getter and\nfails closed when its four catalog collections do not share the same handle. It is an\nadditive API; existing Submission/Grade consumers retain their contracts. The six\nclosed `Workbook*` models are package-manifest models. Apply normal application\nschema migrations before using them; the service never creates runtime schema.\n\nA guardian calls `resolveWorkbookActivity` with an explicit learner, workbook key,\ntitle, edition (empty means unknown), page key and printed page label. Keys identify\nthe physical copy/edition and page occurrence, including section/side where needed.\nNames and photo hashes do not establish identity. A conflicting reuse fails. One\nprivate personal-use-copy course/enrollment per learner/workbook supplies internal\nsubmission scaffolding. The public page API needs no course-building workflow.\n\n`captureWorkbookWork` owns original files through SubmissionAsset and creates an\nimmutable attempt with source SHA-256 hashes, actual page bounds, actor, capture\ntime, assistance, correction predecessor/episode, policy version and effective\nreview-required flag. Assistance defaults to `unknown`. Capture retries reuse the\nsame Submission/attempt and reject changed bytes or help claims. A correction\nrequires the same learner/activity/current predecessor and stays in the same\npractice episode. Analysis/reanalysis adds revisions, never practice attempts.\nExisting LessonProgress.attempts remains a compatibility capture counter.\n\nThe workbook review-required policy defaults on and is shared by its pages.\n`setWorkbookFeedbackPolicy` uses expected policy version; existing capture snapshots\nremain unchanged. Policy changes cannot erase prior disclosure. Guarded capture,\npolicy and decision writes serialize on the learner within the active transaction.\nIngestion target resolution acquires that learner lock before the enrollment lock,\nmatching capture/review order. Guardian apply follows authorized timeline cursors\nwhen locating a preserved assessment on an older attempt.\nComposite mutations also use the education transaction's serialized composite\nqueue, allowing inherited queued capture/review writes without queue recursion.\nAny swallowed mutation error still aborts the complete transaction and file rollback\nuses the inherited fresh-file compensation ledger.\n\n## Structured assessment and real ingestion\n\n`WorkbookAssessment` version 1 has stable question IDs, original/corrected prompt\nand answer text, explicit result/reason, nullable points, feedback, retained source\nanchors and curated concept mappings. An anchor names an owned asset plus SHA-256,\none-based verified page (or null for a whole-file PDF source) and normalized\nx/y/width/height rectangle. Ingestion proposals never generate rectangles: the\nservice uses [0,0,1,1] and explicit source/page grounding with limitations. The provider wire contract\ncontains evidenceId only, with no page/rect selector. An image is a known single\npage; PDF questions retain a whole original-file anchor with page:null, not a\nclaim about page 1. Extraction metadata remains provenance, never a model page\nlocalization grant. Fictitious PDF\npages and foreign files fail. Missing, illegible, unsupported and absent questions\nare unassessed; unassessed points stay null. Marking certainty, mapping certainty,\nmapping review provenance and evidence limitations are distinct; absent certainty\nis explicitly persisted as unknown. Reviewed overrides append new bodies and keep\noriginal AI text inspectable.\n\nThe configurable vocabulary is trusted server configuration, not AI-created labels.\nThe default `WORKBOOK_MATH_VOCABULARY` has addition, subtraction and place value.\nOther subjects may use an explicit curated `workbookVocabulary` on WorkbookService,\nthe ingestion handler factory and preservation helper. Unknown concept IDs fail;\nzero mapped concepts is valid. AI mappings are proposed, guardian mappings reviewed.\nKnown question IDs are pinned across captures; exact prompts are pinned within a\ncapture's reanalysis only. Correction transcriptions come from the new capture,\nso earlier answer-bearing prompts cannot override corrected work. Immutable\nhistorical prompts remain unchanged. The public `getWorkbookQuestionIdentity`\nreturns IDs only: historical prompt text can itself include a learner response.\nHandlers use printed number/occurrence/subpart when present; unnumbered tasks use\nanswer-free printed-task slugs with visible-task occurrence/subpart, never output\narray position or learner answers. They\nread prompt, answer, feedback and marks exclusively from current evidence. New\nprompts should contain printed task text without the learner response. An absent\nor ambiguously matched known question is unassessed/not_present with an explicit\nlimitation. Missing, duplicate, invented or foreign IDs reject the proposal; a\nchanged question order does not change identity. The ID check does not establish\nsemantic equivalence of printed tasks; uncertain correspondence requires human\nreview rather than an invented match.\nA different page/question requires explicit new activity identity, never AI retargeting.\n\n`captureEducationIntake` accepts optional `workbookActivityId`/`assistance` when its\nservice is WorkbookService. It preserves the existing return SubmissionView and\nbinds the workbook attempt/policy before the host queues processing.\n`educationIntakeHandlers({workbookActivityId,...})` advertises a bounded JSON question\nschema to real ingestion generation. Assessed results require reason none;\nunassessed reasons require null points. Per-question outcomes must agree with\npoints. Correctness is independent of scoring: readable objectively solvable\narithmetic may be assessed without a printed rubric. When no point allocation is\nevidenced, both `points` and `possiblePoints` remain null for every result,\nincluding incorrect; never invent weights or mix null/numeric values. The\naggregate still counts correct/incorrect results while its score remains null.\nIncorrect earns zero only when the possible-point allocation is known.\nModel aggregate scores/narratives are not requested or trusted: exact\ncount summaries and points scores are computed from validated questions without\nrounding or invented partial credit. Default legacy handlers remain compatible.\n`preserveWorkbookIntakeAnalysis` reads the public immutable completed generation,\nchecks its exact submission and offered workbook schema, maps cited original\ningestion evidence by hash/order to retained education-owned files and imports one\nvalidated proposal. Provider/model/version, configuration/rubric, source analysis\npin and proposal digest are durable provenance. Pending/failed processing produces\nno assessment. Malformed/ambiguous/foreign-source proposals return typed needs_review with no\neducation revision or Grade. The host can offer retry/review without a route 500.\nAuthorization, durable-source integrity and database failures still throw. Repeated\ntransfer is idempotent by analysis attempt. Preservation must complete before host\nscratch retention cleanup; there is no automatic retention bypass in this API.\nThe ingestion handler can apply only an exact previously preserved proposal through\nguardian review; it never grants AI grade authority. No prose parsing is used. The generation configuration carries bounded source\nhash/page-support provenance from the completed extraction before it ceases to be\nthe current analysis. This uses public ingestion APIs and is pinned by its\nconfiguration digest; question anchors always use honest source-only grounding.\n\n## Authority, visibility and history\n\n`recordWorkbookAnalysis` accepts authorized submit context and writes provisional\nAI revisions only. `reviewWorkbookAssessment` requires guardian authority, exact\nsource revision, expected current guardian head and stable request key. It appends\nan immutable guardian revision recording sourceRevisionId, superseded head, actor,\ntime and decision reason. Changed retries conflict. Review of the current capture\nuses the existing guardian Grade/progress boundary. Historical annotation changes\nits evidence authority only and cannot grade or reopen its successor. Reanalysis\nafter a guardian decision remains provisional. A guardian can invalidate an outcome\nwith an unassessed replacement and a stated reason; old AI does not regain authority.\n\n`releaseWorkbookFeedback` separately releases an exact revision using expected\ncurrent guardian head. New stale releases fail; accepted retry requests replay their\noriginal result after later decisions. Release creates no Grade. With the gate off,\nAI feedback is visible as provisional while authoritative grading still requires a\nguardian. Gate-on learners receive no unreleased assessment payloads, scores or\nhints. WorkbookService's ordinary getSubmission also redacts Grade for own learners;\nhosts must route workbook reads through this service and prevent legacy routes from\nserving workbook grades. Guardian history is complete; learners see only their own\nwork and visible revisions. Answer keys/source storage URIs never enter these DTOs.\n\n`acknowledgeWorkbookFeedbackDelivery` is an authenticated own-learner acknowledgment\nfor an exact currently visible feedback revision. It deduplicates per actor/revision\nand rejects withheld, foreign, sibling and guardian acknowledgments. Immutable\ndelivery events record actor/time and mean rendered/delivered, never read or understood.\n\n`getWorkbookTimeline` uses education rows/assets without reading ingestion. It lists\nnewest captures/revisions first, includes all releases/delivery events and provides\ncursor pagination (1–100, default 25). A guardian revisionId selector locates its\ncontaining capture for evidence links. Guardian source IDs/actors/reasons remain\ninspectable. Released historical revisions remain visible after supersession.\n\n`getConceptEvidenceSummary` is guardian-only for this milestone. It recomputes from\ncurrent guardian revisions, separates provisional observations, and reports independent,\nassisted and unknown evidence. Correction captures remain one episode; they cannot\nbe extra independent demonstrations. Every observation cites capture, question,\nrevision, episode, result, assistance and time. Unassessed work is excluded from\nerror/correct counts. Descriptive cues cite actual evidence: needs practice, improving\non this item, needs a fresh independent check, not enough evidence. No mastery\npercentage, generic score comparison, hidden threshold or predictive label is used.\n\n## Preserved processing status and retention\n\n`getWorkbookPreservationState` authorizes exact actor/learner/activity/submission\nand returns only the intake ID plus retained assessment/analysis IDs and the\ninterpretation revision. It contains no questions, scores, feedback or hidden\nassessment payloads. New AI provenance records `analysisRevision` from the\ncompleted interpretation snapshot, separately from its extraction-source pin.\n\n`preserveWorkbookIntakeAnalysis` checks this education-owned state and the public\ncontent-free ingestion item revision before requesting raw completed analysis.\nAn exact already-preserved revision returns `retained` immediately. Guardian\nreview, release, corrections and receipt expiry therefore do not force a stale\nproposal reread or prevent durable education history from loading. The public\ningestion stale-snapshot guard remains unchanged and still rejects the old raw\nsnapshot after guardian grading. A genuinely new valid analysis can append a new\nprovisional revision; a novel stale analysis returns `needs_review/stale_analysis`\nand creates no mark. Legacy retained rows without interpretation revision remain\nreadable when scratch is stale/unavailable or the public\nProposalsNotConfiguredError reports removed provider read configuration. A novel\nanalysis with no provider read configuration returns unavailable without import; an authorized current snapshot whose\nattempt ID is already retained is also skipped before retry reconstruction.\n\nOnly the exact public scratch `Intake unavailable` response is translated to\nretained history, or `unavailable` for a capture with no retained assessment,\nafter independent education authorization. Missing/pending current analysis may\nreturn pending or retained. Unexpected configuration, integrity and database\nfailures still throw. This does not widen scratch access: no stale or unavailable\nraw proposal is read, imported or used as grade authority. Hosts must also render\nthe authorized education timeline independently of optional scratch processing.\n\n## Compatibility API privacy boundary\n\nWorkbook feedback is projected only from the revision/release workflow. The base\n`SubmissionService` recognizes authoritative `WorkbookActivity` lesson ownership,\nincluding calls through a plain service instance. Own-learner compatibility views\nwithhold Grade and grade-derived status; learner progress projections withhold\nbestScore/completedAt and do not let completion alter next-lesson selection.\nGuardian and ordinary non-workbook projections retain their existing behavior.\nThis applies in both feedback policy modes; direct feedback remains provisional\nand does not authorize compatibility Grade disclosure.\n\nGeneric capture/resubmission rejects workbook enrollment or lesson scaffolding,\nincluding idempotent retries. Only the protected exact-activity owning workflow\ncan capture workbook work. Hosts must call public\n`assertNonWorkbookSubmissionScope` before legacy intake receipt creation and when\nreading an old legacy binding, using authoritative enrollment, lesson and\npredecessor selectors. `WorkbookWorkflowRequiredError` is a controlled denial.\nThe browser's optional workbook flag is never authority. Authenticated original\nasset delivery remains a separate read authorization path.\n\n## Validation and limits\n\n`src/__tests__/workbook-workflows.ts` is the shared SQLite/PostgreSQL contract suite;\n`workbook-service.test.ts` runs SQLite, and `workbook.optional.test.ts` runs against\nan available isolated PostgreSQL database. Workers never provision service stacks.\nRun package test/typecheck/build; the integration owner runs test:postgres and the\nintegrated review. Synthetic provider DTO tests establish transfer integrity, not\nreal handwriting accuracy. Actual provider/sample and app restart/retention\nacceptance remain separate integration evidence. Family deletion/export/backup\npolicy and automatic lesson generation are not implemented by this milestone.\n\n## Identity-independent work\n\n[Photo intake](photo-intake.md#marking-before-filing) may own an explicit unfiled\nwork unit. Its nullable workbook FK is constrained to an exact retained receipt;\nprivate enrollment/worksheet routing is technical rather than bibliographic.\nThe same attempt/assessment/review/release and concept-evidence contracts apply.\n`filing` is optional on legacy-shaped DTOs and explicit on current projections.\nAppend-only filing changes display association only; it never moves immutable\nlearning evidence or changes captured review policy. Ordinary identified workbook\nidentity and generated-surface restrictions remain unchanged.\n\n### Unnumbered visible questions\n\nA printed question number is not required for marking. On the first valid\nassessment, an unnumbered task may use `u:<short printed-task slug>:<occurrence/subpart>`\nfrom current answer-free task text and unambiguous visible task identity. The\noccurrence distinguishes tasks in the source, never positions in a generated\nquestions array. Responses, computed solutions and changing evidence UUIDs cannot\nsupply identity. Printed IDs remain useful where present; both forms share the\nexisting nonempty/unique ID schema and 200-character bound.\n\nOnce preserved, the exact ID set remains pinned on reanalysis and correction.\nReordering output does not renumber tasks. A cropped, duplicate or illegible task\nwhose correspondence cannot be established remains `unassessed/not_present` with\na limitation on its known ID; it cannot be silently relabeled as another task.\nHistorical IDs are correspondence hints, not evidence of current responses.\nMissing IDs, collisions, changed ID sets and same-capture prompt remapping still\nfail closed. The framework never inserts omitted provider IDs or parses prose to\nrepair malformed proposals.\n\n### Semantic identity visibility\n\nQuestion IDs can contain printed task text. Own-learner identity reads and\noperation discovery therefore require release of the **selected exact revision**\non a guardian-origin source attempt. An unrelated revision or another attempt's\nrelease is insufficient. Ordinary own-origin IDs remain available for legitimate\ncurrent-work marking and corrections without exposing withheld grades/feedback.\n\nCorrection ancestry is checked completely under the same tenant, learner,\nactivity and practice episode. Each guardian-origin ancestor must have an exact\nreleased revision publishing the compatible immutable ID set before a descendant\ncan expose those semantic IDs. Matching published ID sets preserve already public\nhistorical snapshots after a later private head, without aliases or rewritten IDs.\nMissing, corrupt or cyclic ancestry fails closed; there is no depth cutoff that\nsilently skips earlier source guards. Current descendant release never grants\nrights to a hidden ancestor.\n\nThe same boundary governs child correction capture, timeline payloads and delivery\nacknowledgments, including legacy/trusted-produced descendants. Raw provisional\nrevision writes/returns cannot probe or replay guardian-origin private content;\nauthorized guardians retain processing/history access, and own-origin correction\nmarking remains supported once ancestral source identity is published. Schema,\nID collision/remapping rules and immutable stored history remain unchanged.\n\nThe offered marking schema mirrors the assessment validator: assessed results use\n`markingCertainty` clear/unknown; `uncertain` marking requires `unassessed`, an\nallowed non-none reason and both point fields null. This is not a conversion of\nprovider output: contradictory results remain refused. Mapping certainty remains\nindependent. Visible but overwritten/ambiguous responses can stay unassessed while\nother clearly visible questions are assessed in the same valid proposal.\n\n### Observed assessment layout\n\nOptional `WorkbookAssessment.layout` version 1 describes observed sections in source\norder and zero-based row/column question placements. It is advisory, with fixed\n`provenance: 'observed'`; it never establishes identity or verified geometry.\nSections reference a retained source asset and a physical one-based observed page,\nor null when unknown. A whole-file PDF anchor may support an observed page within\nits retained page count while keeping its original anchor page null. Every placed\nquestion must anchor the section's exact source. Multiple question IDs may share a\ncell. When every placement in a cell has an explicit `part`, sort by part then\noriginal question array index for ties. If any placement in that cell omits\n`part`, the whole cell follows original question array order. Partial placement is valid. Missing `readingOrder` preserves original\nquestion order for a flattened display.\n\nThe browser-safe `normalizeWorkbookObservedLayout` copies only bounded known\nfields: at most 32 sections and 200 placements, 1–8 columns, rows 0–199, parts\n0–31, section IDs up to 64 characters and labels up to 200. Duplicate section IDs\nor question placements, invalid source/page/placement references and malformed\nmetadata discard the complete optional layout and retain `layoutOmission: 'invalid'`.\nValid answers and normal assessment validation remain independent. Owning analysis\nand guardian writes both normalize against the current capture's source hashes;\na correction cannot inherit a previous capture's source layout. Default guardian\ncopies retain valid layout and safe omission markers. Bodies remain immutable JSON\ninside existing revision privacy and exact release boundaries; no migration or\nadditional learner visibility is introduced.\n\nFocused shared SQLite/PostgreSQL cases live in\n`src/__tests__/workbook-layout-workflows.ts`, with SQLite in\n`workbook-layout-service.test.ts` and isolated PostgreSQL in\n`workbook-layout.optional.test.ts`. Pure bounds/reference cases are in\n`workbook-layout.test.ts`. Integration runs the PostgreSQL wrapper under its\nexisting isolated database lifecycle.\n\nGeneration carries optional `layout` as advisory JSON so malformed presentation\ncannot make valid question answers fail the generic schema. Its described wire\nshape uses original evidence IDs; the bridge maps those IDs to retained assets\nand then calls the strict owner normalizer. Within existing transport/JSON resource\nlimits, wrong types, unknown versions and conflicting metadata are omitted with\nonly the safe marker, while answers retain their normal strict validation. Layout\nnever establishes a question ID, a source authorization or a verified region.\n\n\n### Same-capture reread context\n\n`getWorkbookQuestionContext` returns authorized canonical `{id,prompt}` only\nfrom the exact current attempt's identity revision. Generation preserves those\nstrings verbatim for the existing strict same-capture prompt invariant, while\nrereading learner responses and any observed layout from current evidence. This\ncontext is not an answer key or authority for answers. A correction without its\nown revision receives predecessor IDs only; predecessor prompts are never copied.\nExact source/revision and full correction-ancestry visibility apply to both paths,\nincluding own-learner reads. No historical rows are rewritten and absent layout\nremains valid. `getWorkbookQuestionIdentity` remains the IDs-only public API.\n\nCanonical prompts follow ordinary assessment payload visibility, including\ncaptured review policy and exact guardian revision release even for learner-owned\nsources; source/ancestry authorization alone does not publish guardian edits.\nFor ingestion callbacks only, the engine supplies current `analysisRevision` from\nits owned item row. Context selection ignores AI materialized by that exact\ncurrent-attempt revision, so preserving a proposal does not invalidate its own\ncompleted snapshot or preview. It never ignores guardian decisions, predecessor\nrevisions or another interpretation. Direct reads omit this engine pin and retain\nnormal current revision visibility; browser/upload fields cannot supply the pin.\n\n### Observed answer-region overlays\n\nOptional `answerRegions` version1/provenance observed/coordinateSpace\n`display-oriented` carries one approximate rect `[x,y,width,height]` per question,\nwith exact retained asset/page1 and observationId. Rects are normalized to the\nwhole EXIF-oriented image; they never change question IDs, grading, anchors or\nlayout, and never claim verified geometry. Images only in this version; PDFs or\nunknown positions omit regions. Direct malformed metadata is omitted with safe\n`answerRegionsOmission: invalid`; marking/layout survive independently.\n\nVision catalogs originate in the existing opt-in extraction call. Generation\nreferences exact evidence/observation IDs, never supplies coordinates. The bridge\ncopies catalog rectangles from the extraction-pinned configuration with matching\nsource hash. Guardian copies retain them; correction sources must match current\nanchors. Existing exact release and ancestry privacy apply to the complete body.\n"
6794
+ "content": "# Physical workbook contract\n\n`WorkbookService` extends `SubmissionService` with the same actor-bound authorization,\nprivate asset runtime, transaction executor and guardian Grade boundary. Both\nLearnerAuthorizer and CatalogAuthorizer receive the active executor. Host session\nheartbeats and current permission checks must use that supplied executor, including\nnested guardian review; a captured outer pool can wait on its own transaction.\nCatalogService obtains the executor through the public collection.db getter and\nfails closed when its four catalog collections do not share the same handle. It is an\nadditive API; existing Submission/Grade consumers retain their contracts. The six\nclosed `Workbook*` models are package-manifest models. Apply normal application\nschema migrations before using them; the service never creates runtime schema.\n\nA guardian calls `resolveWorkbookActivity` with an explicit learner, workbook key,\ntitle, edition (empty means unknown), page key and printed page label. Keys identify\nthe physical copy/edition and page occurrence, including section/side where needed.\nNames and photo hashes do not establish identity. A conflicting reuse fails. One\nprivate personal-use-copy course/enrollment per learner/workbook supplies internal\nsubmission scaffolding. The public page API needs no course-building workflow.\n\n`captureWorkbookWork` owns original files through SubmissionAsset and creates an\nimmutable attempt with source SHA-256 hashes, actual page bounds, actor, capture\ntime, assistance, correction predecessor/episode, policy version and effective\nreview-required flag. Assistance defaults to `unknown`. Capture retries reuse the\nsame Submission/attempt and reject changed bytes or help claims. A correction\nrequires the same learner/activity/current predecessor and stays in the same\npractice episode. Analysis/reanalysis adds revisions, never practice attempts.\nExisting LessonProgress.attempts remains a compatibility capture counter.\n\nThe workbook review-required policy defaults on and is shared by its pages.\n`setWorkbookFeedbackPolicy` uses expected policy version; existing capture snapshots\nremain unchanged. Policy changes cannot erase prior disclosure. Guarded capture,\npolicy and decision writes serialize on the learner within the active transaction.\nIngestion target resolution acquires that learner lock before the enrollment lock,\nmatching capture/review order. Guardian apply follows authorized timeline cursors\nwhen locating a preserved assessment on an older attempt.\nComposite mutations also use the education transaction's serialized composite\nqueue, allowing inherited queued capture/review writes without queue recursion.\nAny swallowed mutation error still aborts the complete transaction and file rollback\nuses the inherited fresh-file compensation ledger.\n\n## Structured assessment and real ingestion\n\n`WorkbookAssessment` version 1 has stable question IDs, original/corrected prompt\nand answer text, explicit result/reason, nullable points, feedback, retained source\nanchors and curated concept mappings. An anchor names an owned asset plus SHA-256,\none-based verified page (or null for a whole-file PDF source) and normalized\nx/y/width/height rectangle. Ingestion proposals never generate rectangles: the\nservice uses [0,0,1,1] and explicit source/page grounding with limitations. The provider wire contract\ncontains evidenceId only, with no page/rect selector. An image is a known single\npage; PDF questions retain a whole original-file anchor with page:null, not a\nclaim about page 1. Extraction metadata remains provenance, never a model page\nlocalization grant. Fictitious PDF\npages and foreign files fail. Missing, illegible, unsupported and absent questions\nare unassessed; unassessed points stay null. Marking certainty, mapping certainty,\nmapping review provenance and evidence limitations are distinct; absent certainty\nis explicitly persisted as unknown. Reviewed overrides append new bodies and keep\noriginal AI text inspectable.\n\nThe configurable vocabulary is trusted server configuration, not AI-created labels.\nThe default `WORKBOOK_MATH_VOCABULARY` has addition, subtraction and place value.\nOther subjects may use an explicit curated `workbookVocabulary` on WorkbookService,\nthe ingestion handler factory and preservation helper. Unknown concept IDs fail;\nzero mapped concepts is valid. AI mappings are proposed, guardian mappings reviewed.\nKnown question IDs are pinned across captures; exact prompts are pinned within a\ncapture's reanalysis only. Correction transcriptions come from the new capture,\nso earlier answer-bearing prompts cannot override corrected work. Immutable\nhistorical prompts remain unchanged. The public `getWorkbookQuestionIdentity`\nreturns IDs only: historical prompt text can itself include a learner response.\nHandlers use printed number/occurrence/subpart when present; unnumbered tasks use\nanswer-free printed-task slugs with visible-task occurrence/subpart, never output\narray position or learner answers. They\nread prompt, answer, feedback and marks exclusively from current evidence. New\nprompts should contain printed task text without the learner response. An absent\nor ambiguously matched known question is unassessed/not_present with an explicit\nlimitation. Missing, duplicate, invented or foreign IDs reject the proposal; a\nchanged question order does not change identity. The ID check does not establish\nsemantic equivalence of printed tasks; uncertain correspondence requires human\nreview rather than an invented match.\nA different page/question requires explicit new activity identity, never AI retargeting.\n\n`captureEducationIntake` accepts optional `workbookActivityId`/`assistance` when its\nservice is WorkbookService. It preserves the existing return SubmissionView and\nbinds the workbook attempt/policy before the host queues processing.\n`educationIntakeHandlers({workbookActivityId,...})` advertises a bounded JSON question\nschema to real ingestion generation. Assessed results require reason none;\nunassessed reasons require null points. Per-question outcomes must agree with\npoints. Correctness is independent of scoring: readable objectively solvable\narithmetic may be assessed without a printed rubric. When no point allocation is\nevidenced, both `points` and `possiblePoints` remain null for every result,\nincluding incorrect; never invent weights or mix null/numeric values. The\naggregate still counts correct/incorrect results while its score remains null.\nIncorrect earns zero only when the possible-point allocation is known.\nModel aggregate scores/narratives are not requested or trusted: exact\ncount summaries and points scores are computed from validated questions without\nrounding or invented partial credit. Default legacy handlers remain compatible.\n`preserveWorkbookIntakeAnalysis` reads the public immutable completed generation,\nchecks its exact submission and offered workbook schema, maps cited original\ningestion evidence by hash/order to retained education-owned files and imports one\nvalidated proposal. Provider/model/version, configuration/rubric, source analysis\npin and proposal digest are durable provenance. Pending/failed processing produces\nno assessment. Malformed/ambiguous/foreign-source proposals return typed needs_review with no\neducation revision or Grade. The host can offer retry/review without a route 500.\nAuthorization, durable-source integrity and database failures still throw. Repeated\ntransfer is idempotent by analysis attempt. Preservation must complete before host\nscratch retention cleanup; there is no automatic retention bypass in this API.\nThe ingestion handler can apply only an exact previously preserved proposal through\nguardian review; it never grants AI grade authority. No prose parsing is used. The generation configuration carries bounded source\nhash/page-support provenance from the completed extraction before it ceases to be\nthe current analysis. This uses public ingestion APIs and is pinned by its\nconfiguration digest; question anchors always use honest source-only grounding.\n\n## Authority, visibility and history\n\n`recordWorkbookAnalysis` accepts authorized submit context and writes provisional\nAI revisions only. `reviewWorkbookAssessment` requires guardian authority, exact\nsource revision, expected current guardian head and stable request key. It appends\nan immutable guardian revision recording sourceRevisionId, superseded head, actor,\ntime and decision reason. Changed retries conflict. Review of the current capture\nuses the existing guardian Grade/progress boundary. Historical annotation changes\nits evidence authority only and cannot grade or reopen its successor. Reanalysis\nafter a guardian decision remains provisional. A guardian can invalidate an outcome\nwith an unassessed replacement and a stated reason; old AI does not regain authority.\n\n`releaseWorkbookFeedback` separately releases an exact revision using expected\ncurrent guardian head. New stale releases fail; accepted retry requests replay their\noriginal result after later decisions. Release creates no Grade. With the gate off,\nAI feedback is visible as provisional while authoritative grading still requires a\nguardian. Gate-on learners receive no unreleased assessment payloads, scores or\nhints. WorkbookService's ordinary getSubmission also redacts Grade for own learners;\nhosts must route workbook reads through this service and prevent legacy routes from\nserving workbook grades. Guardian history is complete; learners see only their own\nwork and visible revisions. Answer keys/source storage URIs never enter these DTOs.\n\n`acknowledgeWorkbookFeedbackDelivery` is an authenticated own-learner acknowledgment\nfor an exact currently visible feedback revision. It deduplicates per actor/revision\nand rejects withheld, foreign, sibling and guardian acknowledgments. Immutable\ndelivery events record actor/time and mean rendered/delivered, never read or understood.\n\n`getWorkbookTimeline` uses education rows/assets without reading ingestion. It lists\nnewest captures/revisions first, includes all releases/delivery events and provides\ncursor pagination (1–100, default 25). A guardian revisionId selector locates its\ncontaining capture for evidence links. Guardian source IDs/actors/reasons remain\ninspectable. Released historical revisions remain visible after supersession.\n\n`getConceptEvidenceSummary` is guardian-only for this milestone. It recomputes from\ncurrent guardian revisions, separates provisional observations, and reports independent,\nassisted and unknown evidence. Correction captures remain one episode; they cannot\nbe extra independent demonstrations. Every observation cites capture, question,\nrevision, episode, result, assistance and time. Unassessed work is excluded from\nerror/correct counts. Descriptive cues cite actual evidence: needs practice, improving\non this item, needs a fresh independent check, not enough evidence. No mastery\npercentage, generic score comparison, hidden threshold or predictive label is used.\n\n## Preserved processing status and retention\n\n`getWorkbookPreservationState` authorizes exact actor/learner/activity/submission\nand returns only the intake ID plus retained assessment/analysis IDs and the\ninterpretation revision. It contains no questions, scores, feedback or hidden\nassessment payloads. New AI provenance records `analysisRevision` from the\ncompleted interpretation snapshot, separately from its extraction-source pin.\n\n`preserveWorkbookIntakeAnalysis` checks this education-owned state and the public\ncontent-free ingestion item revision before requesting raw completed analysis.\nAn exact already-preserved revision returns `retained` immediately. Guardian\nreview, release, corrections and receipt expiry therefore do not force a stale\nproposal reread or prevent durable education history from loading. The public\ningestion stale-snapshot guard remains unchanged and still rejects the old raw\nsnapshot after guardian grading. A genuinely new valid analysis can append a new\nprovisional revision; a novel stale analysis returns `needs_review/stale_analysis`\nand creates no mark. Legacy retained rows without interpretation revision remain\nreadable when scratch is stale/unavailable or the public\nProposalsNotConfiguredError reports removed provider read configuration. A novel\nanalysis with no provider read configuration returns unavailable without import; an authorized current snapshot whose\nattempt ID is already retained is also skipped before retry reconstruction.\n\nOnly the exact public scratch `Intake unavailable` response is translated to\nretained history, or `unavailable` for a capture with no retained assessment,\nafter independent education authorization. Missing/pending current analysis may\nreturn pending or retained. Unexpected configuration, integrity and database\nfailures still throw. This does not widen scratch access: no stale or unavailable\nraw proposal is read, imported or used as grade authority. Hosts must also render\nthe authorized education timeline independently of optional scratch processing.\n\n## Compatibility API privacy boundary\n\nWorkbook feedback is projected only from the revision/release workflow. The base\n`SubmissionService` recognizes authoritative `WorkbookActivity` lesson ownership,\nincluding calls through a plain service instance. Own-learner compatibility views\nwithhold Grade and bestScore. Every workbook progress projection uses the explicit\nparent decision for status and completedAt; historical grade-derived completion\nalone is Doing with no completion date. Ordinary non-workbook projections retain\ntheir existing behavior.\nThis applies in both feedback policy modes; direct feedback remains provisional\nand does not authorize compatibility Grade disclosure.\n\n## Explicit parent completion\n\n`completeWorkbookActivity` requires the current verified guardian, exact activity,\nsubmissionId, expectedAttemptId, explicit expectedHeadId (including null), and a\nbounded requestKey. The SDK independently denies own learners for\n`education.learner.complete`, even when a host callback grants it. The head token\nis the latest guardian revision, otherwise latest AI revision, otherwise null;\nnull permits a parent's judgment without any assessment. The decision and progress\nupdate share the authorized transaction and learner serialization lock. No grade,\nassessment, feedback release, or learner visibility is created by completion.\nGeneric guardian grading of workbook submissions also cannot complete work.\n\n`WorkbookCompletionDecision` retains immutable server actor/time, attempt,\nsubmission, head provenance, and retry key. Exact retries return that historical\ndecision even after a correction; changed retries and stale selectors reject with\n`WorkbookCompletionConflictError` (changed_retry, stale_attempt, stale_head, or\nalready_complete). Hosts should acknowledge recording and reread current state,\nnever interpret a historical retry as completing a newer attempt.\n\nCompletion persists across later assessment or guardian review on the same\nattempt. A newly captured correction or independent practice attempt makes the\nprevious decision historical and returns the current work to Doing. Corrections\nretain their episode and must name the current predecessor; fresh practice keeps\nits existing separate episode path. No legacy Grade is converted into a decision.\n\nActivity lists and timelines expose own-safe `completion` (status and nullable\ncompletedAt), currentAttemptId, and latestSubmissionId. These current pointers\nare independent of history pagination. Timeline completionContext is guardian-only\nand carries the selectors to display and later submit verbatim; own learners\nreceive null. Per-attempt completionDecision provenance is also guardian-only.\nThe existing attempt headId continues to mean guardian review head.\n\nDeploy the ordinary additive manifest migration before recording decisions. Reads\nof an unmigrated decision table report Doing; runtime handlers never create it.\n\nGeneric capture/resubmission rejects workbook enrollment or lesson scaffolding,\nincluding idempotent retries. Only the protected exact-activity owning workflow\ncan capture workbook work. Hosts must call public\n`assertNonWorkbookSubmissionScope` before legacy intake receipt creation and when\nreading an old legacy binding, using authoritative enrollment, lesson and\npredecessor selectors. `WorkbookWorkflowRequiredError` is a controlled denial.\nThe browser's optional workbook flag is never authority. Authenticated original\nasset delivery remains a separate read authorization path.\n\n## Validation and limits\n\n`src/__tests__/workbook-workflows.ts` is the shared SQLite/PostgreSQL contract suite;\n`workbook-service.test.ts` runs SQLite, and `workbook.optional.test.ts` runs against\nan available isolated PostgreSQL database. Workers never provision service stacks.\nRun package test/typecheck/build; the integration owner runs test:postgres and the\nintegrated review. Synthetic provider DTO tests establish transfer integrity, not\nreal handwriting accuracy. Actual provider/sample and app restart/retention\nacceptance remain separate integration evidence. Family deletion/export/backup\npolicy and automatic lesson generation are not implemented by this milestone.\n\n## Identity-independent work\n\n[Photo intake](photo-intake.md#marking-before-filing) may own an explicit unfiled\nwork unit. Its nullable workbook FK is constrained to an exact retained receipt;\nprivate enrollment/worksheet routing is technical rather than bibliographic.\nThe same attempt/assessment/review/release and concept-evidence contracts apply.\n`filing` is optional on legacy-shaped DTOs and explicit on current projections.\nAppend-only filing changes display association only; it never moves immutable\nlearning evidence or changes captured review policy. Ordinary identified workbook\nidentity and generated-surface restrictions remain unchanged.\n\n### Unnumbered visible questions\n\nA printed question number is not required for marking. On the first valid\nassessment, an unnumbered task may use `u:<short printed-task slug>:<occurrence/subpart>`\nfrom current answer-free task text and unambiguous visible task identity. The\noccurrence distinguishes tasks in the source, never positions in a generated\nquestions array. Responses, computed solutions and changing evidence UUIDs cannot\nsupply identity. Printed IDs remain useful where present; both forms share the\nexisting nonempty/unique ID schema and 200-character bound.\n\nOnce preserved, the exact ID set remains pinned on reanalysis and correction.\nReordering output does not renumber tasks. A cropped, duplicate or illegible task\nwhose correspondence cannot be established remains `unassessed/not_present` with\na limitation on its known ID; it cannot be silently relabeled as another task.\nHistorical IDs are correspondence hints, not evidence of current responses.\nMissing IDs, collisions, changed ID sets and same-capture prompt remapping still\nfail closed. The framework never inserts omitted provider IDs or parses prose to\nrepair malformed proposals.\n\n### Semantic identity visibility\n\nQuestion IDs can contain printed task text. Own-learner identity reads and\noperation discovery therefore require release of the **selected exact revision**\non a guardian-origin source attempt. An unrelated revision or another attempt's\nrelease is insufficient. Ordinary own-origin IDs remain available for legitimate\ncurrent-work marking and corrections without exposing withheld grades/feedback.\n\nCorrection ancestry is checked completely under the same tenant, learner,\nactivity and practice episode. Each guardian-origin ancestor must have an exact\nreleased revision publishing the compatible immutable ID set before a descendant\ncan expose those semantic IDs. Matching published ID sets preserve already public\nhistorical snapshots after a later private head, without aliases or rewritten IDs.\nMissing, corrupt or cyclic ancestry fails closed; there is no depth cutoff that\nsilently skips earlier source guards. Current descendant release never grants\nrights to a hidden ancestor.\n\nThe same boundary governs child correction capture, timeline payloads and delivery\nacknowledgments, including legacy/trusted-produced descendants. Raw provisional\nrevision writes/returns cannot probe or replay guardian-origin private content;\nauthorized guardians retain processing/history access, and own-origin correction\nmarking remains supported once ancestral source identity is published. Schema,\nID collision/remapping rules and immutable stored history remain unchanged.\n\nThe offered marking schema mirrors the assessment validator: assessed results use\n`markingCertainty` clear/unknown; `uncertain` marking requires `unassessed`, an\nallowed non-none reason and both point fields null. This is not a conversion of\nprovider output: contradictory results remain refused. Mapping certainty remains\nindependent. Visible but overwritten/ambiguous responses can stay unassessed while\nother clearly visible questions are assessed in the same valid proposal.\n\n### Observed assessment layout\n\nOptional `WorkbookAssessment.layout` version 1 describes observed sections in source\norder and zero-based row/column question placements. It is advisory, with fixed\n`provenance: 'observed'`; it never establishes identity or verified geometry.\nSections reference a retained source asset and a physical one-based observed page,\nor null when unknown. A whole-file PDF anchor may support an observed page within\nits retained page count while keeping its original anchor page null. Every placed\nquestion must anchor the section's exact source. Multiple question IDs may share a\ncell. When every placement in a cell has an explicit `part`, sort by part then\noriginal question array index for ties. If any placement in that cell omits\n`part`, the whole cell follows original question array order. Partial placement is valid. Missing `readingOrder` preserves original\nquestion order for a flattened display.\n\nThe browser-safe `normalizeWorkbookObservedLayout` copies only bounded known\nfields: at most 32 sections and 200 placements, 1–8 columns, rows 0–199, parts\n0–31, section IDs up to 64 characters and labels up to 200. Duplicate section IDs\nor question placements, invalid source/page/placement references and malformed\nmetadata discard the complete optional layout and retain `layoutOmission: 'invalid'`.\nValid answers and normal assessment validation remain independent. Owning analysis\nand guardian writes both normalize against the current capture's source hashes;\na correction cannot inherit a previous capture's source layout. Default guardian\ncopies retain valid layout and safe omission markers. Bodies remain immutable JSON\ninside existing revision privacy and exact release boundaries; no migration or\nadditional learner visibility is introduced.\n\nFocused shared SQLite/PostgreSQL cases live in\n`src/__tests__/workbook-layout-workflows.ts`, with SQLite in\n`workbook-layout-service.test.ts` and isolated PostgreSQL in\n`workbook-layout.optional.test.ts`. Pure bounds/reference cases are in\n`workbook-layout.test.ts`. Integration runs the PostgreSQL wrapper under its\nexisting isolated database lifecycle.\n\nGeneration carries optional `layout` as advisory JSON so malformed presentation\ncannot make valid question answers fail the generic schema. Its described wire\nshape uses original evidence IDs; the bridge maps those IDs to retained assets\nand then calls the strict owner normalizer. Within existing transport/JSON resource\nlimits, wrong types, unknown versions and conflicting metadata are omitted with\nonly the safe marker, while answers retain their normal strict validation. Layout\nnever establishes a question ID, a source authorization or a verified region.\n\n\n### Same-capture reread context\n\n`getWorkbookQuestionContext` returns authorized canonical `{id,prompt}` only\nfrom the exact current attempt's identity revision. Generation preserves those\nstrings verbatim for the existing strict same-capture prompt invariant, while\nrereading learner responses and any observed layout from current evidence. This\ncontext is not an answer key or authority for answers. A correction without its\nown revision receives predecessor IDs only; predecessor prompts are never copied.\nExact source/revision and full correction-ancestry visibility apply to both paths,\nincluding own-learner reads. No historical rows are rewritten and absent layout\nremains valid. `getWorkbookQuestionIdentity` remains the IDs-only public API.\n\nCanonical prompts follow ordinary assessment payload visibility, including\ncaptured review policy and exact guardian revision release even for learner-owned\nsources; source/ancestry authorization alone does not publish guardian edits.\nFor ingestion callbacks only, the engine supplies current `analysisRevision` from\nits owned item row. Context selection ignores AI materialized by that exact\ncurrent-attempt revision, so preserving a proposal does not invalidate its own\ncompleted snapshot or preview. It never ignores guardian decisions, predecessor\nrevisions or another interpretation. Direct reads omit this engine pin and retain\nnormal current revision visibility; browser/upload fields cannot supply the pin.\n\n### Observed answer-region overlays\n\nOptional `answerRegions` version1/provenance observed/coordinateSpace\n`display-oriented` carries one approximate rect `[x,y,width,height]` per question,\nwith exact retained asset/page1 and observationId. Rects are normalized to the\nwhole EXIF-oriented image; they never change question IDs, grading, anchors or\nlayout, and never claim verified geometry. Images only in this version; PDFs or\nunknown positions omit regions. Direct malformed metadata is omitted with safe\n`answerRegionsOmission: invalid`; marking/layout survive independently.\n\nVision catalogs originate in the existing opt-in extraction call. Generation\nreferences exact evidence/observation IDs, never supplies coordinates. The bridge\ncopies catalog rectangles from the extraction-pinned configuration with matching\nsource hash. Guardian copies retain them; correction sources must match current\nanchors. Existing exact release and ancestry privacy apply to the complete body.\n"
6454
6795
  },
6455
6796
  {
6456
6797
  "path": "agents/photo-intake.md",
@@ -6471,6 +6812,11 @@
6471
6812
  "path": "agents/catalog-previews.md",
6472
6813
  "module": "catalog-previews",
6473
6814
  "content": "# Publisher catalog previews\n\n`CatalogSourceRecord.preview` and `CatalogResourceView.preview` are optional\n`CatalogPreview` metadata. They describe a publisher image, not an acquired\noriginal, cover guarantee, download permission, or learner access grant. The\npublic root exports `CatalogPreviewService`, `CatalogPreviewStore`,\n`CatalogPreviewLoader`, `catalogPreviewFor`, and `normalizeCatalogPreview`.\n`CatalogResource.getPreview()` reads the guarded persisted JSON. Deploy the normal\nadditive manifest migration for its nullable `preview` column before sync; legacy\nrows have no preview. Sync retains the first retrieval timestamp on identical\nrevision retries and rejects changing an existing preview within that revision.\nNew revisions replace the current reference; old revision requests become 404.\nPNG verification resolves its Node host capability only when invoked; importing\ncatalog metadata/models remains browser-safe. Serving/verification requires a\nNode host and fails closed without it.\nThe image hash participates in the IM page record's revision, so changed bytes\ncannot silently reuse a preview revision.\n\nThe public IM first-edition adapter automatically selects at most one embedded\ninstructional diagram on a K–5 family-materials page. It requires that exact\npage's CC BY 4.0 footer and attribution, an official ImageFile figure with an\nempty caption, no separate credits or image-license notice in the instructional\ncontent, a descriptive chart/diagram alt, and an exact official asset identifier.\nBranding, photographs, credited or ambiguous images, other providers, unsupported\nformats and missing metadata remain absent. This narrow page-scoped policy does\nnot grant a provider-wide image license. The source footer explicitly excludes\nthe name/logo and preserves third-party image licenses; never remove those\nexclusions or apply this rule to arbitrary images. Existing teacher visibility\nis retained, including family hierarchy records currently classified teacher.\n\nConcrete publisher evidence, retrieved 2026-10-28:\n[Grade 1 Unit 1 Family Materials](https://im.kendallhunt.com/k5/families/grade-1/unit-1/family-materials.html)\nembeds ImageFile 38531, “Tally Chart. Turtle, tallies, 6. Dog, tallies, 2.\nRabbit, tallies, 12.” Its\n[official PNG](https://cms-assets.illustrativemathematics.org/fd1cykl2wl4zyfc6vzfkl3x09pvr)\nis 93,072 bytes, 1350 × 674. The licensed instructional fragment and PNG are\nfixtures in `src/__tests__/fixtures/im-family-*`, attributed to © 2021\nIllustrative Mathematics, CC BY 4.0. The image is unmodified. Captions or separate\nattribution appearing in a future sync make the preview unavailable.\n\nEnumeration performs metadata reads and at most one **preview image** GET per\npage; it never GETs original PDFs/ZIPs. It accepts only official HTTPS origin and\nopaque path, refuses redirects, and bounds the complete network/body operation\nby the adapter deadline (20 seconds default, 60 maximum). Required HTML/HEAD\nwork completes before optional sync previews, which get an independent deadline\nof one quarter of that budget, capped at two seconds and the remaining total\nenumeration budget. A hung preview cannot pause\nmetadata enumeration or its cursor. PNG previews have a\n2 MiB byte cap and 4096-pixel width/height cap. SVG, HTML, JPEG and other formats\nare currently unavailable. Only non-interlaced, static 8-bit RGB/RGBA PNGs are\nverified: complete chunks/CRCs/IEND and bounded inflated scanlines must be valid;\ntruncated or corrupt data never becomes a verified preview. This uses existing provider request bounds rather\nthan a general URL proxy. A preview failure leaves catalog metadata readable.\n`CatalogReadService.search()` performs no image or content fetches.\n\nWire a host endpoint using only the published root API:\n\n```ts\nimport {\n CatalogPreviewService,\n createPublicIllustrativeMathematicsFirstEditionAdapter,\n} from '@happyvertical/smrt-education';\n\nconst previews = new CatalogPreviewService({\n // Both functions use your trusted active-session/tenant executor. Adapt your\n // persisted resource to CatalogSourceRecord, including row.getPreview().\n getResource: (id, actor) => catalogStore.getReadableResource(id, actor),\n authorize: (actor, resource) => catalogPolicy.canReadNow(actor, resource.id),\n}, createPublicIllustrativeMathematicsFirstEditionAdapter());\n\n// Inside GET /catalog/preview; actor is derived from the server session.\nreturn previews.serve({ actor, resourceId, revision });\n```\n\nRender your own endpoint URL only when `view.preview` is present, using `alt`,\nwidth/height and rights attribution. Never place `reference` or `sourceUrl` into\nan image URL. The endpoint re-reads the current resource and checks active\nhost authorization, tenant/actor identity, teacher visibility, withdrawal,\nrevision and optional rights expiry on **every request**, including cache hits.\nThe host must check actor membership/active tenant, not merely trust strings in a\nrequest. Do not expose an unscoped collection read as this adapter.\n\nRights expiry is checked again after awaited loading/verification and before\nserving or inserting cached bytes. The per-service cache holds at most 16 MiB of verified public image bytes for at\nmost 60 seconds; reuse a service instance to benefit from it. Cache lookup occurs\nafter authorization. Changed length, dimensions or SHA-256, expiry, missing or\ninvalid metadata, upstream failures and access denial yield uniform 404 with\n`Cache-Control: no-store`; successful responses are PNG, `nosniff`, sandboxed,\nand `private, no-store`, so browsers cannot serve a revoked preview from cache.\nNo credential, upstream image URL, redirect or raw provider error reaches the\nbrowser. Synchronize regularly to observe publisher withdrawal/license changes;\nserver byte caching never bypasses the durable current-resource decision.\n\nPrivate asset previews and derivatives of acquired originals are unavailable in\nthis publisher-only contract. Use the asset authority and the separate acquired\noriginal derivative capability (#4470), with authorization on every private\nasset read. Possessing an asset, granting full-resource download/print, or being\nable to view a publisher page never supplies preview permission here.\n"
6815
+ },
6816
+ {
6817
+ "path": "agents/learner-plan.md",
6818
+ "module": "learner-plan",
6819
+ "content": "# Enrollment Plan reads\n\n`LearnerPlanService(db, authorizeLearner, authorizeCatalog)` extends the existing\nLearnerService transaction/active actor boundary and adds private parent notes\n([notes contract](enrollment-notes.md)). Its `withTransaction` and `withExecutor`\nservices retain the Plan methods. Root exports `EnrollmentPlanView` and\n`PlanLessonView`; all results are data only.\n\n`listPlan(request)` returns exact learner enrollments ordered by sortOrder/id.\n`getEnrollmentPlan({...request,enrollmentId})` verifies that exact tenant,\nlearner, enrollment and curriculum before reading its course. Missing or denied\ncourse visibility returns `availability: 'unavailable'`, `course: null`, and\nempty lessons/evidence; it does not interrupt other authorized enrollments.\nActor/enrollment denial still rejects the request. Operational database errors\nare not disguised as missing material.\n\nThe course DTO contains only id, title, subject, summary, modality and sourceKey.\nTechnical unfiled capture scaffolding has sourceKey exactly `unfiled-work`;\nconsumers exclude it by identity, never by title. The DTO does not invent a cover\nor claim any source/asset preview binding. It returns no teacher or answer-key\nassets. Lessons preserve catalog section/lesson order and existing role-filtered\nprogress, including private workbook feedback rules. Optional workbookActivityId\nis resolved for the exact tenant/learner/enrollment/lesson; consumers route that\nwork to their workbook interface, not generic scan capture.\n\nWorkbook progress uses the same explicit parent completion projection as Work.\nOld grade-derived complete/skipped rows without a current decision project in\nprogress with completedAt null; they cannot contribute a completion timestamp to\nprogressEvidence. Later analysis of the same attempt retains a parent's decision;\na new correction or practice attempt reopens it. Ordinary lesson progress is\nunchanged. See [the workbook contract](workbook-service.md).\n\nFor required-online lessons, optional sourceUrl comes only from the same\ncatalog-authorized Lesson.externalUrl and must be HTTPS without credentials.\nAnswer URLs and asset locations are never projected. A missing or unsafe online\nURL is unavailable to Continue and cannot block a later usable lesson. Consumers\nrevalidate links at their own browser boundary and display honest unavailable\nmaterial states.\n\n`nextActionableLessonId` considers planned/active enrollments and only unstarted\nor in-progress lessons with usable material. Planned is usable because normal\n`enroll` creates planned enrollments and the base workflow has no activation API.\nPaused/completed/dropped enrollments have no next action. Complete/skipped lessons\nand submitted/needs_review work never instruct rework. `awaitingReviewLessonIds`\nis separate from Continue. Reads never change progress or create learning history.\n\n`progressEvidence` is nullable `{kind: 'started'|'completed',at,lessonId}` from\nvisible durable progress start/completion timestamps. It is the newest such\nrecorded timestamp, **not** latest actual work. Repeated starts preserve the\noriginal startedAt. Display it as “Last recorded start/completion”, never infer\nactivity from updatedAt, enrollment ordering, notes, or missing timestamps.\n\n```ts\nimport { LearnerPlanService, ENROLLMENT_NOTE_MAX_LENGTH, validateEnrollmentNote }\n from '@happyvertical/smrt-education';\nconst service = new LearnerPlanService(db, authorizeLearner, authorizeCatalog);\nconst plan = await service.listPlan(serverLearnerRequest);\nvalidateEnrollmentNote(text, requestKey); // input validation, not authorization\nconst note = await service.addEnrollmentNote({\n ...serverGuardianRequest, enrollmentId, text, requestKey,\n});\n```\n\nBuild before consuming the public package; deploy its ordinary additive manifest\nmigration before using notes. No runtime handler creates application schema.\nShared Plan and note workflows run against SQLite and PostgreSQL.\n"
6474
6820
  }
6475
6821
  ],
6476
6822
  "recipes": [
@@ -91,6 +91,7 @@ export declare class SubmissionService extends LearnerService {
91
91
  protected assets(): Promise<AssetRuntime>;
92
92
  protected storePage(request: Pick<LearnerRequest, 'tenantId' | 'actorUserId'>, profileId: string, page: SubmissionPage): Promise<Asset>;
93
93
  protected validatePages(pages: readonly SubmissionPage[]): void;
94
+ private lockProgressOwner;
94
95
  private progress;
95
96
  private requireSubmission;
96
97
  /** Persisted origin/release evidence, shared by base submission and workbook