Types and Asset System¶
Design Goals¶
Every file in Stargazer carries structured keyvalue metadata that describes its role, enabling:
- Consistency: All tools produce and consume files through the same metadata contract
- Extensibility: New asset types and metadata fields can be added without breaking existing code
- Queryability: Files can be discovered and filtered by metadata without path conventions
- Companion linking: A file a tool needs beside another (e.g. index + primary) names it by CID in
companion_of
Architecture¶
flowchart TD
SC("Asset Subclasses\nschema — real typed dataclass fields")
KV("dict[str, str]\ntransport — flat storage keyvalues")
ST[("StorageClient\npersistence — object store + index")]
SC <-->|"to_keyvalues() / from_keyvalues()"| KV
KV <-->|"upload() / query()"| ST
There is no separate "storage primitive" layer. Asset is both the typed schema and the storage identity. Typed fields are ordinary dataclass attributes; the flat dict[str, str] exists only at the storage boundary, produced and consumed by to_keyvalues() / from_keyvalues().
Asset: The Base Class¶
Asset (assets/asset.py) is a single dataclass for all typed file assets. Every file in the system is an Asset instance.
| Field | Type | Purpose |
|---|---|---|
cid |
str |
IPFS CID of the file, computed locally from its bytes |
path |
flyte.io.File \| None |
Where the stored file lives (set by update()); assigning a local Path wraps it in a File |
keyvalues |
dict[str, str] |
On a typed subclass, the extra keys its class doesn't declare (see Extra keys); on a bare Asset, all of its metadata (see Catchall below) |
Subclass Declaration¶
Subclasses declare _asset_key — a unique string identifying the asset kind — plus ordinary typed dataclass fields with defaults (e.g. Alignment declares sample_id, duplicates_marked, bqsr_applied). See Writing a Task for a worked declaration. Subclasses auto-register in Asset._registry (via __init_subclass__), which maps _asset_key strings to their class. Registration is import-driven and per-process: a class defined in user code registers the moment its module imports, so assemble() returns it in any process that imports it.
__setattr__ enforces the declared schema on typed subclasses — assigning a field the dataclass doesn't declare raises AttributeError, catching metadata typos in task code at write time. On a bare Asset (no _asset_key) it passes through. Attributes are strict; keyvalues is not: a key the class doesn't declare goes in keyvalues explicitly.
Extra keys¶
A typed asset can carry keyvalues its class doesn't declare, as strings in keyvalues. An Alignment tagged lane=3 stays an Alignment, so every task that takes one can still be handed it, and assemble(lane="3") finds it. Declared fields keep their types and their checks; extras have no type to check, so they're stored and returned exactly as given. If a class later declares that key, the stored value is parsed into the field on the next query, the same way bare records type-promote.
A key is either a declared field or an extra, never both: from_keyvalues() splits a record so the two can't overlap, and to_keyvalues() raises if code puts asset or a declared field into keyvalues by hand. A record read back from storage carries its stamped _owner among the extras, as a bare Asset does.
Extras ride along wherever the asset goes: between tasks (Flyte serializes the dataclass, keyvalues included), into the index, and back through assemble(). A task that builds a new asset from an input copies only the fields it sets, so an input's extras don't pass to its outputs. The user's own index stores any number of extras; a file uploaded to the public tier is limited by Pinata's keyvalue cap.
Serialization¶
to_keyvalues() / from_keyvalues() convert between typed fields and the flat dict[str, str] storage carries:
strfields pass through unchanged.- All other types round-trip via
json.dumps/json.loads(bool→"true",int→"12",list→ JSON array).
A typed asset's extra keys are written beside its declared fields. from_keyvalues() raises on a declared non-str value that doesn't parse; callers that must tolerate malformed records catch it (see Specialization).
Catchall: bare Asset¶
A bare Asset (no _asset_key) carries a free-form keyvalues dict, serialized verbatim (the asset key lives inside it). It is for kinds of file no class describes yet, not for adding fields to a kind that has one: an extra field goes on the typed asset (see Extra keys). Only the asset key is required, and no field schema is enforced. When a user later defines a matching Asset subclass, those records type-promote on the next query.
Validation: build_asset()¶
build_asset(keyvalues) (assets/__init__.py) is the single validation choke point shared by the MCP server (upload_file, update_file) and the admin asset page (/assets/tus uploads and /assets/update metadata edits). It requires an asset key, rejects reserved _-prefixed keys (stamped automatically — see Ownership), builds a registered key's class (declared fields must parse to their types; any other key becomes an extra), and builds a bare Asset for unregistered keys. One place decides typed-vs-generic so the page and the SDK never drift.
Ownership (_owner)¶
_owner is a reserved keyvalue stamped automatically for attribution — never typed by users (build_asset() rejects _* keys). The storage client stamps it from STARGAZER_OWNER on every index row it writes (upload() and update_metadata()), and PinataClient.upload() on public uploads; the env wins over any stale value, and with it unset nothing is stamped. PinataClient.update_metadata() stamps nothing: its caller names the owner in the patch, and the asset explorer's edit route sets the signed-in user's subject. The explorer's uploads stamp the signed-in user too. Every public record has one: PinataClient refuses an upload or edit that would leave a public record without _owner, and its reads skip any public file that has none, since anyone holding the key can upload straight to Pinata. It records who made a record and decides which public companions fetch() takes; it grants or denies nothing else, and on Pinata's public network anyone holding the key can edit or delete any record.
Core Methods¶
fetch()— downloads self, then every asset withcompanion_of = self.cidinto the same directory (the user's own, plus public ones the user or the asset's publisher published); returns the local pathupdate(path, **kwargs)— sets the given attributes, stores the file, and setscidandpathto_dict()/from_dict()— JSON serializationdeclared_fields()— the names of the fields a type declares, in order: its typed keyvalues, without the basecid,pathandkeyvalues
Asset Subclass Catalog¶
See the Catalog for a complete list of registered asset types.
Notebook is the one type no task produces: a workspace notebook becomes one when its owner publishes it from the dashboard to the public network, and nothing else creates one.
Companion Pattern¶
A companion is a file a tool expects beside another, such as an index or a sequence dictionary. It records that file's CID in its companion_of keyvalue. When fetch() is called on an asset:
- Downloads the asset itself
- Queries for assets whose
companion_ofequals this asset's CID: the user's own, then public ones the user published, then public ones from the asset's own publisher for names still missing. Nobody else's are taken - Downloads them into the same directory, so tools find an index next to its file
- Returns the asset's local path
Example: Reference(cid="bafk…").fetch() also finds and downloads every ReferenceIndex, SequenceDict and AlignerIndex with companion_of="bafk…". The typed companions are AlignmentIndex, ReferenceIndex, SequenceDict, AlignerIndex, VariantsIndex, KnownSitesIndex, and a VQSRModel's VQSRModelIndex and VQSRTranches, but any asset with companion_of set is one, a bare Asset included.
companion_of is the only link fetch() follows. The {asset_key}_cid fields and source_cid record which input an output was made from — reference_cid and r1_cid on an Alignment, alignment_cid on a BQSRReport. That's provenance: assemble(reference_cid=…) finds every alignment made against a reference, and fetch() leaves them behind. Reads link their mate through mate_cid, which is neither.
Assembly¶
assemble(**filters) is a module-level async function in assets/asset.py. It queries storage with keyvalue filters, deduplicates by CID, and returns a flat list[Asset] of specialized subclass instances.
Every filter must match exactly. A list value matches any of its entries, in a single query (the asset key accepts a list too).
Workflows filter the returned list with isinstance to pick out the types they need — see Writing a Workflow.
Specialization¶
specialize(record) in assets/__init__.py converts a raw storage record to its registered subclass by looking up keyvalues["asset"] in Asset._registry. Keys the class doesn't declare come back as its extras. It is graceful at query time (the mirror of build_asset() being strict at write time): an unregistered key, or a record whose declared values don't parse against the registered class, falls back to a bare Asset carrying the keyvalues verbatim rather than raising — so one malformed legacy record can't crash a whole assemble().
Storage Layer¶
utils/storage.py defines StorageClient, the one storage interface: upload(), download(), query(), companions(), delete(), publish() and update_metadata(). The module-level default_client is built lazily from the environment, so it sees credentials Flyte injects just before a task runs.
- Bytes go to the object store at
<store root>/users/<owner>/assets/<cid>/<name>. - Metadata goes to the index (
utils/index.py), one row per CID; on Union it's the user's own table on Aurora DSQL. - Public data joins queries from Pinata's public network when
PINATA_JWTis set. - The cache at
<STARGAZER_LOCAL>/<cid>/<name>sits in front of downloads.
The CID comes from utils/cid.py, which builds the same UnixFS DAG Pinata does, so identity never depends on where a file is stored. Locations and settings are in Configuration.
Tasks never call storage directly. All storage interaction flows through Asset.fetch(), Asset.update() and assemble().