Skip to content

No route serves a document's original bytes — the export's digests are unverifiable #859

Closed

Description

@ansonnmm

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 with Content-Length, honest Content-Type, a safe filename, and a SHA-256-derived ETag.
  • 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=N serves 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_at content stays readable (the bytes are still retained); purged_at answers 410 Gone; a ledger-referenced but missing blob is a 500 invariant failure, not 404.
  • 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions