@happyvertical/smrt-education 0.57.43 → 0.57.44

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 (82) hide show
  1. package/AGENTS.md +12 -0
  2. package/README.md +5 -0
  3. package/agents/course-authoring-service.md +179 -0
  4. package/agents/course-generation-review.md +79 -0
  5. package/agents/learner-service.md +33 -0
  6. package/agents/question-records.md +100 -0
  7. package/dist/chunks/{workbook-photo-service-C6mbUUp6.js → workbook-photo-service-BxvZDqjM.js} +1440 -309
  8. package/dist/chunks/workbook-photo-service-BxvZDqjM.js.map +1 -0
  9. package/dist/course-append-only.d.ts +15 -0
  10. package/dist/course-append-only.d.ts.map +1 -0
  11. package/dist/course-authoring-contracts.d.ts +464 -0
  12. package/dist/course-authoring-contracts.d.ts.map +1 -0
  13. package/dist/course-authoring-licence.d.ts +15 -0
  14. package/dist/course-authoring-licence.d.ts.map +1 -0
  15. package/dist/course-authoring-models.d.ts +291 -0
  16. package/dist/course-authoring-models.d.ts.map +1 -0
  17. package/dist/course-authoring-service.d.ts +244 -0
  18. package/dist/course-authoring-service.d.ts.map +1 -0
  19. package/dist/course-delegation.d.ts +26 -0
  20. package/dist/course-delegation.d.ts.map +1 -0
  21. package/dist/course-generation-contracts.d.ts +150 -0
  22. package/dist/course-generation-contracts.d.ts.map +1 -0
  23. package/dist/course-generation-models.d.ts +94 -0
  24. package/dist/course-generation-models.d.ts.map +1 -0
  25. package/dist/course-generation-output.d.ts +31 -0
  26. package/dist/course-generation-output.d.ts.map +1 -0
  27. package/dist/course-generation-proposal-generator.d.ts +94 -0
  28. package/dist/course-generation-proposal-generator.d.ts.map +1 -0
  29. package/dist/course-generation-review-service.d.ts +149 -0
  30. package/dist/course-generation-review-service.d.ts.map +1 -0
  31. package/dist/course-release-scope.d.ts +37 -0
  32. package/dist/course-release-scope.d.ts.map +1 -0
  33. package/dist/course-review-contracts.d.ts +127 -0
  34. package/dist/course-review-contracts.d.ts.map +1 -0
  35. package/dist/course-review-digest.d.ts +4 -0
  36. package/dist/course-review-digest.d.ts.map +1 -0
  37. package/dist/course-review-evaluation.d.ts +63 -0
  38. package/dist/course-review-evaluation.d.ts.map +1 -0
  39. package/dist/course-review-gate.d.ts +15 -0
  40. package/dist/course-review-gate.d.ts.map +1 -0
  41. package/dist/course-review-models.d.ts +96 -0
  42. package/dist/course-review-models.d.ts.map +1 -0
  43. package/dist/group-sharing-contracts.d.ts +5 -0
  44. package/dist/group-sharing-contracts.d.ts.map +1 -1
  45. package/dist/group-sharing-policy.d.ts.map +1 -1
  46. package/dist/group-sharing-reader.d.ts.map +1 -1
  47. package/dist/index.d.ts +27 -0
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +5658 -116
  50. package/dist/index.js.map +1 -1
  51. package/dist/ingestion.js +1 -1
  52. package/dist/learner-plan-service.d.ts.map +1 -1
  53. package/dist/learner-service.d.ts +81 -7
  54. package/dist/learner-service.d.ts.map +1 -1
  55. package/dist/learners.d.ts +6 -0
  56. package/dist/learners.d.ts.map +1 -1
  57. package/dist/manifest.json +14304 -2177
  58. package/dist/question-authoring-service.d.ts +125 -0
  59. package/dist/question-authoring-service.d.ts.map +1 -0
  60. package/dist/question-content.d.ts +25 -0
  61. package/dist/question-content.d.ts.map +1 -0
  62. package/dist/question-contracts.d.ts +240 -0
  63. package/dist/question-contracts.d.ts.map +1 -0
  64. package/dist/question-models.d.ts +124 -0
  65. package/dist/question-models.d.ts.map +1 -0
  66. package/dist/question-render.d.ts +20 -0
  67. package/dist/question-render.d.ts.map +1 -0
  68. package/dist/question-response-contracts.d.ts +54 -0
  69. package/dist/question-response-contracts.d.ts.map +1 -0
  70. package/dist/question-response-service.d.ts +63 -0
  71. package/dist/question-response-service.d.ts.map +1 -0
  72. package/dist/question-rubric.d.ts +23 -0
  73. package/dist/question-rubric.d.ts.map +1 -0
  74. package/dist/smrt-knowledge.json +5410 -910
  75. package/dist/submission-service.d.ts.map +1 -1
  76. package/dist/submissions.d.ts +5 -0
  77. package/dist/submissions.d.ts.map +1 -1
  78. package/dist/workbook-contracts.d.ts +6 -0
  79. package/dist/workbook-contracts.d.ts.map +1 -1
  80. package/dist/workbook-models.d.ts.map +1 -1
  81. package/package.json +8 -8
  82. package/dist/chunks/workbook-photo-service-C6mbUUp6.js.map +0 -1
package/AGENTS.md CHANGED
@@ -34,6 +34,18 @@ sharing separate from guardian Grade authority. Photo-first receipt/context
34
34
  capture uses [WorkbookPhotoService](agents/photo-intake.md); covers/references
35
35
  never create learning evidence, and blank exercises need explicit work intent.
36
36
 
37
+ Revisioned authored courses, release, archive, enrollment pins and licence use
38
+ [CourseAuthoringService](agents/course-authoring-service.md). Keep history
39
+ append-only, require request keys, and never weaken personal-use-copy rules.
40
+
41
+ Generation proposals into new draft revisions and revision-bound educational
42
+ review use [CourseGenerationReviewService](agents/course-generation-review.md).
43
+ Keep supplied content unless a decision is recorded; reviews carry no score.
44
+
45
+ Immutable questions, answer keys/rubrics, stable learning objectives, worksheet
46
+ render ids and exact-revision references use [QuestionAuthoringService](agents/question-records.md).
47
+ Keep keys out of learner bundles; a missing key stays null.
48
+
37
49
  Source import: [host boundary and transaction contract](agents/source-import.md).
38
50
  `SourceImportService` requires host opt-in, matching active actor/tenant and
39
51
  explicit licensing authorization; never add a system/global write bypass.
package/README.md CHANGED
@@ -15,6 +15,11 @@ work and revision-bound comments remain retained after leaving a group.
15
15
  See the [group sharing contract](agents/group-sharing.md) for the required host
16
16
  authorizer, private media delivery, facet semantics and migration/test matrix.
17
17
 
18
+ Guardians author courses with `CourseAuthoringService`: append-only course and
19
+ lesson revisions, a reviewed release that materializes immutable lessons,
20
+ reversible archive, and recorded enrollment pins and upgrades. See the
21
+ [course authoring contract](agents/course-authoring-service.md).
22
+
18
23
  ## Install
19
24
 
20
25
  ```sh
@@ -0,0 +1,179 @@
1
+ # Course authoring contract
2
+
3
+ `CourseAuthoringService` (SMRT #4514, consumed by Hearth #68) writes revisioned,
4
+ family-owned courses and releases them into the existing Course/Section/Lesson
5
+ shape. Risk is high: tenancy, authorization, new schema and a public contract.
6
+ It is a standalone service (not a `LearnerService` subclass); every write is one
7
+ transaction and every authorizer call receives that transaction's executor.
8
+
9
+ ## Models (closed, required tenant, no REST/CLI/MCP/AI)
10
+
11
+ `AuthoredCourse` (source key `authored`) stays the stable identity row; it never
12
+ holds content, is never assigned and its licence is never edited. Append-only:
13
+ `CourseRevision`, `LessonRevision` (stable `lessonKey` across versions; an
14
+ unchanged lesson reuses its row), `CourseRelease`, `ReleasedLesson`,
15
+ `CourseArchiveDecision`, `CourseDiscardTombstone`, `EnrollmentReleasePin`,
16
+ `CourseUpgradeChoice`. `CourseDraftHead` is the only mutable row and moves only
17
+ through the service's expected-head compare-and-swap. `save()` on an existing
18
+ append-only row and `delete()` throw; erasure belongs to a retention operation.
19
+ Schema ships via normal manifest migrations (additive: new tables only, no
20
+ change to existing tables, test-pinned); the runtime never creates tables.
21
+
22
+ ## Actions and host mapping
23
+
24
+ `education.course.read-draft|author|generate|review|release|assign|archive`. The host
25
+ maps roles; the authorizer also receives `operation`, `courseId`,
26
+ `enrollmentId`/`learnerId` and `delegatingGuardianUserId` (agent calls) and must
27
+ re-validate that delegation on the supplied executor. The service checks the
28
+ ambient tenant context, active actor and delegating-guardian membership first.
29
+ `generated` provenance additionally needs `generate`. Releasing and assigning
30
+ are independent grants: `pinEnrollment`, `upgradeEnrollment` and `setAutoUpgrade`
31
+ (operations `pin-enrollment`, `upgrade-enrollment`, `set-auto-upgrade`) use only
32
+ `assign`, never `release`. A host maps `assign` per learner from its assignment
33
+ setting (guardians only, guardians plus agents, learner can ask) using
34
+ `operation`, `enrollmentId`/`learnerId` and `delegatingGuardianUserId`; a
35
+ learner-initiated request is host policy, the service adds no learner authority.
36
+ Archive and discard use `archive`. `generate` and `review` are used by the [generation and review service](course-generation-review.md).
37
+ Foreign or missing courses/enrollments are not-found/denied alike.
38
+
39
+ Delegated actor: the authenticated actor is the agent; `delegatingGuardianUserId`
40
+ and a host `delegationId` (opaque, 1-200 printable characters) are validated as a
41
+ unit (one without the other is `invalid-request`; the guardian may not be the agent,
42
+ which is denied). Validation grants nothing: the guardian keeps no authority via the
43
+ agent, the host authorizer decides, and both users need active membership. One helper
44
+ (`course-delegation.ts`) builds actor, authorizer and record fields. `delegationId` is
45
+ passed to every authorizer call (all `education.course.*` actions, including the
46
+ per-enrollment `auto-upgrade` call, which gets the opt-in's recorded id) and recorded
47
+ (nullable on views) on revisions, releases, archive/discard, pins, opt-ins, skips
48
+ (`optInDelegationId`; automatic pins also record the opt-in's) and generation, review
49
+ and question records. Request-key replays compare it: another delegation id conflicts.
50
+ Delegation is opt-in and fails closed: `verifyDelegation(executor, { tenantId,
51
+ actorUserId, delegatingGuardianUserId, delegationId, action, operation, courseId?,
52
+ enrollmentId?, learnerId? }) => boolean | { allowed, reason?, code? }` must be set in the
53
+ service options. A delegated request without it, or when it denies or throws, is a
54
+ `CourseAuthoringAuthorizationError` before the host authorizer runs. The per-enrollment
55
+ `auto-upgrade` check re-verifies the opt-in's recorded delegation through the same port
56
+ (on the savepoint executor); a failure skips the move. Plain guardians never need it.
57
+ Assignment (`LearnerService.enroll`) is described in the [learner contract](learner-service.md).
58
+
59
+ ## Writes
60
+
61
+ Every write requires a `requestKey`. A retried key replays its original result;
62
+ the same key with a different payload throws `CourseRequestKeyConflictError`.
63
+ `saveDraft` takes `expectedHeadRevisionId` and throws `CourseStaleHeadError`
64
+ (with the current head) when stale. Errors extend `CourseAuthoringError` with a
65
+ stable `code`; never match on messages.
66
+
67
+ ## Release
68
+
69
+ `release` requires an injected `releaseReviewGate(executor, {tenantId, courseId,
70
+ revisionId}) => {current, reviewId?, reason?}`. No gate configured throws
71
+ `ReviewGateRequiredError`; `current !== true` throws `ReviewNotCurrentError`.
72
+ `createReviewBackedReleaseGate()` is the review-backed implementation (the default of
73
+ `CourseGenerationReviewService`; see [generation and review](course-generation-review.md)). The decision records actor,
74
+ delegating guardian and `reviewId`, one release per revision. It materializes a
75
+ release-only `AuthoredCourse` row (source key `authored-release`), sections and
76
+ `Lesson` subclasses whose ids derive deterministically from the revision id and
77
+ lesson identity; `Lesson.externalRef` and `ReleasedLesson` carry the stable
78
+ lesson identity for progress/reporting. Learners on different releases never
79
+ share mutable rows. Personal-use releases stay `draft` status (the existing rule).
80
+
81
+ ## Enrollment pin and upgrades
82
+
83
+ `Enrollment` is untouched (its `courseId` stays immutable): enroll through
84
+ `LearnerService` on a release's `releaseCourseId`, then `pinEnrollment` records
85
+ the pin. `upgradeEnrollment` appends an explicit move to a newer release.
86
+ `setAutoUpgrade` records an opt-in/out at course or enrollment level (enrollment
87
+ wins) for future releases; each automatic move is a recorded pin without actor. Pin, upgrade and
88
+ `setAutoUpgrade` need a `policyBasis` (the assignment-setting value relied on, e.g.
89
+ `guardians-only`; 1-200 printable chars, else `invalid-request`). The caller's is optional: the
90
+ `assign` authorizer decision may return `policyBasis`, which then wins; both given must be equal
91
+ (`CoursePolicyBasisMismatchError`, `policy-basis-mismatch`) and neither is `invalid-request`; an invalid
92
+ authorizer basis is a validation error, nothing written. The authorizer runs first, also on replays, and
93
+ replays compare the effective basis. Recorded with actor and delegating guardian on the pin or
94
+ choice row and exposed on the views. An automatic move records the per-enrollment `auto-upgrade`
95
+ decision's `policyBasis` (an invalid one denies the move), else the basis of the
96
+ opt-in that governs it (the same for a skip), plus its `optInScope` (`course`|`enrollment`). Each
97
+ automatic move is authorized per enrollment during `release` with action
98
+ `assign`, operation `auto-upgrade`, that `enrollmentId`/`learnerId`, the opt-in's
99
+ recorded actor and delegating guardian (current membership re-checked, the host
100
+ re-evaluates the delegation or relationship) and the transaction executor; hosts
101
+ must map `auto-upgrade` per learner from the assignment setting. A denial or
102
+ authorizer failure (each call runs in a SAVEPOINT, rolled back on a throw, so a failing host statement cannot abort the release transaction) never blocks the release or other enrollments: that
103
+ enrollment stays on its pin and an `EnrollmentAutoUpgradeSkip` row records
104
+ `not-authorized` (or the authorizer's `reason`), the optional `code`, the opt-in scope, actor,
105
+ guardian and `policyBasis`. A replayed
106
+ request key with a different basis is a request-key conflict.
107
+ `EnrollmentPinView` (from `pinEnrollment`, `upgradeEnrollment`, `getEnrollmentRelease`) carries
108
+ `learnerId`, `courseId` (authored identity) and `releaseCourseId` (history rows carry `releaseCourseId`);
109
+ `setAutoUpgrade` returns `learnerId` (null for a course choice). All derive from the enrollment row on the
110
+ server, with no new input: hosts must not read the `enrollments` table: `learnerId` and `courseId` (plus `releaseId`/`releaseCourseId` for pin and upgrade) arrive in the assign authorizer and `verifyDelegation` requests. `getEnrollmentRelease` returns
111
+ `EnrollmentReleaseView` (`learnerId`, `courseId`, `releaseCourseId`, `pin`, `lessons`): after any move
112
+ (automatic or explicit), started lessons stay on the immutable row they were
113
+ started on, unstarted lessons follow the current release. `LearnerService`
114
+ consumes the same rule (see `learner-service.md`).
115
+
116
+ ## Authorizer answers
117
+
118
+ An authorizer returns `boolean` or `{ allowed, reason?, code? }` (only `allowed: true`
119
+ allows). On an auto-upgrade denial `reason` (at most 200 characters, control characters
120
+ stripped) and `code` (`[a-z0-9._-]`, at most 64; e.g. `assignment-setting`,
121
+ `agent-permission`, `guardian-link`) are stored on the skip; invalid values fall back to
122
+ `not-authorized` and an empty code. A thrown error's message is never recorded. The per-enrollment `verifyDelegation` refusal is recorded the
123
+ same way: an object refusal's `reason`/`code` (same sanitizing); a boolean refusal, a throw or no port
124
+ records the generic `not-authorized` and an empty code.
125
+
126
+ ## Reads
127
+
128
+ All use `read-draft`, the executor and delegation fields, and are tenant-exact (a foreign
129
+ course is not-found, a foreign enrollment a denial). Lists take `limit` (default 50, capped at
130
+ 100) and an opaque `cursor`, and return `{ items, nextCursor }` in a total order.
131
+ - `listCourses({ includeDiscarded? })` (operation `list-courses`): `CourseSummaryView`
132
+ per course: state, `head` (`revisionId` is the expected-head token), `latestRelease`,
133
+ `archive`, `licenseKind` (most restrictive of head and every release), `groupShareable`
134
+ (false for personal-use and until a release exists) and `releaseCount`; hosts need no
135
+ `getCourse` per row. Oldest first; discarded courses only when requested.
136
+ - `listUpgradeChoices({ courseId, enrollmentId? })` (`list-upgrade-choices`): opt-in/out
137
+ history in sequence order; with an enrollment, its own rows plus `governing`.
138
+ - `listAutoUpgradeSkips({ courseId?, enrollmentId? })` (`list-auto-upgrade-skips`, one
139
+ required): `AutoUpgradeSkipView` with `reason`, `code`, opt-in actor/guardian/delegation.
140
+ - `QuestionAuthoringService.readReleasedLesson({ releaseId, lessonKey })`
141
+ (`read-released-lesson`): lesson revision, layout and the keyed worksheet bundle.
142
+ - `QuestionResponseService.readReleasedLesson({ enrollmentId, lessonId })` (learner
143
+ authorizer, `education.learner.read`; the learner or an authorized guardian): only the
144
+ enrollment's effective lessons (else `QuestionResponseScopeError`), keyless bundle,
145
+ no materials or provenance, personal-use body withheld (`bodyWithheld`).
146
+
147
+ ## Archive and discard
148
+
149
+ `archive`/`unarchive` append reversible decisions, mirror release `Course.status`
150
+ for catalog listings only and never touch enrollments, progress, submissions,
151
+ grades, workbooks, notes or evidence. Archived courses refuse release, new pins
152
+ and upgrades. `discardDraft` ends an unreferenced draft with a tombstone (history
153
+ stays); it is refused (`CourseDiscardRefusedError.references`) for releases and
154
+ enrollments, and for review/generation/variant rows via the injected `referenceGuard`
155
+ (`createCourseReferenceGuard`).
156
+
157
+ ## Licence and sharing
158
+
159
+ Most restrictive wins: own-work < open-license < licensed-for-sharing <
160
+ personal-use-copy (`mostRestrictiveLicense`). A lesson's own licence folds every restriction the course ever recorded
161
+ for its `lessonKey` or its normalized content, so rename, omitted keys and
162
+ remove-then-re-add keep it; only dropping that content lifts it, and a
163
+ `lessonKey` once restricted stays restricted. A reused asset keeps the most
164
+ restrictive licence ever recorded for it. Uploaded materials default to
165
+ `personal-use-copy` unless the author states otherwise. Any personal-use part
166
+ makes the course tenant-private: `GroupSharingWorkFacts.courseShareable=false`
167
+ (reason `course-private`) for authored releases, and the policy option list hides
168
+ them. Work is attributed to the course of its own lesson, and any personal-use
169
+ release anywhere in the enrollment's pin lineage also makes it `course-private`
170
+ and hides the course from the options; unpinned enrollments and databases without the pin tables are unchanged. Existing workbook personal-use scaffolds are unchanged.
171
+
172
+ ## Materials
173
+
174
+ Uploads are copied by the injected `materialCopy` port into education-owned
175
+ assets (tenant-checked afterwards) with source/actor/time provenance, so
176
+ ingestion expiry never removes them; replays never copy twice, and a port must
177
+ not leave side effects outside the transaction it cannot compensate. Releases
178
+ link them with `LessonAsset` under private asset access for personal-use and
179
+ answer-key material.
@@ -0,0 +1,79 @@
1
+ # Material generation proposals and educational review
2
+
3
+ `CourseGenerationReviewService` (SMRT #4516, extends `QuestionAuthoringService`,
4
+ consumed by Hearth #68) adds generation proposals and revision-bound review to the
5
+ course contract. Risk is high: schema and public contract. It uses the same
6
+ authorizer, tenancy checks and compare-and-swap save path. Every call receives
7
+ the transaction executor and `operation` (`request-generation`, `run-generation`,
8
+ `cancel-generation`, `accept-proposal`, `read-generation`, `record-review`,
9
+ `resolve-finding`, `note-context-change`, `read-review`). Foreign ids are not found.
10
+
11
+ ## Models (closed, required tenant, no REST/CLI/MCP/AI, append-only)
12
+
13
+ `CourseGenerationJob` (request plus retained input snapshot),
14
+ `CourseGenerationEvent` (status log: queued, running, succeeded, failed,
15
+ cancelled; provider, model, response model, nullable token/cost usage, error
16
+ code, outcome), `CourseProposal`, `CourseProposalDecision`, `CourseReview`,
17
+ `CourseReviewFinding`, `CourseReviewOutdatedMarker`, `CourseFindingResolution`.
18
+ `save()` on an existing row and `delete()` throw. Eight additive tables, test-pinned;
19
+ the runtime never creates tables. `CourseReview` has no score, rating or grade.
20
+
21
+ ## Generation
22
+
23
+ The host injects a `CourseMaterialGenerator` (`createProposalCourseGenerator`
24
+ adapts an ingestion `ProposalGenerator`); the service never selects a provider.
25
+ `requestGeneration` (action `education.course.generate`) records a job against the
26
+ head the caller saw; the request key is idempotent, a retry or duplicate returns
27
+ the existing job and proposal, and the same key with other input throws
28
+ `CourseRequestKeyConflictError`. `runGeneration` calls the generator outside any
29
+ transaction (deadline and output cap), then validates the untrusted output against
30
+ a closed schema. Invalid output, provider error, timeout, cancellation or a moved
31
+ head record `failed`/`cancelled` and leave the head unchanged; the input stays.
32
+ Personal-use lesson bodies and assets are never sent to the provider.
33
+
34
+ Success appends one new draft revision (`generate` plus `author`, provenance
35
+ `generated`) holding only additions and fills of empty fields. A proposal that
36
+ differs from supplied content is a `conflict` and is held in `CourseProposal`.
37
+ `getProposal` returns the per-field comparison (supplied, proposed, current,
38
+ decision). `decideProposal` accepts or rejects conflict fields: an accept appends
39
+ a revision on `expectedHeadRevisionId` and records the decision; supplied content
40
+ is replaced only that way. Never write generated content to a released revision or
41
+ assign it to a learner.
42
+
43
+ ## Review
44
+
45
+ `recordReview` (action `education.course.review`) binds to an exact revision and a
46
+ context digest (level target and theme from the revision; learner plus evidence
47
+ snapshot reference when given). Findings point at a course, lesson or question
48
+ revision id plus field/region, with concern, explanation, suggested change,
49
+ `uncertain`, `humanVerificationRequired` and `blocking`. Sections are separate:
50
+ `general-level` and `individual-learner` (needs a learner context). Every review
51
+ stores `COURSE_REVIEW_DISCLAIMER`; it is an aid, never certification.
52
+
53
+ A review is outdated when a new draft revision is saved (a persisted marker is
54
+ appended inside that save), when `noteContextChange` reports a new evidence
55
+ snapshot, or when the head differs or the digest no longer verifies (derived).
56
+ History, authorship (actor plus delegating guardian) and findings are kept.
57
+ `resolveFinding` records one resolution per finding; a delegated agent cannot
58
+ resolve a `humanVerificationRequired` finding.
59
+
60
+ ## Release gate and discard guard
61
+
62
+ `createReviewBackedReleaseGate()` (the service default) makes a revision releasable
63
+ only when its latest review is for that revision, is the draft head, has no marker,
64
+ a matching digest, and no unresolved blocking or human-verification finding on
65
+ any review of it or of any ancestor revision (a newer or empty review never hides
66
+ one). Nothing drops a finding automatically: not an edit, rename, copied question
67
+ or removal of its target. Only `resolveFinding` clears one (a guardian can, even
68
+ when the target is gone; a delegated agent cannot resolve human-verification
69
+ findings); otherwise `ReviewNotCurrentError.reason` is `no-review`, `outdated`,
70
+ `context-mismatch` or `unresolved-findings`. A plain `CourseAuthoringService`
71
+ without a gate still fails closed. `createCourseReferenceGuard(extra)` blocks
72
+ discard for review and generation rows; pass variant guards through `extra`.
73
+
74
+ ## Evaluation
75
+
76
+ `evaluateReviewer(cases, reviewer)` scores a supplied reviewer function against
77
+ seeded flaws and suitable material (precision, recall, false-alarm rate, per
78
+ category). Fixtures live in `src/__tests__/course-review-fixtures.ts`; no live model
79
+ is called by this package.
@@ -47,6 +47,27 @@ Same-tenant membership alone never grants sibling access.
47
47
  A learner cannot plan their own curriculum. Title is required.
48
48
  - `enroll` requires that guardian authority and a matching tenant curriculum.
49
49
  It returns the existing enrollment for the same curriculum/course unchanged.
50
+ A delegated agent may enroll, only when the host opts in: construct
51
+ `LearnerService(db, authorize, authorizeCatalog, { verifyDelegation })` where
52
+ `verifyDelegation(executor, { tenantId, actorUserId, delegatingGuardianUserId,
53
+ delegationId, learnerId, courseId? }) => boolean | { allowed, reason?, code? }` runs on the
54
+ current executor. Without the port, or if it denies or throws, `enroll` refuses with
55
+ `LearnerAuthorizationError` before the learner authorizer and before any write. Supply
56
+ `delegatingGuardianUserId` and `delegationId` (both or neither, 1-200 printable characters,
57
+ guardian not the agent). After the port, the service re-checks the DELEGATING guardian's
58
+ active membership and `guardianRelationshipId` (that guardian's profile to the learner, same
59
+ tenant) and the agent's active membership. The authorizer gets `education.learner.plan`,
60
+ `own: false`, `delegated: true`, `relationshipTypeId` undefined (so a legacy
61
+ `relationshipTypeId === guardian` rule denies), the guardian's type as
62
+ `delegatingRelationshipTypeId`, `delegatingGuardianUserId` and `delegationId`, and decides.
63
+ A new delegated enrollment appends one `EnrollmentAssignmentRecord` (`Enrollment` is
64
+ unchanged). Only `enroll` and `createCurriculum` accept a delegation (on `LearnerService` itself; subclasses without
65
+ the port refuse); every other operation ignores it, so the agent has no
66
+ other learner authority. Catalog reads still use the agent as actor.
67
+ `createCurriculum` accepts the same delegated actor (`operation: 'create-curriculum'`
68
+ on the verifier request, `enroll` sends `'enroll'`), with identical verification, guardian
69
+ relationship recheck, authorizer shape and stripping of caller authority keys; each new
70
+ delegated curriculum appends one `CurriculumCreationRecord` (own table, `Curriculum` unchanged).
50
71
  - `startLesson` allows the learner or authorized guardian to create their own
51
72
  enrollment's lesson progress in `in_progress`. It derives learner identity
52
73
  from the validated enrollment. Repeated starts return existing state without
@@ -54,6 +75,18 @@ Same-tenant membership alone never grants sibling access.
54
75
  - `listProgress` reads only the validated learner/enrollment and lessons still
55
76
  visible in that enrollment's course. Inconsistent persisted links fail
56
77
  closed or are omitted from the list.
78
+ - An enrollment with an `EnrollmentReleasePin` uses its effective lesson set
79
+ instead of `enrollment.courseId` alone (`resolveEnrollmentLessons`, shared
80
+ with `CourseAuthoringService.getEnrollmentRelease`, on the current executor):
81
+ lessons started (non-`not_started` status or attempts) stay on their original
82
+ immutable rows in any release the enrollment followed; the rest follow the
83
+ current pin, in that release's order. `nextLessons`, `assignmentLessons`,
84
+ `listProgress`, plan reads, `startLesson` and submission/workbook lesson
85
+ assertions all use it, so a lesson outside the set is rejected and progress
86
+ rows never move. Unpinned enrollments (or hosts without the authoring
87
+ tables) read their own course as before. Pins recorded for another learner
88
+ fail closed. `LessonProgress` accepts a lesson on any release course the
89
+ enrollment is pinned to; no data migration.
57
90
 
58
91
  Service results are data-only views, with no persistence or database methods.
59
92
  Extra request fields are never model patches: a learner cannot set score,
@@ -0,0 +1,100 @@
1
+ # Question and learning-objective records
2
+
3
+ `QuestionAuthoringService` (SMRT #4515, extends `CourseAuthoringService`, consumed
4
+ by Hearth #68) keeps authored questions and learning objectives. Risk is high:
5
+ schema and public contract. It reuses the course authorizer: writes need
6
+ `education.course.author` (plus `generate` for generated provenance), reads that
7
+ return answer keys need `education.course.read-draft`; every call receives the
8
+ transaction executor and the operation (`create-question`, `save-question`,
9
+ `link-objectives`, `create-objective`, `save-objective`, `read-question`,
10
+ `read-worksheet`, `resolve-question-revisions`). Tenant is required; foreign ids are not-found.
11
+
12
+ ## Models (closed, required tenant, no REST/CLI/MCP/AI, append-only)
13
+
14
+ `Question` (identity, optional authored `courseId`), `QuestionRevision`,
15
+ `LearningObjective` (identity with tenant-unique stable `externalKey`, for
16
+ coordination with hearthed.org#35), `LearningObjectiveRevision`,
17
+ `QuestionResponse`. `save()` on an existing row and `delete()` throw. There is no
18
+ head row: the head is the highest `sequence`, and writes take an expected-head
19
+ token under a row lock and a unique `(identity, sequence)` index.
20
+
21
+ A revision holds prompt, response type, choices, subparts, media (tenant assets),
22
+ objective identity ids, concept ids, answer key, rubric and points. A missing key
23
+ or rubric is stored NULL and read back `null`; an empty key is invalid and is never
24
+ inferred. Provenance (`supplied|inferred|generated|edited|extracted`) is recorded
25
+ for each present field, defaulting to the revision's; provenance for an absent
26
+ field is refused. Objectives are linked by identity id, so wording changes, variants
27
+ and themes keep the same objective.
28
+
29
+ ## Writes
30
+
31
+ `createQuestion`, `saveQuestionRevision`, `linkObjectives`,
32
+ `createLearningObjective`, `saveLearningObjectiveRevision`. Every write needs a
33
+ request key; a retry replays its original result and the same key with another
34
+ payload throws `CourseRequestKeyConflictError`. A stale head throws
35
+ `CourseStaleHeadError`; a reused objective key throws
36
+ `LearningObjectiveKeyConflictError`. Errors keep stable `code`s.
37
+
38
+ `resolveQuestionRevisions({ revisionIds })` (`read-draft`, no course; the executor and delegation fields
39
+ are passed) returns `{ items, missing }`: per found id `revisionId`, `questionId`, `sequence`,
40
+ `responseType`, `promptSummary` (plain text, whitespace collapsed, at most 200 characters) and, when
41
+ linked, `learningObjectiveKeys`. Never keys, rubrics, choices, media or provenance. 1-200 ids (else
42
+ validation error), de-duplicated, request order. An id with no revision in this tenant, foreign or
43
+ unknown alike, appears only in `missing`.
44
+
45
+ ## Lessons, attempts, grades
46
+
47
+ A lesson input may carry ordered `questions` (revision id, section, column, source
48
+ page/rect anchor) and flat `layoutParams`; they are part of the lesson revision
49
+ digest, and saving requires each id to be an existing question revision of the
50
+ tenant (`QuestionReferenceError`). Newer question revisions never move a pin.
51
+
52
+ `WorkbookQuestion.questionRevisionId`/`lessonRevisionId` and the nullable
53
+ `Grade.questionRevisionId` are additive. A referenced revision must exist in the
54
+ tenant; `Grade.rubricResults` must then fit its rubric (criterion id, number or
55
+ `{points}` up to `maxPoints`; empty stays unscored; results need a rubric).
56
+ Unreferenced legacy records are unchanged. `QuestionResponse` is an append-only
57
+ learner response to one exact revision with completion mode
58
+ (`printable|online|interactive`), assistance, a correction chain, `episodeId` and
59
+ the `worksheetVersionId` seen, a numbered `attemptNumber` and the writing request's
60
+ `payloadDigest`. Writes go through `QuestionResponseService` below.
61
+
62
+ ## Responses (`QuestionResponseService`)
63
+
64
+ Extends `LearnerService`, so it uses the learner authorizer, active-actor/tenant
65
+ checks and executor-bound policy reads. New authorizer actions:
66
+ `education.learner.respond` (the learner's own enrollment; a guardian never starts
67
+ an attempt) and `education.learner.review-response` (guardian only; a learner
68
+ claiming it is refused before the host is asked).
69
+
70
+ - `recordResponse`: online attempt (new episode, next `attemptNumber`) or, with
71
+ `correctsResponseId`, the learner's correction in the same episode.
72
+ - `recordGuardianCorrection`: names the response it corrects; `response` omitted
73
+ records assistance only and keeps the answer. Both append; nothing is rewritten.
74
+ - `readLessonResponses`: the audience follows the actor. The learner gets the
75
+ keyless bundle (keys never leave the database call), the guardian the keyed one.
76
+
77
+ The question revision must be in the enrollment's effective lesson set
78
+ (`resolveEnrollmentLessons`, then the lesson revision's `questions`); otherwise
79
+ `QuestionResponseScopeError`. Unpinned enrollments have no authored questions.
80
+ The response is a closed shape (`text`, `numeric`, `choiceIds`, `parts`) checked
81
+ against the revision's choices and subparts (`QuestionResponseValidationError`).
82
+ Every write needs a request key (unique per learner); a replay returns the stored
83
+ row, another payload throws `CourseRequestKeyConflictError`. Other errors:
84
+ `QuestionResponseNotFoundError`, `LearnerAuthorizationError`.
85
+
86
+ ## Rendering
87
+
88
+ `getWorksheet` (or the pure `buildLearnerWorksheet`/`buildGuardianWorksheet`)
89
+ builds one bundle from a release lesson's pinned revisions. `worksheetVersionId` is
90
+ a digest of ordered revision ids, placement and layout params, so printable and
91
+ online renders of the same revision match and a scan can be matched later. The
92
+ learner bundle is built from an allowlist and has no key, rubric or provenance
93
+ type members; only the guardian bundle carries them. Both service audiences need
94
+ `read-draft`; learner delivery belongs to the host.
95
+
96
+ ## Migration
97
+
98
+ Schema ships through normal manifest migrations: five new tables (`question_responses` included) plus nullable
99
+ columns on `grades` (`question_revision_id`) and `lesson_revisions` (`questions_json`,
100
+ `layout_params_json`); test-pinned as additive. The runtime never creates tables.