If your compliance reports are showing completions that never happened – or worse, missing completions that did – the root cause is almost always a misunderstanding of how your LMS writes, stores, and reads tracking data at the database and protocol level. This guide cuts through the abstraction layer and shows exactly what happens between a learner clicking “Submit” and that result appearing in your reporting dashboard, with specific reference to SCORM 2004 4th Edition, xAPI 1.0.3 (ADL Initiative, 2013, updated via community errata), and cmi5 Quartz 1.0 (released 2016, maintained by ADL).
How LMS Data Storage Actually Works: The Core Architecture
Most LMS platforms – whether SaaS (Cornerstone, Docebo, TalentLMS) or self-hosted (Moodle, Canvas) – share a common architectural pattern regardless of vendor. Understanding that pattern is the prerequisite for everything else in this guide.
The Three-Layer Data Model
Layer 1: The Relational Core
Every LMS maintains a relational database (typically MySQL, PostgreSQL, or MSSQL) holding its canonical data model. At minimum, this includes the following entity structure:
— Core LMS relational schema (normalized representation)
users (
user_id UUID PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255),
department_id INT REFERENCES departments(dept_id),
created_at TIMESTAMP,
custom_fields JSONB — vendor-specific extensible attributes
);
courses (
course_id UUID PRIMARY KEY,
title VARCHAR(500),
standard ENUM(‘scorm12′,’scorm2004′,’xapi’,’cmi5′,’video’,’pdf’),
version VARCHAR(20),
manifest_path TEXT,
created_at TIMESTAMP
);
enrollments (
enrollment_id UUID PRIMARY KEY,
user_id UUID REFERENCES users(user_id),
course_id UUID REFERENCES courses(course_id),
enrolled_at TIMESTAMP,
due_date DATE,
status ENUM(‘not_started’,’in_progress’,’completed’,’failed’,’expired’),
INDEX (user_id, course_id)
);
completions (
completion_id UUID PRIMARY KEY,
enrollment_id UUID REFERENCES enrollments(enrollment_id),
attempt_number INT DEFAULT 1,
started_at TIMESTAMP,
completed_at TIMESTAMP,
score_raw DECIMAL(6,2),
score_scaled DECIMAL(4,3), — -1.0 to 1.0 per xAPI spec
success_status ENUM(‘passed’,’failed’,’unknown’),
completion_status ENUM(‘completed’,’incomplete’,’not_attempted’,’unknown’),
time_on_task INT, — seconds
suspend_data TEXT, — SCORM bookmark blob
raw_payload JSONB — full serialized data model
);
Layer 2: The Standard-Specific Tracking Store
This is where vendors diverge. Each “commit” made by a SCORM course causes the LMS to store the JSON-serialized SCORM data model into the LMS database. The critical detail: the entire SCORM data model is written as a serialized blob per commit, not as discrete column updates. This means a network interruption mid-session drops the entire commit, not just the last field.
For xAPI, the architecture is fundamentally different. xAPI statements are stored in an external LRS, rather than directly within the LMS. The LRS is a dedicated server responsible for receiving, storing, and providing access to these learning records. Whether that LRS is embedded in your LMS platform or external (SCORM Cloud, Learning Locker, watershed) determines your data ownership model.
Layer 3: The Runtime API Bridge
SCORM content communicates through a JavaScript API injected into the browser window by the LMS. SCORM 1.2 uses API as the JavaScript object name; SCORM 2004 uses API_1484_11. The content calls LMSInitialize() / Initialize(“”) to open a session, sets data model values via LMSSetValue() / SetValue(), and terminates with LMSFinish() / Terminate(“”). Every SetValue call that isn’t followed by a Commit is queued in memory – it is not written to the database until either a Commit call or session termination.
SCORM Data Model: What Actually Gets Written
The SCORM 2004 data model (IEEE 1484.11.2) defines 61 data model elements. In practice, LMSes actively write only a subset:
| Data Model Element | SCORM 1.2 Equivalent | Description | Written By |
|---|---|---|---|
| cmi.completion_status | cmi.core.lesson_status | completed / incomplete / not attempted / unknown | Content |
| cmi.success_status | cmi.core.lesson_status (passed/failed) | passed / failed / unknown | Content |
| cmi.score.scaled | cmi.core.score.raw | Score as -1.0 to 1.0 ratio | Content |
| cmi.score.raw | cmi.core.score.raw | Raw numeric score | Content |
| cmi.session_time | cmi.core.session_time | Duration of current session | Content |
| cmi.total_time | cmi.core.total_time | Cumulative time across sessions | LMS (accumulates) |
| cmi.suspend_data | cmi.suspend_data | Bookmark / state blob (max 64KB in 2004, 4096 chars in 1.2) | Content |
| cmi.location | cmi.core.lesson_location | Current page/slide identifier | Content |
| cmi.learner_id | cmi.core.student_id | Learner identifier | LMS (read-only) |
| cmi.learner_name | cmi.core.student_name | Learner display name | LMS (read-only) |
| cmi.entry | cmi.core.entry | ab_initio / resume / “” | LMS sets on launch |
The cmi.entry value is the mechanism behind bookmarking: the LMS sets it to resume when resuming an existing attempt, which tells the content to restore from cmi.suspend_data. If your LMS always sets cmi.entry to ab_initio, your learners will restart from slide 1 every session regardless of what the content does.
xAPI Statement Structure
xAPI statements follow a Subject-Verb-Object pattern defined in the xAPI 1.0.3 specification:
{
“id”: “12345678-1234-5678-1234-567812345678”,
“actor”: {
“objectType”: “Agent”,
“mbox”: “mailto:learner@example.com“,
“name”: “Jane Smith”
},
“verb”: {
“id”: “http://adlnet.gov/expapi/verbs/completed“,
“display”: { “en-US”: “completed” }
},
“object”: {
“objectType”: “Activity”,
“id”: “https://example.com/courses/safety-101“,
“definition”: {
“name”: { “en-US”: “Workplace Safety 101” },
“type”: “http://adlnet.gov/expapi/activities/course“
}
},
“result”: {
“completion”: true,
“success”: true,
“score”: {
“scaled”: 0.92,
“raw”: 92,
“min”: 0,
“max”: 100
},
“duration”: “PT1H23M45S” // ISO 8601 duration
},
“context”: {
“registration”: “67890abc-…”, // ties statements to a single attempt
“platform”: “Acme LMS v4.2”,
“language”: “en-US”
},
“timestamp”: “2026-04-10T09:23:00.000Z”,
“stored”: “2026-04-10T09:23:01.412Z”,
“authority”: {
“objectType”: “Agent”,
“mbox”: “mailto:lrs@example.com“
}
}
The context.registration UUID is the key field most implementations get wrong. Every attempt by the same learner on the same activity must use the same registration UUID within an attempt and a different UUID across attempts. Without consistent registration values, your LRS cannot distinguish re-attempts from new learner records.
cmi5: The Hybrid Architecture
cmi5 (ADL, 2016) uses the xAPI data model for statement transport but adds SCORM-style packaging (cmi5.xml replaces imsmanifest.xml) and a mandatory credential handshake on launch. cmi5 uses the xAPI data model to track learning activities, and incorporates SCORM’s content packaging and sequencing capabilities. The launch URL includes an endpoint, fetch, registration, activityId, and actor parameter – the fetch URL is a one-time-use token the content exchanges for an auth token before it can post any statements.
Standards and LMS Compatibility Matrix
| Standard | Version | Transport | Data Store | Session Model | Offline Support | Max Score Precision |
|---|---|---|---|---|---|---|
| SCORM 1.2 | IEEE 1484.11.1 (2004) | JavaScript API (API) | LMS DB | Browser session | No | Raw integer |
| SCORM 2004 3rd Ed | ADL (2006) | JavaScript API (API_1484_11) | LMS DB | Browser session | No | Scaled (-1 to 1) |
| SCORM 2004 4th Ed | ADL (2009) | JavaScript API (API_1484_11) | LMS DB | Browser session | No | Scaled (-1 to 1) |
| xAPI 1.0.3 | ADL (2013) | HTTP REST + JSON | LRS | Stateless | Yes (queue + sync) | Scaled (-1 to 1) |
| cmi5 Quartz 1.0 | ADL (2016) | HTTP REST + JSON via LRS | LRS | Launch token | Yes | Scaled (-1 to 1) |
| AICC HACP | AICC (1993, deprecated) | HTTP POST | LMS DB | Session-based | No | Raw integer |
| LTI 1.3 | IMS Global (2019) | OAuth 2.0 + JSON | Platform DB | OAuth session | Partial | Depends on tool |
Platform-specific notes:
| LMS Platform | SCORM 1.2 | SCORM 2004 4th Ed | xAPI 1.0.3 | cmi5 | LTI 1.3 | Built-in LRS |
|---|---|---|---|---|---|---|
| Moodle 4.x | ✅ | ✅ | ✅ (plugin) | ⚠️ Limited | ✅ | ❌ (external required) |
| Canvas (Instructure) | ✅ | ✅ | ⚠️ Via LTI | ❌ | ✅ | ❌ |
| Cornerstone OnDemand | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (embedded) |
| TalentLMS | ✅ | ✅ | ✅ | ✅ | ✅ | Via integration |
| Docebo | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (embedded) |
| SAP SuccessFactors LMS | ✅ | ✅ | ✅ | ⚠️ | ✅ | ❌ |
| Workday Learning | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ |
| Absorb LMS | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (embedded) |
Workday Learning’s lack of native xAPI support is a well-documented constraint requiring a sidecar LRS pattern for organizations that need granular activity tracking.
Implementation Steps: Configuring Completion Storage Correctly
Step 1: Verify Your Completion Trigger Configuration
Every LMS allows you to set whether completion is determined by the content (the SCORM package calls LMSFinish with cmi.completion_status = completed) or by the LMS (e.g., percentage of slides viewed, time threshold). These two mechanisms can conflict.
In SCORM 2004 4th Edition, the content sets cmi.completion_status and cmi.success_status independently. The LMS must not override cmi.completion_status with its own slide-count threshold unless the content never sets it. Verify in your LMS admin console which rule takes precedence. Most enterprise LMSes expose this as a course-level setting: “Use content-defined completion” vs. “Use platform-defined completion.”
Step 2: Configure Mastery Score Alignment
SCORM 1.2 uses cmi.student_data.mastery_score (set in the imsmanifest.xml) to determine pass/fail. The LMS reads this value on import and uses it to evaluate cmi.core.score.raw at session termination. If your authoring tool exports a mastery score of 80 but your LMS admin has overridden it to 70 in the course settings, the LMS-level value wins – and your reporting will show “passed” for scores between 70–79 that the content intended to fail.
For SCORM 2004, the mastery score lives in <adlcp:masteryscore> inside imsmanifest.xml. Check it with any ZIP viewer before upload.
Step 3: Set Attempt Handling Rules
Attempt Storage Options (typical LMS configuration):
├── Overwrite (single record) → Only final attempt stored. Audit trail lost.
├── Append (multi-record) → All attempts stored. Score reporting uses: first / last / highest / average
└── Completion on first pass → Status locked after first completed attempt; subsequent re-takes ignored
For compliance-regulated environments (healthcare, finance, aviation), never use Overwrite mode. Regulators frequently require proof that a specific attempt produced a specific score on a specific date. Multi-record attempt storage is the only architecture that satisfies this requirement.
Step 4: xAPI – Configure Your LRS Endpoint and Auth
For xAPI content, configure the LRS endpoint, key, and secret in your LMS’s content upload settings. The content runtime reads these credentials on launch (passed as URL parameters for cmi5, or hardcoded in the package for standalone xAPI). Test connectivity before deployment using SCORM Cloud’s xAPI test suite at cloud.scorm.com.
The LRS endpoint must use HTTPS. Statements sent over HTTP are rejected by spec-compliant LRS implementations. If your LRS is behind a proxy that terminates TLS, confirm the Authorization header passes through – some reverse proxy configurations strip it.
Step 5: Version-Specific Import Validation
Before bulk uploading SCORM packages, validate them in SCORM Cloud’s debugger. It exposes the full runtime log – every SetValue, GetValue, and Commit call – which is the fastest way to identify whether broken completions are a content bug or an LMS bug. If the package completes correctly in SCORM Cloud but not in your LMS, the bug is in your LMS configuration or version support. If it fails in SCORM Cloud, fix the package before deployment.
Common Issues and Fixes
Issue 1: Completion Not Recording – LMSFinish / Terminate Never Called
Symptom: Learner completes all content; LMS shows “in progress” indefinitely.
Root cause: If LMSFinish is not called when a SCORM file is completed, or if the call is interrupted, the completion may not be reflected in the LMS. This typically happens when the learner closes the browser tab instead of clicking an explicit exit button, or when the package relies on the deprecated beforeunload event.
Fix: Check the package’s JavaScript for window.onbeforeunload completion triggers. Chrome 80+ and all modern browsers block synchronous XHR during page dismissal, meaning these commits silently fail. Republish from your authoring tool using the unload event replacement pattern, or enable the “Send completion on window close” fallback in your LMS if available.
Issue 2: Unable to Find API / Unable to Acquire API_1484_11
Symptom: Course loads to a blank screen or displays an error immediately on launch.
Root cause: The SCORM JavaScript wrapper is looking for the API object in the wrong place. SCORM 1.2 content looks for window.API; SCORM 2004 content looks for window.API_1484_11. If the LMS launches the course in a frameset but the content was built for a popup window (or vice versa), the traversal algorithm walks the wrong frame hierarchy.
Fix: Confirm the LMS launch mode matches the content’s expected launch mode (popup vs. iframe vs. frameset). Test the package in SCORM Cloud to confirm the API is accessible. If using LMS365 within Microsoft Teams, note that Teams’ webview strips certain JavaScript window traversal paths entirely – content must be rebuilt specifically for embedded webview contexts.
Issue 3: Score Writes Correctly But Completion Status Is Wrong
Symptom: Reporting shows a score of 95% but completion_status = “incomplete.”
Root cause: In SCORM 2004, cmi.completion_status and cmi.success_status are separate fields. Verify that the SCORM package properly updates the completion status based on learner interactions and sends the appropriate completion status values (e.g., “completed,” “incomplete,” “passed,” “failed”) to the LMS according to the defined criteria. Many authoring tools set success status (passed) without setting completion status (completed). The LMS may display “passed” in one column and “incomplete” in another – both are correct per spec.
Fix: In SCORM 2004, the content must explicitly call SetValue(“cmi.completion_status”, “completed”) in addition to SetValue(“cmi.success_status”, “passed”). If you cannot edit the content, configure the LMS to infer completion from success status – most platforms expose this as a course-level toggle.
Issue 4: xAPI Statements Sent but Not Appearing in Reports
Symptom: Network tab shows 200 OK responses from the LRS, but data doesn’t show in the LMS reporting UI.
Root cause: The LRS is receiving the statements, but the LMS reporting layer is querying the LRS with a filter that excludes your statements. Common culprits: actor identifier mismatch (the content sends mbox but your LRS stores account), activity ID mismatch (trailing slash difference: https://example.com/course/1 vs https://example.com/course/1/), or context.registration is missing/inconsistent between statements.
Fix: Query the LRS directly via its REST API (GET /statements) bypassing the LMS UI to confirm the statements exist. Compare the actor.mbox / actor.account in the stored statement against what your LMS uses as the user identifier. Activity IDs are case-sensitive URIs – a single character difference creates a new activity record.
Issue 5: Bookmark / Resume Not Working Between Sessions
Symptom: Learner resumes a course and returns to slide 1 instead of the last visited slide.
Root cause: Either the LMS is not passing cmi.entry = resume on relaunch, or cmi.suspend_data is being truncated. SCORM 1.2 limits suspend_data to 4,096 characters; SCORM 2004 limits it to 64,000 characters. Articulate Storyline and Rise courses with large variable sets can exceed the 1.2 limit, silently truncating the bookmark data.
Fix: Check what value cmi.entry returns via GetValue at course launch using the SCORM Cloud debugger. If it returns ab_initio when it should return resume, the LMS is not persisting attempt state between sessions – check the LMS’s attempt retention settings. If cmi.entry is correct but the bookmark fails, inspect suspend_data length on the final commit of the previous session.
Issue 6: Completion Status Conflicts Between SCORM 1.2 lesson_status and SCORM 2004 Dual Status Fields
Symptom: After migrating content from SCORM 1.2 to SCORM 2004, completion and pass/fail reporting break.
Root cause: SCORM 1.2 collapses completion and success into a single cmi.core.lesson_status field with values: passed, failed, completed, incomplete, not attempted, browsed. SCORM 2004 separates these into cmi.completion_status (completed/incomplete) and cmi.success_status (passed/failed/unknown). LMSes that were built around the 1.2 model often map SCORM 2004 fields incorrectly, treating cmi.success_status = passed as equivalent to 1.2’s lesson_status = completed.
Fix: After republishing content to SCORM 2004, verify in SCORM Cloud that both cmi.completion_status and cmi.success_status are being set independently and that your LMS maps both fields to the correct reporting columns. SCORM 1.2 vs SCORM 2004 version mismatches cause silent failures – records import but track incorrectly, or completions disappear entirely.
💡 Experienced LMS Administrator Tip: The Passed vs. Completed Data Warehouse Trap
When building compliance reports off your LMS database, always query BOTH completion_status AND success_status (or their LMS-specific equivalents). Most LMS reporting UIs conflate the two into a single “Status” column, hiding a critical distinction: a learner can have completion_status = completed with success_status = failed – meaning they finished the course but didn’t pass the assessment. In a compliance audit, “completed” without “passed” is a failed training record. Write your database queries to check both fields explicitly, and never rely on the LMS UI’s single status column for regulatory reporting. In SCORM 1.2 databases, the equivalent trap is treating lesson_status = completed as equivalent to lesson_status = passed – they are not interchangeable.
FAQ
Q1. Why do some learners show completion in the LMS but no record exists in our LRS?
This is the classic dual-store synchronization gap. Many LMS platforms that support both SCORM and xAPI write SCORM completions to their relational database immediately on LMSFinish, but xAPI statements to the LRS asynchronously. If the async write fails (network timeout, LRS authentication error, malformed statement), the LMS shows “completed” while the LRS has no record. Check your LMS’s background job queue and LRS error logs simultaneously. For cmi5 content, this problem is less common because the content itself posts directly to the LRS – the LMS completion depends on statement receipt, not the reverse.
Q2. How does SCORM suspend_data interact with a database BLOB column - is there a size limit at the DB layer separate from the spec limit?
Yes. SCORM 1.2’s spec limit of 4,096 characters is often the binding constraint, but some LMS implementations store suspend_data in a VARCHAR(4096) column that enforces this at the DB layer. SCORM 2004’s 64,000-character limit requires a TEXT or MEDIUMTEXT column – if the LMS vendor used VARCHAR(4096) for both versions (a known implementation shortcut), your SCORM 2004 bookmark data will silently truncate at 4,096 characters. Verify the actual column type by querying your LMS database directly, or contact your vendor to confirm SCORM 2004 suspend_data column sizing.
Q3. What is the correct database architecture for storing multiple xAPI verbs per learner per course without creating duplicate completion records?
The xAPI statement store is append-only by design – every statement is a new record, and there is no concept of “updating” an existing statement. Your LRS assigns a unique UUID to each statement at ingestion. The reporting layer is responsible for resolving the “current state” of a learner’s progress by querying statements in chronological order and applying precedence rules (e.g., a later completed statement supersedes an earlier incomplete). The ADL specifies the State API (/activities/state) as the xAPI mechanism for storing mutable learner state (analogous to SCORM’s suspend_data). Use the State API for bookmark data; use the Statement API for immutable event records. Conflating the two is the most common xAPI implementation error that causes duplicate or contradictory completion records in reporting dashboards.