Appearance
Analytics Rebuildable Projection Proof
Status: bounded proof accepted, not service or rollout completion.
This page records the SVC-022 proof boundary for analytics-service. Analytics owns copied result/progress/mastery read models and weak-topic rollups. It does not own canonical attempt, exam, course, question, classroom, profile, or notification state, and it does not join those databases during a rebuild.
Event Sources
The current producer contracts provide versioned source events with a stable sourceService plus sourceEventId pair:
| Source | Event examples | Projection input boundary |
|---|---|---|
attempt-service | attempt.submitted.v1, attempt.graded.v1 | attempt.graded.v1 carries a versioned attempt/student/exam/result/question snapshot. The analytics materializer consumes it directly when the copied snapshot is complete. |
exam-service | exam.published.v1, exam.assigned.v1, exam.results_released.v1 | Exam and assignment display snapshots plus release metadata; canonical exam writes stay in exam-service. |
course-service | course.learning_catalog.changed.v1, course.enrollment.granted.v1, course.lesson_progress.saved.v1, course.video_progress.saved.v1, course.material_view.saved.v1 | Course catalog, lesson, material, and progress snapshots; canonical Course state stays in course-service. |
classroom-service | classroom.membership.snapshot.v1 | Organization/classroom membership references and counts; membership authority stays in IAM/classroom boundaries. |
Analytics event ingestion stores the source pair and payload for backfill selection. Re-sending the same source pair returns the original event without mutating its type or payload. The event ledger is an input stream, not a claim that a worker has already rebuilt every projection.
Attempt Result Materializer (2026-07-14)
POST /internal/v1/analytics/rebuild/attempt-results consumes stored, unmaterialized attempt-service attempt.graded.v1 events for one organization at a time. It validates schema version 1, the attempt/entity identity, GRADED status, copied student full name, exam snapshot, score, counts, and submitted timestamp. It derives weak topics and source-question difficulties only from that copied payload.
The worker writes an attempt-result-v1 projection receipt and result row in one transaction. Replay sees the receipt and does not increase result, summary, or weak-topic counts. Bad or incomplete source events write a failed rebuild report and no partial result. Batches are ordered by (occurred_at, event_id) and return the final cursor. The endpoint is internal-only: it is absent from all Gateway route tables and does not change the default legacy analytics routes.
attempt-service now persists student_full_name in its attempt snapshot and copies it to attempt.graded.v1, so replayed analytics events do not require a profile-service lookup. Existing events without the field are intentionally reported as incomplete rather than being filled from another service database.
Course Learning Materializer (2026-07-15)
POST /internal/v1/analytics/rebuild/course-learning consumes only stored course.learning_catalog.changed.v1, course.lesson_progress.saved.v1, and attempt.graded.v2 events for one organization. It validates a catalog revision and its lesson/quiz placements, records the newest progress snapshot per student and lesson, and accepts a graded attempt only when the copied Course context matches that event's student and organization and names course-service as validator. Generic attempt.graded.v1 events cannot make course mastery rows.
000007_course_learning_projection.sql stores the copied catalog, progress, trusted attempt facts, and local mastery rows. A course-learning-v1 receipt is written atomically with each accepted input. The materializer computes 0.5 * correctRate + 0.3 * latestQuizScore + 0.2 * lessonCompletion and the legacy threshold action from those local tables alone. A newer catalog revision replaces mappings deterministically, so a removed lesson/quiz mapping removes the derived mastery rows without changing generic result projections.
GET /internal/v1/analytics/courses/{courseId}/masteries is an internal local read. It forces STUDENT to self, scopes TEACHER/ADMIN by organization, and denies PARENT; a Course/Gateway adapter must still revalidate current enrollment or teacher visibility before a browser sees any response. Neither endpoint occurs in a Gateway route table.
Course's learning-catalog endpoint remains an owner replay/backfill feed. The optional Course-owned 000006 delivery outbox can copy the same immutable snapshot to analytics_events after its source transaction commits. Its worker is disabled unless the dedicated Course delivery environment is configured, uses Course-local lease/retry/dead-letter state, and is outside mutation request latency. 000008 then enqueues each accepted catalog/progress/v2 input in an Analytics-local outbox. When its dedicated worker is enabled, Analytics lease-claims, materializes, retries, or dead-letters that copied input without polling Course. Neither worker adds a public/default Gateway route.
COURSE_SEARCH_PROJECTION_OUTBOX_POSTGRES_CONFIRM=disposable-postgres make test-course-learning-catalog-delivery-postgres is the local persistence proof for owner event -> outbox -> delivery lease/retry/delivered/dead-letter state. It does not call a live Analytics endpoint and is not browser, Gateway, default-route, or production evidence. The Analytics disposable proof also verifies the local projection outbox trigger plus lease/retry/dead-letter state; it does not prove a deployed worker configuration.
Full Chain Isolated Runtime (2026-07-15)
COURSE_LEARNING_ANALYTICS_RUNTIME_CONFIRM=local-postgres make test-course-learning-analytics-runtime builds native Course, Attempt, and Analytics binaries, then runs them with a temporary Gateway and a test-owned Exam runtime fixture. It creates a published Course, topic-linked lesson and quiz reference, grants the student enrollment, starts an attempt through the temporary Gateway route, saves a correct answer, and submits it as STUDENT.
The fresh initdb cluster has separate owner databases only. The latest bundle is output/qa/course-learning-analytics-runtime-20260715-033520/: Course catalog delivery is 3:3:0, Course progress delivery is 1:1:0, Analytics ingests exactly one attempt.graded.v2, its corresponding projection delivery is 1:1:0, and the local mastery is totalAttempts:1/correctRate:100/latestQuizScore:100/lessonCompletion:100. The runner also verifies a replayed submit does not create a second attempt and that a trusted Course context for a different student is rejected with ATTEMPT_FORBIDDEN. Fixture request artifacts retain the forwarded actor and route context but redact both Authorization and X-Internal-Token values.
This is an isolated local runtime proof, not browser, deployed-worker, default-route, or production evidence. The route table exists only inside the artifact directory, and the runner removes its native processes and PostgreSQL cluster on exit.
Rebuild Workflow
The bounded workflow is:
- The internal worker reads unmaterialized
attempt.graded.v1events in cursor order for exactly one organization. - It validates and materializes the copied producer payload without an owner database join.
- It stores the result and projection receipt atomically, then records a rebuild report with input, projected, skipped, and failed counters.
Focused service and HTTP tests exercise source event ingestion, materialization, cursor output, replay receipts, bad-schema rejection, mixed-organization isolation, result/summary reads, and persisted rebuild counters. The automated PostgreSQL test exercises the same HTTP contract with the real repository and migrations. The Course-learning polling worker is now an opt-in durable Analytics-local outbox consumer; automatic generic attempt-result consumers remain separate follow-up work.
Route Guard Evidence
make test-analytics-routes passed for the default and both non-default route tables. The default table keeps /api/analytics*, /api/exams*, /api/classrooms*, /api/students*, /api/parents*, and fallback traffic on legacy routing. Non-default rehearsal promotes only exact GET /api/analytics/results and GET /api/analytics/weak-topics, with gateway auth, ADMIN/TEACHER role checks, and organization scope.
Rollback
Keep deploy/gateway/routes.json as the active route table. Remove the non-default analytics route examples or disable native analytics callers if projection freshness, response parity, access adapters, or runtime/browser proof regresses. Do not promote the default analytics route family from this proof alone.
Verification
bash
GOTOOLCHAIN=go1.25.11 go test ./services/analytics-service/... -count=1
make test-analytics-routes
ANALYTICS_POSTGRES_REBUILD_CONFIRM=disposable-postgres make test-analytics-postgres-rebuild
COURSE_LEARNING_ANALYTICS_RUNTIME_CONFIRM=local-postgres make test-course-learning-analytics-runtime
GOTOOLCHAIN=go1.25.11 go test ./services/attempt-service/... ./services/course-service/... ./services/exam-service/... ./services/classroom-service/... -count=1The focused Go commands pass for this materializer slice. No legacy file, shared database, default gateway route, or production data was changed.
Isolated Runtime Recheck (2026-07-13)
The disposable hoctapaz-analytics-proof-20260713 Compose project applied analytics migrations only to its own PostgreSQL database. A teacher-scoped request then recorded one versioned attempt source event, replayed it with a changed payload while retaining the original event, upserted one explicit hydrated result snapshot, and recorded one completed rebuild report. Replaying that report retained the original counters and status. A query for a different organization returned ANALYTICS_FORBIDDEN.
The non-default deploy/gateway/routes.analytics-read-native-localhost-example.json routed exact result and weak-topic reads with X-Gateway-Route-State: native_read. Starting the Gateway with the active deploy/gateway/routes.json instead returned X-Gateway-Route-State: legacy_proxy for the same result read; the expected 502 used an intentionally unavailable legacy target. Captures are in output/qa/analytics-rebuildable-projection-proof-20260713/. The temporary Compose project, volumes, and Gateway process were removed after capture. This does not add browser proof or default-route promotion.
Internal Materializer Runtime Recheck (2026-07-14)
A separate disposable PostgreSQL 16 container, hoctapaz-analytics-materializer-proof-20260714, received only the analytics-service migrations through 000006_analytics_projection_receipts. The local analytics-service proof process accepted one stored attempt-service attempt.graded.v1 event and the internal materializer created exactly one result (Nguyen An, score 8.5, weak topic Ham so), one attempt-result-v1 receipt, and one completed report with inputEvents=1 and projectedResults=1. A second internal rebuild returned inputEvents=0 and projectedResults=0; the database still contained one result and one receipt. The proof process and disposable database container were removed after verification. This is isolated service/database evidence only: it does not add browser, Gateway, default-route, or production proof.
Automated Disposable PostgreSQL Rebuild Test (2026-07-14)
ANALYTICS_POSTGRES_REBUILD_CONFIRM=disposable-postgres make test-analytics-postgres-rebuild starts one named PostgreSQL 16 container, creates a random isolated schema, and applies only analytics migrations. Its HTTP test ingests a valid attempt.graded.v1 event, confirms one projected result, one attempt-result-v1 receipt, and the expected weak-topic row, then rebuilds again without additional rows. It next ingests schema version 2 and confirms a failed report with no partial result or receipt. The same run also ingests a Course catalog, two progress snapshots, a trusted v2 attempt, and a different-organization v2 attempt; it confirms two local masteries, four course-learning-v1 receipts, replay with no added rows, and organization isolation. The runner removes the container on exit and never reads a configured analytics service DSN. This is reproducible local owner-storage evidence only; it does not prove browser, Gateway, default-route, async worker, or production behavior.
The latest local bundle is output/qa/analytics-postgres-rebuild-20260715-033318/. Its summary.json records the disposable analytics-only database boundary and successful TestPostgresHTTPAttemptResultRebuildReplayWorkflow plus TestPostgresHTTPCourseLearningReplayWorkflow; go-test.log captures the same passing run. The named PostgreSQL container was absent after the runner's EXIT trap completed.