Skip to content

Attempt Result-Release Notification Proof

Status: Hermetic durable-outbox workflow, four-service runtime, and parent-web candidate browser proof for delayed result release in SVC-020. It is not default Gateway, delivery-provider, live Postgres, or live profile-data proof.

Scope

The first exam-service result release writes authenticated internal exam.results_released.v1 relay state into exam_outbox_events in the same transaction as resultsReleasedAt. Its worker retries the stable source event id until attempt-service accepts it. attempt-service owns the copied result-policy snapshot and returns only the attempt ids whose exam_results_released_at was newly set. The same owner transaction inserts PENDING rows into attempt_notification_outbox only for those updated attempts. notification-service remains the inbox, preference, and replay owner. The runtime proof uses Profile only as the recipient-resolution owner; it does not copy parent-student data into Attempt or Notification.

For each returned id, attempt-service captures a compact event payload only when the attempt is submitted, graded, and was hidden under MANUAL or pre-close AFTER_CLOSE before release. The dispatcher later publishes due rows. Parent recipients are resolved during dispatch, so profile latency is not on the release write. Replays return no changed ids and create no further row.

Student events use {attemptId}:notification:grade_released; parent events use {attemptId}:notification:grade_released_parent. Parent recipient ids come from profile-service and are deduplicated. The exam release source event id is kept as notification payload provenance.

Verification

bash
ATTEMPT_RESULT_RELEASE_NOTIFICATION_SELF_TEST=1 \
  make test-attempt-result-release-notification-workflow

GOTOOLCHAIN=go1.25.11 \
  go test -p 2 ./services/attempt-service/... ./services/notification-service/... -count=1

make test-notification-routes test-parent-alert-routes test-attempt-routes

EXAM_RESULT_RELEASE_POSTGRES_CONFIRM=disposable-postgres \
  make test-exam-result-release-postgres

make test-exam-result-release-deploy

EXAM_RESULT_RELEASE_RUNTIME_CONFIRM=exam-result-release-isolated \
  make test-exam-result-release-runtime

EXAM_RESULT_RELEASE_RUNTIME_CONFIRM=exam-result-release-isolated \
  make test-exam-result-release-parent-browser

The focused workflow covers hidden MANUAL and pre-close AFTER_CLOSE attempts, student plus deduplicated parent events, provenance, replay suppression, an atomic attempt-notification pending row, retry backoff, and eventual successful delivery after a publisher failure without undoing visibility. Exam-service usecase coverage separately verifies the durable first-hop relay: release visibility and its pending record commit together, an attempt-service failure backs off, and a later retry delivers the same source event id. The tests use memory stores and fake publishers, do not write a shared database, and do not call a provider.

Route And Rollback State

The receiver is internal POST /v1/events/exam-results-released. Public attempt, notification, and parent-alert routes remain default legacy-proxied in deploy/gateway/routes.json; existing non-default route rehearsals are separate.

To stop the first hop without reversing visibility, unset ATTEMPT_SERVICE_URL; the pending relay remains in exam-service until a publisher is configured again. To stop notification delivery without reversing visibility, unset NOTIFICATION_SERVICE_URL/NOTIFICATION_SERVICE_BASE_URL; existing pending rows remain in attempt-service until a publisher is configured again. Unset PROFILE_SERVICE_URL/PROFILE_SERVICE_BASE_URL only when parent delivery is intentionally paused. Keep the default route table.

test-exam-result-release-postgres starts a distinct Docker PostgreSQL container with a random localhost port. The test creates and drops a unique schema, applies every exam-service migration there, proves a JSON outbox failure rolls back resultsReleasedAt, then proves pending/retry/delivered state survives fresh database pools. It does not read a configured service DSN or mutate an existing container.

test-exam-result-release-deploy guards the Compose, Helm, K8s, service example environment, worker startup, and authenticated internal client wiring. For standalone local service runs, set EXAM_INTERNAL_SERVICE_TOKEN to the same value as attempt-service's ATTEMPT_INTERNAL_SERVICE_TOKEN (or both services' INTERNAL_SERVICE_TOKEN).

test-exam-result-release-runtime starts a separate Compose project with exam-service, attempt-service, profile-service, notification-service, and fresh owner database volumes. It seeds one student/parent relationship only in the disposable Profile database, creates and publishes a MANUAL exam over the exam API, starts and submits a hidden attempt from an attempt-owned runtime snapshot, then releases through exam-service. Its start request keeps the Attempt trust boundary enabled with X-Internal-Service: api-gateway, the matching isolated internal token, and X-Exam-Runtime-Decision-Source: exam-service.

The proof requires the authenticated Exam to Attempt relay before the result becomes visible. It then requires Attempt to resolve the parent through Profile and publish exactly one GRADE_RELEASED student event plus one GRADE_RELEASED_PARENT parent event to Notification. It checks two Notification source events and two inbox rows, asserts no retry was needed, and verifies release replay creates neither another relay row nor duplicate Notification rows. Exam, Attempt, and Notification bind only random loopback ports; Profile stays internal to the disposable Compose network. The runner removes the project and volumes by default and writes output/qa/exam-result-release-notification-runtime-*/summary.json.

test-exam-result-release-parent-browser uses that same disposable source event and starts an additional temporary Gateway plus parent-web Vite process. It rewrites only a temporary copy of the non-default parent-alert route table to the disposable Notification port. A signed PARENT token loads the actual GRADE_RELEASED_PARENT row at /parent/alerts?parentAlertsGateway=native, then marks it read through POST /api/parent/alerts/{notificationId}/read. The browser captures route headers, request evidence, response payloads, a screenshot, and latency in the runtime artifact. It does not alter deploy/gateway/routes.json.

Remaining Proof Gates

  • Default public attempt, notification, and parent-alert Gateway routes remain legacy-proxied; this does not promote any route.
  • No delivery provider is configured or called.
  • Profile records are disposable fixtures, not shared or live profile-data validation.

Go-platform documentation is generated from repository Markdown.