Offline-First Exam Engine
Exam software that keeps working when the network does not, and keeps every contested grade provable afterwards.
At a glance
- Deterministic scoring that replays identically
- Offline-first client sync with conflict resolution
- Tamper-evident submission records
- PostgreSQL-backed attempt and answer store
- TypeScript end to end with Vitest coverage
From the repository
README
Offline-first exam runtime with a cryptographically chained event log — engineered so a power cut, a 30-minute network blackout, or a hostile candidate device still produces the same, verifiable grade.
System architecture · 🔍 Interactive HTML · 📐 Regeneration source Attempt lifecycle · 🔍 Interactive HTML · 📐 Regeneration source
📌 Overview
Exam software has to survive three hostile facts: candidates control their own devices, school networks are unreliable, and a contested grade must be provable long after the exam ends.
This engine treats every candidate action as an entry in an append-only, SHA-256 chained, Ed25519-signed event log. The client is a pure fold over that log; the server is a second, independent fold over the same bytes. The client cannot cheat because the server recomputes every hash from raw events — it never trusts anything the client claims. The network cannot interrupt the exam because every action is committed to WAL-mode SQLite before the UI advances. And a grade can be re-audited forever because the sealed chain hash is the receipt.
✨ Key Features
| Feature | Why it matters | |
|---|---|---|
| 🔗 | Cryptographic event chain | hashₙ = SHA256(hashₙ₋₁ ‖ payload ‖ ts ‖ seq) — one edited byte breaks the chain and is caught at ingestion and again at grading. |
| 📴 | Zero-data-loss offline mode | better-sqlite3 with journal_mode=WAL, synchronous=FULL, and append-only triggers: committed events survive a power cut; the exam never pauses for the network. |
| 🧮 | Deterministic replay & reconciliation | Server grades by folding the same log with the same rules. Same log → same answer set → same GradeSheet, on any machine, in any arrival order. |
| ⏱️ | Monotonic tamper-proof clock | Elapsed time comes from performance.now(). Wall-clock edits, timezone changes, and NTP steps cannot extend the exam; server-signed anchors ratchet time forward only. |
| 🔑 | Ephemeral per-attempt identity | An Ed25519 keypair is generated in memory at unlock and bound server-side to the attempt — events signed by any other key are rejected with 403 PUBLIC_KEY_MISMATCH. |
| 📡 | Resilient batch sync | Durable sync queue re-reads pending rows from disk every attempt: full-jitter exponential backoff, ON CONFLICT DO NOTHING ingestion, so a lost ack is safe to resend. |
🏗️ System Architecture

Live, zoomable version with search, theme toggle, and focus views: 🔍 docs/architecture.html — open it in any browser.
** Native Mermaid view (renders directly on GitHub)**
Component breakdown
| Component | Package | Responsibility |
|---|---|---|
ExamEngine | client-core/engine | Deterministic state machine: unlock, answer select/clear, focus events, ticks, submit, and restore() — rebuilds exact state from the local log after a crash. |
ExamRecorder | client-core/recorder | The ordering contract: sign → chain → append → return. The UI never observes a state change until the event is durable. |
ExamTimer | client-core/engine | Monotonic clock immune to wall-clock edits; folds signed server anchors as one-way ratchets. |
EventStore | client-core/db | SQLite WAL ledger with triggers that make UPDATE/DELETE of audited columns physically impossible; synced is the only mutable flag. |
SyncQueue | client-core/sync | Drains unsynced rows in ≤100-event batches with full-jitter backoff; permanent rejections stay pending for forensic review. |
Verification API | server/api | Stateless HTTP surface (/unlock, /anchor, /events/batch, /submit, /grades/:id) — no sessions, so reconnecting after hours is fine. |
Chain Auditor | server/reconciliation | Recomputes every chain hash from raw rows and verifies every signature against the key bound at unlock. |
Replay + Grading | server/reconciliation | Pure fold producing answers, focus-loss counts, digests, and the GradeSheet — independent of anything the client claimed. |
shared-types | packages/shared-types | The wire contract: Zod event schemas, canonical JSON encoding, and hash primitives shared byte-for-byte by both sides. |
Trust boundaries
- Boundary 1 — the candidate device is untrusted. Everything inside it is treated as adversarial input: the private key never leaves RAM, the wall clock is audit metadata only, and the answer key never ships to the client (
publicManifest()strips it). - Boundary 2 — the server holds the truth. Ingestion writes only after prefix verification; the attempt's public key is pinned at unlock; grading reads only the server's own ledger. A forged or reordered log is rejected deterministically, never "repaired".
🔄 Core Workflow — Exam Attempt Lifecycle

Interactive version with per-message focus: 🔍 docs/exam-lifecycle.html
🧩 Native Mermaid view
Step-by-step
- Unlock (the only hard network requirement). The client POSTs its ephemeral public key; the server binds it to the attempt and returns the answer-key-stripped manifest plus a signed time anchor.
EXAM_UNLOCKEDopens the chain. - Answer offline. Every
ANSWER_SELECTED/FOCUS_LOST/TICKis hashed into the chain, Ed25519-signed, and committed to WAL SQLite before the UI advances. Disconnects are invisible to the candidate. - Reconnect and ship.
SyncQueuedrains pending rows in batches. The server verifies the stored prefix (assertChainPrefix) and each new event against its running head before writing anything; valid segments append atomically, duplicates conflict-hop viaON CONFLICT DO NOTHING. - Ack is the sync checkpoint. Only server-acknowledged sequences flip
synced=1. A crash between send and ack costs at most one batch, which is safely resent. - Submit seals the chain. The client's final
EXAM_SUBMITTEDdeclares an answer digest — the server will independently recompute it. - Reconcile. The auditor recomputes every hash and signature from raw ledger rows, the replayer folds the log into the final answer set, and the grader scores it against the server-held key.
DIGEST_MISMATCH,DURATION_EXCEEDED, andANSWER_AFTER_SUBMITall land inviolations[]— never silently.
🧰 Tech Stack
| Layer | Technology | Role |
|---|---|---|
| Language | TypeScript 5.7 (strict, NodeNext, ESM) | Typed end-to-end; shared Zod contracts |
| Runtime | Node.js ≥ 20.10 | Client host + server |
| Client storage | better-sqlite3 — WAL, synchronous=FULL, append-only triggers | Zero-data-loss local ledger |
| Cryptography | @noble/ed25519 · @noble/hashes (SHA-256/512) | Event signatures + hash chain |
| Contracts | zod | Wire-schema validation on both sides of the boundary |
| Server API | Express 5 | Stateless HTTP ingestion + reconciliation |
| Server storage | PostgreSQL 16 (pg) | Immutable event ledger, attempts, grades |
| Tooling | npm workspaces monorepo · tsc · Docker Compose · Makefile · vitest | Build, env, tests |
📁 Project Structure
.
├── packages/
│ ├── shared-types/ # Zod event/manifest schemas, canonical JSON, hash primitives
│ │ └── src/{events,exam,hash,signing}.ts
│ ├── client-core/ # Offline-first client engine
│ │ └── src/
│ │ ├── crypto/ # keys.ts (Ed25519) · hasher.ts (chain)
│ │ ├── db/ # sqlite.ts + schema.sql (WAL ledger, append-only triggers)
│ │ ├── engine/ # state.ts (fold) · timer.ts (monotonic clock)
│ │ ├── sync/ # queue.ts (batched sync + full-jitter backoff)
│ │ └── recorder.ts # sign → chain → append contract
│ └── server/ # Central verification & reconciliation
│ └── src/
│ ├── api/ # routes.ts · auth.ts · crypto.ts · catalog.ts
│ ├── db/ # pool.ts + schema.sql (immutable ledger)
│ └── reconciliation/ # auditor.ts (chain audit) · replay.ts (grade)
├── tests/
│ ├── unit/ # crypto_chain · deterministic_replay · exam_state
│ └── integration/ # offline_sync_recovery · server_reconciliation
├── docs/ # Diagrams
│ ├── architecture.html # Interactive Archify diagram
│ ├── exam-lifecycle.html # Interactive Archify diagram
│ └── assets/ # Static PNG previews (light/dark)
├── docker-compose.yml # PostgreSQL 16 + schema auto-init
├── Makefile # install · build · typecheck · test · db-up/down
└── package.json # npm workspaces
🚀 Quick Start
Prerequisites: Node.js ≥ 20.10 · Docker (for PostgreSQL)
# 1 · Clone
git clone https://github.com/<your-org>/education-deterministic-exam-engine.git
cd education-deterministic-exam-engine
# 2 · Install workspace dependencies
npm install
# 3 · Start PostgreSQL 16 (auto-applies packages/server/src/db/schema.sql)
npm run db:up # = docker compose up -d postgres
# 4 · Build all packages and typecheck
npm run build
npm run typecheck
# 5 · Run the verification server (defaults to :8080, exam:exam@localhost:5432)
npm run start:server
# 6 · Exercise the API
curl http://localhost:8080/api/v1/health
🔧 Configuration & Make targets
Server environment variables (with defaults):
PORT=8080
DATABASE_URL=postgres://exam:exam@localhost:5432/exam_engine
Port already in use?
docker-compose.ymlpublishes host5432. If you already run Postgres locally, change the host side of theportsmapping (e.g.54320:5432) and pointDATABASE_URLat the same port.
Make targets:
make install # npm install
make build # compile shared-types → client-core → server
make typecheck # tsc --noEmit across the monorepo
make test # vitest run (94 tests)
make db-up # docker compose up -d postgres
make db-down # docker compose down
make clean # remove build output
🔌 API Surface
| Method | Endpoint | Purpose |
|---|---|---|
GET | /api/v1/health | Ledger reachability probe |
POST | /api/v1/exams/:examId/unlock | Verify candidate, bind ephemeral key, return stripped manifest + signed anchor |
POST | /api/v1/exams/:examId/anchor | Issue a fresh single-use signed clock anchor |
POST | /api/v1/exams/:examId/events/batch | Ingest ≤100 signed events with prefix verification; idempotent ack |
POST | /api/v1/exams/:examId/submit | Audit + replay + grade; writes GradeSheet |
GET | /api/v1/exams/:examId/grades/:candidateId | Fetch sealed grade |
🧪 Testing
npm test # 5 files · 94 tests · ~5s
npm run test:watch # vitest watch mode
- Unit — chain corruption detection, sequence-gap rejection, state-machine transitions, replay-vs-client grade equality, tamper scenarios.
- Integration — a full offline attempt: 30 minutes of answer churn with the network off, reconnect, batch sync, and server reconciliation that must equal the client's view event-for-event.
- Reconciliation — forged signatures, broken chains, replayed anchors, and post-submission writes are each rejected with the exact violation code.
🛡️ Non-Negotiable Invariants
- The hash chain is the source of truth — the server recomputes it and never trusts a client-supplied head.
- The UI may only show state that is durably committed.
- Exam duration is measured in monotonic time; wall-clock input is audit metadata only.
- A replayed grade must equal the live grade shown to the candidate.
event_logis append-only on both sides — enforced by SQLite triggers and PostgreSQL trigger functions.
🗺️ Roadmap
- Phase 1 — Monorepo, tooling, Postgres compose, shared contracts
- Phase 2 — Ed25519 keys, SHA-256 chaining, append-only SQLite store
- Phase 3 — Deterministic state machine + monotonic timer
- Phase 4 — Batch sync queue + server ingestion
- Phase 5 — Server audit, replay, grading + E2E offline recovery
- Candidate portal UI (Electron / web shell over
client-core) - Exam-authoring CLI and encrypted answer-key vault
- Merkle root checkpoints per N events (audit without full replay)
🤝 Contributing
- Fork the repo and create a feature branch from
main. - Install with
npm install; keep the build honest:npm run typecheck && npm testmust pass. - Add or update tests for every behavioral change — determinism claims are verified by tests, not comments.
- Open a PR describing the invariant your change preserves.
📄 License
Distributed under the MIT License. See LICENSE for details.
Built as an offline-first, determinism-first examination platform.
Architecture and lifecycle diagrams are generated with Archify — candidates live at docs/architecture.source.json and docs/exam-lifecycle.source.json.
⭐ Star the repo if deterministic exam verification interests you.