Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Advertising runs and artifacts

This is what a tool does at the end of a job: say what it ran and what came out of it.

POST /api/v1/advertise/produced     artifacts this run generated
POST /api/v1/advertise/consumed     artifacts this run used

Both take the same body — one run, and the artifacts it touched — and both create the run if it does not exist yet, so a job that produces and consumes makes two calls and gets one run.

The credential must act as a deployment and carry advertise:produce or advertise:consume. The deployment comes from the credential; there is no field for it. See How a tool authenticates.

curl -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
     -d '{
       "run": {"external_key": "ci/12345/attempt-1", "status": "success",
               "started_at": "2026-08-30T14:02:11Z", "ended_at": "2026-08-30T14:02:49Z"},
       "artifacts": [{
         "title": "Validation report — input.ttl against the shapes at v3",
         "conforms_to": "https://registry.example.org/type/shacl-validation-report",
         "license": "https://spdx.org/licenses/CC-BY-4.0",
         "keywords": ["SHACL"],
         "was_derived_from": ["https://peer-registry.example.net/artifact/01J7Z…"],
         "distributions": [{
           "download_url": "https://validator.example.org/reports/9f2a.ttl",
           "media_type": "text/turtle",
           "byte_size": 2118342,
           "checksum": {"algorithm": "sha256", "value": "9f2a…"},
           "access_protocol": "https",
           "auth_method": "apikey",
           "availability": "restricted",
           "access_request_url": "https://example.org/data-access"
         }]
       }]
     }' \
     https://registry.example.org/api/v1/advertise/produced

The response names what now exists:

{
  "run": "https://registry.example.org/run/01a05…",
  "artifacts": ["https://registry.example.org/artifact/01a05…"],
  "created": true,
  "queued_for_resolution": ["https://peer-registry.example.net/artifact/01J7Z…"]
}

created is false when every artifact in the payload was already recorded for this run and role — which is the signal that your retry was a retry.

Idempotency

Both endpoints are idempotent on (run, artifact, role).

The run is identified by its external_key — a CI job id, a workflow attempt, whatever your system already calls it — scoped to the advertising deployment. Post the same key twice and you update one run rather than creating a second, and the artifacts are matched against what that run already has.

This matters because retries are normal. A CI step that reruns, a webhook that redelivers, a job that is restarted after an infrastructure failure: none of them should double the lineage graph. If you send no external_key, every call mints a new run, which is almost never what you want from an automated caller.

An artifact carries its own external_key too; when it has none, one is derived from the run's, so the pairing still holds.

Advertise when you know what happened. A run posted as running and never updated is a run the registry has to keep believing in. If you do want progress visible, post with status: "running" and post again with the outcome — the second call finds the same run by its external_key and updates it.

Foreign inputs

was_derived_from, was_revision_of and is_version_of may point at an IRI at another registry. Advertising never blocks on the network: an unknown IRI is stored verbatim, and a background worker fetches a stub into that peer's own graph afterwards. The queued_for_resolution list in the response tells you which ones that happened for.

This is what makes cross-registry lineage cheap enough to actually do. See Federation.

Types are checked, keywords are not

conforms_to must be a term the registry holds, and a write naming one it cannot resolve is a 422 before anything is written. Look it up first with GET /api/v1/vocab/search?branch=data&q=…. See Artifact types and topics.

keywords are matched against the registry's list where they can be and kept verbatim where they cannot — see Artifact keywords.

Say "no bytes" out loud

If the artifact's bytes are not obtainable from here, do not omit the URL and leave it ambiguous — set availability: "metadata-only" on the distribution, or give no distribution at all. That makes the record's Signposting headers omit rel="item", so a client can tell a policy from an oversight. See Availability.

Content identifiers

A distribution that carries a checksum also gets a content identifier: an RFC 6920 ni:/// URI derived from the digest, which is the same string for the same bytes no matter which registry, deployment or run produced them.

That makes GET /api/v1/artifacts?content=ni:///sha-256;… a question about bytes rather than about records — has anyone here described this exact file? — which is the question you have when two pipelines may have produced the same output by different routes.

The identifier is a pure function of the algorithm and the digest: the digest in base64url without padding, after ni:///<algorithm>;. Nothing about the registry goes into it, so compute it wherever the file already is:

printf 'ni:///sha-256;%s\n' \
  "$(openssl dgst -binary -sha256 FILE | openssl base64 -A | tr '+/' '-_' | tr -d '=')"

POST /api/v1/artifacts/identify (or GET with the same two parameters) returns the identifier for an algorithm and value you send. It is a convenience for checking your own implementation, not a source of truth, and its response says so along with the code to stop calling it.

It will not accept the file. Sending bytes is a 422, deliberately: streaming a file to the registry so it can compute a digest you can compute locally would put a network round trip, a size limit and the registry's availability between a producer and an identifier that does not depend on the registry at all.

Registering an artifact with no run

POST /api/v1/artifacts records an artifact that no run in this registry produced — data that predates the registry, or arrived from outside it. Same body shape as one element of artifacts, same vocabulary rule.

Saying what made it

Where there is a run, the registry already knows: an artifact points at the run that generated it, the run at the deployment that performed it, the deployment at its software. That chain is the better answer, because the registry built it from the credential rather than from anything a caller typed.

With no run, that chain has no first link, so two optional fields carry the answer instead:

{
  "title": "Nightly cohort export",
  "produced_by": {
    "name": "cohort-exporter", "kind": "software", "version": "2.4.0",
    "homepage": "https://example.org/cohort-exporter"
  },
  "produced_by_user": {
    "name": "A Researcher", "kind": "person",
    "identifier": "https://orcid.org/0000-0002-1825-0097",
    "email": "researcher@example.org"
  }
}

produced_by_user is deliberately not creators. Creators are authorship, the kind that survives into a citation. This is the operational question — who was at the keyboard, or which account an agent acted under — asked when something needs explaining rather than crediting. An artifact can reasonably have both, and they can name different people.

Give an identifier where one exists. An ORCID or a ROR becomes the agent's own identity rather than a node this registry minted, so the same person is the same node in every registry that federates with this one.

These are claims, and the difference matters. attributed_to on the record is written by the registry from the credential that presented it, and no payload can influence it. produced_by is whatever the caller said. Both are kept, because both are useful; only one is evidence. A record can perfectly well say it was produced by one system while the attribution shows it arrived from another, and that discrepancy is a fact worth being able to see.

In RDF these are prov:qualifiedAttribution nodes carrying prov:agent and a prov:hadRole of tar:producingSystem or tar:producingUser, with prov:actedOnBehalfOf between them when both are given. They are qualified rather than plain prov:wasAttributedTo for exactly the reason above: that predicate is the registry's own, and a caller-supplied agent sitting beside it would make the one attribution nobody can forge indistinguishable from the ones anybody can.

OpenLineage

Airflow, dbt, Spark and anything else that already emits OpenLineage can post its native events instead:

POST /api/v1/openlineage

The adapter maps what OpenLineage covers onto runs, artifacts and lineage, and keeps the whole event as tar:openLineagePayload so that nothing it does not model is lost. That is the honest version of an adapter: it does not pretend the mapping is total, and it does not throw away what it could not place.

The artifact type the adapter assigns is a term like any other, held to the same rule.

Reading it back

GET /api/v1/runs                        ?q= ?instance= ?software= ?status=
GET /api/v1/runs/{id}                   one run, with what it used and generated
GET /api/v1/artifacts                   ?q= ?conforms_to= ?license= ?availability= ?keyword=
                                        ?instance= ?software= ?run= ?content= ?registry=
GET /api/v1/artifacts/{id}
GET /api/v1/artifacts/{id}/lineage      ?direction=up|down|both (default both) ?depth= (1–6, default 1)
GET /api/v1/instances/{id}/runs         one deployment's runs
GET /api/v1/instances/{id}/artifacts    the artifacts its runs touched