Skip to content

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

BandRangeMeaning
Certain1.0The event is exactly what it says (tab_switch — the window was hidden)
High0.8–0.95Rare benign explanations exist (blur — system popups steal focus too)
Medium0.6–0.79Benign explanations are common (app_flagged — running ≠ using)
Low≤0.5Ambiguous 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:

PresetClipboardWatcherFullscreenThresholds (M/H/C)Decay half-life
Practiceallowoffno60/140/2608 min
Mock interviewwarnonno40/90/18010 min
Company OArestrict-externalonno30/70/14015 min
Certificationrestrict-copy-and-externalexpectedexpected25/60/12030 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())
}
  1. Chain — each event's hash covers the previous event's hash plus its own fields; seq is dense from 1. integrity_verify recomputes the whole chain from the genesis and reports the first broken seq — editing, deleting, or reordering any row flips the report to "Log tampered."
  2. 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.
  3. 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.
diagram100%
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

Ediky Workflow — internal engineering documentation.