Appearance
Course Learning Projection Contract
Agent workflow: follow README.md for Audit -> Investigate -> Code -> Test -> Fix. This is an orchestrator-owned cross-service task, not a reason to add a second source of truth to course-service.
Status: Course catalog producer, opt-in durable Course-to-Analytics delivery, trusted Attempt v2 producer, and Analytics materializer phases are implemented locally. 000005 creates the replayable Course catalog feed, 000006 adds a Course-owned lease/retry/dead-letter outbox, and 000008 adds an Analytics lease/retry/dead-letter projection outbox; Course's internal decision validates active enrollment, published topic-bound quiz placement, and matching Exam id; 000010 persists the copied decision on Attempt and emits attempt.graded.v2; 000007 stores Analytics-local catalog/progress/facts/masteries plus receipt-backed replay. Public reads and any configured non-default Gateway/BFF candidate remain pending. No public route, default-route promotion, or database join is approved by this task.
Dispatch type: orchestrator
Owner: course-service, attempt-service, and analytics-service coordination.
Legacy source evidence:
/Users/velikho/Desktop/WORKING/HOCTAPAZ/node-platform/apps/api/src/modules/app-data/app-data.courses-assessment.ts:272-710
Writable files:
docs/agents/service-tasks/analytics-course-learning-projection.mddocs/agents/service-tasks/course-service.mddocs/agents/service-tasks/attempt-service.mddocs/agents/service-tasks/analytics-service.md- Delegated owner-service code, contracts, isolated test runners, and QA docs named by an accepted implementation handoff.
Orchestrator-owned files:
- Cross-service Course/Attempt/Analytics contracts and handoffs.
- Gateway/BFF adapters, default route tables, and promotion/rollback decisions.
- Shared deploy manifests and frontend candidate activation.
Outcome
Replace the legacy course mastery calculation with a replayable analytics-service read model. The eventual projection may serve a student-authorized mastery list, lesson recommendation, and teacher report, but only from copied owner snapshots. course-service remains canonical for courses, enrollments, published lessons, and progress; attempt-service remains canonical for attempts and grading; question/exam services remain canonical for question, topic, and exam state.
Evidence
Read before implementation:
docs/agents/service-tasks/analytics-service.mddocs/agents/service-tasks/course-service.mddocs/api/analytics-service.mddocs/api/course-service.mddocs/qa/analytics-rebuildable-projection-proof.md/Users/velikho/Desktop/WORKING/HOCTAPAZ/node-platform/apps/api/src/modules/app-data/app-data.courses-assessment.ts:272-710services/course-service/internal/usecase/analytics_events.goservices/attempt-service/internal/usecase/analytics_events.goservices/analytics-service/internal/usecase/attempt_result_projection.go
The orchestrator owns cross-service contracts, gateway/BFF adapters, and this task pack. The scoped implementation owners are:
| Owner | Writable scope after delegation | Must not own |
|---|---|---|
course-service | course catalog/context producer and course-local access validation | analytics projection tables or attempt grading |
attempt-service | durable, trusted course context on an attempt and copied graded event payload | course enrollment/ref validation by direct database access |
analytics-service | event consumers, replay receipts, projection tables, internal read models | canonical course/attempt/question/exam data |
| Gateway/BFF | authenticated composition and response visibility | a shadow mastery database or direct service database access |
Why A New Contract Is Required
The legacy calculation joins CourseLesson, CourseLessonQuestion, CourseQuiz, exam/question rows, attempt answers, profiles, and StudentMastery in one database. Its compatibility formula is:
text
masteryScore = 0.5 * correctRate + 0.3 * latestQuizScore + 0.2 * lessonCompletionAll values are percentage values in the inclusive 0..100 range. A native projection must make the same calculation only from copied events.
Existing course.lesson_progress.saved.v1 already carries a bounded course, lesson, and topic snapshot. Existing attempt.graded.v1 carries an attempt, student, exam, result, and question-topic snapshots. It does not prove that the graded attempt came from one particular course. Mapping an attempt back to course_quiz_refs only by examId is prohibited: an exam may be reused in more than one course or non-course assignment.
Required Producer Contracts
1. Course learning catalog
Add a versioned, replayable Course producer snapshot before analytics computes lesson completion or a recommendation:
json
{
"schemaVersion": 1,
"courseId": "course_123",
"organizationId": "org_123",
"catalogRevision": 42,
"publishedLessons": [
{ "lessonId": "lesson_1", "topicId": "topic_linear", "orderIndex": 1 }
],
"quizContexts": [
{ "courseQuizReferenceId": "course_quiz_ref_1", "examId": "exam_1", "lessonId": "lesson_1", "orderIndex": 1 }
]
}The producer event must identify its immutable revision in sourceEventId and cover publish, unpublish, lesson topic/order changes, quiz placement changes, and an internal backfill. It contains ids, ordering, and bounded display data only: no lesson content, answer keys, question body, media/storage URLs, profile fields, or payment data.
course.lesson_progress.saved.v1 remains the source of per-student lesson progress. The materializer must treat its timestamped source ids as distinct updates and retain only the newest progress state for each (organizationId, courseId, studentId, lessonId).
2. Trusted course-attempt context
Before a learner starts a course quiz, course-service must validate the active enrollment and local quiz placement, then provide a trusted internal context to the exam/attempt start adapter. The browser must never choose or assert this context itself.
attempt-service must persist the following copied snapshot at attempt start and include it in a new attempt.graded.v2 payload:
json
{
"courseContext": {
"courseId": "course_123",
"courseQuizReferenceId": "course_quiz_ref_1",
"lessonId": "lesson_1",
"studentId": "student_123",
"organizationId": "org_123",
"validatedBy": "course-service",
"validatedAt": "2026-07-15T00:00:00Z"
}
}The start adapter must reject a context whose student, organization, course quiz reference, or exam does not match the trusted owner decision. The analytics consumer must reject or skip a malformed context. Version 1 graded events continue to materialize the existing generic result projection but do not create course mastery rows.
Analytics Materializer And Reads
analytics-service consumes the two copied contracts into its own local catalog, progress, mastery, and receipt rows. The operation is scoped by organization and is replay-safe by the producer sourceService plus sourceEventId pair. It must not call or query course, attempt, exam, question-bank, profile, or legacy databases during materialization.
For each (organizationId, courseId, studentId, topicId), derive:
correctRatefrom course-context graded question results for that topic.latestQuizScorefrom the latest submitted course-context graded attempt containing that topic.lessonCompletionfrom the current progress rows divided by published catalog lessons for that topic.masteryScorewith the legacy formula above, preserving deterministic rounding only at API presentation.- a recommendation action using the existing
FOUNDATION,REVIEW, orPRACTICEthreshold policy.
Analytics may return only an opaque recommended courseId, lessonId, and topicId. A Gateway/BFF adapter must re-authorize the student and obtain the currently visible lesson from course-service; it must not trust a stale projection to authorize access or expose an exam/question payload.
The teacher report is a separate analytics read model. It may expose local student ids, progress/mastery aggregates, and weak-topic ids to an authorized adapter. Profile display names, parent links, and exports require their own profile/access adapter and are not copied into this projection by default.
Implementation Order
- [x] Add Course catalog snapshot/backfill events and focused producer tests.
000005_course_learning_catalog_events.sqlowns its revision/event tables independently of Search, andGET /v1/courses/learning-catalog-eventsis an ADMIN-only pull feed. The PostgreSQL proof covers transaction coalescing, publish/unpublish tombstones, filtered lesson/quiz placements, and cursor pagination. Topicless published lessons and course-level quizzes are omitted because they cannot be safely attributed to a mastery topic. - [x] Add trusted course-attempt context to the exam/attempt start decision, persisted attempt schema, and
attempt.graded.v2producer tests.POST /v1/internal/courses/{courseId}/quiz-links/{referenceId}/attempt-contextrequires authenticated Gateway token plus student scope and validates the Course owner facts without touching Exam/Attempt databases. The Gateway adapter support treats browsercourseLaunchids only as routing hints, strips them before asking Exam for runtime access, then inserts the Course response into the private Attempt start input. Attempt rejects missing owner markers, incomplete context, mismatched student/organization/exam, or a validator other thancourse-service, stores the snapshot in000010, and emits v2 only for context-bound attempts. The isolatedtest-course-attempt-context-runtimeproof runs native Course, Attempt, and Gateway binaries against fresh Course/Attempt databases. It proves the non-default adapter handoff and persisted snapshot with a test-owned Exam runtime-access contract fixture. It does not prove Exam authoring/runtime persistence and does not add a default-route or deploy candidate. - [x] Add analytics migrations, v2 parser, replay receipt, materializer, and isolated PostgreSQL integration test.
000007stores only copied Course catalog/current progress/trusted v2 facts;course-learning-v1receipts make every event replay-safe. The materializer rebuilds the legacy formula and thresholds locally, skips generic v1 attempts, and removes stale rows when a newer catalog revision removes their mapping. The disposable proof covers catalog, progress, v2 grading, replay, and organization isolation.000006_course_learning_catalog_outbox.sqlalso supplies an opt-in, Course-owned transport adapter: after the owner event commits, its worker lease-claims the immutable snapshot and posts the normal source pair to Analytics. The worker is disabled until its dedicated delivery environment is configured, keeps mutation requests synchronous only with Course, and retries or dead-letters in Course storage.000008then enqueues the copied catalog/progress/v2 event in Analytics itself. Its separately opt-in worker materializes local rows with its own lease/retry/dead-letter state; it never polls Course and remains outside both Course mutation and Analytics ingestion request latency.COURSE_LEARNING_ANALYTICS_RUNTIME_CONFIRM=local-postgres make test-course-learning-analytics-runtimeadditionally proves the full local owner path: Course catalog/progress outboxes, a Gateway-resolved trusted Course context, Attemptattempt.graded.v2, Analytics outbox delivery, and the final mastery read. It uses a fresh three-database PostgreSQL cluster and a temporary candidate Gateway table only; it is not browser, deployed, or default-route evidence. - [x] Add internal analytics reads with
STUDENTself-only andTEACHER/ADMINorganization-scoped access adapters. Keep them absent from default Gateway tables.GET /internal/v1/analytics/courses/{courseId}/masteriesreturns only local rows, forces a student caller to their own id, denies parents, and scopes teacher/admin reads by organization. A Course/Gateway adapter must still validate active enrollment or current teacher visibility before any browser response; no Gateway table references this endpoint. - Add a non-default Gateway/BFF candidate only after owner-source response parity, browser proof, latency capture, and rollback rehearsal exist.
Acceptance:
- Replaying every catalog, progress, and v2 graded event changes no projection count or aggregate after the first materialization.
- An attempt without a valid course context never affects a course mastery row, even if its
examIdappears in a course quiz reference. - Cross-organization data and mismatched student/course context are rejected.
- Catalog revision changes remove stale topic/lesson mappings deterministically without deleting generic attempt results.
- Compatibility fixtures cover the formula, latest graded attempt ordering, zero-attempt/zero-progress values, threshold actions, and course-exam reuse.
Verification:
GOTOOLCHAIN=go1.25.11 go test ./services/course-service/... -count=1GOTOOLCHAIN=go1.25.11 go test ./services/attempt-service/... -count=1GOTOOLCHAIN=go1.25.11 go test ./services/analytics-service/... -count=1COURSE_LEARNING_ANALYTICS_RUNTIME_CONFIRM=local-postgres make test-course-learning-analytics-runtime- An additional disposable-PostgreSQL workflow proves the catalog, progress, v2 grading, idempotent replay, and cross-organization isolation together.
make test-analytics-routes,make test-student-course-workflow-routes,pnpm docs:build, andgit diff --checkpass before handoff.
Explicit Non-Goals
- No direct database joins, legacy writes, or legacy database test data.
- No default-route promotion or public mastery/recommendation endpoint.
- No wallet purchase or paid-access decision in the projection.
- No assumption that the current
attempt.graded.v1events are backfillable into course mastery.