Repository navigation
No route serves a document's original bytes — the export's digests are unverifiable #859
Description
The export names every original byte; no route serves a single one
The RDF export (0020) is the project's supported machine-readable read contract. For every document it emits a stable identity and a byte digest — …:document:{id}, …:docversion:{id}:{version}, utopia:sha256 — so an outside auditor can verify exactly which bytes every claim rests on.
But there is no way to retrieve those bytes. GET /api/v1/documents/{id} returns metadata and normalized chunk text; the MCP get_document tool returns parsed text capped at 24k chars; nothing serves the original content. An auditor can check a digest and never fetch the bytes it describes. The ledger can name the original; it cannot hand it over.
This is also load-bearing for 0040: chunks carry anchors into pages, images and recordings on the premise that "the original is already kept" — and it is ({data_dir}/files/{sha256}, content-addressed, deduplicated, retained per document_versions). The storage model is complete. What's missing is the read contract.
Proposal
Two document-identity-keyed routes, serving already-retained bytes:
GET /api/v1/documents/{id}/content[?version=N]— raw bytes withContent-Length, honestContent-Type, a safe filename, and aSHA-256-derivedETag.GET /api/v1/documents/{id}/versions— the version ledger (version,sha256,size_bytes,ingested_at) so callers can name a version before asking for it.
Semantics:
- Auth: session cookie or scoped PAT at Viewer level through
require_kb(doc.kb_id)— the same gate the export already uses. Ingest tokens rejected: they are write-scoped. - Versions:
?version=Nserves that recorded generation; default is current. Generations never recorded in the ledger (pre-versioning uploads that were never adopted) answer 404 honestly. - Lifecycle:
missing_since/deleted_atcontent stays readable (the bytes are still retained);purged_atanswers410 Gone; a ledger-referenced but missing blob is a500invariant failure, not404. - Explicitly out of scope: HTTP Range, streaming, media preview, a hash-keyed blob route.
Why document identity, not a blob route
Authorization resolves through documents — require_kb on doc.kb_id already exists and is what the export uses. A GET /blobs/{sha256} route cannot express "may this caller read this" because shas are shared across documents (that sharing is exactly why purge has a refcount query), and it doubles as a digest-existence oracle for anyone who can guess or harvest hashes. Content addressing is a storage seam, not a contract. Every sha the ledger can name is already reachable through the document route, so a second address buys zero expressiveness and permanent surface.
Cost and benefit
Cost: two frozen routes, one additional auth call site, byte-egress header discipline, a formalized missing-blob invariant, buffered reads bounded by the existing 100 MiB upload cap (streaming can follow). No schema change — document_versions and the blob store already carry everything.
Benefit, on Utopia's own terms: the export becomes end-to-end verifiable; 0040's anchors become openable; preview/download, independent audit, re-parse, and portability all work without consumers duplicating a byte store of their own. This would also deserve an explicit declaration that these two routes are a compatibility boundary — today's /api/v1 JSON routes deliberately aren't one (web/src/docs/mcp.md), which is exactly why a real read contract needs to be added rather than implied.
Rough size: ~5 files (route registration, two handlers, two store queries, authorizer reuse, tests). Happy to land an ADR in docs/decisions/ first per the contributing guide, then the implementation PR against dev.
Activity