feat: content hashes on document versions + tamper-evident project export manifest
From the PR description
Summary
Exports today cannot prove that a document version is the file the workspace actually held. There are no content hashes anywhere in the schema, so once a file leaves Mike there is no way to tell an untouched export from a modified one. This PR stores a SHA-256 of every version's bytes at write time and adds a per-project export manifest, making any export of those files tamper-evident at the source.
Changes
oss-migrations/20260612_document_version_content_sha256.sqlandschema.sql: nullablecontent_sha256 textcolumn ondocument_versions. Existing rows stay unhashed until their bytes are next rewritten.lib/documentVersions.ts: one small helper,contentSha256(bytes), SHA-256 hex overArrayBuffer | ArrayBufferView.- Hash set at every version write: upload (standalone and project), versions/upload, versions/copy, versions/replace, copy-into-project, assistant edit, generated docs, and the bulk document replication path in
chatTools.ts. - Hash refreshed on the in-place byte rewrites: edit accept/reject resolution, turn-version reuse in
runEditDocument, and version replace. lib/userDataExport.ts:buildProjectExportManifestfollows the same builder pattern as the account export. Stable field order so manifests diff cleanly.routes/projects.ts:GET /projects/:projectId/export, samecheckProjectAccessgate and Content-Disposition idiom as the user export routes. The manifest carries project id/name, every version'scontent_sha256,sourceprovenance,created_at, and thedocument_editsaccept/reject trail (references only, no edit text).
Why
Verification becomes one command: shasum -a 256 file.docx against the manifest. This also seems useful for the local/desktop direction and the questions firms ask before adopting a tool like this ("can you prove what was reviewed and accepted"), and hashes at write time are the cheapest possible foundation for that. Zero behavior change otherwise: no existing endpoint's response changes, the column is nullable, and unhashed legacy rows simply show null in the manifest.
A storage backfill for legacy rows would need to stream every object from S3, so I left it out rather than guess at how you would want to batch it. Happy to follow up with one if useful.
Testing
npm run build --prefix backendpasses (tsc, no errors).- Hash helper smoke-tested against the known SHA-256 vector for "abc" across
Buffer,ArrayBuffer, and offsetUint8Arrayviews (several call sites pass views into larger buffers, e.g. multer'sfile.buffer). - No test framework exists in
backend/, so I did not introduce one for this.
I work on Legalise, a UK-side project on the accountability layer; happy to align this manifest with anything you'd rather see.
Andy
🤖 Generated with Claude Code
Our analysis
Store per-version content hashes for tamper-evident exports — read the full analysis →
Think the analysis missed something the PR description covers?
Capture this PR into my fork
Download a Markdown prompt that tells Claude how to port every
commit in this PR into your working tree. Run it via
claude -p < capture-pull-181.md from
inside the repo you want the changes in.