xAPI Producer Contract v1¶
Status: normative for the MVP. Version: 1.0.0. Last updated: 2026-07-16.
This pins the statement shape that emitters produce and the LRS consumes. It is the authority for four
things that previously had no authority: the canonical activity IRI, the verb set, the
object.definition.type → object_type mapping that clickhouse.sql's materialized views filter
on, and the Start/Pause dwell pattern every MicroSim needs (§7).
It exists because lrs-design-v1.md §1.3 explicitly defers the producer side, so
the DDL consumes fields nothing is contracted to send.
scripts/smoke.sh
already cites "xapi-producer-contract-v1.md §3" — the harness was written against this document
before it existed. Where this contract and the harness agree, the agreement is now deliberate.
Scope. This is the minimal version the plan calls for: enough to pin the DDL and give lrs loadgen
a spec. It closes the first bullet of TODO.md's retrofit item (verbs, activity types, required
result fields) and nothing else — emission strategy, batching, throttling, and consent remain open
there.
Each rule below is tagged: [RATIFIED] already true in code, now binding · [RESOLVED] a real disagreement, settled with evidence · [NEW] the design was silent; this decides it · [OPEN] needs your call.
1. The canonical activity IRI [RESOLVED]¶
The rule¶
object.idfor a page is{site_url}+ the page's nav path, with a trailing slash.site_urlismkdocs.yml'ssite_url—https://dmccreary.github.io/learning-record-store/. It is nevermain.html, never another site, never a bare path.
https://dmccreary.github.io/learning-record-store/sims/lrs-data-model/
https://dmccreary.github.io/learning-record-store/sims/sine-wave/
What the sources actually said¶
The plan (mvp-plan.md:84) describes this as three sources disagreeing on the IRI. Having read all
three: that framing is slightly off, and the correction matters. smoke.sh and the design doc do
not use different forms — they use the same form and name a different page. That is not a
conflict; a contract governs the rule, not which page emits. The real defects are narrower:
| Source | Value | Verdict |
|---|---|---|
mkdocs.yml site_url |
https://dmccreary.github.io/learning-record-store/ |
Authoritative. It defines what is actually served. |
scripts/smoke.sh |
…/learning-record-store/sims/lrs-data-model/ |
Correct. Conforms. Names a different page than sine-wave — not a conflict. |
lrs-design-v1.md:1190 |
…/learning-record-store/sims/lrs-data-model/ |
Correct, and identical to smoke.sh. |
lrs-design-v1.md:1194 (grouping) |
https://example.edu/textbook/lrs/v1.0.0 |
Placeholder. Rejected — see §4. |
sine-wave.js ACTIVITY_BASE_ID |
was https://dmccreary.github.io/microsims/sims/sine-wave/main.html |
Was wrong twice. Fixed 2026-07-16 — see below. |
So: one genuinely malformed emitter, one placeholder. Not a three-way split.
Why sine-wave.js is wrong twice¶
- Wrong site. It points at
dmccreary.github.io/microsims/, a different repo's Pages site. The file lives here, andmkdocs.yml's nav serves it from this site atsims/sine-wave/. The IRI is a leftover from wherever the sim was transplanted from. - Wrong page — and this is the load-bearing one.
main.htmlis the iframe payload, not a navigable page. MkDocs rendersindex.mdto/sims/sine-wave/and copiesmain.htmlbeside it (both files exist in every sim directory). Citingmain.htmlmints a second IRI for one activity.
That is not cosmetic. lrs.student_page_rollup is ORDER BY (district_id, student_key, object_id).
Two IRIs for one page put one student's engagement in two rows that never merge — so
statements_compressed splits, C-6's compression ratio under-reports, and the graph grows a second
PageEngagement vertex for a page the student visited once. The C-6 assertion in
smoke.sh --tier=graph would be measuring a number corrupted at the producer.
Fixed 2026-07-16 — and it was never one line¶
ACTIVITY_BASE_ID is now https://dmccreary.github.io/learning-record-store/sims/sine-wave/.
Worth recording why this took more than a one-line edit, because the original note in
mvp-plan.md:84 described it as a single bad IRI. Fixing it surfaced five more wrong URLs in the
same file, none of which that note mentions:
| What | Was | Now |
|---|---|---|
ACTIVITY_BASE_ID |
…/microsims/sims/sine-wave/main.html |
…/learning-record-store/sims/sine-wave/ |
actor.account.homePage |
https://dmccreary.github.io/microsims/ |
https://demo.example.edu (§10) — it names an account namespace, not a website |
result.extensions ×2 |
…/microsims/xapi/ext/value, …/previous-value |
https://w3id.org/lrs/ext/… (§6) |
grouping[0] |
…/microsims/sims/sine-wave/ — a page URL |
the textbook version IRI (§4) |
metadata.json identifier |
…/microsims/sims/sine-wave/ |
…/learning-record-store/sims/sine-wave/ |
The grouping[0] one is the interesting failure: it was not merely pointing at the wrong host, it was
holding the wrong kind of thing entirely — this sim's own page URL where the textbook version
belongs. parent is where a page URL goes. A host-only search-and-replace would have "fixed" the URL
and left textbook_id/version_id unparseable, which is worse than the original, because it looks
correct.
The statement also gained parent[0] and the concept_id extension it needs to reach
mv_student_concept_rollup at all.
sine-wave never POSTs — the statements render in a log panel. But it is the artifact students read to learn what an xAPI statement is, so a shape the gateway would reject is a teaching bug, not a harmless one.
Rules¶
- Trailing slash is significant.
…/sims/x/and…/sims/xare different strings toORDER BY object_id. Always emit the slash. - Absolute HTTPS only. No relative paths, no
http://, nolocalhost. The IRI is an identifier and must be stable across environments — a statement emitted from a localmkdocs servemust carry the published IRI, nothttp://127.0.0.1:8000/…. - The IRI is an identifier, not a URL to fetch. It need not resolve at ingest time. It must not
change when the site moves; if
site_urlever changes, that is a migration, not a redeploy.
2. Question IRIs — the fragment scheme [RESOLVED 2026-07-16]¶
A question's
object.idis its page IRI +#q{N}, whereNis the question's ONE-BASED ordinal as presented to the student.#q1is the first question. This holds for every source — a MicroSim'skeyQuestionsarray and a chapter quiz's numbered list alike.
https://dmccreary.github.io/learning-record-store/sims/lrs-data-model/#q3
https://dmccreary.github.io/learning-record-store/chapters/01-what-is-an-ibook-lrs/quiz/#q1
One rule, not two. The tempting alternative was to let the fragment follow each source's own
natural indexing — a zero-based array index for keyQuestions (it is an array), a one-based ordinal
for a chapter quiz (it is a displayed number). That was rejected: it would give one field two
meanings, and every reader of a statement would have to know which kind of source produced it before
they could interpret the IRI.
Why one-based. The fragment should mean what the student and a debugger both see on the page.
#q0 denoting "Question 1" is a permanent footgun — every future reader has to remember the offset,
and a chapter quiz that visibly numbers itself 1–10 would emit IRIs disagreeing with its own headings.
The off-by-one this exposed, and how it was found¶
The first draft of this contract pinned zero-based, inferred from the only evidence available:
smoke.shemitted${OBJECT_ID}#q2while naming that question "How many PageEngagement vertices exist?" — which iskeyQuestions[2]zero-based, i.e. the third question. A one-basedq2would have been "Where do the raw xAPI statements actually live…", which does not match the name.
So the harness was self-consistent only under a zero-based reading, and the contract followed it. That inference was flagged as needing confirmation rather than presented as fact — correctly, because it was wrong. What the evidence actually showed was a latent off-by-one: the fragment was being written as a zero-based array index while the name beside it was chosen by counting questions the way a human does. The two conventions were already in conflict inside a single statement; nobody had noticed because nothing consumed it.
Fixed: smoke.sh now emits #q3 for that question, matching its name and this rule.
Method note. Building the first chapter quiz is what forced this. A four-question sim can hide an off-by-one; a quiz that prints "1." through "10." beside its own emitted IRIs cannot. A second, differently-shaped emitter is worth more than more reasoning about the first one.
Fragments for sub-activities that are not questions [NEW 2026-07-16]¶
#q{N} covers questions. A page can also contain named sub-activities — a slider, a node in a
diagram — and those need a fragment too.
A named sub-activity's fragment is its stable local name, slugified, not its position.
Why names here and ordinals for questions — this is one rule, not two. The fragment always names the sub-activity by its most stable local identifier, and what that is depends on what the thing is:
- A quiz question's identity is its position in a numbered list. The student sees "3."; the text may be reworded without becoming a different question. Ordinal is stable, prose is not.
- A diagram node's identity is its name.
Formulate Hypothesisstays that step even if the diagram is reordered or a branch is inserted above it. An ordinal would silently re-point the IRI at a different concept the moment the layout changed — the same class of failure as §1'smain.html: one activity quietly acquiring a second identity, or two acquiring one.
The test for any fragment scheme is the same: would an edit that does not change what the thing
is change its IRI? If yes, the scheme is wrong, because student_page_rollup and
student_concept_rollup both key on object_id and will split or merge silently.
The ordinal rule only holds when the order is stable [RESOLVED 2026-07-16]¶
#q{N}requires a STABLE presentation order. When a quiz randomizes its question order, the ordinal is not an identity and MUST NOT be used — name the question by what it asks about, using the named-sub-activity rule above.
…/sims/animal-cell/#q-nucleus ✔ randomized order — named
…/chapters/01-what-is-an-ibook-lrs/quiz/#q1 ✔ fixed order — ordinal
This was found by building sims/animal-cell, and it is a real defect in the rule as first
written, not a local preference. That sim's quiz does:
The order is reshuffled on every load. So #q1 is "identify the nucleus" for one student and
"identify the cytoplasm" for the next. mv_student_question_rollup is
GROUP BY (district_id, student_key, object_id), so answers about six different structures would
merge into six rows keyed by position — each one an average across whichever questions happened to
land in that slot. That is worse than collecting nothing, because it looks like data.
This is §2's own test failing: "would an edit that does not change what the thing is change its IRI?" Here not even an edit is required — a page reload does it. So the ordinal was never this question's stable identity; what it asks about is. That is exactly the named-sub-activity rule above, and this is therefore still one rule, not a third one: the fragment always names the sub-activity by its most stable local identifier. For a fixed-order quiz that is the number the student sees. For a shuffled one it cannot be.
Why #q-{name} and not bare #{name}. The q- prefix keeps questions visibly in the #q…
family, and — load-bearing — it keeps a question's IRI distinct from the hotspot of the same name
on the same page. animal-cell emits both #nucleus (a Control the student inspected) and
#q-nucleus (a Question they answered). Those are different activities that share a pixel; see §5's
new "one object, one type" note.
How to tell which you have: if you can reword a question without it becoming a different question, its identity is positional → ordinal. If you can reorder it without it becoming a different question, its identity is its subject → name. A shuffled quiz answers yes to the second, so it must be named.
3. Verbs [RESOLVED]¶
Exactly three verbs are valid in v1. A statement with any other verb is rejected at the gateway.
| Verb IRI | Emitted for | Required result |
|---|---|---|
http://adlnet.gov/expapi/verbs/answered |
A question attempt | success (bool). score.scaled when scored. |
http://adlnet.gov/expapi/verbs/experienced |
Page or MicroSim engagement | duration (ISO-8601). See §7. |
http://adlnet.gov/expapi/verbs/interacted |
A control being manipulated — a slider, a button | none required; result.extensions carry the value |
interacted was added 2026-07-16, widening mvp-plan.md:82's "2 verbs" scope. The reason is that
the plan's two verbs could not express what this repo's own MicroSims already do: sine-wave.js
emits interacted and docs/sims/sine-wave/index.md discusses it at length in the emission-strategy
trade-off. A slider drag is neither an answer (no success) nor dwell (no interval), so forcing it
into answered or experienced would make the verb carry a meaning it does not have. interacted
statements are still real evidence: they carry concept_id, so they feed
mv_student_concept_rollup's statements_compressed — which is the C-6 compression signal — even
though they contribute attempts = 0.
completed is not in v1. It is the only verb appearing anywhere in the design
(lrs-design-v1.md:1187), and it is none of these three. The design's smoke.sh used it; the
rewritten scripts/smoke.sh uses answered. That rewrite was correct and this ratifies it —
completed carries no success, so it cannot feed
mv_student_concept_rollup's countIf(result_success IS NOT NULL), and a rollup fed by completed
reports attempts = 0 for every student. Adding completed later means deciding what it means for
mastery first.
Why answered and not interacted for a question: already argued at smoke.sh. answered
carries the result.success and result.score.scaled the concept rollup needs to produce
attempts > 0. The two verbs coexist; they are not alternatives.
4. The textbook version IRI (grouping[0]) [RESOLVED]¶
context.contextActivities.grouping[0].id={site_url}textbook/{textbook_id}/{version_id}
https://dmccreary.github.io/learning-record-store/textbook/lrs/v1.0.0
→ textbook_id = "lrs" version_id = "v1.0.0"
This is smoke.sh's form and it is correct. lrs-design-v1.md:1194's
https://example.edu/textbook/lrs/v1.0.0 is a placeholder from the design's worked example —
example.edu is not a real host and must not reach a durable statement.
grouping[0] is required on every statement. textbook_id and version_id are
LowCardinality(String) and NOT NULL in lrs.statements; there is no sensible default, and a
statement that cannot be attributed to a textbook version cannot be replayed against the version of
the content it describes (C-2).
parent[0], when present, is the page IRI a question belongs to (smoke.sh). Required for
answered, meaningless for experienced (a page is not its own parent).
5. object.definition.type → object_type [NEW]¶
lrs.statements.object_type is Page | MicroSim | Question | Concept (clickhouse.sql), and two
materialized views filter on it — mv_student_page_rollup on 'Page', mv_student_question_rollup
on 'Question'. The design never says how the processor derives it. Without this mapping both
rollups are empty and C-6 has nothing to measure. This table decides it:
object.definition.type |
→ object_type |
Notes |
|---|---|---|
http://adlnet.gov/expapi/activities/lesson |
Page |
A prose/textbook page. |
http://adlnet.gov/expapi/activities/simulation |
MicroSim |
An interactive sim page. Object is the page IRI, no fragment. |
http://adlnet.gov/expapi/activities/cmi.interaction |
Question |
Ratified — smoke.sh already emits this. |
http://adlnet.gov/expapi/activities/interaction |
Control |
A slider or button within a page. Object IRI is fragment-qualified. |
| (anything else) | — | Reject at the gateway. |
Note cmi.interaction (Question) and interaction (Control) differ by four characters and mean
entirely different things. That is an unfortunate inheritance from the ADL vocabulary, not a choice
made here — but it is exactly the sort of thing a gateway validator should be strict about.
Concept is in the enum but no producer emits it. Concepts attach via the extension in §6, never
as an object. It stays for the enrichment path.
Why Control exists, and why it is not MicroSim [RESOLVED 2026-07-16]¶
mv_student_page_rollup is GROUP BY (district_id, student_key, object_id). A control's IRI is
fragment-qualified (…/sims/bouncing-ball/#speed-slider). So if a slider were typed MicroSim,
every control on a page would become its own PageEngagement vertex for a page the student visited
once — the same defect as naming main.html in §1, arriving by a different road. Controls are
therefore excluded from the page rollup by type, and land in the concept rollup instead, which is where
their evidence belongs.
object_type is a LowCardinality(String), not an ENUM, so adding Control needed no migration.
One object, one type — the mode does not change it [RESOLVED 2026-07-16]¶
object_typeis a property of the OBJECT, never of the UI mode the student was in. An artifact with two modes emits two objects, not one object with two types.
TODO.md raised this as an open contract question against the interactive-poster shape: does a
hotspot's object_type depend on the mode it is clicked in? sims/animal-cell is that artifact —
it has an explore mode and a quiz mode over the same six hotspots — and the answer is no.
| Mode | The student's act | object.id |
Type |
|---|---|---|---|
| explore | inspect the nucleus | …/sims/animal-cell/#nucleus |
Control |
| quiz | answer "where is the nucleus?" | …/sims/animal-cell/#q-nucleus |
Question |
Why not one IRI with a mode-dependent type. It is the same defect §1 rejects for main.html,
arriving from the opposite direction. §1 forbids one activity with two identities; this would
create two activities with one identity. Every reader of a statement would have to know the
emitter's UI state before they could interpret its IRI, and mv_student_question_rollup — which
filters object_type = 'Question' and groups by object_id — would key a question row on an IRI
that also denotes a hotspot. The type filter happens to keep the two apart in today's views, which
is precisely what makes it dangerous: it is correct by accident of the current DDL, not by
construction, and the first view that groups by object_id without a type filter merges them.
Inspecting a thing and being asked to find it are different activities. They are related, and
they should re-converge — but at the concept, which is where relatedness belongs.
mv_student_concept_rollup applies no type filter, so #nucleus and #q-nucleus both carry
concept_id: cell-nucleus and both land in one ConceptMastery vertex. Splitting the IRI
therefore costs nothing and buys type sanity: the explore statements contribute
statements_compressed at attempts = 0, the quiz statements contribute real attempts/successes,
and the rollup adds them up correctly without either pretending to be the other.
The general rule: if two acts would need different result fields to be honest, they are different
objects. An inspection has no success to report; an answer must have one (§3). That difference is
the tell.
The MicroSim rollup gap — closed¶
The first draft of this contract flagged that mv_student_page_rollup filtered object_type = 'Page',
so a MicroSim's dwell was stored in lrs.statements and then silently dropped at the rollup —
PageEngagement would report zero dwell for every sim in the textbook while the evidence sat in the log.
The bouncing ball made this concrete rather than theoretical: dwell is its entire output.
Resolved: clickhouse.sql's mv_student_page_rollup now filters
object_type IN ('Page', 'MicroSim'). Verified against a live ClickHouse with the statements the
bouncing ball actually emitted — the MicroSim's PT26.12S reaches dwell_ms_total as 26120, exactly
one rollup row is produced, and the #speed-slider Control does not leak in. Changed while nothing is
durable; the same change against a populated rollup would need a backfill.
6. Concepts: extension vs. enrichment [OPEN — a real conflict]¶
The design and the harness disagree, and this is not cosmetic.
lrs-design-v1.md:309(§5.3 step 3, "Enrich"): the processor attachessection_id,version_id, and "theconcept_idsthe object covers, from the cached structural graph." Producers send nothing; the LRS knows which concepts a page covers.scripts/smoke.sh: the producer sendscontext.extensions["https://w3id.org/lrs/ext/concept_id"] = "compression-ratio".
These are different architectures. Enrichment scales to unlabeled content and keeps the concept map server-side; the extension makes the producer authoritative and requires every emitter to know the concept taxonomy.
v1 rule (pragmatic, and reversible):
Producers SHOULD send
https://w3id.org/lrs/ext/concept_id— a single concept ID string. When present it is authoritative. When absent,concept_idsis[]and the statement is excluded frommv_student_concept_rollupby its ownWHERE notEmpty(concept_ids). Graph-based enrichment is deferred, not rejected: when it lands, it fills the gap when the extension is absent, and the extension still wins.
Why: no structural graph is seeded in the MVP, so enrichment has nothing to read. If the extension
weren't authoritative, concept_ids would be empty for every statement, mv_student_concept_rollup
would stay empty, and step 4's mastery join would have nothing to join — the exact null-forever failure
the mastery fix (clickhouse.sql) exists to prevent.
Note the singular/plural mismatch: the extension is concept_id (one string); the column is
concept_ids Array(String). The processor wraps it: concept_ids = [value]. Statements covering
multiple concepts are not expressible in v1. If that's needed, add …/ext/concept_ids (array) in v1.1
and keep the singular as an alias — do not redefine the singular key.
This changes the ingest contract relative to design §5.3, which is why it is tagged OPEN rather than silently written down.
7. Start/Pause: the dwell pattern [NEW 2026-07-16]¶
Nearly every MicroSim has a Start/Pause control, and it is the interaction the verb set could not
express until this section existed. The reference implementation is
docs/sims/bouncing-ball/.
A Start/Pause pair is ONE run interval, and a run interval is ONE
experiencedstatement, emitted on Pause, carrying the elapsed time asresult.duration.
| Event | Emit | Why |
|---|---|---|
| Start | Nothing. Record the wall clock. | A student who starts a sim and walks away has produced no evidence. A started with no matching paused is an unclosed interval nothing can score, and it would inflate statements_compressed with rows that carry no duration. |
| Pause | One experienced, result.duration = elapsed. |
The interval is the evidence. result.duration is the only field feeding dwell_ms_total, and one statement carries it as well as two do. |
| Tab hidden while running | The same experienced, flushed. |
Start-it-and-close-the-tab is the common case, not the edge case. Without a flush the modal student emits nothing at all. Use visibilitychange, not beforeunload — it is the only one that fires reliably on mobile Safari. |
| Run < 250 ms | Nothing. | A mis-click is not engagement. Emitting it puts PT0S rows into dwell_ms_total and pollutes the C-6 ratio with noise. |
The object is the page, not the button. object.id is the sim's page IRI with no fragment, typed
simulation → MicroSim (§5). The Start/Pause button is not its own activity: what is being
measured is engagement with the simulation, and the button is merely how the student expressed it. A
button-fragment IRI here would land the dwell in a PageEngagement vertex named after a button.
Why not two statements. The literal instrumentation — started on Start, paused on Pause — is
more xAPI-idiomatic and is what most LRS integrations do. It is rejected here because it doubles
statement volume for zero additional information, requires the reader to reconstruct duration by
pair-joining statements at read time (ordering under at-least-once delivery makes that unreliable), and
produces unclosed intervals whenever a student never pauses. The pattern above degrades gracefully:
the worst case is a missing interval, never a wrong one.
Paused-by-default is load-bearing. The MicroSim standard requires every sim to load paused. That is primarily pedagogical — a sim animating as a student scrolls past is a distraction. But it is also a data rule here: an auto-running sim would emit dwell the student never chose to spend, and every downstream engagement number would be inflated by however long the page happened to sit in a viewport.
Repeated cycles are repeated statements. Start→Pause→Start→Pause emits two experienced
statements, and mv_student_page_rollup sums them into one PageEngagement row via
sum(duration_ms). That is the intended compression: N intervals → 1 vertex.
8. What producers never send¶
| Field | Who sets it | Why not the producer |
|---|---|---|
district_id |
Gateway, from the auth token | A producer must not be able to claim another district's tenancy. |
student_key |
Processor, HMAC of the actor | §5.2's boundary. The producer sends the real identity in actor.account.name; the pseudonym is derived exactly once, server-side. |
stored_at |
Gateway, at receipt | Arrival time. timestamp is event time and is the producer's. |
section_id |
Processor (enrichment, design:309) | Roster data. Empty in v1 — nothing seeds the structural graph yet. |
voided_by |
The void API (F-3) | Retraction is never deletion. |
provisional |
Processor | Set when the object isn't reconciled yet (§5.4 accept-first). |
actor.account.name is PII by design and that is correct — it is the only place real identity
enters, and the processor rewrites the actor block before the raw column is stored
(clickhouse.sql). Producers must not pre-hash it: the salt is per-district and lives in the
vault.
9. Transport [RATIFIED]¶
POST /xapi/statements
Content-Type: application/json
X-Experience-API-Version: 1.0.3
Authorization: Bearer <token>
- Body is always a JSON array, even for one statement (
smoke.sh). - All-or-nothing per batch, per xAPI conformance and
mvp-plan.md:90. One invalid statement rejects the batch; there is no partial success. idis optional. When the producer supplies one it is preserved verbatim —smoke.shsendsidand then queriesWHERE statement_id = '${STATEMENT_ID}', so round-tripping it is load-bearing for the harness. When absent the gateway assigns a UUIDv7 (mvp-plan.md:90).timestampis ISO-8601 UTC, producer-supplied, event time.
Interaction with the §7 open question in
mvp-status.md. Producer-suppliedidis what makes gateway-side dedup onstatement_id(candidate (a)) possible at all. A producer that omitsidgets a fresh UUIDv7 per delivery and cannot be deduplicated — a retry becomes a distinct statement and double-counts in every rollup. If dedup lands,idbecomes required, not optional. Worth knowing before that decision, since it is a producer-visible change.
10. The reference statement¶
This is scripts/smoke.sh verbatim in shape, and it is the shape lrs loadgen must emit
(mvp-plan.md:94: "same shape in, nothing downstream changes").
[{
"id": "0190f8a1-...-7b3c",
"actor": {
"objectType": "Agent",
"account": {"homePage": "https://demo.example.edu", "name": "student-0042"}
},
"verb": {
"id": "http://adlnet.gov/expapi/verbs/answered",
"display": {"en-US": "answered"}
},
"object": {
"objectType": "Activity",
"id": "https://dmccreary.github.io/learning-record-store/sims/lrs-data-model/#q2",
"definition": {
"type": "http://adlnet.gov/expapi/activities/cmi.interaction",
"name": {"en-US": "How many PageEngagement vertices exist?"}
}
},
"result": {"score": {"scaled": 0.9}, "success": true, "duration": "PT4M12S"},
"context": {
"contextActivities": {
"grouping": [{"id": "https://dmccreary.github.io/learning-record-store/textbook/lrs/v1.0.0"}],
"parent": [{"id": "https://dmccreary.github.io/learning-record-store/sims/lrs-data-model/"}]
},
"extensions": {"https://w3id.org/lrs/ext/concept_id": "compression-ratio"}
},
"timestamp": "2026-07-16T14:22:03Z"
}]
actor.account.homePage is https://demo.example.edu — a demo tenant, not a placeholder to purge.
Unlike example.edu in grouping (§4), it identifies the account namespace rather than a content
version, and real districts supply their own.
11. Field → column map¶
Everything the DDL reads, and where it comes from. If a row here is wrong, a rollup is wrong.
| Statement path | Column | Notes |
|---|---|---|
id |
statement_id UUID |
Or gateway UUIDv7 — §9. |
actor.account.name |
→ student_key |
HMAC'd. Never stored raw. |
verb.id |
verb_id |
§3. Exactly two values. |
object.definition.type |
→ object_type |
§5. Both page/question MVs filter on this. |
object.id |
object_id |
§1. Trailing slash significant. |
grouping[0].id |
→ textbook_id, version_id |
§4. Parsed from the IRI. |
result.score.scaled |
result_score Nullable(Float32) |
0.0–1.0. Null ⇒ not counted in score_count. |
result.success |
result_success Nullable(UInt8) |
Null ⇒ not counted in attempts. |
result.duration |
duration_ms Nullable(UInt32) |
ISO-8601 → ms. Feeds dwell_ms_total. |
ext/concept_id |
→ concept_ids Array(String) |
§6. Wrapped to a 1-element array. |
timestamp |
timestamp DateTime64(3) |
Event time. Partition key + rollup min/max. |
| (whole statement) | raw |
Actor block pseudonymized first — clickhouse.sql. |
12. Open items¶
Still open:
- §6 — extension vs. enrichment. v1 makes the producer authoritative. This contradicts design §5.3 step 3 and should be reflected there, or reversed here.
- §9 —
idbecomes required if gateway dedup lands (mvp-status.md§7 candidate (a)). - The design doc is not yet amended. §§1, 3, 4, 5, 6, 7 each correct or fill something in
lrs-design-v1.md. Tracked in the plan's "Amend the docs as we go". smoke.shdoes not yet exerciseinteractedor the §7 dwell pattern. Its one statement is ananswered. Neither new path has a tier asserting it.- Multi-concept statements are not expressible (§6).
concept_idis one string. If a page covers three concepts, v1 cannot say so. - Repeat attempts — a producer now exists; the policy is still open.
quiz-xapi.jsemits at most oneansweredper question per page load, and never for an answer chosen after the explanation was revealed. That is the right default for a reveal-the-answer quiz — a peeked answer is not evidence of knowledge.
What changed 2026-07-16: sims/animal-cell's quiz mode does produce a sequence, and it is
the first thing in the repo that does. It never reveals the answer, and handleAnswer() locks only
on a correct click, so a wrong click leaves the question live and the student retries. Verified
in-browser: three clicks at one question emitted three answered statements against the single IRI
…/#q-mitochondria with success: false, false, true — which
mv_student_question_rollup aggregates to attempts = 3, successes = 1.
Emitting the failures is the honest choice, not the noisy one. With six hotspots a student can
brute-force: click every marker and the last is necessarily right. Emitting only the success would
render that student identical to one who knew it instantly (attempts = 1, successes = 1) — the
rollup would report mastery the interaction plainly disproves. The full sequence yields
attempts = 6, successes = 1, which is what BKT's guess parameter exists to read. For a
click-to-identify quiz the sequence is not noise around the signal; it is the signal.
Note this is consistent with the peek rule rather than an exception to it: both follow from do not report success the interaction does not support.
Still open: whether a reveal-style quiz should offer retry at all (that is the UX decision
the original item named), and whether BKT should treat within-question retries as one opportunity
or several. A guess-then-correct sequence is not the same evidence as two attempts a week apart,
and mv_student_concept_rollup currently cannot tell them apart — it counts both as attempts.
That distinction needs deciding before F-7 is read as a mastery number.
7. Every emitter hardcodes demo-student. One actor means the §8 pseudonymization boundary has
never been exercised by a producer. That is loadgen's job, not a sim's.
8. The same activity in two textbooks is indistinguishable in every rollup. object.id is the
canonical URL (§1), so a shared MicroSim — sims/scientific-method/ is embedded by physics and
chemistry as well as this book — carries the same IRI everywhere; only grouping[0] differs.
But every rollup is keyed (district_id, student_key, {concept_id|object_id}) with no
textbook_id, so exposures across books merge into one vertex with dwell summed.
lrs.statements retains textbook_id/version_id, so the question is answerable from the log —
never from ConceptMastery. This is the two-store split working, not a defect, but it has a
sharp consequence: a student skimming because they already met the material in physics looks
identical to a student who did not engage. Low engagement is not evidence of low mastery. If a
report ever wants to distinguish "this book taught them" from "they arrived knowing it", that
requires either a textbook-keyed rollup or a log query — decide which before a dashboard implies
the former.
9. Per-node dwell reaches no rollup. interacted/Control statements carry result.duration
(scientific-method emits per-step dwell), but mv_student_page_rollup sums duration_ms while
excluding Control, and mv_student_concept_rollup ignores duration entirely. So "which step did
the student labour over?" lives only in lrs.statements. Deliberate for now — the log is the
system of record, so a rollup can be added later without re-collecting anything.
- Every emitter still renders; none POSTs, and the shape has never been validated by a
consumer.
docs/js/lrs-xapi.jsnow enforces the rules this document states — it rejects non-v1 verbs and types outright, and warns on amain.htmlIRI, a fragment-qualifiedMicroSim/Page, anansweredwith nosuccess, and a local origin. That is a real improvement over five hand-written copies, but it is the producer marking its own homework. Until the gateway exists, "conforms to the contract" means "conforms to one JS file's reading of it."
Closed 2026-07-16:
- ~~Does a hotspot's
object_typedepend on the mode it is clicked in?~~ Resolved: no.object_typeis a property of the object; two modes emit two objects (#nucleusControl,#q-nucleusQuestion) which re-converge at the concept rollup. See §5's "one object, one type". Settled against a real artifact —sims/animal-cell— asTODO.mdinsisted it should be. - ~~§2's ordinal rule is universal.~~ Amended: it requires a stable presentation order.
sims/animal-cellshuffles its quiz on every load, so#q{N}would have merged six different questions into six position-keyed rows. Named fragments (#q-nucleus) apply. See §2. - ~~Five emitters, five implementations of the contract.~~ Extracted to
docs/js/lrs-xapi.js;animal-cellis the first consumer. The derive-vs-hardcode split is resolved in favour of derive, which required a rule the previous derive-based copy did not have: strip themain.html/index.htmlpayload filename, not just the site base path. Verified in-browser — from a localhost iframe at/learning-record-store/sims/animal-cell/main.htmlthe module deriveshttps://dmccreary.github.io/learning-record-store/sims/animal-cell/. §1 is now satisfied by construction rather than by luck. The four older emitters have not been migrated yet.
Closed 2026-07-16:
- ~~§2 — is
#q{N}zero-based?~~ Resolved: ONE-based, one rule for every source. The zero-based pin was an inference fromsmoke.shthat turned out to be reading a latent off-by-one rather than an intent.smoke.shnow emits#q3for the question it names. See §2.
Closed 2026-07-16:
- ~~§5 — MicroSim engagement doesn't reach
student_page_rollup.~~ Fixed. MV widened toobject_type IN ('Page','MicroSim'), verified against live ClickHouse with real emitted statements. - ~~
sine-wave.jsemits a malformed IRI.~~ Fixed, along with five other wrong URLs in the same file that the original note missed —actor.account.homePage, tworesult.extensionsnamespaces,grouping[0](which held a page URL where the version IRI belongs), andmetadata.json'sidentifier. The lesson generalizes: the malformed IRI was never one line, and "fix the URI" was not a one-line job. - ~~Start/Pause has no contracted representation.~~ Now §7, with
bouncing-ballas the reference emitter.