Asset: formats and classification
The archive stores opaque bytes on purpose. Downstream parsers, bridges, and catalogs still need to know what those bytes are, and they need "we do not know yet" and "the evidence disagrees" as stored facts, not as missing fields. The asset layer is that vocabulary.
flowchart LR
bytes["Opaque bytes on a drive"] --> sniffer["ContentTypeSniffer · one magic-prefix table"]
sniffer --> fact["FormatFact · closed oneof of 18 formats"]
decl["Producer declaration at a door"] --> classn["Classification state"]
fact --> classn
classn --> archive["Archive · stores, filters, ClassifyEntry"]
classn --> parse["Parse coordinator · same sniffer for routing"]
Where it lives
The contract starts in asset/proto
(:protomolt-asset-proto): the FormatFact registry, the Classification state
machine, and ContentProfile. It depends only on the validation contract.
asset/characterize (:protomolt-asset-characterize) is the
engine: ContentTypeSniffer, FormatGrammars, the identifier, FormatCompatibility, and
Classifications.resolve. It is pure JDK.
Bridging and catalog facets live beside that pair:
asset/bridge, asset/bridge-parse, and asset/catalog.
The archive in repo/ stores classifications, validates declarations at upload
doors, and holds the derived renditions bridges write. The parse coordinator routes work
using the same sniffer, so a format conclusion is one platform answer, not two competing
guesses.
Formats as messages
Each format is a protobuf message inside a closed oneof on
FormatFact. There is no parallel Java model and no separate rulebook: the
validate.v1 annotations on the message are the claim contract. Eighteen
formats ship today, grouped by kind. Click a kind to see which messages it holds.
TarArchiveContainerZipArchiveContainerGzipFileContainerParquetDatasetDatasetAvroDatasetDatasetDelimitedTableDatasetNdjsonDatasetDatasetJsonDocumentSemi-structuredXmlDocumentSemi-structuredYamlDocumentSemi-structuredPdfDocumentDocumentWordDocumentDocumentSpreadsheetDocumentDocumentPresentationDocumentDocumentMarkdownDocumentDocumentHtmlDocumentDocumentPlainTextDocumentRasterImageMedia
Showing all eighteen formats.
The rules are strict on purpose. A TarArchive filename must match its
grammar. A GzipFile CEL rule keeps .tar.gz as tar, not gzip. A
DelimitedTable needs a one-character delimiter and a
HeaderPresence enum (not a bool). A RasterImage media type must
be an image/* MIME. Filename fields use ignore_if_zero: an unset
name is allowed by the field rule, but a set name is held to grammar. Presence of a name
on a declaration is a door rule: the archive refuses nameless declarations.
ObjectStoreOrigin (bucket plus object key) rides beside classification as
origin metadata. It is not a format.
Audio and video formats are absent until something in-tree validates or bridges them.
Classification states
CLASSIFICATION_STATE_UNSPECIFIED is never stored. Every stored classification
is one of five states. Step through how a typical entry moves, or jump to a state:
UNCLASSIFIED
Stored with no format known. Listable and countable as a work item: something is on the shelf, and nobody has said what it is yet.
| State | Meaning |
|---|---|
UNCLASSIFIED | Stored, no format known; a listable work item. |
DECLARED | Producer declared a format and the door validated it. |
IDENTIFIED | Characterization concluded from bytes with evidence; nothing was declared. |
VERIFIED | Declared and identified agree. |
CONFLICTED | Declared and identified disagree; stored loudly; bridging refuses. |
Record versus refuse is deliberate. An invalid declaration is refused at the door. A
valid declaration that contradicts the bytes lands as CONFLICTED. No
declaration leaves the entry UNCLASSIFIED, then IDENTIFIED
after characterization. The Classification message carries CEL shape rules so state and
fields always match, plus CharacterizationEvidence, Attribution
(classified_by), ObjectStoreOrigin, and classified_at.
Identification may generalize without creating a conflict.
FormatCompatibility treats CSV as a kind of text, docx as a kind of zip, and
a compressed tar as a kind of gzip. A claim that sits inside a generalization is not a
conflict; only a ruling-out conclusion conflicts.
Classifications.resolve is the single resolution point every consumer calls.
Characterization: one sniffer
ContentTypeSniffer is one platform magic-prefix table. ZIP payloads dispatch
on extension; an inconclusive probe returns sniffed=false. The parse
coordinator uses the same table, so routing and archival classification cannot drift.
Characterizer.identify(head, filename) reads the first 512 bytes
(PREFIX_BYTES). A gzip-plus-tar name becomes compressed tar. JSON gets a
probe. Delimited tables are never identified from bytes alone: delimiter and header
presence must come from the producer. Names are endorsed only when they match grammar.
Every fact the characterizer produces is valid by construction.
Content classes
Beside format sits ContentProfile: what kind of material the bytes carry for
downstream quality and routing. The content classes are
INFORMATIONAL_TEXT, CONVERSATIONAL_TEXT,
TABULAR_DATA, OCR_TEXT, and CODE. A
QualityScore carries dimensions; OCR material must carry measured quality.
Archive integration
The archive is where classification becomes durable.
EntryInfo.classification rides on every entry. PutEntry and
UploadRendition accept a declared format and an origin.
RenditionManifestEntry.content_profile carries the content class. The primary
rendition is what classifies the entry. Declarations, once made, are never silently
withdrawn.
ClassifyEntry re-reads the primary rendition through the same detection seam.
ListEntriesRequest can filter by classification_state, and
ArchiveStats.classification_states exposes exact per-state counts computed
from the archive's live entries.
The archive stores each entry as a v2 document (described on Repo),
and its classification rides in the entry's CORE: the encoded
Classification under archive.classification and the state name
under archive.classification_state, the key the filter matches. A streamed
upload of the primary rendition keeps only the 64 KiB head and 128 KiB tail of the bytes
as they pass, classifies from them, and sends the result in the upload trailer, which the
repository admits again before the one commit. A stock repository host serves these
calls on its gRPC port.
Renditions, derived ones included, can be read back as a stream.
ReadRendition sends one rendition of one version, whole or as a byte range,
in frames of at most 64 KiB paced by the reader. Its header carries the manifest's size
and SHA-256 for the whole object; a whole read is checked against that digest and fails
with DATA_LOSS on a mismatch, while a range read is checked for length only.
The renditions viewer in the repository console lists an
entry's renditions with their state, size, hash, and provenance, and previews or
downloads them through this call over the repository host's Connect gateway, in binary
protobuf, for a signed-in user whose account bindings reach the archive.
Bridging: derive, do not convert
A bridge derives a new rendition beside the original. It never converts bytes in place. Bridging is gated on the classification state machine, provenance-stamped on every output, and refused when classification names no single format: only the three states that name one format (DECLARED, IDENTIFIED, VERIFIED) may bridge. CONFLICTED and UNCLASSIFIED sources are refused rather than guessed at.
Bridges is a routing rule, not a runtime of its own: which bridge kinds
apply to which format, which three states name a format, what derived rendition names to
write, and each output's schema pin taken from a generated descriptor.
BridgeEngine.forKind(kind, format) separates applicable from executable on
this host (keyed on both). One schema bridge, for example, reads CSV, NDJSON, and Avro,
but not a Parquet footer.
BridgeEntry writes every output in one version beside the untouched original
and reports per bridge: PRODUCED, UNCHANGED (idempotence via
content addressing, not a ran-already flag), DEFERRED (names what work still
needs), or FAILED (verbatim). The bridge-entry verb is the same
two calls as a checkpointed, retryable run. There is no new runtime.
container membersRunningschemafor Avro / delimited / NDJSONRunningtextRunningocr-textRunningParquet schemaDeferreddatasetDeferredconversationDeferred
Showing every bridge kind named in the contract.
OCR quality is scored as CEL dimensions on the descriptor (for example
"0.62", reviewable in git). Engine confidence rides along unscored: treating
silence as zero would punish an engine that declines to guess. Conversation kind still
routes from no format yet; extracted text is text/plain with no
schema-subject pin.
Facets: catalog that cannot diverge
AssetCatalogRow declares index hints and metric members on its own fields.
Projection computes nothing and reads no object, so the catalog cannot diverge from the
archive. The path is verified against a real Lucene index and a real metric executor for
actual catalog questions.
Build-taught rules
- A derived rendition must never become an entry's primary. Bridges write beside the original; the primary stays the classifying bytes.
- OCR escalation fires on a zero-byte derivation. An empty text rendition lands as empty, not as a missing row.
- Index fields that mean "no answer" must declare
null_value. Otherwise the facet silently drops exactly the assets a backlog view exists to find.
A CONFLICTED console surface remains the open follow-on.
Six rules
- Format rules are the claim contract. There is no lenient mode.
- States are stored facts, never defaults.
- Identification carries evidence.
- Bridges derive; they never convert in place.
- One detector.
- Proto-first; no parallel Java model.