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 six 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/analysis-core |
The analysis engine shared by the analyzer and the server: the bundle loader (unzip + parse), the validation checks, the event index and file reconstruction, and the per-submission and cross-submission heuristics. Isomorphic — the same code runs in the browser and in Node — so it depends only on log-core plus jszip, diff, and @noble/ed25519. |
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 / export / source — the Export tab is a v3.1 stub), a 29-flag heuristics tuning UI (per-flag weight + on/off), and cross-submission flags. 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. It runs entirely
in-browser on top of analysis-core — no bundle data leaves your machine. You still
have to be signed in as staff: the route sits behind RequireAuth +
RequireStaff like the rest of the app.
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/key-management.md— every key in the system: who holds it, what it signs, setup, rotationdocs/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
│ ├── analysis-core/ # isomorphic analysis engine (loader, validation, heuristics)
│ ├── 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 |
Build all six workspace 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 through a two-level trust
chain (Manifest 2.0; full design in
docs/superpowers/specs/2026-08-18-multicourse-program-architecture.md §2–§3):
root keypair (maintainer; offline; once, ever; NEVER signs a manifest)
│ signs
▼
course_cert { course_id, course_pubkey, valid_from, valid_until }
│ authorizes
▼
course keypair (course staff; signs .provenance-manifest files)
│ signs
▼
.provenance-manifest
The root key also certifies the deployment's institution key, which is what signs student credentials — a separate branch of the same chain, covered in step 3 below.
For the whole picture — every key, who holds it, what its compromise costs, and how
rotation works — see docs/key-management.md.
Every staff tool has an npm script, so nothing here needs a node invocation:
| Script | Tool | What it does |
|---|---|---|
npm run keygen:course |
tools/generate-course-keypair.ts |
Generate an ed25519 keypair (root, course, or institution) |
npm run mint:course-cert |
tools/mint-course-cert.ts |
Root-sign a course_cert |
npm run mint:institution-cert |
tools/mint-institution-cert.ts |
Root-sign an institution_cert |
npm run sign:manifest |
tools/sign-manifest.ts |
Sign a .provenance-manifest |
Pass tool flags after -- (e.g. npm run mint:course-cert -- --course-id …). Each
script is a thin wrapper around node --experimental-strip-types tools/<tool>.ts,
which still works if you prefer to invoke it directly.
The recorder embeds only the root public key — one VSIX build serves every course.
A course's authority comes entirely from its root-signed course_cert, which travels
inline inside every manifest that course signs, not from anything baked into the
extension. format_version: "1.0" manifests (no trust chain, no course_cert) remain
permanently supported for archived submissions; everything below produces 2.0 unless
you pass --format 1.0.
The root private key is the highest-value secret in this system. It transitively
authorizes every course, past and future. Generate it once, offline, on a secured
machine, exactly like a course keypair below — never inside this repo, never emailed,
never logged. A dev root keypair is checked into .notes/dev-root-keypair.json
(git-excluded, deliberately public/insecure) purely so local development and the test
fixtures under test-workspace/ have something to sign against; it must never be used
for a real deployment.
There is no dedicated root-keypair-generation tool. Use the course-keypair generator:
it emits a plain ed25519 keypair in exactly the shape a root key needs
({ public_key_hex, private_key_hex }). Run it once, on an air-gapped or otherwise
hardened machine, and back up the private key to physical media.
npm run keygen:course -- /Volumes/SECURE/root-keypair.jsonPositional path only — do not pass --course-id here; that mints a course
certificate, which is a different artifact.
Generate the course keypair (once per course, on a secured machine):
npm run keygen:course -- /Volumes/SECURE/cs61a-fa26.jsonThe public key is printed to stdout. The private key is written to the chosen path
with mode 0600. Back it up to physical media.
Mint the course's certificate, using the ROOT keypair (typically a separate step run by whoever holds the root key, not by course staff):
npm run mint:course-cert -- \
--course-id berkeley-cs61a --course-pubkey <64-hex-from-generate-step> \
--valid-from 2026-08-20 --valid-until 2027-01-15 \
--root-keypair /Volumes/SECURE/root-keypair.json \
--out /Volumes/SECURE/cs61a-fa26.cert.jsonKeep the validity window short (one semester) — an offline recorder cannot learn about key revocation, so a short window is the only mitigation. The certificate is self-verified against the root public key before being written; a tool that hands out a certificate that fails its own check is worse than no tool.
npm run keygen:course can also do both steps in one run — pass --course-id,
--valid-from, --valid-until (and optionally --root-keypair / --cert-out)
alongside the output path — whenever the same machine holds both keys.
Only needed if the deployment issues student credentials (identity
format_version 2.1 — the /enroll page). Without it,
POST /api/v1/identity/credential answers 503 no_institution_key forever.
There is one institution key per deployment, not one per course. Generate the keypair on the server, carry only its public half to the offline root machine, mint there, and bring the certificate back:
# on the API server
npm run keygen:course -- /secure/institution-keypair.json # positional path only
# on the offline root machine, with the public key from above
npm run mint:institution-cert -- \
--institution-id berkeley --institution-pubkey <64-hex-from-generate-step> \
--valid-from 2026-08-20 --valid-until 2027-08-19 \
--root-keypair /Volumes/SECURE/root-keypair.json \
--out /Volumes/SECURE/berkeley-institution.cert.jsonThen set both halves in one environment variable on the server:
PROVENANCE_INSTITUTION_KEY='{"private_key_hex":"<64 hex>","cert":<the cert JSON>}'The institution private key never leaves the server; the root private key never
touches it. Full operator detail — rotation, blast radius, handling rules — is in
docs/admin-guide.md §10.6, "The institution signing key".
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"],
"course_id": "berkeley-cs61a",
"collaboration": "solo",
"submission": "bundle",
"scope": "directory",
"policy": {
"capture": {
"selection_change": true,
"focus_change": true,
"terminal": true,
"heartbeat_interval_ms": 30000
},
"enrollment": { "required": true }
}
}Field rules (enforced by parseManifest / parseManifestValue 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. -
course_id— MUST equal thecourse_idinside the certificate you sign with, or the manifest will fail its own chain check (program spec §3 step 3). -
collaboration/submission/scope—"solo" | "group","bundle" | "git","directory" | "repo". -
policy.capture— the professor-facing capture controls (program spec §4). A course can turn capture down; a student cannot turn it off, because this block is inside the course-signed payload. Omit keys you don't want to change from the default (everything on, 30s heartbeat). -
policy.enrollment.required— defaults totrue. Set it tofalseand the recorder stops telling un-enrolled students that they are un-enrolled: no(not enrolled)in the status bar, no enrollment prompt. Recording is completely unaffected — the event stream, hash chain, seal, every validation check and every heuristic are identical either way, and a student who has enrolled still gets an identity block. What the course gives up is attribution: nothing in the submission says who produced it, so group work cannot be split between partners and a contested submission cannot be tied to a person by the recording alone. Reasonable for a course of solo assignments where the enrollment flow costs first-week students more than the attribution is worth; wrong for a course with group projects.Two caveats. The knob only takes effect on recorders that ship support for it — an older build verifies the manifest fine and keeps prompting — so check the version table above before relying on it. And it is honoured only at
format_version: "2.0": at 1.x thepolicyblock is outside the signed payload, so a student could otherwise waive their own course's prompting.
Omit sig and course_cert; the signer adds both. (If you re-sign an already-signed manifest, the old sig/course_cert are stripped first.)
Sign it:
PROVENANCE_COURSE_KEYPAIR_PATH=/Volumes/SECURE/cs61a-fa26.json \
PROVENANCE_COURSE_CERT_PATH=/Volumes/SECURE/cs61a-fa26.cert.json \
npm run sign:manifest -- /path/to/assignment-starter/.provenance-manifestThe tool signs with the course private key, staples the certificate inline, then
self-verifies the full trust chain (verifyManifestChain, root → cert → manifest)
before writing anything to disk — it refuses to write a manifest that would not
itself verify. Pass --format 1.0 to emit the legacy shape instead (no course_id /
collaboration / submission / scope / policy / course_cert — just the four
original fields), which log-core continues to support permanently.
PROVENANCE_ROOT_PUBLIC_KEY_HEX=<the maintainer's root public key> \
npm run build:prod --workspace packages/recorderbuild:prod embeds the root public key (via tools/embed-root-key.ts), builds,
packages a VSIX, then restores the source file so further local work uses the dev key.
Unlike the old per-course-key model, this is done once per root-key rotation, not
once per course — a course's authority is entirely in its course_cert, which the
VSIX never needs to know about ahead of time. The script refuses to run if the env var
is missing, malformed, or matches the dev root key, so a misconfigured release can
never silently ship a dev VSIX.
Optionally also set PROVENANCE_LEGACY_COURSE_PUBLIC_KEY_HEX (the grandfathered
single course key from before the root-key hierarchy) if this build still needs to
activate against Manifest 1.x files in the field:
PROVENANCE_ROOT_PUBLIC_KEY_HEX=<the maintainer's root public key> \
PROVENANCE_LEGACY_COURSE_PUBLIC_KEY_HEX=<the old course public key> \
npm run build:prod --workspace packages/recorderIt's optional and only needed while 1.x manifests are still in the field; omitting it
produces a VSIX that will not activate on 1.x manifests (2.0 only). See
legacy-course-public-key.ts for the removal condition once every course has
re-issued as 2.0.
Refresh the analyzer's known-good extension-hash list so a new VSIX won't trip
extension_hash_mismatch:
npm run update-hashes -- --root-keypair /Volumes/SECURE/root-keypair.jsonThis reads public_key_hex from the root keypair JSON, runs the same build:prod
pipeline as above, and adds the resulting VSIX's extension_hash to the allowlist.
If PROVENANCE_LEGACY_COURSE_PUBLIC_KEY_HEX is set in the environment, it's passed
through to the build the same way — but note that embedding the legacy key changes
the built dist/ and therefore the hash, so a build with it set produces a different
hash than one without. The script always hashes whatever dist/ the build it just
ran actually produced, so the recorded hash is correct for that variant; to allowlist
both a 1.x-compatible and a 2.0-only VSIX, run the script twice, once with the var
set and once without.
Other modes: --show (print current list), --no-build (hash an already-bundled
dist/), --hash <hex> / --remove <hex> (manual entries), --clear, --help.
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.