diff --git a/enterprise-lms-roster-passback-guard/README.md b/enterprise-lms-roster-passback-guard/README.md
new file mode 100644
index 00000000..093ace47
--- /dev/null
+++ b/enterprise-lms-roster-passback-guard/README.md
@@ -0,0 +1,32 @@
+# Enterprise LMS Roster Passback Guard
+
+Self-contained Enterprise Tooling slice for issue #19. It validates Canvas/Moodle roster and grade-passback sync evidence before an institution enables LMS integration events.
+
+## What It Checks
+
+- Course-section enrollment stays inside approved enterprise sync scope.
+- Dropped enrollment state is fresh enough for roster export.
+- ORCID/profile linkage exists for enterprise reporting.
+- Student roster sync consent is present before FERPA-sensitive data moves.
+- Grade passback events occur inside the approved release window.
+- Grade and roster events have acknowledged webhook receipts.
+
+## Outputs
+
+- `reports/lms-sync-packet.json`: structured reviewer decisions and findings.
+- `reports/lms-sync-report.md`: readable report for each synthetic LMS sync scenario.
+- `reports/summary.svg`: visual summary of approve, review, hold, and block decisions.
+- `reports/demo.mp4`: short demo artifact for Algora review.
+
+## Local Verification
+
+```bash
+node enterprise-lms-roster-passback-guard/test.js
+node enterprise-lms-roster-passback-guard/demo.js
+node --check enterprise-lms-roster-passback-guard/index.js
+node --check enterprise-lms-roster-passback-guard/test.js
+node --check enterprise-lms-roster-passback-guard/demo.js
+node --check enterprise-lms-roster-passback-guard/sample-data.js
+```
+
+The module is dependency-free, uses synthetic data only, and makes no network calls.
diff --git a/enterprise-lms-roster-passback-guard/demo.js b/enterprise-lms-roster-passback-guard/demo.js
new file mode 100644
index 00000000..9f576ae0
--- /dev/null
+++ b/enterprise-lms-roster-passback-guard/demo.js
@@ -0,0 +1,59 @@
+const fs = require('node:fs');
+const path = require('node:path');
+
+const {evaluateLmsSyncGuard, buildLmsSyncReport} = require('./index');
+const {scenarios} = require('./sample-data');
+
+const reportsDir = path.join(__dirname, 'reports');
+fs.mkdirSync(reportsDir, {recursive: true});
+
+const evaluations = scenarios.map((scenario) => ({
+ scenario: scenario.name,
+ ...evaluateLmsSyncGuard(scenario),
+}));
+
+const packetJson = JSON.stringify(evaluations, null, 2);
+const reviewerReport = evaluations.map(buildLmsSyncReport).join('\n---\n');
+const approved = evaluations.filter((item) => item.decision === 'approve-sync').length;
+const review = evaluations.filter((item) => item.decision === 'needs-admin-review').length;
+const passback = evaluations.filter((item) => item.decision === 'hold-passback').length;
+const blocked = evaluations.filter((item) => item.decision === 'block-sync').length;
+const findings = evaluations.reduce((sum, item) => sum + item.findings.length, 0);
+
+const svg = `
+`;
+
+fs.writeFileSync(path.join(reportsDir, 'lms-sync-packet.json'), `${packetJson}\n`);
+fs.writeFileSync(path.join(reportsDir, 'lms-sync-report.md'), reviewerReport);
+fs.writeFileSync(path.join(reportsDir, 'summary.svg'), svg);
+
+console.log(`Wrote ${evaluations.length} LMS sync evaluations to ${reportsDir}`);
+console.log(`Decision counts: approved=${approved}, review=${review}, passback=${passback}, blocked=${blocked}`);
+console.log(`Reviewer findings: ${findings}`);
diff --git a/enterprise-lms-roster-passback-guard/index.js b/enterprise-lms-roster-passback-guard/index.js
new file mode 100644
index 00000000..bfcef054
--- /dev/null
+++ b/enterprise-lms-roster-passback-guard/index.js
@@ -0,0 +1,220 @@
+const staleDropDays = 14;
+
+function normalizeList(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function guardAction(type, target, reason) {
+ return {type, target, reason};
+}
+
+function daysBetween(start, end) {
+ return Math.floor((Date.parse(end) - Date.parse(start)) / 86400000);
+}
+
+function isInsideWindow(timestamp, window) {
+ if (!timestamp || !window) {
+ return false;
+ }
+ return Date.parse(timestamp) >= Date.parse(window.opensAt) && Date.parse(timestamp) <= Date.parse(window.closesAt);
+}
+
+function countByType(findings, type) {
+ return findings.filter((finding) => finding.type === type).length;
+}
+
+function severityCounts(findings) {
+ return findings.reduce((counts, finding) => {
+ counts[finding.severity] = (counts[finding.severity] || 0) + 1;
+ return counts;
+ }, {});
+}
+
+function evaluateLmsSyncGuard(input) {
+ const roster = normalizeList(input.roster);
+ const passbackEvents = normalizeList(input.passbackEvents);
+ const webhookReceipts = normalizeList(input.webhookReceipts);
+ const approvedSections = new Set(normalizeList(input.approvedSections));
+ const receiptByEvent = new Map(webhookReceipts.map((receipt) => [receipt.eventId, receipt]));
+ const findings = [];
+ const requiredActions = [];
+
+ for (const enrollment of roster) {
+ if (!approvedSections.has(enrollment.sectionId)) {
+ findings.push({
+ type: 'out-of-scope-section',
+ severity: 'critical',
+ userId: enrollment.userId,
+ sectionId: enrollment.sectionId,
+ message: `${enrollment.userId} is enrolled in unapproved section ${enrollment.sectionId}`,
+ });
+ requiredActions.push(guardAction(
+ 'remove_out_of_scope_enrollment',
+ enrollment.userId,
+ 'LMS sync must stay inside approved institutional course sections'
+ ));
+ }
+
+ if (enrollment.status === 'dropped' && enrollment.lastChangedAt && daysBetween(enrollment.lastChangedAt, input.generatedAt) > staleDropDays) {
+ findings.push({
+ type: 'stale-dropped-enrollment',
+ severity: 'major',
+ userId: enrollment.userId,
+ lastChangedAt: enrollment.lastChangedAt,
+ message: `${enrollment.userId} has stale dropped enrollment state from ${enrollment.lastChangedAt}`,
+ });
+ requiredActions.push(guardAction(
+ 'confirm_dropped_enrollment',
+ enrollment.userId,
+ 'stale dropped students need admin confirmation before roster sync'
+ ));
+ }
+
+ if (!enrollment.orcid) {
+ findings.push({
+ type: 'missing-orcid-linkage',
+ severity: 'major',
+ userId: enrollment.userId,
+ message: `${enrollment.userId} lacks ORCID/profile linkage for enterprise reporting`,
+ });
+ requiredActions.push(guardAction(
+ 'link_orcid_profile',
+ enrollment.userId,
+ 'enterprise reporting needs stable researcher identity linkage'
+ ));
+ }
+
+ if (!enrollment.consent) {
+ findings.push({
+ type: 'missing-ferpa-consent',
+ severity: 'critical',
+ userId: enrollment.userId,
+ message: `${enrollment.userId} lacks FERPA-safe sync consent`,
+ });
+ requiredActions.push(guardAction(
+ 'confirm_lms_sync_consent',
+ enrollment.userId,
+ 'student roster data cannot sync without consent evidence'
+ ));
+ }
+ }
+
+ for (const event of passbackEvents) {
+ if (!approvedSections.has(event.sectionId)) {
+ findings.push({
+ type: 'passback-section-out-of-scope',
+ severity: 'critical',
+ eventId: event.eventId,
+ sectionId: event.sectionId,
+ message: `${event.eventId} targets unapproved section ${event.sectionId}`,
+ });
+ requiredActions.push(guardAction(
+ 'block_passback_event',
+ event.eventId,
+ 'grade passback section must be approved for this institution'
+ ));
+ }
+
+ if (!isInsideWindow(event.releasedAt, input.gradeReleaseWindow)) {
+ findings.push({
+ type: 'grade-release-window-violation',
+ severity: 'major',
+ eventId: event.eventId,
+ releasedAt: event.releasedAt,
+ message: `${event.eventId} released outside configured grade window`,
+ });
+ requiredActions.push(guardAction(
+ 'hold_grade_passback',
+ event.eventId,
+ 'grade releases must occur inside the approved LMS release window'
+ ));
+ }
+
+ const receipt = receiptByEvent.get(event.eventId);
+ if (!receipt || !receipt.acknowledged) {
+ findings.push({
+ type: 'missing-webhook-acknowledgement',
+ severity: 'major',
+ eventId: event.eventId,
+ message: `${event.eventId} lacks acknowledged LMS webhook receipt`,
+ });
+ requiredActions.push(guardAction(
+ 'replay_or_confirm_webhook',
+ event.eventId,
+ 'grade passback must have an acknowledged LMS webhook receipt'
+ ));
+ }
+ }
+
+ const counts = severityCounts(findings);
+ const criticalCount = counts.critical || 0;
+ const majorCount = counts.major || 0;
+ const decision = criticalCount > 0
+ ? 'block-sync'
+ : findings.some((finding) => finding.type.includes('passback') || finding.type.includes('grade') || finding.type.includes('webhook'))
+ ? 'hold-passback'
+ : majorCount > 0
+ ? 'needs-admin-review'
+ : 'approve-sync';
+ const syncReadinessScore = Math.max(0, 100 - criticalCount * 40 - majorCount * 20);
+
+ return {
+ syncId: input.syncId,
+ generatedAt: input.generatedAt,
+ institution: input.institution,
+ decision,
+ syncReadinessScore,
+ findings,
+ requiredActions,
+ summary: {
+ rosterCount: roster.length,
+ passbackEventCount: passbackEvents.length,
+ webhookReceiptCount: webhookReceipts.length,
+ approvedSectionCount: approvedSections.size,
+ outOfScopeEnrollments: countByType(findings, 'out-of-scope-section'),
+ staleDrops: countByType(findings, 'stale-dropped-enrollment'),
+ missingOrcidLinks: countByType(findings, 'missing-orcid-linkage'),
+ passbackHolds: findings.filter((finding) => finding.type.includes('passback') || finding.type.includes('grade') || finding.type.includes('webhook')).length,
+ severityCounts: counts,
+ },
+ };
+}
+
+function buildLmsSyncReport(result) {
+ const lines = [
+ '# Enterprise LMS Roster Passback Guard Report',
+ '',
+ `Sync: ${result.syncId}`,
+ `Institution: ${result.institution}`,
+ `Generated: ${result.generatedAt}`,
+ `Decision: ${result.decision}`,
+ `Sync readiness score: ${result.syncReadinessScore}`,
+ '',
+ '## Summary',
+ '',
+ `Roster entries: ${result.summary.rosterCount}`,
+ `Passback events: ${result.summary.passbackEventCount}`,
+ `Webhook receipts: ${result.summary.webhookReceiptCount}`,
+ `Approved sections: ${result.summary.approvedSectionCount}`,
+ `Findings: ${result.findings.length}`,
+ '',
+ '## Findings',
+ '',
+ ...(result.findings.length
+ ? result.findings.map((finding) => `- ${finding.severity}: ${finding.type} - ${finding.message}`)
+ : ['- None']),
+ '',
+ '## Required Actions',
+ '',
+ ...(result.requiredActions.length
+ ? result.requiredActions.map((action) => `- ${action.type}: ${action.target} (${action.reason})`)
+ : ['- None']),
+ '',
+ ];
+ return lines.join('\n');
+}
+
+module.exports = {
+ evaluateLmsSyncGuard,
+ buildLmsSyncReport,
+};
diff --git a/enterprise-lms-roster-passback-guard/reports/demo.mp4 b/enterprise-lms-roster-passback-guard/reports/demo.mp4
new file mode 100644
index 00000000..0cf3b416
Binary files /dev/null and b/enterprise-lms-roster-passback-guard/reports/demo.mp4 differ
diff --git a/enterprise-lms-roster-passback-guard/reports/lms-sync-packet.json b/enterprise-lms-roster-passback-guard/reports/lms-sync-packet.json
new file mode 100644
index 00000000..5ed38f13
--- /dev/null
+++ b/enterprise-lms-roster-passback-guard/reports/lms-sync-packet.json
@@ -0,0 +1,156 @@
+[
+ {
+ "scenario": "out-of-scope-section-block",
+ "syncId": "sync-canvas-biology",
+ "generatedAt": "2026-05-22T14:40:00Z",
+ "institution": "North Valley University",
+ "decision": "block-sync",
+ "syncReadinessScore": 60,
+ "findings": [
+ {
+ "type": "out-of-scope-section",
+ "severity": "critical",
+ "userId": "u-ben",
+ "sectionId": "CHEM-999-Z",
+ "message": "u-ben is enrolled in unapproved section CHEM-999-Z"
+ }
+ ],
+ "requiredActions": [
+ {
+ "type": "remove_out_of_scope_enrollment",
+ "target": "u-ben",
+ "reason": "LMS sync must stay inside approved institutional course sections"
+ }
+ ],
+ "summary": {
+ "rosterCount": 2,
+ "passbackEventCount": 0,
+ "webhookReceiptCount": 1,
+ "approvedSectionCount": 2,
+ "outOfScopeEnrollments": 1,
+ "staleDrops": 0,
+ "missingOrcidLinks": 0,
+ "passbackHolds": 0,
+ "severityCounts": {
+ "critical": 1
+ }
+ }
+ },
+ {
+ "scenario": "admin-review-roster-drift",
+ "syncId": "sync-moodle-physics",
+ "generatedAt": "2026-05-22T14:40:00Z",
+ "institution": "North Valley University",
+ "decision": "needs-admin-review",
+ "syncReadinessScore": 60,
+ "findings": [
+ {
+ "type": "stale-dropped-enrollment",
+ "severity": "major",
+ "userId": "u-clio",
+ "lastChangedAt": "2026-05-01T00:00:00Z",
+ "message": "u-clio has stale dropped enrollment state from 2026-05-01T00:00:00Z"
+ },
+ {
+ "type": "missing-orcid-linkage",
+ "severity": "major",
+ "userId": "u-drew",
+ "message": "u-drew lacks ORCID/profile linkage for enterprise reporting"
+ }
+ ],
+ "requiredActions": [
+ {
+ "type": "confirm_dropped_enrollment",
+ "target": "u-clio",
+ "reason": "stale dropped students need admin confirmation before roster sync"
+ },
+ {
+ "type": "link_orcid_profile",
+ "target": "u-drew",
+ "reason": "enterprise reporting needs stable researcher identity linkage"
+ }
+ ],
+ "summary": {
+ "rosterCount": 2,
+ "passbackEventCount": 0,
+ "webhookReceiptCount": 1,
+ "approvedSectionCount": 1,
+ "outOfScopeEnrollments": 0,
+ "staleDrops": 1,
+ "missingOrcidLinks": 1,
+ "passbackHolds": 0,
+ "severityCounts": {
+ "major": 2
+ }
+ }
+ },
+ {
+ "scenario": "passback-hold",
+ "syncId": "sync-grade-passback",
+ "generatedAt": "2026-05-22T14:40:00Z",
+ "institution": "North Valley University",
+ "decision": "hold-passback",
+ "syncReadinessScore": 60,
+ "findings": [
+ {
+ "type": "grade-release-window-violation",
+ "severity": "major",
+ "eventId": "grade-1",
+ "releasedAt": "2026-05-22T14:00:00Z",
+ "message": "grade-1 released outside configured grade window"
+ },
+ {
+ "type": "missing-webhook-acknowledgement",
+ "severity": "major",
+ "eventId": "grade-2",
+ "message": "grade-2 lacks acknowledged LMS webhook receipt"
+ }
+ ],
+ "requiredActions": [
+ {
+ "type": "hold_grade_passback",
+ "target": "grade-1",
+ "reason": "grade releases must occur inside the approved LMS release window"
+ },
+ {
+ "type": "replay_or_confirm_webhook",
+ "target": "grade-2",
+ "reason": "grade passback must have an acknowledged LMS webhook receipt"
+ }
+ ],
+ "summary": {
+ "rosterCount": 1,
+ "passbackEventCount": 2,
+ "webhookReceiptCount": 1,
+ "approvedSectionCount": 1,
+ "outOfScopeEnrollments": 0,
+ "staleDrops": 0,
+ "missingOrcidLinks": 0,
+ "passbackHolds": 2,
+ "severityCounts": {
+ "major": 2
+ }
+ }
+ },
+ {
+ "scenario": "clean-lms-sync",
+ "syncId": "sync-clean-lms",
+ "generatedAt": "2026-05-22T14:40:00Z",
+ "institution": "North Valley University",
+ "decision": "approve-sync",
+ "syncReadinessScore": 100,
+ "findings": [],
+ "requiredActions": [],
+ "summary": {
+ "rosterCount": 2,
+ "passbackEventCount": 1,
+ "webhookReceiptCount": 2,
+ "approvedSectionCount": 1,
+ "outOfScopeEnrollments": 0,
+ "staleDrops": 0,
+ "missingOrcidLinks": 0,
+ "passbackHolds": 0,
+ "severityCounts": {}
+ }
+ }
+]
diff --git a/enterprise-lms-roster-passback-guard/reports/lms-sync-report.md b/enterprise-lms-roster-passback-guard/reports/lms-sync-report.md
new file mode 100644
index 00000000..e8ed45fc
--- /dev/null
+++ b/enterprise-lms-roster-passback-guard/reports/lms-sync-report.md
@@ -0,0 +1,102 @@
+# Enterprise LMS Roster Passback Guard Report
+
+Sync: sync-canvas-biology
+Institution: North Valley University
+Generated: 2026-05-22T14:40:00Z
+Decision: block-sync
+Sync readiness score: 60
+
+## Summary
+
+Roster entries: 2
+Passback events: 0
+Webhook receipts: 1
+Approved sections: 2
+Findings: 1
+
+## Findings
+
+- critical: out-of-scope-section - u-ben is enrolled in unapproved section CHEM-999-Z
+
+## Required Actions
+
+- remove_out_of_scope_enrollment: u-ben (LMS sync must stay inside approved institutional course sections)
+
+---
+# Enterprise LMS Roster Passback Guard Report
+
+Sync: sync-moodle-physics
+Institution: North Valley University
+Generated: 2026-05-22T14:40:00Z
+Decision: needs-admin-review
+Sync readiness score: 60
+
+## Summary
+
+Roster entries: 2
+Passback events: 0
+Webhook receipts: 1
+Approved sections: 1
+Findings: 2
+
+## Findings
+
+- major: stale-dropped-enrollment - u-clio has stale dropped enrollment state from 2026-05-01T00:00:00Z
+- major: missing-orcid-linkage - u-drew lacks ORCID/profile linkage for enterprise reporting
+
+## Required Actions
+
+- confirm_dropped_enrollment: u-clio (stale dropped students need admin confirmation before roster sync)
+- link_orcid_profile: u-drew (enterprise reporting needs stable researcher identity linkage)
+
+---
+# Enterprise LMS Roster Passback Guard Report
+
+Sync: sync-grade-passback
+Institution: North Valley University
+Generated: 2026-05-22T14:40:00Z
+Decision: hold-passback
+Sync readiness score: 60
+
+## Summary
+
+Roster entries: 1
+Passback events: 2
+Webhook receipts: 1
+Approved sections: 1
+Findings: 2
+
+## Findings
+
+- major: grade-release-window-violation - grade-1 released outside configured grade window
+- major: missing-webhook-acknowledgement - grade-2 lacks acknowledged LMS webhook receipt
+
+## Required Actions
+
+- hold_grade_passback: grade-1 (grade releases must occur inside the approved LMS release window)
+- replay_or_confirm_webhook: grade-2 (grade passback must have an acknowledged LMS webhook receipt)
+
+---
+# Enterprise LMS Roster Passback Guard Report
+
+Sync: sync-clean-lms
+Institution: North Valley University
+Generated: 2026-05-22T14:40:00Z
+Decision: approve-sync
+Sync readiness score: 100
+
+## Summary
+
+Roster entries: 2
+Passback events: 1
+Webhook receipts: 2
+Approved sections: 1
+Findings: 0
+
+## Findings
+
+- None
+
+## Required Actions
+
+- None
diff --git a/enterprise-lms-roster-passback-guard/reports/summary.svg b/enterprise-lms-roster-passback-guard/reports/summary.svg
new file mode 100644
index 00000000..0dcafad6
--- /dev/null
+++ b/enterprise-lms-roster-passback-guard/reports/summary.svg
@@ -0,0 +1,28 @@
+
diff --git a/enterprise-lms-roster-passback-guard/sample-data.js b/enterprise-lms-roster-passback-guard/sample-data.js
new file mode 100644
index 00000000..bada2b5a
--- /dev/null
+++ b/enterprise-lms-roster-passback-guard/sample-data.js
@@ -0,0 +1,67 @@
+const scenarios = [
+ {
+ name: 'out-of-scope-section-block',
+ syncId: 'sync-canvas-biology',
+ generatedAt: '2026-05-22T14:40:00Z',
+ institution: 'North Valley University',
+ approvedSections: ['BIO-501-A', 'BIO-501-B'],
+ gradeReleaseWindow: {opensAt: '2026-05-20T00:00:00Z', closesAt: '2026-05-30T00:00:00Z'},
+ roster: [
+ {userId: 'u-ada', role: 'student', sectionId: 'BIO-501-A', status: 'active', orcid: '0000-0001', consent: true},
+ {userId: 'u-ben', role: 'student', sectionId: 'CHEM-999-Z', status: 'active', orcid: '0000-0002', consent: true},
+ ],
+ passbackEvents: [],
+ webhookReceipts: [{eventId: 'roster-1', acknowledged: true, receivedAt: '2026-05-22T14:41:00Z'}],
+ },
+ {
+ name: 'admin-review-roster-drift',
+ syncId: 'sync-moodle-physics',
+ generatedAt: '2026-05-22T14:40:00Z',
+ institution: 'North Valley University',
+ approvedSections: ['PHYS-610-A'],
+ gradeReleaseWindow: {opensAt: '2026-05-20T00:00:00Z', closesAt: '2026-05-30T00:00:00Z'},
+ roster: [
+ {userId: 'u-clio', role: 'student', sectionId: 'PHYS-610-A', status: 'dropped', lastChangedAt: '2026-05-01T00:00:00Z', orcid: '0000-0003', consent: true},
+ {userId: 'u-drew', role: 'instructor', sectionId: 'PHYS-610-A', status: 'active', lastChangedAt: '2026-05-22T00:00:00Z', orcid: '', consent: true},
+ ],
+ passbackEvents: [],
+ webhookReceipts: [{eventId: 'roster-2', acknowledged: true, receivedAt: '2026-05-22T14:41:00Z'}],
+ },
+ {
+ name: 'passback-hold',
+ syncId: 'sync-grade-passback',
+ generatedAt: '2026-05-22T14:40:00Z',
+ institution: 'North Valley University',
+ approvedSections: ['CS-720-A'],
+ gradeReleaseWindow: {opensAt: '2026-05-20T00:00:00Z', closesAt: '2026-05-21T00:00:00Z'},
+ roster: [
+ {userId: 'u-erin', role: 'student', sectionId: 'CS-720-A', status: 'active', lastChangedAt: '2026-05-22T00:00:00Z', orcid: '0000-0004', consent: true},
+ ],
+ passbackEvents: [
+ {eventId: 'grade-1', userId: 'u-erin', sectionId: 'CS-720-A', score: 94, releasedAt: '2026-05-22T14:00:00Z'},
+ {eventId: 'grade-2', userId: 'u-erin', sectionId: 'CS-720-A', score: 88, releasedAt: '2026-05-20T14:00:00Z'},
+ ],
+ webhookReceipts: [{eventId: 'grade-1', acknowledged: true, receivedAt: '2026-05-22T14:01:00Z'}],
+ },
+ {
+ name: 'clean-lms-sync',
+ syncId: 'sync-clean-lms',
+ generatedAt: '2026-05-22T14:40:00Z',
+ institution: 'North Valley University',
+ approvedSections: ['BIO-501-A'],
+ gradeReleaseWindow: {opensAt: '2026-05-20T00:00:00Z', closesAt: '2026-05-30T00:00:00Z'},
+ roster: [
+ {userId: 'u-fox', role: 'student', sectionId: 'BIO-501-A', status: 'active', lastChangedAt: '2026-05-22T00:00:00Z', orcid: '0000-0005', consent: true},
+ {userId: 'u-gia', role: 'instructor', sectionId: 'BIO-501-A', status: 'active', lastChangedAt: '2026-05-22T00:00:00Z', orcid: '0000-0006', consent: true},
+ ],
+ passbackEvents: [
+ {eventId: 'grade-clean', userId: 'u-fox', sectionId: 'BIO-501-A', score: 91, releasedAt: '2026-05-22T14:00:00Z'},
+ ],
+ webhookReceipts: [
+ {eventId: 'grade-clean', acknowledged: true, receivedAt: '2026-05-22T14:01:00Z'},
+ {eventId: 'roster-clean', acknowledged: true, receivedAt: '2026-05-22T14:02:00Z'},
+ ],
+ },
+];
+
+module.exports = {scenarios};
diff --git a/enterprise-lms-roster-passback-guard/test.js b/enterprise-lms-roster-passback-guard/test.js
new file mode 100644
index 00000000..7fce0f12
--- /dev/null
+++ b/enterprise-lms-roster-passback-guard/test.js
@@ -0,0 +1,106 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+ evaluateLmsSyncGuard,
+ buildLmsSyncReport,
+} = require('./index');
+
+test('blocks LMS sync when enrollment section is outside approved enterprise scope', () => {
+ const result = evaluateLmsSyncGuard({
+ syncId: 'sync-canvas-biology',
+ generatedAt: '2026-05-22T14:40:00Z',
+ institution: 'North Valley University',
+ approvedSections: ['BIO-501-A', 'BIO-501-B'],
+ gradeReleaseWindow: {opensAt: '2026-05-20T00:00:00Z', closesAt: '2026-05-30T00:00:00Z'},
+ roster: [
+ {userId: 'u-ada', role: 'student', sectionId: 'BIO-501-A', status: 'active', orcid: '0000-0001', consent: true},
+ {userId: 'u-ben', role: 'student', sectionId: 'CHEM-999-Z', status: 'active', orcid: '0000-0002', consent: true},
+ ],
+ passbackEvents: [],
+ webhookReceipts: [{eventId: 'roster-1', acknowledged: true, receivedAt: '2026-05-22T14:41:00Z'}],
+ });
+
+ assert.equal(result.decision, 'block-sync');
+ assert.equal(result.findings[0].type, 'out-of-scope-section');
+ assert.equal(result.findings[0].userId, 'u-ben');
+ assert.equal(result.summary.outOfScopeEnrollments, 1);
+});
+
+test('requires admin review for stale enrollment drops and missing ORCID linkage', () => {
+ const result = evaluateLmsSyncGuard({
+ syncId: 'sync-moodle-physics',
+ generatedAt: '2026-05-22T14:40:00Z',
+ institution: 'North Valley University',
+ approvedSections: ['PHYS-610-A'],
+ gradeReleaseWindow: {opensAt: '2026-05-20T00:00:00Z', closesAt: '2026-05-30T00:00:00Z'},
+ roster: [
+ {userId: 'u-clio', role: 'student', sectionId: 'PHYS-610-A', status: 'dropped', lastChangedAt: '2026-05-01T00:00:00Z', orcid: '0000-0003', consent: true},
+ {userId: 'u-drew', role: 'instructor', sectionId: 'PHYS-610-A', status: 'active', lastChangedAt: '2026-05-22T00:00:00Z', orcid: '', consent: true},
+ ],
+ passbackEvents: [],
+ webhookReceipts: [{eventId: 'roster-2', acknowledged: true, receivedAt: '2026-05-22T14:41:00Z'}],
+ });
+
+ assert.equal(result.decision, 'needs-admin-review');
+ assert.deepEqual(
+ result.findings.map((finding) => finding.type),
+ ['stale-dropped-enrollment', 'missing-orcid-linkage']
+ );
+ assert.equal(result.requiredActions.length, 2);
+});
+
+test('holds grade passback outside release window or without webhook acknowledgement', () => {
+ const result = evaluateLmsSyncGuard({
+ syncId: 'sync-grade-passback',
+ generatedAt: '2026-05-22T14:40:00Z',
+ institution: 'North Valley University',
+ approvedSections: ['CS-720-A'],
+ gradeReleaseWindow: {opensAt: '2026-05-20T00:00:00Z', closesAt: '2026-05-21T00:00:00Z'},
+ roster: [
+ {userId: 'u-erin', role: 'student', sectionId: 'CS-720-A', status: 'active', lastChangedAt: '2026-05-22T00:00:00Z', orcid: '0000-0004', consent: true},
+ ],
+ passbackEvents: [
+ {eventId: 'grade-1', userId: 'u-erin', sectionId: 'CS-720-A', score: 94, releasedAt: '2026-05-22T14:00:00Z'},
+ {eventId: 'grade-2', userId: 'u-erin', sectionId: 'CS-720-A', score: 88, releasedAt: '2026-05-20T14:00:00Z'},
+ ],
+ webhookReceipts: [{eventId: 'grade-1', acknowledged: true, receivedAt: '2026-05-22T14:01:00Z'}],
+ });
+
+ assert.equal(result.decision, 'hold-passback');
+ assert.deepEqual(
+ result.findings.map((finding) => finding.type),
+ ['grade-release-window-violation', 'missing-webhook-acknowledgement']
+ );
+});
+
+test('approves clean LMS roster and grade passback packet with deterministic report', () => {
+ const result = evaluateLmsSyncGuard({
+ syncId: 'sync-clean-lms',
+ generatedAt: '2026-05-22T14:40:00Z',
+ institution: 'North Valley University',
+ approvedSections: ['BIO-501-A'],
+ gradeReleaseWindow: {opensAt: '2026-05-20T00:00:00Z', closesAt: '2026-05-30T00:00:00Z'},
+ roster: [
+ {userId: 'u-fox', role: 'student', sectionId: 'BIO-501-A', status: 'active', lastChangedAt: '2026-05-22T00:00:00Z', orcid: '0000-0005', consent: true},
+ {userId: 'u-gia', role: 'instructor', sectionId: 'BIO-501-A', status: 'active', lastChangedAt: '2026-05-22T00:00:00Z', orcid: '0000-0006', consent: true},
+ ],
+ passbackEvents: [
+ {eventId: 'grade-clean', userId: 'u-fox', sectionId: 'BIO-501-A', score: 91, releasedAt: '2026-05-22T14:00:00Z'},
+ ],
+ webhookReceipts: [
+ {eventId: 'grade-clean', acknowledged: true, receivedAt: '2026-05-22T14:01:00Z'},
+ {eventId: 'roster-clean', acknowledged: true, receivedAt: '2026-05-22T14:02:00Z'},
+ ],
+ });
+
+ assert.equal(result.decision, 'approve-sync');
+ assert.equal(result.findings.length, 0);
+ assert.equal(result.syncReadinessScore, 100);
+
+ const report = buildLmsSyncReport(result);
+ assert.match(report, /# Enterprise LMS Roster Passback Guard Report/);
+ assert.match(report, /Sync: sync-clean-lms/);
+ assert.match(report, /Decision: approve-sync/);
+ assert.match(report, /Findings: 0/);
+});