Introduction
The Tool Artifact Registry is a self-hostable catalogue of four things and the links between them: the software an organisation has, the deployments that actually run it, the runs those deployments perform, and the data artifacts those runs consume and produce.
It exists because those four are usually kept in four places that do not agree. A software catalogue knows what exists but not where it runs. A monitoring system knows what is running but not what it is for. A data catalogue knows a file exists but not which program wrote it. A CI system knows a job ran but tells nobody. Each is right about its own slice and none of them can answer a question that crosses two — what in this estate can produce a validation report, where is it deployed, and what did it last produce?
Two capabilities follow from putting them in one graph, and they are the point:
Matchmaking, before anything has run. A deployment can declare what it is able to produce and consume. That answers "what could produce this kind of artifact?" on an empty registry, which is the question you have when you are looking for a tool rather than looking for a file.
Lineage, after it has. A run links the artifacts it used to the artifacts it generated, so "where did this file come from and who else used it?" is a graph walk rather than an archaeology project.
What it is not
It is not a data lake, an artifact store or a package registry. It holds descriptions and pointers, never the bytes. An artifact record says what a thing is, who made it, what produced it and how to get at it — or, honestly, that you cannot get at it from here.
It is not a monitoring system. It probes deployment endpoints for liveness because a catalogue full of dead links is worse than useless, but one probe every few minutes is a freshness signal, not an SLA.
It is not an authorization system for the data it describes. Access to an artifact is decided by whoever serves the artifact.
Design commitments
These shape everything else, so they are worth stating up front.
RDF-native, not RDF-flavoured. Records are quads in an Oxigraph store, described with standard vocabularies — DCAT for datasets and distributions, PROV for lineage, SKOS for concepts, schema.org and CodeMeta for software. There is a JSON API because JSON is what clients want, but it is a projection of the graph, not the source of truth. A read-only SPARQL endpoint is a first-class surface, not a debugging aid.
Writes are validated by real SHACL. shapes/tar-shapes.ttl is the rule set and a SHACL
engine enforces it before anything is committed. What the API accepts is changed by editing a
Turtle file, not by editing Rust. A rejected write returns 422 with the engine's own
sh:ValidationReport.
FAIR is not open. Findable and described does not imply retrievable, and the registry refuses to blur the two. An artifact may be recorded as provably not obtainable here, and that state is machine-detectable rather than something a client infers from a missing field. See Availability.
Federation is a cross-link, not a harvest. Any object position in the graph may hold a foreign IRI. A registry does not copy its peers' catalogues; it points at them and caches a stub, in a named graph of that peer's own, never mixed with its own records.
Every identifier dereferences. A record's IRI is also its web page, its Turtle, its JSON-LD and its Markdown. There is no separate "web view" URL to keep in step.
Vocabulary is checked, not suggested. An artifact type must be a term the registry actually holds. A write naming one it cannot resolve is refused before anything is written, with a message saying how to search for the right term, adopt an existing one, or mint a new one. This exists because free-text classification degrades silently — see Artifact types and topics.
Status
A working prototype. Every endpoint in the design record is implemented and covered by tests. Where it departs from the design, or stops short of it, that is written down in Limitations rather than left for you to discover.
Where to go next
- The model — the four layers and why runs belong to a deployment.
- Getting started — a registry running locally in a few commands.
- Conventions — then the API chapter for whatever you are trying to do.
- Agent-facing surfaces — if the client is a language model.
The model
Four layers, plus two things that hang off them. This chapter is the conceptual model; for the
RDF it becomes — the classes, the properties, and why the registry has a vocabulary of its own
at all — see The tar: ontology.
Software abstract: the program, its repository, licence, responsible party
└─ Release a versioned, runnable plan — a tag, an image digest, a download
└─ Deployment an installation of that release; the agent that actually acts
└─ Run one execution
├─ used → Artifact a consume advertisement
└─ generated → Artifact a produce advertisement
└─ Distribution how to get at it, or how to ask
Why four and not two
The layer that people want to collapse is the deployment, and collapsing it is what breaks the model. A run is performed by something that exists somewhere — it has an endpoint, an operator, a jurisdiction and a credential. Abstract software has none of those. If runs attached to software, then "which of our installations produced this?" would have no answer, two organisations running the same program would be indistinguishable, and there would be nothing for a credential to identify.
So the deployment is the acting agent, and one rule follows from it that the whole authorisation model rests on: a deployment may only advertise runs in which it is itself the agent, and which deployment that is comes from the credential, never from the request body.
The release layer earns its place more quietly. It is what makes "this deployment is three versions behind" a fact the registry can state, and it is where a capability can change: a tool that gained an output format in v3 declares that on the release, not on the software.
Software
The abstract program. Not a copy of it, not a place to run it — the thing you would name in a sentence.
Notable fields:
kinds— a set, not one choice, fromservice,library,cli,desktop,workflow. One program is routinely several: a library with a CLI wrapper and a hosted service. There is a singularkindfield too; it is the first ofkinds, kept so older clients still read.deployable— whether it makes sense for this software to have an endpoint at all. A library or a desktop application is not deployable, and marking it so is what stops the registry demanding a URL that does not exist. A deployment of non-deployable software may not carry an endpoint; that is enforced on write.maturity— a repostatus.org development status. Set it only if the project declares one.topics— what the software is about. Controlled; see Vocabulary.capability— what it can produce and consume. See below.sync— a source repository the registry may keep named fields in step with. See Registering software.
Release
A versioned, runnable plan. A version string, optionally a publication date, a container image and its digest, an install command, a changelog, and any number of downloads.
A release may carry its own capability, which is how a capability that changed between
versions is recorded truthfully.
Deployment
An installation. It has a label, the software it is a deployment of, optionally the
release it runs, an endpoint_url if it is reachable, an operator, an availability and a
jurisdiction.
It also carries whatever identifies it as a caller: an oidc_client_id and oidc_issuer, or
registry-minted API tokens, and allowed_scopes bounding what those credentials may do. See
How a tool authenticates.
Health
The registry probes deployment endpoints in the background and records health as up,
down or unknown, with health_checked_at and a health_detail. This is observed, never
written by a caller — a deployment asserting that it is up is a claim, and the interesting case
is exactly the one where it cannot answer.
A record may name a health_endpoint, a URL whose only job is to say the deployment is alive.
That is held to a 2xx. Leave it out and the endpoint_url itself is probed, where anything
that answers at all counts as up — because a great many healthy services return 401 or 404
at their root, and marking those down would be a false alarm about a working deployment.
A deployment with no endpoint is never probed and never reports "down" for it. Its liveness
signal is last_seen_at, stamped when it announces itself or advertises a run.
Run
One execution, performed by one deployment. A status of success, failed, running or
aborted, a start and end time, optionally the release that ran and an external_key.
The external_key is how a run in some other system — a CI job id, a workflow attempt — is
named here, and it is what makes advertisement idempotent. Retrying the same CI step does not
duplicate lineage.
Artifact
A data artifact: what it is, who made it, what produced it, and how to get at it. The registry never holds the bytes.
The field that carries the most weight is conforms_to — the artifact's type. It is what
every capability query and every subscription filter matches on, exactly, so it is controlled:
it must be a term the registry holds. See Artifact types and topics.
Lineage lives on the artifact too, as was_derived_from, was_revision_of and is_version_of,
alongside the was_generated_by link the run advertisement creates. Any of those may point at
a foreign IRI at another registry.
Distribution
How to get at an artifact, or how to ask. An artifact has zero or more.
A distribution carries an access_url or a download_url, a media_type, a byte_size, a
checksum, an access_protocol (https, http, s3, sparql, oci, ipfs, file), an
auth_method (none, apikey, oauth2, basic, signed-url), an availability, and an
access_request_url for the case where the answer is "apply".
The media type belongs here and not on the artifact, deliberately. A serialisation is not a kind of thing: the same shapes graph re-serialised from Turtle to N-Triples is the same artifact.
Availability, and the honest absence
availability is one of:
public | anyone can get it |
restricted | access needs an agreement or an account |
embargoed | it will become available later |
metadata-only | the bytes are not obtainable from here at all |
metadata-only is the one that matters. It means the artifact is findable, described, and
provably not retrievable — and the registry makes that provable rather than inferable. There is
no download_url at all, the UI renders no download affordance, and the Signposting Link
headers omit rel="item". A machine can therefore tell "no bytes here" from "bytes behind
auth" without parsing the body and guessing.
This is the concrete form of the commitment that FAIR is not open. A registry that quietly omitted a URL would leave a client unable to distinguish a policy from an oversight.
Capability
A declaration of what some software, release or deployment is able to do, expressed as sets
of artifact types it produces and consumes.
It is separate from the run graph on purpose, and both are first-class:
- the capability answers "what could produce this kind of artifact?" before anything has ever run;
- the run graph answers "what did produce this one, and who used it?" afterwards.
A registry with no runs in it is still useful for the first question, which is the question you have when you are choosing a tool.
Declared and observed capability can disagree. The registry does not currently reconcile them; see Limitations.
Artifact series
Artifacts that are successive versions of the same thing are linked with is_version_of into a
series, which is what lets a detail page show siblings rather than a wall of near-duplicates.
Records that are withdrawn
Deleting a record tombstones it rather than erasing it: the IRI still resolves, and says that
it was withdrawn. An identifier that has been published and then returns 404 is a broken
promise, and something somewhere is still citing it. A withdrawn record leaves the lists it was
in, so it stops appearing in search and in listings, but a client holding its IRI still gets a
truthful answer.
Peer records
A record whose origin.kind is not local came from a peer registry. It is a cached stub, held
in a named graph of that peer's own and never mixed into local data. It carries cached_at and
a resolve_status, and the UI marks it with an origin chip. See
Federation.
The tar: ontology
The model explains what the registry describes. This chapter is the formal side of it: the classes and properties the registry's own namespace declares, what each one may be said about, and — for every one of them — what was checked in the standard vocabularies before it was added.
The registry speaks DCAT 3, DCTERMS, PROV-O, schema.org, SKOS, SPDX, CodeMeta, ADMS and VoID
wherever they say what it means. tar: exists for what none of them says.
The namespace IRI does not resolve.
https://w3id.org/tar/ns#is a name, not a location: it needs a w3id.org registration that has not been made, so following it gets you nothing today. Until it is made, the document below is the ontology, and every running registry serves it:curl -G --data-urlencode \ 'query=CONSTRUCT { ?s ?p ?o } WHERE { GRAPH <urn:tar:bundle:vocab> { ?s ?p ?o } }' \ -H 'Accept: text/turtle' https://registry.example/sparql
Where it lives
shapes/vocab.ttl, compiled into the binary and loaded into the named graph
urn:tar:bundle:vocab at every start. One file, not two, on purpose: the rdfs:comment on each
term is the reason that term exists rather than a standard one, and an ontology whose
justifications are kept somewhere else is an ontology whose justifications nobody reads. Since
that file is one bundle in one graph (Named graphs), the
CONSTRUCT above returns the ontology whole and nothing else — which is what a w3id redirect
would eventually point at.
The SHACL shapes are a separate file and a separate graph (shapes/tar-shapes.ttl,
urn:tar:shapes). They also mint IRIs in this namespace — tar:SoftwareShape,
tar:ArtifactShape and so on — which are shape identifiers rather than vocabulary terms and are
not declared here.
What the axioms are for, and what they are not
The registry validates writes with SHACL, and SHACL ignores rdfs:domain and rdfs:range
entirely. Nothing in this file rejects anything.
What the axioms do is let a consumer running an RDFS reasoner infer types. That inverts the
usual instinct: an over-tight domain does not tighten this registry, it silently invents triples
in somebody else's store. So a domain is declared only where every subject the registry writes
genuinely is of that class, and left off where a property is used on more than one kind of
record — rdfs:domain over two classes means the subject is inferred to be both at once, which
would be false, and minting a union class to make the axiom well-formed would be tidiness
asserted as fact. tar:availability (distributions and deployments), tar:hasCapability
(software, releases and deployments) and tar:tombstoned (every kind of record) therefore have
ranges but no domains.
Ranges given as xsd:dateTime describe what the write builders emit and what the shapes
enforce. A caller can defeat that with TAR_SHACL_VALIDATE_WRITES=false and a malformed date,
which the builder stores as a plain literal rather than dropping; a reasoner reading such a
graph is reading data the registry itself reports as invalid.
Classes
| Class | Also asserted on the same node | Declared hierarchy |
|---|---|---|
tar:Software | schema:SoftwareApplication, schema:SoftwareSourceCode | rdfs:subClassOf schema:SoftwareApplication |
tar:Release | schema:SoftwareApplication, prov:Plan | rdfs:subClassOf schema:SoftwareApplication, prov:Plan |
tar:Instance | prov:SoftwareAgent; dcat:DataService only when it has an endpoint | rdfs:subClassOf prov:SoftwareAgent |
tar:Capability | prov:Plan | rdfs:subClassOf prov:Plan |
tar:ArtifactSeries | — | none |
tar:RepositorySync | — | none |
tar:ArtifactType | skos:Concept | rdfs:subClassOf skos:Concept |
tar:ResearchTopic | skos:Concept | rdfs:subClassOf skos:Concept |
tar:ArtifactKeyword | skos:Concept | rdfs:subClassOf skos:Concept |
tar:LegacyTopic | skos:Concept | rdfs:subClassOf skos:Concept |
tar:producingSystem and tar:producingUser are not classes but individuals: instances of
prov:Role, used as the object of prov:hadRole.
Two things are worth reading off that table.
Nothing is declared owl:equivalentClass with anything. A reasoner acts on equivalence, and
a wrong equivalence spreads: it would make every statement about one IRI a statement about the
other. rdfs:subClassOf is the strongest claim that is actually true here, and for two of the
classes even that is too strong.
The multi-typing is deliberate, and subsumption explains it rather than excusing it. A
Release is a schema:SoftwareApplication, and so is the Software it is a release of — which
is exactly why tar:Software and tar:Release exist: no standard vocabulary distinguishes an
abstract program from one of its versioned, runnable plans as classes, and a SHACL shape
targeting one would fire on both. The subclass axioms say the schema.org typing is honest; the
tar: classes say which of the two you are looking at.
tar:Instance is rdfs:subClassOf prov:SoftwareAgent and deliberately not a subclass of
dcat:DataService: the code asserts dcat:DataService only when a deployment has an endpoint,
and a library installed on a laptop is a deployment with no endpoint at all. Making it a
subclass would infer a data service where there is none.
tar:ArtifactSeries and tar:RepositorySync are the two classes the code has always asserted
and the ontology never declared. Both are given no superclass, for stated reasons:
- A series was typed
skos:Conceptonce, which put every artifact's title into the artifact-type picker and had to be migrated back out. It is not adcat:Dataseteither — it has no distributions, no licence and no bytes, which is the whole reason it is a separate node. - A
tar:RepositorySyncis registry configuration rather than a described resource. Typing itprov:Planwas considered and rejected: that would claim the registry executes it as the plan of some activity, and there is noprov:Activityin the graph that it is the plan of.
Properties
Domain and range as declared. "Beside" names a standard term the registry writes as well, on
the same subject, in the same write — not a rdfs:subPropertyOf, because in each case the
standard term is lossy in a way the comment records, and a subproperty axiom would assert an
entailment that is not there.
Capability
| Property | Domain | Range | Beside |
|---|---|---|---|
tar:hasCapability | — (Software, Release or Instance) | tar:Capability | — |
tar:produces | tar:Capability | skos:Concept | — |
tar:consumes | tar:Capability | skos:Concept | — |
The range of produces/consumes is skos:Concept and not tar:ArtifactType, even though
that is what the write path demands of a local record. A type cached from a peer carries none of
this registry's classes — a peer is authoritative for its own types — and is nonetheless a legal
value here. A range of tar:ArtifactType would infer, about somebody else's term, a
classification this registry has explicitly declined to make.
Structure
| Property | Domain | Range | Beside |
|---|---|---|---|
tar:runsRelease | tar:Instance | tar:Release | — |
tar:instanceOf | tar:Instance | tar:Software | — |
tar:usedRelease | prov:Activity | tar:Release | prov:qualifiedAssociation / prov:hadPlan, always written too and authoritative |
tar:sync | tar:Software | tar:RepositorySync | — |
Access descriptors
| Property | Domain | Range | Beside |
|---|---|---|---|
tar:availability | — (Distribution or Instance) | xsd:string | dct:accessRights → EU access-right authority table |
tar:accessProtocol | dcat:Distribution | xsd:string | — |
tar:authMethod | dcat:Distribution | xsd:string | — |
tar:accessRequestURL | dcat:Distribution | — | — |
tar:defaultMediaType | skos:Concept | xsd:string | — |
tar:availability takes public, restricted, embargoed or metadata-only, and it stays
authoritative because its standard reading is lossy: the EU table has no embargo concept and
cannot distinguish "described but not retrievable" from merely non-public, so embargoed and
metadata-only both coarsen to NON_PUBLIC. The SHACL rules that stop a metadata-only
distribution carrying a download URL key on the four-way distinction.
Run bookkeeping
| Property | Domain | Range | Beside |
|---|---|---|---|
tar:status | prov:Activity | xsd:string | schema:actionStatus |
tar:openLineagePayload | prov:Activity | xsd:string | — |
tar:claimedNamespace | prov:Activity | xsd:string | — |
tar:status takes success, failed, running or aborted. schema.org's ActionStatusType
has no member for an aborted action and folds it into failure, so the literal stays
authoritative and schema:actionStatus is the interoperable supplement.
Lifecycle
| Property | Domain | Range | Beside |
|---|---|---|---|
tar:tombstoned | — (any record) | xsd:boolean | adms:status → …/dataset-status/WITHDRAWN |
tar:tombstonedAt | — (any record) | xsd:dateTime | as above |
tar:health | tar:Instance | xsd:string | — |
tar:healthCheckedAt | tar:Instance | xsd:dateTime | — |
tar:healthDetail | tar:Instance | xsd:string | — |
tar:lastSeenAt | tar:Instance | xsd:dateTime | — |
prov:invalidatedAtTime was rejected as the sole form of a tombstone because its domain is
prov:Entity, and a tombstone also applies to a deployment (an agent) and to software.
Software, release and deployment description
| Property | Domain | Range |
|---|---|---|
tar:deployable | tar:Software | xsd:boolean |
tar:readme | tar:Software | xsd:string |
tar:readmeBaseURL | tar:Software | — |
tar:registrationClient | tar:Software | xsd:string |
tar:registrationIssuer | tar:Software | xsd:string |
tar:containerImage | tar:Release | xsd:string |
tar:imageDigest | tar:Release | xsd:string |
tar:installCommand | tar:Release | xsd:string |
tar:healthEndpoint | tar:Instance | — |
tar:jurisdiction | tar:Instance | xsd:string |
tar:selfRegisteredBy | tar:Instance | xsd:string |
tar:selfRegisteredIssuer | tar:Instance | xsd:string |
tar:instanceKey | tar:Instance | xsd:string |
tar:oidcClientId | tar:Instance | xsd:string |
tar:oidcIssuer | tar:Instance | xsd:string |
tar:allowedScope | tar:Instance | xsd:string |
tar:apiFormat | dct:Standard | xsd:string |
tar:temporalStart | dcat:Dataset | xsd:dateTime |
tar:temporalEnd | dcat:Dataset | xsd:dateTime |
Repository sync
| Property | Domain | Range |
|---|---|---|
tar:syncSource | tar:RepositorySync | xsd:string |
tar:syncRepo | tar:RepositorySync | xsd:string |
tar:syncField | tar:RepositorySync | xsd:string |
tar:syncEnabled | tar:RepositorySync | xsd:boolean |
tar:syncedAt | tar:RepositorySync | xsd:dateTime |
tar:syncStatus | tar:RepositorySync | xsd:string |
tar:syncError | tar:RepositorySync | xsd:string |
tar:syncChanged | tar:RepositorySync | xsd:string |
Vocabulary navigation and validation reporting
| Property | Domain | Range |
|---|---|---|
tar:inBroader | skos:Concept | xsd:string |
tar:jsonField | sh:ValidationResult | xsd:string |
tar:inBroader carries a parent concept's label, not the parent concept, and is deliberately
not skos:broader. It is a rendering shortcut for the pickers, which show the parent to tell
near-synonyms apart — one bundled vocabulary contains ontology, odontology and palaeontology,
and only the parent separates them at a glance. A consumer that wants the relation should follow
skos:broader in the bundle.
Which standard vocabulary does what
| Vocabulary | Used for |
|---|---|
| DCAT 3 | artifacts as dcat:Dataset, dcat:Distribution with accessURL/downloadURL/mediaType/byteSize, deployments as dcat:DataService with dcat:endpointURL, the registry as a dcat:Catalog via dcat:inCatalog, dcat:keyword and dcat:theme |
| DCTERMS | dct:title, dct:description, dct:abstract, dct:license, dct:conformsTo, dct:subject, dct:identifier, dct:issued/dct:modified/dct:created, dct:isVersionOf, dct:publisher/creator/contributor, dct:accessRights, dct:Standard |
| PROV-O | runs as prov:Activity with startedAtTime/endedAtTime, deployments as prov:SoftwareAgent, prov:used and prov:wasGeneratedBy for lineage, prov:wasDerivedFrom/wasRevisionOf, prov:qualifiedAssociation/hadPlan, prov:qualifiedAttribution/hadRole, prov:actedOnBehalfOf, prov:specializationOf for content identity, prov:wasAttributedTo for the writing credential |
| schema.org | schema:SoftwareApplication/SoftwareSourceCode, schema:name, codeRepository, softwareVersion, applicationCategory, actionStatus, and people and organisations as schema:Person/Organization |
| SKOS | every vocabulary term: skos:Concept, prefLabel, altLabel, definition, inScheme, skos:ConceptScheme |
| SPDX | spdx:checksum → spdx:Checksum with spdx:algorithm and spdx:checksumValue; licence IRIs under spdx.org/licenses/ |
| CodeMeta | codemeta:developmentStatus (repostatus.org values), codemeta:maintainer |
| ADMS | adms:status on a tombstoned record |
| VoID | void:Dataset and void:triples on the bundle graphs themselves |
| FOAF | foaf:page for an artifact's documentation |
| EU authority tables | the value sets for dct:accessRights and for the withdrawn dataset status |
Why each tar: term exists
Grouped by the reason, and every reason is the term's own rdfs:comment in shapes/vocab.ttl
rather than a fresh argument. That file is the authority; this is a reading of it.
No vocabulary distinguishes these classes, and SHACL has to target them. tar:Software,
tar:Release, tar:Instance, tar:Capability. A Release is also a
schema:SoftwareApplication, so a shape targeting software would fire on both;
prov:SoftwareAgent alone cannot target a deployment without also firing on every other agent.
No vocabulary attaches a reusable I/O declaration to a tool. tar:hasCapability,
tar:produces, tar:consumes. Bioschemas' input/output expect FormalParameter nodes on
the tool itself rather than a reusable declaration object; wfdesc expects workflow Parameter
nodes; biotoolsSchema's function is JSON and XML, not RDF. The type concept a harvester wants
is already the object IRI here, and the indirection would only hide it. The /export/biotools
endpoint is where that mapping belongs.
No vocabulary says which release a deployment runs. tar:runsRelease, tar:instanceOf,
tar:usedRelease. doap:release runs Project → Version, the wrong direction and the wrong
subject; dcat:version is a literal; dct:isVersionOf would claim a deployment is a version of
its software, and prov:specializationOf that it is the same entity. PROV deliberately has no
unqualified activity-to-plan property, and prov:used cannot be reused because every reader
here treats prov:used as "consumed artifact".
DCAT conveys access mechanics only implicitly. tar:availability, tar:accessProtocol,
tar:authMethod, tar:accessRequestURL. DCAT 3 leaves the protocol to be guessed from the URL
scheme; ODRL expresses policy rather than authentication mechanics; schema:conditionsOfAccess
is free text for humans; DCAT-AP 3 has no access-request property and HealthDCAT-AP is still
drafting one. dcat:landingPage is dataset-level navigation rather than an access-request flow.
The interoperable term exists but loses a distinction the registry enforces. tar:status,
tar:tombstoned, tar:tombstonedAt, tar:syncStatus — each written beside, or in place of, a
standard term that folds a state the registry treats separately.
Registry operations nobody else models. tar:health, tar:healthEndpoint,
tar:healthCheckedAt, tar:healthDetail, tar:lastSeenAt, tar:deployable,
tar:jurisdiction, tar:readme, tar:readmeBaseURL, tar:containerImage, tar:imageDigest,
tar:installCommand, tar:apiFormat, tar:defaultMediaType, tar:temporalStart,
tar:temporalEnd, tar:sync and the tar:sync* family, tar:openLineagePayload,
tar:claimedNamespace, tar:inBroader, tar:jsonField, and the workload-identity terms
tar:oidcClientId, tar:oidcIssuer, tar:allowedScope, tar:registrationClient,
tar:registrationIssuer, tar:selfRegisteredBy, tar:selfRegisteredIssuer,
tar:instanceKey. The two *Issuer properties are separate from tar:oidcIssuer because that
one's domain is tar:Instance: a Software that carried it would be asserting it is one of its
own deployments, and a Software and an Instance are different things even when one credential
touches both. Each comment names what was checked: dct:spatial
asserts spatial coverage of data rather than the law an operator answers to; codemeta:readme
is a URL pointing at a README rather than its text; codemeta:buildInstructions is a link to
build documentation rather than an executable one-liner; dcat:mediaType is defined on a
distribution and would misstate its domain on a concept; dct:temporal needs a
dct:PeriodOfTime node for what two optional timestamps say flat.
Two roles, because prov:wasAttributedTo is spoken for. tar:producingSystem and
tar:producingUser are prov:Role individuals carried on a prov:Attribution. The unqualified
prov:wasAttributedTo is written by the registry itself from the presenting credential and read
back as a single value; letting a caller supply an agent there would make the one attribution
nobody can forge indistinguishable from the ones anybody can.
Concept classes, so that "is it a term" and "is it the right kind of term" are one question.
tar:ArtifactType, tar:ResearchTopic, tar:ArtifactKeyword, tar:LegacyTopic. Asked to
classify a tool, a coding agent produced EDAM's "RNA-Seq" — a real term, on a record that had
nothing to do with RNA-Seq. The kind used to be a tar:conceptBranch literal beside the
concept, and it came apart from the concept the first time the two were written by different
code paths: the concepts went into urn:tar:local and a later backfill put their markers
somewhere else, after which every query asking for both inside one GRAPH block found neither.
A class asserted in the same statement as a skos:Concept cannot come apart that way, because
it is that statement. rdfs:subClassOf skos:Concept keeps every one of them a concept to a
reader that has never heard of tar:.
Retired terms
Eight tar: terms carry owl:deprecated true. They are read as fallbacks for graphs written
before the 2026-08-30 audit, and none of them is given a domain or a range — a term that is
being removed should not be teaching a reasoner to infer anything.
| Retired | Replaced by |
|---|---|
tar:atInstance | prov:wasAssociatedWith |
tar:externalKey | dct:identifier |
tar:homeRegistry | dcat:inCatalog |
tar:tagline | dct:abstract |
tar:kind | schema:applicationCategory |
tar:maturity | codemeta:developmentStatus |
tar:contact | codemeta:maintainer |
tar:conceptBranch | the concept classes |
One of these is not fully retired, and the ontology says so rather than pretending.
tar:contact was replaced by codemeta:maintainer on Software, which is what the write path
emits — but the artifact write path still emits tar:contact for an artifact's contact field,
and nothing was put in its place. That is an inconsistency in the registry rather than a
considered distinction between two kinds of contact, and it is recorded in a skos:note on the
term. No domain is declared for it, because it now means one thing on records written before the
audit and another on artifacts written today.
tar:conceptBranch is declared only so that a store written before the concept classes has a
name to look the leftover up by; the first boot of this version removes it.
A worked example
One artifact, exactly as a registry serves it — GET /artifact/{id} with
Accept: text/turtle, or the .ttl extension the Signposting describedby link points at.
The identifiers below are shortened for width; nothing else is edited.
@prefix dcat: <http://www.w3.org/ns/dcat#> .
@prefix dct: <http://purl.org/dc/terms/> .
@prefix prov: <http://www.w3.org/ns/prov#> .
@prefix spdx: <http://spdx.org/rdf/terms#> .
@prefix tar: <https://w3id.org/tar/ns#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
<https://registry.example/artifact/01a05d84-…> a dcat:Dataset , prov:Entity ;
# DCAT because it is a thing in a catalogue; PROV because it is a thing in a lineage.
# Both, on the same node, deliberately: neither vocabulary alone answers both questions.
dct:title "Validation report — batch 2" ;
dct:description "SHACL validation report produced by a scheduled run." ;
dct:license <https://spdx.org/licenses/CC-BY-4.0> ;
dct:issued "2026-09-01T15:08:50Z"^^xsd:dateTime ;
dct:conformsTo <http://edamontology.org/data_2048> ;
# What the artifact *is*. The value must be a concept the registry holds and carry
# tar:ArtifactType, or the write is refused with the search, adopt and mint routes.
dcat:keyword "SHACL" , "validation" , "batch-17" ;
dcat:theme <https://registry.example/keyword/shacl> ;
# One input field, two predicates. DCAT's own division: dcat:keyword is a literal, and
# dcat:theme ranges over a concept from a scheme. "SHACL" matched the registry's keyword
# list and gained a theme; "batch-17" did not and stays free text rather than being
# dropped.
dct:isVersionOf <https://registry.example/artifact-series/01a05d84-…> ;
dcat:distribution <https://registry.example/distribution/01a05d84-…> ;
prov:wasAttributedTo <urn:tar:root> ;
dct:modified "2026-09-01T15:08:50Z"^^xsd:dateTime ;
# Written by the registry from the presenting credential, on every write, by the same
# code for every record type. This is the attribution nobody can forge, which is why a
# caller-supplied agent never goes here.
prov:qualifiedAttribution
<https://registry.example/artifact/01a05d84-…#producingSystem> ,
<https://registry.example/artifact/01a05d84-…#producingUser> .
# Who produced it, and in what capacity — a qualified attribution because there is more than one
# answer and they mean different things. The node IRIs are the artifact's own IRI plus the role,
# so they are stable across rewrites rather than blank nodes that change identity on every parse.
<https://registry.example/artifact/01a05d84-…#producingSystem> a prov:Attribution ;
prov:agent <https://registry.example/agent/01a05d84-…> ;
prov:hadRole tar:producingSystem .
<https://registry.example/artifact/01a05d84-…#producingUser> a prov:Attribution ;
prov:agent <https://orcid.org/0000-0002-1825-0097> ;
prov:hadRole tar:producingUser .
# An ORCID is a better subject than a minted one, so the registry uses it as-is rather
# than inventing a local agent IRI that would federate with nothing.
<https://registry.example/distribution/01a05d84-…> a dcat:Distribution ;
dcat:accessURL <https://shacl.example.org/reports/21> ;
dcat:downloadURL <https://shacl.example.org/reports/21.ttl> ;
dcat:mediaType "text/turtle" ;
dct:format "text/turtle" ;
dcat:byteSize 2119366 ;
tar:availability "restricted" ;
dct:accessRights <http://publications.europa.eu/resource/authority/access-right/RESTRICTED> ;
# The four-way literal is authoritative — the SHACL rules that stop a metadata-only
# distribution carrying a download URL key on it — and the EU authority table is the
# DCAT-AP reading written beside it. Lossy on purpose: the table has no embargo concept.
tar:accessProtocol "https" ;
tar:authMethod "apikey" ;
tar:accessRequestURL <https://example.org/data-access> ;
# Three things DCAT conveys only implicitly, so that a client can choose a retrievable
# distribution without guessing from the URL scheme, and can find out how to ask when it
# cannot.
spdx:checksum [ a spdx:Checksum ;
spdx:algorithm spdx:checksumAlgorithm_sha256 ;
spdx:checksumValue "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" ] ;
prov:specializationOf <ni:///sha-256;n4bQgYhMfWWaL-qgxVrQFaO_TxsrC4Is0V1sFbDwCgg> .
# The same digest again, as a name instead of a literal. Two registries handed the same
# digest derive the same RFC 6920 IRI with no coordination, which is how they discover
# they hold the same bytes; the checksum literal above joins with nothing. It hangs off
# the distribution and not the artifact because bytes are what a distribution has — one
# artifact may have several distributions with different digests.
Two nodes the record refers to are records of their own and come back from their own IRIs, not with the artifact:
<https://registry.example/artifact-series/01a05d84-…> a tar:ArtifactSeries ;
skos:prefLabel "Validation report — batch 2" .
# "This report, any version". No distributions, no licence, no bytes — which is why it
# is a node of its own rather than a second dcat:Dataset.
<https://registry.example/agent/01a05d84-…>
a schema:SoftwareApplication , prov:SoftwareAgent , prov:Agent ;
schema:name "shacl-manager" ;
schema:softwareVersion "2.1.0" ;
prov:actedOnBehalfOf <https://orcid.org/0000-0002-1825-0097> .
# Written only when both roles are filled: the system acted for the person. PROV says
# delegation on the agents, not on the attributions.
Two more choices are worth naming, because a reader meeting them for the first time would reasonably expect something else. Neither appears above, since both are on Software:
dct:abstractfor the one-line summary. The short tagline isdct:abstract, "a summary of the resource", while the long form staysschema:description.schema:sloganwas rejected because itsdomainIncludesis Organization, Brand and Product rather than a creative work.dcat:endpointDescriptionfor API documentation. The object is a node whose IRI is the document's own URL, typeddct:Standardand carryingtar:apiFormatplus adct:conformsTopointing at the specification the document follows. DCAT's own definition — "a description of the service's operations and how to invoke them" — is what an OpenAPI document is.
Identifiers and representations
Every record has one permanent IRI, minted under the registry's TAR_BASE_IRI, and that IRI is
also its web page. There is no separate "web view" URL to keep in step with the identifier,
because a pair of URLs for one thing is a pair that eventually disagrees.
https://registry.example.org/software/01a05…
https://registry.example.org/release/01a05…
https://registry.example.org/instance/01a05…
https://registry.example.org/run/01a05…
https://registry.example.org/artifact/01a05…
https://registry.example.org/artifact-series/01a05…
https://registry.example.org/type/shacl-validation-report
https://registry.example.org/keyword/rdf-graphs
Ids are UUIDv7, so they sort by creation time. Minted vocabulary terms get a slug instead of a UUID, because a term's identifier is read by people.
Representations
The same IRI serves six representations. Ask by Accept header, or append an extension — they
return the same bytes.
| Extension | Accept | What it is |
|---|---|---|
.ttl | text/turtle | Turtle. What the record natively is. |
.jsonld | application/ld+json | JSON-LD. |
.nq | application/n-quads | N-Quads, named graph included. |
.json | application/json | A flat developer JSON shape — the same body the REST API returns. |
.md | text/markdown | The record as prose. See Agent-facing surfaces. |
.html | text/html | The web application. The default for a browser. |
curl -H 'Accept: text/turtle' https://registry.example.org/software/01a05…
curl -H 'Accept: application/json' https://registry.example.org/software/01a05…
curl https://registry.example.org/software/01a05….jsonld
The Markdown is a representation, not a second copy: same graph, same code path as the Turtle. The prose cannot drift from the RDF because there is nothing for it to drift from.
Anything the registry does not route falls through to the web application, so an unknown path
returns the app shell rather than a 404 — which is worth knowing if you are writing a client
that treats HTML as an error.
Signposting
Every record response carries FAIR Signposting Link headers, so a client can discover the
alternates from any single response instead of assuming the extension convention:
Link: <…/artifact/01a05…>; rel="cite-as",
<…/artifact/01a05….ttl>; rel="describedby"; type="text/turtle",
<…/artifact/01a05….jsonld>; rel="describedby"; type="application/ld+json",
<…/artifact/01a05….md>; rel="alternate"; type="text/markdown",
<http://…/type/…>; rel="type",
<https://…/report.ttl>; rel="item"; type="text/turtle",
<https://spdx.org/licenses/CC-BY-4.0>; rel="license"
rel="item" is emitted only for bytes that actually exist. A metadata-only artifact omits it
entirely, which is how a machine distinguishes "there are no bytes here" from "there are bytes
and you need a credential" — see Availability.
Named graphs
The store is quads, not triples, and which graph a statement is in carries meaning:
| Graph | Holds | Written by |
|---|---|---|
<urn:tar:local> | Records this registry is authoritative for, including the artifact types it has minted or adopted. | The write handlers |
<urn:tar:peer:{id}> | A cached stub fetched from one peer. One graph per peer. | The peer resolver |
<urn:tar:shapes> | The SHACL shapes that validate writes. | The boot loader |
<urn:tar:bundle:vocab> | The registry's own terms and the classes a concept can carry. | The boot loader |
<urn:tar:bundle:edam> | One bundled external vocabulary. | The boot loader |
<urn:tar:bundle:euroscivoc> | The other bundled external vocabulary. | The boot loader |
<urn:tar:bundle:keywords> | The artifact keyword scheme. | The boot loader |
<urn:tar:bundles> | One node per bundle graph: its content digest, its size and when this registry last wrote it. | The boot loader |
Three families, and which one a graph is in decides who may write it.
Peer data is loaded straight into its own graph by the resolver and never passes through a write handler, which is why rules this registry enforces on its own records are not, and must not be, applied to a peer's.
The bundle graphs are reference data the binary ships: four files under shapes/ and one
table in the source. Each has its own graph and is the only writer of it, because a graph that
is dropped and reloaded from a file must contain only what the binary can reproduce. They are
also held a second time, in an in-memory store that is loaded from the same constants at every
start and never touched by anything else — that is what the write-path check "is this a term the
registry holds" reads, so a registry pointed at a remote SPARQL endpoint does not make a network
call per record written. See Graph store.
A store written before the split has a single <urn:tar:vocab> holding all of it at once. The
first boot on this version moves anything the binary cannot regenerate — a type tar seed or an
older POST /api/v1/types wrote there — into <urn:tar:local>, and drops the rest.
SPARQL
A read-only SPARQL 1.1 endpoint over all of the above is at /sparql, and it is a public read
surface in its own right rather than a debugging aid — a standard query language is most of the
registry's value to an analyst.
curl -G --data-urlencode 'query=SELECT ?s WHERE { GRAPH <urn:tar:local> { ?s a <https://w3id.org/tar/ns#Instance> } } LIMIT 10' \
-H 'Accept: application/sparql-results+json' \
https://registry.example.org/sparql
POST a query as application/sparql-query, or GET with ?query=. It is governed by
TAR_SPARQL_PUBLIC, which is independent of TAR_PUBLIC_READ: closing REST reads does not
close the query endpoint, so an operator who wants a genuinely private registry has to say so
about both.
Updates are not accepted. Writes go through the API, where SHACL validation, the vocabulary rule and the audit log live.
Getting started
From a checkout
A Rust toolchain, and Node for the UI. The container build pins the versions it uses — see
Dockerfile — and the build has no other system dependencies beyond a C toolchain and clang.
cargo build --release
cd frontend && npm install && npm run build && cd ..
export TAR_BASE_IRI=http://127.0.0.1:8080
export TAR_ROOT_TOKEN=$(openssl rand -hex 24)
export TAR_DATA_DIR=./data
./target/release/tar seed # example content, so a fresh install is not an empty page
./target/release/tar serve
Open http://127.0.0.1:8080. Everything reads anonymously; sign in with the root token to register or edit.
TAR_BASE_IRI is the only universally required setting — the registry cannot mint
dereferenceable identifiers without knowing what it is called. It refuses to start without it,
and refuses to start with a TAR_ROOT_TOKEN that is a recognisable placeholder or shorter than
16 characters.
The Makefile wraps the same commands: make build, make run, make seed, make test.
About the base IRI
It becomes part of every identifier the registry mints, permanently. Changing it later does not rewrite the records that already exist, so a registry that is going to be reachable at a real hostname should be told that hostname before it is seeded, not after.
For a local trial http://127.0.0.1:8080 is fine, and the identifiers it mints are honestly
local ones.
The seed
tar seed loads a small worked example so that the first page is not empty: 4 pieces of
software, 5 deployments, 12 runs, 19 artifacts, and 16 registry-minted artifact types,
including one artifact derived from a record at another registry so that the cross-registry
case is visible from the start.
It is example content, not a fixture anything depends on. --with-runs=false loads the
catalogue without the run and artifact graph.
With Docker
export TAR_ROOT_TOKEN=$(openssl rand -hex 24)
docker compose up --build
TAR_ROOT_TOKEN has no default and compose will refuse to start without it, which is
deliberate — a bootstrap credential everyone's install shares is not a credential.
One service, one volume. TAR_BASE_IRI defaults to http://localhost:8080; set it to whatever
the registry will actually be reachable at.
First steps once it is running
# What is this registry, and how do I talk to it?
curl http://127.0.0.1:8080/.well-known/tar-registry
# What does my credential actually let me do?
curl -H "Authorization: Bearer $TAR_ROOT_TOKEN" http://127.0.0.1:8080/api/v1/whoami
# Everything in it, as prose
curl http://127.0.0.1:8080/llms.txt
# What can produce a validation report?
curl 'http://127.0.0.1:8080/api/v1/capabilities?produces=http://127.0.0.1:8080/type/shacl-validation-report'
Then:
- Registering software, if you are filling in a catalogue.
- How a tool authenticates and Advertising runs and artifacts, if you are wiring up a tool.
- Identity provider setup, for sign-in.
Trying sign-in locally
An importable identity-provider realm — three roles, a PKCE public client, a service-account
client, and users with known passwords — lives in deploy/keycloak/. See Identity provider
setup, which also covers the audience mapper that is the one
thing that must line up.
Running the tests
cargo test # unit, end-to-end, MCP and subscription suites
cd frontend && npm test # component, parsing and screen tests
Conventions
Everything in this section is under /api/v1 on the registry's own origin. The registry
describes itself at GET /.well-known/tar-registry, which is the first thing a client should
read: it reports the API base, whether reads are public, which authentication methods are
configured, the SPARQL endpoint, the llms.txt location and the peers it federates with.
curl https://registry.example.org/.well-known/tar-registry
Authentication
A credential is a bearer token, whatever kind it is:
Authorization: Bearer <token>
Reads are anonymous by default (TAR_PUBLIC_READ). Writes always need a credential. Which
credential to use, and how a deployment gets one, is How a tool
authenticates.
GET /api/v1/whoami reports what a credential resolved to — the principal, its roles, its
scopes and the deployment it acts as, if any. It is the first thing to call when a job gets a
403.
Roles and scopes
Two orthogonal things. Roles come from a person's identity provider token, or from the root token; scopes are carried by registry-minted API tokens and bound the deployment credentials.
| Role | May |
|---|---|
reader | read |
curator | register and edit software, deployments, releases and vocabulary terms |
admin | everything, including peers and token administration |
| Scope | Permits |
|---|---|
advertise:produce | advertise artifacts a run produced |
advertise:consume | advertise artifacts a run consumed |
register:software | register and update software |
register:instance | register deployments |
read:private | read records that are not publicly readable |
admin:* | everything |
Errors
Every error path returns RFC 9457 application/problem+json:
{
"type": "https://w3id.org/tar/problem/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "credential lacks the advertise:produce scope (has: advertise:consume)"
}
| Status | type suffix | When |
|---|---|---|
400 | bad-request | Malformed request. |
401 | unauthorized | No credential, or one that did not verify. WWW-Authenticate: Bearer is set. |
403 | forbidden | A valid credential lacking the role or scope. Retrying with different arguments will not help. |
404 | not-found | No such record. |
409 | conflict | A uniqueness rule was violated. |
410 | tombstoned | The record was withdrawn. It still resolves and says so. |
422 | shacl-validation-failed | The write is well-formed JSON but not a record the registry accepts. |
502 | upstream-failed | A peer or repository the registry had to reach did not answer. |
The 422, specifically
A rejected write carries the SHACL engine's own report alongside the problem document:
{
"type": "https://w3id.org/tar/problem/shacl-validation-failed",
"title": "Write rejected by SHACL validation",
"status": 422,
"detail": "kind: value must be one of service, library, cli, desktop, workflow",
"report": "@prefix sh: … a sh:ValidationReport ; sh:result [ … ] .",
"report_media_type": "text/turtle"
}
Each result in the report carries a tar:jsonField naming the JSON field that caused it, so a
form can attach the message to the input that produced it without parsing sh:resultPath back
into a field name.
The vocabulary rule reports through the same report, deliberately. A caller has one error shape to handle, not two — see Artifact types and topics.
Listing, filtering and pagination
List endpoints return:
{ "items": [ … ], "total": 42, "next_cursor": "https://registry.example.org/software/01a05…", "facets": [ … ] }
Pagination is keyset, not offset. Pass the next_cursor you were given back as ?cursor=
to get the following page; a null cursor means the end. The cursor is a record IRI, and the
ordering is descending IRI string — which is newest-first within one registry, because ids are
UUIDv7 and sort by mint time. ?limit= defaults to 25 and is clamped to 200.
facets accompanies the software listing with the value counts a filter UI needs, so a client
does not have to fetch the whole catalogue to know what is worth filtering on. Other listings
omit it.
Idempotency
The advertisement endpoints are idempotent on (run, artifact, role), keyed by the run's
external_key. A retried CI step does not duplicate lineage. See Advertising runs and
artifacts.
Request size
Bodies are capped by TAR_MAX_PAYLOAD_BYTES, default 2 MiB. Software records carry whole
READMEs, so this is worth raising if you are importing large ones.
Audit
Every write is recorded. GET /api/v1/audit returns the log, for admins.
How a tool authenticates
Three credential types, one rule:
A deployment may only advertise runs in which it is itself the agent, and which deployment that is comes from the credential, never from the payload.
Naming a different deployment in a request body is a 403, not a hint. This is the whole
reason the deployment layer exists — see The model.
GET /api/v1/whoami tells you what a credential actually resolved to, and it is the first
thing to call when something returns 403:
{
"authenticated": true,
"credential": "oidc-workload",
"subject": "validator-prod",
"instance": "https://registry.example.org/instance/01a05…",
"may_register_deployments_of": null,
"issuer": "https://sso.example.org/realms/main",
"scopes": ["advertise:produce", "advertise:consume"],
"roles": [],
"is_curator": false,
"is_admin": false
}
1. OIDC workload identity — prefer this
Give the deployment a client in the identity provider you already run, and tell the registry which client that is.
curl -X PATCH -H "Authorization: Bearer $ADMIN" -H 'content-type: application/json' \
-d '{"oidc_client_id":"validator-prod"}' \
https://registry.example.org/api/v1/instances/01a05…
The tool then fetches its own short-lived token and presents it:
TOKEN=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" -d grant_type=client_credentials \
"$ISSUER/protocol/openid-connect/token" | jq -r .access_token)
curl -H "Authorization: Bearer $TOKEN" … https://registry.example.org/api/v1/advertise/produced
No secret for that deployment is ever stored in the registry. Rotation, expiry and revocation belong to the identity provider, which is where an organisation already manages them. That is the whole argument for preferring this.
The registry verifies the signature against the issuer's JWKS, checks the audience, and maps
the claim named by TAR_OIDC_CLIENT_CLAIM (default azp) to a deployment that declares it.
The same path takes Kubernetes and CI tokens
A projected Kubernetes ServiceAccount token and a GitHub Actions OIDC token are ordinary OIDC
tokens from other issuers. List those issuers in TAR_WORKLOAD_ISSUERS and put the subject in
oidc_client_id:
tar:oidcClientId "repo:your-org/your-tool:ref:refs/heads/main"
tar:oidcClientId "system:serviceaccount:tools:validator"
A CI job then advertises what it produced with no stored secret at all.
Workload issuers assert identity, never authority
Only TAR_OIDC_ISSUER may assert the reader / curator / admin roles. An issuer listed in
TAR_WORKLOAD_ISSUERS is trusted to say which deployment is calling and nothing else.
This distinction is load-bearing rather than fussy: a partner's identity provider, a Kubernetes
API server and a CI provider can all mint a token containing a realm role called admin, and
honouring it would hand them the registry.
2. Registry API tokens — the fallback
A registry with no identity provider still has to work. Tokens are Argon2id-hashed, scoped, revocable, optionally expiring, and shown exactly once.
curl -X POST -H "Authorization: Bearer $ADMIN" -H 'content-type: application/json' \
-d '{"scopes":["advertise:produce","advertise:consume"],"label":"ci","expires_in":"90d"}' \
https://registry.example.org/api/v1/instances/01a05…/tokens
The token is minted for one deployment, so the identity rule holds the same way it does for a workload token: the deployment comes from the credential.
There is a second flavour, minted for a software rather than a deployment, which is what self-registration uses — see Registering a deployment.
GET the same path to list a deployment's tokens; DELETE /api/v1/instances/{id}/tokens/{token_id} revokes one.
When to use which
| You run an identity provider, and the tool can reach it | Workload identity. |
| The tool runs in Kubernetes or in CI | Workload identity, using that platform's issuer. |
| There is no identity provider, or the tool cannot reach one | A registry API token. |
| Many short-lived deployments of one program | A software token and self-registration. |
A registry token is not a worse credential in kind — it is a credential whose lifecycle you have to manage yourself, in the registry, rather than in the system that already does that job.
3. People
Browser sign-in is OIDC authorisation code + PKCE against TAR_OIDC_ISSUER. Roles come from
the token, read from TAR_OIDC_ROLES_CLAIM (default realm_access.roles).
When no issuer is configured the UI hides sign-in altogether and falls back to pasting a registry token, so a registry with no identity provider is still administrable.
Setting up a provider, including the audience mapper that catches everybody out, is Identity provider setup.
Token refresh
The access token is renewed silently, twice over: from a timer set from its own exp claim,
ahead of expiry, and reactively when a request comes back 401 — the backstop for a timer that
did not fire on time, such as a laptop that slept through it. Several requests that expire
together share one renewal, which matters once a provider rotates the refresh token: without
it, the first renewal would invalidate the token the others are about to present.
The refresh token is held in memory and nowhere else — not sessionStorage, not
localStorage, not a cookie. It is the long-lived half of the credential, so a closed tab or a
reload drops it with nothing left behind for the next person on a shared machine to find. The
access token in sessionStorage keeps working until it expires; after that, sign-in is needed
again. A pasted registry API token has no refresh token, so nothing changes for it.
The bootstrap token
TAR_ROOT_TOKEN is an admin credential for getting a fresh registry to the point where real
credentials exist. The registry refuses to start if it is a recognisable placeholder or shorter
than 16 characters, because a bootstrap credential that everybody has the same value for is not
a credential.
Use it to seed the catalogue and mint the first real credentials, then configure an identity provider and stop using it.
Registering software
A software record describes the abstract program. Registering one needs the curator role or
the register:software scope.
curl -X POST -H "Authorization: Bearer $CURATOR" -H 'content-type: application/json' \
-d '{
"name": "example-validator",
"tagline": "Validates RDF graphs against shapes.",
"description": "…",
"homepage": "https://example.org/validator",
"code_repository": "https://github.com/your-org/example-validator",
"documentation": "https://example.org/validator/docs",
"license": "https://spdx.org/licenses/Apache-2.0",
"kinds": ["service", "cli"],
"maturity": "active",
"deployable": true,
"topics": ["https://registry.example.org/… (from vocab search)"],
"keywords": ["SHACL"],
"publisher": {"name": "Your Organisation", "kind": "organization",
"homepage": "https://example.org"},
"contact": {"name": "A Maintainer", "kind": "person",
"email": "maintainer@example.org"}
}' \
https://registry.example.org/api/v1/software
PATCH /api/v1/software/{id} edits it — sending only the fields you mean to change. DELETE
tombstones it.
The fields worth thinking about
kinds is a set, from service, library, cli, desktop, workflow. One program is
routinely several of these, and forcing a single choice makes the catalogue lie about the
common case. There is a singular kind too, which is the first of kinds; it exists for older
clients and you should send kinds.
deployable says whether it makes sense for this software to have an endpoint at all. Set
it false for a library or a desktop application, and the registry stops asking for a URL that
does not exist — and refuses a deployment of it that carries one.
license is an SPDX IRI, not a string. https://spdx.org/licenses/MIT, not MIT. An
absent licence is rendered honestly as "licence not stated", which is strictly better than a
plausible guess; leave it out if the project does not state one.
topics are controlled. Look them up with GET /api/v1/vocab/search?branch=topic&q=… and
use the IRI verbatim; a topic the registry cannot resolve is a 422. See
Artifact types and topics.
readme carries the project's README so the detail page can render it. It is often the
largest thing in the record — if you are importing many, TAR_MAX_PAYLOAD_BYTES is the setting
that will bite first. readme_base_url is what relative image and link paths in it resolve
against.
api_docs is a list of {url, format, title, description}, where format is one of
openapi, asyncapi, graphql, sparql-service-description, ols4, postman, other. The
registry can fetch and render one at GET /api/v1/software/{id}/api-doc.
registration_clients lists the OIDC client ids allowed to self-register deployments of
this software, and registration_issuer names the identity provider those ids belong to.
See Registering a deployment.
Releases
A release is a versioned, runnable plan.
curl -X POST -H "Authorization: Bearer $CURATOR" -H 'content-type: application/json' \
-d '{
"version": "2.1.0",
"date_published": "2026-08-30",
"container_image": "ghcr.io/your-org/example-validator:2.1.0",
"image_digest": "sha256:ab12…",
"install_command": "pipx install example-validator==2.1.0",
"changelog": "https://example.org/validator/changelog#2.1.0",
"downloads": [{"url": "https://example.org/…/validator-2.1.0-linux-x86_64.tar.gz",
"platform": "linux-x86_64", "byte_size": 8412233,
"availability": "public"}]
}' \
https://registry.example.org/api/v1/software/$SOFTWARE_ID/releases
GET /api/v1/software/{id}/releases
POST /api/v1/software/{id}/releases
DELETE /api/v1/software/{id}/releases/{release_id}
A release may carry its own capability, which is how a capability that changed between
versions is recorded truthfully rather than by overwriting the software's.
Declaring a capability
What the software is able to produce and consume, as artifact type IRIs:
curl -X PUT -H "Authorization: Bearer $CURATOR" -H 'content-type: application/json' \
-d '{"produces": ["https://registry.example.org/type/shacl-validation-report"],
"consumes": ["https://registry.example.org/type/rdf-graph",
"https://registry.example.org/type/shacl-shapes-graph"]}' \
https://registry.example.org/api/v1/software/$SOFTWARE_ID/capability
PUT /api/v1/instances/{id}/capability does the same for one deployment, when a particular
installation can do more or less than the software in general.
This is what makes matchmaking work on a registry with no runs in it. See Searching and matchmaking.
Every IRI here is held to the vocabulary rule.
Keeping a record in step with its repository
A software record may name a source repository the registry will re-read.
curl -X PATCH -H "Authorization: Bearer $CURATOR" -H 'content-type: application/json' \
-d '{"sync": {"source": "github", "repo": "your-org/example-validator",
"fields": ["tagline", "description", "readme", "license", "maturity"],
"enabled": true}}' \
https://registry.example.org/api/v1/software/01a05…
curl -X POST -H "Authorization: Bearer $CURATOR" \
https://registry.example.org/api/v1/software/01a05…/sync
Sync overwrites only the fields the record named as managed. Everything else belongs to whoever curated it and is left alone, even when the repository has an obvious value for it.
That constraint is the whole design. A sync that helpfully refreshed everything would silently discard the sentence a curator wrote because it was better than the repository's one-liner, and the loss would be invisible until somebody noticed the page had got worse. Naming the managed fields makes the trade explicit at the moment somebody opts in.
The record reports what the last run changed, in sync.last_changed, alongside
last_synced_at, last_status and last_error — so a sync that has been quietly broken for a
month is visible on the page rather than inferred from staleness.
Which fields may be managed is a fixed set; list_enumerations on the MCP server
reports it, as does the UI.
Credentials for a private repository
A public repository needs none. A private one needs a token, and there are two ways to get one, in order of preference:
- the signed-in curator's own forge token, brokered by the identity provider — then the registry reads exactly what that person can read, and nothing more;
TAR_FORGE_TOKEN, a registry-wide token. Simpler, and it means every curator can pull anything that token can see.
Reading and filtering
GET /api/v1/software ?q= ?kind= ?topic= ?keyword= ?license= ?publisher= ?produces= ?consumes= ?registry=
GET /api/v1/software/{id}
GET /api/v1/software/{id}/api-doc
GET /api/v1/software/{id}/export/biotools
The listing returns facets alongside the items — value counts for licence, kind and topic —
so a filter UI does not have to fetch the catalogue to know what is worth offering.
export/biotools projects a record into the bio.tools interchange shape, for estates that
already publish there.
Registering a deployment
A deployment record says that some release of some software is installed somewhere, and it is
the thing a credential identifies. Deployments arrive in two very different ways, and either
way may be driven by either kind of credential — so there are four combinations, and the UI
prints the exact commands for all four on a software's Create deployment page
(/software/{id}/deploy), built from that registry's own base IRI rather than a placeholder.
Curated
Somebody who knows the estate creates the record. Right when deployments are few and long-lived.
curl -X POST -H "Authorization: Bearer $CURATOR" -H 'content-type: application/json' \
-d '{
"label": "validator, production",
"software": "01a05…",
"release": "01a06…",
"endpoint_url": "https://validator.example.org",
"health_endpoint": "https://validator.example.org/healthz",
"operator": {"name": "Platform team", "kind": "organization"},
"availability": "restricted",
"jurisdiction": "NL",
"oidc_client_id": "validator-prod",
"allowed_scopes": ["advertise:produce", "advertise:consume"]
}' \
https://registry.example.org/api/v1/instances
PATCH /api/v1/instances/{id} edits it; DELETE tombstones it.
Needs the curator role or the register:instance scope.
health_endpoint
A URL whose only job is to say the deployment is alive, held to a 2xx. Leave it out and the
endpoint_url itself is probed, where anything that answers counts as up — because a great
many healthy services answer 401 or 404 at their root, and marking those down would be a
false alarm about a working deployment. Give a real health endpoint if you have one; the check
is only as good as what it is pointed at.
Software that cannot be deployed
If the software is marked deployable: false — a library, a desktop application — a deployment
of it may not carry an endpoint, and one that does is refused. The record is still useful: it
says the thing is installed here, and it can still advertise runs.
Self-registering
The application is handed one credential and every deployment of it creates and maintains its own record. Right when deployments are many, short-lived, or created by something other than a person.
A curator issues the key once, for the software rather than for a deployment:
curl -X POST -H "Authorization: Bearer $CURATOR" -H 'content-type: application/json' \
-d '{"label":"self-registration","scopes":["register:instance","advertise:produce"]}' \
https://registry.example.org/api/v1/software/$SOFTWARE_ID/tokens
Every deployment then announces itself, and repeats this whenever anything changes:
curl -X PUT -H "Authorization: Bearer $APP_KEY" -H 'content-type: application/json' \
-d '{"label": "validator on prod", "instance_key": "prod-cluster",
"endpoint_url": "https://validator.example.org",
"health_endpoint": "https://validator.example.org/healthz",
"version": "2.1.0"}' \
https://registry.example.org/api/v1/instances/self
The first call creates the record; every call after it updates that same one. It is a PUT
because it is meant to be run unconditionally at startup — announcing is not something a
deployment should have to remember whether it has already done.
instance_key
What tells two deployments sharing one credential apart. Without it, one key would mean one deployment, and a second replica would overwrite the first one's record instead of making its own. Anything stable per deployment works: a cluster name, a hostname, a pod's stable identifier.
The credential decides which software
Not the payload. A credential bound to one application cannot register a deployment of another
by naming it in the body — that is a 403. GET /api/v1/whoami reports the binding as
may_register_deployments_of.
The four combinations
| Curated | Self-registering | |
|---|---|---|
| Registry API token | An admin creates the record and mints a per-deployment token at POST /api/v1/instances/{id}/tokens. | A curator mints a per-software token at POST /api/v1/software/{id}/tokens; every deployment PUTs to /instances/self. |
| Identity provider | An admin creates the record with oidc_client_id set to the deployment's client. | The software lists the client ids in registration_clients; a deployment presenting a token from its own issuer registers itself. |
With an identity provider in either row there is no key to issue, store or leak at all, which is the argument for it.
registration_clients
A field on the software record listing the OIDC client ids allowed to self-register deployments of it:
curl -X PATCH -H "Authorization: Bearer $CURATOR" -H 'content-type: application/json' \
-d '{"registration_clients":["validator-prod","validator-staging"]}' \
https://registry.example.org/api/v1/software/01a05…
A credential authorised this way is shared by every deployment of the application, so the
registry deliberately does not write that client id onto the deployment record — doing so
would make the next replica authenticate as this one. The credential is remembered as
self_registered_by, which is what finds the right record on the next announcement.
registration_issuer
A client id is only unique within an identity provider: validator-prod at your Keycloak and
validator-prod at a partner's are two different principals that spell their name the same
way, and registering a client under a given name is free at every issuer. So on a registry that
accepts more than one issuer — TAR_OIDC_ISSUER plus anything in TAR_WORKLOAD_ISSUERS, such
as a Kubernetes API server or GitHub Actions — the client id alone does not say who may
register.
registration_issuer names the provider the ids in registration_clients belong to:
curl -X PATCH -H "Authorization: Bearer $CURATOR" -H 'content-type: application/json' \
-d '{"registration_issuer":"https://keycloak.example.org/realms/ids"}' \
https://registry.example.org/api/v1/software/01a05…
Leave it unset and the registry reads the clients against its primary issuer, or against
the sole workload issuer when that is the only one configured. Those are the cases with one
obvious answer. When several issuers are accepted and none is primary there is no honest
default — picking one would hand the weakest accepted issuer the authority meant for the
strongest — so the registry refuses to create the record until the issuer is named, and says so
at POST/PATCH rather than as a 403 the first time the workload calls.
The same rule governs the other two bindings: an Instance's oidc_issuer beside its
oidc_client_id, and self_registered_issuer, which the registry records itself when a
deployment self-registers so that later announcements from a same-named client at a different
issuer do not land on the record.
The looser third option
TAR_OIDC_AUTO_REGISTER_INSTANCES lets any accepted credential name its own software and
register a deployment of it. Convenient in a trusted cluster, and much weaker: it is the
operator saying that every credential this registry accepts may add records. Off by default.
When it is off and an unrecognised workload announces itself, the 403 names all three ways
out rather than just refusing — create the deployment with its oidc_client_id, issue a
software token, or add the client to registration_clients.
Reading deployments
GET /api/v1/instances list, with ?software= ?health= ?availability=
GET /api/v1/instances/{id} one record
GET /api/v1/instances/{id}/runs its runs
GET /api/v1/instances/{id}/artifacts the artifacts its runs touched
PUT /api/v1/instances/{id}/capability declare what it can produce and consume
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 late, not early
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
Searching and matchmaking
Three different questions, three different endpoints. It is worth knowing which one you have.
| Question | Endpoint |
|---|---|
| I know roughly what it is called. | /api/v1/search |
| What can produce or consume this kind of artifact? | /api/v1/capabilities |
| Where did this artifact come from, and who used it? | /api/v1/artifacts/{id}/lineage, /api/v1/graph |
Free-text search
curl 'https://registry.example.org/api/v1/search?q=validation&type=software&limit=30'
| Parameter | |
|---|---|
q | Required in practice — an empty q returns nothing rather than everything. |
type | software, instance, artifact or run. Omit for all four. |
limit | Default 30, clamped 1–100. |
federated | true to ask this registry's peers too. See Federation. |
It matches the fields a person would search on: a software's name, tagline and abstract; a deployment's label and description; an artifact's title and description; a run's label and identifier.
There are also fed_* parameters in the query string. Those belong to the propagation envelope
registries use when they ask each other, not to you.
Matchmaking
This is the question a catalogue exists to answer, and it works on a registry with no runs in it.
curl 'https://registry.example.org/api/v1/capabilities?produces=https://registry.example.org/type/shacl-validation-report'
produces, consumes, or both; at least one is required, and each is an artifact type IRI.
The answer is the software, releases and deployments that have declared they can do it.
Declared capability is a claim, and the registry says so rather than dressing it up. It is the claim you need when you are choosing a tool, before there is any run history to go on.
The results are capped at 200 and there is no pagination on this endpoint. If matchmaking returns more than 200 candidates the question was probably too broad.
For the corresponding filter on the software listing, /api/v1/software?produces=… takes the
same IRIs.
Lineage
curl 'https://registry.example.org/api/v1/artifacts/01a05…/lineage?direction=both&depth=3'
direction is up (what this came from), down (what came from this) or both, default
both. depth is clamped 1–6, default 1.
GET /api/v1/graph?iri=…&depth= returns the same subgraph centred on any node, with depth
clamped 1–4. Both return exactly what a graph view would need; there is no graph visualisation
in the UI yet.
SPARQL, when the question does not fit
The three endpoints above cover the questions worth having a route for. Anything else — which deployments in this jurisdiction have produced nothing in six months? — is a SPARQL query, and the endpoint is public by default:
curl -G --data-urlencode 'query=…' \
-H 'Accept: application/sparql-results+json' \
https://registry.example.org/sparql
See Identifiers and representations.
Subscriptions
A subscription says: tell me when an artifact matching this appears. It belongs to a deployment, which is what makes it a tool-to-tool mechanism rather than a notification feature — the deployment that wants to know is the one that will act on it.
GET /api/v1/instances/{id}/subscriptions
POST /api/v1/instances/{id}/subscriptions
GET /api/v1/subscriptions/{sid}
PATCH /api/v1/subscriptions/{sid}
DELETE /api/v1/subscriptions/{sid}
GET /api/v1/subscriptions/{sid}/deliveries
POST /api/v1/subscriptions/{sid}/deliveries/ack
Managing a subscription needs admin, curator, or the credential of the deployment that owns
it. A mismatched subscription id returns 403 rather than 404, so the endpoint cannot be used
to enumerate what exists.
Creating one
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{
"label": "shapes graphs to validate against",
"filter": {
"conforms_to": ["https://registry.example.org/type/shacl-shapes-graph"],
"availability": ["public", "restricted"],
"roles": ["produced"],
"exclude_own": true
},
"webhook_url": "https://validator.example.org/hooks/tar"
}' \
https://registry.example.org/api/v1/instances/$INSTANCE_ID/subscriptions
Omit webhook_url and the subscription is pull-only. That is the whole difference between the
two modes; there is one queue underneath.
The filter
Every field is a list, and they combine OR within a field, AND across fields. An empty filter matches everything.
| Field | Matches |
|---|---|
conforms_to | Artifact type IRIs. |
software | Any deployment of that software. |
instance | One named deployment. |
keywords | dcat:keyword, case-insensitively. |
license | SPDX IRIs, exactly. An artifact with no licence never matches a non-empty licence filter. |
availability | public, restricted, embargoed, metadata-only. |
q | Substring of title or description, case-insensitively. |
roles | produced, consumed. Empty means produced only. |
exclude_own | Default true — do not notify a deployment about its own output. |
Registry-minted software and deployment ids may be sent bare and are expanded server-side. Type and licence IRIs must be full IRIs, because the registry has no basis for guessing what a bare one meant.
exclude_own defaults to true because the overwhelmingly common subscription is "tell me what
somebody else made", and a tool woken by its own output is a loop.
Filter on the type, not the keyword
A subscription written against a keyword is a subscription written against a spelling. This is the case where the vocabulary rules earn their keep: a subscription that never fires is indistinguishable from a quiet week, so nobody notices it is broken.
Webhook delivery
The registry POSTs to webhook_url:
POST https://validator.example.org/hooks/tar
x-tar-delivery: 01a05…
x-tar-subscription: 01a05…
x-tar-timestamp: 1756567331
x-tar-attempt: 1
x-tar-signature: sha256=<hex>
{
"type": "artifact.advertised",
"subscription": "…", "registry": "…",
"role": "produced",
"run": "…", "instance": "…", "software": "…",
"artifact_iri": "…",
"artifact": { … }
}
artifact is exactly what an anonymous GET /api/v1/artifacts/{id} returns, so a receiver does
not have to call back for the ordinary case.
Verifying the signature
x-tar-signature is sha256= followed by the hex HMAC-SHA256 of "{timestamp}.{body}" under
the subscription's secret. Supply webhook_secret when you create the subscription, or let the
registry generate one — it is returned once.
Include the timestamp in what you verify, and reject old ones. Signing the body alone would let anyone who saw one delivery replay it forever.
What the registry will not deliver to
HTTPS only unless TAR_SUBSCRIPTION_ALLOW_HTTP is set, and never to a private, loopback or
link-local address unless TAR_SUBSCRIPTION_ALLOW_PRIVATE_TARGETS is set. Redirects are not
followed.
A webhook URL is chosen by whoever registers the subscription and can point anywhere, so refusing private targets is what stops the registry being used to reach inside a network on somebody's behalf. The address is checked at registration and re-resolved at send time, and the delivery then connects to exactly the address that check approved — the name is not looked up again, so a record that changes in between cannot redirect the connection. The certificate is still verified against the hostname.
Note the contrast with health probing, which allows private addresses by default. The two look alike and are not: a deployment's endpoint is an address in your own estate, and for an internal registry it is normally private.
Retries
| Default | ||
|---|---|---|
TAR_SUBSCRIPTION_MAX_ATTEMPTS | 8 | Attempts before one delivery is marked dead. |
TAR_SUBSCRIPTION_SUSPEND_AFTER | 12 | Consecutive failures before the subscription's webhook is suspended. |
TAR_SUBSCRIPTION_BACKOFF_BASE | 30s | |
TAR_SUBSCRIPTION_BACKOFF_MAX | 6h | |
TAR_SUBSCRIPTION_TIMEOUT | 5s | Per attempt, capped at 30s. |
TAR_SUBSCRIPTION_TICK | 5s | Worker poll interval. |
TAR_SUBSCRIPTION_BATCH | 20 | Deliveries attempted per tick. |
TAR_SUBSCRIPTION_WEBHOOKS | true | The delivery worker at all. |
Backoff is base × 2^(attempts−1), capped at max.
Suspension stops webhook attempts; the pull path keeps working, so a subscription whose
receiver was down for a day is not a subscription that lost its data. PATCH {"resume": true}
un-suspends it and re-arms failed and dead deliveries for another attempt.
Pull delivery
For a tool that cannot accept an inbound connection — behind a firewall, on a laptop, running only during a job.
curl -H "Authorization: Bearer $TOKEN" \
'https://registry.example.org/api/v1/subscriptions/01a05…/deliveries?limit=50'
Returns the queued deliveries plus next_cursor and remaining. The cursor is a monotonic
sequence number, not an IRI. limit defaults to 25 and is clamped 1–200.
Acknowledge either by passing ack=true on the read — which acknowledges everything in that
response — or afterwards:
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"cursor": 4711}' \
https://registry.example.org/api/v1/subscriptions/01a05…/deliveries/ack
The acknowledged cursor only ever advances. Acknowledging an older value is a no-op rather than a rewind, so a slow consumer racing itself cannot replay what it has already handled.
Omit the cursor on a read and you resume from wherever you last acknowledged.
ack=true is the convenient form and the lossy one: if your process dies between receiving the
response and acting on it, that work is gone. Acknowledge separately if the work matters.
Federation
Two registries federate by pointing at each other, not by copying each other.
That choice runs through everything here. A harvest gives you a second copy of somebody else's catalogue that is wrong as soon as they change it, and it makes you responsible for data you did not curate. A cross-link gives you their identifier, a cached stub so the page renders, and a clear statement of whose record it is.
Peers
GET /api/v1/peers list admin
POST /api/v1/peers add one admin
GET /api/v1/peers/suggested the review queue admin
DELETE /api/v1/peers/{id} remove one admin
POST /api/v1/peers/announce inbound, unauthenticated
GET /api/v1/resolve dereference a foreign IRI
Adding one:
curl -X POST -H "Authorization: Bearer $ADMIN" -H 'content-type: application/json' \
-d '{"base_url": "https://peer-registry.example.net", "preview": false, "announce": true}' \
https://registry.example.org/api/v1/peers
The registry fetches the peer's /.well-known/tar-registry, and refuses if the base IRI the
peer advertises does not match the URL you gave. A registry that calls itself something else
is either misconfigured or not the registry you think you are pointing at, and cross-linking to
it would mint links that do not resolve.
preview: true does the fetch and the validation and stores nothing — worth doing first.
announce: true, the default, calls the peer's own /api/v1/peers/announce so the relationship
can become mutual.
Suggestions are not peers
Adding a peer also records everyone they federate with as suggested, and the inbound
/api/v1/peers/announce records its caller the same way. Neither ever becomes an active peer on
its own.
announce is deliberately unauthenticated, because it grants nothing: it puts a URL in a queue
for an administrator to look at. Trust is not transitive, and an endpoint that made it transitive
would be an endpoint that lets a stranger join your federation by asking.
Foreign IRIs in your own records
Any object position may hold a foreign IRI — was_derived_from pointing at an artifact at
another registry, most commonly.
Advertising never blocks on the network. An unknown IRI is stored verbatim, the write succeeds, and a background worker fetches a stub afterwards. A peer being slow or down must never be able to fail somebody else's CI job.
The stub lands in a named graph of that peer's own, <urn:tar:peer:{id}>, and is never merged
into local data. That separation is what lets the registry apply its own rules to its own
records and not to a peer's: peer data does not pass through a write handler at all, so the
vocabulary rule never sees it.
A resolved stub is cached for TAR_PEER_RESOLVE_TTL, default 24 hours. GET /api/v1/resolve?iri=… dereferences one on demand, and &refresh=true forces a re-fetch. The
background resolver ticks every 30 seconds and backs off on failure.
An unresolved record renders as the bare IRI marked "not resolved yet", with its origin chip — never as a skeleton, because a skeleton promises content that may never arrive.
Federated search
GET /api/v1/search?q=…&federated=true fans out to peers live. Nothing is pre-fetched, so the
results are as current as the peers are.
Only search fans out. Capability matchmaking and the graph endpoint are local-only.
Live fan-out across a graph of registries is a loop waiting to happen, so there are three independent brakes and a time budget:
- Query identity. Each query carries an id; a registry that has already handled it answers
already_handled: truewith a200. Not an empty result and not an error — a peer must be able to tell "I have already answered this" from "I found nothing". - A hop budget, decremented at each hop and always reduced to the minimum of what was granted and this registry's own maximum. A peer cannot spend more of your budget than you have.
- A path check. The query carries the registries it has visited, and a registry never asks a peer already on the path, nor the origin, nor itself.
The time budget is passed down, clamped to this registry's own total timeout, with a margin held back before forwarding so that a hop still has time to answer after its own children do.
| Default | |
|---|---|
TAR_FEDERATED_SEARCH_MAX_HOPS | 3 (max 8) |
TAR_FEDERATED_SEARCH_TIMEOUT | 3s — per peer |
TAR_FEDERATED_SEARCH_TOTAL_TIMEOUT | 10s (max 60s) |
TAR_FEDERATED_SEARCH_HOP_MARGIN | 600ms |
TAR_FEDERATED_SEARCH_MAX_PEERS | 12 (max 64) |
TAR_FEDERATED_SEARCH_MAX_PEER_HITS | 100 (max 1000) |
TAR_FEDERATED_SEARCH_MAX_PEER_BYTES | 2 MiB (max 32 MiB) |
TAR_FEDERATED_SEARCH_MAX_PEER_STATUSES | 32 (max 256) |
TAR_FEDERATED_SEARCH_MAX_TOTAL_HITS | 500 (max 5000) |
TAR_FEDERATED_SEARCH_ID_TTL | 10m |
Every response says which peers answered, which timed out and which failed. A federated search where half the federation was down should not look like a federated search that found half as much.
Results are not deduplicated across peers beyond the origin chip, and the keyset ordering interleaves imperfectly across origins. Both are in Limitations.
What federation does not give you
- Not authorisation. A peer's records are as public as that peer makes them. Federating does not grant you access to anything.
- Not agreement on vocabulary. Two registries that each minted their own term for the same thing still have two terms. Adopting is the mechanism that fixes that, and it is a decision a curator makes, not something federation does for you.
- Not availability. If a peer is down, its records are unresolved. The registry says so rather than hiding it.
Artifact types and topics
Two fields are controlled, and for the same reason.
- An artifact's type —
conforms_to, andproduces/consumeson a capability — says what the artifact is. - A software's topics say what it is about.
Both must be terms the registry actually holds. A write naming an IRI it cannot resolve is refused before anything is written.
Why this is not free text
A keyword is a label, and a wrong one costs a little. A type is what every capability query and every subscription filter matches on, exactly, so the same slippage costs far more.
Asked to describe one validation report, one caller writes …/type/shacl-report, another
…/type/shacl-validation-report, and a third assembles a plausible-looking ontology number
from memory. All three records look right. None of them match each other. ?conforms_to= then
answers a third of what is in the catalogue, and a subscription written against one spelling
never fires — which is indistinguishable from a subscription with nothing to deliver.
That last one is the real damage. A search that under-returns is at least visibly a search. A subscription that silently never fires looks exactly like a quiet week.
Where the terms come from
Two sources, and for an RDF-heavy estate the second carries most of the weight.
Bundled vocabularies
The registry ships two, generated at build time and committed so that a checkout builds with no network:
| File | Source | Holds |
|---|---|---|
shapes/edam.ttl | EDAM | 949 data-branch terms as artifact types, plus 260 topic-branch terms kept only as legacy (see below). |
shapes/euroscivoc.ttl | EuroSciVoc | 1,064 research topics. |
EDAM's data branch was chosen because it is a real, maintained, dereferenceable data-type vocabulary with synonyms and definitions, it is what a widely used tool registry types software inputs and outputs with, and it costs nothing to carry. EuroSciVoc covers what software is about rather than what data is, which is the other half.
build.rs checks upstream at most once a day, rewrites the file only when the content actually
differs, and leaves the committed bundle alone with a warning if the network or the parse
fails. TAR_UPDATE_EDAM=1 forces a check; TAR_EDAM_OFFLINE=1 skips it entirely and fails if
there is no committed bundle to fall back on.
Which vocabularies these are is a property of this build, not of the API. The API never
returns a vocabulary's name in a field — source is bundled, local or external, which is
the distinction a caller actually needs — because several vocabularies are in play and more will
follow, and a field value naming one would be wrong the moment another arrives.
Terms the registry holds itself
POST /api/v1/types. This is not a fallback path for awkward cases.
Searched for what an RDF tooling estate actually emits, a bundled life-science data vocabulary of 949 terms yields seventeen containing the word "ontology" — almost all of them identifiers of ontology concepts — and not one term for a shapes graph, a validation report, a schema, a mapping, an update, a hash-chained patch log or a masked replica. Those are exactly the things such an estate produces all day. Sixteen of the types the bundled example content registers are therefore the registry's own, and the bundled data branch is there for the artifacts that genuinely are life-science data.
This is the expected shape of the thing. A general registry will have local terms.
Adopting versus minting
Registering a term does two different jobs, and choosing the wrong one recreates exactly the problem the rule exists to prevent.
| when | what it records | |
|---|---|---|
Adopt — send iri | the term already has an identifier somewhere else | that identifier, and the scheme it came from |
Mint — omit iri | nothing anywhere names this thing | an identifier of this registry's own |
Adoption matters more than it looks. If every registry invents a local alias for a term that already has a public IRI, then federation is comparing near-synonyms again, one level up, and the problem has simply moved. Two registries that adopt the same term end up agreeing on one identifier without ever having to coordinate.
# adopt a term that already has a name elsewhere
curl -X POST -H "Authorization: Bearer $CURATOR" -H 'content-type: application/json' \
-d '{"label":"Software suite",
"iri":"http://purl.obolibrary.org/obo/SWO_0000001",
"scheme":"http://www.ebi.ac.uk/swo"}' \
https://registry.example.org/api/v1/types
# mint one for a thing nothing else names
curl -X POST -H "Authorization: Bearer $CURATOR" -H 'content-type: application/json' \
-d '{"label":"Hash-chained patch log","slug":"patch-log",
"definition":"An append-only log of RDF patches, each linked to its predecessor by hash."}' \
https://registry.example.org/api/v1/types
Accepted fields: label (required), iri, scheme, slug, definition,
default_media_type, aliases.
POST /api/v1/types needs the curator role. That is the point: it is the one place new types
enter the registry, so it is where the judgement belongs.
Searching before you write
curl 'https://registry.example.org/api/v1/vocab/search?q=validation+report&branch=data&limit=20'
| Parameter | |
|---|---|
q | Plain words. Fewer than two characters returns nothing — one character matches most of a large vocabulary. |
branch | Restrict the kind of term: data for artifact types, topic for research topics, keyword for the keyword list. Omit to search everything, including locally minted types. A fourth value exists for the legacy topics described below; it is there for completeness and there is no reason for a caller to use it. An unrecognised value returns no hits rather than an error. |
limit | Default 20, clamped 1–100. |
It matches labels, synonyms and definitions, and returns each hit's iri, label,
definition, source and a score. Use the iri verbatim.
GET /api/v1/vocab/resolve?iris=… takes a comma-separated list (up to 100) and returns labels
for IRIs you were handed by a person or found in a repository file — the cheap way to check
something before writing it into a record.
GET /api/v1/types lists what the registry holds; GET /api/v1/types/{id} is one term, and
like every other record it dereferences.
A term, and the right kind of term
Existing is not enough, and this was found rather than anticipated.
Pointed at a registry and told to classify a piece of software, a coding agent produced an IRI that did exist — it was a term for a specific laboratory technique, in a completely unrelated branch — and a plain existence check waved it onto a record that had nothing to do with it.
So every concept the registry holds carries a class saying which kind of term it is, declared as
a subclass of skos:Concept:
| Kind | Accepted in |
|---|---|
| an artifact type | conforms_to, capability.produces, capability.consumes |
| a research topic | topics |
| an artifact keyword | the keyword list; never where a type or topic is expected |
| a legacy topic | nothing |
A legacy topic is a subject area kept only so that a record already citing one still renders a label. It is never offered by a picker and never accepted on a write.
The rule is therefore not "does this term exist" but "could a search have returned this term for this field", which rejects a real term of the wrong kind as firmly as an invented one.
The class is the statement
The class is asserted in the same statement that makes the concept, and that is the point.
It was a separate literal beside the concept until the two were written by different code paths
and landed in different named graphs — the concept in one, a backfilled marker in another —
after which every query asking for both inside one GRAPH block found neither, and the
registry's own types were held, accepted on write, and offered by no picker.
A marker that has to be kept next to something drifts away from it. A marker that is the statement cannot.
The refusal
A type or topic IRI the registry cannot resolve is a 422 before anything is written, on every
path that can name one: POST /api/v1/artifacts, both /api/v1/advertise/*,
/api/v1/openlineage, both capability routes, and software, deployment and release writes —
including repository sync. The same rule runs behind the MCP tools, so an agent and a curl get
the same verdict.
422 https://example.org/type/invented is not an artifact type this registry knows. First
search for one with GET /api/v1/vocab/search?branch=data&q=… and use the `iri` it returns.
If the term is defined somewhere this registry does not carry and you have its IRI, adopt
it with POST /api/v1/types, sending that `iri`. Mint a new one with POST /api/v1/types,
without an `iri`, only when nothing anywhere names this thing.
Reuse, then adopt, then mint — in that order, and a test asserts the message keeps that order, because the order is the advice.
It travels as a sh:ValidationReport carrying tar:jsonField "conforms_to", the same shape a
shape violation uses, so an edit form highlights the offending input without learning a second
error format.
The message names no vocabulary. Several are in play and more will follow, so a refusal that named one would be wrong the moment another arrived.
In the UI the term picker closes the loop itself: type a name nothing matches and it offers to register it, paste an IRI and it offers to adopt it. Either way what leaves the picker is an IRI the registry will accept.
What was considered and not used
| Candidate | Why not |
|---|---|
| A serialisation-format vocabulary | A format is not a kind of thing. It would type a shapes graph, a validation report and an ontology identically as Turtle, and would change an artifact's type when the same bytes are re-serialised. The distribution already carries media_type, which is where serialisation belongs. |
| The DCMI Type Vocabulary | Twelve terms. Every artifact here is Dataset, so it discriminates nothing — and DCAT already types these records as dcat:Dataset anyway. |
| IANA media types | Already in use, at the distribution level, and again not a type: one media type covers a shapes graph, a report and an ontology alike. A type concept may carry a default_media_type, which is the honest relationship between the two. |
| Bundling a new vocabulary for RDF artifacts | There is no maintained public one that names a shapes graph, a mapping or a schema as data types. Inventing one and bundling it would be minting registry-local terms with extra steps and no upstream. |
Federation is untouched by this rule
A peer's record legitimately cites a type minted at that peer, and it keeps doing so. Peer data is loaded straight into that peer's own named graph by the resolver and never passes through a write handler, so nothing this registry is not authoritative for is ever held to this rule.
Once a foreign type has been resolved into a peer graph it becomes a term this registry holds, and a local record may then cite it too.
It is accepted, but not offered: it carries none of this registry's own classes, so a picker does not list it. That is a deliberate half-answer — which registry owns a term, and whether adopting a peer's implies agreeing with it, is a federation question this prototype does not settle. The picker's adopt flow is how to make such a term first-class here.
Artifact keywords
A type says what an artifact is, and is checked hard. A keyword is a label, and is checked softly — but not not at all, because free text is where a catalogue quietly stops being searchable.
One deployment writes SHACL, another shacl, a third shacl shapes, and a filter for any of
them finds a third of what is there. Worse, a subscription written against SHACL silently
misses everything advertised as shacl, and a subscription that delivers nothing looks exactly
like one with nothing to deliver.
The list
The registry keeps a short list of its own, at GET /api/v1/keywords. Seven entries, each with
a label, a slug, a definition and a set of aliases:
| Label | Slug |
|---|---|
| Embeddings | embeddings |
| OWL | owl |
| RDF Graphs | rdf-graphs |
| SHACL | shacl |
| SHEX | shex |
| Mappings | mappings |
| SPARQL query | sparql-query |
It is deliberately short. A controlled list long enough to cover everything is a list nobody reads, and the escape hatch below is what makes a short one workable.
What happens to a keyword you send
A keyword matching the list — by label, slug or alias, ignoring case and punctuation — is
stored under its preferred label, and additionally linked with dcat:theme to a concept
that dereferences.
Anything else is kept verbatim as dcat:keyword.
That is DCAT's own division between a concept drawn from a scheme and a plain literal, so nothing is invented and no existing record breaks. Free text stays allowed; it simply will not match a keyword filter or a subscription written against the list.
# five spellings in, four keywords out
curl … -d '{"artifacts":[{"keywords":["shacl","SHACL Shapes","rml","pizza-ontology","RDF"]}]}'
# → ["SHACL", "Mappings", "pizza-ontology", "RDF Graphs"]
Two of the five collapsed onto one concept, one mapped to a differently-named one, one was unrecognised and survived as written, and the case was normalised. That is the whole behaviour.
Filtering
?keyword= on /api/v1/artifacts accepts the concept IRI, the slug, the label, or any alias —
so a client that only ever saw one spelling still finds the records stored under another.
Subscription filters take keywords too, matched case-insensitively; see Subscriptions.
Why keywords are not types
They overlap in what they describe, and it is worth being explicit about why both exist.
A type answers a machine's question — can this software consume that artifact? — so it has to be exact, and the cost of getting it wrong is a query that silently under-returns. A keyword answers a person's question — what is this roughly about? — so it has to be forgiving, and the cost of getting it wrong is a slightly worse search result.
Holding keywords to the type rule would mean refusing a write because somebody described their own data in their own words. Holding types to the keyword rule would mean matchmaking that quietly does not work.
Agent-facing surfaces
Three of them, in increasing order of how much the client has to know in advance.
| For a client that | |
|---|---|
/llms.txt | has just been given a URL and knows nothing. |
| Markdown representations | can fetch a URL and read prose. |
| The MCP server | would rather call tools than compose URLs. |
None of them is a separate copy of the data. That is the constraint the whole design here is built around: a prose rendering that is generated from a different source than the RDF will disagree with it, and the disagreement will be invisible.
/llms.txt
GET /llms.txt follows the llmstxt.org convention: what the registry is, how to read any
record without an RDF parser, the entry points, and a link to every record in the catalogue.
curl https://registry.example.org/llms.txt
It lists software and deployments in full up to a limit, because those are a stable set that changes by the week and a small registry is worth listing whole. Artifacts and runs get a recent window instead — a single busy pipeline produces more of them in a day than the catalogue holds in a year, and listing them the same way would bury the parts of the file that orient a reader under a wall of near-identical rows. Everything else is one paged request away, and the file says so.
It is public whenever reads are public. A file whose entire purpose is to tell an unfamiliar client how to read the registry is worth nothing behind a credential the client does not yet know it needs.
Markdown representations
Every record IRI serves Markdown. Append .md, or send Accept: text/markdown:
curl -H 'Accept: text/markdown' https://registry.example.org/software/01a05…
curl https://registry.example.org/software/01a05….md # the same bytes
It is a representation, not a second copy — the same graph through the same code path as the Turtle — so the prose cannot drift from the RDF.
It is also where the registry states the things a client otherwise gets wrong, in prose, at the point of use:
- that
deployable: falsemeans there is no endpoint to call, rather than that the endpoint is missing from the record; - that a peer's record is a cached stub with a timestamp, not this registry's own claim;
- that a withdrawn record still resolves, and is withdrawn;
- that vocabulary terms must be looked up rather than recalled.
Every response also carries Signposting Link headers including rel="alternate"; type="text/markdown", so the Markdown is discoverable without knowing the .md convention in
advance.
The MCP server
The registry speaks the Model Context Protocol on /mcp, on its own web server, behind the same
credentials as the REST API. Nothing needs installing.
claude mcp add --transport http tar-registry https://registry.example.org/mcp
The full chapter is The hosted MCP server. The Connect tab in the UI prints the copy-paste setup for the common clients, built from that registry's own URL rather than a placeholder.
Why the registry refuses guesses
An agent asked to fill in a form will fill it in. A guessed type IRI or a confident licence for a repository that states none produces a record that looks right and is wrong — which is strictly worse than an empty one, because the UI renders an absent licence honestly as "licence not stated" and there is no rendering for "invented".
So the registry does not rely on asking nicely. A write may only name a vocabulary term the registry actually holds, enforced in one place for both REST and MCP, and the refusal explains how to recover — search, adopt, or mint. Guessing fails loudly rather than quietly.
The corollary for anyone writing an agent against this API: omit a field you cannot confirm. An absent field is rendered honestly. A plausible wrong one is undetectable.
See Artifact types and topics.
The hosted MCP server
The registry speaks the Model Context Protocol on a route of its own web server, so a coding agent can read and fill in registry data over HTTP with nothing installed locally.
POST https://your-registry.example/mcp
That is the whole client configuration. There is no tar binary to install, no stdio
subprocess, no second deployment. It is one route on the same axum process, at the same origin,
behind the same credentials as the REST API — because the registry already is an
authenticated web server, and asking users to install a copy of it to reach it would add an
install step, a version skew and a second set of authorisation rules to keep in step.
claude mcp add --transport http tar-registry https://your-registry.example/mcp
1. Protocol revision and transport
| Pinned revision | 2026-07-28 |
| Transport | Streamable HTTP (POST /mcp) |
| Worked from | https://modelcontextprotocol.io/specification/2026-07-28/ — basic/transports/streamable-http, basic/versioning, server/discover, server/tools, basic/authorization, basic/authorization/authorization-server-discovery |
| Also served | 2025-11-25, 2025-06-18, 2025-03-26 via the legacy initialize handshake |
2026-07-28 is a substantial change from the revisions before it, and this server implements
the current shape rather than the remembered one:
- No protocol-level session. No
Mcp-Session-Idis minted or echoed; one is ignored if sent. Every request stands alone, carrying its own credential and its own protocol version. - No standalone GET stream, no DELETE teardown.
GET /mcpandDELETE /mcpanswer405 Method Not Allowed, which is what the spec asks a modern-only endpoint to do. server/discoverreplaces theinitializehandshake: it reports supported versions, capabilities and server identity in one unauthenticated request.- Request metadata is mirrored into HTTP headers —
MCP-Protocol-Version,Mcp-Method,Mcp-Name— so intermediaries can route without parsing bodies, and the server validates the mirror against the body. A divergence is refused with400and JSON-RPC-32020(HeaderMismatch); that is the point of the mechanism, because a gateway acting on a header while the server acts on a different body is a real vulnerability. - Caching metadata.
tools/listreturnsttlMsandcacheScope: "private"— private, not public, because the tool set is filtered by the caller's authority and a shared cache must never serve one credential's list to another.server/discoverispublic. - Version negotiation without a handshake. An unknown
MCP-Protocol-Versiongets400with-32022UnsupportedProtocolVersionErrorand thesupportedlist, so a client can retry rather than give up.
Why also serve the legacy era
basic/versioning describes a dual-era server, and being one costs about fifty lines here
because there is no session to keep: the legacy initialize is a handshake in name only, and
every subsequent request is authenticated on its own exactly as a modern one is. It is what
makes the endpoint usable by the clients that exist rather than only the clients the spec
describes. A request carrying modern metadata is served per this revision; an initialize
request selects legacy semantics and gets the version it asked for, if we speak it.
Why hand-written JSON-RPC rather than an SDK
rmcp 3.1.4 is the maintained Rust SDK, and it would have been a reasonable choice. It was not
taken because the framing is about a hundred lines of serde, while the parts that are actually
hard here — the header/body mirror validation, dual-era dispatch, and filtering the tool list by
an axum-resolved Principal — are precisely the parts an SDK's own transport and service model
wants to own. Adopting it would have meant running that model beside axum's and re-deriving the
registry's credential handling inside it, to gain code that would still need auditing against
this revision. The framing lives in src/mcp/rpc.rs and is unit-tested.
2. How a client discovers and authenticates
The registry is an OAuth 2.1 protected resource. It does not become an authorization server; it says where one is, in the two places the spec requires.
Client Registry (/mcp) Keycloak
| POST /mcp tools/list ------> |
| <-- 401 WWW-Authenticate: Bearer resource_metadata="…"
| GET /.well-known/oauth-protected-resource --> |
| <-- { "resource": "...", "authorization_servers": ["…/realms/tar"] }
| GET …/.well-known/openid-configuration --------------------> |
| <-- authorization server metadata -------------------------- |
| … OAuth 2.1 + PKCE + RFC 8707 `resource` … -----------------> |
| <-- access token ------------------------------------------- |
| POST /mcp tools/list (Authorization: Bearer …) --> |
RFC 9728 Protected Resource Metadata is served at both locations a client must probe:
/.well-known/oauth-protected-resource/mcp— path-inserted, naming{base}/mcp/.well-known/oauth-protected-resource— root, naming{base}
Each names authorization_servers from TAR_OIDC_ISSUER and TAR_WORKLOAD_ISSUERS, plus
bearer_methods_supported: ["header"]. resource_documentation is deliberately omitted: the
registry serves its SPA on any unrouted path, so any URL named there would resolve to the app
shell rather than to documentation. Both are unauthenticated: they are what a client reads
before it has a credential.
The WWW-Authenticate challenge on every 401 points at the root document. That is
deliberate. RFC 9728 requires each document's resource to match the URL it was fetched for,
so the two documents necessarily name different identifiers — but src/auth/jwt.rs validates
aud against a single configured audience defaulting to the base IRI. Pointing the challenge
at the root document means a client sends resource={base}, the authorization server mints
aud={base}, and the token verifies. An operator whose authorization server honours RFC 8707
per-path resources should set TAR_OIDC_AUDIENCE to {base}/mcp instead.
The challenge carries no scope parameter, and scopes_supported is omitted from the
metadata unless TAR_MCP_SCOPES is set. Read tools need authentication and nothing more, and
per the scope-selection strategy a client with neither falls back to omitting the parameter —
which is the behaviour that actually works against a stock Keycloak realm, where the registry's
roles arrive in the token without any custom scope having to be requested. Advertising scope
names the authorization server has never heard of turns a working sign-in into
invalid_scope. Operators who have modelled register:software and friends as OAuth scopes
set TAR_MCP_SCOPES and get least privilege.
The credentials that work
Whatever the REST API accepts, this accepts, because it is the same crate::auth::authenticate:
- a Keycloak/OIDC JWT for a person, carrying
reader/curator/adminroles; - an OIDC workload token whose client id an Instance declares — the credential a deployment uses to advertise its own runs;
- an opaque
tar_…registry token minted per Instance, for deployments with no identity provider. Simplest for a quick trial:
claude mcp add --transport http tar-registry https://your-registry.example/mcp \
-H "Authorization: Bearer tar_…"
3. The tools
Seventeen, not a mirror of forty REST routes. Each entry costs context on every request, and near-duplicates make a model choose badly.
Orientation
| Tool | What it is for |
|---|---|
registry_info | What this registry holds, and — from your credential — exactly what you may write. Call it first. |
Vocabulary — deliberately first-class
| Tool | What it is for |
|---|---|
vocab_search | Search the controlled vocabulary. branch=topic (EuroSciVoc: what software is about), branch=data (EDAM: what an artifact is), or everything including locally minted types. |
vocab_resolve | Check that IRIs you were handed are real, and get their labels. |
list_enumerations | Every closed value set the registry validates against — kinds, maturity, availability, access protocols, auth methods, run statuses, scopes. |
register_artifact_type | Make a type nameable when vocab_search has none: adopt an IRI the term already has elsewhere (pass iri), or mint one when nothing anywhere names it. The honest alternative to a fabricated IRI. |
Reading
| Tool | What it is for |
|---|---|
search_registry | Free-text across software, instances, artifacts and runs; optionally federated. |
list_records | Filtered, paginated listing of one record kind. |
get_record | One record in full. |
find_capable_software | Matchmaking: what can produce or consume this artifact type — answerable before any run exists. |
get_artifact_lineage | Walk provenance up, down or both. |
Writing
| Tool | Authority required |
|---|---|
register_software | curator role, or register:software |
update_software | curator role, or register:software |
add_release | curator role, or register:software |
declare_capability | curator role, or register:software |
register_artifact_type | curator role, or register:software |
register_instance | curator role, or register:instance |
advertise_produced | a credential that acts as an Instance, plus advertise:produce |
advertise_consumed | a credential that acts as an Instance, plus advertise:consume |
tools/list returns only the tools the caller can actually use — the spec explicitly permits
the set to vary by the authorization presented, and it should: a model shown a tool it cannot
use will call it, read a refusal and try again. A person's curator token never sees the
advertisement tools (a person is not a deployment); an instance token scoped to
advertise:produce sees the read tools and advertise_produced, and nothing else.
What is deliberately not a tool
Minting or revoking API tokens; deleting or tombstoning records; adding, removing or announcing to peers; managing subscriptions; raw SPARQL; OpenLineage ingestion. Credential issuance and deletion are person-operations and stay in the UI. Raw SPARQL against a network-reachable endpoint driven by a model is an exfiltration and cost surface with no matching benefit — the typed read tools cover what an agent needs. A test asserts the catalogue contains no tool whose name matches any of these, so adding one later trips it.
4. Stopping an agent inventing metadata
A model asked to fill in a form will fill it in. A guessed EDAM IRI or a confident "MIT licence" for a repository that states none produces a record that looks right and is wrong — strictly worse than an empty one, because the UI renders an absent licence honestly as "licence not stated" and there is no rendering for "invented".
Three measures, in increasing order of how much they can be relied on.
4.1 The descriptions say so
Every write tool's description ends with the same contract, and every vocabulary-valued
parameter repeats it in its own description, because a model reads the parameter it is
filling:
DO NOT INVENT VALUES. Ontology IRIs (topics, artifact types) MUST come from
vocab_searchorregister_artifact_type; this server refuses any term it cannot resolve, so guessing fails loudly rather than quietly. Closed value sets MUST come fromlist_enumerations. Omit any field you cannot confirm from the repository, the package metadata or the user — the registry renders an absent field honestly ("licence not stated"), while a plausible wrong value is undetectable.
4.2 Looking up is cheaper than recalling
vocab_search and list_enumerations exist so that the correct behaviour is also the easy one,
and a search returning nothing says what to do next — omit the field, adopt the IRI the term
already has elsewhere, or register a new one — rather than leaving the model to fill the
silence.
4.3 The server checks — this is the one that holds
Prose is a suggestion. A write may only name a term this registry holds, and that rule lives
in one place, src/domain/vocabulary.rs, enforced on every REST write path. It is not a second
rule for agents: guard_vocabulary extracts every ontology IRI from the arguments at any depth
and asks that same code for a verdict, purely so the refusal can be phrased in tool names rather
than routes. There used to be two rules here, kept in step by hand, and they had already drifted
— MCP warned about a foreign type IRI that REST accepted outright.
Does the registry hold it? A term it cannot resolve to a skos:Concept in any of its graphs
is refused, whoever it belongs to. This is stricter than it was: an unrecognised foreign IRI used
to be waved through with a warning, on the grounds that federation needs foreign types. It does —
but a peer's record never passes through a write handler, so nothing about federation depended on
this registry being allowed to mint an unvetted type. Adopting the term first, with
register_artifact_type and its iri, is one call and leaves both registries agreeing on one
identifier.
Is it the right kind of thing? This half was found by pointing a real coding agent at this
server and telling it to guess. It produced http://edamontology.org/topic_3170, which does
exist — it is EDAM's "RNA-Seq" — and an existence check alone waved it onto a record that had
nothing to do with RNA-Seq. Terms like it are bundled only so older records citing them still
render a label, and are typed as a kind vocab_search never offers. So the rule is not "does this
term exist" but "could vocab_search have returned this term for this field", which rejects a
real term of the wrong kind as firmly as an invented one:
Refused before writing anything — 1 vocabulary problem(s) in these arguments:
- http://edamontology.org/topic_3170 is a term this registry holds, but not one it classifies
software by — it is kept so that records already citing it still render a label. Search with
`vocab_search` branch=topic and use what it gives you, or omit the field.
Nothing is written when this fires. The message names no vocabulary, deliberately: several are in play and more will follow, so a refusal that named one would be wrong the moment another arrives.
4.4 The correction loop
A write the shapes or the vocabulary rule reject comes back 422 with an RFC 9457 problem
document whose detail is already field: message, built from tar:jsonField on each
validation result — the vocabulary rule reports through the same report as a shape violation, so
there is one error shape to handle, not two. That is
surfaced verbatim with instructions:
The registry refused this write: one or more fields are not values it accepts. Offending fields:
kind: value must be one of service, library, cli, desktop, workflowFix exactly the named field(s) and retry. If you cannot establish the true value of a field, remove it from the request rather than substituting a plausible one — the registry renders an absent field honestly.
It closes: the model retries with the one named field changed rather than re-guessing the record. A test walks the loop end to end.
A 403 gets the opposite instruction — do not retry with different arguments; this is an
authorisation limit — because a model that reads "refused" and starts varying its input is
the failure mode there.
Listings are summaries
list_records returns a projection — the fields you would choose a record on — not the records
themselves. It used to return the REST body verbatim, and a software record carries its whole
README: four of them came to 112 KB and overran a client's tool-output cap, so browsing a
four-record catalogue failed outright. get_record returns the complete record once the caller
has chosen one.
5. Safety
MCP follows the registry's own read policy — no looser, no tighter. With TAR_PUBLIC_READ
on (the default), an unauthenticated caller gets the read-only tools and may call them: they
reach the same records anyone can already fetch over REST and query over SPARQL, so refusing
them here bought no secrecy. It cost something real, though — it forced every client into an
OAuth flow it did not need, and a misconfigured identity provider then turned "read the
catalogue" into "cannot connect at all".
With TAR_PUBLIC_READ off, an unauthenticated caller gets the protocol handshake only — server
name, version, capabilities, the same thing /healthz already reveals — and a 401 with the
discovery challenge on everything else. Not the tool list, not a count, not a record. The
handshake stays open because that is what lets a client reach the challenge and start the OAuth
flow rather than failing at connection time.
Either way the tool list is filtered by the caller's authority and every call is executed by the REST handler with the caller's own credential, so "anonymous" means "can see what anonymous can see", never "can do more". A credential that is offered and rejected is always a 401, whatever the read policy: the caller meant to authenticate and needs to know it failed.
A tool call can never do more than the credential could over REST — structurally. Every
tool executes as an ordinary HTTP request dispatched through crate::api::router in-process,
carrying the caller's own Authorization header verbatim. The REST handler runs its own
require_curator() / require_scope() / require_instance(), its own SHACL validation and its
own audit write. There is no second authorisation path to keep in step, and no tool can reach an
operation the credential could not. A test proves the two paths refuse the same call.
Authorisation failures are tool errors, not transport errors. A missing role or scope comes
back as isError: true with a sentence naming what is missing, rather than a 403 that kills the
connection — a model can act on the first and not the second. Only authentication failure is a
transport 401, because that is what drives OAuth discovery.
Operations reserved for a person. Token minting and revocation, deletion and tombstoning, peer management, subscriptions and raw SPARQL are not exposed at all. Not gated — absent.
Read-only mode. TAR_MCP_READ_ONLY=1 hides every write tool from tools/list and refuses
it if called anyway. TAR_MCP_ENABLED=0 removes the endpoint entirely (404).
Origin validation. The transport spec requires it against DNS rebinding, so an Origin
header that is neither the registry's own nor in TAR_MCP_ALLOWED_ORIGINS gets 403. Absent
Origin — every non-browser client — passes.
Header/body divergence is refused with -32020, so a gateway routing on Mcp-Name cannot
disagree with what the server executes.
Body size is bounded by the router's existing TAR_MAX_PAYLOAD_BYTES limit; list tools clamp
their own page sizes.
6. Configuration
| Variable | Default | Meaning |
|---|---|---|
TAR_MCP_ENABLED | true | Serve /mcp at all. |
TAR_MCP_READ_ONLY | false | Hide and refuse every write tool. |
TAR_MCP_ALLOWED_ORIGINS | (base IRI only) | Comma-separated extra Origin values to accept. |
TAR_MCP_SCOPES | (unset) | scopes_supported for the metadata document. Set only if your authorization server actually knows these scopes. |
Everything else — issuer, audience, roles, scopes, tokens — is the registry's existing configuration, unchanged.
7. Verification
tests/mcp.rs covers the protocol (handshake, mirrored-header validation, version negotiation,
notifications, the 405s, the legacy fallback), the discovery documents and challenge, the tool
surface and its authority filtering, the vocabulary guard in both halves, the SHACL correction
loop, and REST/MCP authorisation parity. src/mcp/*.rs carry unit tests for framing, header
decoding and gate logic.
Verified against Claude Code 2.1.251 as a real MCP client: it negotiates 2026-07-28 via
server/discover, lists the tools its credential allows, and calls them. A raw wire transcript,
should you want one:
$ curl -s -X POST http://127.0.0.1:8100/mcp \
-H 'content-type: application/json' \
-H 'mcp-protocol-version: 2026-07-28' -H 'mcp-method: server/discover' \
-d '{"jsonrpc":"2.0","id":"d1","method":"server/discover","params":{}}'
{"id":"d1","jsonrpc":"2.0","result":{
"resultType":"complete",
"supportedVersions":["2026-07-28","2025-11-25","2025-06-18","2025-03-26"],
"capabilities":{"tools":{}},
"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"tool-artifact-registry","version":"0.1.0"}},
"ttlMs":3600000,"cacheScope":"public"}}
$ curl -s -X POST … -H 'mcp-method: tools/list' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer realm="tool-artifact-registry",
resource_metadata="http://127.0.0.1:8100/.well-known/oauth-protected-resource",
error="invalid_token", error_description="an MCP request needs a bearer token: …"
$ curl -s -X POST … -H 'mcp-method: tools/call' -H 'mcp-name: vocab_search' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"register_software","arguments":{"name":"x"}}}'
{"id":3,"jsonrpc":"2.0","error":{"code":-32020,
"message":"Mcp-Name header \"vocab_search\" does not match the body value \"register_software\""}}
Deployment
What you are deploying is one statically linked binary that also serves the built UI, plus one
directory of state. There is no application server, no separate database process, and no
background daemon to install. docker run with one environment variable is a complete install;
everything below is about making that survivable.
Three shapes, in increasing order of ceremony:
| For | |
|---|---|
| A single container | One host, one registry. The quickest real deployment. |
| Compose | The same, plus the pieces it can use — an identity provider, an external graph store. |
| Kubernetes | A cluster you already run. |
All three are the same image with the same settings; only the thing that supervises it changes.
Decide TAR_BASE_IRI before anything else
It is the only universally required setting, and the only one you cannot change later.
Every identifier this registry mints is built from it. A record registered while
TAR_BASE_IRI is https://registry.example.org is called
https://registry.example.org/software/01a05d4c-… — permanently, in the graph, in every
response, in every peer that has cross-linked to it, and in every file anyone has exported.
Change the base IRI afterwards and those identifiers do not move: they stay in the store,
pointing at a host that no longer answers for them. Nothing rewrites them. tar dump will show
you how many you invalidated, and that is all the help there is.
So, before first boot:
- It must be the URL people and machines actually reach the registry at — the public one,
the one on the certificate, the one in the ingress rule. Not the pod IP, not the service name,
not
localhostbecause that is what you tested with. - The scheme must be the scheme they use.
https://in the base IRI with a plain-HTTP ingress mints identifiers that do not dereference. - No trailing slash (one is trimmed), and no port unless the port is genuinely part of the URL.
- It is also the default audience a signed-in person's token must carry. See Identity provider setup, where that catches everybody.
The registry refuses to start without it, and refuses anything that is not an http(s) URL.
If you must change it, treat it as a migration and not a config edit: dump, decide what the old identifiers should do — a redirect from the old host is the only thing that keeps them working — and restore into a registry that has never been anything else.
A single container
docker volume create tar-data
export TAR_ROOT_TOKEN=$(openssl rand -hex 24)
docker run -d --name tar \
-p 8080:8080 \
-e TAR_BASE_IRI=https://registry.example.org \
-e TAR_ROOT_TOKEN="$TAR_ROOT_TOKEN" \
-v tar-data:/data \
--restart unless-stopped \
ghcr.io/maastrichtu-ids/tool-artifact-registry:0.1.0
That is the whole install. The image already sets TAR_DATA_DIR=/data,
TAR_LISTEN=0.0.0.0:8080 and TAR_STATIC_DIR=/ui, declares /data as a volume, and carries a
HEALTHCHECK that runs tar healthcheck.
Optionally load the worked example so the first page is not empty — with the server stopped, because both stores are single-writer:
$ docker run --rm -e TAR_BASE_IRI=https://registry.example.org -v tar-data:/data \
ghcr.io/maastrichtu-ids/tool-artifact-registry:0.1.0 seed
{
"artifacts": 19,
"instances": 5,
"runs": 12,
"software": 4,
"types": 16
}
What persists, and what does not
Everything that matters is under /data, and nothing that matters is anywhere else:
Under /data | |
|---|---|
| the graph store | Every record — software, releases, deployments, runs, artifacts, minted vocabulary terms, cached peer stubs. |
| a SQLite database | Hashed API tokens, peers, the audit log, federation cursors, idempotency keys, subscriptions and their delivery queues. |
Back up the volume and you have backed up both. Lose it and you have lost every issued token and
the audit log even if the graph lives in an external store — only the graph moves when you set
TAR_SPARQL_ENDPOINT, never the operational database.
The SHACL shapes and the bundled vocabularies are compiled into the binary and reloaded into the store on every start, so they need no volume and a restored dump does not have to carry them.
Running it unprivileged
The binary writes only under TAR_DATA_DIR. It runs with a read-only root filesystem, as a
non-root user, with every capability dropped:
docker run -d --name tar \
--read-only --user 65532:65532 --cap-drop ALL --security-opt no-new-privileges \
-p 8080:8080 \
-e TAR_BASE_IRI=https://registry.example.org \
-e TAR_ROOT_TOKEN="$TAR_ROOT_TOKEN" \
-v tar-data:/data \
ghcr.io/maastrichtu-ids/tool-artifact-registry:0.1.0
The volume has to be writable by that user — chown 65532:65532 on a bind mount, or an
fsGroup on Kubernetes, which the manifests set. This is verified, not assumed: seeding,
serving, and a POST /api/v1/software all succeed under those restrictions.
Compose
compose.yaml at the root of the repository is the same single container, supervised:
export TAR_BASE_IRI=https://registry.example.org
export TAR_ROOT_TOKEN=$(openssl rand -hex 24)
docker compose up -d
TAR_ROOT_TOKEN has no default and compose refuses to start without one, which is deliberate:
a bootstrap credential every install shares is not a credential.
An external graph store
compose.yaml carries a Fuseki under a profile, off unless you ask for it:
docker compose --profile external-store up -d
and the TAR_SPARQL_* settings to point the registry at it, commented out beside it. The volume
is still required — see above. Graph store covers what the two backends share
and where they differ.
With an identity provider
compose.identity.yaml brings up the registry and a Keycloak with the realm already
imported, in one command:
docker compose -f compose.identity.yaml up -d --wait
Then http://127.0.0.1:8099, sign in as curator / curator-password.
This exists because doing it by hand took two commands and then an audience that had to be set
by hand — and getting that wrong produces a sign-in that appears to succeed at the identity
provider and then fails at the registry, which is a miserable thing to debug from either end.
Here the registry is served on http://127.0.0.1:8099, one of the two origins the bundled
realm's audience mappers already name, so there is nothing to set:
A token minted by that Keycloak, with nothing configured by hand, comes out carrying
aud: ["http://127.0.0.1:8099", "http://127.0.0.1:8098", "account"] — and the registry accepts
it:
$ curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8099/api/v1/whoami
{"authenticated":true,"credential":"oidc-human","display_name":"curator",
"is_curator":true,"roles":["reader","curator"], …}
Two things to know before changing it:
TAR_PORTmay be 8099 or 8098, and nothing else. Those are the originsdeploy/keycloak/realm-tar.jsoncarries audience mappers and redirect URIs for. Any other value needs a mapper added to that file.- The registry uses the host network. The registry compares a token's
isstoTAR_OIDC_ISSUERbyte for byte and fetches the signing keys from that same URL, so the issuer string has to be true for the browser and for the registry. On a bridge network those are two different strings. Sharing the host's network namespace makes one of them work for both.
It is a development stack and says so: Keycloak runs in development mode, over plain HTTP, with
an in-memory database wiped on down, and passwords committed to the repository on purpose. A
real deployment points TAR_OIDC_ISSUER at a real identity provider and adds the audience
mapper there — see Identity provider setup.
Kubernetes
deploy/kubernetes/ holds plain manifests assembled with kustomize, which kubectl has built
in:
kubectl kustomize deploy/kubernetes # see exactly what will be applied
kubectl apply -k deploy/kubernetes
Manifests rather than a chart. There is one workload, one service and one volume here; a chart
would buy templating this does not need, at the cost of a values file standing between the
reader and the object that actually gets created. The one thing a chart would genuinely unify —
keeping the ingress hostname and TAR_BASE_IRI in step — is done with a kustomize
replacement, so the ingress host and the certificate's host are derived from
TAR_BASE_IRI rather than repeated next to it.
What you edit
One line, in kustomization.yaml:
configMapGenerator:
- name: tar-config
literals:
- TAR_BASE_IRI=https://registry.example.org
The ingress host and the TLS hosts entry follow from it automatically. Change it and check:
$ kubectl kustomize deploy/kubernetes | grep -E 'TAR_BASE_IRI|host:'
TAR_BASE_IRI: https://registry.example.org
- host: registry.example.org
What you create out of band
Never in a manifest, never in git:
kubectl -n tar create secret generic tar-secrets \
--from-literal=TAR_ROOT_TOKEN="$(openssl rand -hex 24)"
Add the graph-store credential to the same secret if you use an external endpoint —
TAR_SPARQL_USERNAME and TAR_SPARQL_PASSWORD, or TAR_SPARQL_BEARER_TOKEN. The Deployment
takes them with envFrom.secretRef, so nothing enumerates them in a file that gets committed.
The volume
TAR_DATA_DIR is a ReadWriteOnce PersistentVolumeClaim mounted at /data. Both stores under
it are single-writer, which decides two other things in the manifest:
replicas: 1. The embedded graph store takes an exclusive lock on its directory; a second pod does not share it, it fails to start.strategy: Recreate. A rolling update would start the new pod while the old one still held the lock, and it would crash-loop until the rollout gave up. Recreate trades a few seconds of downtime for an upgrade that works.
Probes
Both endpoints exist and answer different questions, so they are used for different things:
GET /healthz | Static {"status":"ok"}. Liveness. A liveness probe that also checks a dependency restarts a healthy process because something else broke. |
GET /readyz | Counts the graph and runs SELECT 1 against SQLite. Readiness, and the startup probe. A pod whose external SPARQL endpoint is unreachable stops taking traffic without being killed. |
GET /metrics | Prometheus text: total triples, records by kind, peers configured and failing. |
All three are reachable regardless of TAR_PUBLIC_READ — a probe that needs a credential fails
for the wrong reason.
The startup probe allows up to 150 seconds. Boot itself is fast (about 0.3 s to listening on a warm volume), but a first boot on cold storage loads the shapes and the bundled vocabularies into an empty store, and a startup probe that is too tight turns a slow first start into a crash loop.
The ingress
TAR_MAX_PAYLOAD_BYTES defaults to 2 MiB and ingress-nginx caps request bodies at 1 MiB, so the
manifest sets nginx.ingress.kubernetes.io/proxy-body-size: "2m". Without it a large software
record is rejected by the proxy, with the proxy's error rather than the registry's. Keep the two
in step if you raise either.
The published image
ghcr.io/maastrichtu-ids/tool-artifact-registry, built and pushed by
.github/workflows/release.yml on every push to the default branch and every v* tag.
| Tag | |
|---|---|
0.1.0, 0.1 | From a v0.1.0 tag. What a deployment should pin. |
latest | The most recent release tag. Not the default branch. |
main, main-<sha> | The tip of the default branch, for when you deliberately want it. |
linux/amd64 and linux/arm64, each built on a runner of that architecture and joined into one
manifest list, so docker pull gets the right one with no --platform. Each release carries a
signed build provenance attestation:
gh attestation verify oci://ghcr.io/maastrichtu-ids/tool-artifact-registry:0.1.0 \
--owner MaastrichtU-IDS
Packages on GHCR are created private, and that is a setting on the package rather than on the
repository. A public repository does not make its images public. Until someone changes it in
the package's own settings, docker pull from a machine that is not logged in fails with an
authentication error that looks like the image does not exist:
echo "$GITHUB_TOKEN" | docker login ghcr.io -u <username> --password-stdin
Configuration that matters in production
Configuration is the complete list. These are the ones a deployment gets wrong:
TAR_BASE_IRI | Required, permanent. See the top of this page. |
TAR_ROOT_TOKEN | The bootstrap admin. In a Secret. Refused if it is a recognisable placeholder or shorter than 16 characters. Issue real tokens and stop using it. |
TAR_PUBLIC_READ | true. Anonymous reads. |
TAR_SPARQL_PUBLIC | true. Anonymous SPARQL. Independent of the above on purpose — a private registry has to say so about both, because SPARQL is a read surface in its own right and closing REST reads should not silently close it. |
TAR_OIDC_ISSUER, TAR_OIDC_CLIENT_ID | Browser sign-in. The client needs an audience mapper for TAR_BASE_IRI, which is the mistake everyone makes once. |
TAR_WORKLOAD_ISSUERS | Extra issuers accepted for workload tokens only — a Kubernetes API server, a CI provider. They are trusted to say which deployment is calling and nothing else; only TAR_OIDC_ISSUER may assert roles. Getting that backwards hands the registry to anyone who can open a pull request. |
TAR_SPARQL_ENDPOINT | An external graph store instead of the embedded one. Setting it is the whole switch. |
TAR_OPERATOR | Who runs this. Reported in /.well-known/tar-registry. |
tar config prints the effective configuration with secrets redacted, reading the environment
exactly as serve does — so it answers "why is this registry behaving like that" without
starting it:
$ docker run --rm -e TAR_BASE_IRI=… -e TAR_ROOT_TOKEN=… -v tar-data:/data \
ghcr.io/maastrichtu-ids/tool-artifact-registry:0.1.0 config
base_iri https://registry.example.org
data_dir /data
graph store embedded oxigraph at /data/graph
listen 0.0.0.0:8080
public_read true
sparql_public true
shacl_validate_writes true
root_token set
static_dir /ui
oidc issuer (unset)
An external graph store
TAR_SPARQL_ENDPOINT points the registry at any SPARQL 1.1 endpoint instead of the embedded
store; its absence is the whole switch, so an existing install changes nothing.
Graph store is the detail. For a deployment, three things matter:
- The volume is still required. Only the graph moves. Tokens, peers, the audit log,
subscriptions and the federation cache stay in SQLite under
TAR_DATA_DIR. - Credentials belong in a Secret.
TAR_SPARQL_USERNAME/TAR_SPARQL_PASSWORDorTAR_SPARQL_BEARER_TOKEN. Setting both forms is an error rather than a silent preference. - Readiness follows it.
/readyztouches the store, so an unreachable endpoint takes the pod out of the load balancer rather than serving empty results. That is the point: a query that returns nothing because the server is down looks exactly like a registry with no records.
Upgrades
Pull the new tag and restart. There is no migration step to run by hand:
- The SQLite schema migrations run on every start.
- The SHACL shapes and the bundled vocabularies are reloaded from the binary into the store on every start. That is idempotent, and it is also how a graph migration is applied.
Two things to know:
- Take a backup first. See below. Migrations are applied to the volume in place.
- A shapes change can strand a record. A write is judged on the whole record it asserts, so a record citing a vocabulary term the registry has since retired is refused on an edit to some entirely different field. The boot log names every such record and the term, once, rather than deleting a value nobody asked it to delete. Read the boot log after an upgrade.
On Kubernetes the Recreate strategy means an upgrade is a short outage rather than an overlap;
that is a consequence of the single-writer store, not a choice about availability.
Backup and restore
Backup and restore is the reference. What a deployment needs:
The whole of it is TAR_DATA_DIR. Snapshot the volume with the process stopped and you have
everything, including the operational database that a graph dump does not contain.
To back up a running registry, use the HTTP endpoint, not the CLI. tar dump boots its own
handle on the store, and the store is single-writer, so it fails against a live server:
$ docker exec tar /tar dump
Error: opening graph store at /data/graph
Caused by:
IO error: While lock file: /data/graph/LOCK: Resource temporarily unavailable
GET /admin/dump serves the same N-Quads over HTTP, for admins, from the running process:
curl -H "Authorization: Bearer $TAR_ROOT_TOKEN" \
https://registry.example.org/admin/dump > registry.nq
Restoring is the reverse, into a stopped registry:
$ docker run --rm -e TAR_BASE_IRI=… -v tar-data:/data -v "$PWD":/backup:ro \
ghcr.io/maastrichtu-ids/tool-artifact-registry:0.1.0 restore --nquads /backup/registry.nq
loaded 1456 quads
The count is the quads that were new: the shapes and vocabularies are already in the store, loaded from the binary at boot, so a restore does not re-add them. Verified round trip — a 12,145-triple registry dumped and restored into an empty volume comes back at 12,145 triples with the same record counts.
The trap: --graph is not a backup
tar dump with no argument writes N-Quads, and the named graph is part of the meaning —
which graph a statement is in is what distinguishes this registry's records from a peer's cached
stub.
tar dump --graph <g> and /admin/dump?graph=<g> write N-Triples, because that is what the
single-graph consumers want. Restoring that file puts its triples in the default graph, where
nothing looks for them:
$ head -1 registry.nq # four terms, the graph last
<https://w3id.org/tar/ns#ReachableShape> <http://www.w3.org/ns/shacl#not> _:b0 <urn:tar:shapes> .
$ head -1 local.nt # three terms, no graph
<https://registry.example.org/artifact/01a05d4c-…> <http://www.w3.org/ns/prov#wasAttributedTo> <urn:tar:seed> .
Use the whole-store dump for backups. See Limitations §18.
Where the documentation lives
The site is published from the default branch to https://maastrichtu-ids.github.io/tool-artifact-registry/.
Configuration
Everything is an environment variable. TAR_BASE_IRI is the only universally required one —
the registry cannot mint dereferenceable identifiers without knowing what it is called, and
refuses to start without it. Everything else has a working default, so docker run with one
variable is a complete install.
tar config prints the effective configuration with secrets redacted. For how these settings
land in a real deployment — a container, compose, or a cluster — see Deployment.
Core
| Variable | Default | |
|---|---|---|
TAR_BASE_IRI | — | Required. The http(s) URL the registry is reachable at. Becomes part of every identifier it mints, permanently. |
TAR_LISTEN | 0.0.0.0:8080 | |
TAR_DATA_DIR | ./data | The graph store and the SQLite database. memory for an ephemeral store. |
TAR_STATIC_DIR | frontend/dist if it exists | The built UI. Unset and with no such directory, the registry serves the API only. |
TAR_TITLE | Tool Artifact Registry | Shown in the UI and in llms.txt. |
TAR_OPERATOR | — | Who runs this registry. Reported in /.well-known/tar-registry. |
TAR_ROOT_TOKEN | — | Bootstrap admin credential. Refuses a placeholder or anything under 16 characters. |
TAR_MAX_PAYLOAD_BYTES | 2MiB | Accepts KiB, MiB, MB suffixes. Raise it if you import software records with large READMEs. |
TAR_LOG | info,tower_http=warn | Tracing filter, tracing-subscriber EnvFilter syntax. |
Graph store
| Variable | Default | |
|---|---|---|
TAR_SPARQL_ENDPOINT | — | An external SPARQL 1.1 Query endpoint to use instead of the embedded store. Unset, the registry uses embedded Oxigraph under TAR_DATA_DIR — which is what every existing install does, and nothing about it changes. |
TAR_SPARQL_UPDATE_ENDPOINT | the query endpoint | Many servers split query and update onto separate URLs. |
TAR_SPARQL_BEARER_TOKEN | — | Bearer credential for the endpoint. |
TAR_SPARQL_USERNAME / TAR_SPARQL_PASSWORD | — | HTTP basic credential. Both or neither, and not alongside a bearer token. |
TAR_SPARQL_TIMEOUT | 60s | Per request. |
TAR_DATA_DIR still holds the SQLite operational database either way; only the graph moves.
Details, and what atomicity means over HTTP, in Graph store.
Read access
| Variable | Default | |
|---|---|---|
TAR_PUBLIC_READ | true | Serve reads without a credential. |
TAR_SPARQL_PUBLIC | true | Serve SPARQL without a credential. |
The two are independent, and that is deliberate: SPARQL is a public read surface in its own right, and losing it whenever an operator closes REST reads would make the two settings one. An operator who wants a genuinely private registry has to say so about the query endpoint too.
/healthz, /readyz, /metrics, /api/v1/registry, /api/v1/context, /api/v1/whoami, the
/.well-known/ documents and the MCP handshake stay reachable either way — a probe or a
discovery document that needs a credential fails for the wrong reason.
Validation
| Variable | Default | |
|---|---|---|
TAR_SHACL_VALIDATE_WRITES | true | Off downgrades SHACL violations to warnings. |
It does not switch off the two rules that need the rest of the graph — that an artifact type is a term the registry holds, and that a deployment of non-deployable software may not carry an endpoint. A half-described record is a trade an operator can make; an unlookuppable type is not, because it silently breaks matchmaking and subscriptions rather than one record.
Identity
Covered in Identity provider setup: TAR_OIDC_ISSUER,
TAR_OIDC_CLIENT_ID, TAR_OIDC_CLIENT_SECRET, TAR_OIDC_AUDIENCE,
TAR_OIDC_REQUIRE_AUDIENCE, TAR_OIDC_ROLES_CLAIM, TAR_OIDC_CLIENT_CLAIM,
TAR_OIDC_SCOPE_CLAIM, TAR_WORKLOAD_ISSUERS, TAR_OIDC_AUTO_REGISTER_INSTANCES.
TAR_DEV_INSECURE_JWT short-circuits token verification. Test only.
Health probing
| Variable | Default | |
|---|---|---|
TAR_HEALTH_CHECK_ENABLED | true | Probe deployment endpoints in the background. |
TAR_HEALTH_CHECK_INTERVAL | 5m | |
TAR_HEALTH_CHECK_TIMEOUT | 5s | |
TAR_HEALTH_CHECK_BATCH | 20 | Deployments probed per pass. |
TAR_HEALTH_ALLOW_PRIVATE | true | Probe private and loopback addresses. |
Private addresses are allowed by default here and refused by default for webhooks, which looks inconsistent and is not. A deployment endpoint is an address in your own estate, and for an internal registry it is normally private — refusing those would mean the feature never worked where it is most wanted. A webhook URL is chosen by whoever registers a subscription and points anywhere.
Federation
| Variable | Default | |
|---|---|---|
TAR_PEER_RESOLVE_ENABLED | true | Fetch stubs for foreign IRIs in the background. |
TAR_PEER_RESOLVE_TTL | 24h | How long a cached stub stays fresh. |
TAR_PEER_RESOLVE_TIMEOUT | 5s | |
TAR_FEDERATED_SEARCH_TIMEOUT | 3s | Per peer. |
TAR_FEDERATED_SEARCH_TOTAL_TIMEOUT | 10s | Whole fan-out. Max 60s. |
TAR_FEDERATED_SEARCH_MAX_HOPS | 3 | Max 8. |
TAR_FEDERATED_SEARCH_HOP_MARGIN | 600ms | Held back before forwarding, so a hop can still answer. |
TAR_FEDERATED_SEARCH_MAX_PEERS | 12 | Max 64. |
TAR_FEDERATED_SEARCH_MAX_PEER_HITS | 100 | Max 1000. |
TAR_FEDERATED_SEARCH_MAX_PEER_BYTES | 2 MiB | Max 32 MiB. |
TAR_FEDERATED_SEARCH_MAX_PEER_STATUSES | 32 | Max 256. |
TAR_FEDERATED_SEARCH_MAX_TOTAL_HITS | 500 | Max 5000. |
TAR_FEDERATED_SEARCH_ID_TTL | 10m | How long a query id is remembered for loop detection. |
See Federation.
Subscriptions
| Variable | Default | |
|---|---|---|
TAR_SUBSCRIPTION_WEBHOOKS | true | Run the delivery worker at all. |
TAR_SUBSCRIPTION_TICK | 5s | Worker poll interval. |
TAR_SUBSCRIPTION_BATCH | 20 | Deliveries attempted per tick. Max 500. |
TAR_SUBSCRIPTION_TIMEOUT | 5s | Per attempt. Capped at 30s. |
TAR_SUBSCRIPTION_MAX_ATTEMPTS | 8 | Before one delivery is dead. Max 20. |
TAR_SUBSCRIPTION_SUSPEND_AFTER | 12 | Consecutive failures before the webhook is suspended. |
TAR_SUBSCRIPTION_BACKOFF_BASE | 30s | |
TAR_SUBSCRIPTION_BACKOFF_MAX | 6h | |
TAR_SUBSCRIPTION_ALLOW_HTTP | false | Otherwise HTTPS only. |
TAR_SUBSCRIPTION_ALLOW_PRIVATE_TARGETS | false | Otherwise private, loopback and link-local targets are refused. |
See Subscriptions.
MCP
| Variable | Default | |
|---|---|---|
TAR_MCP_ENABLED | true | Serve /mcp at all. |
TAR_MCP_READ_ONLY | false | Hide and refuse every write tool. |
TAR_MCP_ALLOWED_ORIGINS | base IRI only | Comma-separated extra Origin values. |
TAR_MCP_SCOPES | — | scopes_supported in the metadata document. Set only if your authorization server actually knows these scope names. |
Repository sync and API documents
| Variable | Default | |
|---|---|---|
TAR_FORGE_TOKEN | — | Registry-wide forge token for reading private repositories. |
TAR_APIDOC_ALLOW_PRIVATE | true | Fetch API description documents from private addresses. |
Build-time
These affect build.rs, not the running server. Both take 1.
| Variable | |
|---|---|
TAR_UPDATE_VOCAB | Force an upstream check of the bundled vocabularies rather than waiting for the daily one. |
TAR_VOCAB_OFFLINE | Skip the check entirely and build from the committed bundles. Fails the build, loudly, if one is missing. |
A release build should set TAR_VOCAB_OFFLINE=1. The container image and CI do. Left
unset, the build may fetch from an upstream host and rewrite the bundles in shapes/, which
makes the output depend on what that host served that day and on it being up at all. The older
names for these two are still accepted.
Durations and sizes
Durations accept 30s, 5m, 24h, 7d, or a bare number of seconds. Sizes accept KiB,
MiB, MB, or bare bytes. Booleans accept 1, true, yes or on — except the
TAR_HEALTH_* switches, which currently recognise only 1 and true. Prefer 1 or true
everywhere and the difference never comes up.
Graph store
The registry keeps its records as quads. By default those quads live in an embedded Oxigraph
store under TAR_DATA_DIR, which is why one binary and one volume is a complete install.
An estate that already runs a triple store can point the registry at it instead:
| Variable | Default | |
|---|---|---|
TAR_SPARQL_ENDPOINT | — | SPARQL 1.1 Query endpoint. Setting it selects the external backend. Unset, the registry uses embedded Oxigraph, exactly as before. |
TAR_SPARQL_UPDATE_ENDPOINT | the query endpoint | SPARQL 1.1 Update endpoint. Many servers split the two — Fuseki serves /ds/sparql and /ds/update. |
TAR_SPARQL_BEARER_TOKEN | — | Authorization: Bearer … on every request. |
TAR_SPARQL_USERNAME / TAR_SPARQL_PASSWORD | — | HTTP basic auth. Both or neither. |
TAR_SPARQL_TIMEOUT | 60s | Per request. Generous next to the federation timeouts: this is the registry's own storage, and a boot-time load of the bundled vocabularies is one request. |
Configuring a bearer token and a username is an error rather than a silent preference — the
registry will not choose a credential for you. tar config prints which backend is in use and
which kind of credential, never the credential itself:
$ tar config | head -4
base_iri https://demo.example
data_dir /var/lib/tar
graph store external SPARQL endpoint — query http://fuseki:3030/tar/sparql / update http://fuseki:3030/tar/update (basic auth)
listen 0.0.0.0:8080
TAR_DATA_DIR still matters with an external endpoint: the operational database (tokens, peers,
audit, subscriptions, the federation cache) is SQLite either way, and only the graph moves.
This is not /sparql
/sparql is the registry's own read-only query surface for analysts and peers, and it
refuses updates whichever backend is configured. The variables above are a private connection to
the registry's storage. They have nothing to do with each other.
Fuseki, end to end
docker run --rm -d --name tar-fuseki -p 3030:3030 -e ADMIN_PASSWORD=admin stain/jena-fuseki
curl -u admin:admin -X POST 'http://localhost:3030/$/datasets?dbName=tar&dbType=tdb2'
export TAR_BASE_IRI=https://registry.example
export TAR_SPARQL_ENDPOINT=http://localhost:3030/tar/sparql
export TAR_SPARQL_UPDATE_ENDPOINT=http://localhost:3030/tar/update
export TAR_SPARQL_USERNAME=admin
export TAR_SPARQL_PASSWORD=admin
tar seed && tar serve
Nothing else changes. The named graphs are the same either way — see
Named graphs — so tar dump from an embedded registry restores
into an external one and vice versa, and the two answer identically for the same data.
Reference data
About 12 000 of the registry's quads are not records at all: four bundled files under shapes/
and one table in the source, holding the SHACL shapes, the registry's own terms, two external
vocabularies and the artifact keyword scheme. They used to be pushed into the record store on
every single start, with a DROP GRAPH of the shapes each time. Against an external endpoint
that is 12 000 quads over HTTP at every restart, and the write-path question "is this a term the
registry holds" was one more round trip per record written.
They now live in two places:
- An in-memory store, loaded at every start from the constants compiled into the binary. It starts empty, so loading it every time is correct by construction — no staleness, no guard, no network. This is what the write path reads, which is why registering a record whose type comes from a bundled vocabulary asks the endpoint nothing about the vocabulary at all.
- The record store, one graph per bundle, guarded by a content digest.
/sparqlhas to be able to join a record to the term it cites and a peer has to be able to fetch a definition, so the copy is real.<urn:tar:bundles>records each graph's digest, its size and when it was written; a boot that finds every digest unchanged issues oneSELECTand writes nothing.
The digest covers the base IRI as well as the file, because the base decides how a relative IRI
resolves and where the keyword concepts are minted: a store served under a new TAR_BASE_IRI
reloads its reference data rather than serving the old registry's identifiers.
$ curl -sG --data-urlencode 'query=PREFIX void: <http://rdfs.org/ns/void#>
SELECT ?g ?n WHERE { GRAPH <urn:tar:bundles> { ?g void:triples ?n } }' \
-H 'Accept: application/sparql-results+json' https://registry.example/sparql
A term the registry minted, adopted, or cached from a peer is a record, not reference data:
it lives in urn:tar:local or in that peer's graph, and the write-path check falls through to
the record store for exactly those. That fallback is one query — the same one the old code made
unconditionally — so the worst case is what every write used to cost and the common case is
nothing.
What the two backends share, and what they do not
Every read the registry performs by subject — the record description, "does this exist", "which
graph is it in", "how many quads" — is one SPARQL query, written once in
src/store/queries.rs and run through the backend's select/ask. A backend implements
select, ask, construct and apply and inherits the rest, so the two cannot drift apart on
what a record is without one of them failing to run a standard query.
Writes are one request. A registry write is "replace what we said about this resource": some
subject deletions, some property deletions, some insertions. Against the embedded store that is
one Oxigraph transaction. Against an external endpoint it is a single SPARQL Update request —
DELETE {…} WHERE {…} ; DELETE WHERE {…} ; INSERT DATA {…} in one body — because a request is
processed as one unit by the servers this targets, whereas one HTTP call per operation can leave
a record with its old distribution deleted and its new one never inserted.
That last guarantee is the server's. SPARQL 1.1 does not require a request to be atomic, and a server that processes operations independently gives atomicity only per operation. The registry cannot detect the difference over HTTP and does not claim to.
An unreachable endpoint is an error, never an empty result. A query that returns nothing because the server is down looks exactly like a registry with no records, so every failure names the endpoint:
$ TAR_SPARQL_ENDPOINT=http://127.0.0.1:3999/nothing/sparql tar seed
Error: SPARQL endpoint http://127.0.0.1:3999/nothing/sparql is unreachable
Caused by:
0: error sending request for url (http://127.0.0.1:3999/nothing/sparql)
1: client error (Connect)
2: tcp connect error
3: Connection refused (os error 111)
Choosing
Embedded is the right default and stays the recommendation for a single registry: no second process, no JVM, no network hop on the read path, and writes are a local transaction.
Reach for an external endpoint when the graph is already somewhere else — a Fuseki or GraphDB your organisation runs and backs up, a store other tools query directly, or a dataset too large to sit beside the API process. The cost is a round trip per store call and, today, a worker thread blocked for its duration (see Limitations).
Identity provider setup
The registry can run with no identity provider at all — registry API tokens and a root token are enough to administer it. Configuring one buys two things: people sign in instead of pasting tokens, and deployments authenticate with credentials the registry never stores.
Configuration
export TAR_OIDC_ISSUER=https://sso.example.org/realms/main
export TAR_OIDC_CLIENT_ID=tar-ui
That is the minimum for browser sign-in. Everything else has a working default:
| Default | ||
|---|---|---|
TAR_OIDC_ISSUER | — | The human issuer. The only issuer allowed to assert roles. |
TAR_OIDC_CLIENT_ID | — | The public client the browser uses. |
TAR_OIDC_CLIENT_SECRET | — | Only if your client is confidential. |
TAR_OIDC_AUDIENCE | TAR_BASE_IRI | The aud a token must carry. |
TAR_OIDC_REQUIRE_AUDIENCE | true | Whether aud is required rather than merely checked when present. |
TAR_OIDC_ROLES_CLAIM | realm_access.roles | Where human roles are read from. |
TAR_OIDC_CLIENT_CLAIM | azp | Where a workload's client id is read from. |
TAR_OIDC_SCOPE_CLAIM | scope | Where granted scopes are read from. |
TAR_WORKLOAD_ISSUERS | — | Comma-separated. Accepted for workload tokens only. |
TAR_OIDC_AUTO_REGISTER_INSTANCES | false | Let any accepted credential register a deployment of software it names itself. |
The audience mapper, which is what catches everyone
TAR_OIDC_AUDIENCE defaults to TAR_BASE_IRI, and TAR_OIDC_REQUIRE_AUDIENCE defaults to
true. So the client in your identity provider needs an audience mapper adding that exact
string.
Without one, a typical provider issues an access token with an audience of its own account service, sign-in completes at the provider, the browser comes back, and the registry rejects the token. The symptom is a successful login followed immediately by a failure, which is a confusing thing to debug from either end.
The audience is the base IRI, not the origin, not the sign-in redirect URL. If you serve the registry on a second origin, that origin needs its own mapper.
Requiring the audience rather than merely checking it when present is deliberate. A token with no audience is a token minted for nobody in particular, and accepting one means accepting any token that issuer ever signs, for any application.
Roles
Three, read from the roles claim:
| Role | Means |
|---|---|
reader | Signed in; no write authority. |
curator | Register and edit software, releases, deployments and vocabulary terms. |
admin | Everything a curator can, plus peers and token administration. |
A signed-in person with none of them is exactly that: signed in, and able to read what anonymous readers can read.
Workload issuers
TAR_WORKLOAD_ISSUERS lists additional issuers accepted for workload tokens: a Kubernetes
API server, a CI provider's OIDC issuer, a partner's identity provider.
They are trusted to say which deployment is calling and nothing else. Only TAR_OIDC_ISSUER
may assert roles.
This is the single most important line in the auth configuration. A Kubernetes API server and a
CI provider can each mint a token containing a realm role called admin; if the registry
honoured that, adding a CI issuer would hand the registry to anyone who can open a pull request
against any repository on that platform.
Pin the issuer on every credential binding
Roles are not the only thing a second issuer can spell. A client id is only unique within an
issuer: validator-prod at your Keycloak and validator-prod at a CI provider are different
principals with the same name, and registering a client under a chosen name is free everywhere.
So once you add a workload issuer, say which issuer each binding means:
| Where | Field | Names |
|---|---|---|
| Software | registration_issuer | the provider registration_clients belong to |
| Deployment | oidc_issuer | the provider oidc_client_id belongs to |
| Deployment | self_registered_issuer | written by the registry when a deployment self-registers |
A binding that pins nothing is read against the primary issuer (TAR_OIDC_ISSUER), or the
sole workload issuer when that is all there is — the two readings with one obvious answer. With
several accepted and no primary, the registry refuses the binding rather than guessing, because
guessing would grant the weakest accepted issuer the authority meant for the strongest. On a
single-issuer registry none of this changes anything.
A local provider to try it against
deploy/keycloak/ holds a one-container Keycloak with an importable realm — three roles, a
PKCE public client, a service-account client for the workload path, and users with known
passwords — so both flows can be exercised for real rather than described.
docker compose -f deploy/keycloak/compose.yaml up -d
deploy/keycloak/README.md has the realm's users, clients, ports and redirect URIs, and the
exact environment to serve the registry with. The credentials in it are test values committed on
purpose: that is what makes the setup reproducible rather than click-configured. Nothing in that
directory belongs anywhere near a real deployment — it runs in development mode, over plain
HTTP, with a database that is wiped on every down.
Its clients already carry the audience mappers for the origins it documents. Serve on any other origin and you must add one.
Repository sync credentials
Keeping a software record in step with a private repository needs a forge token. If your
identity provider can broker one for the signed-in person, the registry reads exactly what that
person can read. Otherwise TAR_FORGE_TOKEN is a registry-wide fallback, which means every
curator can pull anything that token can see. See Registering
software.
What is not covered
Nothing on the browser sign-in side. Token refresh — once a gap — now renews silently on a
timer and on a 401, with the refresh token kept in memory only; see
Token refresh.
Backup and restore
What state there is
Two stores under TAR_DATA_DIR:
| Holds | |
|---|---|
| The graph store | Every record: software, releases, deployments, runs, artifacts, vocabulary, cached peer stubs. |
| A SQLite database | Hashed API tokens, peers, the audit log, federation cursors, idempotency keys, subscriptions and their delivery queues. |
They are separate on purpose. The graph is the catalogue and is meant to be dumped, diffed and reloaded; the SQLite side is operational bookkeeping that is mostly reconstructible and contains the only secrets — hashed, but still.
Backing up the directory backs up both. Do it with the process stopped.
Dump and restore the graph
tar dump > registry.nq # every graph, as N-Quads
tar dump --graph urn:tar:local > local.nq # just this registry's own records
tar restore --nquads registry.nq
N-Quads rather than Turtle because the named graph is part of the meaning — which graph a statement is in is what distinguishes this registry's records from a peer's cached stub, and a format that dropped it would silently merge the two. See Named graphs.
A dump is also the honest way to migrate to a new base IRI, in that it shows you exactly how many identifiers you are about to invalidate. The registry will not rewrite them for you.
GET /admin/dump serves the same thing over HTTP, for admins.
Shapes and vocabulary reload on boot
The SHACL shapes and the bundled vocabularies are reloaded from disk on every start. That is idempotent, and it is also how a graph migration is applied: change the shapes file, restart, and the new rules are in force for subsequent writes.
It also means a restored dump does not need to carry them.
Records a shapes change can strand
A write is judged on the whole record it asserts, and a PATCH carries the fields the caller did
not name. So a record citing a vocabulary term the registry has since retired is refused on an
edit to some entirely different field.
The boot log names every such record and the term, once, rather than deleting a value nobody asked it to delete. Replacing or clearing the named term fixes the record permanently. See Limitations.
Health and monitoring
GET /healthz liveness
GET /readyz readiness
GET /metrics Prometheus text format
/metrics reports total triples, record counts by kind, and how many peers are configured and
how many are failing to resolve. All three are public regardless of TAR_PUBLIC_READ, because a
liveness probe that needs a credential is a liveness probe that fails for the wrong reason.
The container image's own healthcheck runs tar healthcheck.
Auditing
Every write is recorded with the principal, the kind of actor, the operation and the record.
GET /api/v1/audit returns it, for admins.
It is in SQLite rather than in the graph, deliberately: the audit log is about the registry, not part of the catalogue, and putting it in the graph would mean it federated.
Checking the configuration
tar config
Prints the effective configuration with secrets redacted — the base IRI, data directory, listen
address, read and validation switches, whether a root token is set, the static directory, the
OIDC issuer and workload issuers, and the peer resolution settings. It reads the environment
exactly as serve does, so it answers "why is this registry behaving like that" without
starting it.
Limitations
Where the prototype departs from its design, or stops short of it. Written down rather than discovered.
1. Write validation sees the candidate record, not the whole graph
SHACL validation runs on the record being written, before it is committed. Constraints that
would need the rest of the store — sh:class on a referenced node, say — are therefore not
evaluated. That is the price of validating before committing rather than after.
Validation itself is real SHACL: shapes/tar-shapes.ttl is enforced by a SHACL engine, and
editing that file changes what the API accepts with no Rust change. Severity decides:
sh:Violation blocks, sh:Warning never does, and TAR_SHACL_VALIDATE_WRITES=false downgrades
violations to warnings.
Two rules that do need the rest of the graph live in Rust for exactly this reason, and report
through the same sh:ValidationReport: whether an artifact type is a term the registry holds,
and whether a deployment of non-deployable software may carry an endpoint. Neither honours
TAR_SHACL_VALIDATE_WRITES — a half-described record is a trade an operator can make, an
unlookuppable type is not.
The first of those is exactly sh:class, and it was measured rather than assumed. Injecting the
bundles' class assertions into the candidate data graph so the engine can see them takes
validation from 1.9 ms to 9.8 ms per write, plus 2.8 ms to read them back, against the
52 µs the targeted lookup costs — roughly 2 ms a write against 12.6 ms.
The time is the smaller objection. Three things a shape cannot do at all settled it:
sh:message is fixed text and could not name the offending IRI or the way out;
TAR_SHACL_VALIDATE_WRITES=false would switch the rule off; and the allowance for a type cached
from a peer is about which named graph a concept sits in, which the engine's single data graph
cannot express.
2. Distributions and capabilities are IRIs, not blank nodes
The design shows blank nodes. They are minted as IRIs instead, which makes them addressable and
citable. A record still reads as one document, because describe returns them with their parent.
3. Two denormalised links
tar:instanceOf and tar:atInstance sit alongside the authoritative
prov:qualifiedAssociation. Every list and count query would otherwise be a two-hop join
through a reified node.
4. Repository liveness metrics are not implemented
Repository sync is — a record can keep named fields in step with its source repository. What is missing is the liveness signal the design asked for: stars, forks, last commit. The UI degrades by omitting those cells rather than rendering zeros.
5. Human sign-in has no token refresh — closed
Sign-in itself is verified against a live identity provider: a browser was driven through
authorisation code + PKCE, and the curator and admin roles were confirmed to decide what a
signed-in person may do — POST /api/v1/software succeeds for a curator, /api/v1/peers is
403 for a curator and 200 for an admin.
The gap was refresh: the access token was used until it expired with no silent renewal, so a
long editing session ended in a 401 and a re-sign-in with the form's contents lost. The
registry now renews twice over — from a timer set from the token's own exp claim, ahead of
expiry, and reactively when a request comes back 401 for the case the timer missed (a laptop
that slept through it). Concurrent requests that all expire together share one renewal rather
than each spending the refresh token, which matters once a provider rotates it: the first use
would otherwise invalidate it for the rest.
The refresh token itself stays exactly where the design note said it should not go: in memory
only, never sessionStorage, localStorage, or a cookie. A closed tab or a reload ends the
renewable session — the access token in sessionStorage keeps working until it expires, then
sign-in is needed again — which is the trade this file has always made, kept rather than
loosened to get renewal.
A registry API token has no refresh token to renew with, so nothing changes for it: a 401
still means sign in again, exactly as before.
6. Federated search is not deduplicated
Fan-out is live, and results are not deduplicated across peers beyond the origin chip. Two peers that both cache a stub of the same third-party record produce two rows.
7. Peer resolution caches more than it needs to
The resolver fetches a whole Turtle document into the peer graph rather than extracting a minimal stub, so a verbose peer can cache considerably more than the type, title, publisher and home registry the design called for.
8. Keyset pagination orders by IRI string
Which is time-ordered within one registry, because identifiers are UUIDv7. Across origins it interleaves imperfectly.
9. No lineage graph visualisation
Deferred. GET /api/v1/graph and GET /api/v1/artifacts/{id}/lineage already return exactly
what a graph view would need.
10. A shapes or vocabulary change can strand an existing record
A write is judged on the whole record it asserts, and a PATCH carries the fields the caller did
not name — so a record citing a term the registry has since retired is refused on an edit to some
other field entirely.
The boot log names every such record and the term, once, rather than deleting a value nobody asked it to delete. Replacing or clearing the named term fixes the record permanently. Nothing shipped is affected; a long-lived store might be.
11. A type resolved from a peer is accepted but not offered
It is a term this registry holds, so citing it is fine. It carries none of this registry's own classes, so the picker does not list it.
That is the conservative half-answer. Which registry owns a term, and whether adopting a peer's implies agreeing with it, is a federation question this prototype does not settle. The picker's adopt flow is how to make such a term first-class here.
12. Observed capability is not reconciled with declared capability
The software page shows what was declared. What a deployment has actually produced is one SPARQL query away, and the two can disagree. Surfacing the disagreement should wait until there is enough run data for it to mean something.
13. A residual DNS-rebinding race on webhook targets — closed
A subscription's webhook URL is checked against private and loopback addresses at registration
and re-resolved at send time. The send-time check used to return only a verdict, leaving the
HTTP client to resolve the name a second time — so a record with a short TTL could answer the
check with a public address and the connection with 169.254.169.254.
The check now returns the addresses it approved and the delivery is pinned to them, so there is no second lookup to win. TLS still verifies the certificate against the hostname: pinning replaces DNS, not identity.
14. Subscriptions have no scope of their own
Managing a subscription reuses the rule that governs token management — admin, curator, or the
credential of the owning deployment. There is no subscribe:* scope, so a credential cannot be
issued that may subscribe and nothing else.
15. The external SPARQL backend blocks a worker thread per store call — closed for the request path
GraphStore is a synchronous trait — it predates the second backend, and every read in the
registry is a plain function call because the store used to be in-process. Against
TAR_SPARQL_ENDPOINT, calling one of its methods directly from an async handler occupied that
handler's Tokio worker thread for the whole HTTP round trip: on a multi-threaded runtime with
more concurrent requests than worker threads, one slow remote query delayed every other request
the same worker was about to advance.
Making the trait itself async fn — the fix this entry used to name — turned out to be far
larger than it reads: not 119 call sites, but recolouring most of src/domain, most of
src/api, and src/auth/jwt, converting every synchronous store-layer unit test to
#[tokio::test], and rewriting iterator patterns that call domain functions into stream
combinators. The change actually made is the one Tokio's own documentation recommends for
exactly this shape of problem: every request-path call site — all ~90 API handlers across 18
files, and the three OIDC credential-binding lookups in auth::jwt that run on every
bearer-token request before any handler is reached — passes its synchronous, store-touching
work to [error::blocking], which runs it on Tokio's dedicated blocking thread pool and gives
the worker thread back to the scheduler for the round trip's duration. Ctx moved from
borrowing &AppState to owning an Arc<AppState> clone so it can cross that boundary. Nothing
in src/domain or src/store changed: the same synchronous functions run, just on a different
thread.
Two call sites were deliberately left as they were, both flagged in code comments: the
per-artifact and per-was_derived_from-parent existence checks inside advertise()'s main
loop, and the equivalent one in the OpenLineage adapter's map_dataset. Both interleave a
synchronous exists() check with state.ops.* calls that are already async, inside a loop that
decides its own control flow per iteration — cleanly separating the two would mean restructuring
the loop into two passes, which is a larger and more error-prone change than the check's actual
cost justifies: it is a single fast existence lookup, not a scan.
Also unwrapped, and deliberately: seed.rs, bundles.rs::sync, and the peer-resolver and
health-check background loops. Those run once at boot or pace themselves on their own timer,
never competing with a flood of concurrent user requests for the same worker pool, which is the
specific failure this fix closes.
Embedded Oxigraph, the default, is unaffected either way: those calls never leave the process, so moving them to a different thread pool costs a small fixed dispatch overhead and buys nothing. The behavioural difference only shows up against a remote endpoint under real concurrent load.
16. Atomicity on an external endpoint is the server's promise, not ours
A registry write is built into a single SPARQL Update request so that its deletions and insertions are one unit. Fuseki, GraphDB, Virtuoso and Oxigraph's own server execute a request in one transaction, which is what makes this equivalent to the embedded backend's transaction.
SPARQL 1.1 does not require that. Against a server that processes the operations of a request independently, a write is atomic only per operation, and the registry has no way to tell over HTTP which kind of server it is talking to. It does not attempt to detect it and does not claim the guarantee it cannot verify.
17. Two rough edges in the external backend
Neither affects the embedded default.
A subject deletion follows the ownership closure four levels deep, where the embedded store's walk is unbounded. A record's sub-resources — its distributions, its checksums — are removed with it so that replacing a record does not orphan them, and doing that inside one atomic update request means a fixed-depth pattern rather than a walk that can look at what it found. The deepest nesting the registry writes is two levels, so this is headroom; a sub-resource nested deeper than four would be orphaned against an external endpoint and removed against the embedded one.
/admin/dump and tar dump materialise the whole graph in memory on the external backend,
because there is no streaming path from a SELECT result to the response body. Embedded
Oxigraph streams. For a catalogue-sized registry this is fine and for a very large external
dataset it is not; back that up with the store's own tools instead.
18. tar dump --graph loses the graph name, on either backend
tar dump with no argument writes N-Quads and restores faithfully. tar dump --graph <g> writes
N-Triples — which is what /admin/dump?graph= and peer stub exchange want — so restoring that
file with tar restore puts its triples in the default graph, where nothing looks for them.
Pre-existing behaviour, unchanged by the second backend, and worth knowing before using a
single-graph dump as a backup.
Design record
The documents that were written before and during the build. They are kept as a record of what was decided and why, not as a description of the current system — where a spec and the code disagree, the code is right and the rest of this site describes it.
They are worth reading for the reasoning. The chapters elsewhere say what the registry does; these say what else was considered.
| Design | The whole system: the model, the endpoints, the authorisation rules, the open questions. |
| Vocabulary audit | Which standard vocabularies the RDF terms were checked against, and where a registry-specific term was unavoidable. |
| Workload identity | How a deployment authenticates without a stored secret. |
| Artifact subscriptions | Filters, delivery, retries, and the security argument for refusing private webhook targets. |
| Federated search propagation | Live fan-out across a graph of registries without looping. |
The frontend handoff is the corresponding document for the UI, including the questions it left open and the answers they got.
Tool Artifact Registry — Design
| Status | Draft for review |
| Date | 2026-08-30 |
| Owner | Ensar Emir Erol — MaastrichtU-IDS |
| Frontend handoff | docs/design-handoff.md |
1. Problem
MaastrichtU-IDS runs a growing set of tools — shacl-manager, sulo-schema-builder,
rdf_tx, obda-lazy-cache-demo — across several deployments (the ids3 and idsg2
clusters, partner sites, laptops). Each consumes and produces data artifacts: RDF graphs,
SHACL shapes, validation reports, OWL ontologies, mapping files, masked replicas.
Today there is no shared record of:
- what tools exist, who is responsible for them, under what licence;
- what a tool can consume and produce;
- what a given deployment actually did produce, and when;
- where those artifacts live and how to get at them;
- how an artifact in one institution's estate relates to one in another's.
Nothing outlives the person who ran the job. This document specifies a service that fixes that, and that any third party can deploy for their own estate and cross-link with ours.
1.1 Requirements
Verbatim from the stakeholder:
- Store in a graph (or NoSQL/SQL DB) — choose and justify.
- Tool information (name, link, repo, licence, responsible party, …).
- Artifacts each tool generates and consumes.
- Endpoint to advertise artifacts a tool generates (with access ways, FAIR data).
- Endpoint to advertise artifacts a tool consumed (or, tool sent to be consumed).
- Deployable by many people: anyone can run their own artifact registry (containerised, minimal ops, sane defaults, Helm/compose).
- Deployments must be referenceable by each other: a tool or artifact in one registry can point at one in another registry (stable global identifiers, federation/cross-links, discovery of peer registries).
1.2 Non-goals for v1
- Not an artifact store. The registry never holds artifact bytes (§3, D1).
- Not a workflow engine. It records what ran; it does not schedule or execute.
- Not multi-tenant. One registry serves one estate; multiple estates federate (§9).
- Not an access broker. It describes how to request access; it does not grant it.
2. Prior art
Three mature ecosystems exist. None covers the intersection this project needs. Building is justified — reinventing their formats is not.
2.1 Software / tool registries
| System | What it does well | Why it is not enough |
|---|---|---|
| bio.tools + biotoolsSchema | 20k+ tools; 50+ curated attributes; EDAM-typed inputs/outputs per function; the reference vocabulary for describing a computational tool | Class-level I/O only. No artifact instances, no runs, no deployments, no federation of independent installs. |
| Research Software Directory (RSD) | Self-hostable (Docker images per release), already run by NLeSC, Utrecht, Leiden, Amsterdam UMC; excellent citation and impact UX | No artifact model at all. Cannot express "this deployment produced this file". Instances are not modelled; federation is not a feature. |
| WorkflowHub, Software Heritage, OpenAIRE | Archival, citation, DOIs | Same gap: describe the software, not what it emitted. |
2.2 Data lineage platforms
| System | What it does well | Why it is not enough |
|---|---|---|
| OpenLineage (Linux Foundation) | The standard event format for "job run X consumed dataset A, produced dataset B"; native emitters in Airflow, Spark, dbt, Flink, Dagster | Not RDF. No licence, no checksum, no DCAT distributions, no access protocol or auth descriptor, no dereferenceable identifiers, no capability declarations, no federation. |
| Marquez (OL reference impl.), DataHub, OpenMetadata, Apache Atlas, Egeria, Spline | Self-hostable lineage capture and visualisation | Assume a single enterprise data platform. No cross-instance federation, no persistent global identifiers, no tool-registry semantics (licence, repo, responsible party), no FAIR story. |
2.3 Catalogue federation
CKAN + DCAT-AP harvesting, OAI-PMH, and Signposting solve federated catalogue discovery and machine-actionable FAIR navigation. None of them model tools, deployments, or runs.
2.4 Conclusion
The gap is the intersection: an RDF-native, self-hostable, federatable registry in which tool identity, declared capability, deployment instances, and instance-level produce/consume lineage live in one graph under dereferenceable global IRIs. No existing system ships that.
What we adopt rather than reinvent: biotoolsSchema, DCAT 3, PROV-O, EDAM, SPDX, FAIR Signposting, and OpenLineage (via a translating adapter, §7.6).
2.5 Why not just deploy RSD?
RSD is the closest existing system to requirement 6 and is institutionally adjacent to us. It is rejected because requirements 3, 4, 5 and 7 — the core of this project — are entirely absent from its data model, and retrofitting an artifact/run/instance graph onto a PostgREST-over-Postgres schema designed for software cards is a larger job than building on a triplestore that already speaks our vocabularies.
We keep the door open: GET /api/v1/software/{id}/export/biotools emits a
biotoolsSchema-conformant record, so our tool descriptions can populate a bio.tools or RSD
instance without a runtime dependency in either direction (§7.2).
3. Decisions
| # | Decision | Rationale | Rejected |
|---|---|---|---|
| D1 | Metadata and pointers only. The registry never stores artifact bytes. | Keeps ops minimal (req. 6), makes federation cheap, avoids storage scaling and quota/GC. Artifacts stay in the systems that own them. | Optional blob store; content-addressed pinning cache. Both add a storage dependency for marginal benefit at our scale. |
| D2 | Identifiers: {base_iri}/{kind}/{uuidv7}, HTTPS, dereferenceable, content-negotiated. | No central coordination, no collisions across peers, time-ordered keys, resolvable by any client. Human slugs exist as non-authoritative schema:identifier. | w3id.org (needs central coordination per namespace); readable slugs (rename breaks links, cross-peer collisions); DOI (cost, latency, not per-artifact viable). See §12 Q1 for a DOI overlay. |
| D3 | Store: embedded Oxigraph (RDF) + SQLite (operational state). | §5. | Fuseki/QLever (second container, JVM or index build); Postgres+JSONB (impedance mismatch with DCAT/PROV); Neo4j (non-standard serialisation, weak FAIR alignment). |
| D4 | Stack: Rust — axum + oxigraph + sqlx. | Single static binary, ~30 MB image, no runtime deps → best possible answer to "anyone can run their own" (req. 6). Matches rdf_tx. Oxigraph is a Rust crate, so the triplestore embeds rather than being a service. | Python/FastAPI (matches more sibling repos, but needs an external triplestore process). |
| D5 | Four-layer model: Software → Release → Instance → Run. | Runs belong to a deployment, not to abstract software. Two sites running shacl-manager are two Instances of one Software; that is the join key across registries. | Collapsing Instance into Software (cannot attribute runs or endpoints); collapsing Release into Software (no image digest provenance). |
| D6 | Capability (class-level) and Run (instance-level) are both first-class. | Capability answers "what can produce a SHACL report?" — discovery works before anything has run. Run answers "where did this come from, who used it?" — lineage and audit. | Runs only (cold-start discovery problem); capability only (loses the whole provenance story). |
| D7 | Native JSON-LD/DCAT+PROV is the canonical API; OpenLineage is a translating adapter endpoint. | OL covers the run-event skeleton and essentially nothing else we need — no licence, no distributions, no checksum, no access protocol, no capabilities, no resolvable IRIs (see §7.6 gap table). Canonical-native keeps semantics clean; the adapter still buys free Airflow/dbt/Spark integration. | OL as the sole wire format (our FAIR fields would live in a non-standard facet and Capability would have no home). |
| D8 | Auth: API tokens per Instance + OIDC for humans. Anonymous read by default. | The advertising party is a deployment; a token that identifies an Instance is exactly the authorisation and attribution primitive the advertise endpoints need. Keycloak already runs on ids3. | Open write (unusable as a record of authority); signed advertisements (deferred, §12 Q2). |
| D9 | Federation: cross-link + lazy resolve, opt-in peer list. | Advertisement never blocks on network. No global consensus, no harvest storage, no staleness reconciliation. Peers are added deliberately — the trust boundary stays manual. | Harvest/mirror (storage + conflict rules); live fan-out only (availability hostage to slowest peer). |
| D10 | Artifacts are immutable once advertised. Corrections and new versions mint a new IRI linked by prov:wasRevisionOf / dct:isVersionOf to a version-series concept IRI. | A lineage edge that can silently change meaning is worthless. Mirrors Zenodo's concept-DOI/version-DOI split. | Mutable artifact records with in-place edits. |
| D11 | ArtifactType is any IRI. EDAM is preloaded and is the recommended default. | Life-science typing must not be a hard dependency for non-bio artifacts (SHACL shapes, OBDA mappings). | EDAM-only (excludes our own artifact kinds). |
4. Data model
4.1 Layers
Software shacl-manager abstract; repo, licence, party
└─ Release v2.1 (image sha256:ab12…) a versioned, runnable plan
└─ Instance shacl.ids.unimaas.nl a deployment; agent that acts
└─ Run 01J9F… 2026-08-30T14:02Z one execution
├─ used → Artifact (consume advertisement)
└─ generated → Artifact (produce advertisement)
└─ Distribution access descriptors
4.2 Entities
| Entity | RDF type(s) | Key fields |
|---|---|---|
Registry | dcat:Catalog | base IRI, title, operator, software version, SPARQL URL, peers |
Agent | schema:Person, schema:Organization, prov:Agent | name, ORCID / ROR, email, homepage |
Software | schema:SoftwareApplication, schema:SoftwareSourceCode | name, description, homepage, codeRepository, dct:license (SPDX IRI), applicationCategory, EDAM topics, keywords, maturity, dct:publisher, contact Agent |
Release | schema:SoftwareApplication (versioned), prov:Plan | schema:softwareVersion, schema:datePublished, container image ref + digest, changelog URL, dct:isVersionOf → Software |
Instance | prov:SoftwareAgent; also dcat:DataService when it serves an endpoint | label, tar:runsRelease → Release, operator Agent, dcat:endpointURL, dcat:endpointDescription (OpenAPI/service description), tar:availability, jurisdiction, health, home registry |
ArtifactType | skos:Concept (any IRI; EDAM preloaded) | label, definition, default media type |
Capability | prov:Plan, tar:Capability | tar:produces → ArtifactType[], tar:consumes → ArtifactType[], declared at Software or Release; an Instance may narrow it |
Artifact | dcat:Dataset, prov:Entity | title, description, dct:conformsTo → ArtifactType, keywords, dct:license, dct:issued, prov:wasDerivedFrom, prov:wasRevisionOf, dct:isVersionOf → version-series IRI |
Distribution | dcat:Distribution | see §6.1 |
Run | prov:Activity | prov:startedAtTime, prov:endedAtTime, tar:status, prov:qualifiedAssociation, prov:used, external run key |
Peer | dcat:Catalog (foreign) | base IRI, title, last seen, resolve status |
ApiToken | (SQLite only, never in RDF) | hash, Instance, scopes, expiry, created-by |
4.3 Relations
@prefix tar: <https://w3id.org/tar/ns#> .
<Release> dct:isVersionOf <Software> .
<Software> tar:hasCapability <Capability> .
<Capability> tar:produces <ArtifactType> ;
tar:consumes <ArtifactType> .
<Instance> a prov:SoftwareAgent ;
tar:runsRelease <Release> ;
dct:publisher <Agent> ;
dcat:endpointURL <https://shacl.ids.unimaas.nl> .
<Run> a prov:Activity ;
prov:qualifiedAssociation [ prov:agent <Instance> ;
prov:hadPlan <Release> ] ;
prov:used <Artifact_in> . # consumed
<Artifact_out> prov:wasGeneratedBy <Run> . # produced
<Artifact> dcat:distribution <Distribution> ;
dct:conformsTo <ArtifactType> ;
prov:wasDerivedFrom <https://peer.example.org/artifact/01J7…> .
The prov:qualifiedAssociation form is deliberate: it binds who acted (the Instance) and
what plan they followed (the Release) in one reified node, which is exactly PROV's
intended use and lets a run be attributed even when the Release is unknown.
Federation touches the model in exactly one place: any object position may hold a foreign IRI. There is no "remote artifact" type. A cross-registry lineage edge is an ordinary triple.
4.4 Identifiers
{TAR_BASE_IRI}/software/{uuidv7}
{TAR_BASE_IRI}/release/{uuidv7}
{TAR_BASE_IRI}/instance/{uuidv7}
{TAR_BASE_IRI}/artifact/{uuidv7}
{TAR_BASE_IRI}/artifact-series/{uuidv7} # version concept IRI (D10)
{TAR_BASE_IRI}/run/{uuidv7}
{TAR_BASE_IRI}/type/{uuidv7} # local ArtifactTypes only; EDAM keeps its own IRIs
Every IRI dereferences with content negotiation: text/turtle, application/ld+json,
application/json (a flattened developer-facing shape), text/html (the UI page).
4.5 Worked example
<https://reg.ids.unimaas.nl/software/01J8A…>
a schema:SoftwareApplication ;
schema:name "shacl-manager" ;
schema:codeRepository <https://github.com/MaastrichtU-IDS/shacl-manager> ;
dct:license <https://spdx.org/licenses/Apache-2.0> ;
dct:publisher <https://ror.org/02jz4aj89> ;
tar:hasCapability [
tar:consumes <http://edamontology.org/data_2600> ,
<https://reg.ids.unimaas.nl/type/01J8B…> ; # SHACL shapes graph
tar:produces <http://edamontology.org/data_2048> ] .
<https://reg.ids.unimaas.nl/instance/01J8C…>
a prov:SoftwareAgent, dcat:DataService ;
rdfs:label "shacl.ids.unimaas.nl" ;
tar:runsRelease <https://reg.ids.unimaas.nl/release/01J8D…> ;
dcat:endpointURL <https://shacl.ids.unimaas.nl> .
<https://reg.ids.unimaas.nl/run/01J9F…>
a prov:Activity ;
prov:startedAtTime "2026-08-30T14:02:11Z"^^xsd:dateTime ;
tar:status "success" ;
prov:qualifiedAssociation [ prov:agent <https://reg.ids.unimaas.nl/instance/01J8C…> ;
prov:hadPlan <https://reg.ids.unimaas.nl/release/01J8D…> ] ;
prov:used <https://reg.mumc.nl/artifact/01J7Z…> . # foreign input
<https://reg.ids.unimaas.nl/artifact/01J9G…>
a dcat:Dataset ;
dct:title "Validation report — patients.ttl vs fhir-shapes v3" ;
dct:conformsTo <http://edamontology.org/data_2048> ;
dct:license <https://spdx.org/licenses/CC-BY-4.0> ;
prov:wasGeneratedBy <https://reg.ids.unimaas.nl/run/01J9F…> ;
dcat:distribution [
a dcat:Distribution ;
dcat:accessURL <https://shacl.ids.unimaas.nl/reports/9f2a> ;
dcat:mediaType "text/turtle" ;
dcat:byteSize 2118342 ;
spdx:checksum [ spdx:algorithm spdx:checksumAlgorithm_sha256 ;
spdx:checksumValue "9f2a…" ] ;
tar:accessProtocol "https" ;
tar:authMethod "apikey" ;
tar:availability "restricted" ] .
5. Store choice
5.1 Decision
Embedded Oxigraph for the RDF domain graph, plus
SQLite (via sqlx) for operational state. Both embed in the single Rust binary. The default
deployment is one container and one volume.
| Data | Store | Why |
|---|---|---|
| Software, Releases, Instances, Capabilities, Artifacts, Distributions, Runs, peer stubs | Oxigraph | Native RDF. Zero impedance mismatch with DCAT/PROV-O/biotoolsSchema. SPARQL 1.1 becomes a public API for free. Open-world extension without migrations. |
| API tokens, OIDC sessions, peer sync cursors and backoff, audit log, resolve cache TTLs, background job state, rate-limit counters | SQLite | Relational, transactional, frequently mutated, and must never be exposed via the public SPARQL endpoint. Secrets do not belong in a queryable graph. |
5.2 Justification against the requirements
- Req. 1 (graph): the domain genuinely is a graph — lineage traversal, cross-registry cross-links, typed relations. Modelling it as RDF also is the interoperability story; a relational schema would need a serialisation layer to reach the same place.
- Req. 6 (minimal ops): no external database process. No JVM (Fuseki), no index build
step (QLever), no separate Postgres.
docker run -v tar-data:/data …is a complete install. - Req. 7 (federation): SPARQL
SERVICEgives federated query to power users at no implementation cost, and peer graphs isolate foreign data cleanly (§5.4).
5.3 Costs, honestly
| Cost | Mitigation |
|---|---|
| Oxigraph is single-writer; no clustering | Catalogue-scale workload. Writes are advertisements, not a data plane. Deployment is 1 replica by design (§10). |
| No relational constraints on the graph | SHACL shapes ship with the registry and validate every write before commit (TAR_SHACL_VALIDATE_WRITES, default on), enforced by the shacl-rust engine. Dogfoods shacl-manager's shapes. |
| SPARQL pagination is awkward | The REST API owns pagination via SQLite-backed keyset cursors; SPARQL is for ad-hoc analytical use, not the UI. |
| Ceiling if an estate outgrows embedded storage | All graph access sits behind a GraphStore trait. A RemoteSparqlStore implementation (Fuseki / QLever / GraphDB) is a config switch, not a rewrite. This is a v1 structural requirement, not a v2 aspiration. |
5.4 Named graphs
<urn:tar:local> authoritative — triples this registry minted
<urn:tar:peer:{id}> cached foreign stubs, read-only
<urn:tar:shapes> SHACL shapes used for write validation
<urn:tar:vocab> preloaded EDAM / SPDX / DCAT terms
Provenance of every triple is recoverable by construction; foreign data can never be mistaken
for local authority; evicting a peer is DROP GRAPH <urn:tar:peer:{id}>.
6. FAIR metadata and access descriptors
6.1 dcat:Distribution fields
| Field | Vocabulary | Notes |
|---|---|---|
dcat:accessURL | DCAT | Landing page or service entry point |
dcat:downloadURL | DCAT | Direct bytes, when they exist |
dcat:mediaType / dct:format | DCAT / DCTERMS | IANA media type |
dct:conformsTo | DCTERMS | SHACL shape or profile IRI the bytes conform to |
dcat:byteSize | DCAT | |
spdx:checksum | SPDX | algorithm + value; sha256 recommended |
dct:license, dct:rights, odrl:hasPolicy | DCTERMS / ODRL | |
dcat:accessService | DCAT | → a dcat:DataService (SPARQL endpoint, S3 bucket, OGC API) |
tar:accessProtocol | ours | https | http | s3 | sparql | oci | ipfs | file. http was added after a deployment serving over plain HTTP had to omit the field entirely, which loses the one thing worth recording — that the transport is unencrypted. |
tar:authMethod | ours | none | apikey | oauth2 | basic | signed-url |
tar:availability | ours | public | restricted | embargoed | metadata-only |
tar:accessRequestURL | ours | Where to request access when not public |
Vocabulary audit (2026-08-30): every
tar:term above was audited against the standard vocabularies — see2026-08-30-vocabulary-audit.md.tar:availabilityis now always written besidedct:accessRightswith the EU access-right authority table (the DCAT-AP reading); several othertar:terms were replaced outright (tar:atInstance→prov:wasAssociatedWith,tar:externalKey→dct:identifier, …).
6.2 metadata-only is the common case, not an edge case
For IDS's health-data work, most artifacts must be findable and described but not
retrievable. tar:availability = metadata-only means: the registry advertises that the
artifact exists, its type, its shape, its provenance chain, its responsible party, and
tar:accessRequestURL — and carries no downloadURL at all. FAIR is not open, and the model
says so structurally rather than by convention.
6.3 Signposting
Every artifact and software GET emits Signposting Link headers:
Link: <https://reg.ids.unimaas.nl/artifact/01J9G…> ; rel="cite-as"
Link: <https://reg.ids.unimaas.nl/artifact/01J9G….ttl> ; rel="describedby"; type="text/turtle"
Link: <https://reg.ids.unimaas.nl/artifact/01J9G….jsonld> ; rel="describedby"; type="application/ld+json"
Link: <http://edamontology.org/data_2048> ; rel="type"
Link: <https://shacl.ids.unimaas.nl/reports/9f2a> ; rel="item"; type="text/turtle"
Link: <https://spdx.org/licenses/CC-BY-4.0> ; rel="license"
Link: <https://orcid.org/0000-…> ; rel="author"
Link: <https://reg.ids.unimaas.nl/api/v1/registry> ; rel="collection"
metadata-only artifacts omit rel="item" and add rel="describedby" only — a client can
tell the difference between "no bytes here" and "bytes behind auth" without parsing the body.
6.4 FAIR principle coverage
| Principle | Mechanism |
|---|---|
| F1 unique persistent ID | UUIDv7 HTTPS IRIs (D2); registry-of-mint is authoritative (§9.7) |
| F2 rich metadata | DCAT + PROV-O + biotoolsSchema + EDAM |
| F3 metadata includes ID of data | dcat:distribution → accessURL/downloadURL |
| F4 registered/indexed | The registry itself; /api/v1/search; peer federation |
| A1 retrievable by ID over open protocol | HTTPS content negotiation on every IRI |
| A1.2 auth where necessary | tar:authMethod, tar:accessRequestURL |
| A2 metadata persists when data does not | Metadata is independent of the bytes; metadata-only and tombstoned distributions remain |
| I1 formal knowledge representation | RDF / Turtle / JSON-LD |
| I2 FAIR vocabularies | EDAM, SPDX, DCAT, PROV-O, SKOS, ODRL |
| I3 qualified references | prov:wasDerivedFrom, prov:used, prov:qualifiedAssociation |
| R1.1 clear licence | dct:license on Software, Artifact and Distribution |
| R1.2 provenance | The Run graph — this is the project's core contribution |
| R1.3 community standards | biotoolsSchema export; DCAT-AP-compatible catalogue |
7. API surface
Base path /api/v1. Every GET on a resource honours Accept for text/turtle,
application/ld+json, application/json, text/html, and emits Signposting headers.
7.1 Identity and discovery
GET /.well-known/tar-registry registry self-description (JSON-LD)
GET /api/v1/registry dcat:Catalog record
GET /healthz /readyz /metrics liveness, readiness, Prometheus
7.2 Software and releases
POST /api/v1/software register
GET /api/v1/software list; q, license, publisher, edam_topic, keyword, kind
GET /api/v1/software/{id}
PATCH /api/v1/software/{id}
DELETE /api/v1/software/{id} soft delete (tombstone; IRI keeps resolving)
POST /api/v1/software/{id}/releases
GET /api/v1/software/{id}/releases
GET /api/v1/software/{id}/export/biotools biotoolsSchema JSON (§2.5)
7.3 Capabilities
PUT /api/v1/software/{id}/capability declare produces[] / consumes[]
PUT /api/v1/instances/{id}/capability narrow the inherited declaration
GET /api/v1/capabilities?produces={typeIRI}&consumes={typeIRI}
GET /api/v1/capabilities is the matchmaking endpoint: "what can consume what
shacl-manager emits?" It answers before any run exists, which is why D6 keeps it separate
from lineage.
7.4 Instances
POST /api/v1/instances register a deployment
GET /api/v1/instances list; software, operator, status, release, registry
GET /api/v1/instances/{id}
PATCH /api/v1/instances/{id}
POST /api/v1/instances/{id}/tokens mint a scoped API token
GET /api/v1/instances/{id}/runs
GET /api/v1/instances/{id}/artifacts
7.5 Artifacts, runs, advertisement
POST /api/v1/artifacts register an artifact + distributions
GET /api/v1/artifacts/{id}
GET /api/v1/artifacts/{id}/lineage?depth=&direction=up|down|both
GET /api/v1/runs/{id}
POST /api/v1/advertise/produced requirement 4
POST /api/v1/advertise/consumed requirement 5
Both advertisement endpoints are idempotent on (run_key, artifact_iri, role) — a retried
CI step does not duplicate lineage. Both accept foreign IRIs in artifact position; that is how
cross-registry lineage forms with no coordination.
POST /api/v1/advertise/produced:
{
"run": {
"external_key": "gh-actions/12345/attempt-1",
"started_at": "2026-08-30T14:02:11Z",
"ended_at": "2026-08-30T14:02:49Z",
"status": "success",
"release": "https://reg.ids.unimaas.nl/release/01J8D…"
},
"artifacts": [{
"title": "Validation report — patients.ttl vs fhir-shapes v3",
"conforms_to": "http://edamontology.org/data_2048",
"license": "https://spdx.org/licenses/CC-BY-4.0",
"keywords": ["shacl", "validation", "fhir"],
"was_derived_from": ["https://reg.mumc.nl/artifact/01J7Z…"],
"distributions": [{
"access_url": "https://shacl.ids.unimaas.nl/reports/9f2a",
"download_url": "https://shacl.ids.unimaas.nl/reports/9f2a.ttl",
"media_type": "text/turtle",
"byte_size": 2118342,
"checksum": { "algorithm": "sha256", "value": "9f2a…" },
"conforms_to": "https://reg.ids.unimaas.nl/shapes/validation-report",
"access_protocol": "https",
"auth_method": "apikey",
"availability": "restricted",
"access_request_url": "https://ids.unimaas.nl/data-access"
}]
}]
}
Response 201:
{
"run": "https://reg.ids.unimaas.nl/run/01J9F…",
"artifacts": ["https://reg.ids.unimaas.nl/artifact/01J9G…"],
"created": true
}
POST /api/v1/advertise/consumed takes the same run block and an artifacts array whose
entries are either a bare reference or a full inline registration:
{
"run": { "external_key": "gh-actions/12345/attempt-1" },
"artifacts": [
{ "iri": "https://reg.mumc.nl/artifact/01J7Z…" },
{ "title": "local input graph", "conforms_to": "http://edamontology.org/data_2600",
"distributions": [{ "download_url": "s3://ids-bucket/in.ttl", "access_protocol": "s3" }] }
]
}
An unknown foreign IRI is stored verbatim and queued for background resolution. The advertisement never blocks on the network (§9.4).
The Instance is derived from the presenting token, never from the payload — see §8.3.
7.6 OpenLineage adapter
POST /api/v1/openlineage accepts an OpenLineage RunEvent
Rationale for the adapter-not-canonical shape (D7). OL coverage of our required fields:
| Requirement | OpenLineage |
|---|---|
| Run id, timestamps, state, job | covered — RunEvent, job, nominalTime |
| Repository link | covered — sourceCodeLocation job facet |
| Responsible party | covered — ownership facet |
| Storage format | partial — storage facet, fileFormat only |
| Alternate identifiers | partial — symlinks facet |
| Licence (SPDX) | absent |
| DCAT distribution set (accessURL vs downloadURL, mediaType, conformsTo, multiple distributions) | absent |
| Checksum | absent |
| Access protocol + auth method | absent |
| Dereferenceable global IRIs | absent — OL identifies datasets by (namespace, name) strings by design |
| Capability declarations | absent — OL is run-events only |
| Tool registry metadata (homepage, keywords, EDAM, citation, funding) | absent |
| Federation / peers / cross-links | absent |
Mapping performed on ingest:
| OpenLineage | Tool Artifact Registry |
|---|---|
run.runId | Run tar:externalKey; a local UUIDv7 IRI is minted |
eventTime + eventType | prov:startedAtTime / prov:endedAtTime; COMPLETE→success, FAIL/ABORT→failed |
job.namespace | Instance — resolved from the presenting token; payload value recorded as a label only |
job.name | run label; matched against Release when recognisable |
job.facets.sourceCodeLocation | Software schema:codeRepository; creates a stub Software if unknown |
job.facets.ownership | dct:publisher |
inputs[] | prov:used |
outputs[] | prov:wasGeneratedBy |
dataset.namespace + .name | Artifact tar:externalKey (identity key for idempotency) |
dataset.facets.symlinks | if a tar: IRI appears here, the artifact is matched to it instead of minted |
dataset.facets.storage.fileFormat | dcat:mediaType, best effort |
dataset.facets.dataSource.uri | dcat:accessURL |
dataset.facets.fairAccess (custom) | full Distribution per §6.1, when the producer supplies it |
| everything else | preserved verbatim as a JSON literal on the Run (tar:openLineagePayload) so nothing is lost |
7.7 Query
GET /api/v1/search?q=&type=&federated=false cross-entity, faceted
GET /api/v1/graph?iri=&depth= subgraph for UI rendering
POST /sparql read-only SPARQL 1.1 (Oxigraph)
POST /sparql is a first-class surface, not a bonus: it gives analysts and peer registries a
standard federated query language without us designing one.
7.8 Federation
GET /api/v1/peers
POST /api/v1/peers admin: add by base URL, validated via well-known
DELETE /api/v1/peers/{id} DROP GRAPH <urn:tar:peer:{id}>
POST /api/v1/peers/announce inbound mutual-discovery announcement
GET /api/v1/peers/suggested peers-of-peers, for admin review
GET /api/v1/resolve?iri= resolve a foreign IRI, cache a stub, return it
7.9 Errors
RFC 9457 application/problem+json throughout. SHACL write-validation failures return 422
with the validation report embedded as text/turtle in a report member — the same report
format shacl-manager emits, so tooling is shared.
8. Auth model
8.1 Principals
| Principal | Credential | Typical use |
|---|---|---|
| Instance | Bearer API token, scoped | CI job or running service advertising produce/consume |
| Human | OIDC (Keycloak on ids3); roles reader / curator / admin | UI: register software, mint tokens, manage peers |
| Anonymous | none | Read, unless TAR_PUBLIC_READ=false |
8.2 Scopes
advertise:produce, advertise:consume, register:software, register:instance,
read:private, admin:*. Tokens are minted per Instance, stored as Argon2id hashes in
SQLite, shown once, revocable, optionally expiring.
Bootstrap: TAR_ROOT_TOKEN on first boot creates the initial admin; the registry refuses to
start with a default or empty value.
8.3 Core authorisation rule
An Instance may only advertise runs in which it is itself the agent.
The Instance is taken from the presenting token and never from the request body. No principal
can forge another deployment's lineage. Any job.namespace or instance field in a payload is
retained as a label for debugging and ignored for authorisation.
Software records are editable by their creator or by a curator; ownership transfer is an
admin action. Every write records prov:wasAttributedTo in the graph and an append-only row
in the SQLite audit log (who, when, what, from where).
8.4 Federation trust
Peer data is always a read-only stub in <urn:tar:peer:{id}> and is never merged into
<urn:tar:local>. A peer cannot create, modify, or delete local records. Inbound
/api/v1/peers/announce only produces a suggestion for admin review — never an
auto-added peer.
9. Federation model
- Self-description.
/.well-known/tar-registryreturns JSON-LD: base IRI, title, operator, software version, public-read flag, SPARQL URL, capabilities, and the peer list. - Adding a peer. An admin
POSTs a base URL. The registry fetches the well-known document, validates it, checks the advertised base IRI matches, and stores the peer. Optionally itPOSTs/api/v1/peers/announceback for mutual discovery. - Cross-linking needs no resolution. An unknown foreign IRI in any object position is stored verbatim. Advertisement latency is never coupled to peer availability.
- Lazy resolution. A background worker dereferences unknown foreign IRIs with
Accept: text/turtle, writes a minimal stub (type, title, publisher, home registry) into the peer graph, and caches with a TTL (TAR_PEER_RESOLVE_TTL, default 24 h). Failures back off exponentially and are visible in the peer admin UI. - Peers of peers. The resolver reads a peer's advertised peer list and surfaces them at
/api/v1/peers/suggested. They are never auto-added — the trust boundary stays manual (D9). - Federated search.
?federated=truefans out to peers'/api/v1/searchwithTAR_FEDERATED_SEARCH_TIMEOUT(default 3 s), returning partial results with apartial: trueflag and a per-peer status list. Power users use SPARQLSERVICE. - Conflict rule. The registry that minted an IRI is authoritative for it. Stubs never
overwrite local triples. If two registries describe the same Software, the Software IRIs
differ and are linked by
owl:sameAs/schema:sameAs— set deliberately by a curator, never inferred.
10. Deployment
10.1 Artifact
One statically linked Rust binary in a distroless image (~30 MB). Volumes: /data (Oxigraph)
and /data/ops.db (SQLite).
10.2 Compose
services:
registry:
image: ghcr.io/maastrichtu-ids/tool-artifact-registry:0.1.0
environment:
TAR_BASE_IRI: https://reg.example.org
TAR_ROOT_TOKEN: ${TAR_ROOT_TOKEN:?set me}
ports: ["8080:8080"]
volumes: ["tar-data:/data"]
healthcheck:
test: ["CMD", "/tar", "healthcheck"]
volumes: { tar-data: }
One service. That is the whole minimal install (req. 6).
10.3 Helm
Chart at deploy/helm/tool-artifact-registry: Deployment (1 replica — Oxigraph is
single-writer, strategy: Recreate), RWO PVC, Service, Ingress, ConfigMap, Secret,
ServiceMonitor. Probes on /healthz and /readyz.
10.4 ids3
Kustomize overlay at services/ids3/projects/tool-artifact-registry/ following the existing
_base project pattern; an ArgoCD Application; Vault + VSO for TAR_ROOT_TOKEN and the OIDC
client secret; egress through egress-proxy for peer resolution and forge polling; Harbor for
the image; Traefik for ingress and TLS.
10.5 Configuration
| Variable | Default | Notes |
|---|---|---|
TAR_BASE_IRI | (required) | The only mandatory setting — IRIs cannot be minted without it. Changing it after data exists requires a documented rebase migration. |
TAR_ROOT_TOKEN | (required on first boot) | Refuses empty/default |
TAR_DATA_DIR | /data | |
TAR_LISTEN | 0.0.0.0:8080 | |
TAR_PUBLIC_READ | true | |
TAR_OIDC_ISSUER / _CLIENT_ID / _CLIENT_SECRET | unset | OIDC disabled when unset |
TAR_SHACL_VALIDATE_WRITES | true | |
TAR_PEER_RESOLVE_ENABLED | true | |
TAR_PEER_RESOLVE_TTL | 24h | |
TAR_PEER_RESOLVE_TIMEOUT | 5s | |
TAR_FEDERATED_SEARCH_TIMEOUT | 3s | |
TAR_FORGE_TOKEN | unset | GitHub/GitLab token for repo liveness metrics |
TAR_FORGE_POLL_INTERVAL | 24h | |
TAR_MAX_PAYLOAD_BYTES | 2MiB |
Everything but TAR_BASE_IRI and TAR_ROOT_TOKEN has a working default (req. 6, sane defaults).
10.6 Operations
- Backup:
GET /admin/dumpstreams N-Quads (all graphs, or?graph=); SQLite via.backup. - Restore:
tar restore --nquads dump.nq --ops ops.db. - Upgrade: graph migrations are additive SPARQL Updates shipped per release and applied
idempotently on boot; SQLite via
sqlx migrate. - Scaling: read-heavy growth is answered by the
GraphStoretrait and a remote-SPARQL backend (§5.3), not by replicas.
10.7 Seed data
tar seed --from ids-examples registers the sibling repos as Software with declared
capabilities, so a fresh install is demonstrable immediately:
| Software | Consumes | Produces |
|---|---|---|
shacl-manager | RDF graph, SHACL shapes graph | SHACL ValidationReport, conformance summary |
sulo-schema-builder | schema model, SULO ontology | RDF/Turtle, OWL+SULO, SHACL shapes, Mermaid UML |
rdf_tx | SPARQL update, RDF quads | hash-chained patch log, masked RDF replica |
obda-lazy-cache-demo | relational source, R2RML/RML mapping | materialised RDF view, mapping coverage report |
11. Frontend
v1 covers browse/search, registration and editing, and peer administration. Layout is
two-column with a sticky right rail; Instances is a top-level tab alongside Software,
Artifacts, Runs and Peers. Lineage graph visualisation is deferred to v2 — v1 renders
the same data as tables.
Full screen inventory, routes, component list, API contracts per screen, and states:
docs/design-handoff.md.
12. Open questions
| # | Question | Notes |
|---|---|---|
| Q1 | Do we mint DOIs for artifacts or software releases? | Requires DataCite membership and cost. Would sit as an overlay on the UUIDv7 IRIs (D2), not a replacement. Blocks nothing in v1. |
| Q2 | When do we add cryptographically signed advertisements? | Rejected for v1 (D8) on key-distribution cost. Becomes important the moment a peer registry we do not operate can influence our lineage view. |
| Q3 | Retention and GC for stale peer stubs. | Currently TTL-refreshed forever. Do stubs for a peer that has been unreachable for 90 days get dropped, tombstoned, or kept? |
| Q4 | Multi-tenancy inside one registry. | Out of scope for v1 (§1.2). Confirm no IDS use case needs it before that ossifies — retrofitting tenancy onto named graphs is expensive. |
| Q5 | Should Capability eventually be a SHACL shape rather than an ArtifactType chip? | Far more precise matchmaking ("consumes graphs conforming to this shape"). Natural v2, and shacl-manager already has the machinery. |
| Q6 | Is SHACL write-validation blocking or advisory? | Answered in the prototype: severity decides. sh:Violation blocks with 422; sh:Warning is recorded and never blocks, which is how "no licence declared" and "no distribution" are handled. TAR_SHACL_VALIDATE_WRITES=false downgrades violations to warnings for an estate that prefers a half-described artifact to a rejected one. |
| Q7 | Project licence and repository home. | Assumed Apache-2.0 under MaastrichtU-IDS, matching siblings. Confirm. |
| Q8 | Rate limiting and abuse controls for a public instance. | Not designed yet. Needed before any registry is exposed to the open internet with TAR_PUBLIC_READ=true. |
| Q9 | TAR_BASE_IRI change after data exists. | A rebase migration is named in §10.5 but not specified. Needed before the first production deployment moves domain. |
13. Out of scope for v1
Artifact byte storage; workflow execution; access granting/brokering; multi-tenancy; lineage graph visualisation; harvest-based federation; DOI minting; signed advertisements; horizontal write scaling.
tar: vocabulary audit
| Status | Applied |
| Date | 2026-08-30 |
| Scope | Every term in https://w3id.org/tar/ns#, audited against DCAT 3 / DCAT-AP, DCTERMS, PROV-O, schema.org + Bioschemas, ADMS, VoID, DQV, ODRL, FOAF, DOAP, CodeMeta, biotoolsSchema, SKOS, RO-Crate and the EU authority tables |
Every invented term is a federation cost: a peer registry, a DCAT-AP harvester or a generic
SPARQL client understands the standard term and not ours. This audit checked each tar: term
against the vocabularies above — fetching the actual definitions, domains and ranges rather
than working from memory — and replaced, supplemented, or kept-with-evidence accordingly.
Ground rules applied:
- Where a standard term has the same meaning and a compatible domain/range, it replaces the
tar:term outright. The old term is markedowl:deprecated trueinshapes/vocab.ttland is still read as a fallback (both in projections and via SPARQL property-path alternation, e.g.prov:wasAssociatedWith|tar:atInstance), so graphs written before the audit stay queryable. It is never written again. - Where a standard term is coarser than ours but is what harvesters actually read, both
are written: the
tar:literal stays authoritative for the registry's own logic, the standard triple is the interoperable supplement. - Where nothing standard fits, the term is kept and its
rdfs:commentinshapes/vocab.ttlnow states specifically what was checked and why it did not fit. - The JSON API in
src/model.rsis unchanged throughout; this is the RDF underneath.
Replaced terms
tar: term | Now written as | Evidence | Notes |
|---|---|---|---|
tar:atInstance (Run → Instance) | prov:wasAssociatedWith | PROV-O §wasAssociatedWith — domain prov:Activity, range prov:Agent | This is PROV-O's own unqualified form of the prov:qualifiedAssociation the registry already writes; the Instance is a prov:SoftwareAgent. The old vocab comment even admitted it ("the authoritative form is prov:qualifiedAssociation"). Exact match. |
tar:externalKey (Run, Artifact) | dct:identifier | DCMI Terms §identifier — "an unambiguous reference to the resource within a given context" | A CI system's run key (gh-actions/12345/attempt-1) is exactly an identifier-in-a-context. adms:identifier was considered and rejected: it requires an adms:Identifier node with agency/notation, which overstates a plain opaque idempotency token. |
tar:homeRegistry (Instance → Registry) | dcat:inCatalog plus the catalog-side dcat:resource edge | DCAT 3 vocabulary Turtle: dcat:inCatalog owl:inverseOf dcat:resource, new in DCAT 3, with the scope note "MAY be used only in addition to its inverse" — hence both directions are written | Registration of an Instance now also asserts <registry> a dcat:Catalog ; dcat:resource <instance>, so the catalog membership is visible to DCAT clients from either end. |
tar:tagline (Software) | dct:abstract | DCMI Terms §abstract — "a summary of the resource" | The long-form text stays on schema:description. schema:slogan was rejected: its domainIncludes is Organization/Brand/Place/Product, not CreativeWork. |
tar:kind (Software; service|library|cli|workflow) | schema:applicationCategory (alone) | schema.org/applicationCategory — domainIncludes SoftwareApplication | The registry already wrote both predicates with the identical value; the tar: triple was pure duplication and is simply dropped. The SHACL sh:in constraint moved to the schema term. |
tar:maturity (Software) | codemeta:developmentStatus (https://w3id.org/codemeta/terms/developmentStatus) | CodeMeta terms: "Description of development status, e.g. active, inactive, suspended. See repostatus.org"; namespace resolves via w3id | Values remain free-text literals (the UI treats them so); repostatus.org IRIs are the recommended values going forward. adms:status rejected: workflow status of an asset, not software lifecycle. |
tar:contact (Software → Agent) | codemeta:maintainer (https://w3id.org/codemeta/terms/maintainer) | CodeMeta terms: "Individual responsible for maintaining the software (usually includes an email contact address)" | schema:maintainer is still in schema.org's pending area; CodeMeta v3 deliberately mints its own IRI for that reason and matches the intended "responsible contact" semantics. dcat:contactPoint rejected: its range is vcard:Kind, ours are schema:Person/schema:Organization. |
Kept, with a standard supplement written beside it
tar: term | Supplement written | Evidence | Why both |
|---|---|---|---|
tar:availability (Distribution, Instance; public|restricted|embargoed|metadata-only) | dct:accessRights with the EU Access Rights authority table: public→PUBLIC, restricted→RESTRICTED, embargoed→NON_PUBLIC, metadata-only→NON_PUBLIC | DCAT 3 §6.8.6 defines dct:accessRights on dcat:Distribution as well as on dcat:Resource; the table's members were fetched and verified (PUBLIC, RESTRICTED, NON_PUBLIC, SENSITIVE, CONFIDENTIAL, NORMAL, OP_DATPRO) | The EU table has no embargo concept and nothing that distinguishes "described but not retrievable" from merely non-public — and the registry's SHACL rules (metadata-only ⇒ no downloadURL) key on exactly those distinctions. So the four-way literal stays authoritative and the standard triple is the deliberately lossy DCAT-AP reading. Note dcatap:availability is a different concept (planned persistence of a distribution) and was not confused with this. |
tar:status (Run; success|failed|running|aborted) | schema:actionStatus with an ActionStatusType member (success→CompletedActionStatus, failed/aborted→FailedActionStatus, running→ActiveActionStatus), and the Run additionally typed schema:Action so the property sits on its intended domain | schema.org/actionStatus — domain Action, range ActionStatusType; the four enumeration members were verified | The enumeration has no distinct member for aborted (it folds into failed) — the mapping is non-injective, so replacing outright would lose information the UI and failure metrics use. PROV models run state only via prov:endedAtTime presence, which loses failure semantics entirely. |
tar:tombstoned / tar:tombstonedAt | adms:status → dataset-status/WITHDRAWN | W3C ADMS adms:status ("status of the Asset in the context of a particular workflow process", domain rdfs:Resource); the EU dataset-status members were fetched and verified (COMPLETED, DEPRECATED, DEVELOP, DISCONT, OP_DATPRO, WITHDRAWN) | The boolean stays as the cheap query key. prov:invalidatedAtTime was rejected as the sole standard form: its domain is prov:Entity, and tombstones also apply to Software and Instances (agents). |
Kept as-is (nothing standard fits)
Each of these now carries an rdfs:comment in shapes/vocab.ttl recording the search, so a
reader does not have to redo it.
tar: term | What was checked, in short |
|---|---|
tar:Software, tar:Release, tar:Instance (marker classes) | A Release is also a schema:SoftwareApplication, so SHACL sh:targetClass and count queries need discriminators; no vocabulary separates abstract software from a versioned release as classes. |
tar:Capability, tar:hasCapability, tar:produces, tar:consumes | Bioschemas ComputationalTool input/output (bioschemas.org/properties/input, verified to resolve) expect FormalParameter nodes attached to the tool itself — a different shape with an indirection this model does not need, given the object here already is the EDAM/skos:Concept type IRI. biotoolsSchema's function/input/output is JSON/XML, served by /export/biotools (spec §7.2), not an RDF vocabulary. wf4ever wfdesc:hasInput/hasOutput expect workflow parameter nodes. The Capability node stays, doubling as the prov:Plan. |
tar:runsRelease | schema.org (nothing), DOAP (doap:release is Project→Version, wrong direction/subject), PROV (prov:hadPlan exists only on a qualified Association), DCAT (dcat:version is a literal). "Deployment X currently runs release Y" has no standard property. |
tar:instanceOf | dct:isVersionOf would claim the Instance is a version of the Software; prov:specializationOf claims identity; nothing in schema.org/DOAP/DCAT expresses "deployment of". Denormalised on purpose (an Instance may predate any Release). |
tar:usedRelease | PROV deliberately has no unqualified activity→plan property; prov:used is unusable because this registry's readers treat prov:used as "consumed artifact" (lineage traversal, counts). The authoritative prov:qualifiedAssociation/prov:hadPlan is always written beside it. |
tar:accessProtocol | DCAT 3 conveys protocol only implicitly (accessURL scheme, dcat:accessService); no protocol property in DCAT/DCAT-AP/VoID/schema.org. |
tar:authMethod | DCAT 3: nothing. ODRL: permissions/duties, not authentication mechanics. schema:conditionsOfAccess: free text for humans. Machine-readable auth otherwise lives only inside OpenAPI documents behind dcat:endpointDescription. This is the FAIR A1.2 hint. |
tar:accessRequestURL | Nearest are dcat:landingPage (dataset-level, generic navigation) and odrl:hasPolicy (states the policy, not where to apply). DCAT-AP 3 has no access-request property; HealthDCAT-AP is still drafting in this space — revisit when it stabilises. |
tar:defaultMediaType | dcat:mediaType is defined on dcat:Distribution; putting it on a skos:Concept (ArtifactType) would misstate its domain. Scheme-level UI metadata. |
tar:readme, tar:readmeBaseURL | CodeMeta's readme is a URL to a README file, verified at codemeta.github.io/terms; ours is the inline Markdown content, stored so the UI renders without a network call. |
tar:containerImage, tar:imageDigest, tar:installCommand | CodeMeta (buildInstructions is a URL to docs), schema.org (downloadUrl — an OCI ref is not a URL), DOAP: no terms for OCI references, image digests, or install one-liners. |
tar:jurisdiction | dct:spatial asserts spatial coverage of the data, not the law an operator answers to; DPV's dpv:hasJurisdiction expects a Location resource and drags in a policy framework for one field. |
tar:health | Volatile, registry-observed liveness. DQV measures dataset quality, not service uptime; no catalogue vocabulary models it. |
tar:openLineagePayload, tar:claimedNamespace | OpenLineage is JSON, not RDF; the payload is preserved verbatim by design (spec §7.6). The claimed namespace is deliberately not dct:identifier — it is an unverified claim, recorded as a label only (spec §8.3). |
tar:oidcClientId, tar:oidcIssuer, tar:allowedScope | Workload-identity binding (addendum D12–D15). No RDF vocabulary models identity-provider client binding or token scopes. |
tar:jsonField | An extra triple on a standard sh:ValidationResult mapping it to the JSON input field; plain SHACL consumers ignore it. |
What a harvester or generic SPARQL client can now understand
Before the audit, the following facts about our records were expressed only in the invented namespace and were invisible to anything that had not read our vocabulary. Now:
- Who performed a run.
?run prov:wasAssociatedWith ?agent— the single most common PROV query pattern — now works against this registry, from any PROV-aware client, alongside the qualified form. Previously onlytar:atInstanceheld the one-hop edge. - Whether data is accessible. A DCAT-AP harvester reading
?distribution dct:accessRights ?rgets EU authority-table IRIs — the exact value set DCAT-AP mandates — for every distribution and data-serving instance. Open-data portals can correctly file ourmetadata-onlyhealth-data artifacts as NON_PUBLIC instead of displaying them as undescribed. - Whether a run succeeded.
?run schema:actionStatus schema:FailedActionStatus, with runs typedschema:Action— legible to schema.org consumers with no profile knowledge. - Which catalog a deployment belongs to.
dcat:inCatalog/dcat:resourcein both directions; a harvester walking the catalog now discovers instances as DCAT resources. - Software cards. Short summary (
dct:abstract), category (schema:applicationCategorywithout a proprietary duplicate), development status (codemeta:developmentStatus) and maintainer (codemeta:maintainer) are all in vocabularies that CodeMeta-based tooling (e.g. software heritage/scholarly-infrastructure crosswalks) already consumes. - External identifiers. Run and artifact keys are
dct:identifier, so deduplication and cross-referencing against other systems no longer require our namespace. - Deletion. A tombstoned record is
adms:statusWITHDRAWN — a standard, queryable lifecycle statement rather than a proprietary boolean alone.
What still requires our vocabulary: capability matchmaking (tar:produces/tar:consumes),
the deployment structure (tar:instanceOf/tar:runsRelease), the fine-grained access
descriptor (tar:accessProtocol/tar:authMethod/tar:accessRequestURL/tar:availability's
embargoed/metadata-only distinction), and operational/security bookkeeping. Each of those is
documented in shapes/vocab.ttl with the reason no standard term fits.
Genuine modelling issues surfaced by the audit (not vocabulary problems)
availabilityconflates two axes. public/restricted is an access level; embargoed/metadata-only is an existence/temporal statement. This is why no single standard value set maps onto it. An embargo also has no end date in the model — if embargoes matter,dct:available(date the resource becomes available) is the standard carrier and would let embargoed→PUBLIC-from-date be said properly.- A "metadata-only distribution" is a slightly odd object — a
dcat:Distributionthat distributes nothing. The artifact-level absence of any distribution already encodes metadata-only (andoverall_availabilitytreats it so); the explicit metadata-only distribution row exists mainly to carrytar:accessRequestURL. Consider allowingaccess_request_urlat artifact level and dropping the empty distribution. - Capability cannot express constraints, only type chips — already spec Q5. When that
remodel happens, the Bioschemas
FormalParametershape is the natural target and would maketar:produces/tar:consumesreplaceable after all. - Catalog membership is asserted for Instances but not for Artifacts/Software. A
DCAT-AP harvester walking
dcat:resourcefrom the catalog finds deployments but not the datasets themselves; addingdcat:datasetedges on artifact registration would complete the DCAT catalog picture at one triple per artifact.
Addendum: kind gained a desktop value
Registering RDFCraft — a Nuitka-packaged executable that opens a local pywebview window —
showed the kind list had no honest value for it. service was wrong (it is not hosted),
library was wrong (it is an application, not something you import), workflow was wrong, and
cli asserted a command-line interface it does not have. desktop was added.
Worth recording why this bit: schema:applicationCategory, which now carries this value, is
free text in schema.org. The closed sh:in list is ours, not the vocabulary's. Closing an
open term buys validation and costs expressiveness, and every value we failed to anticipate
becomes a write the registry rejects for no good reason. capability deliberately went the
other way with a free-IRI escape hatch (D11); kind has none, and that asymmetry is a
deliberate trade rather than an oversight — but it is one to revisit if a third case appears.
Addendum: EuroSciVoc replaces EDAM for software topics
EDAM stays for artifact types and the biotoolsSchema export. It no longer classifies the software, because it could not: asked to describe a SHACL validator, an ontology browser, a schema builder and a CSV-to-RDF mapper, it returned the same two topics — "Ontology and terminology" and "Data management" — for all four. Four agents classified independently and each reached the same pair, because those are the only EDAM topics that fit a semantic-web estate. A facet where every value has the same count as every record is not classifying anything.
EDAM is "an ontology of concepts prevalent within bioinformatics and computational biology".
These tools are not that. The mismatch showed up in the data branch too: data_2600, used here
for "an RDF graph", actually means Pathway or network.
The Software Ontology (SWO) was considered and rejected: it imports GO wholesale (its roots are
molecular_function and biological_process, and searching it for "validation" returns
valid_for_go_annotation_extension), and it has no term for SPARQL, SHACL or the semantic
web. Its genuine strength — 145 licence classes — is ground already covered by SPDX and CodeMeta.
EuroSciVoc, the EU Science Vocabulary, has semantic web, ontology (under knowledge engineering), databases, software and software development. It is what DCAT-AP uses for dct:subject, so a harvester understands these records
without knowing anything about us — the same argument that put dct:accessRights on EU authority
IRIs. 1064 concepts, generated by build.rs from the Publications Office SPARQL endpoint.
The result, on the same four records:
| before (EDAM) | after (EuroSciVoc) | |
|---|---|---|
| shacl-rust | Ontology and terminology · Data management | ontology · software |
| sulo-schema-builder | Ontology and terminology · Data management | ontology · knowledge engineering |
| OntoExplorer | Ontology and terminology · Data management | ontology · semantic web · databases |
| RDFCraft | Ontology and terminology · Data management | semantic web · databases · software |
The facet went from two values covering everything to five that separate: filtering on
semantic web returns RDFCraft and OntoExplorer; on software, RDFCraft and shacl-rust.
EDAM's topic branch is still bundled, typed tar:LegacyTopic so it is not offered in the picker
but any record still citing an EDAM topic — ours or a federated peer's — keeps rendering a label.
(It was a tar:conceptBranch "topic-edam" literal when this was written; ?branch=topic-edam
still selects it, and now names the class rather than a literal on the concept.)
The JSON field is still named edam_topics; renaming it would break every existing caller for a
cosmetic gain, and it has always accepted any IRI (D11).
Workload Identity — Addendum to the Tool Artifact Registry design
| Status | Draft for review · implemented in the prototype |
| Date | 2026-08-30 |
| Amends | 2026-08-30-tool-artifact-registry-design.md — D8, §8, §9.1, §10.5 |
| Question | "Instead of giving API keys to each tool, can we authenticate and authorise tools using Keycloak or something similar?" |
1. Answer
Yes, and it is a better primitive than the per-Instance API token of D8.
The insight D8 already had is the right one: the advertising party is a deployment, and the
credential should identify that deployment. What D8 got wrong is who mints the credential.
Minting it here makes the registry a secret store: a long-lived bearer string that somebody
has to distribute to a cluster, rotate on a schedule nobody owns, and revoke by hand when a
person leaves. Keycloak already runs on ids3 and already does all three.
So: each Instance becomes an OIDC client. The deployment authenticates to its own identity
provider with the client_credentials grant, gets a short-lived JWT, and presents that. The
registry verifies the signature against the issuer's JWKS and maps a claim in the token to the
Instance record that declared it.
The authorisation rule of §8.3 does not change. It gets stronger:
An Instance may only advertise runs in which it is itself the agent.
The Instance is still taken from the credential and never from the request body. The difference is that the identity is now asserted by an issuer we trust, expires in minutes, and is revocable centrally rather than by an admin remembering which CI secret to rotate.
2. Decisions
| # | Decision | Rationale | Rejected |
|---|---|---|---|
| D12 | An Instance may declare tar:oidcClientId (optionally narrowed by tar:oidcIssuer). A verified token whose client claim matches acts as that Instance. | Reuses the identity provider the estate already runs. No secret for a deployment is ever stored in the registry. Rotation, expiry and revocation move to Keycloak, where they are already solved problems. | Registry-minted tokens as the only credential (D8): a secret store we did not want to be. mTLS client certs: a second PKI to operate. |
| D13 | Registry API tokens remain, as the zero-dependency fallback. | Requirement 6 says anyone can run their own registry with minimal ops. A registry that requires a Keycloak is not that. A single container with TAR_ROOT_TOKEN must stay a complete install. | OIDC-only (breaks req. 6); tokens-only (the status quo this addendum replaces). |
| D14 | Trust a list of issuers, not one. TAR_OIDC_ISSUER for the estate's own provider, TAR_WORKLOAD_ISSUERS for others. | The same verification path then accepts a Kubernetes projected ServiceAccount token and a GitHub Actions OIDC token, which lets a CI job advertise with no stored secret at all. That is strictly better than any token we could mint. | One issuer only: would have forced a registry token back into every GitHub Actions workflow. |
| D15 | Scopes come from the token when it carries ours, and from the Instance record otherwise. | Keycloak client scopes are fiddly to configure per client; an estate that has not done it still gets least privilege from tar:allowedScope on the record. A token that does carry scopes always wins, so a provider can tighten but never widen. | Trusting only token scopes (adoption cost); trusting only the record (ignores what the provider asserted). |
| D16 | Human sign-in uses the same issuer, with roles. reader / curator / admin come from realm_access.roles or resource_access.{client}.roles. | One identity provider for people and for workloads; the registry stores no passwords and no user table. | A local user table. |
3. How it works
3.1 A deployment advertises
# Once, in Keycloak: create client `shacl-manager-ids3`, service accounts enabled.
# Once, in the registry: set tar:oidcClientId on the Instance record.
TOKEN=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=client_credentials \
"$ISSUER/protocol/openid-connect/token" | jq -r .access_token)
curl -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
--data @produced.json https://reg.ids.unimaas.nl/api/v1/advertise/produced
3.2 What the registry does with it
- Read
issfrom the token without verifying, and reject it unless the issuer is trusted. - Fetch that issuer's JWKS (cached one hour, refetched once on an unknown
kid) and verify the signature,exp,issandaud. - Read the client claim —
azpby default, configurable, falling back toclient_idthensub. - Find the
Instancewhosetar:oidcClientIdmatches, and whosetar:oidcIssuermatches the issuer when it declares one. A client id is only unique within an issuer. - Grant the scopes in the token that this registry understands; if none, the Instance's
tar:allowedScope; if none of those,advertise:produceandadvertise:consume. - Every write records
prov:wasAttributedTothe Instance, and an audit row naming the credential kind and issuer.
A verified token bound to no Instance authenticates but can advertise nothing, and the 403
says exactly which client id to register. GET /api/v1/whoami is the first thing to curl when
a CI job gets a 403: it reports the credential kind, the resolved Instance, the issuer and the
effective scopes.
3.3 Beyond Keycloak — no stored secret at all
Because trust is a list of issuers, two more identity sources work through the same path:
| Source | Issuer | What to put in tar:oidcClientId |
|---|---|---|
| Kubernetes projected ServiceAccount token | the cluster's OIDC issuer | system:serviceaccount:shacl:shacl-manager |
| GitHub Actions OIDC | https://token.actions.githubusercontent.com | repo:MaastrichtU-IDS/shacl-manager:ref:refs/heads/main |
A workflow then advertises with a token GitHub mints for that job. There is no secret in the repository, nothing to rotate, and the credential cannot be replayed from anywhere else.
4. Configuration
| Variable | Default | Notes |
|---|---|---|
TAR_OIDC_ISSUER | unset | The estate's provider. Enables human sign-in and workload tokens. OIDC is off entirely when unset. |
TAR_OIDC_CLIENT_ID / _CLIENT_SECRET | unset | For browser sign-in. The UI uses authorisation code + PKCE, so the secret is only needed for confidential-client setups. |
TAR_WORKLOAD_ISSUERS | unset | Comma-separated. Accepted for workload tokens only, never for browser sign-in. |
TAR_OIDC_AUDIENCE | TAR_BASE_IRI | Expected aud. |
TAR_OIDC_REQUIRE_AUDIENCE | true | Requires the aud claim and checks it. A token without one is rejected, not waved through: otherwise any token from a trusted issuer, minted for any other service, would work here. Turn off only for a provider that cannot set aud — it weakens replay protection. |
TAR_OIDC_CLIENT_CLAIM | azp | Which claim carries the workload's identity. |
TAR_OIDC_ROLES_CLAIM | realm_access.roles | Dotted path. |
TAR_OIDC_SCOPE_CLAIM | scope | Space-delimited string or array. |
TAR_OIDC_AUTO_REGISTER_INSTANCES | false | Deliberately off: an unknown workload should be registered by a person. |
/.well-known/tar-registry reports all of this, so a peer or a tool can discover how to
authenticate without being told out of band, and the UI hides sign-in entirely when no issuer
is configured.
5. What this does not solve
- Federation trust is untouched. A peer registry's tokens are not accepted here, and ours are not accepted there. Peer data stays a read-only stub (§8.4). Cross-registry write trust would need signed advertisements — still spec Q2, still deferred.
- It is authentication, not authorisation of content. A deployment with a valid token can still advertise a nonsensical artifact. SHACL write validation, not the credential, is what keeps records well-formed.
- JWKS availability becomes a dependency of writes. Verification needs the issuer reachable at least once per hour. Reads are unaffected, registry API tokens are unaffected, and a cached JWKS covers a short outage — but an estate whose Keycloak is down cannot advertise with OIDC in the meantime. That is the price of central revocation, and it is the right trade; the fallback token path exists for the case where it is not.
- Token replay within its lifetime is possible if a token leaks, exactly as for any bearer
credential. Short lifetimes and a correct
audbound the window. DPoP or mTLS-bound tokens would close it and are a natural v2 if the threat model demands it.
6. Changes to the main spec
- D8 is superseded by D12–D16: "Auth: OIDC workload identity per Instance, with registry API tokens as the zero-dependency fallback; OIDC for humans. Anonymous read by default."
- §4.2 gains
tar:oidcClientId,tar:oidcIssuerandtar:allowedScopeonInstance. - §8.1 gains a principal row: an Instance authenticated by an OIDC workload token.
- §9.1 the self-description gains an
authblock. - §10.5 gains the variables in §4 above.
- §12 Q2 (signed advertisements) is unchanged and still open — this addendum secures the hop into the registry, not the claim itself.
Artifact Subscriptions — Design Note
| Status | Implemented |
| Date | 2026-08-31 |
| Spec | 2026-08-30-tool-artifact-registry-design.md — extends §7.5, §8.3, §9.3 |
| Code | src/api/subscriptions.rs, src/ops/subscriptions.rs, migrations/0003_subscriptions.sql, frontend/src/routes/Subscriptions.tsx, tests/subscriptions.rs |
1. Why
D6 says capability and lineage are both first-class, and gives the reason: capability answers
"what can produce a SHACL report?" before anything has run, and the run graph answers "where
did this come from?" afterwards. There is a third tense neither covers — "what just appeared
that I care about?" — and today the only way to ask it is to poll /api/v1/artifacts on a
timer and diff the result. Every downstream tool that wants to react to an artifact has to
build that loop itself, badly, and each one adds a fixed query load to the registry whether or
not anything happened.
A subscription is that third tense made first-class: an Instance registers a standing filter, and the registry tells it when something matches.
2. The filter model
{
"conforms_to": ["http://edamontology.org/data_2048"], // artifact type
"software": ["https://reg/software/01a…"], // who made it, class level
"instance": ["https://reg/instance/01b…"], // who made it, deployment level
"keywords": ["fhir", "cohort-b"],
"license": ["https://spdx.org/licenses/CC-BY-4.0"],
"availability": ["public", "restricted"],
"q": "patients.ttl", // title or description contains
"roles": ["produced"], // or "consumed"
"exclude_own": true
}
Semantics: OR within a field, AND across fields. An empty field is "don't care". So the example above reads "a validation report, from any deployment of shacl-manager, tagged fhir, CC-BY, that I can actually retrieve". This is the only combination rule that stays predictable as fields are added: values within one axis are alternatives, and different axes are independent constraints.
An artifact that lacks the field never matches a constraint on it. An artifact with no
stated licence does not match license: [CC-BY]. Absent is not permissive, and a subscription
that quietly assumed otherwise would be worse than no subscription.
Why these fields
A filter set nobody can express their real interest in is decoration. Each of these answers a question someone actually has:
| Field | The question | Why it earns its place |
|---|---|---|
conforms_to | "tell me when a SHACL report appears" | The reason the feature exists — the capability question in event form. Everything else narrows it. |
availability | "…that I can actually fetch" | §6.2 says metadata-only is the common case, not an edge case. Without this filter, most notifications on a health-data registry are unactionable by construction, and the subscriber learns to ignore them. |
instance / software | "…from someone I trust" | The trust axis. instance names one deployment; software generalises to "any deployment of this tool, anywhere", which is exactly the join key D5 says exists across registries. Resolved through the denormalised tar:instanceOf so it stays a single lookup. |
keywords | "…for cohort B" | The project axis. No ontology covers a study name, a cohort id, or a sprint; dcat:keyword is where that already lives. |
q | "…mentioning patients.ttl" | Filenames and dataset names live in titles, not in vocabularies. Cheap, and it is the escape hatch for everything the structured fields do not model. |
license | "…that I am permitted to ingest" | Weakest of the set on its own, but it is the one field a data-governance rule is actually written against, and it costs one comparison. |
roles | "made, or merely used?" | A consume advertisement also makes an artifact appear. They are different events, so the default is produced and consumed is opt-in. |
exclude_own | — | Default on. A tool that both produces and subscribes would otherwise wake itself on every run, and its own output is the one thing it certainly knows about. |
Deliberately not included: a run-status filter (a produced artifact from a failed run is already rare and arguably still interesting), byte-size and media-type thresholds (properties of a distribution, not of the artifact, and a subscriber that cares can look after being told), and a SHACL-shape filter (spec Q5 defers capability-as-shape; a subscription filter should not get there first).
Matching is a function, not a query
ops::subscriptions::matches(&Filter, owner_instance, &Candidate) -> bool is pure: no
database, no graph, no network. Every rule in the table above has a unit test in that file. Two
consequences follow:
- It is testable in isolation. "Does
availability: [public]match a metadata-only artifact?" is a two-line test, not an integration fixture. - The advertisement path stays cheap. The candidate is built once per artifact and tested against each subscription in memory, instead of running one SPARQL query per subscription.
The candidate is read back from the graph after the write commits, not from the request body. So the matcher sees exactly what a reader of that artifact would see — including fields set by an earlier advertisement that this one did not mention. A foreign IRI that has not been resolved yet simply has few fields, and a filter on a field it lacks correctly does not match; that is honest rather than optimistic.
3. Delivery: two channels, one queue
A match inserts one row into subscription_deliveries. That row is the notification. Both
channels drain the same rows, so the two can never disagree about what matched.
advertise ──► match ──► INSERT delivery row ──┬──► worker POSTs it (webhook)
(pure) (SQLite, no socket) └──► subscriber GETs it (pull)
Pull is the default, not the fallback
The registry already models CLI, desktop and batch tools — deployable = false software, an
Instance with no dcat:endpointURL, "no endpoint — CLI/batch" as a normal state in the UI
(handoff §5.3). A webhook-only design would exclude exactly those deployments, which is most of
them. So:
GET /api/v1/subscriptions/{id}/deliveries?cursor=&limit=&ack=
POST /api/v1/subscriptions/{id}/deliveries/ack {"cursor": 42}
- The cursor is the delivery row's
seq—INTEGER PRIMARY KEY AUTOINCREMENT, so a deleted row can never let a later row reuse a number a client has already passed. - Omitting
cursorresumes from the subscription's own acknowledged position, so a subscriber that keeps no state still makes progress. - Acknowledgement is monotonic: a stale ack never rewinds and replays work already done.
- Nothing is acknowledged by being read. The guarantee is at-least-once, which is the only honest one when the subscriber may crash between reading and acting.
remainingin the response says how much is left, so a subscriber decides whether to keep going without a second round trip.
A subscription with no webhook_url is an ordinary pull subscription, and that is what the UI
creates unless you type a URL. A subscription works for a tool behind a firewall by default.
Webhook
POST of the frozen payload, with:
x-tar-delivery: <uuid> idempotency key for the receiver
x-tar-subscription: <id>
x-tar-timestamp: <unix seconds>
x-tar-attempt: <n>
x-tar-signature: sha256=<hex HMAC-SHA256(secret, "<timestamp>.<body>")>
The secret is generated at creation and shown exactly once, like a token. Unlike a token it
is stored recoverably, because HMAC needs the key itself rather than a hash — the one place
this feature departs from the api_tokens pattern, and the reason is stated in the migration
next to the column. Signing over timestamp.body rather than body alone lets a receiver
reject a replayed capture by age.
The body is the artifact record that GET /api/v1/artifacts/{id} already returns to anonymous
callers, plus the run, instance and software IRIs. A webhook never carries anything the
receiver could not already have read.
4. Failure
Advertisement must not block on the network (§9.3).
The advertise handler calls notify_advertised, which reads the local graph and inserts rows.
No HTTP client is constructed on that path. Everything with a timeout attached happens on the
worker task spawned in serve(), next to the peer resolver, for the same reason.
When a delivery fails:
| Backoff | 30s × 2^(attempts-1), capped at 6h. Same shape as the federation resolver's. |
| Give up on the delivery | After 8 attempts it becomes dead. It is never retried, and it stays visible — it is not swept away. |
| Give up on the endpoint | After 12 consecutive failed attempts the subscription is suspended: the worker stops selecting it entirely. This is what stops the registry hammering a host that is gone. |
| Keep serving | A suspended subscription keeps matching and keeps queueing. The pull path keeps working. Being unable to push is not being unable to notify. |
| Make it visible | delivery_state, consecutive_failures, last_error, last_error_at, and per-delivery status/attempts/next_attempt_at/last_error are all on the API and all rendered on the management screen. The owner sees why, not just that. |
| Recover | PATCH {"resume": true} un-suspends and re-arms the deliveries that died while the endpoint was down — losing them would punish the person who fixed the problem. |
reqwest's error chain is translated before storage, because "could not connect" and "did not
answer within the timeout" and "your receiver said 500" need different fixes.
5. Abuse
A webhook makes the registry issue outbound HTTP to an address someone else chose. That is a capability, and it is the security-relevant part of this feature.
| Case | What is done |
|---|---|
SSRF into the registry's own network — 169.254.169.254, RFC1918, loopback | Refused at registration (literal IPs, localhost, .local, .internal) and again before every attempt, on the resolved A/AAAA records. IPv4-mapped IPv6 (::ffff:127.0.0.1) is judged on the embedded address, carrier-grade NAT and the reserved ranges included. |
| Redirect laundering — a public URL that 302s to the metadata endpoint | The webhook client follows no redirects. A redirect is a delivery failure. |
| Credential leakage in the URL | user:pw@host is refused; the URL is displayed in the UI. |
| Plaintext | https only. http needs TAR_SUBSCRIPTION_ALLOW_HTTP=true, for a registry and its subscribers inside one trusted network. |
| Traffic amplification / DDoS by proxy | 32 subscriptions per Instance, a bounded batch per tick, one POST per unique (subscription, artifact, role), and backoff-then-suspend means a victim host sees a decreasing rate, not an increasing one. |
| The registry as a confused deputy leaking data | The payload is exactly what an anonymous GET /api/v1/artifacts/{id} returns. No credential, no private field, nothing the subscriber could not have polled for. |
| Receiver cannot authenticate us | Every POST is signed, with a replay-resistant timestamp in the signed material. |
| A hostile receiver replying with a gigabyte | Response bodies are read only far enough to quote the failure back to the owner. |
| Enumerating another deployment's subscriptions | A subscription id that is not yours returns 403, never 404 — the split would itself be an oracle. |
| Managing someone else's subscription | may_manage is character-for-character the rule api::tokens uses: the owning Instance's credential, a curator, or an admin. The Instance comes from the credential, never from the path (§8.3). |
The gap that remained, and how it was closed. Between the pre-flight resolution check and
the connection reqwest opened, the name was resolved twice, and a DNS-rebinding attacker with
a very short TTL could win that race. The check now returns the addresses it approved and the
delivery is made with a client pinned to them via resolve_to_addrs, so there is no second
lookup. The hostname is still what TLS verifies, so pinning replaces DNS without weakening
identity. The whole list is closed.
6. Endpoints
GET /api/v1/instances/{id}/subscriptions list owner | curator | admin
POST /api/v1/instances/{id}/subscriptions create owner | curator | admin
GET /api/v1/subscriptions/{sid} detail + recent deliveries
PATCH /api/v1/subscriptions/{sid} filter, webhook, pause, resume, rotate secret
DELETE /api/v1/subscriptions/{sid}
GET /api/v1/subscriptions/{sid}/deliveries the pull path
POST /api/v1/subscriptions/{sid}/deliveries/ack advance the cursor
Settings are read from the environment, with the same TAR_* naming and duration grammar as
the rest of the registry, because src/config.rs is owned elsewhere while this lands:
TAR_SUBSCRIPTION_WEBHOOKS, _ALLOW_HTTP, _ALLOW_PRIVATE_TARGETS, _TIMEOUT, _TICK,
_BATCH, _MAX_ATTEMPTS, _SUSPEND_AFTER, _BACKOFF_BASE, _BACKOFF_MAX. Moving them into
Config is mechanical.
7. Known gaps
- Matching scans every enabled subscription per advertisement. Correct at the scale this
registry targets (tens of deployments) and next to a graph write it is not the bottleneck. An
index on
conforms_tois the obvious next step, and the reason it is not there yet is that an index disagreeing withmatches()would be a correctness bug — the index has to be derived from the same function, not written twice. - Deliveries are never pruned. A busy registry accumulates rows for a subscription nobody
drains. A sweeper keyed on
matched_atand the acknowledged cursor is a few lines and no design work; it is left out rather than guessed at. Retry-Afterfrom the receiver is ignored in favour of our own backoff.- No fan-out across registries. A subscription only sees what this registry is told. The federated version — subscribing at a peer, or a peer forwarding matches — has the same loop and trust problems federated search does (§9.6), and deserves its own note.
- No
subscribe:*scope. Authorisation reuses the token rule, so an Instance credential with onlyadvertise:producecan also manage that Instance's subscriptions — the same latitudeapi::tokensalready grants. A dedicated scope belongs inauth::ALL_SCOPES. - DNS rebinding, as above.
Federated search propagation
| Status | Implemented |
| Date | 2026-08-31 |
| Extends | design §7.7, §7.8, §9.6 — D9 (cross-link plus lazy resolve, opt-in peers) is unchanged |
| Code | src/api/search.rs, src/ops/federation.rs, migrations/0002_federation.sql |
1. What changed
?federated=true used to fan out exactly one hop: registry A asked the peers in A's own peer
list and merged their answers. A record at D — which only B peers with — was unreachable from
A no matter how the query was phrased.
Federated search now propagates. A asks B; B asks its peers; and so on to a hop budget. That immediately raises the only hard problem in the design: in a peer graph with any cycle (A↔B, B↔C, C↔A — the shape a real federation acquires within a week) naive propagation is an exponential storm that never terminates.
Nothing about the trust model moves. Peers are still opt-in and admin-added, peer data is still a read-only stub, and an announcement still only produces a suggestion (§8.4). Propagation changes what a query may traverse, not what a registry will trust.
2. The envelope
Everything travelling with a federated search rides on the existing
GET /api/v1/search as query parameters. There is no new endpoint and no new verb; a peer
running the previous version answers a propagated query correctly as a one-hop local search,
and its narrower response deserialises with the new fields defaulted.
| Parameter | Meaning | Trust |
|---|---|---|
fed_id | The query's identity, minted by the origin and carried unchanged across every hop. Seeing one twice is how a cycle is detected. | Validated: 1–100 chars of [A-Za-z0-9._:-], else 400. Never rewritten. |
fed_hops | Hops still spendable. Decremented at each hop; 0 means answer locally. | Clamped to the receiver's own max_hops. |
fed_budget_ms | Milliseconds the caller will wait. | Clamped to the receiver's own per-peer timeout. |
fed_origin | Base IRI where the query started. | Advisory. Used for tracing and to avoid asking the origin back. Never authorises anything. |
fed_path | Comma-separated base IRIs already on this query's path, receiver appended before forwarding. | Parsed defensively: trimmed, de-duplicated, entry- and list-length capped. |
q, type and limit are forwarded unchanged so every registry filters identically.
A request is treated as a leg of a federated query when it is marked federated=true or
carries a fed_id. A peer that sends only fed_id is asking for a local answer under that
id, and it is still deduplicated — it is the same query.
3. Three independent brakes
No single mechanism is trusted to stop the storm, because each fails differently.
1 — Query identity (federated_queries in SQLite). Every registry claims the id with a
single INSERT OR IGNORE before doing any work. SQLite serialises writers, so of any number
of concurrent arrivals of the same id exactly one wins and every other one learns it lost.
This is the brake that catches the case the path check cannot: a diamond, where two
different routes reach the same registry at the same time. Rows carry a TTL
(TAR_FEDERATED_SEARCH_ID_TTL, default 10 min — comfortably longer than any query lives) and
are swept on the write path, with a hard 50 000-row cap behind that, so the table cannot grow
without bound whatever a flood does.
2 — Hop budget. fed_hops decrements per hop and stops the walk at zero. Each registry
takes min(granted, its own max), so a peer handing out a budget bigger than the one it was
given achieves nothing. max_hops itself is clamped to 8 however it is configured: a 40-hop
federation is not a configuration, it is an outage.
3 — Path check. A registry never forwards to a peer already on fed_path, to the origin,
or to itself. This is a pure optimisation over brake 1 — the id check would refuse those
anyway — but it saves the round trip and, more usefully, it reports the cut edge instead of
silently dropping it.
The path is attacker-controlled, so it cannot be the primary defence: a peer that strips it would restore the storm. That is exactly why brake 1 is enforced independently at every registry and does not depend on any claim made by the request.
4. The already-handled answer
The user's requirement, verbatim in shape: a registry that sees an id it has already handled rejects the repeat, saying so explicitly rather than silently returning empty.
{
"query": "shacl-manager",
"hits": [], "total": 0, "partial": false, "peers": [],
"already_handled": true,
"federation": {
"query_id": "cycle-1",
"registry": "https://reg.b.example",
"origin": "https://reg.a.example",
"first_seen_at": "2026-08-31T09:14:02.113Z",
"reason": "https://reg.b.example already handled federated query cycle-1 at 2026-08-31T09:14:02.113Z; its results were returned on the path that arrived first. This is the first repeat, which means the peer graph contains a cycle."
}
}
Design notes on that shape:
- HTTP 200, not 4xx. In a mesh with any cycle a repeat is the expected, correct
outcome of a well-formed query, not a client error. Returning
409would poison every caller's error rate and make a healthy federation look broken. The flag is the contract; the status code is not. hits: []is explained, not implied. Zero hits here does not mean "nothing matched" — it means "you already have these, on the path that reached me first" — andreasonsays so in words a human reading a peer report can act on.partialstaysfalse. Coverage is complete: another path covered this subtree. A refused repeat is a healthy answer and must not be rendered as a failure.- The caller records the peer as
status: "already_handled"with the peer's ownreasonas anote, so the cut edge is visible in the topology report rather than vanishing.
5. Reporting the topology honestly
A hit that travelled two hops is not the same evidence as one from a peer the operator chose
to trust, so the response keeps them apart. model::SearchResults, SearchHit and
PeerSearchStatus are unchanged (owned elsewhere); ops::federation defines a superset
with the same field names, so every existing consumer keeps working.
Per hit (#[serde(flatten)] over SearchHit, so the old JSON shape is byte-for-byte intact):
| Field | Meaning |
|---|---|
reach | local | direct (a peer we configured) | indirect (relayed to us) |
hops | Registry-to-registry hops crossed. 0 = local, 1 = a peer of ours |
via | The directly configured peer the hit entered through |
origin keeps pointing at the home registry, not the relay: when B relays D's record, A
shows origin.peer_base_iri = D and via = B. The relayed record's origin.peer_id is
dropped — it is a row id in B's peer table and means nothing in A's.
Per peer, PeerSearchStatus gains reach, hops, via, note, and two statuses beyond
ok/timeout/error:
already_handled— the peer refused a repeat of this query id (a cut cycle edge).skipped— we did not ask: already on the path, hop budget exhausted, or fan-out capped.
Peers reported by a peer are ingested and re-expressed from our point of view, so the caller sees the whole subtree that answered, including registries it has never configured.
Per response, federation carries query_id, origin, registry, max_hops,
hops_granted, hops_forwarded, path, and budget_exhausted — the last being the honest
admission that peers existed which the budget did not reach. A bounded answer says it is
bounded rather than passing for a complete sweep.
6. Abuse cases
| Case | What stops it |
|---|---|
| Cycle / storm | Three independent brakes (§3). Each registry handles a given query id exactly once. |
| Slow peer | One tokio::time::timeout wraps send and body read. Previously only send() was covered, so a peer that answered headers instantly and then dribbled a body was bounded only by the client-wide timeout. |
| Unbounded total time | Time is a budget, not a per-hop cost. The caller grants fed_budget_ms; the callee clamps it to its own and spends budget − hop_margin (600 ms) on its own fan-out. A walk of any depth finishes inside the origin's per-peer timeout instead of multiplying by depth. Peers are asked concurrently, so the total is bounded by one timeout, not by their number. |
| Enormous result set | Content-Length is checked when offered, and the stream is cut at 2 MiB regardless (a peer can lie about, or omit, the length). Hits from one peer are capped at 100, relayed peer statuses at 32, our own merged response at 500. |
| Inflated hop budget | Every budget is intersected with local policy, never adopted. max_hops clamped to 8. |
| Inflated time budget | Same: min(own, granted), and granted itself capped at 600 s before the min. |
| Fan-out amplification | Peers per query capped at 12 (TAR_FEDERATED_SEARCH_MAX_PEERS); the excess is reported as skipped, not hidden. |
| Seen-id table growth | TTL sweep plus a hard row cap, both on the write path. No background task to own or to fail silently. |
Injection / log spam via fed_id | The id is validated, never sanitised — rewriting it would break the sender's own deduplication — and every SQL parameter is bound. |
| Duplicate records from two routes | Merged on (iri, entity_type), keeping the copy with the fewest hops: the most direct evidence wins. |
Two residual risks, stated rather than hidden:
- Censorship via
fed_path/fed_origin. A malicious peer can name a registry in the path to stop us asking it. It could equally just not forward the query, so this grants no new power; it is bounded to one query and visible as askippedrow. - Amplification of an anonymous read. One unauthenticated
GETstill causes up tomax_peersoutbound requests, and the mesh sees at most one handled query per registry per id. There is no rate limiter yet. An operator who is exposed setsTAR_FEDERATED_SEARCH_MAX_HOPS=1(the previous one-hop behaviour) or turns offTAR_PUBLIC_READ.
7. Configuration
All defaults are working defaults; a docker run with only TAR_BASE_IRI still propagates
safely.
| Variable | Default | Meaning |
|---|---|---|
TAR_FEDERATED_SEARCH_MAX_HOPS | 3 | Hops from the origin. 1 restores one-hop-only. Clamped to 8. |
TAR_FEDERATED_SEARCH_TOTAL_TIMEOUT | 10s | Ceiling on one registry's whole fan-out. Clamped to 60 s. |
TAR_FEDERATED_SEARCH_HOP_MARGIN | 600ms | Held back from a granted budget so a callee answers before its caller gives up. |
TAR_FEDERATED_SEARCH_MAX_PEERS | 12 | Peers contacted per query. |
TAR_FEDERATED_SEARCH_MAX_PEER_BYTES | 2MiB | Bytes read from one peer. |
TAR_FEDERATED_SEARCH_MAX_PEER_HITS | 100 | Hits accepted from one peer. |
TAR_FEDERATED_SEARCH_MAX_PEER_STATUSES | 32 | Peer-status rows accepted from one peer. |
TAR_FEDERATED_SEARCH_MAX_TOTAL_HITS | 500 | Hits in our own merged response. |
TAR_FEDERATED_SEARCH_ID_TTL | 10m | How long a query id stays claimed. |
TAR_FEDERATED_SEARCH_TIMEOUT (existing, default 3s) remains the per-peer timeout.
These are read in ops::federation::FedSettings::from_env rather than Config, because
src/config.rs was owned by another change while this landed. Folding them into Config
is mechanical and should happen next; the names and the duration grammar already match.
8. Tests
tests/api.rs stands up real registries on loopback ports and peers them over the real
well-known handshake, so the cycle is a genuine one across sockets rather than a mocked call.
a_repeated_federated_query_id_is_refused_as_already_handled— the refusal, its wording, that it is per-id and not a circuit breaker, and that a malformed id is400.the_hop_budget_stops_propagation— A→B→C: at two hops C's record reaches A and is labelledindirect,hops: 2,via: B, withoriginstill pointing at C; at one hop C is never asked; a 9999-hop request is clamped.a_cycle_in_the_peer_graph_terminates— the full triangle, where every registry has two routes to every other. It asserts each registry claimed the id exactly once, that at least one refused a repeat (so termination came from loop prevention, not from the hop budget running out), that each record appears exactly once, and that a cut edge does not make the answerpartial.a_peer_cannot_flood_us_with_results— a hostile stub peer returning 5 000 hits is truncated; one returning megabytes is refused unread.src/ops/federation.rsunit tests cover the claim primitive, TTL sweep and row cap, id validation, path parsing, and the hop clamp.
Tool Artifact Registry — Frontend Handoff
| Status | Draft for review |
| Date | 2026-08-30 |
| Spec | docs/specs/2026-08-30-tool-artifact-registry-design.md |
| Audience | Frontend developer implementing the v1 UI |
Read §4 (data model), §6 (FAIR access), §7 (API) and §8 (auth) of the spec before starting. This document adds only what the spec does not: screens, routes, components, states.
1. Stack
Match the sibling repos (shacl-manager/frontend, sulo-schema-builder/frontend):
- React 18 · Vite 5 · TypeScript 5
react-router-domv6- Vitest + Testing Library + jsdom
- No UI framework mandated. No state-management library in v1 — router loaders plus local state are sufficient; add one only when a real cross-screen need appears.
Served either by the registry binary as static assets under / (default, keeps the
single-container promise of spec §10.2) or standalone via Vite in development with a proxy to
/api.
2. Design principles
- Lead with what is unique. Any registry can show a name and a licence. Only this one can show what a tool consumes and produces and what a deployment actually did. Those blocks sit above the fold; description and metadata sit below.
- Software and Instance are different things and must never look alike. Software is
abstract and has no runs. Instance is concrete, has an endpoint, a health state, and runs.
Confusing them is the single most likely user error — see the
kindand origin chips (§6.1). - Local vs foreign must always be visible. A record cached from a peer registry is read-only and possibly stale. It never renders identically to a local record.
- Machine-readable is a first-class affordance. Every detail page offers Turtle / JSON-LD download and shows the persistent IRI. This is a FAIR tool; hiding the RDF would be absurd.
- Anonymous read is the default. Every read screen must render fully without a session.
3. Information architecture
Top-level tabs:
Software Instances Artifacts Runs Peers* [search] [sign in]
* admin/curator only
Routes
| Route | Screen | Auth |
|---|---|---|
/ | redirect → /software | — |
/software | Software list | anon |
/software/:id | Software detail | anon |
/software/new, /software/:id/edit | Software form | curator |
/instances | Instance list | anon |
/instances/:id | Instance detail | anon |
/instances/new, /instances/:id/edit | Instance form | curator |
/instances/:id/tokens | Token management | owner or admin |
/artifacts | Artifact list | anon |
/artifacts/:id | Artifact detail | anon |
/runs | Run list | anon |
/runs/:id | Run detail | anon |
/peers | Peer admin | admin |
/search?q= | Search results | anon |
/auth/callback | OIDC callback | — |
* | Not found / tombstone | anon |
IRI dereference: the backend content-negotiates. A browser Accept: text/html on
{base}/software/{uuid} serves the SPA, which routes to /software/:id. Registry IRIs and
UI routes are the same URLs — do not invent a separate /ui prefix, it would break the
"IRIs are dereferenceable in a browser" property.
4. Layout system
Two-column with a sticky right rail, applied to both Software and Instance detail pages so the two read as one system.
- Main column
minmax(0, 1fr), rail320px, gutter24px, page max-width1200px. - Rail is
position: sticky; top: 64pxabove1024px; below1024pxthe rail collapses and its sections flow to the bottom of the main column in rail order. - Header is full-width above both columns.
- Section order in the main column is fixed; rail order is fixed. Do not reorder responsively beyond the collapse described above.
4.1 Software detail
┌──────────────────────────────┬─────────────────┐
│ shacl-manager │ METADATA │
│ SHACL shape mgmt+validation │ Apache-2.0 │
│ [Repo] [Docs] │ kind: service │
├──────────────────────────────┤ EDAM topics: │
│ 5 instances │ 143 runs/30d │ ◆ Data quality │
├──────────────────────────────┤ ◆ Semantic web │
│ USE IT │ Rust 94% Py 4% │
│ $ docker pull ghcr.io/… ⧉ ├─────────────────┤
├──────────────────────────────┤ CITE │
│ CONSUMES │ PRODUCES │ …/software/01J │
│ ◆ RDF graph │ ◆ SHACL rpt │ [BibTeX] [RIS] │
│ ◆ SHACL shape │ ◆ Summary ├─────────────────┤
├──────────────────────────────┤ RELEASES │
│ INSTANCES │ v2.1 2mo │
│ shacl.ids.unimaas.nl v2.1 ● │ v2.0 7mo │
│ shacl.mumc.nl v2.0 ● ├─────────────────┤
│ laptop-eerol v2.1 — │ PEOPLE │
├──────────────────────────────┤ ▣ E. Erol ORCID │
│ ## Description │ ▣ MaastrichtU │
│ Multi-tenant platform for … ├─────────────────┤
│ │ PUBLICATIONS │
│ ## Publications │ FEDERATION │
│ … │ FAIR ⬇ ttl jsonld│
└──────────────────────────────┴─────────────────┘
Software pages have no run list. Runs belong to Instances (spec D5). The signal bar shows
a roll-up (143 runs/30d across all instances) that links to /runs?software=:id.
4.2 Instance detail
Same shell. Main column: header (label, health dot, runs ▸ software v2.1, operator,
[Open endpoint] [OpenAPI]), signal bar (last run, runs/30d, failures, artifacts), Runs
table, Artifacts produced here table, narrow-capability note if it differs from the
Software declaration. Rail: endpoint details, tar:availability, jurisdiction, operator,
release currency warning, home registry, FAIR downloads.
5. Screens
Each entry gives the API calls (spec §7) and the states to build.
5.1 Software list — /software
GET /api/v1/software?q=&license=&publisher=&edam_topic=&keyword=&kind=&cursor=
Card or row per software: name, tagline, licence chip, kind chip, instance count, EDAM topic
chips, origin chip. Left facet panel mirrors the query params. Keyset pagination
(cursor) — not page numbers.
States: loading skeleton · empty ("No software registered yet" + Register software CTA for
curators, plus a tar seed --from ids-examples hint for admins) · filtered-empty (distinct
copy + Clear filters) · error.
5.2 Software detail — /software/:id
GET /api/v1/software/{id} · GET /api/v1/software/{id}/releases ·
GET /api/v1/instances?software={id}
Blocks in the order shown in §4.1. Notes:
- Use it renders only if a container image or install command exists on the latest Release.
- Consumes / Produces — two columns of
ArtifactTypeChip. Each chip links to/artifacts?conforms_to={typeIRI}. Under Produces, a link "N tools consume this" →/software?consumes={typeIRI}(backed byGET /api/v1/capabilities). If no capability is declared, show an inline empty state with a Declare capability action for curators — do not hide the block; its absence is information. - Instances — label, release, health dot, operator, last run. A row whose release is older than the latest Release gets a muted "outdated" marker.
- Cite (rail) — persistent IRI with copy,
[BibTeX] [RIS], version selector when releases exist. - FAIR (rail) —
⬇ Turtle⬇ JSON-LD⬇ biotoolsSchema, hitting the same IRI with anAcceptheader or the/export/biotoolsendpoint.
5.3 Instance list — /instances
GET /api/v1/instances?software=&operator=&status=&release=®istry=
Row: label, software + version, health dot, operator, endpoint (or "no endpoint — CLI/batch"), last run, origin chip. Facets: software, operator, status, home registry.
An Instance without dcat:endpointURL is normal (a laptop or batch run) and must not
render as broken.
5.4 Instance detail — /instances/:id
GET /api/v1/instances/{id} · /runs · /artifacts
Run table columns: run id (short, copyable), started (relative + absolute on hover), status,
N in → M out, external key, agent. Row expands to a run summary; clicking opens
/runs/:id.
5.5 Artifact detail — /artifacts/:id
GET /api/v1/artifacts/{id} · GET /api/v1/artifacts/{id}/lineage?depth=1
- Header: title, type chip, licence chip, availability badge, origin chip.
- Distributions — one card each: access/download URLs, media type, size, checksum with
copy,
conformsTolink, protocol and auth-method chips. Ifavailabilityismetadata-only, render no download affordance at all — show the availability badge and an Request access button bound totar:accessRequestURL. Never a disabled download button; that miscommunicates. - Provenance — "Generated by run X at instance Y running software Z v2.1", plus
wasDerivedFrominputs and downstream consumers (from the depth-1 lineage call), as lists. - Versions — other artifacts in the same
dct:isVersionOfseries, current one marked. - Rail: persistent IRI, cite, FAIR downloads, publisher, dates.
5.6 Run detail — /runs/:id
Timeline header (started, ended, duration, status), instance + release + operator,
Consumed and Produced artifact lists side by side, external key, raw payload viewer
(collapsed) when tar:openLineagePayload is present.
5.7 Software / Instance forms
POST|PATCH /api/v1/software · POST|PATCH /api/v1/instances
Sectioned form mirroring the model: identity → links → licence & party → EDAM topics & keywords → capability (produces/consumes type pickers with EDAM autocomplete plus a free-IRI escape hatch, per spec D11).
The API validates writes with SHACL and returns 422 with a Turtle validation report
(spec §7.9). Map report entries back to the offending fields — parse sh:resultPath and
sh:resultMessage and render inline field errors, with the full report available behind a
Show validation report disclosure. Do not dump Turtle at the user as the primary error.
5.8 Token management — /instances/:id/tokens
POST /api/v1/instances/{id}/tokens
Scope checkboxes (advertise:produce, advertise:consume, …), optional expiry. The token
value is shown exactly once, in a modal, with a copy button and an explicit "this will not
be shown again" warning; the list thereafter shows prefix, scopes, created, last used, expiry,
and a revoke action. Revocation asks for confirmation and names the instance.
5.9 Peer admin — /peers
GET|POST|DELETE /api/v1/peers · GET /api/v1/peers/suggested
Peer table: title, base IRI, last seen, resolve status, cached-record count, actions.
Add-peer flow: paste base URL → the UI calls the peer's /.well-known/tar-registry preview →
show what will be trusted (title, operator, base IRI) → confirm.
Suggested peers (peers-of-peers) are a separate, visually secondary list with Review and Dismiss; they are never added automatically (spec §8.4, §9.5). Removing a peer is destructive — it drops the cached graph — so confirm with the record count named.
5.10 Search — /search?q=
GET /api/v1/search?q=&type=&federated=
Results grouped by entity type. A Search peer registries toggle sets federated=true;
when the response carries partial: true, render a persistent banner listing which peers
timed out. Federated results are visually distinct via the origin chip and are never
interleaved silently with local ones.
6. Shared components
6.1 Chips and badges
| Component | Purpose | Rules |
|---|---|---|
OriginChip | local vs peer: <name> | On every record header and every list row that can contain foreign data. Peer chips link to the peer's record at its home registry. Foreign records render all edit affordances as absent, not disabled. |
KindChip | service / library / cli / workflow | Distinguishes Software kinds; helps keep Software vs Instance distinct. |
ArtifactTypeChip | EDAM or local type | Shows label, tooltips the definition, links to filtered artifacts. Falls back to the IRI's last segment if the label is unresolved. |
AvailabilityBadge | public / restricted / embargoed / metadata-only | Drives whether download affordances render at all (§5.5). |
LicenseChip | SPDX | Links to the SPDX IRI. Renders "unlicensed" distinctly from absent. |
HealthDot | instance up/down/unknown | Never colour-only — pair with text or shape. |
RunStatus | success / failed / running / aborted | Same rule. |
6.2 Other
CopyField (IRI, checksum, token, install command — copy button with confirmation),
CommandBlock ($ docker pull … with copy), SignalBar (label/value pairs, degrades to
— for unknowns), CiteBlock (IRI + BibTeX/RIS + version selector), FairDownloads
(Turtle / JSON-LD / biotoolsSchema), FacetPanel, KeysetPager, RelativeTime
(relative text, absolute in title, <time datetime>), EmptyState, ErrorState,
ProblemJsonError (renders RFC 9457 title/detail/instance).
7. Cross-cutting states
- Loading: skeletons that match final layout. No spinners on full pages.
- Empty vs filtered-empty: always distinct copy; filtered-empty offers Clear filters.
- Unknown values:
—, nevernull,undefined, or a blank cell. - Errors: RFC 9457 rendered via
ProblemJsonError; retry where the action is idempotent. - Stale peer data: any record from
<urn:tar:peer:*>shows "cached from peer · N ago" next to the origin chip. - Tombstoned records: soft-deleted IRIs still resolve (spec §7.2). Render the record with a clear tombstone banner and no actions — do not 404.
- Auth-gated actions: hidden for anonymous users, not shown-and-disabled. Sign-in is a
header affordance, and OIDC is optional server-side — if
/.well-known/tar-registryreports no OIDC issuer, hide sign-in entirely.
8. Accessibility
- Keyboard-navigable throughout; visible focus rings; skip-to-content link.
- Status and health never conveyed by colour alone (§6.1).
- Tables use real
<table>semantics with<th scope>; copy buttons have accessible names ("Copy persistent IRI", not "Copy"). - Modals trap focus and restore it on close; the token modal must be dismissible only by an explicit action, since the value is unrecoverable.
- Chip rows are lists; icon-only buttons carry
aria-label. - Target WCAG 2.2 AA contrast in both light and dark themes.
9. v1 scope
In: browse and search (Software, Instances, Artifacts, Runs); Software and Instance detail pages; registration and editing forms; token management; peer administration; federated search toggle; cite blocks; copy-paste install snippets; repo liveness metrics; publications.
Deferred to v2: lineage graph visualisation (v1 renders the same data as tables — the data model and API already support the graph, so this is purely a UI addition); capability-as-SHACL-shape pickers (spec Q5); multi-tenant UI; artifact upload.
Note on liveness metrics: of the four borrowed sections, repo liveness (commits, stars, forks, last-commit age) is the only one with backend cost — a forge poller, a token, and a rate-limit/cache story (spec §10.5). If it slips, the frontend must degrade to hiding those signal-bar cells rather than rendering zeros.
Charts: v1 uses numeric signal cells only. If sparklines or activity charts are added
later, follow the dataviz guidance rather than inventing a palette.
10. Open questions for the frontend
- Dark mode in v1, or light only? (Affects token setup and the whole component palette — cheap now, expensive to retrofit.)
- Do we adopt a component library or hand-roll? Siblings hand-roll; this UI has more surface than they do.
- Should the Software page show an aggregated Produces/Consumes derived from observed runs alongside the declared capability, and how do we render a disagreement between them?
- How much of a peer's record do we render before resolution completes — a skeleton, or the bare IRI with a Resolve now action?
11. The answers those questions got
Recorded here rather than in the README, so the questions and their answers stay together.
- Dark mode in v1? Yes — tokens in
frontend/src/styles.css. Cheap now, expensive to retrofit, exactly as the question said. - Component library? Hand-rolled, matching the sibling repositories. About 10 KB of CSS and no dependency to track. The extra surface did not turn out to change the answer.
- Observed vs declared capability on the Software page? Not built. The declared capability is shown; the observed one is one SPARQL query away. It should be added once there is enough run data for a disagreement between the two to mean something — showing a disagreement drawn from three runs would be noise presented as a finding.
- How much of an unresolved peer record to render? The bare IRI, marked "not resolved yet", plus the origin chip. Never a skeleton: a skeleton promises content that may never arrive.
The liveness-metrics note above went the way it feared — repository sync is implemented, repository liveness metrics are not, and the UI omits those cells. See Limitations.