Repo: the document store

Repo is ProtoMolt's claim-check document store. The bulky bytes of a document live in S3-compatible object storage; a Postgres ledger holds the rows that index them. Documents are split into four independently addressable parts, so a reader can fetch a few kilobytes of metadata without dragging the multi-megabyte payload along.

In plain terms. When one pipeline step hands a document to the next, the message that travels is tiny: a reference, like a coat-check ticket. The actual bytes stay in object storage until someone asks for them. Repo is the coat check. It keeps the ticket ledger in Postgres, the bytes in any S3-compatible store, and it can return the whole document or only the pieces a caller actually needs.

Boot a demo repository locally (gRPC on 9090, HTTP upload on 8080)

docker run --rm -p 9090:9090 -p 8080:8080 \
  ghcr.io/ai-pipestream/protomolt-serve --demo
flowchart LR
        step["A pipeline step"] -->|"node id + requested parts"| repo["repo-service"]
        repo --> pg["Postgres ledger: rows, manifests, purge records"]
        repo --> obj["S3-compatible storage: part objects, blobs, archive objects"]
        obj -->|"only the parts asked for"| repo
        repo -->|"assembled document"| step
Where bytes and metadata live. Small references cross the API; the bytes stay in object storage, and repo-service is the only component that resolves object-storage coordinates.

One document, four parts

A stored Document is not one blob. The codec splits it on clean protobuf field boundaries into four parts, and each part is its own object under a shared prefix: core.pb, blobs.pb, chunks/<subKey>.pb, parsed.pb. Every part gets a SHA-256 entry in a manifest. A reader names the parts it wants, and the full document reassembles byte-for-byte from the fragments, merged in manifest order. The round-trip tests compare bytes to prove it.

The split is descriptor-driven, not hardcoded to Document: the codec reads a PartLayout that says which field is the identity, which fields form which part, and which part is chunked and sub-keyed. Any protobuf message type can be decomposed the same way. Click through the four parts below to see what each one holds and when it is fetched.

One Document message, split on field boundaries. Click a part (or a field) to see what it holds, who reads it, and when its bytes are fetched.

The document's fields

  • doc_ididentity, derived deterministically
  • ownershipaccount and security context
  • search_metadataminus its semantic_results
  • structured_datatyped extracted fields
  • semantic_resultschunked, sub-keyed per set
  • blob_bagreferences to the raw bytes
  • parser_resultseverything parsers emitted

Parts on the drive

CORE .../documents/<accountId>/<nodeId>/core.pb

Holds: the doc_id and how it is derived, ownership, structured_data, and search_metadata except semantic_results.

Read by: nearly every step. This part and the ledger row are the two things most calls touch.

Fetched when: almost any read. A search hit needs CORE alone and never touches the megabytes in BLOBS.

BLOBS .../blobs/<accountId>/<blobId>.bin

Holds: blob_bag, the raw binary content. At intake the bytes are staged as objects under blobs/, and blob_bag keeps a FileStorageReference pointing at them. That reference is the claim check.

Read by: parsers only. Written once at intake, then tombstoned after parsing under the source-blob policy.

Fetched when: only when a parser asks for raw bytes. A metadata lookup never fetches this part.

CHUNKS .../chunks/<subKey>.pb

Holds: search_metadata.semantic_results, split per chunk set and sub-keyed by result_id.

Read by: retrieval steps that need the text or embedding chunks.

Fetched when: a read names that chunk set. Other sets stay on the drive untouched.

PARSED .../parsed.pb

Holds: parser_results, the append-only exhaust of everything the parsers produced.

Read by: downstream steps that want parsed structure without re-running a parser.

Fetched when: a read requests PARSED explicitly.

Part IO fans out across virtual threads, so an all-parts save or read runs the four object operations concurrently.

repo container codec, the canned layout for Document

return PartLayout.builder(Document.getDescriptor())
        .identityField("doc_id")
        .partField(DocumentPart.DOCUMENT_PART_BLOBS, "blob_bag")
        .partField(DocumentPart.DOCUMENT_PART_PARSED, "parser_results")
        .chunkedPart(DocumentPart.DOCUMENT_PART_CHUNKS,
                "search_metadata.semantic_results", "result_id")
        .build();

Two companion mechanics fall out of the split. A partial save writes only the listed parts and carries the rest forward from a source address with a server-side object copy, so the unwritten parts move between addresses without their bytes ever transiting the service. A partial read assembles only the requested parts, and GetDocumentManifest reports locations, hashes, sizes, and states without fetching a single byte. Each manifest entry is also stamped with WriteProvenance (module, node, graph, version); carried-forward parts keep their original stamp, and a blank stamp means unknown, never invented.

Identity: four segments, one deterministic UUID

A document's identity is not a random id and not the raw doc_id string. It is a name-based UUID (the v5 style) derived from four logical segments, together called a NodeAddress: the document id, the graph address id, the owning account id, and the graph id. The graph segment matters because two independent pipelines can give different nodes the same name; including the graph id keeps their stored rows distinct, so one pipeline finishing its own work can never disturb another's in-flight copy.

The raw doc_id never reaches an object key. Keys are built from the deterministic UUID, so re-saving the same logical document lands on the same identity every time. Identity is never random; re-saves are idempotent by construction.

repo/proto address.proto: the four segments the node id is derived from

Show the actual definition

// The storage identity of one stored document state: the four logical
// segments the deterministic node id (UUIDv5) is derived from. This is THE
// way to address a row; node_id strings are derived echoes, never truth.
message NodeAddress {
  string doc_id = 1;
  string graph_address_id = 2;  // datasource id at intake, graph node id in a pipeline
  string account_id = 3;        // owning account (tenant root)
  string graph_id = 4;          // "intake:<accountId>" at intake, owning graph id in a pipeline
}

The ledger enforces the model in SQL. The four segments form a unique identity, blank graphs are unrepresentable, and a row is always in exactly one of three statuses:

repo service, the document identity rules

CONSTRAINT uq_documents_identity UNIQUE (doc_id, graph_address_id, account_id, graph_id),
CONSTRAINT chk_documents_status CHECK (status IN ('AVAILABLE', 'PENDING_PURGE', 'PURGE_FAILED'))

Re-uploading the same bytes costs nothing

When a document is saved again and the split root checksum matches the checksum of the existing AVAILABLE row, the service skips the object writes entirely, marks the row re-processed, and answers deduplicated=true with the existing coordinates. The caller learns the document is already there; no bytes move.

A caller that wants a real rewrite anyway passes force_save, and the write becomes store-verified: the SHA-256 rides the PUT as a checksum trailer, and the object store itself rejects any landed bytes that do not match the digest. A corrupted write cannot be mistaken for a stored document.

repo blob store, the checksum trailer on a verified PUT

// SDK checksum trailer: the store compares the landed bytes against
// this digest and fails the PUT on mismatch (verified write).
b.checksumSHA256(Base64.getEncoder().encodeToString(HexFormat.of().parseHex(spec.sha256Hex())));

The same digest runs through the HTTP upload route, which is the door for big payloads: the request body streams through a SHA-256 DigestInputStream straight into object storage with no in-memory buffering. Content-Length is required (the contract answers 411 without it), and the response is a small JSON receipt. A blank doc_id derives a name-based UUID from the content hash itself.

repo README: the upload receipt

{
  "node_id": "3f8a…",
  "doc_id": "…",
  "deduplicated": false,
  "size_bytes": 8388608,
  "sha256": "…",
  "storage_ref": { "drive_name": "intake", "object_key": "intake/blobs/<accountId>/<blobId>.bin" }
}

Drives: the only door to object storage

A Drive binds four things: a bucket, a key prefix, a region, and a credentials reference. The credentials themselves are never in any message; the reference is resolved at IO time. Every account is provisioned with two drives at creation: an intake drive for staged source material and a pipeline drive for the claim-check bodies passed between hops. A custom drive type exists for anything else.

The house rule is strict: nothing but repo-service speaks S3. Drives are the only thing that resolves to object-storage coordinates, so everyone else in the platform sees drives and FileStorageReferences and nothing more. Swap the backing store (MinIO, LocalStack, SeaweedFS, Ceph, rustfs, AWS itself) and only the drive config changes. A Redis-backed store and a read-through cache decorator implement the same internal BlobStore port, and a dogfood client can even use another repo-service as its byte store over gRPC. The Redis-backed store is a sample-grade implementation.

Objects live under per-account, per-node prefixes: <drive.prefix>/documents/<accountId>/<nodeId>/… for parts, <drive.prefix>/blobs/<accountId>/<blobId>.bin for staged uploads, and <drive.prefix>/blobs/<uuid-of-sha256> for content-addressed puts, where identical puts land idempotently on one object.

Delete is two-phase, and the purge ledger makes it safe

The object write and the ledger row that references it are not committed atomically, and that is a deliberate design choice, not an oversight. The standing rule that makes it safe: an object with no live ledger row that owns it is an orphan, and orphans are reclaimable. Deletes, the purge path, and a set of lifecycle loops are all built around that rule.

A normal delete (purge_storage=false) runs in two phases. Phase A happens synchronously inside the gRPC call; Phase B happens later, asynchronously. Step through it:

Two-phase delete. Phase A is fast and metadata-only; Phase B does the dangerous part later, under a ledger that remembered exactly which keys to delete.

Ledger row: AVAILABLE

  1. 1. The request

    A caller sends DeleteDocument with purge_storage=false. The synchronous phase begins.

  2. 2. Phase A, inside the call

    The ledger row tombstones to PENDING_PURGE, metadata-only and fast, and one document_purges record per row is enqueued in the same transaction, snapshotting every object key to delete. Phase B never recomputes keys.

  3. 3. Phase B, asynchronous

    A purger thread claims PENDING records with SELECT … FOR UPDATE SKIP LOCKED, re-reads the document row under a row lock, and applies the staleness guard: if the row came back AVAILABLE or was updated after the purge was requested, the purge is VOID and the objects stay.

  4. 4. Settle

    The snapshot keys are batch-deleted (a missing key counts as success), the row is removed, and the record is marked PURGED. Errors increment attempts; at 10 the record is FAILED and the row reads PURGE_FAILED, the dead-letter state an operator handles.

flowchart LR
          avail["AVAILABLE"] -- "Phase A: tombstone + enqueue purge record" --> pend["PENDING_PURGE"]
          pend -- "staleness guard: row revived or updated" --> void["VOID: nothing deleted"]
          pend -- "Phase B: keys deleted, row removed" --> purged["PURGED"]
          pend -- "10 failed attempts" --> failed["PURGE_FAILED: operator territory"]
The delete lifecycle. Phase A tombstones and enqueues; Phase B settles under a staleness guard.

Around the same rule sit three more lifecycle machines, each covered by integration tests. A sweeper rescans for rows stuck in PENDING_PURGE whose purge record never got enqueued (a crashed Phase A) and enqueues one. A storage reconciler walks the bucket listing against what the ledger owns and reports, or deletes, orphaned objects older than an hour (dry-run by default). A coherence probe works the other direction, probing a sample of manifest keys and tombstoning confirmed-missing objects in the manifest while the row stays available. These are library calls today; a slow opt-in loop can run them periodically.

The archive: the same engine as a generic asset store

The ArchiveService is the repository family's general-purpose store, served by the same binary and running on the same drives, blob stores, and Postgres ledger. Its hierarchy is account → archive → entry → version → rendition, where a rendition is any one form of an entry: the raw file, a parsed protobuf, markdown, NDJSON, parquet, anything.

Identity is one deterministic name-based UUID over (account_id, archive, entry_id), which is simultaneously the ledger primary key, the object-key prefix, and the natural partition key for events. Content addressing is entry-local: a rendition object's key is derived from its own content hash, scoped under its entry:

Object layout on the drive: an archive rendition

<drive.prefix>/archive/<accountId>/<archive>/<entryUuid>/<rendition>[<subKey>]/<sha256>

An unchanged rendition across versions is one shared object, so retained history costs only what actually changed, and every deletion question stays bounded to one entry's own manifests. Deliberately, this is not a global content-addressed store, which would need reference-counted garbage collection. Object-key segments admit [A-Za-z0-9._-] only, because signature canonicalization must not diverge on exotic bytes.

Each archive picks a versioning policy. VERSIONING_NONE keeps one retained state, and the version counter doubles as the optimistic-concurrency token. VERSIONING_RETAINED keeps every save as a new immutable version. Renditions have states too: PRESENT, EMPTY, or DELETED, and a deleted rendition keeps its size, its hash, and a named reason. The map still lists the file, the file is gone, and the hash proves what it was, which is what makes redaction and retention auditable.

Every entry carries a classification state: UNCLASSIFIED, DECLARED, IDENTIFIED, VERIFIED, or CONFLICTED, never a silent default. A save can declare a FormatFact, validated against the format's own rules (a .zip filename can never claim tar), and ClassifyEntry re-reads the primary rendition's bytes through the asset family's detection seam. Archive stats are exact and adjusted in the same transaction as the mutations they describe.

There are three upload doors, and the contract publishes no capability the service does not implement: a unary PutEntry for small and medium payloads in memory, a client-streaming UploadRendition (declare the size, optionally the expected SHA-256; with a declared hash the bytes stream straight to their final content-addressed key, without one they stage and settle by server-side copy), and an HTTP POST /v1/archive:upload where the body is the bytes, identity rides query parameters, and a JSON receipt comes back.

Encryption is the caller's choice

There is no encryption code inside repo at all. The service stores and serves opaque bytes; if a caller needs confidentiality, it encrypts before storing and decrypts after reading. The platform itself uses this pattern: the delegation transcripts of the transform module are wrapped in AES-256-GCM before they ever reach the repository, then stored through the plain DocumentService under their own MIME type. Encryption-at-rest is a codec layered on top of the claim-check API, not a repo feature, and a drive's credentials_ref likewise points at external credential resolution, never at secrets in messages. Authentication is optional and off by default: with no token set, repo is a trusted-network service reachable only from inside the node's network, and account_id is a plain request field with no per-account authorization inside the service.