The HTTP contract for a running gdi-node-standalone node: what each endpoint is, which
listener serves it, and how it behaves. The companion docs are the
operator runbook and the provider guide.
The node serves two listeners, and the split is a security boundary. The public plane carries only visible-dataset data. The management plane exposes the hidden-dataset state oracle and operational metrics, so it must stay off the public Ingress. See operating.md §1 for health-probe detail and operating.md §21 for what is audited.
{prefix} below is the configured beacon mount path: [beacon].aggregated_base_path
(default /aggregated/beacon/v2) and [beacon].sensitive_base_path (default
/sensitive/beacon/v2). The two differ by default, so the node mounts an aggregated
beacon (g_variants + datasets) and a separate sensitive beacon (individuals). Set
them equal to mount one combined beacon serving all three entry types.
Served on [service].listen (default 0.0.0.0:8080).
| Method | Path | Purpose | CORS |
|---|---|---|---|
| GET | / |
Service directory: {"services": {"beacon": "<aggregated prefix>", "fairdp": "/fairdp"}} (fairdp only when [fairdp] is configured; the sensitive prefix is never listed) |
yes¹ |
| GET | {prefix} , {prefix}/info |
Beacon BeaconInfo (the root is the bare base path; the trailing-slash form is not a route) |
yes (aggregated/combined) / no (sensitive)¹ |
| GET | {prefix}/service-info |
GA4GH ServiceInfo |
yes (aggregated/combined) / no (sensitive)¹ |
| GET | {prefix}/configuration |
Beacon configuration, scoped to the mount | yes (aggregated/combined) / no (sensitive)¹ |
| GET | {prefix}/entry_types |
Entry-type definitions | yes (aggregated/combined) / no (sensitive)¹ |
| GET | {prefix}/map |
Beacon map | yes (aggregated/combined) / no (sensitive)¹ |
| GET | {prefix}/filtering_terms |
Filtering terms (currently empty) | yes (aggregated/combined) / no (sensitive)¹ |
| GET, POST | {prefix}/g_variants |
Genomic-variant query (aggregated/combined) | yes¹ |
| GET, POST | {prefix}/datasets |
Dataset query (aggregated/combined) | yes¹ |
| GET, POST | {prefix}/individuals |
Individuals placeholder (sensitive/combined) | yes (combined) / no (split)² |
| GET | /fairdp |
FAIR Data Point root (RDF) | yes |
| GET | /fairdp/ |
308 Permanent Redirect to the FDP root |
yes |
| GET | /fairdp/catalog/{id} |
FDP Catalog record | yes |
| GET | /fairdp/dataset/{id} |
FDP Dataset record | yes |
| GET | /fairdp/distribution/{id} |
FDP Distribution record | yes |
| GET | /.well-known/c4gh-recipient |
The node's crypt4gh recipient (PEM) | no³ |
¹ CORS (GET+POST, content-type) applies to the aggregated/combined beacon mount and the
FDP only. Every informational route above is mounted under both prefixes, so with the
default split prefixes the same path answers with CORS under aggregated_base_path and
without it under sensitive_base_path — hence the per-mount marker. g_variants and
datasets need no such marker because they exist only on the aggregated/combined mount,
as individuals exists only on the sensitive/combined one.
[service].cors_allowed_origins decides which origins it admits: empty, the
default, sends Access-Control-Allow-Origin: *; a non-empty allow-list of exact origins
(scheme://host[:port]) echoes the caller's Origin when it is on the list (with
Vary: Origin) and sends no Allow-Origin when it is not. The same policy governs every
public response, including the error responses synthesized outside the routed mounts (the
resilience 414/413/408/503/500 and the unmatched-path 404), so a restricted
node does not leak a readable error body to an origin it refuses to serve. CORS is a
browser control, not an access control: it does not restrain curl or a server-side fetch.
² With the default distinct prefixes, individuals is served from a separate sensitive
mount that carries no CORS. Setting the two prefixes equal collapses the mounts into one
combined beacon, and individuals then carries CORS. ³ /.well-known/c4gh-recipient is
fetched by the provider tool, not by a browser.
- FDP routes content-negotiate on
Acceptand serve two forms:text/turtle(or itsapplication/x-turtlealias) when offered, otherwiseapplication/ld+json. Every otherAccept— missing,*/*,application/rdf+xml,application/n-triples— gets JSON-LD with a200, never a406. There is no strict-negotiation opt-out. Every negotiated200carriesVary: accept— the redirect and thetext/plainerrors do not, having nothing negotiated to vary on — and the routes return404when[fairdp]is unconfigured. - Every public-plane response carries
Cache-Control: no-store. The beacon answers a disclosure-controlled query and the FDP record follows an operator overlay that can change between calls, so neither is safe for a shared cache to keep; a client that wants to avoid a re-query should hold the result itself rather than rely on an intermediary. /.well-known/c4gh-recipientreturns the recipient as a PEM body (Content-Type: text/plain; charset=utf-8) plus anX-C4GH-Recipient-Fingerprintresponse header, so a re-wrapping operator can confirm the key without diffing the PEM. A keyless node answers404. The fingerprint issha256:<hex>, the lowercase-hex SHA-256 of the raw 32-byte X25519 public key — the same valueidentity list,identity initandrekeyprint.- A trailing slash is not a route, with one exception.
{prefix}/,/fairdp/dataset/{id}/,/fairdp/profile/service/and every other slash-suffixed path answer404in their surface's dialect./fairdp/instead answers308 Permanent RedirectwithLocation: /fairdp, so a harvester configured with the slashed source URL does not read the FDP's404as an empty FAIR Data Point and complete a green harvest with zero datasets. The redirect is present whether or not[fairdp]is configured, so it cannot be used to probe for the block. /fairdp/profile/{service|catalog}is unrouted (404): thedct:conformsToprofile markers are opaque and not served. Like every other unknown path under/fairdpit answers in the FDP's own dialect, a baretext/plainnot found, not the Beacon envelope (see Error responses).- Resilience envelope (public plane): a request target (path + query) over 8 KiB →
414; a body over[service].max_request_body_bytes→413; a request over[service].request_timeout_seconds→408; load-shed past[service].max_concurrent_requests→503; a handler panic →500. Each is counted ongdi_http_requests_rejected_total{reason}and rendered in the dialect of the surface the request was for (see Error responses).413can only arise on a beaconPOST, since the FDP isGET-only. - The management plane carries the same timeout, load-shed and panic-catch, so a client of
the state oracle must expect
408,503and500there too. They are rendered as bare tower statuses rather than asbeaconErrorResponse./health/liveand/health/readyare exempt from admission control alone: they skip the semaphore and the load-shed, so a saturated plane cannot make the kubelet evict a node that is serving correctly, but they keep the timeout and the panic-catch. - Every public-plane response carries a server-generated
x-request-id, and any inbound value is ignored. It also appears in the logs and in every JSON error body, so a client can quote it for correlation. On the responses that carry CORS it is listed inAccess-Control-Expose-Headers, so a cross-origin browser client can read it. - The management plane's traced routes carry one too, and that plane honours an inbound
x-request-idand echoes it back, so an orchestrator can follow one request across the boundary. An inbound value is adopted only if it is 1–128 bytes of visible ASCII; anything else is replaced by a server-minted id rather than rejected. The planes differ because the public plane is internet-facing, where a caller-chosen correlation id is worse than none, while the management listener binds loopback by default./health/live,/health/readyand/metricssit outside that layer and carry no request id in either direction. - Every public-plane response carries
X-Content-Type-Options: nosniff, which prevents MIME-sniffing of the JSON, RDF and PEM bodies. The management plane does not.
{prefix}/g_variants, {prefix}/datasets, and {prefix}/individuals accept the Beacon v2
query framework either as GET query-string parameters or as a POST JSON body
(query.requestParameters plus the query.filters, query.pagination,
query.requestedGranularity and query.includeResultsetResponses siblings).
Variant parameters can sit directly in query.requestParameters or, as the GA4GH model has
them, under query.requestParameters.g_variant. Both are read the same way; if a key is in
both, g_variant wins. A g_variant that is not an object is a 400 on g_variants.
The envelope fields live under query. Seven keys are read from query.*, the canonical
location: includeResultsetResponses, requestedGranularity, testMode, pagination,
filters, requestedSchemas and datasetIds. The same key nested inside
query.requestParameters is also honoured, and the query.* sibling wins when both carry
it. A key placed at the top level, as a sibling of query rather than a member of it,
is not the Beacon v2 location and is silently ignored, so a client asking for ALL or
MISS there gets the HIT default's hits-only shape. filters is the one exception: a
top-level filters is lifted so it can be rejected with the same 400 a canonical one
gets, because an ignored filter would let a client believe the result set was narrowed when
it was not.
The field semantics are the GA4GH Beacon v2 contract — consult the Beacon v2 spec (v2.2.0, the version this node pins) (reference docs), not this doc. What follows is where this node constrains or deviates from it.
-
Variant selectors:
referenceName,assemblyId,start,end,referenceBases,alternateBases,variantType,variantMinLength,variantMaxLength. Once any variant selector is present,referenceNameis required.assemblyIdis normalized to one canonical assembly (GRCh38/hg38/b38, and a GRC patch suffix such asGRCh38.p13folds to its stem, since a patch shares the primary assembly's coordinates), and areferenceNameaccession that contradicts it is400. The normalization does not cover versioned RefSeq assembly accessions such asGCF_000001405.39; those are400, so use the name forms. -
assemblyIdselects among the assemblies the node serves. A dataset is only ever searched under its own assembly, which/datasetsreports asgdiDatasetInfo.assembly, so a cross-assembly false hit is impossible. What the request'sassemblyIdselects:Request Response omits assemblyId, one assembly among the visible datasets200, searched under that assembly; the assumed value is echoed asmeta.receivedRequestSummary.assumedAssemblyIdomits assemblyId, several served400—assemblyId is ambiguous: this node serves several assemblies (…); name one in requestParameters.assemblyIdnames a recognised assembly ( GRCh37/GRCh38after synonym folding) that no visible dataset holds200withexists: falseand noresultSets— not a claim that any dataset was consultednames a value the normalization cannot resolve 400unknown assemblyIdthe node holds no visible dataset at all 200,exists: false, whateverassemblyIdsaysassumedAssemblyIdis a node extension key, because Beacon v2 has no field for a server-filled value and the standardrequestParametersecho types every entry as an object. It appears only when the node assumed the assembly. -
Base characters.
referenceBasesandalternateBasesare validated toACGTNonly, a narrowing of the GA4GH pattern^([ACGTUNRYSWKMBDHV\-\.]*)$: an aggregated allele-frequency beacon stores concreteACGTNalleles and cannot resolve an ambiguity code, so a non-ACGTNbase is a400.Nis accepted but matched literally, soreferenceBases: "N"equals a storedNand nothing else. Query deletions and insertions withvariantType(DEL/INS/INDEL), not with the empty-alternateBasesform. Coordinates are non-negative and bounded to2^31 − 1. -
The query span is capped. A Range or Bracket query whose span exceeds
[beacon].max_query_span_bp(default10000000) is a400(query span exceeds the limit), not a truncated200. A bracket's span is measured across its full reachable extent (s_mintoe_max), not the width of either bracket, so two narrow brackets far apart are rejected on the distance between them. Sequence queries are exempt. The cap is a per-deployment availability control (0disables it) and is advertised on no endpoint, so split whole-chromosome scans into chunks rather than assuming a particular limit across a federation. -
variantTypeis a small, allele-derived vocabulary. The node classifies every stored variant from itsREF/ALTalleles, after trimming the shared prefix and suffix, into exactly one ofSNP(single-base substitution),MNP(equal-length multi-base substitution),INS(pure insertion),DEL(pure deletion) orDELINS(a complex, length-changing substitution, HGVS delins). It does not read a VCFSVTYPEand never stores structural types (DUP/CNV/INV/BND), which are dropped at ingest. A filter is normalized case-insensitively and accepts the synonymsSNV→SNP,MNV→MNP,INSERTION→INS,DELETION→DEL,MIXED/COMPLEX→DELINS.INDELmatches all three length-changing types (INS,DEL,DELINS), so a client using the GA4GH reference vocabulary still gets the expected result. Any other value is a400rather than a misleadingexists: false. -
variantMinLength/variantMaxLengthbound the alternate-allele length. The node filters onlen(alternateBases), so an SNV has length 1 andvariantMinLength=1includes it. This matches the GDI reference beacon, so a length-filtered query returns the same variant set across a federation. The GA4GH v2.2 schema marks both parameters "without prescribed use". -
requestedGranularity∈boolean | count | record; when omitted it falls back to[beacon.configuration].default_granularity. An unknown value is400. Aggregate counts below[beacon].min_allele_countare suppressed, so acountorrecordresponse may omit low-frequency entries; when the floor suppresses any population of a variant the response collapses to the aggregateTotalonly, so a suppressed cell cannot be recovered by subtracting its siblings. Thedatasetslisting is public registry metadata with no disclosure ceiling to lower, so it always servesrecord(returnedGranularity: record) while echoing the client'srequestedGranularityinreceivedRequestSummary. -
includeResultsetResponses∈ALL | HIT | MISS | NONE; omitted →HIT. Every dataset consulted for the query becomes a resultSet, either a hit (exists: true) or a per-dataset miss (exists: false, emptyresults).HITreturns only the hits,MISSonly the misses,ALLboth,NONEan emptyresultSets.responseSummaryreports the true aggregate regardless of the filter. An out-of-enum value is400. The applied value is echoed inmeta.receivedRequestSummary.includeResultsetResponses, so the hit-only default is visible rather than inferred. -
Pagination.
skipdefaults to0;limitdefaults to[beacon].default_page_limit(default10) and is clamped to[beacon].max_page_limit(default1000);limit: 0, Beacon's "unbounded" sentinel, is likewise clamped to the cap. The clamp is silent on the status line but not on the wire:meta.receivedRequestSummary.paginationcarries the effectiveskipandlimit. An operator may lower the cap — it is the query path's memory knob, see deployment.md — so read the echo rather than assuming1000across a federation.skipandlimitapply per dataset, not across the response. Each selected dataset becomes its ownresultSetwith its own[skip, skip+limit)window, and the response concatenates them, so a query matching N visible datasets returns up toN × limitentries whilemeta.receivedRequestSummary.pagination.limitechoes the per-dataset value. Size buffers and paging loops fromN × limit, and scope a query to one dataset withdatasetIdsif you need a single bounded window. Beacon v2 does not define paging acrossresultSets; this is the GDI nesting's reading of it. -
filtersare rejected, not applied. This beacon advertises no filtering terms (/filtering_termsis empty), so a non-emptyfiltersselector ong_variantsordatasetsis a400(filters are not supported…) rather than a silently-unfiltered200the caller believes was narrowed. An empty or absentfiltersis a no-op. Theindividualsplaceholder accepts a structuralfilterslist as a no-op and does not echo it back: the vendored summary schema typesfiltersitems as strings while clients send objects, and the field is optional, so omitting it is conformant and matchesg_variantsanddatasets, which never echo one either. -
testModeis accepted and is a no-op: the query is answered normally and the submitted flag is echoed inmeta.receivedRequestSummary.testModeon every entry type. A non-booleantestModeis a400. Everything this node serves is public aggregate allele frequency data, which satisfies the vendored$defs/TestModerequirement that the instance "SHOULD use virtual or non-sensitive data". The echo is of the received flag; the node does not emit the optional top-levelmeta.testMode. -
Unsupported query types →
400.geneId(Gene ID Query),aminoacidChangeandgenomicAlleleShortForm(Genomic Allele Query), andmateName(breakend) are valid Beacon v2 query types that this position-indexed beacon does not implement, so they are a400rather than a misleadingexists: false, on bothg_variantsanddatasets. -
Bodies and content type. On the POST surface a missing or wrong
Content-Typeis415. There is no422: a semantically-invalid query is a400beaconErrorResponse. A misplaced envelope — a bare top-levelrequestParameterswith noquerywrapper, or a non-objectquery,requestParametersorg_variant— is a400ong_variantsrather than a misleading empty-query200; a genuinely empty query ({},{"query":{}}) is a200.datasetsandindividualsdo not apply that check and read a bare top-levelrequestParametersleniently. A successful query is200, including an all-empty result; every error is abeaconErrorResponse. -
Leniency. Unknown or extra top-level and
requestParametersfields are ignored, for forward compatibility; an accession-formreferenceName(NC_000003.12) is accepted and matched, where the GA4GH reference implementation rejects it; andrequestedSchemaon the informational endpoints is accepted and ignored, since the node serves its single default schema. -
datasetIdsscopes the query. AdatasetIdsarray, placed underqueryor insiderequestParameters(a comma-separated string on the GET surface), restricts theg_variantsscan to the named datasets. An absent list scans every visible dataset. A requested id that is not currently visible, hidden or unknown, contributes no resultSet, so a dataset-scoped existence query never returns a hit from an out-of-scope dataset.Absent and submitted-but-empty differ. A
datasetIdsthat is present but resolves to no usable id —[],[null],[7],"",","— scopes the query to nothing and returns no resultSets; it does not fall back to every visible dataset. Collapsing the two would re-create the out-of-scope hit this field exists to prevent, invisibly atbooleanandcountgranularity where only the OR-edexistsreaches the client.
A boolean query is 200; an empty or no-match query returns exists: false. An empty
query carries no variant selectors, so no assembly is resolved for it. The wire envelope
conforms to the GA4GH beaconBooleanResponse schema, and beaconId and returnedSchemas
reflect the node's [beacon].id and pinned model:
curl '{base}/aggregated/beacon/v2/g_variants?requestedGranularity=boolean'{
"meta": {
"beaconId": "org.test.beacon",
"apiVersion": "v2.2.0",
"returnedGranularity": "boolean",
"returnedSchemas": [
{
"entityType": "genomicVariant",
"schema": "https://raw.githubusercontent.com/ga4gh-beacon/beacon-v2/v2.2.0/models/json/beacon-v2-default-model/genomicVariations/defaultSchema.json"
}
],
"receivedRequestSummary": {
"apiVersion": "v2.2.0",
"requestedGranularity": "boolean",
"testMode": false,
"includeResultsetResponses": "HIT",
"requestedSchemas": [],
"pagination": { "skip": 0, "limit": 10 }
}
},
"responseSummary": { "exists": false }
}A record-granularity hit additionally returns response.resultSets[].results[] with
frequencyInPopulations (allele counts and frequencies) — see the COVID query in the
README's Quickstart 1.
Each frequencyInPopulations[].frequencies[] entry (crates/beacon/src/model.rs
Frequency) carries one population's counts. GoE below is Genome of Europe, the
allele-frequency network this node's extension fields are shared with:
| Field | GA4GH / GDI | Source column | Presence |
|---|---|---|---|
population |
GA4GH-standard, required | POPULATION |
always |
alleleFrequency |
GA4GH-standard, required | AF |
always |
alleleCount |
GDI/GoE extension |
AC |
omitted when the source VCF carried no AC for the population |
alleleNumber |
GDI/GoE extension |
AN |
omitted when the source VCF carried no AN for the population |
alleleCountHomozygous |
GDI/GoE extension |
AC_Hom INFO → parquet AC_HOM |
omitted, never 0, when that population's AC_Hom was absent from the source VCF, independently of AC_Het/AC_Hemi |
alleleCountHeterozygous |
GDI/GoE extension |
AC_Het INFO → parquet AC_HET |
as above, for AC_Het |
alleleCountHemizygous |
GDI/GoE extension |
AC_Hemi INFO → parquet AC_HEMI |
as above, for AC_Hemi |
Only population and alleleFrequency are part of the Beacon v2 default model; the five
count fields are the GDI/GoE network's permitted, non-portable extension. Every count
field is optional and an absent source value omits the JSON key rather than emitting 0,
because 0 is itself a meaningful count — a population that exists but has no homozygous
carriers. The three genotype sub-counts
(alleleCountHomozygous/Heterozygous/Hemizygous) are withheld together, even when
present in the source, whenever serving one of them below [beacon].min_allele_count would
let a client recover a suppressed cell by subtracting sibling populations from Total;
alleleCount, alleleNumber and alleleFrequency are unaffected. Populations that
predate the AC_Hom/AC_Het/AC_Hemi VCF INFO fields carry none of the three keys, which
is a genuinely absent source column rather than a suppression.
Each results[] entry is a Beacon v2.2.0 genomic variation record, the entity
meta.returnedSchemas[].schema names, carrying variantInternalId, variation,
identifiers and frequencyInPopulations. Its variation is a LegacyVariation:
referenceBases, alternateBases, variantType, and a location encoded per
GA4GH VRS 1.3, which the default model
$refs. The wire values below come from the COVID sample dataset:
"variation": {
"location": {
"type": "SequenceLocation",
"sequence_id": "refseq:NC_000003.12",
"interval": {
"type": "SequenceInterval",
"start": { "type": "Number", "value": 45823239 },
"end": { "type": "Number", "value": 45823240 }
}
},
"referenceBases": "T",
"alternateBases": "C",
"variantType": "SNP"
}- Every VRS class carries its
type.SequenceLocation,SequenceIntervalandNumbereach declaretypein theirrequiredset withadditionalProperties: false, so an untyped location or interval matches no VRS class. - Coordinates are objects, not bare integers.
interval.startand.endareoneOf [DefiniteRange, IndefiniteRange, Number], all objects, so read the coordinate frominterval.start.value. They are 0-based half-open (start= VCFPOS - 1,end=start + len(referenceBases)), so the example above is the single base at 1-based position 45 823 240. sequence_idis a CURIE,refseq:{accession}for the query's assembly and chromosome, and stayssnake_casebecause it is a VRS key rather than a Beacon one. It is always present: ingest admits only GRCh37 and GRCh38 and only canonical contigs, and a dataset whose store disagrees with its manifest is served as a miss with anerrorlog rather than as a location without its reference sequence.identifiers.genomicHGVSIdis rendered from the same accession, so it is present on every served entry too.
Every query-semantic rejection on a beacon endpoint, and every resilience error
(414/413/408/503/500) on a request under a beacon prefix, uses the Beacon
beaconErrorResponse envelope: the same meta shape a success carries, plus an error
object whose errorCode is the originating HTTP status (meta abbreviated here):
{
"meta": { "beaconId": "…", "apiVersion": "v2.2.0", "returnedGranularity": "…", "returnedSchemas": ["…"], "receivedRequestSummary": { "…": "…" } },
"error": { "errorCode": 400, "errorMessage": "unsupported parameter: geneId" },
"requestId": "018f…"
}The top-level requestId mirrors the x-request-id response header.
Three caveats on the envelope:
-
405is the exception. A wrong method on a real route returns a bare status with an empty body and anAllowheader, with no envelope and noerrorblock. A conformant client cannot reach it on a valid operation, but a client that always parses the envelope body should special-case it. -
An unmatched path (
404), and every resilience error the layers synthesize (414/408/503/500), answers in the dialect of the surface the request was for, so a mistyped path is told what it missed rather than handed a Beacon envelope for a FAIR Data Point typo:- under a beacon prefix (
{prefix}/nope,{prefix}/g_variants/extra; and, for the resilience errors, the bare prefix itself — which is a route, not a404:GET {prefix}servesBeaconInfo, as the route table above says): thebeaconErrorResponseenvelope above, witherrorMessageno Beacon endpoint matches this pathfor the404, andrequest target exceeds the maximum length/request timed out/service overloaded/internal errorfor the resilience errors. The two differ inreturnedSchemas: a resilience error names that mount's primary entry type (genomicVarianton the aggregated or combined mount,individualon the sensitive mount), while the404carries an empty list, because a path that matched no route was interpreted as no entity. The field is still present and still conformant (ListOfSchemassets nominItems), so a client must not indexreturnedSchemas[0]on a404; - under
/fairdp: the FDP's baretext/plain—not found, or the same resilience messages — which is what an unknown catalog or a hidden dataset gets; - anywhere else (
/nope,/robots.txt,/fairdb, a management path on the public listener): a neutral JSON body,{"status": 404, "message": "no route matches this path", "services": {…}, "requestId": "…"}for the404, whoseservicesis the same directoryGET /serves, and{"status": <code>, "message": "…", "requestId": "…"}for a resilience error. It stays JSON sorequestIdcan be injected, and it carries the public CORS policy.
The surface is decided from the request path, matching at a segment boundary, so
/fairdpxis not the FDP. A handler's own error response is never re-rendered, which is why a routed path never yields the neutral body even when it answers404:/.well-known/c4gh-recipienton a keyless node is the case to know, answering its own404astext/plain—no crypt4gh recipient configured— so a client must not assume a JSON body there. Its trailing-slash form/.well-known/c4gh-recipient/matches no route and does get the neutral JSON. Only the last two dialects are not the envelope, so a client that parses every error body as abeaconErrorResponsemust key on the path it requested. - under a beacon prefix (
-
The error
metanames the endpoint's entry type but not its granularity.returnedSchemasnames the schema of the entry type you called (genomicVariant/dataset/individual), except on an unmatched-path404where it is empty.returnedGranularityis always therecorddefault on an error, since nothing was returned. Correlate an error byerror.errorCodeandrequestIdplus the endpoint, not bymeta.returnedGranularity.
The /fairdp* resources serve RDF (DCAT-AP + FDP-O), content-negotiated to Turtle or
JSON-LD. /fairdp is the FDP root, an fdp-o:FAIRDataPoint that ldp:contains each
configured catalog; /fairdp/catalog/{id}, /fairdp/dataset/{id} and
/fairdp/distribution/{id} are the dcat:Catalog, dcat:Dataset and dcat:Distribution
records beneath it. Only visible datasets appear. The root document is illustrative —
blank-node ids and triple order vary, so it is not byte-pinned; the shape is validated
against the gdi-metadata SHACL shapes by the harness in
conformance/:
@prefix dct: <http://purl.org/dc/terms/> .
@prefix dcat: <http://www.w3.org/ns/dcat#> .
@prefix fdp-o: <https://w3id.org/fdp/fdp-o#> .
@prefix ldp: <http://www.w3.org/ns/ldp#> .
@prefix foaf: <http://xmlns.com/foaf/0.1/> .
@prefix vcard: <http://www.w3.org/2006/vcard/ns#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
<https://node.example.org/fairdp>
a fdp-o:FAIRDataPoint , fdp-o:MetadataService , dcat:DataService ;
dct:title "GDI Estonia FAIR Data Point" ;
dct:license <http://publications.europa.eu/resource/authority/licence/CC_BY_4_0> ;
dct:language <http://publications.europa.eu/resource/authority/language/ENG> ;
dct:conformsTo <https://node.example.org/fairdp/profile/service> ;
fdp-o:conformsToFdpSpec <https://specs.fairdatapoint.org/v1.2/fdp-specs-v1.2.html> ;
fdp-o:metadataIdentifier <https://node.example.org/fairdp> ;
fdp-o:metadataIssued "2026-01-01T00:00:00Z"^^xsd:dateTime ;
fdp-o:metadataModified "2026-01-01T00:00:00Z"^^xsd:dateTime ;
dcat:endpointURL <https://node.example.org/fairdp> ;
dct:publisher [ a foaf:Agent ; foaf:name "Example Organisation" ; foaf:homepage <https://example.org> ;
foaf:mbox <mailto:gdi@example.org> ;
dcat:contactPoint [ a vcard:Kind ; vcard:fn "GDI Estonia" ; vcard:hasEmail <mailto:gdi@example.org> ] ] ;
dcat:keyword "genomics", "allele frequencies" ;
dcat:contactPoint [ a vcard:Kind ; vcard:fn "GDI Estonia" ; vcard:hasEmail <mailto:gdi@example.org> ] ;
ldp:contains <https://node.example.org/fairdp/catalog/gdi-aggregated> ;
fdp-o:metadataCatalog <https://node.example.org/fairdp/catalog/gdi-aggregated> .
<http://publications.europa.eu/resource/authority/licence/CC_BY_4_0> a dct:LicenseDocument .
<http://publications.europa.eu/resource/authority/language/ENG> a dct:LinguisticSystem .dct:conformsTo points at /fairdp/profile/service, which is unrouted (404): the
profile marker is opaque, not a served document.
dct:language comes from [fairdp].language, a list of EU language-authority IRIs that
defaults to English. It appears on the root, every catalog and every dataset; there is no
per-dataset language.
HealthDCAT-AP asks for more, and every node publishes it: every record gives its
licence, access-rights, language, legislation and legal-basis values an rdf:type (the last
two lines above), and every dataset record carries healthdcatap:hasStructuredData and the
variables table. These are built in
(fairdp-publish.toml);
[fairdp.publish] in node.example.toml changes them.
Served on [service].management_addr (default 127.0.0.1:9090). This plane carries the
hidden-dataset state oracle and operational metrics, so keep it off the public Ingress:
in-cluster binding plus a NetworkPolicy, or a host firewall on bare metal.
| Method | Path | Purpose |
|---|---|---|
| GET | /health/live |
Liveness (always 200) |
| GET | /health/ready |
Readiness and per-subsystem detail (200 / 503) |
| GET | /version |
Build version (JSON) |
| GET | /catalogs |
The configured catalogs (id + title) as plain JSON — the ids a package's metadata.catalog must name |
| GET | /datasets/{id}/state |
One dataset's serving state, for any id including hidden and error |
| GET | /datasets |
The whole dataset inventory — opt-in, present only with [service].expose_dataset_list = true, else 404 |
| GET | /datasets/suppressed |
The datasets an operator is currently withholding — same flag and same disclosure class, else 404 |
| GET | /stats/queries |
Per-dataset usage counters since boot — opt-in, present only with [stats].enabled = true, else 404 |
| POST | /reload |
Re-read the config file, as SIGHUP does — opt-in, present only with [control].enabled = true, else 404 |
| POST | /reconcile |
Run the SIGUSR1 pass now — same opt-in flag; 202 |
| POST | /log-level |
Flip diagnostic logging like SIGUSR2, with an auto-revert window — same opt-in flag |
| POST | /datasets/{id}/reingest |
Clear one dataset's recorded source signature and run the reconcile pass now — same opt-in flag and same pacing clock as /reconcile |
| GET | /metrics |
Prometheus exposition (present only when a recorder installed) |
The management plane carries no CORS and no beacon error envelope; a handler panic is a
bare 500. High-frequency health probes are not traced.
Where per-dataset facts live. Anything keyed by dataset id — the state oracle, the
inventory, the usage counters — is a management-plane surface and never a /metrics label.
Metric labels stay bounded and content-free, because a scrape target is cheap to grant and
a dataset-id label would carry an enumeration of every id, hidden ones included, wherever
those series are shipped. Per-dataset counts therefore live on the flag-gated
/stats/queries route.
The management endpoints return small, fixed JSON documents rather than Beacon envelopes.
Error statuses are the exception: GET /datasets/{id}/state returns a bare text/plain
body on 400 and an empty body on 404, and a handler panic is a bare 500, so a
programmatic consumer should check the status code before parsing the body as JSON.
GET /version — build and version info, always 200 (values illustrative):
{
"service_version": "1.0.0-rc.3",
"gdi_metadata_version": "<pinned gdi-metadata model version>",
"git_sha": "<git commit, 12 hex chars; 'unknown' if no repository was reachable>",
"build_epoch": "<Unix seconds; 'unknown' if no repository was reachable>"
}Both provenance fields are strings, unknown included: build_epoch is not a number. See
operating.md §18 for how each is
resolved.
GET /catalogs — the configured catalogs, always 200. Each [catalogs] id and title
as plain JSON, sorted by id, read from the same SIGHUP-reloadable snapshot as the FDP root,
so the two cannot disagree. This is the surface an integrating system reads to learn which
catalog ids this node accepts at ingest; harvesters keep reading the FDP root. The body is
catalogs.schema.json:
{
"catalogs": [
{"id": "gdi-aggregated", "title": "Genome of Europe Aggregated Data"}
]
}GET /health/ready — 200 when ready, else 503. draining appears only while
shutting down; each subsystem is ok, not-configured or unavailable
(not-configured is never a failure) and initial_reconcile is done or pending. Shown
for a keyless, no-S3, no-Vault node:
drainingis not pollable: do not build a probe or dashboard on it. A shutdown stops the listener accepting new connections immediately, so a fresh connection during the drain is refused rather than answered503 {"draining": true}. Only a connection opened before theSIGTERMcan observe the field, and only for the few milliseconds before it closes. For the same ordering reasongdi_ingest_total{outcome="cancelled"}is unscrapeable: the ingest drain runs after the management listener stops. Treat both as post-mortem log evidence, not as live signals.
{
"ready": true,
"degraded": false,
"subsystems": {
"s3": "not-configured",
"vault": "not-configured",
"at_rest": "not-configured",
"key_material": "ok",
"initial_reconcile": "done"
}
}at_rest is the PME master key: not-configured unless [vault].transit_key is set, ok
when the key unwrapped this node's sentinel, and one of two distinct failures:
| value | meaning | what to do |
|---|---|---|
mismatch |
the configured Transit key can no longer decrypt data this node wrote (a replaced key, or a reset secrets backend) | recover the key (operating.md §17). pme reseal refuses: the mismatch is real |
unverifiable |
the check could not be completed from local state — the sentinel is unreadable, unparseable, or names a scheme this build does not know | inspect the sentinel, then pme reseal --yes; if the scheme is unknown, deploy the newer binary |
Both gate ready, and they are reported separately because the operator's next step
differs. at_rest is separate from vault because in that state Vault is reachable and
answering, so reporting it under vault would misdirect triage at connectivity. Unlike a
dark provider bucket it does gate ready, because a node whose at-rest store is unreadable
would answer queries while every PME-backed read fails.
degraded is true whenever any configured subsystem reads unavailable, including the
per-bucket S3 health that does not gate ready. So ready: true, degraded: true means
"serving, but at least one provider bucket is dark, so the served view is incomplete". It
is always present, and it is not the complement of ready.
When [[s3.buckets]] is configured, subsystems also carries an s3_buckets object with
per-channel health (ok / unavailable), and subsystems.s3 rolls them
up. S3 bucket health does not gate readiness: a degraded provider bucket reports
unavailable but the node stays ready, since it must not drop every other provider from
rotation. Alert on gdi_s3_poll_errors_total or gdi_health_ready{component="s3"}, not on
component="overall".
The body also carries a top-level s3_markers object, present once at least one bucket has
reconciled: a per-channel { observed_marker, last_reconcile_at } naming the
_sync_marker.json change-token the node last reconciled on, and when. This is the
read-side handoff ack. An orchestrator that writes a package, bumps _sync_marker.json and
HEADs the marker to learn its new ETag can poll s3_markers[channel].observed_marker
until it equals that ETag, or wait for last_reconcile_at to advance past its write,
instead of using a blind timeout.
GET /datasets — the node's whole dataset inventory as a JSON array, 200. Present
only when [service].expose_dataset_list = true; otherwise the route does not exist and
the plane answers 404, so a consumer should treat it as best-effort and fall back to
per-id state polls. Each element is
{id, state, channel, provenance, suppressed?, source_state?}: the same objects
dataset list --format json prints, from the same collector. Unsuppressed rows omit
suppressed rather than emitting null. The route takes no query parameters and refuses
any with 400, so a mis-spelled question cannot read as an authoritative answer.
state is the effective serving state: the source-declared state with any operator
override composed over it, and likewise the offboarding withhold for an orphaned channel
(one the status index still owns datasets for but the running configuration does not
declare; a bucket a config reload added counts as declared from the moment the reload
lands). It is the same answer GET /datasets/{id}/state gives, and the same rule the
node's own serving path applies. When an override has masked a different declared state,
source_state carries what the index recorded, so an error dataset that is also withheld
reports state: hidden, source_state: error; when nothing was masked the key is omitted.
The operator's suppression justification is not on this route, nor on
dataset list --format json, for the reason given under GET /datasets/{id}/state below.
A consumer needs that a dataset is withheld and in which mode.
The route reads the on-disk status index, as the CLI does, so a source-declared
visible↔hidden flip may lag by at most one reconcile. An operator override is composed
in on top of that index value, because the node never writes a withhold back to the index;
the override file is the durable record.
An override is applied from the node's in-memory set, not read from the store per request.
That set refreshes on reload (SIGUSR1) and on the periodic override reconcile
(rescan_interval_seconds, 600 s by default), so a dataset hide or take-down written
to the store can take that long to appear here. The same in-memory set gates what the
Beacon serves, so reading the store per request here alone would make this route report
hidden for a dataset the node is still answering. If you need an override applied now,
apply it with SIGUSR1; to read what the store holds independently of node state, use
dataset list.
GET /datasets/suppressed — the datasets an operator is currently withholding: same
array shape, same [service].expose_dataset_list flag, same audit event, 400 on any
query parameter. The plain listing structurally cannot answer this, because a take-down
erases the local copy and purges the status entry, so the dataset survives only as an
override; those rows are synthesized only for this route, so a completed erasure cannot
leak into the plain listing as merely "hidden". An orphaned channel's datasets, withheld by
configuration rather than by an override, are listed here too, with source_state set and
no suppressed key. A read failure is a 500 with a bare text/plain body, since the
underlying error can name a filesystem path this plane does not put on the wire.
GET /stats/queries — per-dataset usage counters, 200. Present only when
[stats].enabled = true; otherwise the route does not exist and the plane answers 404,
so a consumer should treat it as a capability to probe. The body is
query-stats.schema.json:
{
"schemaVersion": 1,
"startedAt": "2026-08-07T09:00:00Z",
"asOf": "2026-08-07T12:34:56Z",
"datasets": {
"GDI-EE-EXAMPLE-1751234567890": {"consulted": 152, "hit": 37, "listed": 12, "fairdpReads": 9}
}
}The four counters are in memory and since process start, monotonic within a boot:
| Counter | Incremented when |
|---|---|
consulted |
the dataset was in the selection set of an answered g_variants query, at any granularity |
hit |
that same query matched this dataset (exists for this dataset, not the OR-ed answer) |
listed |
the dataset appeared in the served page of a Beacon /datasets response |
fairdpReads |
/fairdp/dataset/{id} or /fairdp/distribution/{id} was read; a catalog read is catalog-keyed and is not attributed to its datasets |
Not counted: a rejected query, which is rejected before dataset resolution and so carries
no dataset attribution (see beacon_query_rejected in the audit stream instead), the
/individuals placeholder, and the info, configuration, map and service-info routes.
startedAt is the boot identity, and it is what makes the endpoint pollable: counters
restart from zero when the process does, so a consumer differencing two snapshots must
compare startedAt first. Unchanged means current − previous is the traffic between the
polls; changed means the node restarted and the current values are the whole story. It is
process start, never config-reload time, so a SIGHUP leaves it alone. Entries for erased
datasets persist until restart, and hidden datasets appear here, the same disclosure class
as the state oracle on the same private plane.
POST /reload — re-read the config file. Present only when [control].enabled = true;
otherwise the route does not exist (404). Bodyless, so it cannot inject configuration, it
only tells the node to re-read its own already-trusted file, and idempotent, so it is safe
to retry.
It runs the same implementation SIGHUP runs: the reloadable subset ([catalogs] plus the
[ingest] writer allow-list), any added [[s3.buckets]] entry or changed bucket
credentials, and the PME DEK-cache flush. A change outside that subset is logged as
restart-only and not applied, exactly as with the signal.
The flush matters during an at-rest key revocation: it drops every cached plaintext DEK,
bounding revocation latency to the request rather than to the cache TTL, and it runs
whether or not the candidate config was accepted. Without pods/exec it is the only
non-disruptive way to get it — see operating.md §10 step 5.
What the endpoint adds over the signal is an answer:
{"applied": true, "restart_required": false, "detail": "…"}{"applied": false, "reason": "invalid", "detail": "…"}Read restart_required, not applied. applied: true only means the reloadable
subset was swapped in; the same file may also have changed something outside it. That is
the common case: every opt-in flag decides route mounting at boot, and every endpoint,
bucket or prefix edit re-points a keyspace, so all of them are restart-only.
restart_required is absent on a refusal, where nothing was applied at all.
reason is a closed set of two: unparsable (the file could not be read or parsed as
TOML) and invalid (it parsed but failed the same startup validation boot runs). Both are
200, because a refusal is an answer, the node is healthy, and the running config is
untouched. The underlying error is in the node's log rather than in the body, since it can
name filesystem paths this plane does not put on the wire.
Two other statuses: 429 with Retry-After inside the [control].min_interval_seconds
window (default 10; a refused request does not restart the window), and 503 during the
boot window before the action is installed.
POST /reconcile — run the SIGUSR1 pass now: reload and enforce the operator
suppression store and the node-local overlay overrides, drain queued dataset reingest
markers, rescan the inbox, and wake every bucket monitor. Same opt-in flag and same rate
limit as /reload; bodyless.
It answers 202 Accepted, not 200:
{"started": true, "detail": "…"}The pass is unbounded work — a full inbox rescan on a large node — so the response means
started, not finished. Poll GET /datasets/{id}/state for the effect, exactly as after a
SIGUSR1. This matters most for a withhold: dataset hide and take-down write a
suppression file, and nothing applies it until the next reconcile.
It cannot accelerate a deletion. A suspected mass removal is confirmed across polls
separated by at least one marker_poll_interval before anything is evicted, so calling
this endpoint in a burst neither confirms nor speeds up an eviction.
POST /datasets/{id}/reingest — clear one dataset's recorded source signature and run
the reconcile pass now: the HTTP twin of dataset reingest <id> for a bucket-owned id.
Same opt-in flag as /reconcile, and paced on that endpoint's clock, since it starts the
same pass. Bodyless; the id is the path.
It answers 202 Accepted:
{"cleared": true, "started": true, "detail": "…"}cleared means the id's last_seen_signature is gone from the status index, so the S3
reconcile's same-ETag short-circuit no longer pins the unchanged package to its error.
started means the shared reconcile pass has been detached to wake the bucket monitors.
Poll GET /datasets/{id}/state for the effect. Nothing is written to the override store,
so it stays never-written-by-the-node and may be mounted read-only on a serving replica.
Other statuses: 400 for a malformed id; 404 for a well-formed id this node has no
status entry for, never seen or already erased, so there is no signature to clear; 409
when clearing would change nothing — a live (visible/hidden) id is already served, an
inbox-owned id is restored with dataset reingest <id> on the host, and an error id with
no recorded signature re-ingests on its own. The three refusals are decided before the
pacing window is consumed. 429 with Retry-After applies inside the shared window, and
503 before the reconcile action is installed, clearing nothing.
A data-fault error class (invalid-manifest, invalid-parquet-schema, unsafe-archive)
is not helped by this endpoint: those clear only on a changed package (upload --replace),
exactly as for the CLI.
POST /log-level — flip diagnostic logging, setting the node's own crates to debug,
exactly as SIGUSR2 does. Bodyless and a toggle: on if it was off, off if it was on.
{"verbose": true, "filter": "…", "reverts_in_seconds": 900, "detail": "…"}The difference from the signal is the auto-revert: turning verbosity on schedules a return
to the boot-time level after [control].log_level_revert_seconds (default 900). That
prevents debug being left on in production indefinitely and bounds the log-flood denial
of service someone reaching this plane could otherwise sustain. Turning it off by hand
cancels the pending revert, and re-arming supersedes it; a revert never overrides a later
decision, whichever trigger made it, so a SIGUSR2 supersedes a pending revert exactly as
a second POST /log-level does. reverts_in_seconds is omitted when the call turned
verbosity off. These bodies are snake_case like the rest of the management plane, not
camelCase like the Beacon surface and /stats/queries.
GET /datasets/{id}/state — 200 for a known id, 400 malformed, 410 deleted,
404 never ingested.
410 Gone is returned while a deleted tombstone sidecar for the id stands in the inbox:
the dataset existed, was erased, and any re-drop of it is actively suppressed. It is
distinct from 404 so that a client can tell "permanently refused" from "not picked up
yet". Removing the sidecar releases the id, and un-suppresses any package still sitting in
the inbox, after which the id answers 404 again. A sidecar that cannot be read at all,
truncated or not JSON, for an id the node is not serving is held as a tombstone too rather
than released: the tombstone is the erasure record, and a torn write must not turn 410
into 404 and re-open ingest. Such a file is counted on
gdi_state_sidecar_rejected_total{reason="unreadable"}, and repairing or removing it
releases the id.
The body's required fields are id, state, channel and provenance. channel is the
source provenance, inbox or the owning bucket's name; provenance is the crypt4gh
writer-key provenance described below. state is the effective serving state, with any
active operator suppression composed over the declared state, so the body can never say
state: "visible" while carrying a suppression object beside it.
The optional fields:
| Field | Present when |
|---|---|
source_state |
an override masked a different declared state; it reports what the source resolved to |
error_message |
the dataset's ingest errored. An errored and suppressed id reads state: "hidden", source_state: "error" with the class retained |
overlay_applied_at |
an operator metadata overlay ({id}.metadata.json) is applied; the value is its dct:modified stamp |
overlay_error |
the last overlay was rejected; one of fetch, parse, validate |
state_sidecar_error |
the last {id}.state.json was rejected; one of unreadable, unrecognized |
superseded_redrop_at |
a changed re-upload under this live id was ignored (RFC3339) |
stale |
the owning channel has been dark for longer than [service].max_visibility_staleness_seconds |
suppression |
an operator dataset hide or take-down override is active: {mode, at}, mode ∈ hide | remove |
last_seen_signature |
the node has recorded a signature for the id |
Each is omitted, never null or false, when it does not apply. Four of them need more
than a row:
overlay_error = "parse"also reports a node-local operator override (dataset correct) whose stored file is present but unparseable. That id keeps its last-good served metadata and is not reverted to the source, so this field is the only signal that the override store is degraded; treat it as an alert. The same case is counted asgdi_overlay_apply_failed_total{channel="local-override",reason="parse"}, so it is alertable without polling per id.state_sidecar_erroris the visibility counterpart. A rejected visibility sidecar fails the dataset safe tohidden, so this field is what distinguishes a dataset an operator hid from one withdrawn from the public plane by a truncated or malformed sidecar write. Its counter isgdi_state_sidecar_rejected_total{channel,reason}.superseded_redrop_atis the only signal that a correction did not land. A live dataset is immutable, so re-uploading corrected bytes under the same id is quarantined or dropped and the entry staysvisible. Clear it by re-issuing the correction as atake-downplus re-add, or under a new id.- Read
stateandstaletogether.state: "visible"withstale: truemeans the node is not serving the dataset on the public plane, because it can no longer confirm the provider still publishes it. A consumer that ignores the field reports a withheld dataset as visible.
last_seen_signature is the opaque identifier of the artifact the node last processed
under this id: an S3 ETag, or an inbox package's content hash. It changes exactly when
the node has processed different bytes, which is how a client tells "the node has not
looked at my upload yet" from "the node looked at it and rejected it"; deploy --wait uses
it for that, because comparing error_message strings cannot distinguish a re-rejection
from the pre-existing error. Compare it for equality only. It is not a digest of the
dataset contents, its construction differs per channel, and nothing about its format is
stable.
The operator's justification for a suppression is not served here. It is free text that in
practice names people, and this plane is unauthenticated; read it from the override file,
the audit stream or dataset list. See operating.md §12
for the CLI and the override's precedence over the source.
The example below is a dataset with a recovered writer key, no overlay and no suppression:
{
"id": "GDI-EE-EXAMPLE-20260409143052837",
"state": "visible",
"channel": "inbox",
"provenance": {
"kind": "recovered",
"fingerprints": [
"sha256:59dea1f5d1b035cf30101bcec603973ded73f8727616b58b8912d38c66e4f022"
]
},
"last_seen_signature": "sha256:73ec88d6edd94412b2f35612903048abf5cac990b908d73e86fedd14757523b5"
}provenance is the crypt4gh writer-key provenance recorded at ingest, tagged by kind:
kind |
meaning | fingerprints |
|---|---|---|
recovered |
the header yielded writer-key fingerprint(s) | present, non-empty |
plaintext |
an inbox staging dir with no crypt4gh envelope | absent |
recovery_failed |
a .tar.c4gh that published but whose header would not parse |
absent |
unknown |
no successful ingest has recorded provenance for this id | absent |
The fingerprint is proof-of-possession, not an authenticated producer identity: anyone
holding the node's public recipient key can author a package under a fresh writer key of
their own. Whether the node gates on that key depends on [ingest].writer_policy:
writer_policy |
behaviour | what the fingerprint means to you |
|---|---|---|
off (default) |
the fingerprint is recorded, never enforced | an accountability record only — "which key wrote this?", after the fact |
warn |
a writer outside the channel's allow-list is logged and still ingested | as above, plus an operator-side signal |
enforce |
a writer outside the channel's allow-list is rejected (error_message: writer-rejected) |
the producer is on the allow-list configured for that channel |
The allow-lists are per channel — [ingest].inbox_allowed_writer_fingerprints for the
local inbox, allowed_writer_fingerprints on each [[s3.buckets]] entry — so a key
legitimate for one provider does not authorise publishing as another. Under off or
warn, treat the fingerprint as trustworthy only when compared against an out-of-band
allow-list of known writer keys. Under enforce the node has already made that comparison.
error_message vocabulary. When state is error, error_message is one of a
closed, path-free set — the same strings that label the gdi_ingest_total{error_class=…}
metric — so a client may match on them exhaustively. The set is the ErrorClass enum in
crates/core/src/error.rs, held in sync with this table by a test that fails the build if
the two diverge:
error_message |
Cause |
|---|---|
invalid-config |
Service configuration failed startup preflight. |
invalid-manifest |
manifest.json failed schema, required-field or cross-check validation. Includes a numberOfRecords or populations mismatch, a wrong manifestVersion, a bad mode or assembly, or an unknown config key. |
invalid-parquet-schema |
The parquet payload failed schema or per-value validation. |
unsafe-archive |
A TAR or staging member failed the safety checks, or the package exceeded a size cap. |
unknown-catalog |
The manifest's catalog is not one the node is configured to accept. |
decrypt-failed |
crypt4gh decryption failed: no configured node identity decrypts the package, or it is corrupt or truncated. |
query-too-large |
A client request matched more than a configured limit. A client error surfaced on the beacon path, not an ingest state. |
resource-exhausted |
A server-side ceiling was full when the request arrived, currently the process-wide beacon query-memory budget. Surfaced on the beacon path as a 503, never a 4xx, because the request may be well formed and retrying it later can succeed. Not an ingest state. |
writer-rejected |
The artifact's producer is not trusted for its channel under an enforce [ingest].writer_policy, so it is not published. Two shapes carry this class: a .tar.c4gh whose recovered crypt4gh writer key is not on the channel's allow-list, and an unidentified plaintext staging-dir drop, which carries no writer key at all and so can never be allow-listed. The class does not imply the artifact was encrypted. |
scrub-failed |
A stored dataset failed the at-rest store scrub: a PME (PARE) parquet did not decrypt or authenticate. Either the at-rest bytes were tampered with, or the key material that wrote them is gone. Distinct from decrypt-failed, which is the package envelope at ingest. Node-side, so it is drained at startup and re-attempted once the key is restored. |
internal-error |
A catch-all internal failure, including a transient infra fault such as Vault being unreachable, which the node retries rather than persists. |
Machine-readable contract, in lieu of OpenAPI. The node ships no hand-maintained
OpenAPI document; for a GA4GH Beacon that would duplicate the upstream spec and drift. The
machine-readable contract is instead the set of schemas its responses are validated
against: the vendored GA4GH Beacon v2.2.0 JSON schemas under
conformance/ga4gh-beacon-v2/ (requests, responses,
configuration) and
conformance/ga4gh-beacon-v2-default-model/
(the genomicVariations entity), with the VRS 1.3 schema
the latter $refs, and the FAIR Data Point / DCAT SHACL shapes under
conformance/shapes/. Integrating clients should treat those
upstream schemas and shapes as authoritative; this document is the human-readable reference
on top of them.
Beacon v2. The public beacon targets the GA4GH Beacon v2 API, pinned to v2.2.0 via
[beacon].api_version; the authoritative contract is the
upstream spec
(reference docs), not this doc. The framework response
schemas are vendored verbatim (see conformance/ga4gh-beacon-v2/VENDORED.md). The node's
real /info, /service-info, /configuration, /entry_types, /filtering_terms and
/map responses, its g_variants boolean, count and record responses, and its
beaconErrorResponse are validated against those schemas, along with a /map crawl that
validates every advertised endpoint by entry type and every g_variants results[] item
against the default-model and VRS 1.3 entity schemas (see
the variation object). FAIR Data Point / DCAT output is
validated separately against the pinned gdi-metadata SHACL shapes — see
conformance/README.md. RDF is content-negotiated, so it is
not modelled as a JSON contract.
The following HTTP-surface deltas matter when integrating against the node. Emitted-metric-signal deltas are operator-facing and live in operating.md §2 and §3.
- The public plane performs no authentication or authorization. The aggregated Beacon and the FAIR Data Point are open, so that count, boolean and aggregate-record discovery need no credential. A production GDI deployment that needs registered or controlled access must front the node with an external LS-AAI/OIDC + REMS/DAC layer, either an Ingress OIDC proxy or an integrated back-office proxy. The node has no built-in auth and no sensitive tier.
/infocarries the network-registry fields only when they are configured.alternativeUrl([beacon].alternative_url) andorganization.logoUrl([beacon.organization].logo_url) are optionalbeaconInfoResponsefields, and the node emits each key only when the config sets it; an unset field is absent, nevernull. The GDI allele-frequency network reads both, and a member that omits either has been observed to degrade the network's member listing rather than just its own entry. Queries still aggregate, so the symptom is easy to miss. Set both on any node that joins a network — see deployment.md.- The public plane has no admin or control surface. Nothing on it triggers a re-ingest
or otherwise mutates state, so the public surface cannot enumerate hidden datasets. The
management plane is a different matter: behind
[control].enabledit mounts four action endpoints —POST /reload,POST /reconcile(which drains queued re-ingest markers),POST /log-levelandPOST /datasets/{id}/reingest— documented in the management-plane section above. They are opt-in, off by default, bound to loopback by default, unauthenticated when enabled, and rate-limited; what keeps them safe is the bind address and the NetworkPolicy. Out-of-band re-ingest (see operating.md §5) remains available and is the only route when[control]is off. - Per-IP rate limiting is the fronting Ingress's job, not the node's. The node has a
global concurrency cap and load-shed for resource protection, but no per-client rate
limit, because the real client IP is known at the proxy rather than behind it. Configure
per-IP throttling at the Ingress or reverse proxy. The node's own re-identification
control is small-count suppression: set
[beacon].min_allele_countto a non-zero floor. AWARNis logged at startup in every environment while it is0, because the aggregatedg_variantsplane is unauthenticated regardless of theenvironmentlabel. Rate-limiting only slows probing; suppression prevents the leak. - Also set a per-source connection limit, which is a different directive. A rate
limiter counts requests, and the cheapest way to deny this node's public plane makes
none: hold idle TCP sockets until the per-plane connection cap is reached. Ordinary
clients then get connection resets, no rate counter moves, no
http_requestaudit line is written, and the only signal isgdi_http_connections_rejected_total{plane="public"}climbing. The cap itself behaves correctly, so this is a gap in ingress configuration rather than in the node. Uselimit_conn(nginx) ormaxConnectionsPerSourceand its equivalents (Traefik, HAProxy, Envoy) alongside the per-IP rate limit; neither substitutes for the other. Slowloris is fully handled: half-sent bodies fillmax_concurrent_requests, ordinary clients get a clean503, and the node recovers the moment the sockets close.
Every datasets collection entry, and every g_variants resultSets[] entry whether a hit
or an exists: false miss, carries a GDI-namespaced gdiDatasetInfo object:
{
"id": "GDI-EE-EXAMPLE-…",
"name": "…",
"gdiDatasetInfo": {
"assembly": "GRCh38",
"populations": ["EE", "FI", "Total"],
"minAlleleCount": 5
}
}Without it a beacon answer is ambiguous. A variant absent from a population may have been
suppressed rather than never observed, and a query against the wrong assembly matches
nothing; in both cases absence reads as zero. Carrying it on the query response, not only
on /datasets, is what makes a 0 self-describing: a client reading an exists: false
resultSet with minAlleleCount: 5 knows the true count is bounded to {0} ∪ [1, 5),
without a second call and without having to already know which dataset ids to look up.
assembly— the only assembly this dataset is ever searched under. Ag_variantsquery naming a different one never consults it, and is answeredexists: falsewith no resultSet, so the miss cannot be mistaken for "consulted and not found". A query naming no assembly is answered under this one when it is the only one the node serves, and is400"ambiguous" when it is not.populations— the labels the dataset actually serves, after the build-time floor. Omitted, rather than empty, when a package predates the field. Verified at ingest against thePOPULATIONcolumn of the parquet, exactly asnumberOfRecordsis, so a manifest claiming a population the data does not hold is rejected.minAlleleCount— the effective floor,max(node, dataset).0means no suppression.
This is a dataset-level disclosure and never a per-result one: it rides on the resultSet,
which is the dataset, never on a results[] entry. Naming the floor says that thresholds
exist; flagging an individual suppressed row would reveal which variants have below-floor
cells, the fact the Total-collapse exists to hide. The value is query-independent, so a
suppressed and an absent dataset still assemble to a byte-identical exists: false
resultSet.
The beacon-v2 Dataset and resultSet schemas are both additionalProperties: true, so
the extension is additive and conformant on either, and apiVersion is unchanged.
- operating.md §1 — Health and readiness endpoints — probe wiring, readiness-drain semantics, what
200and503mean. - operating.md §2 — Reading the metrics and §3 — Alert thresholds — the
/metricsseries and what to alert on. - operating.md §21 — Audit log — which requests are audited, including the dataset-state oracle.
- operating.md §12 — Dataset lifecycle and state transitions — the states behind
GET /datasets/{id}/state. node.example.toml— the listener, mount-prefix, body-limit and timeout knobs referenced above.