Files and blobs
PartialFiles keep the opaque bytes that go with a fleet (a firmware image, a config dump, a runbook, a
packet capture) searchable and deduplicated: a file handle over a content-addressed blob
store, behind the same Storage Gateway as everything else.
Two layers: the file handle and the blob
Section titled “Two layers: the file handle and the blob”fileis indexable metadata: a filename, content-type, size,sha256(notagscolumn yet, deferred to #191), the searchable handle an operator references; it owns no bytes, pointing at a blob by hash. The filename is a human label with an extension (codec-firmware-2.1.4.txt), deliberately not a triad name: it takes no name rule, carries no uniqueness, and the handle is addressed by its uuid, which is whyfileis declared “human identifier, not a name” in the identity shapes.- the blob store holds the bytes, content-addressed by
sha256: the hash is the key, so identical bytes are one blob.
Splitting them means search and inventory never touch bytes, and one blob can back many handles.
Access: a permission and a sensitive tier, no placement
Section titled “Access: a permission and a sensitive tier, no placement”A file carries no fleet placement: unlike a secret, which is for one component or system and sits on the exclusive arc, a file relates to many things (a firmware image documents many device types) or to nothing, so its locality is its future attachments (a many-to-many relation), not an owner column. A file is tenant-wide, gated by two existing layers:
- the
file:<action>permission on every route (file:create,file:read,file:delete; download ridesfile:read); - a per-file
sensitiveflag reusing the secret sensitivity tier (ADR-0025): a flagged file is lifted to the:admintier (file:read:admin), hidden from a lister and a non-disclosing 404 to a reader without it (a competitive quote is a sensitive global file, a firmware image an ordinary shared one).
Unlike a secret, the flag defaults false, and file is not in the sensitive-resource set,
so the viewer floor (*:read) reads ordinary files; only the flag fences the confidential ones.
Content-addressing earns four properties
Section titled “Content-addressing earns four properties”A blob is keyed by the hash of its bytes, not a UUID, which buys:
- dedup: identical bytes collapse to one blob (two operators uploading the same firmware);
- integrity: the hash verifies the bytes on read, tamper-evident by construction;
- immutability: bytes cannot change without changing the key;
- backtest-stability: an event referencing a hash still resolves under a backtest.
Dedup is database-scoped
Section titled “Dedup is database-scoped”The blob key is sha256, the bare content hash, no tenant_id: isolation is per-database (a
database per tenant), so dedup is global within a database and one tenant can never detect
another’s content by hash collision, because the blobs never share a store; the efficiency cost is
the right price for physical isolation.
Backends, swappable behind the gateway
Section titled “Backends, swappable behind the gateway”The bytes live behind the Storage Gateway, so the backend swaps with no model change (the same seam as the columnar and object tiers):
- default:
pgblobs(a dedicated Postgres blob table), the single-binary, no-external-dependency story;
Alternate blob backends, tracked in #248
- scale: an S3-compatible object store;
- disk for local and dev.
The file and the hash reference are identical across backends; only storage_ref resolution
differs.
Chunking and streaming for very large blobs (firmware images, captures) on the pgblobs backend.
Reference-counted GC, not age-based
Section titled “Reference-counted GC, not age-based”Async mark-sweep GC, attachments, and the reference lanes, tracked in #242
A blob is collectable only when no live reference points at its hash AND a grace or retention
floor has passed (age-based GC alone is wrong: dedup means a blob uploaded long ago can be the one
a recent event references). References come from a file handle, a large log_line body, a
collection.failed raw hash-ref, and an attach event (an event or audit_log row recording “this
component was attached to this file at T”). They disappear when a file is deleted or a referencing
event ages out (a retention partition drop), so GC is coupled to retention: dropping a
partition releases its references.
Mechanism: index-probe mark-sweep by default. GC enumerates blobs past the grace floor and
probes the indexed hash-ref columns on the referencing tables. A maintained refcount column or
blob_ref table is a measured optimization, earned only if the per-blob probes profile too
expensive; the grace floor is the safety margin so GC never races a just-written event.
The grace-floor duration relative to the backtest window (long enough that a prospective backtest re-deriving over the window cannot reference a collected blob).
Storage
Section titled “Storage”The handle and the content-addressed bytes; the physical layout (the gateway, GC) is above and on storage.
file (Partial) is the searchable metadata handle, pointing at a blob by hash: tenant-wide,
sensitive per the access section above.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK, default uuidv7() | |
name | text | not null | |
content_type | text | not null | |
size | bigint | not null | |
sha256 | text | FK → blob.sha256, not null | The content address of the bytes this handle names |
sensitive | boolean | not null, default false | Lifts the row to the :admin tier |
created_at | timestamp with time zone | not null, default now() | |
updated_at | timestamp with time zone | not null, default now() |
blob (Partial) is the content-addressed bytes: dedup on the hash, default pgblobs (inline
bytea), S3 / disk behind the same blob.Store seam. Content type lives on the file, because
content-addressing is about the bytes. A file delete frees its unreferenced blob synchronously
(dedup-aware); async mark-sweep GC is deferred.
| Column | Type | Constraints | Notes |
|---|---|---|---|
sha256 | text | PK | The content address; dedup key across every referencing file |
bytes | bytea | not null | |
size | bigint | not null | |
created_at | timestamp with time zone | not null, default now() |