The normative on-disk format of a GDI dataset package: the interchange contract any tool
that produces or consumes a package builds against. The gdi-dataset-tool build and
pack commands write it and the node ingests it, and this document specifies it for both
and for any third-party producer or consumer.
For the authoring workflow, the package.yaml you write, which is not this format, see
gdi-dataset-tool.md. For how the node stores a package after ingest
see architecture.md.
A package is an uncompressed TAR (see below) encrypted with crypt4gh v1 and named
{datasetId}.tar.c4gh. The framing is standard crypt4gh:
magic "crypt4gh" (8 bytes) │ version u32-le (= 1) │ packet_count u32-le
│ packet_count header packets (one per recipient)
│ body: a sequence of 64 KiB ChaCha20-Poly1305 cipher segments
- The package is addressed to one or more recipients, normally the node's published crypt4gh recipient. A fresh random session key is drawn per encryption.
- crypt4gh headers are re-wrappable, so the recipient set can be changed by rewriting only
the header, with no payload re-encryption. That is what
gdi-dataset-tool rekeydoes for a node-identity rotation.
Recipient and writer key are different keys. Confusing them breaks ingest. The
recipient is the public key a package is encrypted to, normally the node's. The
writer key is the secret key the header was signed with, that is, who packed it. On
ingest the node recovers the writer key's fingerprint (sha256:<hex>) and checks it against
that channel's allow-list — [ingest].inbox_allowed_writer_fingerprints for the inbox,
allowed_writer_fingerprints on the bucket for S3. [ingest].writer_policy decides what a
failed check costs.
The trap is that a plain gdi-dataset-tool rekey mints a fresh ephemeral writer key unless
--as names one. The fingerprint therefore changes, and a node running writer_policy = "enforce" rejects a package it previously accepted. Pass --as with the original identity
when re-keying a package that must stay attributable to the same provider.
manifest.json is the first TAR member, so a consumer that needs only metadata can read
the leading bytes of even a multi-GB .tar.c4gh (over S3, a ranged GET), decrypt those,
and stop before the parquet payload. To size that range you need the header length and the
segment size:
- Header length. The header is
magic(8) ‖ version(4) ‖ packet_count(4)followed bypacket_countpackets, each prefixed by its own u32-le total length (the prefix includes its own 4 bytes). The header ends, and the body begins, at16 + Σ packet_len. This is derivable from the header bytes alone; a consumer need not decrypt or re-encrypt to find it.gdi_node_standalone_core::crypt4gh::header_len(prefix) -> Result<Option<usize>>is the authoritative definition of this framing. It returnsNoneif the prefix is too short, in which case fetch more and retry. - Segment size. The body is a sequence of fixed cipher segments of
CIPHER_SEGMENT_SIZE=SEGMENT_SIZE (65536) + 12-byte nonce + 16-byte tag= 65564 bytes, each decrypting to up toSEGMENT_SIZE= 65536 plaintext bytes (both are public constants ofcrypt4gh). - The boundary rule. A ranged read must end on a segment boundary,
header_len + n·CIPHER_SEGMENT_SIZE. A cut in the middle of a segment fails to decrypt; the reference Pythoncrypt4ghraisesValueError: Could not decrypt that blockafter emitting the good prefix. One whole segment, 64 KiB of plaintext, more than covers themanifest.jsonat the front of a typical package. Ifmanifest.jsonis larger, fetch further whole segments until its TAR member is complete.
The plaintext inside the envelope is an uncompressed TAR with a strict member order:
| # | Member | Notes |
|---|---|---|
| 1 | manifest.json |
Always first, so a metadata-only consumer reads it without touching the payload. |
| 2 | headers/{vcfId}.vcf |
Sorted; the source VCF headers, filtered by internal.headerPolicy. Dropped at ingest and not served, so like files and internal this is a producer-to-integrating-system channel (see files / internal). |
| 3 | allele-freq.chr{CHR}.{block}.br{range}.{vcfId}.parquet |
Sorted; the aggregated-frequency payload, last. |
Parquet member names are six dot-separated components: allele-freq, chr{CHR} (1–22,
X, Y, M), {block}, br{range}, {vcfId}, parquet. For example,
allele-freq.chr3.0.br10000000.0123456789abcdef.parquet. TAR entry headers are normalized
for reproducibility: mode 0644, uid and gid 0, empty owner names, mtime 0, regular
files only.
Several
{vcfId}s may share one{block}, and a consumer must not assume otherwise. One source VCF yields one{vcfId}, so a package built from several VCFs can hold several files for the samechrandblock. Rows are sorted by(POS, REF, ALT, POPULATION)within each file, never across files. Sources split by population cover the same loci, so theirPOSspans overlap and reading the files back to back yields aPOSthat jumps backwards. A consumer that needs ordered rows must merge the block's files; one that assumes concatenation is ordered will mis-group silently.The reference node merges such a block in memory before folding it, at a cost described in operating.md §22. Packages that split by position rather than population put one file in each block and avoid the question.
The package's first member. Serialized camelCase. Top level:
Canonical type contract:
manifest.schema.jsonis the authoritative machine-readable schema (JSON Schema draft 2020-12), generated from the RustManifestmodel incrates/core/src/modeland held to it by a freshness test. It carries every field's name, type and description. The tables below are a human-readable summary, and on any conflict the schema wins. Validate or code-generate against the schema rather than hand-transcribing these tables. Semantics the schema cannot express are noted inline below:numberOfRecordsis required at ingest though optional on the wire, and the node re-derives and enforces bothnumberOfRecordsandpopulations.
| Field | Type | Required? | Meaning |
|---|---|---|---|
metadata |
object | yes | FDP-public per-dataset metadata (below). |
files |
array | no | Upstream source provenance (VCF/BAM inventory). Stripped at ingest. |
internal |
object | no | Non-public, node-opaque bookkeeping. Stripped at ingest. |
payload |
object | no | Digests of the packaged members (below). Retained at ingest. |
config |
object | yes | Processing options + provenance (below). |
| Field | Type | Required? | Meaning |
|---|---|---|---|
datasetId |
string | yes | The generated dataset ID (replaces the authoring prefix/org). |
catalog |
string | yes | Must match a catalog the node accepts. |
title |
string | lang-map | yes | Plain string or a {lang: text} map. |
description |
string | lang-map | yes | Ingest rejects a manifest without it; manifest.schema.json lists it under required. |
accessRights |
IRI string | yes | Access-right authority IRI, a closed set: PUBLIC, RESTRICTED and NON_PUBLIC under http://publications.europa.eu/resource/authority/access-right/. Any other value is rejected. |
applicableLegislation |
IRI array (≥ 1) | yes | EU ELI IRIs. Omitting the EHDS ELI (http://data.europa.eu/eli/reg/2025/327/oj) is a warning, not a rejection. |
license |
IRI string | yes | Reuse license IRI. |
creator |
[{name}] (≥ 1) |
yes | Creating agents, each with a non-empty name. |
healthCategory |
IRI array (≥ 1) | yes | GDI health-category IRIs, a closed set (manifest.schema.json lists it). Any other value is rejected. |
contactPoint |
{fn, hasEmail, hasURL?} |
yes | Who to write to about the dataset: a name and a mailto: e-mail, and optionally a web page. |
keywords |
string array | no | Discovery tags. |
healthTheme |
IRI array | no | Disease or healthcare areas, from the EU health-theme vocabulary (https://hdeu-dcat.data.health.europa.eu/resource/authority/health-theme/…, 20 codes). Any other value is rejected; genomics is HEALTH_PRODUCTS. Recommended: build warns without it. |
numberOfUniqueIndividuals |
uint | no | Distinct sequenced subjects. |
conformsTo |
IRI array | no | GDI standards-compliance IRIs, a closed set: ExternallyGoverned, 1MGCompliant and 1MGCohort under http://data.gdi.eu/core/p2/. Any other value is rejected. |
type |
IRI array | no | Dataset-type IRIs (set only for synthetic data), a closed set (manifest.schema.json lists it). |
legalBasis |
IRI array | no | DPV legal-basis IRIs. GDI requires one for NON_PUBLIC data: without it the tool and the node warn. |
isReferencedBy |
IRI array | no | Publication DOI IRIs. |
otherIdentifier |
[{notation, schemaAgency?, name?}] |
no | Secondary identifiers. |
minTypicalAge, maxTypicalAge |
uint (≤ 150) | no | Typical age range of the people in the dataset, in years; the minimum is not above the maximum. |
provenance |
string | lang-map | no | How the data was produced. |
numberOfRecords |
uint | required at ingest | Distinct (chr, POS, REF, ALT) count. The node re-counts and rejects a mismatch. |
populations |
string array | no | The population labels the dataset serves, sorted: the union of each source VCF's emitted set, after the build-time minAlleleCount floor. The node re-derives it from the parquet and rejects a mismatch, as for numberOfRecords; an absent claim is accepted. Served on the beacon datasets entry as gdiDatasetInfo.populations. |
The closed lists (healthCategory, healthTheme, conformsTo, type) take each value at
most once; a repeat is rejected.
Serialized camelCase with deny_unknown_fields, so an unrecognized key fails ingest
loudly.
| Field | Type | Required? | Meaning |
|---|---|---|---|
mode |
aggregated | individual |
yes | Only aggregated is implemented; individual is rejected. |
blockRange |
uint | yes | Position block range in bases (0 = one file per chromosome). |
afSource |
string | no | Allele-frequency provenance source (free text). |
afSourceReference |
string (URL) | no | Provenance source reference. |
minAlleleCount |
uint | no (default 0) |
AC floor. Applied at build as data minimisation, and re-applied at serve time at the effective floor max([beacon].min_allele_count, this value). A node can raise a declared floor but never lower it, and an out-of-band parquet built without the drop is still suppressed. Disclosed as gdiDatasetInfo.minAlleleCount. |
hideLowerCounts |
uint | no | Declared sensitive-tier floor, recorded only. Applied neither at build nor at serve. |
assembly |
{reference} |
yes | The dataset assembly the node reads (promoted from the VCF group's reference; a dataset is single-assembly). |
manifestVersion |
uint | yes | Manifest schema version — see below. |
generatedBy |
string | yes | The tool identity that produced the manifest. |
Who reads these sections is a deployment choice, not a node feature. They exist for the
provider's own records and, optionally, for an integrating system: a back-office of the node
operator's own that reads a package's non-public parts (files, internal, headers/) for
its registry, lineage or sample bookkeeping. A standalone node needs no such system.
files is the inventory of the provider's source inputs, [{category, reference?, preciseReference?, files: [{path, sha256?, size?, conversion?}]}], recorded as provenance
rather than as the served payload. internal is {internalId?, pastVersion?, headerPolicy?}, node-opaque bookkeeping. The node parses both as opaque and then strips
them: neither reaches the stored datasets/{id}/manifest.json, which carries metadata,
config and payload. payload is kept where these two are not, because it describes the
bytes this node now serves rather than the provider's upstream inputs.
{algorithm, members: {"<tar member>": {sha256, size}}}: the SHA-256 and size of every TAR
member the package carries except manifest.json itself, keyed by member name
(headers/{vcfId}.vcf, allele-freq.*.parquet) and sorted by key. algorithm is
"sha256", encoded in the schema as an enum with ^[0-9a-f]{64}$ on each digest, so a
document declaring anything else is schema-invalid rather than merely unverifiable.
The inner key is members, not files, because a top-level files already means the
provider's source inventory.
payload is what the package ships; files is what it was built from. They are not
interchangeable: two packages built from identical source VCFs with a different
minAlleleCount floor, column projection or tool version have identical files and
different payloads. gdi-dataset-tool diff therefore prefers payload and reports which
basis it used, as comparisonBasis: payload | source.
The retained
payloadis the package's inventory, not the served directory's. The node keeps the section verbatim, including itsheaders/{vcfId}.vcfentries, but drops theheaders/members themselves at store time as data minimisation. They are therefore digested in the record and absent from<data_dir>/{id}/on every healthy dataset. A consumer re-verifying the stored manifest against the served directory must check theallele-freq.*.parquetentries and treat theheaders/ones as a record of what arrived, not as files it should find. Only the parquet entries describe bytes the node still holds.
The section is optional. Absence means unknown, never "no payload": consumers must not
treat a missing payload as a verification that passed.
packrefuses a staging directory whose bytes no longer match a recordedpayload, such as a partially-copiedrsync, an ENOSPC truncation or a hand-pruned directory, so a package whose manifest describes bytes it does not carry cannot be signed. With nopayloadrecorded the check is skipped, not failed.- The node verifies it at ingest, after the manifest is validated and before anything is
stored or
filesandinternalare stripped, so a package that does not contain what it declares leaves nothing behind in the data dir. Theparquet-digests.jsonsidecar cannot answer this, because it is computed from the received bytes: a package that disagreed with its own manifest would have the disagreement recorded faithfully andverify --digestwould reportok. - Member names are validated before use. They are attacker-influenced input joined onto a
filesystem path, so a name that escapes the package (
../…, absolute,.-segments) is rejected rather than followed. - The node retains it, unlike
filesandinternal: it carries no upstream provenance, and it is the only at-rest record of what the served data was supposed to be.
internal, contactPoint and otherIdentifier accept unknown keys at parse time, and must:
the node deserialises these same shapes out of a provider's manifest.json, so an older node
has to tolerate a field a newer package added rather than reject a package over a section it
discards anyway. The cost is that a typo in one of them is dropped silently, so
gdi-dataset-tool build reports unrecognised keys as a warning and build --strict as an
error. Every other package.yaml section rejects unknown keys at parse time.
Each path is the source file's path relative to the package.yaml's directory,
/-separated on every platform, or the operator's declared string verbatim when the file
lies outside that directory. It is not an in-package member name; the source files are never
shipped inside the .tar.c4gh. Keeping the relative path rather than the bare basename lets
a per-chromosome layout (chr1/data.vcf.gz, chr2/data.vcf.gz) record two distinguishable
provenance rows.
Each VCF entry carries a conversion block recording what the VCF-to-parquet projection
discarded, ordered along the pipeline. Most fields are uint counts; four are string arrays.
The exact wire shape is pinned by the crates/core/tests/fixtures/manifest.json round-trip
guard.
"conversion": {
"input": { "records": 1000, "nonPassRecords": 12, "gvcfReferenceBlocks": 3,
"populationsRecognized": ["EE", "FI", "Total"] },
"discarded": { "recordsUnsupportedContig": 4, "recordsNoSupportedAlt": 5,
"recordsAllRowsWithheld": 3, "alleles": 6,
"ignoredInfoFields": ["EUR_AF", "SAS_AF"], "populationsWithoutAf": ["LV"] },
"suppressed": { "rowsBelowFloor": 7, "rowsCollapsedToTotal": 8, "variantsCollapsedToTotal": 9 },
"output": { "records": 900, "recordsEmitted": 988, "rows": 2700,
"populations": ["EE", "FI", "Total"] }
}The four string-array fields are input.populationsRecognized,
discarded.ignoredInfoFields, discarded.populationsWithoutAf and output.populations.
They are label lists; a consumer that types them as integers fails to parse a real package.
Every other field is a uint count.
input.gvcfReferenceBlocks is a subset of discarded.recordsNoSupportedAlt, not a drop
class of its own. The four records* counts under discarded are the whole-record drop
classes, and they close the record identity with output.recordsEmitted:
input.records == discarded.recordsUnsupportedContig
+ discarded.recordsNoSupportedAlt
+ discarded.recordsAllRowsWithheld
+ discarded.recordsNoAf
+ output.recordsEmitted
recordsAllRowsWithheld and recordsNoAf are the two ways a record can emit nothing. They
are separate counters because their remedies are opposite:
| Counter | Cause | What the provider does about it |
|---|---|---|
discarded.recordsAllRowsWithheld |
the k-anonymity floor withheld every row (including its coherence collapse) | change config.minAlleleCount, or accept the loss |
discarded.recordsNoAf |
no population had an allele frequency to emit — the input carried no usable AF |
fix the upstream export; the floor is not involved |
Keeping them apart matters because a record with no usable AF, such as a gnomAD AN = 0
site where AF, AF_XX and AF_XY are all omitted, is not a disclosure-control drop. A
provider who saw it counted as one would go looking at a floor setting that had done nothing.
A manifest written without recordsNoAf omits it (#[serde(default)], so it reads as 0)
and folds those records into recordsAllRowsWithheld. The identity still closes, with a
coarser attribution.
The closing term is output.recordsEmitted, not output.records. The latter counts distinct
(chr, POS, REF, ALT) alleles, which multi-allelic splitting inflates and shared loci
deflate, so it can never close against input.records. discarded.alleles counts ALT alleles
lost from records that were published on another ALT, so it is outside this identity too. A
manifest that omits recordsAllRowsWithheld or recordsEmitted (both #[serde(default)])
cannot close the identity.
The source VCFs are not shipped, so the neighbouring sha256 already describes a file no
consumer receives. The conversion block is the same trust class: an unverified provider
claim, never used for serving. The one exception is the dataset-wide metadata.populations,
which the node re-derives from the parquet and enforces. The whole files section is
stripped at ingest.
Sidecars are small JSON files dropped beside a package, in the same S3 prefix or inbox directory, to drive node behaviour out of band. They are not part of the encrypted archive.
A note on deleted, the tombstone state: it is an instruction, not a description. It
says "remove this dataset", and it appears in two directions — as a value an orchestrator
writes into {id}.state.json, and as the node's own {"state":"deleted"} status
writeback. Reading it as a report of something already gone, in either direction, inverts
its meaning.
Both sidecar bodies have a generated, freshness-guarded JSON Schema an orchestrator can
validate against, derived from the same models the node parses:
state-sidecar.schema.json and
metadata-overlay.schema.json.
{id}.state.json — visibility. Lenient parse (unknown keys, including a
schemaVersion, are tolerated):
{ "schemaVersion": 1, "state": "visible", "force": false }state(required):visible|hidden|deleted.force(optional, defaultfalse): bypasses thedeletedsafety guard.traceparent(optional): a W3Ctraceparentstring. When it is present and the node is configured to trust the sidecar source ([service].trust_sidecar_traceparent), the package'singest_jobspan is parented under it, so a producer can correlate its publish with the node's ingest across the HTTP-less S3 handoff. Absent, the node starts a fresh server-side root. That flag is separate from[service].trust_inbound_traceparent, which governstraceparentHTTP headers on the two listeners: writing this sidecar means holding bucket write credentials, while sending a header only means reaching the port, so trusting one must not require trusting the other.
The sampling flag travels with the traceparent. Trace-flags ending -00 mark it as not
sampled, so the node's ingest_job span for that package, and everything under it, is
dropped rather than exported. The node honours that upstream decision rather than overriding
it, which means the writer of the sidecar decides whether the ingest is observable. To get
the node's ingest trace, sample your own publish span (-01). Omit traceparent entirely
and the node roots its own trace, which is always exported.
deleted means different things per channel. On the local inbox channel it is a delete
command: it removes the dataset directory and status, and force bypasses the
visible-dataset guard. On an S3 bucket, deletion means removing the {id}.tar.c4gh object,
so a deleted .state.json there is not a delete verb. Rather than treat it as hidden
silently, the node surfaces it with a warn log and the
gdi_s3_deleted_sidecar_ignored_total{channel} metric, and keeps the dataset hidden, so an
orchestrator that reused the inbox verb on an S3 channel can tell its deletion did not take
effect. To delete on S3, remove the object, for example with gdi-dataset-tool delete, which
also bumps _sync_marker.json.
{id}.metadata.json is an operator metadata overlay patch. Serialized camelCase with
deny_unknown_fields; every field is optional, and a present field overwrites the baseline
while an absent one leaves it untouched. It may carry any metadata field except the
protected datasetId, catalog, numberOfRecords and populations. Setting one of those
fails to parse and rejects the entire patch, not just the offending field; the last two are
node-derived from the parquet at ingest and so are not operator-editable.
On reconcile the node merges the patch over the pristine manifest, re-validates it, and
writes a durable .metadata.overlay.json holding {applied_at, patch}, where applied_at
is a node-stamped YYYY-MM-DDTHH:MM:SS.mmmZ timestamp surfaced as dct:modified. Removing
the sidecar reverts to baseline.
The two sidecars above flow from orchestrator to node. The status writeback flows the other
way: on a bucket where [[s3.buckets]].write_status is enabled, the node writes its
per-dataset result to the reserved _status/{id}.json object, and the tool or an integrating
system reads it to detect drift without querying the node's management API. Its type contract
is the generated, freshness-guarded
status-writeback.schema.json:
{ "schemaVersion": 1, "id": "GDI-…", "state": "visible",
"source_signature": "\"9a0e…\"", "updated_at": "2026-07-10T20:08:00.000Z" }state(required):processing|visible|hidden|error|deleted.source_signature(present except ondeleted): the.tar.c4ghETagthis result is for. A reader compares it against the package's current ETag, per the signature contract below, to tell a fresh result from a stale one.error_message(present only onstate: error): a sanitized, closed-class string drawn from the fixed error-class vocabulary (see api.md), never a raw internal error.schemaVersion,idandupdated_atare provenance. Readers parse leniently, requiring onlystate, so an older or partial object still deserializes. The on-wire keys are mixed:schemaVersionis camelCase and the rest are snake_case, which the schema pins as-is.
On the S3 channel, the node detects whether a {id}.tar.c4gh has changed by its object
ETag, falling back to the object's last-modified timestamp when the endpoint reports no
ETag. That is the dataset's source signature: an unchanged signature means nothing to do, a
changed one means a new or changed package. Two consequences follow that a producer must
know.
- The ETag is upload-method dependent, not a content hash. A single-part
PUTyields an ETag that is the object's MD5, but a multipart upload yields a composite ETag,md5-of-part-md5s-N. The same bytes re-uploaded with a different part size, or by a different client, therefore present as a changed package. Re-upload a package the same way you first uploaded it if you intend no change. - A dataset is immutable once served. A live (
visibleorhidden) dataset is not re-ingested when its package is re-presented, whatever the ETag: the re-presented bytes are logged and ignored, and only a{id}.state.jsonvisibility flip takes effect. To replace a served dataset's payload, delete it and re-add it under a new id, since datasets are content-versioned by id rather than mutated in place. Anerrordataset is re-attempted when its signature changes, which is the intended retry lever; see operating.md §4.
An orchestrator that wants a stable, content-addressed notion of "changed" should track its own content hash of the plaintext it packaged, rather than relying on the transport ETag.
config.manifestVersionis the format version, currently1. The tool stamps it and the node rejects any other value at ingest. There is no tolerant path that reads an older or newer manifest.- The contract is therefore exact-match: a node accepts packages whose
manifestVersionequals the version it supports. A bump is a coordinated change. A producer must not emit a version a target node does not accept, and a node upgrade that changes the supported version is a breaking change recorded in the CHANGELOG. - Only
{id}.state.jsonis tolerant. It carries aschemaVersiondiscriminator, currently1, that the node ignores along with any other unknown key, so that sidecar can gain fields without breaking older nodes. The_status/{id}.jsonwriteback is read leniently in the other direction, requiring onlystate. {id}.metadata.jsonis strict. It accepts noschemaVersionkey and no unrecognized field, because that field list is the editable allow-list. Adding a field to the overlay is therefore a breaking change for strict consumers rather than an additive one, and is recorded in the CHANGELOG.