An academic-integrity telemetry and analysis system.
Provenance has two halves that share one artifact:
- Provenance Recorder — a VS Code extension that runs while a student works on an assignment and produces a tamper-evident log of how the code came into existence.
- Provenance Analyzer — a full-stack web app used by course staff to ingest, score, and review those logs at scale. Includes: a cohort list with filter/sort/export, per-submission drill-in with timeline replay and validation, a heuristics tuning UI, cross-submission paste detection, and a standalone offline mode (
/local) that runs entirely in-browser.
The full design lives in docs/prd.md. Code conventions for working in this repo are in CLAUDE.md.
Provenance is an npm workspace of five packages. Each builds on log-core; none of the
top-level packages depend on each other's source.
| Package | What it is |
|---|---|
packages/log-core |
The log format shared by every other package: event types, JCS canonicalization, the hash chain, the validator, ndjson serialization, bundle and manifest shapes, and ed25519 manifest verification. Pure TypeScript with zero dependencies on VS Code, Node, or the DOM, so the same code runs in the extension, the browser, and the server. |
packages/recorder |
The VS Code extension that records a tamper-evident .provenance log while a student works: all PRD §4 event types, three-signal paste detection, external-change detection, a per-session signing keypair, signed checkpoints, chain recovery, bundle sealing, and a disk-full degraded mode. |
packages/shared |
The Zod schemas that define the HTTP API contract, imported by both the server and the analyzer so the two stay in sync. |
packages/analyzer |
The React/Vite single-page app course staff use to review submissions: Google OAuth login, semester switcher, a virtualized cohort list, per-submission drill-in (overview / timeline / replay / validation), a 24-slider heuristics tuning UI, cross-submission flags, and an export panel. A standalone /local route runs entirely in-browser from a dropped .zip. |
packages/server |
The Node.js + Hono API server: PostgreSQL via Drizzle ORM, Google OAuth with sessions and API tokens, the ZIP ingest pipeline (parse → match → heuristics → cross-flags), a pg-boss job queue, an OpenAPI 3.1 spec with Redoc, Prometheus metrics, and retention/purge cron jobs. Object storage is S3-compatible (MinIO in dev). |
Requires Node 22+ and npm 10+. Docker is required to run the server (Postgres + MinIO via docker compose).
git clone <repo> provenance
cd provenance
npm install
npm run buildnpm run build is a one-time prerequisite, not just a test step. The workspace
packages (log-core, shared, analysis-core) are consumed through their
exports maps, which point at built output in each package's git-ignored dist/.
Until you build, that dist/ is absent and any dev entrypoint that imports these
packages — notably the analyzer frontend — fails to resolve with
imported but could not be resolved errors. Re-run npm run build (or the
per-package build script) after pulling changes to those packages.
npm run build && npm run typecheck && npm run lint && npm run testRequires Docker. The packages/server/README.md has the
full server dev guide (run modes, migrations, env var reference); the essentials are:
# 1. Start Postgres + MinIO
docker compose up -d
# 2. Create the MinIO storage bucket (one-time — uploads 404 without it)
docker compose exec minio mc alias set local http://localhost:9000 minioadmin minioadmin
docker compose exec minio mc mb local/provenance
# 3. Configure environment. Defaults match the compose stack; fill in Google
# OAuth creds for real logins (dummy values are fine for API/worker/seed work).
cp packages/server/.env.example packages/server/.env
# 4. Run migrations
npm run db:migrate --workspace=packages/server
# 5. Start the server — API + pg-boss worker in ONE process (`--mode=all`)
npm run dev --workspace=packages/serverThe server starts on http://localhost:3000. Swagger UI at http://localhost:3000/api/v1/docs.
npm run dev runs the API and the background worker together (via --mode=all), so
uploaded bundles are actually ingested. In production the two run as separate
--mode=api and --mode=worker processes — see the server README. (To run the API
alone in dev: npm run dev --workspace=packages/server -- --mode=api.)
With the server prerequisites above in place (compose up, bucket created, .env,
migrations), populate the database with an example cohort:
npm run seed --workspace=packages/serverThis generates a Gradescope export (~700 students across three assignments, with a
deliberate spread of paste and cross-submission flags) and runs it through the real ingest
pipeline into an isolated seed-demo semester. The ingest takes a few minutes. To view it
in the analyzer, add your Google email to AUTH_SUPERADMIN_EMAILS in
packages/server/.env and sign in. The export ZIP is committed
(packages/server/scripts/seed/example-gradescope-export.zip) for manual upload too.
Details and the --regenerate flag are in packages/server/README.md.
Course staff ingest a Gradescope "Download Submissions" export, which fans out into one submission per student through the pipeline (roster upsert → match → heuristics → cross-flags). There are two ways in, both producing identical results:
- HTTP upload — the analyzer's Ingest page, or
POST /semesters/:id/ingest:gradescope. The primary path. The upload is streamed to disk (not buffered in memory), so it handles multi-GB exports; the analyzer uploads files ≥ 1 GiB resumably (chunked) so an interrupted transfer continues instead of restarting. - Local-path CLI —
npm run ingest:localreads an export directly from the server's disk via a streaming reader, with memory bounded to a single submission bundle. Best when a very large export (10 GB+) already lives on the server — instant, no upload.
See packages/server/README.md for the
full ingest guide, plus the dev tooling for generating large test fixtures (gen:fixture)
and profiling the pipeline (profile:ingest, profile:large).
Requires the workspace to have been built at least once (npm run build from the
repo root — see Quickstart), since the
analyzer imports log-core / shared / analysis-core from their dist/ output.
npm run dev --workspace=packages/analyzerVisit http://localhost:5173. Sign in with a Google account in AUTH_ALLOWED_HOSTED_DOMAINS.
Visit http://localhost:5173/local/load and drop a .zip bundle. No authentication
is required, and it runs entirely in-browser — no data leaves your machine.
Open this repo in VS Code and press Fn + F5 (or pick "Run Recorder Extension" in the
Run & Debug panel). A second VS Code window opens with test-workspace/ loaded; the
status bar shows "Provenance: recording".
For richer recorder instructions see docs/recorder.md.
The student-facing description that ships with the VSIX lives at
packages/recorder/README.md.
docs/admin-guide.md— hosting, Google OAuth setup, retention policy, backups, restore drilldocs/api-quickstart.md— Python and curl examples for the v3 APIpackages/server/README.md— server-specific dev instructions
provenance/
├── docs/
│ ├── prd.md # recorder product spec
│ ├── analyzer-v3-prd.md # analyzer product spec
│ ├── admin-guide.md # hosting + operations guide
│ └── api-quickstart.md # Python + curl API examples
├── packages/
│ ├── log-core/ # shared event types, hash chain, format
│ ├── recorder/ # VS Code extension
│ ├── shared/ # Zod API schemas shared by server + analyzer
│ ├── analyzer/ # React/Vite SPA frontend
│ └── server/ # Node.js + Hono API server
├── tools/ # dev scripts (key generation, manifest signing)
├── test-workspace/ # sample student workspace for dev & integration tests
├── compose.yaml # Docker Compose for Postgres + MinIO
├── CLAUDE.md # repo conventions for Claude Code
└── package.json # npm workspace root
packages/log-corehas zero runtime dependencies on VS Code, Node-only APIs, or the DOM. It's pure TypeScript that runs in any JS environment. An ESLintno-restricted-importsrule onpackages/log-core/**/*.tsrejectsvscode,node:*,fs,path,worker_threads,cryptoimports.packages/recorderdepends onlog-core,vscode, and a small fixed set of approved libraries (@noble/ed25519,@noble/hashes,@noble/ciphers,canonicalize,jszip). The packaged VSIX is ESM (requires VS Code ≥ 1.100, where the extension host loads ESM entry points viaimport()).- The log file format is the contract between recorder and analyzer. It's specified in PRD §5 and pinned with test vectors in
packages/log-core/src/hash-chain.test.ts.
| Command | What it does |
|---|---|
npm run build |
TypeScript build for both packages. |
npm run test |
Vitest unit tests across all workspaces (~1200 total). |
npm run typecheck |
tsc --noEmit across the workspace. |
npm run lint |
ESLint + Prettier check. |
npm run package:recorder |
Build the VSIX (.vsix file) for local installation. |
npm run test:integration --workspace packages/recorder |
Download VS Code 1.120 and run integration tests against the real Extension Host. |
npm run bench --workspace packages/recorder |
Run the SessionWriter perf benchmark (p99 should be << 1ms). |
The recorder verifies every .provenance-manifest manifest against an ed25519 public key embedded in the extension. The keypair is generated offline on a secured machine; the private key never enters the repo.
Generate the course keypair (once, on a secured machine):
node --experimental-strip-types tools/generate-course-keypair.ts /Volumes/SECURE/cs61a-fa26.jsonThe public key is printed to stdout (paste into a clipboard or pipe into the production build). The private key is written to the chosen path with mode 0600. Back it up to physical media.
Author the unsigned .provenance-manifest in the assignment starter folder. Drop this file at the workspace root the students will open:
{
"assignment_id": "hw03",
"semester": "fa26",
"issued_at": "2026-09-15T00:00:00Z",
"files_under_review": ["hw03.py"]
}Field rules (enforced by parseManifest in packages/log-core/src/manifest.ts):
assignment_id— non-empty string, unique per assignment. Rotating it per assignment is what prevents replay of an old session against a new assignment (PRD §6).semester— non-empty string, e.g."fa26".issued_at— non-empty ISO 8601 UTC timestamp.files_under_review— array of workspace-relative paths. Only files in this list get the in-memory expected-content model used for external-change detection (PRD §4.5). Other files are still recorded for workspace context.
Omit the sig field; the signer adds it. (If you re-sign an already-signed manifest, the old sig is stripped first.)
Sign a per-assignment manifest (every time a new assignment is released):
PROVENANCE_COURSE_KEYPAIR_PATH=/Volumes/SECURE/cs61a-fa26.json \
node --experimental-strip-types tools/sign-manifest.ts /path/to/assignment-starter/.provenance-manifestThe script strips any existing signature, canonicalizes the remaining fields (via JCS), signs with the private key, and writes the updated .provenance-manifest back to disk.
Produce a production VSIX with the course public key embedded:
PROVENANCE_COURSE_PUBLIC_KEY_HEX=<64-hex-from-generate-step> \
npm run build:prod --workspace packages/recorderbuild:prod embeds the production key, builds, packages a VSIX, then restores the source file so further local work uses the dev key. The script refuses to run if the env var is missing, malformed, or matches the dev key — so a misconfigured release can never silently ship a dev VSIX.
Refresh the analyzer's known-good extension-hash list so the new VSIX won't trip extension_hash_mismatch when staff load real submissions:
npm run update-hashes -- --keypair /Volumes/SECURE/cs61a-fa26.jsonThis runs the same build:prod pipeline as above (you can re-use the same keypair JSON instead of exporting the env var by hand), then hashes the bundled dist/ and appends the result to packages/analysis-core/src/heuristics/config/known-good-extension-hashes.json. The script computes the hash with the same algorithm the recorder uses at seal time, so any VSIX produced by the same run will validate cleanly. Without --keypair (and with no PROVENANCE_COURSE_PUBLIC_KEY_HEX env var) the script falls back to bundling with the dev key and prints a loud warning — that hash will never match a real release.
Other modes: --show (print current list), --no-build (hash an already-bundled dist/), --hash <hex> / --remove <hex> (manual entries), --clear.
See docs/recorder.md for the full security model and what the recorder defends against.
Provenance is licensed under the Apache License, Version 2.0. See NOTICE.
The recorder VSIX, the analyzer SPA bundle, and the deployed server each redistribute a
number of third-party open-source packages. Their licenses and copyright notices are
listed in THIRD-PARTY-NOTICES.txt.
Provenance is an independent project and is not affiliated with, endorsed by, or sponsored by Microsoft, Google, or Turnitin.
- Visual Studio Code is a trademark of Microsoft Corporation. The recorder
(
packages/recorder) is a VS Code extension; it is not produced or endorsed by Microsoft. - Google and the Google logo are trademarks of Google LLC. The analyzer and server use "Sign in with Google" (Google OAuth) for authentication; Provenance is not produced or endorsed by Google.
- Gradescope is a trademark of Turnitin, LLC. The server includes an optional
ingest path for Gradescope autograder exports (
packages/server/src/services/ingest/gradescope/); Provenance is not produced or endorsed by Turnitin.
All other trademarks are the property of their respective owners.