Appearance
OA Simulator & Exam Integrity: Risk Engine & the Hash-Chained Audit Log
Part 3 of 5 in the OA Simulator & Exam Integrity series.
Marking Schemes & Scoring covered how answers become marks. This page covers the parallel system that runs alongside a desktop OA attempt and answers a different question: how suspicious was this sitting, and can that answer be trusted after the fact? Design principle #1 in EXAM_INTEGRITY.md (ediky-desktop) states the goal precisely: explainability over blocking — cheating on a candidate-owned machine cannot be made impossible, only harder, fully observable, and honestly explained. Every number this system produces traces back to a reasons string a human can read.
Risk, not a binary verdict
riskEngine.js (src/lib/integrity/riskEngine.js) turns a stream of events into one explainable score through four independent stages:
javascript
// Simplified real pipeline shape (riskEngine.js)
function add(event) {
const base = weightFor(policy, event) * (event.confidence ?? 1); // normalize
const factor = comboFactorFor(event) * frequencyFactorFor(event); // modifiers
events.push({ ...event, base, factor });
for (const rule of matchCorrelations(RULES, events, event)) { // correlate
events.push({ type: `correlation:${rule.key}`, base: rule.boost, factor: 1, synthetic: true });
}
return evaluate();
}
function decayedContribution(e, now) {
const age = now - e.t;
return e.base * e.factor * Math.pow(0.5, age / policy.decayHalfLifeMs); // decay
}Frequency escalates repeats of the same event type within a 10-minute window by +20% each, capped at ×2 — one tab switch is a glance at the clock, nine is a pattern. Combo multiplies an external paste arriving within the policy's window of returning from another app by ×1.5 — the copy-from-assistant signature. Decay halves a contribution's weight every decayHalfLifeMs, evaluated at read time, so one early mistake doesn't poison a three-hour exam. breakdown() exposes the decay credit — points clean elapsed time has already earned back — as an explicit negative line, so the score visibly moves in both directions, not just up.
Correlation: weak signals in a telling sequence
A single ambiguous signal (a blur, an idle stretch) stays deliberately cheap. correlations.js declares multi-event patterns that fire a synthetic, fully-explained score boost only when independent signals line up — away → return → external paste within 60s (+20), flagged app + external paste within 2 minutes (+15). These synthetic entries are never persisted to the audit log; the result page regenerates them deterministically from the raw event log on replay, so a correlation can never double-count.
Confidence is calibrated, not assumed
| Band | Range | Meaning |
|---|---|---|
| Certain | 1.0 | The event is exactly what it says (tab_switch — the window was hidden) |
| High | 0.8–0.95 | Rare benign explanations exist (blur — system popups steal focus too) |
| Medium | 0.6–0.79 | Benign explanations are common (app_flagged — running ≠ using) |
| Low | ≤0.5 | Ambiguous by nature (idle — thinking looks identical to being away) |
Policies are inherited data, not per-feature code
Four presets in policies.js, built by inheritance (extendPolicy: base → practice → mock → company → certification) so a shared tuning change propagates instead of being copy-pasted:
| Preset | Clipboard | Watcher | Fullscreen | Thresholds (M/H/C) | Decay half-life |
|---|---|---|---|---|---|
| Practice | allow | off | no | 60/140/260 | 8 min |
| Mock interview | warn | on | no | 40/90/180 | 10 min |
| Company OA | restrict-external | on | no | 30/70/140 | 15 min |
| Certification | restrict-copy-and-external | expected | expected | 25/60/120 | 30 min |
An integrityOverrides object on the individual attempt can extend the resolved preset further (custom weights, thresholds, or disabling correlations) without inventing a fifth hardcoded tier — and old attempts saved before this system existed simply resolve to Practice.
The hash chain: tamper evidence, three layers
Every event lands in SQLite (integrity_events, migration 0008_integrity.sql) with its own hash, computed in Rust:
rust
// integrity.rs — real shape
fn genesis_hash(session_id: &str, attempt_id: &str, policy: &str) -> String {
hex::encode(Sha256::digest(format!("ediky-integrity:{session_id}:{attempt_id}:{policy}")))
}
fn event_hash(prev: &str, seq: i64, ts: i64, kind: &str, severity: &str, confidence: f64, detail: &str) -> String {
let mut h = Sha256::new();
h.update(prev.as_bytes());
h.update(format!(":{seq}:{ts}:{kind}:{severity}:{confidence:.4}:").as_bytes());
h.update(detail.as_bytes());
hex::encode(h.finalize())
}- Chain — each event's hash covers the previous event's hash plus its own fields;
seqis dense from 1.integrity_verifyrecomputes the whole chain from the genesis and reports the first brokenseq— editing, deleting, or reordering any row flips the report to "Log tampered." - Context binding — the genesis hash itself covers the session id, the attempt id, and the policy snapshot, so a verified chain also proves which attempt it belongs to and which thresholds it was scored under. A chain can't be transplanted onto a different attempt or silently re-scored under a softer policy.
- Anchoring — at seal time, the exam page stamps every session's head
(seq, hash)onto the attempt document itself (attempt.integrityAnchors). The report compares anchors against the current SQLite heads: a log rewritten after submit shows "Modified after submit" even if the replacement chain is internally self-consistent.
rendering diagram…
drag to pan · ctrl / ⌘ + scroll to zoom
A single writer (the exam page), serialized by the global DB mutex, makes the seq counter race-free with no locking logic in JS. The honestly-stated limit: all three layers live on the candidate's own machine, so a sufficiently determined attacker who edits both the SQLite chain and the attempt doc's anchors consistently can still fabricate history — closing that gap needs off-machine anchoring (pushing head hashes to a server on sync), which is roadmapped, not shipped.
Self-assessment
Word count for this page: 903 words.
← Previous: Marking Schemes & Scoring→ Next: Proctoring & Detection Logic