Asset Envelope — Open Design Decisions¶
Date: 2026-05-12
Status: Draft — awaiting decisions
Parent spec: 2026-05-12-generic-format-handler-asset-envelope-design.md
Each section is one decision that needs a call before the parent spec can ship. The items came out of a codex peer-review pass; the parent spec already applied the "just-fix" findings (factual errors, mechanical bugs). What remains here is genuinely load-bearing design — pick a direction or push back on the framing.
Leave inline comments on the decisions you want changed. Comments on a single decision take precedence over the recommendations below.
1. DataFrame wrapper vs. Asset ownership (tabular kind)¶
Problem. The current sunstone.DataFrame is a wrapper holding pd.DataFrame in
.data and a Metadata in .metadata. Legacy FormatHandler.read() returns a bare
pd.DataFrame. The parent spec needs to declare which object owns which for tabular
assets, and how df.metadata stays in sync with asset.metadata.
Options.
- A.
Assetowns.sunstone.DataFramebecomes a thin facade over anAsset.df.datareturnsasset.as_table();df.metadatareturnsasset.metadata. Legacy handlers continue returningpd.DataFrame; the adapter wraps. New handlers returnAsset(kind=TABULAR, payload=pd.DataFrame). - B.
sunstone.DataFrameowns.Assetis a transient handoff at handler boundaries. Inside the library, tabular data flows assunstone.DataFrame.Assetonly exists inss.read/ss.write/derive()calls. Non-tabular kinds useAssetend-to-end. - C. Parallel containers with explicit sync points.
Asset.metadata is df.metadatais enforced atAssetconstruction and after everyderive(). Users mutate either side and it shows up everywhere. Requires a sync helper and rules for when to re-sync (e.g., after pandas operations that drop attrs).
Recommendation: A. Single source of truth eliminates "which one do I edit". The
facade keeps the user-facing ergonomics (df.metadata.description = ...) intact.
Cost: a moderate refactor of sunstone.DataFrame to delegate everywhere.
2. Mapping sugar on Metadata¶
Problem. Examples in the parent spec used asset.metadata["sosa:observedProperty"] = ....
Metadata doesn't implement __getitem__/__setitem__. The current workaround is
asset.metadata.custom_properties = asset.metadata.custom_properties or {} then key
access — clunky.
Options.
- A. Add
__getitem__/__setitem__/__delitem__/__contains__toMetadatathat proxy tocustom_properties(lazy-init the dict on first set). Treat colon-bearing keys as RDF triples; non-namespaced keys raise. - B. Leave
Metadataas-is, document the verbose form. Users learn the API. - C. Add a separate
asset.rdf[...]mapping that proxies tocustom_properties, leavingMetadataitself unchanged.
Recommendation: A. This is the discovery story's primary touch-point — data
scientists will write asset.metadata["sosa:observedProperty"] = ... constantly. The
mapping sugar is a few lines and matches the way users already think about RDF triples
on a dataset.
3. derive() — multi-parent¶
Problem. Many real operations combine assets: raster mosaics, image stacks, table
joins, tile generation from multiple sources. derive() as drafted assumes a single
implicit parent.
Options.
- A.
Asset.derive(payload, *, derived_from: Iterable["Asset"] | None = None, ...). Whenderived_fromisNone, default to[self]. When provided, record oneprov:wasDerivedFromper parent. - B. Class method
Asset.combine(parents, payload, ...).self.derive(...)remains single-parent; explicit combinator for multi-parent. - C. Defer. Single-parent only in v1; revisit when first multi-parent handler ships.
Recommendation: A. The default stays clean (asset.derive(payload=...) still
works), and lineage stays correct for mosaics/joins. PROV-O models multi-parent
derivation natively; no reason to artificially restrict it.
4. derive() — name inheritance¶
Problem. Parent spec clears slug to None on derive but leaves name inherited.
A derived NDVI asset would silently carry the parent's "Sentinel-2 SR, July 2024" name
unless the user overrides.
Options.
- A. Clear both
slugandnametoNoneby default. User must supply both on thederive()call or before write. - B. Inherit
nameby default. User overrides when they care. - C. Add
inherit_identity: bool = False— explicit opt-in for inheritance.
Recommendation: A. Same logic as slug: a new dataset deserves its own name. Forcing
the user to think about it at derive time matches the existing to_csv(name=...)
contract.
5. derive() — custom_properties propagation¶
Problem. Blindly carrying custom_properties forward can make false statements.
Example: parent has sosa:observedProperty = "surface-reflectance". NDVI derivative
inherits it unless overridden — but NDVI is not surface reflectance.
Options.
- A. Split into two buckets on
Metadata.custom_properties(semantic, per-asset) andprovenance_properties(carries forward through derive). RDF emission joins them on serialise. - B. Add an inheritance manifest. Each RDF key declares its inheritance behaviour
("inherit", "clear", "ask"). Driven by a small built-in table for known prefixes
(
sosa:,dcat:,prov:, ...) plus user overrides. - C. Don't inherit by default; require
derive(..., inherit_custom_properties=True). - D. Inherit by default; document the footgun.
Recommendation: C. Semantic claims about data are not provenance. Forcing explicit opt-in is the safe default. Option A is cleaner long-term but adds API surface and storage complexity; do it when (B/C) proves insufficient.
6. Extras mutability on inheritance¶
Problem. extras is dict[str, Any]. derive() as drafted inherits it shallowly,
so parent and child can share a mutable profile dict or arrays map. Mutating the
child's profile silently mutates the parent's.
Options.
- A. Deep-copy
extrason derive. Safe default; small perf cost on big extras. - B. Shallow-copy + document the gotcha.
- C. Make
extrasvalues immutable by convention (frozen dataclasses, tuples). Burdens plugin authors.
Recommendation: A. Deep-copy. The kinds we expect — profile dicts, CRS strings, chunk specs — are small. The bug class this prevents (silent cross-asset mutation) is nasty and hard to diagnose.
7. Per-kind derive policies (raster profile invalidation)¶
Problem. Inheriting extras["profile"] for raster derive() is unsafe when the
payload shape/dtype/band count changes. NDVI from a multiband uint16 image becomes
single-band float; the inherited profile lies about count, dtype, nodata.
Options.
- A. Asset has no policy; writers validate and raise. Cheap to implement; user finds out at write time.
- B. Kind-specific derive policies.
AssetKind.RASTERderive inspects the payload shape vs. parent, drops stale profile keys (count,dtype,nodata), keeps geo keys (transform,crs). - C. Helper free functions per kind (
raster_derive(asset, payload, ...)) that encapsulate the right invalidation.Asset.derive()stays naive.
Recommendation: B. A small registry of KindDerivePolicy callables keyed on
AssetKind, called from Asset.derive(). Raster gets the auto-invalidation logic;
other kinds get a no-op default until they need it.
8. derive() from unsaved parents¶
Problem. prov:wasDerivedFrom referencing a parent that was never written
(no slug, no location) is not discoverable or serialisable as a useful entity.
Options.
- A. Reject. Raise on
derive()ifself.metadata.slug is None. - B. Use content-hash identity. If parent has no slug, record
prov:wasDerivedFrom <_:hash-of-content>as a blank node. - C. Collapse to parent's sources. Skip the immediate parent; record what it was derived from. Loses one hop of lineage.
- D. Warn and proceed. Record the parent as best you can (in-memory id) and let the user know the lineage will be incomplete.
Recommendation: B + D. Content-hash blank nodes preserve the lineage shape; emit a warning the first time it happens per process. Future work: opt-in "materialise upstream" mode that writes intermediate assets to disk to give them durable identity.
9. Array payload vs. extras["arrays"] overlap¶
Problem. Kind taxonomy says AssetKind.ARRAY payload is dict[str, ndarray], but
asset.arrays is also defined as a convenience accessor over extras["arrays"].
Same data in two places.
Options.
- A. Drop
asset.arraysfromD3. ForARRAYkind, users accessasset.as_array()(returns the payload dict directly).extrasreserved for store metadata (compressed, dimension labels, chunking). - B. Drop the payload form; store arrays only in extras. Payload becomes a
store handle. Discovery code uses
asset.arrays. Inconsistent with how raster works (payload IS the array there). - C. Keep both; make
asset.arraysan alias foras_array().
Recommendation: A. Payload IS the data, for every kind. Extras hold structure/ metadata about the data. The duplication is a leftover from earlier drafts.
10. Tile pyramids and the stream-based handler protocol¶
Problem. FormatHandler.read(stream: BinaryIO) / write(asset, stream) doesn't fit
XYZ tile directories, MBTiles (single SQLite file but pattern-based access), Zarr stores
(directory of chunks), or object-store prefixes. Tiles need store/location access, not
a single byte stream.
Options.
- A. Defer tiles. Drop
AssetKind.TILESfrom the v1 enum. Add it in a later spec that extends the handler protocol with aread_store(path)method. - B. Add a parallel store-based protocol now.
StoreFormatHandlerwithread(location, **kw) -> Assetandwrite(asset, location, **kw) -> None. URLHandler provides "is this a directory/store?" classification. - C. Generalise
streamto aResourceabstraction —Resource.open_byte_stream()for single-file formats,Resource.list()/Resource.subpath()for stores. Handlers declare which they need.
Recommendation: B. Keeping TILES in v1 the enum but with no shipping handler is
fine; the protocol gap is what blocks shipping. A clean parallel protocol avoids forcing
single-file formats through a more complex abstraction, and lets URLHandler extensions
(GCS prefix listing, etc.) plug in cleanly. C is more elegant but premature until we
have two store-based formats to compare.
11. Autodispatch beyond file extensions¶
Problem. ss.read("inputs/sentinel2_2024_07.tif") works via extension. But .zarr
is a directory, XYZ tile pyramids have no canonical extension, cloud prefixes
(gs://bucket/path/) often lack one. Extension dispatch breaks.
Options.
- A. Multi-key dispatch. Handlers declare
can_read(path, format, *, store_kind). Registry tries extension, then store kind (directory, file, URL-prefix), then declaredformatargument. First match wins. - B. Explicit
kind=parameter onss.read. When auto-detection fails, the user passesss.read(path, kind=AssetKind.TILES). - C. Consult
datasets.yamlfirst. If the dataset entry declaresformat: mbtiles, use that to pick the handler. Falls back to extension.
Recommendation: C, then A as fallback. datasets.yaml is already the source of
truth for what a dataset is; honouring its declared format is the cleanest signal.
Extension dispatch stays for ad-hoc reads outside of datasets.yaml.
12. Legacy vs. new handler distinction¶
Problem. The current parent spec proposed return-annotation inspection
(if read() returns Asset → new; else → legacy). Unreliable — old plugins lack
annotations, and a runtime call consumes a real stream.
Options.
- A. Two protocols.
FormatHandler(legacy, returnspd.DataFrame) andAssetFormatHandler(new, returnsAsset). Registry tries bothisinstancechecks on plugin instantiation; never inspects return annotations or runtime returns. - B. Capability marker. New-style handlers set a class attribute
__sunstone_format_protocol__ = 2. Registry checks the attribute; absence → legacy. - C. Separate entry-point group.
sunstone.format_handlers(new) vs.sunstone.plugins(legacy). Migration = move the entry-point declaration.
Recommendation: A. Two protocols is the most Python-idiomatic, plays well with
runtime_checkable, and gives plugin authors a clear "switch your base class" migration
target. B is cute but ad-hoc; C creates a long-tail of plugins registered in the wrong
group.
13. supports_metadata() — one capability or two?¶
Problem. Today's supports_metadata() conflates two things: "I can extract native
file metadata (CRS, transform, band tags from a GeoTIFF)" and "I can embed Sunstone
JSON-LD into the file". A GeoTIFF handler can do the first but not the second.
Options.
- A. Split into two predicates.
supports_native_metadata()andsupports_sunstone_metadata(). Read paths consult the first, write paths consult the second. - B. One predicate stays; add a
metadata_capabilities()set. Returns a set of capability flags. Forward-compatible if we add more later. - C. Keep one predicate, interpret it as Sunstone-only. Native metadata extraction
is implicit in what the handler chooses to populate on the
Asset.
Recommendation: A. Clear, two predicates, easy to migrate. B is over-engineered for the current need; C silently loses an important capability distinction.
14. PluginRegistry.get_format_handlers() migration risk¶
Problem. External code (not just plugin authors — users of the registry) calls
get_format_handlers() and invokes handler.read() expecting pd.DataFrame. Switching
the contract to Asset silently breaks them.
Options.
- A. Add new accessors; deprecate the old.
get_asset_format_handlers()returns new-protocol handlers.get_format_handlers()keeps returning legacy ones (or adapters thereof). Deprecation warning on the latter. - B. Break it; document loudly in CHANGELOG.
- C. Provide a
read_dataframe()/read_asset()helper pair on each handler that works for both flavours via the adapter.
Recommendation: A. Plugin API is a library promise. A versioned migration path (new accessor + deprecation) keeps trust intact.
15. RDF discovery shape (per-kind required properties)¶
Problem. The discovery destination is "find datasets by language". custom_properties:
dict[str, Any] is too unstructured: nothing forces a RASTER asset to declare CRS,
bbox, temporal coverage, bands. Without a required RDF shape per kind, discovery
queries will either fail or hallucinate.
Options.
- A. Per-kind RDF profiles, validated at write. Define minimum required triples per
AssetKind: TABULAR:dcterms:title,dcterms:description,dcterms:license,prov:wasGeneratedBy.RASTER: above +geo:hasGeometry(footprint),dct:spatial,dcterms:temporal,si:bands(list of band descriptors),si:resolution.ARRAY: above +si:variables(list with shape/dtype/dim labels).TILES: above +si:zoomRange,si:tileScheme. Writers validate; missing required triples → warning (v1) or error (v2).- B. Soft shape — recommend but don't enforce. Document the per-kind profiles; discovery layer copes with absence.
- C. Defer entirely to a follow-up spec. Ship the envelope, design the discovery shape later.
Recommendation: A, in a separate follow-up spec written before the first non-tabular handler lands. The envelope can ship without it, but no GeoTIFF handler should ship without the RDF shape it must emit. Treat as a gating dependency for non-tabular handlers, not for the envelope itself.
16. RDF value typing (IRI vs literal vs blank node)¶
Problem. custom_properties: dict[str, Any] can't tell whether the value is an
IRI reference, a typed literal (xsd:date, xsd:double), a language-tagged string, or a
blank node. Discovery queries need this distinction.
Options.
- A. JSON-LD value conventions. Values are either plain Python literals (str, int,
float, bool, datetime) or dicts following JSON-LD:
{"@id": "..."}for IRIs,{"@value": "...", "@type": "xsd:date"}for typed literals,{"@value": "...", "@language": "en"}for language tags. - B. Small RDF term wrappers.
IRI("..."),Literal("...", lang="en"). Serialised to JSON-LD on emit. - C. Free-form; rely on downstream interpretation.
Recommendation: A. JSON-LD conventions are the lingua franca; users (and tooling) already understand them. No new types to import.
17. Stable asset @id for cross-project discovery¶
Problem. slug is project-local. Two unrelated projects both have current-un-members.
Discovery across packages needs globally stable IDs.
Options.
- A. Asset
@idderived from package + slug + version. E.g.,https://sunstone.institute/datasets/sunstoneinstitute/sunstone-py/current-un-members@1.0.0. Publishable when the package publishes. - B. Content-hash identity.
@idisurn:sunstone:sha256:.... Stable across rebuilds when content is identical. - C. Both —
@idis package-namespaced;si:dataHashis content-hash; discovery uses either.
Recommendation: C. Two-axis identity: humans use namespaced names, machines use content hashes, both work.
18. Per-band / per-variable / per-layer component metadata¶
Problem. LineageMetadata.field_derivations is tabular-specific. Raster bands,
NPZ variables, and tile layers all want per-component metadata (units, dtype,
description, derivation). Repurposing field_derivations to mean "band" sometimes is
confusing.
Options.
- A. Introduce
component_metadata: dict[str, ComponentSchema]onMetadata, keyed by component name.ComponentSchemais a neutral structure withname,kind(column/band/variable/layer),dtype,units,description,derived_from.field_metadatabecomes a tabular-flavoured view onto it. - B. Per-kind metadata classes.
BandMetadata,VariableMetadata, etc., onAsset.extras.Metadatastays tabular-only for fields. - C. Defer. Ship without per-component metadata for non-tabular kinds; revisit when a handler needs it.
Recommendation: A. A neutral component model future-proofs the discovery layer
and avoids per-kind sharding. Migration: field_metadata keeps working as a typed
view; new kinds populate component_metadata directly.
19. Deprecation warning timing¶
Problem. Emitting DeprecationWarning at handler-registration time breaks users
running with warnings-as-errors during import.
Options.
- A. Emit on first actual use, once per handler class. Register a sentinel; first
read()/write()call through the adapter emits the warning, sets the sentinel. - B. Emit on registration but use
PendingDeprecationWarninguntil a configurable release. Promote toDeprecationWarningone minor before removal. - C. Environment-variable escape hatch.
SUNSTONE_SUPPRESS_LEGACY_WARNINGS=1disables.
Recommendation: A + C. First-use timing avoids import-time failures; the env-var escape hatch is cheap insurance. B is fine too but more bookkeeping.
How to respond¶
For each decision, leave an inline crit comment with one of:
- "go" — accept the recommendation as written.
- "do X instead" — pick a different option (A/B/C/...) with one-line reason if non-obvious.
- "defer" — push to a later spec; tell me where it should land (parent spec follow-up, separate spec, never).
- "reframe" — the question itself is wrong; here's the right question.
I'll fold the decisions back into the parent spec in one pass, then we ship.