The bundle format#
A bundle is a directory holding a set of scientific outputs together with a description of the run that produced them. It is self-contained and does not require credentials or network access.
A project that keeps its own outputs on its own disk can write a bundle and get a reviewable, diffable, content-addressed record of what was produced and what it came from. Generally these bundles are then published to the Bookshelf platform, but they could also be shared via other means.
This page specifies what is written to disk. It is written so that an implementation in another language can produce and read bundles without reading the Python that implements this one.
The format in force is manifest schema version 3.7.
Bundle directory#
bundle/
manifest.lock
resources/
7198966a1a10c93fe40255d2c8e49b750e2cc7d3c9f56e4d06ac7e595e9afaa0.parquet
manifest.lockis a YAML document that describes the bundle and its contents.resources/holds the bytes of every managed resource, keyed on content.
The filenames of the resources are derived from their hash and type:
- Take the recorded
hash, which is alwayssha256:followed by exactly 64 lowercase hex characters. - Drop the
sha256:prefix. The 64 hex characters are the file's stem. - The extension is
parquetwhen the resourcetypeistimeseriesortabular,pngwhen it isfigure, andbinotherwise. A figure's master is always a png, so it is stored as.png.
A figure may also carry an svg companion, a vector drawing of the same figure. Its bytes live at resources/<hex>.svg, where the stem is the 64 hex characters of the record's svg_hash. The companion is keyed on its own content, so it sits beside the png master rather than replacing it.
So a timeseries resource with hash sha256:7198...faa0 is stored at resources/7198...faa0.parquet.
The directory is content addressed, so two resources with identical bytes and the same extension share one file. The manifest.lock shall not refer to any files outside of the resources/ directory, in order to make the bundle portable. Readers should ignore additional resources not included in the manifest.
The manifest#
manifest.lock is a mapping with five keys at the top level.
| Field | Required | Meaning |
|---|---|---|
schema_version | always written | the manifest schema this bundle was written against |
writer | optional | the library versions that wrote the resource bytes |
resources | optional, defaults to [] | one record per registered resource |
activity | optional | a description of the run that produced the outputs |
book | optional | the pre-edition framing of a book to publish |
A bundle carrying only resources is valid. Each of writer, activity and book is additive.
The writer header#
writer records the versions of the libraries that produced the bytes under resources/. It carries one field.
| Field | Required | Type | Meaning |
|---|---|---|---|
pyarrow | optional | string | the pyarrow that wrote the parquet bytes |
Parquet output is not stable across pyarrow versions, so the same frame written by two pyarrow versions has two content hashes. Recording the version makes that difference explain itself rather than surfacing as an unattributed change in the recorded hashes.
The whole block is absent when pyarrow was not installed on the machine that wrote the bundle. It is also absent from any bundle written against schema 1.0, because the field did not exist then.
A resource record#
Every entry in resources has these fields.
| Field | Required | Type | Default | Meaning |
|---|---|---|---|---|
name | required | string | the bundle-local name this resource is addressed by | |
hash | required | sha256:<64 lowercase hex> | the content hash, and the replay idempotency key | |
type | required | string | what sort of resource this is, see below | |
kind | optional | managed or pointer | managed | which of the two variants this record is |
format | optional | string | absent | the declared storage format, absent when it is not known |
visibility | optional | hidden, org or public | hidden | the tier this resource records as |
tags | optional | list of strings | [] | free-form labels |
description | optional | string | absent | what this resource holds |
authors | optional | list of author mappings | absent | who made this resource |
doi | optional | string | absent | the DOI for this resource |
citation | optional | string | absent | the citation to use for this resource |
license | optional | string | absent | the terms this resource is under |
license_url | optional | string | absent | the full licence text for those terms |
caption | figure only | string | absent | what the figure shows, at most 500 characters |
alt_text | figure only | string | absent | the figure described for a reader who cannot see it, at most 1000 characters |
metadata | optional | mapping | {} | free-form metadata |
dedupe | optional | boolean | true | whether byte-identical resources may collapse to one canonical resource |
size | managed only | integer | absent | the byte length of the stored bytes |
external_uri | pointer only | string | absent | the external target |
generated | optional | boolean | false | whether an activity produced this resource |
used | optional | list of names | [] | what this resource was derived from |
used_digests | optional | list of digests | [] | inputs it was derived from that the bundle does not carry |
svg_hash | figure only | string | absent | the sha256:<hex> of the svg companion at resources/<hex>.svg |
role | optional | plan | absent | plan when this is the plan the activity followed |
name is local to the bundle that registers it, and it carries no hierarchy. It matches ^[a-z0-9][a-z0-9._-]{0,199}$, it is unique within the manifest, and it is the name the platform registers the resource under. A resource that a book entry names takes that name inside the book too.
The type values currently in use are:
timeseriestabulargeospatialdocumentbinaryfigure
The field is a plain string and the set is not closed, so a reader must carry a type it does not recognise rather than refuse the bundle. Only timeseries, tabular and figure change how a byte file is named.
caption and alt_text belong to a figure alone, and the platform refuses them on any other type. A figure whose visibility is public must record an alt_text, because the website is public and a reader who cannot see the picture still needs to know what it shows.
svg_hash belongs to a managed figure alone. It is written when the producer handed over a matplotlib figure, so the recorder could draw a vector beside the png master. A figure supplied as png bytes records none, and the figure is complete without one. The platform serves the companion as the figure's vector format, and it keeps the first companion bound to a given master.
managed versus pointer#
kind is the discriminator between the two variants. This allows referencing files that are stored elsewhere.
managedmeans the bytes belong to the bundle. The record carriessize, andresources/<hex>.<ext>holds bytes that hash tohash.pointermeans the bytes stay where they are. The record carriesexternal_uriand nosize, and there is no byte file. A pointer says "this resource exists at that URI, and nothing may re-host it".
A reader must branch on kind and never infer the variant from a missing field.
A pointer still carries a hash, so lineage and identity work the same for both variants. When the producer knows the external content's digest, it records it. When it does not, the hash is synthesised so that a hashless pointer still has a stable identity:
- Build the mapping
{"type": <type>, "locations": [["external", <external_uri>]]}. The name is deliberately not part of the seed, so the same external pointer collides on the same canonical resource no matter what the producer named it. - Serialise it as JSON with sorted keys and
,and:separators and no whitespace. - The hash is
sha256:followed by the hex digest of those UTF-8 bytes.
Every implementation must synthesise it the same way, because the identity of the pointer depends on it.
Lineage#
Lineage is expressed on the resource records, not as a separate graph.
generated: truemarks a resource as an output of the recorded activity.usedlists what that output was derived from.
used names the resources an output was derived from:
Each name must belong to a resource recorded earlier in the same resources list, so an input is always registered before whatever consumes it. A name the manifest does not carry is invalid. The server resolves each name against the resources of that same replay and nothing else, so the same bundle always resolves to the same inputs however often it is replayed.
Order is therefore part of the format rather than an accident of how the bundle was written.
used_digests cites the inputs the bundle does not carry, by the bytes they hold:
This is how a book built from an uploaded file cites the upload. Such an input is already on the platform and belongs to no book, so the bundle records nothing for it and it has no name for used to carry. Each digest is canonical sha256:<hex>. This resource must exist and be accesible by the publisher.
Inputs accumulate within a run. A resource records the inputs known at the moment it was registered, so a later output can cite more than an earlier one and never rewrites what the earlier one recorded.
role: plan marks the plan the activity followed, such as a method card:
A plan is neither generated nor derived from anything, so it records no used or used_digests. It needs an activity, and outputs never name it in used. The platform links it instead to every generated resource of the activity, with a used edge carrying role: plan, which is PROV-O prov:hadPlan. A reader older than 3.7 drops the field and replays the plan as an ordinary input with no lineage.
The activity envelope#
The optional activity describes the run that produced the bundle.
| Field | Required | Meaning |
|---|---|---|
activity_id | required | UUID minted by the producer, stable across replays of the same bundle |
kind | required | what sort of run this was, for instance build |
code_ref | required | the code that ran, conventionally <git remote>@<sha> |
config_hash | required | a sha256:<hex> digest identifying the run's configuration |
parameters | optional, defaults to {} | the parameters the run was given |
runner | optional | what executed the run |
config_hash is what says two runs were configured the same way. A producer that has a better digest records it. Otherwise it is the digest of the canonical JSON of parameters.
A bundle records one activity. Recording a second envelope that differs in any field is an error. Recording an identical one again is not.
The envelope carries no start or end time and no duration. Nothing in a bundle is a timestamp (see Determinism).
The book framing#
book frames a book to draft and publish. This field is specific to the Bookshelf and is optional.
| Field | Required | Default | Meaning |
|---|---|---|---|
volume | required | the volume the book belongs to, referenced by name and never created by a bundle | |
version | required | the consumer-facing data version | |
visibility | optional | hidden | the tier of the book |
license | optional | absent | the SPDX licence |
authors | optional | [] | who made this version's data, sent on the replay |
discovery | optional | absent | the editorial metadata baked onto this book, keyed by the recipe's field names |
description | optional | absent | free prose |
metadata | optional | {} | free-form metadata |
entries | optional | [] | the book's membership |
published | optional | false | whether the book should be published, or left a draft |
processing | optional | absent | the [code_ref, config_hash] pairs of the runs that generated the book's members |
discovery and authors hold values the recipe has already resolved, so the bundle records what will be published rather than what was declared. Replay sends both, which is how each book keeps its own copy of what was true when it was published. Neither enters the seal the server computes.
processing is provenance, and it is not part of the seal. It answers "which code produced this", and nothing more. A rebuild whose code changed but whose data did not converges on the existing edition. It is recorded so bookshelf validate reads as a complete account of the build.
Each entry in entries carries the membership and its own optional column descriptions:
| Field | Required | Default | Meaning |
|---|---|---|---|
name | required | a resource recorded in this same manifest | |
data_dictionary | optional | absent | column-level descriptions that apply to this entry |
An absent data_dictionary leaves the entry's existing dictionary unchanged on replay. An empty list explicitly clears it, which is the declaration used for notebooks and rendered pages.
An entry must name a resource recorded in the same manifest, which is what keeps a bundle self-contained. The entry's name is the resource's name, and it is unique within a book, because the platform registers a replayed resource under the name its entry takes. A writer enforces both when it appends an entry. Validation checks the reference and not the uniqueness, so an implementation that reads a hand-edited manifest should check the names itself.
Determinism#
The manifest is written deterministically. The same content produces byte-identical output on any machine, on any run.
- Every mapping is sorted by key, recursively. List order is preserved as recorded.
- Fields with no value are omitted rather than written as null.
- Newlines are LF, and the encoding is UTF-8, whatever the platform.
- Strings are written on one line, up to a bound of 10000 characters.
- Nothing is a timestamp, a hostname, a path from the producing machine, or a random id minted at write time.
This is what makes a bundle reviewable. Re-running a build that produced the same outputs produces the same manifest, so a diff shows what actually changed rather than the noise of a fresh run. It also means a bundle can be committed to a repository and reviewed in a pull request before anything is published anywhere.
Versioning#
schema_version is <major>.<minor>. A reader models one major version, and this specification describes major 3.
- A newer minor loads. Minor changes are additive, and a reader ignores fields it does not model. An older reader therefore reads a newer bundle by dropping what it cannot understand.
- A newer major is refused. A reader must raise rather than interpret it, because a major change means a field it does model may now mean something else. Reading it anyway would silently drop meaning.
- An older major is refused.
- A
schema_versionthat is not a string, or whose major part is not an integer, is refused. - An absent
schema_versionis read as the current version.
Tolerance is one-directional by design.
Validation#
These are the rules an implementation checks to decide that a bundle is a replayable published book. A replayable book contains all the required information to later be streamed to the Bookshelf API.
- The manifest records a
book. - That book has
published: true. - That book has at least one entry.
- Every entry's
namematches a resource recorded in the same manifest. - Every resource with
type: figureandvisibility: publicrecords a nonblankalt_text. The platform refuses a public figure without one, so a replay would fail only after every byte had uploaded. - Every resource's catalogue fields are ones the contract accepts. A
captionover 500 characters or analt_textover 1000 fails here, for the same reason. - Every resource with
kind: managedhas a byte file, and those bytes hash to the recordedhash. A resource whosehashis not canonical fails here, because it names no byte file. - Every resource recording an
svg_hashhastype: figure, andresources/<hex>.svgholds bytes that hash to it.
Rules 7 and 8 re-hash rather than trusting the manifest. A bundle edited between being recorded and being used is refused, so what is published is what the reviewer saw.
A bundle that fails any of these is still a bundle. A resources-only bundle, or one whose book is a draft, is a perfectly valid artefact. It is just not a replayable published book, so a consumer that is about to publish refuses it and says which rule failed.
Pointers are not fetched during validation. Nothing checks that an external_uri resolves, and nothing verifies an external digest. A pointer is a claim about somewhere else, and validating it would need the network that the format is designed to avoid.
What a bundle does not carry#
- The edition. A bundle is pre-edition. The identity of a published edition is assigned by the server when the bundle is replayed, and it is the Nth reprocessing of that
(volume, version)and nothing more. An offline consumer must not synthesise an edition or treatversionas one. What identifies a generation of processing is the activity'scode_refandconfig_hash. - A seal. The server computes the seal from the replay request alone, which is what makes two replays of one bundle converge on one edition. The client neither stores nor computes it.
- Tracking ids. The platform owns the mapping from a bundle-local name to the resource it registered, so nothing in a bundle names a row on a server.
- Timestamps and machine state. See Determinism.
- The volume. A bundle references a volume by name and never creates one.
- Anything about a server. No URLs, no organisation, no credentials, no account. A bundle names its content and its provenance, and nothing about where it might end up.
A worked example#
A complete bundle with one pointer, one managed resource derived from it, an activity envelope and book framing.
bundle/
manifest.lock
resources/
7198966a1a10c93fe40255d2c8e49b750e2cc7d3c9f56e4d06ac7e595e9afaa0.parquet
activity:
activity_id: 0197a000-0000-7000-8000-00000000a001
code_ref: git@github.com:example/example-emissions@0f7c1e2
config_hash: sha256:2078d1b688f90612b09a8b97cf0ef12c6f5e915ebe8f7bf252a82b76a321e5c2
kind: build
parameters:
upstream_version: v1.0.0
runner: python-3.13
book:
authors:
- email: ada@example.com
name: Ada Lovelace
description: A worked example bundle.
entries:
- name: upstream-emissions
- data_dictionary:
- name: region
role: dimension
type: string
name: emissions-co2
license: CC-BY-4.0
metadata: {}
processing:
- - https://github.com/example/feedstock@1f0c9a2
- sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a
published: true
version: v1.0.0
visibility: public
volume: example-emissions
resources:
- dedupe: true
external_uri: https://example.org/upstream/emissions-v1.0.0.csv
generated: false
hash: sha256:c6dd00dc24e5ddcf21081c662e8264bbc0cf7d10986181f961545eaef0e4051c
kind: pointer
metadata: {}
name: upstream-emissions
tags: []
type: tabular
used: []
visibility: public
- dedupe: true
format: parquet
generated: true
hash: sha256:7198966a1a10c93fe40255d2c8e49b750e2cc7d3c9f56e4d06ac7e595e9afaa0
kind: managed
metadata: {}
name: emissions-co2
size: 1396
tags: []
type: timeseries
used:
- upstream-emissions
visibility: public
schema_version: '3.7'
writer:
pyarrow: 23.0.0
Reading it back:
- The pointer carries no bytes and no
size, soresources/holds one file, not two. - Its hash was synthesised from its type and URI, because the producer did not know the upstream digest.
- The managed resource cites the pointer by name, which is the lineage edge, and the pointer is recorded before it, which is what makes that citation legal.
- The book publishes both under the names their resources carry, and carries no edition.
For a Python reader#
bookshelf.publisher.bundle implements this format. Bundle.read, Bundle.validate and Bundle.read_validated load and check a bundle directory, and the Bundle* models mirror the manifest structure field for field. bookshelf.publisher.recording writes bundles from a producer run, and bookshelf.publisher.replay sends one to the platform.