Skip to content

feat: add launch and progress projections - #7

Open
seonghobae wants to merge 3 commits into
feat/learner-affiliationfrom
feat/progress-projection
Open

feat: add launch and progress projections#7
seonghobae wants to merge 3 commits into
feat/learner-affiliationfrom
feat/progress-projection

Conversation

@seonghobae

@seonghobae seonghobae commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add tenant-scoped learning attempts tied to registrations and immutable content-release references
  • add LRS-owned progress projections that retain only opaque source metadata and bounded progress values
  • make repeated newer/equal observations idempotent and reject out-of-order observations
  • extend PostgreSQL composite keys/RLS and the real API smoke workflow
  • reconcile the current PR6 base and extend rollback coverage to attempt/progress tables

Evidence

  • exact current head 00269c3fadba66596e696f544fcbd5b78540c54d
  • base is current PR6 head 4cfb77f5403cba5fd53c37a794da1d2695ecf999
  • actionlint .github/workflows/quality.yml
  • cargo +1.97.1 fmt --all -- --check
  • cargo +1.97.1 test --workspace --all-targets --locked
  • cargo +1.97.1 clippy --workspace --all-targets --locked -- -D warnings
  • RUSTDOCFLAGS='-D warnings' cargo +1.97.1 doc --workspace --no-deps --locked
  • real PostgreSQL/API smoke: learner/attempt/progress 201, older progress observation 400, persisted progress row 1
  • disposable rollback/reapply: all 16 tables removed, then 14 RLS policies reapplied
  • git diff --check

Scope boundary

This is the launch/progress slice of issue #3. LRS client contracts, assessment, completion execution, credentials, browser E2E, observability, hot partitions, and production readiness remain follow-up work.

Review request

Please review the exact current head 00269c3fadba66596e696f544fcbd5b78540c54d against 4cfb77f5403cba5fd53c37a794da1d2695ecf999. Do not treat local or green CI checks as semantic approval.


Open in Devin Review

@seonghobae

Copy link
Copy Markdown
Contributor Author

Please perform an independent review of the exact current head 1ed9e1aa98987e05b1443723a3aea4ee90fba89e against base feat/learner-affiliation. Re-check registration-to-attempt ownership, composite tenant/learner foreign keys, source-payload exclusion, progress idempotency and out-of-order rejection, bounded values, migration/RLS, and CI smoke coverage. Do not treat green checks as semantic approval.

@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b305ed8c-a181-4248-a356-42d1c7f5ea29

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@seonghobae seonghobae left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current-head review request for 72cfe7b. Re-verify the exact-head diff, required Checks, tenant/security boundaries, and any valid review findings after the shared Rust component-install fix. COMMENT only; no approval or protected-merge bypass is requested.

# Conflicts:
#	.github/workflows/quality.yml
#	CHANGELOG.md
#	README.md
#	docs/ARCHITECTURE.md
#	docs/product-technical-gap-baseline.md

Copy link
Copy Markdown
Contributor Author

@OpenCode review only the exact current head 00269c3 against base 4cfb77f. Re-check attempt-to-registration tenant keys, out-of-order progress rejection/idempotency, 14-policy RLS coverage, rollback dependency order, and the reconciled parent workflow. Leave an independent review or approval for this exact head only. Do not update code, merge, self-approve, bypass protections, or treat queued Checks as proof.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment on lines +698 to +731
FROM learning_attempt \
WHERE tenant_id = $1 AND learner_id = $2 AND learning_attempt_id = $3 \
AND attempt_status IN ('launched', 'active') \
ON CONFLICT (tenant_id, learning_attempt_id, source_authority, \
external_activity_reference, source_version) DO UPDATE \
SET source_digest = EXCLUDED.source_digest, \
progress_state = EXCLUDED.progress_state, \
progress_percent = EXCLUDED.progress_percent, \
observed_at = EXCLUDED.observed_at, \
recorded_at = now() \
WHERE progress_projection.observed_at <= EXCLUDED.observed_at \
RETURNING progress_projection_id",
)
.bind(tenant_id)
.bind(learner_id)
.bind(learning_attempt_id)
.bind(&request.source_authority)
.bind(&request.external_activity_reference)
.bind(&request.source_version)
.bind(&request.source_digest)
.bind(&request.progress_state)
.bind(request.progress_percent)
.bind(observed_at)
.fetch_optional(&mut *transaction)
.await?
.ok_or(ApiError::BadRequest(
"active learning attempt or newer progress observation is required",
))?;
let progress_projection_id: Uuid = projection.try_get("progress_projection_id")?;
let next_attempt_status = if request.progress_state == "completed" {
"completed"
} else {
"active"
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Duplicate or corrected completion updates from the learning record store are wrongly rejected

Once a learning attempt is marked finished (attempt_status = 'completed' set at crates/lms_kernel/src/bin/lms_api.rs:727-742), any further progress observation is filtered out (attempt_status IN ('launched', 'active') at crates/lms_kernel/src/bin/lms_api.rs:700) and returns an error, so a re-sent identical completion is treated as invalid.
Impact: A finished learner activity that receives a repeated or corrected completion signal returns an error instead of succeeding, breaking the promised idempotency and blocking progress for any other activity in that attempt.

Filter excludes completed attempts, defeating idempotency and multi-activity progress

The progress upsert only proceeds when the attempt row matches attempt_status IN ('launched', 'active') (crates/lms_kernel/src/bin/lms_api.rs:698-700). After a first observation whose progress_state is "completed", the follow-up UPDATE sets attempt_status = 'completed' (crates/lms_kernel/src/bin/lms_api.rs:727-742).

Consequences:

  1. Idempotency violation: an LRS delivers statements at-least-once. Re-delivering the exact same completion observation (equal observed_at, same source key) now finds no launched/active attempt, so the INSERT ... SELECT yields zero rows, fetch_optional is None, and the handler returns ApiError::BadRequest → HTTP 400 (crates/lms_kernel/src/bin/lms_api.rs:721-725). The PR explicitly claims "repeated newer/equal observations idempotent".
  2. Multi-activity lockout: progress_projection_source_unique keys on (tenant_id, learning_attempt_id, source_authority, external_activity_reference, source_version) (migrations/0001_learning_kernel.sql:261-263), implying multiple activities per attempt. Completing one activity marks the whole attempt completed, so any other activity's progress POST returns 400.
  3. Correction lockout: the observed_at-based out-of-order guard (crates/lms_kernel/src/bin/lms_api.rs:708) can never accept a legitimately newer post-completion correction because the attempt is already excluded by status.

The CI smoke test only exercises a single in-progress observation followed by an older one, so it does not surface this path.

Prompt for agents
In record_progress (crates/lms_kernel/src/bin/lms_api.rs), the INSERT ... SELECT filters the target attempt with attempt_status IN ('launched', 'active'), but the subsequent UPDATE promotes the attempt to 'completed' whenever the observation's progress_state is 'completed'. As a result, once an attempt is completed, all further progress POSTs (including an idempotent re-delivery of the same completion observation, a newer correction, or progress for a different activity of the same attempt) find no matching launched/active attempt and return HTTP 400. This contradicts the stated idempotency guarantee (repeated newer/equal observations should be idempotent) and breaks multi-activity attempts. Consider allowing 'completed' attempts to still accept progress upserts (e.g. include 'completed' in the SELECT filter), relying on the observed_at-based ON CONFLICT WHERE guard for ordering, and reconsider whether a single activity's 'completed' state should mark the entire attempt completed when multiple activities per attempt are possible. Ensure the closed_at reset logic does not incorrectly re-open a genuinely completed attempt.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

--output "$RUNNER_TEMP/lms_registration_response.json"
python3 -c 'import json, sys; assert json.load(open(sys.argv[1]))["registration_status"] == "registered"' "$RUNNER_TEMP/lms_registration_response.json"
registration_id=$(python3 -c 'import json, sys; print(json.load(open(sys.argv[1]))["learning_registration_id"])' "$RUNNER_TEMP/lms_registration_response.json")
attempt_id=$(curl --fail --silent --show-error --request POST \
--data '{"external_attempt_reference":"attempt-ci-1","content_release_reference":"content-release/v1/partner-basics"}' \
"http://127.0.0.1:8080/v1/tenants/$tenant_id/learners/$learner_id/registrations/$registration_id/attempts" \
| python3 -c 'import json, sys; print(json.load(sys.stdin)["learning_attempt_id"])')
curl --fail --silent --show-error --request POST \
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants