Contribution Bundle - Format Specification
Status: implemented (schema/format only) See: DR-0023 Local/Offline and Hosted Submission Model, contribution-bundle-format-spec roadmap
What this document covers
A contribution bundle is a portable scientific contribution package. It carries selected scientific payloads, source-instance metadata, exporter metadata, submission metadata, bundle-local references, and integrity metadata in a format that can later be validated and ingested by a hosted TCKDB instance.
This document defines bundle version 1 (bundle_version == 1, an integer) and the
schemas implemented in
backend/app/schemas/workflows/contribution_bundle.py.
What this version is
- A schema/format milestone only.
- Re-uses existing thermo and kinetics upload schemas as the bundle's scientific payload, so nested validation runs through the same code paths as a normal API upload.
- Validatable without database access.
What this version is not
- Version 1 does not export records from any database.
- Version 1 does not import into any hosted instance.
- Version 1 does not create submissions, jobs, or moderation events.
- Version 1 does not support raw database synchronization between instances.
- Version 1 does not ship artifact packaging — only the manifest shape.
- Version 1 does not support full network bundles, arbitrary graph export, or mixed thermo+kinetics bundles.
- Version 1 does not introduce any FastAPI route, service, or DB write.
Supported bundle families
Allowed bundle_kind values:
| Value | Meaning |
|---|---|
thermo |
Bundle carries thermo upload-equivalent payloads only. |
kinetics |
Bundle carries kinetics upload-equivalent payloads only. |
Future families intentionally not allowed in version 1 (to avoid shipping bundles no exporter or importer is actually validating yet):
statmechtransportnetworkmixedcomputed_reaction
Top-level shape
{
"bundle_format": "tckdb-contribution-bundle",
"bundle_version": 1,
"bundle_kind": "thermo",
"created_at": "2026-04-25T00:00:00Z",
"source_instance": { "...": "..." },
"exporter": { "...": "..." },
"submission": { "...": "..." },
"records": { "thermo_uploads": [], "kinetics_uploads": [] },
"local_refs": { "species:ethanol": { "record_type": "species", "label": "ethanol" } },
"manifest": { "sha256": null, "files": [] }
}
bundle_format
Must be the literal string tckdb-contribution-bundle.
bundle_version
Must be the integer 1. bundle_format names the format and bundle_version
is its integer version; they are separate fields.
bundle_kind
One of thermo | kinetics.
created_at
ISO-8601 UTC timestamp (Pydantic datetime).
source_instance
Identifies the TCKDB instance that produced the bundle.
| Field | Required | Notes |
|---|---|---|
instance_kind |
yes | local or lab_server. hosted is not allowed in version 1. |
instance_name |
yes | Free-text label, e.g. calvin-laptop or pi-rmgteam-server. |
schema_version |
yes | The Alembic revision the local DB is at, e.g. d861dfd60891. |
software_version |
no | Optional TCKDB software version label. |
created_by_local_user |
no | Optional local username/label. |
notes |
no | Free-text. |
exporter
Provenance-only metadata about who produced the bundle. This is not the hosted actor identity. Hosted actor identity comes from hosted authentication during the future import milestone.
| Field | Required | Notes |
|---|---|---|
local_user_label |
yes | Local label such as calvin. |
orcid |
no | ORCID iD if known. Not validated for format in version 1. |
affiliation |
no | Free-text. |
email |
no | Free-text contact. |
notes |
no | Free-text. |
submission
Bundle-level submission metadata. The hosted instance maps this to its own
submission row at import time; the bundle does not create submissions.
| Field | Required | Notes |
|---|---|---|
title |
yes | Short title for the contribution. |
summary |
yes | One-paragraph summary. |
source_kind |
yes | Must be local_bundle in version 1. |
rights |
no | Deposit-time license agreement for every record in the bundle; see below. |
submission.rights
The rights object is the wire fragment tckdb_schemas.rights.DepositRights,
the same one every direct upload request carries:
"rights": {
"license": "CC-BY-4.0",
"depositor_attests_right_to_license": true,
"source_terms": null
}
| Field | Required | Notes |
|---|---|---|
license |
yes | SPDX identifier (1–64 characters). A hosted release compares it with its own data_license by exact, case-insensitive match; there is no compatibility lattice. |
depositor_attests_right_to_license |
yes | Must be literally true. A false fails validation — the bundle cannot carry a stored "no"; it omits the object instead. |
source_terms |
no | Citation or quotation of the terms the records were taken under, for records that are not the depositor's own work. |
On hosted import the fragment is recorded as a depositor_agreement rights
attestation on the submission, attested by the authenticated submitter
— never by the local exporter label. A bundle without rights imports
normally, but its records cannot be selected into a dataset release until a
curator records a basis for the submission.
scripts/export_contribution_bundle.py fills the object from the standing
rights attestation of the submission that deposited the exported records on
the source instance. If the records were deposited under different licenses
the export refuses (one bundle, one agreement); if only some of the records
are attested the export also refuses, rather than extending one deposit's
agreement to records nobody licensed; only if nothing at all is attested is
the object omitted — the exporter never invents consent.
Note on
local_bundleand the database enum. Thesubmission.source_kindfield on the bundle uses a format-level enum (BundleSubmissionSourceKind). The database enumSubmissionSourceKindinbackend/app/db/models/common.pydoes not yet includelocal_bundle. Adding it is deferred to the hosted-import milestone — the bundle format only states "this submission came from a local bundle", and the hosted importer will be responsible for translating that to its own submission machinery.
records
Container for the upload-equivalent scientific payloads. Reuses the existing workflow upload schemas directly:
thermo_uploads: list[ThermoUploadRequest]kinetics_uploads: list[KineticsUploadRequest]
Family rules for version 1:
- A
thermobundle must contain ≥ 1thermo_uploadsentry and must not contain anykinetics_uploads. - A
kineticsbundle must contain ≥ 1kinetics_uploadsentry and must not contain anythermo_uploads. - Mixed bundles are explicitly rejected.
Kinetics declarations in a bundle
A kinetics_uploads entry may carry determination, applicability and protocol (see
backend/schema_spec.md § "Kinetics determination, applicability and protocol declarations"). Uploads of
one bundle that state the same determination content (key, direction, whole-reaction target, source and
reaction) are imported under one reaction entry and share one determination, as the exporter grouped
them. The exporter writes what a portable bundle can carry and reports what it cannot (a
resolved-channel determination, a protocol's supporting calculations) as a declaration_pruned omission. Each exported determination carries a bundle-local determination.group handle
(d1, d2, ... in export order): records of one import that state the same handle and content share a
determination, so two determinations that share a key, source and reaction content stay two after a re-import.
The key is always exactly what the depositor stated. The handle only groups records inside one import, is
never stored, and is refused on /uploads/kinetics and on the fits of a computed-reaction upload. The export
CLI prints every omission, so declaration_pruned is never silent.
Exporting legacy thermo rows (undeclared enthalpy)
ThermoUploadRequest.enthalpy_reference_kind declares what an exported
enthalpy means (see docs/guides/depositing_a_thermo_record.md and
schema_spec.md § "Enthalpy reference declaration"). A thermo row deposited
before that field existed can carry an enthalpy with no declaration; the DB
never backfills one onto such a row, and the import workflow refuses to
accept that exact shape on the way back in
(tckdb_schemas.enthalpy_reference.enthalpy_reference_error).
app.services.contribution_bundle_export.export_thermo_bundle (used by
scripts/export_contribution_bundle.py) therefore never emits
enthalpy_reference_kind: null next to a real enthalpy. Instead it applies
one of two dispositions per legacy row, chosen by where the enthalpy lives:
- 298 K scalar, tabulated point enthalpies or Gibbs energies, or a
Wilhoit
h0_kj_mol— each is an independently optional field, so only the enthalpy value(s) are dropped from the export. A pointg_kj_molis dropped with them: it is H(T) − T·S(T) on the same undeclared zero, and the importer counts it as enthalpy content. Entropy, heat capacity, temperature range, and every other field on the record are carried over unchanged, and the record re-imports normally (now with no enthalpy content and no declaration, which is a self-consistent shape). A tabulated point left with no value at all (one that held only H and/or G) is dropped on its own; the other points are exported, and the dropped temperatures are reported in the omission'spoints_dropped_at_k. If no point survives, a storedmodel_kindoftabulatedis left out so the importer infers the kind from what remains. The record is omitted only when no point, fit or scalar value survives at all, and the omission says so and names the enthalpy values that were the record's only content. - A NASA-7 or NASA-9 fit —
ThermoNASACreate/ThermoNASA9IntervalCreaterequire every coefficient, the enthalpy term included, so there is no way to drop only the enthalpy without destroying the whole fit. Since the fit is the record's entire scientific content, the record is left out of the bundle entirely rather than exported mutilated. This holds even when the record also carries tabulated points: the fit decides, and no point is pruned or reported.
Either way the change is reported, never silent: export_thermo_bundle
returns a ThermoBundleExport(bundle, omissions) instead of a bare bundle.
Each BundleExportOmission names the affected record by its public_ref
(never a database row id) and states which disposition applied and why.
bundle is None only when every selected thermo row was omitted outright
and nothing legitimate remains to export. The CLI prints one note: line
per omission to stderr, and an error: line if nothing was left to write.
A record that already carries a declaration is completely unaffected by this and exports (and re-imports) exactly as described above.
Exporting a target and protocol declaration
ThermoUploadRequest.thermodynamic_target and ThermoUploadRequest.protocol
(see schema_spec.md § "Thermodynamic target and protocol declarations") are
optional attributed claims. A bundle must be importable on any instance, so the
exporter carries what is portable and leaves out, with a report, what names a
row of the exporting database:
- An
equilibrium_ensembletarget, and the protocol'srecipe,formation_reference,thermal_approximationanddepartures, are carried exactly (departures: []stays[]: "no departures" is a statement). - A
single_conformertarget names a conformer group of the exporting database, so it is left out; the protocol'ssupporting_calculationsare public refs to calculations of the exporting database, so they are left out too (a protocol that said nothing else is left out entirely). Left out means absent in the bundle, never replaced: a dropped single-conformer target does not become an equilibrium one. - Each such record gets a
declaration_prunedomission (or, if its enthalpy was also pruned, theenthalpy_prunedomission carries both sentences).
A legacy record that made no declaration exports none and reports nothing.
local_refs
A map from a bundle-local reference key to a small descriptor.
Local refs are not hosted IDs and not raw DB primary keys. They exist so that future bundle versions can name records inside the bundle (e.g. for cross-payload references) without claiming any hosted identity.
Local ref key rules
A local ref key has the form <namespace>:<label>:
- Namespace: lowercase ASCII, may contain
_, must start with a letter. - Label: alphanumeric plus
_,-,.. Must start with alphanumeric. - Purely numeric labels (
species:123) are rejected — they look like raw database primary keys and that is exactly what bundles must not carry as canonical identity.
Recommended namespaces (informational, validated as record_type):
species:ethanol
species_entry:ethanol-singlet
reaction:h_abstraction_001
transition_state:ts_h_abstraction_001
calculation:sp_001
thermo:ethanol_nasa_001
kinetics:h_abstraction_rate_001
literature:zhang_2024_jpca
Keys are unique by Python dict semantics — duplicate keys in the source JSON collapse to the last value, so the schema validates the deduped map.
Annotation-only in version 1
In version 1, local_refs is an annotation map, not a cross-reference graph.
It may describe records, labels, or local identities that help a future
exporter/importer reason about the bundle, but version 1 scientific payloads
(ThermoUploadRequest, KineticsUploadRequest) do not yet cite local
refs.
The version 1 schema therefore validates only:
- local-ref key shape (namespaced
<namespace>:<label>) - local-ref
record_type - rejection of purely numeric (raw-DB-PK shape) labels
- overall map shape
The version 1 schema does not enforce coverage between local_refs and
records — having a thermo:* ref does not prove it refers to a
specific ThermoUploadRequest, because nothing in the upload payload
binds to it yet.
Promoting local_refs to a first-class cross-reference is deferred to a
future bundle version, where scientific payloads can explicitly cite
local refs and the hosted importer can define a deterministic local-ref
contract for import preview/diff.
manifest
Integrity metadata for any external artifacts shipped alongside the bundle. Artifact packaging is not implemented in version 1; the manifest just defines the shape so future artifact bundling has a place to land.
| Field | Required | Notes |
|---|---|---|
sha256 |
no | Optional 64-char lowercase-hex covering hash. |
files |
yes (may be []) |
List of BundleManifestFile entries. |
created_by_tool |
no | Free-text. |
notes |
no | Free-text. |
manifest.files[*]
| Field | Required | Notes |
|---|---|---|
path |
yes | Path inside the bundle. Must be unique across files. |
sha256 |
yes | 64-char lowercase-hex SHA-256. |
size_bytes |
no | Non-negative integer. |
content_type |
no | Free-text MIME or label. |
role |
no | Free-text role hint (e.g. output_log, input_geometry). |
A bundle without external artifacts can use:
{ "sha256": null, "files": [] }
Validation rules implemented in version 1
bundle_format == "tckdb-contribution-bundle".bundle_version == 1.bundle_kind∈{thermo, kinetics}.- All required top-level metadata is present (
source_instance,exporter,submission,records,manifest,created_at). - The selected bundle kind has at least one record of the matching family.
- Mixed thermo+kinetics record sets are rejected.
manifest.filespaths are unique.local_refskeys match the namespaced format rule above.- Purely numeric local-ref labels (raw-DB-PK shape) are rejected.
extra="forbid"on every bundle schema rejects unknown top-level fields, so a future field cannot accidentally slip in unnoticed.- Nested
ThermoUploadRequest/KineticsUploadRequestpayloads run their full upload-time validators (e.g. thermo must carry actual scientific content, kineticsa_unitsmust match molecularity).
DB-backed scientific resolution (species lookup, level-of-theory resolution, deduplication, etc.) is not done in version 1.
Examples
- Thermo and kinetics bundle examples: see
examples/bundles/.
Future extension points (not in version 1)
- Adding
local_bundleto the DBSubmissionSourceKindenum, when the hosted-import milestone needs to persist bundle-origin submissions. - Wider
bundle_kindsupport (statmech,transport,network, etc.). - Cross-payload references that consume
local_refskeys directly inside scientific upload payloads. - Real artifact packaging (tarball/zip layout, content-addressed storage).
- Hosted dry-run, hosted import, and hosted submission lifecycle.
- Frontend UX for bundle authoring and review.