Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ jobs:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Install pinned Rust
run: rustup toolchain install 1.97.1 --profile minimal --no-self-update
- name: Install pinned Rust components
run: rustup component add rustfmt clippy --toolchain 1.97.1
- name: Check formatting
run: cargo +1.97.1 fmt --all -- --check
- name: Run domain tests
Expand Down Expand Up @@ -110,7 +112,7 @@ jobs:
policy_count=$(psql --host 127.0.0.1 --username lms_kernel --dbname lms_kernel_test \
--tuples-only --no-align \
--command "SELECT count(*) FROM pg_policies WHERE schemaname = 'public'")
test "$policy_count" = '15'
test "$policy_count" = '16'
test "$(psql --host 127.0.0.1 --username lms_kernel --dbname lms_kernel_test \
--tuples-only --no-align \
--command "SELECT count(*) FROM pg_constraint WHERE conname = 'completion_decision_registration_fk'")" = '1'
Expand Down Expand Up @@ -232,3 +234,14 @@ jobs:
"http://127.0.0.1:8080/v1/tenants/$tenant_id/learners/$learner_id/registrations/$registration_id/completion-decisions/$decision_id/credentials/$credential_id/revoke")" = '200'
test "$(psql --host 127.0.0.1 --username lms_kernel --dbname lms_kernel_test \
--tuples-only --no-align --command "SELECT count(*) FROM credential_record WHERE tenant_id = '$tenant_id' AND learner_id = '$learner_id' AND credential_status = 'revoked'")" = '1'
audit_count=$(psql --host 127.0.0.1 --username lms_kernel --dbname lms_kernel_test \
--tuples-only --no-align \
--command "SELECT count(*) FROM audit_event_record WHERE tenant_id = '$tenant_id' AND event_digest <> ''")
test "$audit_count" = '4'
if psql --host 127.0.0.1 --username lms_kernel --dbname lms_kernel_test \
--set ON_ERROR_STOP=1 \
--command "BEGIN; SET LOCAL app.tenant_id = '$tenant_id'; UPDATE audit_event_record SET event_digest = 'tampered' WHERE tenant_id = '$tenant_id'; COMMIT;" \
> /dev/null 2>&1; then
echo 'audit events must reject mutation' >&2
exit 1
fi
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@
- Added idempotent credential reference revocation with tenant and decision-boundary checks.
- Added versioned assessment-result reference handoff with external outcome status and passed-only completion evaluation.
- Added registration-bound evidence references and idempotent assessment import retries.
- Added tenant-isolated append-only audit events with correlation, source version, and event digest for assessment import, completion publication, and credential lifecycle transitions.
135 changes: 130 additions & 5 deletions crates/lms_kernel/src/bin/lms_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use lms_kernel::{
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use sqlx::{PgPool, Postgres, Row, Transaction, postgres::PgPoolOptions};
use uuid::Uuid;

Expand Down Expand Up @@ -463,6 +464,64 @@ async fn begin_tenant_transaction(
Ok(transaction)
}

struct AuditEvent<'a> {
tenant_id: Uuid,
correlation_id: Uuid,
action_name: &'a str,
entity_type: &'a str,
entity_id: Uuid,
source_authority: &'a str,
source_version: &'a str,
occurred_at: DateTime<Utc>,
}

fn audit_event_digest(event: &AuditEvent<'_>) -> String {
let mut hasher = Sha256::new();
for part in [
event.tenant_id.to_string(),
event.correlation_id.to_string(),
event.action_name.to_owned(),
event.entity_type.to_owned(),
event.entity_id.to_string(),
event.source_authority.to_owned(),
event.source_version.to_owned(),
event.occurred_at.to_rfc3339(),
] {
hasher.update(part.as_bytes());
hasher.update([0_u8]);
}
hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}

async fn record_audit_event(
transaction: &mut Transaction<'_, Postgres>,
event: AuditEvent<'_>,
) -> Result<(), ApiError> {
let event_digest = audit_event_digest(&event);
sqlx::query(
"INSERT INTO audit_event_record \
(tenant_id, correlation_id, action_name, entity_type, entity_id, \
source_authority, source_version, event_digest, occurred_at) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
)
.bind(event.tenant_id)
.bind(event.correlation_id)
.bind(event.action_name)
.bind(event.entity_type)
.bind(event.entity_id)
.bind(event.source_authority)
.bind(event.source_version)
.bind(event_digest)
.bind(event.occurred_at)
.execute(&mut **transaction)
.await?;
Ok(())
}

async fn create_affiliation(
State(state): State<AppState>,
Path((tenant_id, learner_id)): Path<(Uuid, Uuid)>,
Expand Down Expand Up @@ -1060,7 +1119,7 @@ struct EvidenceInsertRequest<'a> {
async fn insert_evidence_reference(
transaction: &mut Transaction<'_, Postgres>,
request: EvidenceInsertRequest<'_>,
) -> Result<Uuid, ApiError> {
) -> Result<(Uuid, bool), ApiError> {
let evidence = sqlx::query(
"INSERT INTO decision_evidence_reference \
(tenant_id, learner_id, learning_registration_id, evidence_kind, source_authority, \
Expand All @@ -1087,7 +1146,7 @@ async fn insert_evidence_reference(
.fetch_optional(&mut **transaction)
.await?;
if let Some(evidence) = evidence {
return Ok(evidence.try_get("decision_evidence_reference_id")?);
return Ok((evidence.try_get("decision_evidence_reference_id")?, true));
}

let idempotency_key = request.idempotency_key.ok_or(ApiError::Conflict)?;
Expand Down Expand Up @@ -1119,7 +1178,7 @@ async fn insert_evidence_reference(
if !same_request {
return Err(ApiError::Conflict);
}
Ok(existing.try_get("decision_evidence_reference_id")?)
Ok((existing.try_get("decision_evidence_reference_id")?, false))
}

async fn create_evidence(
Expand Down Expand Up @@ -1164,7 +1223,7 @@ async fn create_evidence(
.as_ref()
.map(|status| assessment_result_status_name(status).to_owned());
let mut transaction = begin_tenant_transaction(&state.pool, tenant_id).await?;
let decision_evidence_reference_id = insert_evidence_reference(
let (decision_evidence_reference_id, _) = insert_evidence_reference(
&mut transaction,
EvidenceInsertRequest {
tenant_id,
Expand Down Expand Up @@ -1233,8 +1292,9 @@ async fn create_assessment_result(
)
.map_err(map_kernel_error)?;
let status_name = assessment_result_status_name(&assessment_result_status).to_owned();
let correlation_id = Uuid::new_v4();
let mut transaction = begin_tenant_transaction(&state.pool, tenant_id).await?;
let decision_evidence_reference_id = insert_evidence_reference(
let (decision_evidence_reference_id, inserted) = insert_evidence_reference(
&mut transaction,
EvidenceInsertRequest {
tenant_id,
Expand All @@ -1248,6 +1308,22 @@ async fn create_assessment_result(
},
)
.await?;
if inserted {
record_audit_event(
&mut transaction,
AuditEvent {
tenant_id,
correlation_id,
action_name: "assessment_result_reference.recorded",
entity_type: "decision_evidence_reference",
entity_id: decision_evidence_reference_id,
source_authority: &request.assessment_authority,
source_version: &request.source_version,
occurred_at: observed_at,
},
)
.await?;
}
transaction.commit().await?;

Ok((
Expand Down Expand Up @@ -1288,6 +1364,7 @@ async fn create_completion_decision(
));
}
let evaluated_at = request.evaluated_at.unwrap_or_else(Utc::now);
let correlation_id = Uuid::new_v4();
let mut transaction = begin_tenant_transaction(&state.pool, tenant_id).await?;
let revision = sqlx::query(
"SELECT completion_policy_id, revision_number, required_evidence_kinds \
Expand Down Expand Up @@ -1422,6 +1499,21 @@ async fn create_completion_decision(
.bind(learning_registration_id)
.execute(&mut *transaction)
.await?;
let source_version = format!("policy-revision/{revision_number}");
record_audit_event(
&mut transaction,
AuditEvent {
tenant_id,
correlation_id,
action_name: "completion_decision.published",
entity_type: "completion_decision",
entity_id: completion_decision_id,
source_authority: "lms-policy-engine",
source_version: &source_version,
occurred_at: evaluated_at,
},
)
.await?;
transaction.commit().await?;

Ok((
Expand Down Expand Up @@ -1458,6 +1550,7 @@ async fn create_credential(
return Err(ApiError::BadRequest("credential references are required"));
}

let correlation_id = Uuid::new_v4();
let mut transaction = begin_tenant_transaction(&state.pool, tenant_id).await?;
let credential = sqlx::query(
"INSERT INTO credential_record \
Expand Down Expand Up @@ -1489,6 +1582,20 @@ async fn create_credential(
))?;
let credential_record_id: Uuid = credential.try_get("credential_record_id")?;
let issued_at: DateTime<Utc> = credential.try_get("issued_at")?;
record_audit_event(
&mut transaction,
AuditEvent {
tenant_id,
correlation_id,
action_name: "credential_record.issued",
entity_type: "credential_record",
entity_id: credential_record_id,
source_authority: &request.credential_authority,
source_version: "credential-reference/v1",
occurred_at: issued_at,
},
)
.await?;
transaction.commit().await?;

Ok((
Expand Down Expand Up @@ -1527,6 +1634,7 @@ async fn revoke_credential(
return Err(ApiError::BadRequest("credential references are required"));
}

let correlation_id = Uuid::new_v4();
let mut transaction = begin_tenant_transaction(&state.pool, tenant_id).await?;
let credential = sqlx::query(
"UPDATE credential_record SET credential_status = 'revoked', revoked_at = now() \
Expand All @@ -1544,6 +1652,7 @@ async fn revoke_credential(
.bind(credential_record_id)
.fetch_optional(&mut *transaction)
.await?;
let newly_revoked = credential.is_some();
let credential = match credential {
Some(credential) => credential,
None => sqlx::query(
Expand Down Expand Up @@ -1574,6 +1683,22 @@ async fn revoke_credential(
let credential_status: String = credential.try_get("credential_status")?;
let issued_at: DateTime<Utc> = credential.try_get("issued_at")?;
let revoked_at: Option<DateTime<Utc>> = credential.try_get("revoked_at")?;
if newly_revoked && let Some(revoked_at) = revoked_at {
record_audit_event(
&mut transaction,
AuditEvent {
tenant_id,
correlation_id,
action_name: "credential_record.revoked",
entity_type: "credential_record",
entity_id: credential_record_id,
source_authority: &credential_authority,
source_version: "credential-reference/v1",
occurred_at: revoked_at,
},
)
.await?;
}
transaction.commit().await?;

Ok((
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,4 @@ Partner & Customer Academy: external learner onboarding, sponsor or self entitle

## Executable baseline

The current implementation slice is `crates/lms_kernel`: Rust domain rules enforce non-employee affiliations, effective dates, tenant/learner evidence boundaries, passed-assessment requirements, and replay fingerprints. `crates/lms_kernel/src/bin/lms_api.rs` provides health plus affiliation, offering, learner, entitlement, enrollment, registration, attempt, progress-projection, policy, evidence, assessment-result-reference, completion-decision, and credential-reference endpoints backed by the migration in `migrations/0001_learning_kernel.sql`. This is an executable bounded affiliation/registration/enrollment/progress/assessment-reference/completion/credential kernel, not yet the complete Partner & Customer Academy journey. Released external adapters, assessment execution/scoring, and browser E2E remain open gaps in `docs/product-technical-gap-baseline.md`.
The current implementation slice is `crates/lms_kernel`: Rust domain rules enforce non-employee affiliations, effective dates, tenant/learner evidence boundaries, passed-assessment requirements, and replay fingerprints. `crates/lms_kernel/src/bin/lms_api.rs` provides health plus affiliation, offering, learner, entitlement, enrollment, registration, attempt, progress-projection, policy, evidence, assessment-result-reference, completion-decision, credential-reference, and append-only audit-event writes backed by the migration in `migrations/0001_learning_kernel.sql`. This is an executable bounded affiliation/registration/enrollment/progress/assessment-reference/completion/credential/audit kernel, not yet the complete Partner & Customer Academy journey. Released external adapters, assessment execution/scoring, and browser E2E remain open gaps in `docs/product-technical-gap-baseline.md`.
3 changes: 3 additions & 0 deletions docs/DATA_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Initial entities:
- `decision_evidence_reference`
- `completion_decision`
- `credential_record`
- `audit_event_record`

A learner is not assumed to be an employee, login account, payer, or contracting organization. Optional employment linkage is represented as an effective-dated `learning_affiliation` or external worker reference with `valid_from` and `valid_to`; no employee row is synthesized for a non-employee learner.

Expand All @@ -41,6 +42,8 @@ A learner is not assumed to be an employee, login account, payer, or contracting

`credential_record` is a tenant-scoped reference projection issued only from a completed registration and its exact completion decision. It stores the credential authority, opaque external credential reference, lifecycle status, and issue/revocation timestamps; it does not store a badge payload or become the Open Badges/CLR authority. The four-column decision foreign key prevents a credential from combining a learner, registration, and decision from different rows.

`audit_event_record` is an append-only, tenant-scoped provenance record. It stores an opaque service actor, correlation UUID, action and entity identity, source authority/version, event digest, and occurrence time; it never copies learner, assessment, credential, or provider payloads. Row-level security and a mutation-rejecting trigger protect the record in addition to the API transaction boundary.

Cardinality baseline:

```text
Expand Down
5 changes: 3 additions & 2 deletions docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ The next customer-visible milestone is not another document: a tenant-isolated l
| PR [#10](https://github.com/ContextualWisdomLab/learning-management-platform/pull/10), implementation head `cb9a636` | Adds idempotent tenant-safe credential revocation from the same exact registration and completion decision. | It remains open with checks/review pending; it is not merged product evidence. |
| PR [#11](https://github.com/ContextualWisdomLab/learning-management-platform/pull/11), implementation head `a2b72db` | Adds the versioned `assessment_result_reference/v1` boundary, external pass/fail status, and Rust rejection of non-passed assessment evidence. | It remains open with checks/review pending; it is not merged product evidence. |
| PR [#12](https://github.com/ContextualWisdomLab/learning-management-platform/pull/12), implementation head `899d2dd` | Adds registration-bound evidence references and idempotent assessment import retries. | It remains open with checks/review pending; it is not merged product evidence. |
| PR [#13](https://github.com/ContextualWisdomLab/learning-management-platform/pull/13), implementation head `7514a87` | Adds tenant-isolated append-only audit events for assessment import, completion publication, and credential issue/revocation, with opaque actor, correlation UUID, source version, and event digest. | It remains open with checks/review pending; it is not merged product evidence. |
| Issue [#2](https://github.com/ContextualWisdomLab/learning-management-platform/issues/2) | Defines the repository boundary, modular-monolith slices, PostgreSQL 3NF, adapters, accessibility, and evidence gates. | This is the foundation backlog, not delivered functionality. |
| Issue [#3](https://github.com/ContextualWisdomLab/learning-management-platform/issues/3) | Defines the external-learner vertical and acceptance criteria for identity separation, effective dating, replayable completion, tenancy, and coverage. | This is the first product slice to implement after the bootstrap merges. |

Expand Down Expand Up @@ -128,7 +129,7 @@ Production readiness requires CSAP and SOC 2 control mapping, SBOM and provenanc
| G-04 | P0 | No time-aware affiliation or correction model | Requirements only | Effective-dated and replay/correction tests pass | Add valid-time and decision transaction metadata |
| G-05 | P0 | No versioned external contracts | PR #11 adds the local `assessment_result_reference/v1` JSON schema/route; PR #12 adds registration binding and idempotent retries; no released provider client exists | Schemas, clients, provider-consumer contract tests, idempotent adapters | Add integration package, client, and outbox |
| G-06 | P0 | No deterministic completion/evidence engine | PR #8 evaluates versioned policy/evidence in Rust; PR #11 rejects failed or inconclusive assessment evidence; PR #12 binds evidence to registration | Replay produces the same decision from policy/evidence versions on a real database | Add correction/supersession and provider contract tests |
| G-07 | P1 | No provenance, audit, or operational evidence | No runtime source | Correlation-linked audit and decision history with export | Add audit/provenance module |
| G-07 | P1 | No provenance, audit, or operational evidence | PR #13 adds tenant-isolated append-only audit events for assessment import, completion publication, and credential issue/revocation, with opaque actor, correlation UUID, source version, and event digest | Correlation-linked audit and decision history with export | Add request-correlation propagation, audit export, and operational receipts |
| G-08 | P1 | No PostgreSQL hot-partition, retention, or rollback evidence | PRs #4–#9 provide 3NF tables, composite tenant FKs, RLS, and one embedded migration | Migration, RLS, load, retention, and rollback evidence | Measure append-heavy projections before adding partitions |
| G-09 | P1 | No security/compliance control implementation | Standards profile only | CSAP/SOC 2/NIST control map with test receipts, SBOM, provenance | Add security and operability gates |
| G-10 | P1 | No accessibility/UI/design-system surface | No frontend files | Storybook, token tests, browser interaction and i18n evidence | Start UI only after API journey is real |
Expand All @@ -143,7 +144,7 @@ The current loop is:

1. PR #1: validate its current exact head, obtain an independent current-head review, then merge only when the live rules permit it.
2. Issue #2: add the executable modular-monolith foundation and repository gates.
3. Issue #3: stack the external-learner vertical on that foundation; PR #5 covers registration, PR #6 affiliation, PR #7 launch/progress, PR #8 completion persistence, PR #9 credential issuance, PR #10 credential revocation, PR #11 assessment-result reference handoff, and PR #12 retry/binding safety.
3. Issue #3: stack the external-learner vertical on that foundation; PR #5 covers registration, PR #6 affiliation, PR #7 launch/progress, PR #8 completion persistence, PR #9 credential issuance, PR #10 credential revocation, PR #11 assessment-result reference handoff, PR #12 retry/binding safety, and PR #13 audit/provenance events.
4. Add the product gaps found by runtime evidence as the next bounded PR, not as speculative scaffolding.

## Standards and research evidence
Expand Down
Loading