Kavryx Dev
← All products
Real-time & InteractivePROTOTYPE

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

FeatureWhy it matters
🔗Cryptographic event chainhashₙ = SHA256(hashₙ₋₁ ‖ payload ‖ ts ‖ seq) — one edited byte breaks the chain and is caught at ingestion and again at grading.
📴Zero-data-loss offline modebetter-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 & reconciliationServer 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 clockElapsed 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 identityAn 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 syncDurable 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

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)**

Rendering diagram…

Component breakdown

ComponentPackageResponsibility
ExamEngineclient-core/engineDeterministic state machine: unlock, answer select/clear, focus events, ticks, submit, and restore() — rebuilds exact state from the local log after a crash.
ExamRecorderclient-core/recorderThe ordering contract: sign → chain → append → return. The UI never observes a state change until the event is durable.
ExamTimerclient-core/engineMonotonic clock immune to wall-clock edits; folds signed server anchors as one-way ratchets.
EventStoreclient-core/dbSQLite WAL ledger with triggers that make UPDATE/DELETE of audited columns physically impossible; synced is the only mutable flag.
SyncQueueclient-core/syncDrains unsynced rows in ≤100-event batches with full-jitter backoff; permanent rejections stay pending for forensic review.
Verification APIserver/apiStateless HTTP surface (/unlock, /anchor, /events/batch, /submit, /grades/:id) — no sessions, so reconnecting after hours is fine.
Chain Auditorserver/reconciliationRecomputes every chain hash from raw rows and verifies every signature against the key bound at unlock.
Replay + Gradingserver/reconciliationPure fold producing answers, focus-loss counts, digests, and the GradeSheet — independent of anything the client claimed.
shared-typespackages/shared-typesThe 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

Exam Attempt Lifecycle

Interactive version with per-message focus: 🔍 docs/exam-lifecycle.html

🧩 Native Mermaid view

Rendering diagram…

Step-by-step

  1. 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_UNLOCKED opens the chain.
  2. Answer offline. Every ANSWER_SELECTED / FOCUS_LOST / TICK is hashed into the chain, Ed25519-signed, and committed to WAL SQLite before the UI advances. Disconnects are invisible to the candidate.
  3. Reconnect and ship. SyncQueue drains 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 via ON CONFLICT DO NOTHING.
  4. 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.
  5. Submit seals the chain. The client's final EXAM_SUBMITTED declares an answer digest — the server will independently recompute it.
  6. 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, and ANSWER_AFTER_SUBMIT all land in violations[] — never silently.

🧰 Tech Stack

LayerTechnologyRole
LanguageTypeScript 5.7 (strict, NodeNext, ESM)Typed end-to-end; shared Zod contracts
RuntimeNode.js ≥ 20.10Client host + server
Client storagebetter-sqlite3 — WAL, synchronous=FULL, append-only triggersZero-data-loss local ledger
Cryptography@noble/ed25519 · @noble/hashes (SHA-256/512)Event signatures + hash chain
ContractszodWire-schema validation on both sides of the boundary
Server APIExpress 5Stateless HTTP ingestion + reconciliation
Server storagePostgreSQL 16 (pg)Immutable event ledger, attempts, grades
Toolingnpm workspaces monorepo · tsc · Docker Compose · Makefile · vitestBuild, 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.yml publishes host 5432. If you already run Postgres locally, change the host side of the ports mapping (e.g. 54320:5432) and point DATABASE_URL at 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

MethodEndpointPurpose
GET/api/v1/healthLedger reachability probe
POST/api/v1/exams/:examId/unlockVerify candidate, bind ephemeral key, return stripped manifest + signed anchor
POST/api/v1/exams/:examId/anchorIssue a fresh single-use signed clock anchor
POST/api/v1/exams/:examId/events/batchIngest ≤100 signed events with prefix verification; idempotent ack
POST/api/v1/exams/:examId/submitAudit + replay + grade; writes GradeSheet
GET/api/v1/exams/:examId/grades/:candidateIdFetch 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

  1. The hash chain is the source of truth — the server recomputes it and never trusts a client-supplied head.
  2. The UI may only show state that is durably committed.
  3. Exam duration is measured in monotonic time; wall-clock input is audit metadata only.
  4. A replayed grade must equal the live grade shown to the candidate.
  5. event_log is 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

  1. Fork the repo and create a feature branch from main.
  2. Install with npm install; keep the build honest: npm run typecheck && npm test must pass.
  3. Add or update tests for every behavioral change — determinism claims are verified by tests, not comments.
  4. 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.