Skip to content

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:

SourceEvent examplesProjection input boundary
attempt-serviceattempt.submitted.v1, attempt.graded.v1attempt.graded.v1 carries a versioned attempt/student/exam/result/question snapshot. The analytics materializer consumes it directly when the copied snapshot is complete.
exam-serviceexam.published.v1, exam.assigned.v1, exam.results_released.v1Exam and assignment display snapshots plus release metadata; canonical exam writes stay in exam-service.
course-servicecourse.learning_catalog.changed.v1, course.enrollment.granted.v1, course.lesson_progress.saved.v1, course.video_progress.saved.v1, course.material_view.saved.v1Course catalog, lesson, material, and progress snapshots; canonical Course state stays in course-service.
classroom-serviceclassroom.membership.snapshot.v1Organization/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:

  1. The internal worker reads unmaterialized attempt.graded.v1 events in cursor order for exactly one organization.
  2. It validates and materializes the copied producer payload without an owner database join.
  3. 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=1

The 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.

Go-platform documentation is generated from repository Markdown.