@happyvertical/smrt-education 0.0.0-stage → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/AGENTS.md +32 -0
  2. package/CLAUDE.md +1 -0
  3. package/LICENSE +7 -0
  4. package/README.md +139 -2
  5. package/agents/catalog-service.md +45 -0
  6. package/agents/learner-service.md +101 -0
  7. package/agents/source-import.md +63 -0
  8. package/agents/submission-service.md +122 -0
  9. package/dist/__smrt-register__.d.ts +2 -0
  10. package/dist/__smrt-register__.d.ts.map +1 -0
  11. package/dist/catalog-service.d.ts +50 -0
  12. package/dist/catalog-service.d.ts.map +1 -0
  13. package/dist/catalog.d.ts +72 -0
  14. package/dist/catalog.d.ts.map +1 -0
  15. package/dist/collections.d.ts +70 -0
  16. package/dist/collections.d.ts.map +1 -0
  17. package/dist/course-source.d.ts +25 -0
  18. package/dist/course-source.d.ts.map +1 -0
  19. package/dist/index.d.ts +24 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +24733 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/learner-service.d.ts +95 -0
  24. package/dist/learner-service.d.ts.map +1 -0
  25. package/dist/learners.d.ts +58 -0
  26. package/dist/learners.d.ts.map +1 -0
  27. package/dist/lesson-assets.d.ts +12 -0
  28. package/dist/lesson-assets.d.ts.map +1 -0
  29. package/dist/manifest.json +9484 -0
  30. package/dist/recipes.d.ts +50 -0
  31. package/dist/recipes.d.ts.map +1 -0
  32. package/dist/smrt-knowledge.json +3600 -0
  33. package/dist/source-import-service.d.ts +50 -0
  34. package/dist/source-import-service.d.ts.map +1 -0
  35. package/dist/sources/easy-peasy-parser.d.ts +23 -0
  36. package/dist/sources/easy-peasy-parser.d.ts.map +1 -0
  37. package/dist/submission-service.d.ts +110 -0
  38. package/dist/submission-service.d.ts.map +1 -0
  39. package/dist/submissions.d.ts +70 -0
  40. package/dist/submissions.d.ts.map +1 -0
  41. package/package.json +64 -3
package/AGENTS.md ADDED
@@ -0,0 +1,32 @@
1
+ # smrt-education
2
+
3
+ Catalog objects are optional-tenant scoped and have no generated REST, CLI, MCP,
4
+ or AI surface. Applications use `CatalogService`, supplying their actor-bound
5
+ authorization decision and the trusted `TenantCollection.withTenantCatalogReadScope`
6
+ adapter from smrt-users. That read scope may widen only an allowlisted catalog
7
+ collection's `list` to self, ancestors, and globals; it never authorizes a get,
8
+ write, raw query, sibling, or descendant read.
9
+
10
+ `personal-use-copy` courses and all `answer-key` lesson assets are private
11
+ family material. Do not publish them, promote them, or expose them through a
12
+ learner-facing result. `LessonAsset` is the sole owner join for lesson files;
13
+ do not add asset-id fields to lesson subclasses.
14
+
15
+ Learner objects require exact tenant ownership and close generated surfaces.
16
+ `LearnerService` owns actor/resource authorization and transactional planning and
17
+ start-progress; see [learner contract](agents/learner-service.md). Guardian
18
+ relationships require a host decision about their type and effective terms.
19
+ Never expose direct collection writes as learner handlers, reset progress on a
20
+ retry, or treat an unscored nullable decimal as zero.
21
+
22
+ Submission capture, guardian grading and private PDF packet splitting use
23
+ `SubmissionService`; see [submission contract](agents/submission-service.md).
24
+ Keep asset metadata on the same transaction and compensate only newly planned
25
+ files after rollback; never expose source URIs or accept learner grade patches.
26
+
27
+ Validation: `pnpm --filter @happyvertical/smrt-education test`,
28
+ `test:postgres`, `typecheck`, and `build`.
29
+
30
+ Source import: [host boundary and transaction contract](agents/source-import.md).
31
+ `SourceImportService` requires host opt-in, matching active actor/tenant and
32
+ explicit licensing authorization; never add a system/global write bypass.
package/CLAUDE.md ADDED
@@ -0,0 +1 @@
1
+ @AGENTS.md
package/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright <2025> <Happy Vertical Corporation>
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,140 @@
1
- # Temporary Holding Version
1
+ # @happyvertical/smrt-education
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Preview education catalog models and authorized catalog reads for s-m-r-t.
4
+ The package represents courses, ordered sections, lessons, and lesson-owned files.
5
+ It supplies catalog structure and authorized learner planning/progress workflows.
6
+ Applications authenticate actors and decide guardian permissions.
7
+
8
+ ## Install
9
+
10
+ ```sh
11
+ pnpm add @happyvertical/smrt-education
12
+ ```
13
+
14
+ Use the package with the core, users, profiles, tenancy, and assets packages.
15
+ Apply application migrations before using the catalog collections.
16
+ Runtime catalog operations do not create application schema.
17
+
18
+ ## Models and collections
19
+
20
+ | Model | Purpose |
21
+ | --- | --- |
22
+ | `CourseSource` | Host-managed adapter policy and sharing ceiling |
23
+ | `Course` | Course metadata, license, status, and optional owning tenant |
24
+ | `ExternalCourse` | External course subtype sharing the Course table |
25
+ | `AuthoredCourse` | Authored course subtype sharing the Course table |
26
+ | `CourseSection` | Ordered section belonging to a course |
27
+ | `Lesson` | Ordered lesson belonging to a section |
28
+ | `Reading`, `Video`, `Worksheet`, `Exercise` | Lesson subtypes |
29
+ | `LessonAsset` | Lesson-owned file join with a relationship such as printable or answer-key |
30
+ | `Learner` | A required User/Person identity and guarded settings in one family tenant |
31
+ | `Curriculum` | A learner plan for a distinct period |
32
+ | `Enrollment` | One course in a curriculum |
33
+ | `LessonProgress` | One lesson in an enrollment, with nullable score and separate history |
34
+
35
+ Each exported model has a corresponding exported collection.
36
+ Course and lesson subtypes use single-table inheritance.
37
+ Sections and lessons sort by `sortOrder`, with identifier ordering for ties.
38
+ Lesson files belong in `LessonAsset`; lesson subtypes do not carry asset-id fields.
39
+
40
+ ## Basic integration
41
+
42
+ 1. Create the course, section, lesson, and lesson-asset collections with the
43
+ application's database executor.
44
+ 2. Supply those collections, a request-bound `TenantCollection`, and the host's
45
+ actor-bound permission resolver to `CatalogService`.
46
+ 3. Enable tenancy and establish the authenticated actor's active tenant context.
47
+ 4. Call `findVisible` with that tenant's canonical identifier and the actor's
48
+ user identifier to list the ordinary catalog.
49
+ 5. Call `projectCourse` with the same context and a visible course identifier
50
+ to obtain its ordered sections, lessons, and allowed asset joins.
51
+
52
+ The [catalog service integration tests](src/__tests__/catalog-service.test.ts)
53
+ provide executable examples of collection setup, `withTenant`, and service reads.
54
+ See the [integration contract](agents/catalog-service.md) for host responsibilities.
55
+ The authorizer must make a trusted server-side permission decision for the actor;
56
+ a caller-supplied boolean is not a substitute for authentication or authorization.
57
+
58
+ ## Privacy and ownership
59
+
60
+ Ordinary catalog reads can include the active tenant, its ancestors, and globals.
61
+ The supplied tenancy capability widens only the allowlisted catalog list reads.
62
+ It does not grant generic get, write, raw query, sibling, or descendant access.
63
+
64
+ Personal-use copies require an owning tenant and cannot be published.
65
+ Their owner and license kind cannot be changed after creation.
66
+ `includePrivate` requires a separate host grant and returns only the active
67
+ owner's personal copies, never an ancestor's or global private copy.
68
+ The shared Tenant model has no family/group discriminator; hosts provision
69
+ family tenants and authorize their guardian workflows.
70
+
71
+ Learner projections remove worksheet answer URLs and answer-key asset joins.
72
+ `includeAnswerKeys` requires a separate host permission.
73
+ Every projected child must have the selected course's exact owning tenant.
74
+ Stored inconsistent section, lesson, and asset links are excluded.
75
+ Generated REST, CLI, MCP, and AI operations are closed for these models.
76
+
77
+ ## Learner workflows
78
+
79
+ Create `LearnerService` with the application's root database executor and trusted
80
+ learner/catalog authorizers. In the authenticated actor's exact `withTenant`
81
+ context, an authorized guardian can `createCurriculum` and `enroll`; a learner
82
+ can `getLearner`, `startLesson`, and `listProgress` for their own work.
83
+ Guardian access requires a same-tenant directed `ProfileRelationship` plus the
84
+ host's permission decision. Sibling membership does not grant access.
85
+
86
+ Repeated starts preserve the existing progress, including attempts and scores.
87
+ A new curriculum/year creates a distinct enrollment and fresh progress history.
88
+ An unscored lesson has `bestScore: null`, distinct from zero. The service does
89
+ not accept score or completion patches; `SubmissionService` owns guardian grading.
90
+ Use `withTransaction` and its callback service to compose atomic operations.
91
+ Results are plain data views, so no transaction-bound model handle escapes.
92
+
93
+ The [learner integration contract](agents/learner-service.md) describes required
94
+ identity provisioning, catalog permissions, supported workflows and limitations.
95
+ The [executable learner workflows](src/__tests__/learner-workflows.ts) test this
96
+ integration on both SQLite and PostgreSQL.
97
+
98
+ ## Submissions and worksheet packets
99
+
100
+ `SubmissionService` captures tenant-owned uploads, reviews or attests work with
101
+ optional guardian scores, signs authorized asset claims, and splits private
102
+ PDF packets by page range. It reuses learner ownership and transaction checks.
103
+ See the [submission integration contract](agents/submission-service.md) for
104
+ inputs, retry rules, signing requirements and external-storage compensation.
105
+
106
+ ## Source imports
107
+
108
+ `SourceImportService` imports Easy Peasy pages as private family pointers after
109
+ explicit host terms acceptance. Hosts inject the page fetcher and authorize the
110
+ active actor, family/group destination and licensing evidence. Re-import refreshes
111
+ URLs in place without deleting lessons or learner history. No lesson text or PDFs
112
+ are mirrored. See the [source integration contract](agents/source-import.md) and
113
+ [source behavior matrix](../../docs/test-matrix/3967-education-sources.md).
114
+
115
+ ## Recipes
116
+
117
+ `EducationCatalogRecipe` declares `education.catalog`; `EducationLearnerRecipe`
118
+ declares `education.learner`.
119
+ Its course navigation and model declarations support application composition.
120
+ A recipe does not grant permission to expose model CRUD or private material.
121
+
122
+ ## Database support and validation
123
+
124
+ SQLite and PostgreSQL exercise catalog persistence and child ownership guards.
125
+ The real ancestor hierarchy integration is exercised on SQLite.
126
+ DuckDB tests cover Course-only persistence: existing core DDL gates reject
127
+ catalog child update-cascade and Asset/Tenant self-referential foreign keys.
128
+ Use SQLite or PostgreSQL for full catalog schemas; those gates remain intact.
129
+
130
+ Run the package's `test`, `test:postgres`, `typecheck`, and `build` scripts.
131
+ The [behavior matrix](../../docs/test-matrix/3966-education-catalog.md) records
132
+ regressions, dialect coverage, and the existing DuckDB setup limitations.
133
+
134
+ ## Related documentation
135
+
136
+ - [Package agent guidance](AGENTS.md)
137
+ - [Core persistence](../core/README.md)
138
+ - [Users and tenant hierarchy](../users/README.md)
139
+ - [Tenant isolation](../tenancy/README.md)
140
+ - [Assets](../assets/README.md)
@@ -0,0 +1,45 @@
1
+ # Catalog service integration
2
+
3
+ `EducationCatalogRecipe` is a cookbook declaration, not permission to expose
4
+ model CRUD. Hosts construct `CatalogService` with a `CourseCollection`, their
5
+ request-bound `TenantCollection`, and a permission resolver adapter. They call
6
+ `findVisible({ tenantId, actorUserId })` for a learner projection and pass
7
+ `includePrivate: true` only after a guardian-level
8
+ `education.catalog.read-private` grant. The service enters
9
+ `TenantCollection.withTenantCatalogReadScope` for the Course collection; that
10
+ scope is limited to `list`, active family ancestors, and global rows.
11
+
12
+ Private copies are returned only when their tenant exactly matches the active
13
+ request tenant, even with the private-read grant. Ancestor and global private
14
+ rows are excluded. Projection also excludes each section, lesson, and asset
15
+ join whose tenant differs from the selected course, including malformed stored
16
+ links that predate the model save guards.
17
+
18
+ The shared Tenant model has no family/group kind discriminator. Hosts provision
19
+ family tenants and authorize guardian authoring for those tenants; the catalog
20
+ enforces a non-null owning tenant, immutable personal-copy tenant/license, and
21
+ prohibits `published` personal copies on create and update. Hierarchy position
22
+ is not a family classification. This is a new package with no deployed catalog
23
+ rows to migrate. If a host imports invalid published personal copies, correct
24
+ them to `draft` or `archived` under the original owner before normal saves; never
25
+ relabel or promote them. Read ownership checks remain enforced for invalid rows.
26
+
27
+ The host resolves sections, lessons, and LessonAsset rows only after selecting
28
+ an authorized visible course, preserving the course tenant and excluding
29
+ `answer-key` relationships from learner projections. Guardian authoring and
30
+ asset linking use host handlers that make the same permission decision; no
31
+ generated REST, CLI, MCP, or AI operation may bypass those handlers.
32
+
33
+ Ownership checks use SQL tenant identity predicates so native UUID driver values
34
+ cannot reject an unchanged owner. Course-only persistence is exercised on DuckDB;
35
+ full catalog child/asset schemas inherit core's current DuckDB rejection of
36
+ ON UPDATE CASCADE and self-referential foreign keys. Use SQLite or PostgreSQL for
37
+ those schemas; no schema gate is relaxed by this package.
38
+
39
+ Private-course visibility is a mandatory collection SQL predicate applied before
40
+ `limit` and `offset`, including selected-column reads. Caller options cannot
41
+ replace that predicate. Video constructor options are reapplied after subtype
42
+ field initialization, matching Worksheet. LessonAsset's explicit natural key is
43
+ `(tenant_id, lesson_id, asset_id, relationship)`; same-owner and null-owner retries
44
+ retain one join identity. Core's cross-owner guard remains in place. A hostile
45
+ stored join cannot reserve the legitimate owner's key or be overwritten by it.
@@ -0,0 +1,101 @@
1
+ # Learner service boundary
2
+
3
+ `Learner`, `Curriculum`, `Enrollment`, and `LessonProgress` require an exact
4
+ owning tenant. Their generated API, CLI, MCP, and AI operations are closed.
5
+ The corresponding collections and model saves are trusted server persistence
6
+ APIs, not request handlers. Hosts must not wrap them in generic learner CRUD.
7
+
8
+ ## Identity and authority
9
+
10
+ Provision a Person Profile, a linked User, and an active Membership before
11
+ creating a Learner with explicit `tenantId`, `userId`, and `profileId`. User
12
+ email is optional. Learner identity cannot be reassigned after creation.
13
+ Guardianship is a directed `ProfileRelationship`, not a Learner `parentId`.
14
+ The shared Tenant has no family discriminator: “family” means a tenant that
15
+ the host has provisioned and authorized for this purpose.
16
+
17
+ Construct `LearnerService(db, authorizeLearner, authorizeCatalog)` with one
18
+ root database executor and trusted server-side authorizers. Enable tenancy
19
+ and establish `withTenant({ tenantId, userId }, callback)` from authenticated
20
+ credentials. Every request must match that active actor and tenant exactly;
21
+ missing context, spoofed identifiers, and super-admin bypass contexts fail.
22
+ Both the actor and learner must hold active memberships in that exact tenant.
23
+ A learner's User must still link to the learner's Person Profile.
24
+
25
+ For a different actor, supply `guardianRelationshipId`. The service checks
26
+ that this relationship is owned by the active tenant and directed from the
27
+ actor's User Profile to the learner's Profile. `authorizeLearner` receives
28
+ `own`, `action`, and the verified `relationshipTypeId`; the host must check
29
+ that the type represents guardianship, its terms are effective, and that
30
+ its permission policy allows the action. An arbitrary relationship is not a
31
+ guardian grant. Missing relationships and host denials/exceptions fail closed.
32
+ Same-tenant membership alone never grants sibling access.
33
+
34
+ ## Supported workflow
35
+
36
+ - `getLearner` returns authorized learner metadata and parsed settings.
37
+ - `createCurriculum` creates a **planned** curriculum for an authorized guardian.
38
+ A learner cannot plan their own curriculum. Title is required.
39
+ - `enroll` requires that guardian authority and a matching tenant curriculum.
40
+ It returns the existing enrollment for the same curriculum/course unchanged.
41
+ - `startLesson` allows the learner or authorized guardian to create their own
42
+ enrollment's lesson progress in `in_progress`. It derives learner identity
43
+ from the validated enrollment. Repeated starts return existing state without
44
+ resetting counters, scores, completion, or timestamps.
45
+ - `listProgress` reads only the validated learner/enrollment and lessons still
46
+ visible in that enrollment's course. Inconsistent persisted links fail
47
+ closed or are omitted from the list.
48
+
49
+ Service results are data-only views, with no persistence or database methods.
50
+ Extra request fields are never model patches: a learner cannot set score,
51
+ attempt count, or completion status through this API. Curriculum activation,
52
+ completion transitions and dashboard aggregation are not supported by this
53
+ base service. SubmissionService extends its owning transaction/auth boundary for
54
+ capture and guardian grading; see [submission contract](submission-service.md).
55
+ Direct model writes require trusted authority.
56
+
57
+ Catalog visibility and child ownership reuse `CatalogService` on the same
58
+ executor. Learner operations request private-inclusive catalog access, so the
59
+ host catalog authorizer must permit both `education.catalog.read` and
60
+ `education.catalog.read-private` for the supported enrollment workflow.
61
+ Private copies remain restricted to the exact owner. The service constructs a
62
+ whitelisted catalog request; caller options and answer-key flags are discarded.
63
+ Answer keys are never requested or returned. A catalog grant does not grant learner authorization.
64
+
65
+ ## Transactions and history
66
+
67
+ `withTransaction(async transaction => ...)` composes service operations on the
68
+ callback's transaction-bound service. Use that service for every operation in
69
+ the unit. Throwing rolls back its curriculum, enrollment, and progress writes.
70
+ The callback's service closes when the transaction ends and rejects later calls.
71
+ A failed mutation poisons the unit even if the callback catches its rejection;
72
+ queued mutations are cancelled. Callback failure stops pending work and waits for
73
+ already-running mutations to settle before rollback releases the executor. On
74
+ success, already-enqueued mutations finish before commit. Do not retain the
75
+ callback's service beyond the callback. The constructor
76
+ accepts a root database; callers composing work use this method instead of
77
+ passing an unmanaged transaction handle.
78
+
79
+ PostgreSQL locks the parent curriculum/enrollment row before find-or-create.
80
+ Embedded writes use the core transaction queue. Concurrent mutations inside
81
+ one callback transaction are ordered on that connection, so duplicate starts
82
+ within a `Promise.all` also return the same row. Database natural-key constraints
83
+ arbitrate enrollment `(tenant_id, curriculum_id, course_id)` and progress
84
+ `(tenant_id, enrollment_id, lesson_id)` identities. Explicit storage conflict
85
+ keys include the tenant prefix because core does not add it to explicit keys.
86
+ The logical keys required by #3968 remain curriculum/course and enrollment/lesson:
87
+ immutable parent ownership and parent-tenant checks reject any second tenant
88
+ for the same parent. The storage prefix cannot create a second valid logical row. Ordinary repeated and concurrent calls
89
+ return one row; an aborted workflow can be retried. No in-memory process lock
90
+ is used for PostgreSQL concurrency.
91
+
92
+ A new year is a new curriculum and enrollment, producing independent progress.
93
+ Unscored `bestScore` persists as null; score zero is a real score. Scores are
94
+ nullable decimals in [0,1]; counters are nonnegative safe integers. Settings
95
+ are stored as JSON text and accessed through guarded `getSettings`/`setSettings`.
96
+
97
+ The shared executable workflows in `src/__tests__/learner-workflows.ts` run
98
+ against SQLite and PostgreSQL, including actor/resource/context denial,
99
+ concurrent creation, rollback, history preservation, and zero/null scores.
100
+ Full DuckDB schemas remain blocked by existing upstream DDL restrictions;
101
+ no full learner workflow support on DuckDB is claimed.
@@ -0,0 +1,63 @@
1
+ # Source import integration
2
+
3
+ `SourceImportService` is a server-side host boundary, with no generated REST,
4
+ CLI, MCP or AI operation. Recipes remain metadata only. Provision a closed
5
+ `CourseSource` in the owning tenant, then construct the service with the root
6
+ SQL database and an actor-bound `SourceImportAuthorizer`. Enable tenancy and
7
+ call within `withTenant({ tenantId, userId })`; the request must match both.
8
+ Missing context, a different actor/tenant and super-admin bypass fail closed.
9
+
10
+ Register Easy Peasy only after the host operator accepts its terms, using
11
+ `registerEasyPeasy({ termsAccepted: true, fetchPage })`. Do not derive that
12
+ boolean from an HTTP request. `fetchPage(url, { redirect: 'error' })` must reject
13
+ redirects, bound time/size and return `{ html, url }`. It fetches the page only,
14
+ never a PDF. The service checks the approved HTTPS URL before fetching, checks
15
+ the response URL, limits HTML to 2 million characters, then parses the complete
16
+ page before any write. There is no automatic registration or network access. Structural h1–h6 headings delimit day content, including trailing Resources
17
+ sections; ordinary non-lesson bold instructions do not. Supported
18
+ heading blocks are h1–h6 or a lesson marker as the first bold run in a
19
+ paragraph/list item.
20
+ Recognizable markers outside that layout, including multiple markers in one
21
+ paragraph, fail closed rather than silently assigning a PDF to the wrong day.
22
+
23
+ The authorizer must verify the authenticated actor's management grant and the
24
+ provisioned tenant's family/group classification. Tenant has no built-in kind;
25
+ neither hierarchy position nor client input establishes a family. Authorization
26
+ is required before fetch and again inside the transaction; callbacks must be
27
+ read-only decisions, since a rolled-back contention retry can call them again.
28
+
29
+ `sharing` is a ceiling: private permits private-family import only; group and
30
+ global permit private-family or group import. Group imports additionally require
31
+ nonblank host-recorded `config.licenseEvidence` and an explicit host grant that
32
+ validates that permission. Global/null destinations are unsupported and rejected;
33
+ there is no global/system bypass. Widening a source never promotes existing
34
+ imports. Private imports are draft `personal-use-copy` courses and retain the
35
+ catalog's immutable owner/license and no-publication protection. A host may
36
+ create a new licensed group source and import there after receiving permission.
37
+
38
+ Use a stable `courseRef` independent of source URLs. Identities derive from the
39
+ owning tenant, source row ID, course reference and lesson day; retain the source
40
+ row when updating policy. Changes to titles/page/PDF URLs update in place.
41
+ Missing days are retained, not deleted, so enrollment, progress, submission and
42
+ asset foreign keys remain valid. A changed lesson kind fails the whole import
43
+ rather than replacing a row. Only title and pointer fields are refreshed; body,
44
+ answer keys, learner state and manually authored settings are never replaced.
45
+ The packet URL is returned as a source link and is not downloaded or mirrored.
46
+
47
+ All source policy rechecks and catalog mutations use collections bound to the
48
+ same SQL transaction. Embedded writes use the public write queue; PostgreSQL
49
+ serializes an import identity with a transaction-scoped advisory lock. Only
50
+ rolled-back busy/serialization/deadlock errors retry (at most three retries),
51
+ without repeating fetch. Connection-invalidated and other failures propagate.
52
+ Do not wrap the service in a caller transaction if the caller expects a durable
53
+ commit before returning; any outer transaction remains the caller's boundary.
54
+
55
+ SQLite and PostgreSQL integration tests exercise real transactions and rollback.
56
+ Full catalog children remain unavailable on DuckDB due to core's existing
57
+ ON UPDATE CASCADE/self-referential schema limitations documented in
58
+ [catalog-service.md](catalog-service.md); no alternate schema is substituted.
59
+ Tests use synthetic local HTML and an injected fetch callback, never the network.
60
+
61
+ Generic “Printable” and “Printables” labels identify lesson PDFs; course packets
62
+ require explicit packet/whole-course/complete-course wording. Only actual href
63
+ attributes supply pointers; data-href metadata does not.
@@ -0,0 +1,122 @@
1
+ # Submission and paper workflow
2
+
3
+ Construct `SubmissionService(db, authorizeLearner, authorizeCatalog, {storage,
4
+ storeOptions?, signAsset?})` from trusted server configuration. It extends
5
+ `LearnerService`: the same active actor/tenant, memberships, linked person,
6
+ directed guardian relationship and host permission checks apply. The host must
7
+ allow `education.learner.submit`, `education.learner.grade` or
8
+ `education.learner.packet` as appropriate. Grade and packet actions always reject
9
+ the learner's own identity, even if the host authorizer returns true.
10
+
11
+ The protected transaction/auth hooks are internal owning-service extension
12
+ points. They are not handler APIs. All public service results remain data-only.
13
+ Use the callback's service in `withTransaction`; escaped services are closed.
14
+ Submission operations share its serial queue, poison-on-failure behavior and
15
+ PostgreSQL enrollment lock. No runtime schema creation occurs.
16
+
17
+ ## Capture and grading
18
+
19
+ `captureSubmission` takes the learner request, `enrollmentId`, `lessonId`, a
20
+ nonempty stable `requestKey`, and `pages` containing `name`, `mimeType` and Buffer
21
+ `data`. Each upload becomes a fresh Asset owned by the learner's profile and
22
+ exact tenant, joined solely through `SubmissionAsset`. Up to 100 uploads of
23
+ 25 MiB each are accepted (JPEG, PNG, WebP, or PDF). It atomically creates the
24
+ submission/asset metadata and moves only that enrollment's progress to
25
+ `submitted`, incrementing attempts once. Caller status, score, grader, asset ID,
26
+ and answer-key flags are never patches. Equal request-key/content retries return
27
+ the existing submission; altered retries fail. Submitted/review/complete/skipped
28
+ progress rejects new captures. Content includes ordered filenames, MIME types,
29
+ bytes and notes. Hosts should impose tighter request/body limits as needed.
30
+
31
+ `reviewSubmission` accepts an existing authorized submission and optional score
32
+ and feedback. `attestLesson` creates/reuses an Attestation for a matching progress
33
+ item without requiring uploads. Both require a verified guardian and create a
34
+ new guardian Grade, set submission to `graded` and progress to `complete`.
35
+ Omitted/null score is unscored; zero is a real score. The latest guardian grade
36
+ wins over auto/AI/instructor proposals, and its score replaces progress's score.
37
+ Review history is retained. ExternalCompletion is available for trusted server
38
+ persistence; phase-zero learner handlers cannot forge reported scores. Online
39
+ submissions and automatic grading are not implemented.
40
+
41
+ `getSubmission` returns only submission metadata, page asset IDs and the selected
42
+ grade. It does not include catalog answers, extracted answers, storage paths,
43
+ model objects, or database handles.
44
+
45
+ ## Storage and signed routes
46
+
47
+ The service creates an AssetRuntime bound to the operation's transaction. It
48
+ requires a resolved URI whose filename retains the fresh asset UUID and MIME
49
+ extension; custom resolvers may choose its provider/directory but must route
50
+ that URI to the corresponding unique file. Shared existing locators are
51
+ rejected before preservation or cleanup registration. It plans and persists
52
+ that unique asset URI before writing, registering that new
53
+ asset for cleanup before the provider can partially write. After rollback,
54
+ files are removed through the runtime store. Before deletion, the root database
55
+ must confirm that no asset owner committed: uncertain commit/read failures never
56
+ cause committed bytes to be removed. Only this operation's newly created assets
57
+ are candidates; pre-existing files are never adopted into its cleanup ledger.
58
+
59
+ Database metadata is atomic; external storage uses compensation, not a database
60
+ transaction. If cleanup cannot complete, `SubmissionStorageCleanupError` contains
61
+ fresh asset IDs and the original cause. Operators must reconcile those IDs in
62
+ the configured `education-page/<id>.<extension>` storage namespace (custom
63
+ resolvers must retain their routing audit). A successful retry uses fresh IDs
64
+ and cannot delete or reuse orphaned bytes. Process termination may also leave
65
+ unreferenced files; hosts need storage orphan reconciliation. Do not suppress
66
+ cleanup failures or claim crash-safe distributed atomicity.
67
+
68
+ `signSubmissionAsset` verifies the submission/enrollment/learner ownership and
69
+ its noun join, then verifies the Asset's tenant and learner profile before
70
+ calling the configured trusted signer. Claims contain actor, tenant, learner,
71
+ enrollment, submission, asset and a five-minute expiry; no static source URI is
72
+ returned. The host signs every claim and its serving endpoint must verify the
73
+ signature, expiry, current actor/tenant and object authorization before serving.
74
+ A bearer URL must never become a cross-family or sibling access bypass.
75
+
76
+ ## Personal worksheet packets
77
+
78
+ `splitWorksheetPacket` takes the guardian learner request, exact-owned
79
+ `personal-use-copy` `courseId`, PDF bytes and ranges `{lessonId, firstPage,
80
+ lastPage}`. Page bounds are inclusive and one-based. Ranges must be in bounds,
81
+ disjoint and target distinct Worksheet lessons in that private course. The
82
+ 50 MiB / 100-range limits are enforced before PDF parsing. `pdf-lib` extracts
83
+ actual pages into independent PDFs and records private printable LessonAssets.
84
+ All resulting metadata commits together; storage failure compensates new PDFs.
85
+ The source packet is not retained. No shared/global course is accepted and no
86
+ answer-key join is created. A successful repeated packet upload intentionally
87
+ adds another printable version; a failed upload can be retried safely.
88
+
89
+ Shared integration workflows run on SQLite and real PostgreSQL. Full DuckDB
90
+ workflow support remains outside the existing supported schema boundary.
91
+
92
+ ## Private asset boundary
93
+
94
+ Captured pages and split packet pages use `Asset.accessMode: 'private'` from their
95
+ initial insert. Ordinary generated Asset REST/MCP operations cannot list, hydrate,
96
+ update or delete them, including through the parent STI model. The service opens
97
+ an exact tenant/actor/learner-owner/asset-ID capability only after its normal learner
98
+ relationship checks. A host's signed serving route must repeat authorization and
99
+ open that exact capability before loading the asset. See the owning
100
+ [private asset contract](../../assets/agents/private-assets.md).
101
+
102
+ Storage must be private (including provider ACLs). Public HTTP(S) source locators
103
+ are rejected; a hidden URL alone cannot protect publicly accessible bytes. Apply
104
+ the manifest migration adding `assets.access_mode` before deploying this runtime;
105
+ legacy ordinary assets retain the public default. Runtime creates no schema.
106
+
107
+ Private lesson files must already be private Assets in the same tenant. Saving
108
+ an answer-key join, or any join under a personal-use-copy course, requires the
109
+ host's exact actor/owner/asset capability; a public asset is never automatically
110
+ promoted. Packet splitting retains this capability while creating each join.
111
+ Persisted course tenant/license and section/lesson parent identity cannot change;
112
+ create new structure and explicitly re-author its joins instead. Mandatory
113
+ write predicates enforce this even for natural-key adoption and concurrent writes.
114
+ Ordinary content and metadata refresh remains supported. This
115
+ prevents existing public material being reclassified by moving its parent.
116
+
117
+ For pre-existing invalid joins, an authorized host must inventory private-material
118
+ joins and remove invalid associations, recreate the bytes in private storage as a
119
+ new privately classified Asset, then re-author the join under its exact capability.
120
+ Do not merely hide the URI, mutate access_mode with raw SQL, or delete shared
121
+ public bytes. This repair requires explicit storage/ownership authorization;
122
+ normal runtime does not migrate existing records.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=__smrt-register__.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"__smrt-register__.d.ts","sourceRoot":"","sources":["../src/__smrt-register__.ts"],"names":[],"mappings":""}
@@ -0,0 +1,50 @@
1
+ import type { TenantCollection } from '@happyvertical/smrt-users';
2
+ import type { Course, CourseSection, Lesson } from './catalog.js';
3
+ import type { CourseCollection, CourseSectionCollection, LessonAssetCollection, LessonCollection } from './collections.js';
4
+ import type { LessonAsset } from './lesson-assets.js';
5
+ /** The application-owned decision that permits a catalog view for one tenant. */
6
+ export type TenantCatalogReadScope = TenantCollection['withTenantCatalogReadScope'];
7
+ export type CatalogActorContext = Parameters<Parameters<TenantCatalogReadScope>[0]['authorize']>[0];
8
+ export type CatalogAuthorizer = (request: Omit<CatalogActorContext, 'mode'> & {
9
+ action: 'education.catalog.read' | 'education.catalog.read-private' | 'education.catalog.answer-key.read' | 'education.catalog.manage';
10
+ }) => boolean | Promise<boolean>;
11
+ export interface CatalogReadRequest {
12
+ tenantId: string;
13
+ actorUserId: string;
14
+ includePrivate?: boolean;
15
+ options?: Omit<Parameters<CourseCollection['list']>[0], 'where'>;
16
+ }
17
+ export interface CatalogProjection {
18
+ course: Course;
19
+ sections: Array<{
20
+ section: CourseSection;
21
+ lessons: Array<{
22
+ lesson: Lesson;
23
+ assets: LessonAsset[];
24
+ }>;
25
+ }>;
26
+ }
27
+ export declare class CatalogAuthorizationError extends Error {
28
+ constructor();
29
+ }
30
+ /**
31
+ * The only package-level family → group → global catalog read boundary.
32
+ * Generated surfaces are closed; callers must supply both an application
33
+ * authorization decision and the narrowly-scoped tenancy capability.
34
+ */
35
+ export declare class CatalogService {
36
+ private readonly courses;
37
+ private readonly sections;
38
+ private readonly lessons;
39
+ private readonly assets;
40
+ private readonly tenants;
41
+ private readonly authorize;
42
+ constructor(courses: CourseCollection, sections: CourseSectionCollection, lessons: LessonCollection, assets: LessonAssetCollection, tenants: Pick<TenantCollection, 'withTenantCatalogReadScope'>, authorize: CatalogAuthorizer);
43
+ findVisible(request: CatalogReadRequest): Promise<Course[]>;
44
+ /** Project one already-visible course without exposing answer-key assets by default. */
45
+ projectCourse(request: CatalogReadRequest & {
46
+ courseId: string;
47
+ includeAnswerKeys?: boolean;
48
+ }): Promise<CatalogProjection | null>;
49
+ }
50
+ //# sourceMappingURL=catalog-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"catalog-service.d.ts","sourceRoot":"","sources":["../src/catalog-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAClE,OAAO,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAClE,OAAO,KAAK,EACV,gBAAgB,EAChB,uBAAuB,EACvB,qBAAqB,EACrB,gBAAgB,EACjB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEtD,iFAAiF;AACjF,MAAM,MAAM,sBAAsB,GAChC,gBAAgB,CAAC,4BAA4B,CAAC,CAAC;AACjD,MAAM,MAAM,mBAAmB,GAAG,UAAU,CAC1C,UAAU,CAAC,sBAAsB,CAAC,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,CACnD,CAAC,CAAC,CAAC,CAAC;AAEL,MAAM,MAAM,iBAAiB,GAAG,CAC9B,OAAO,EAAE,IAAI,CAAC,mBAAmB,EAAE,MAAM,CAAC,GAAG;IAC3C,MAAM,EACF,wBAAwB,GACxB,gCAAgC,GAChC,mCAAmC,GACnC,0BAA0B,CAAC;CAChC,KACE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAEhC,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,OAAO,CAAC,EAAE,IAAI,CAAC,UAAU,CAAC,gBAAgB,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC;CAClE;AAED,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,KAAK,CAAC;QACd,OAAO,EAAE,aAAa,CAAC;QACvB,OAAO,EAAE,KAAK,CAAC;YAAE,MAAM,EAAE,MAAM,CAAC;YAAC,MAAM,EAAE,WAAW,EAAE,CAAA;SAAE,CAAC,CAAC;KAC3D,CAAC,CAAC;CACJ;AAED,qBAAa,yBAA0B,SAAQ,KAAK;;CAKnD;AAUD;;;;GAIG;AACH,qBAAa,cAAc;IAEvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAIxB,OAAO,CAAC,QAAQ,CAAC,SAAS;gBART,OAAO,EAAE,gBAAgB,EACzB,QAAQ,EAAE,uBAAuB,EACjC,OAAO,EAAE,gBAAgB,EACzB,MAAM,EAAE,qBAAqB,EAC7B,OAAO,EAAE,IAAI,CAC5B,gBAAgB,EAChB,4BAA4B,CAC7B,EACgB,SAAS,EAAE,iBAAiB;IAEzC,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAuDjE,wFAAwF;IAClF,aAAa,CACjB,OAAO,EAAE,kBAAkB,GAAG;QAC5B,QAAQ,EAAE,MAAM,CAAC;QACjB,iBAAiB,CAAC,EAAE,OAAO,CAAC;KAC7B,GACA,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC;CA+DrC"}