Minting and Resolving ARK Identifiers
This is the hands-on companion to Identifier Scheme Selection: once the choice lands on an Archival Resource Key (ARK), you need to mint identifiers under your Name Assigning Authority Number (NAAN), attach a check character so transcription errors are caught, and resolve the results through the Name-to-Thing (N2T) resolver or your own infrastructure. The deliverable is a small, dependency-free minter and resolver that produces valid ARKs, understands suffix passthrough, and handles the ? and ?? metadata inflections. It assumes Python 3.10+ and that you have been assigned a NAAN by the ARK Alliance. Where a formal citation identifier is also required, the DOI path in DOI Minting with DataCite runs alongside this one; many archives mint ARKs for internal granularity and register DOIs for the citable subset.
An ARK is deliberately simple to generate, which is its appeal at high volume — but “simple” is not “unstructured.” Every ARK has an anatomy that the resolver depends on: the ark:/ scheme label, the NAAN that identifies the assigning organization, an assigned name that often carries a shoulder and a trailing check character, and an optional qualifier suffix that resolvers pass through to components of the base object. Get the anatomy right and resolution is a lookup plus a string join; get it wrong and identifiers either fail their check or resolve to the wrong component.
Two design decisions in the minter pay off for the life of the archive. The first is the shoulder: a short prefix like x5 that partitions a single NAAN into independent minting namespaces, so one authority number can serve many projects or object types without their identifiers ever colliding. Shoulders let you delegate minting to sub-teams while keeping a single registered NAAN. The second is the check character, which turns a silent transcription error into a loud validation failure. Because ARKs are frequently copied by hand out of papers, printed on labels, and read aloud, a scheme that cannot self-detect a mistyped character invites dead links that surface only when someone tries to resolve them years later. Computing and verifying the check character on every mint and every resolve is cheap insurance against exactly that failure.
Anatomy of an ARK
The table decomposes the example ARK ark:/12345/x54xz321/s3/f8.05?? into its parts. This is the reference the minter and resolver are built against: each row is a segment the code either generates or parses, and the last two rows are qualifiers the resolver interprets rather than parts of the base identifier.
| Component | Value | Role |
|---|---|---|
| Scheme label | ark:/ |
Declares the string an ARK; the resolver strips it first |
| NAAN | 12345 |
Name Assigning Authority Number — identifies the assigning organization and drives resolution |
| Shoulder | x5 |
A sub-namespace within the NAAN that partitions minting (e.g. by project or type) |
| Assigned name core | 4xz32 |
The unique betanumeric body generated at mint time |
| Check character | 1 |
Trailing character computed from the name to detect transcription errors |
| Base object | ark:/12345/x54xz321 |
The complete, resolvable identifier |
| Qualifier suffix | /s3/f8.05 |
Passed through to a component of the base object |
| Inflection | ?? |
? requests brief metadata; ?? requests full metadata and the persistence commitment |
The assigned name uses a vowel-free “betanumeric” alphabet (0123456789bcdfghjkmnpqrstvwxz) so that generated identifiers cannot accidentally spell words and are resistant to common misreadings. The check character is computed with the well-established NOID algorithm — a position-weighted sum over the name, modulo the alphabet size — so a single mistyped or transposed character in an ARK fails validation before it ever hits the resolver.
Production Implementation
The module below mints ARKs under a NAAN with a shoulder and a check character, validates any ARK string, and resolves one to a target URL — either through a local NAAN-to-host map or by delegating to N2T. It is fully self-contained and uses only the standard library.
from __future__ import annotations
import random
from dataclasses import dataclass
# Vowel-free "betanumeric" alphabet used by ARK assigned names and the NOID check character.
_XDIGIT = "0123456789bcdfghjkmnpqrstvwxz"
_ORD = {ch: i for i, ch in enumerate(_XDIGIT)}
def _check_char(stem: str) -> str:
"""NOID check character: position-weighted ordinal sum, modulo the alphabet size."""
total = sum(_ORD.get(ch, 0) * (i + 1) for i, ch in enumerate(stem))
return _XDIGIT[total % len(_XDIGIT)]
@dataclass(frozen=True)
class Ark:
naan: str
name: str # shoulder + core + check character
def __str__(self) -> str:
return f"ark:/{self.naan}/{self.name}"
def mint_ark(
naan: str,
shoulder: str = "x5",
*,
length: int = 5,
rng: random.Random | None = None,
) -> Ark:
"""Mint a new ARK: a random core under a shoulder, plus a check character."""
if not naan.isdigit():
raise ValueError(f"NAAN must be numeric: {naan!r}")
generator = rng or random.Random()
core = "".join(generator.choice(_XDIGIT) for _ in range(length))
stem = f"{shoulder}{core}"
return Ark(naan=naan, name=f"{stem}{_check_char(stem)}")
@dataclass(frozen=True)
class ParsedArk:
naan: str
name: str
suffix: str # passthrough qualifier, "" if none
inflection: str # "", "?", or "??"
@property
def base(self) -> str:
return f"ark:/{self.naan}/{self.name}"
def parse_ark(ark: str) -> ParsedArk:
"""Split an ARK into base, suffix passthrough, and metadata inflection."""
inflection = ""
if ark.endswith("??"):
inflection, ark = "??", ark[:-2]
elif ark.endswith("?"):
inflection, ark = "?", ark[:-1]
if not ark.startswith("ark:/"):
raise ValueError(f"Not an ARK: {ark!r}")
parts = ark[len("ark:/"):].split("/")
if len(parts) < 2 or not parts[0] or not parts[1]:
raise ValueError(f"Malformed ARK, missing NAAN or name: {ark!r}")
return ParsedArk(
naan=parts[0],
name=parts[1],
suffix="/".join(parts[2:]),
inflection=inflection,
)
def validate(ark: str) -> bool:
"""Return True if the ARK's trailing check character matches its name."""
name = parse_ark(ark).name
return len(name) > 1 and _check_char(name[:-1]) == name[-1]
# NAAN -> the base URL of the institution that assigns and serves that NAAN's objects.
_NAAN_HOSTS: dict[str, str] = {"12345": "https://data.example.org/ark/"}
def resolve(ark: str, *, via_n2t: bool = False) -> str:
"""Resolve an ARK to a target URL, applying suffix passthrough and inflection."""
parsed = parse_ark(ark)
if via_n2t:
target = f"https://n2t.net/{parsed.base}"
else:
host = _NAAN_HOSTS.get(parsed.naan)
if host is None:
raise KeyError(f"No resolver registered for NAAN {parsed.naan!r}")
target = f"{host}{parsed.name}"
if parsed.suffix:
target = f"{target.rstrip('/')}/{parsed.suffix}"
return f"{target}{parsed.inflection}"
The resolver keeps three behaviors distinct on purpose. Passthrough is a string join onto the base object’s URL, so one minted identifier serves an entire component tree. The inflection is preserved verbatim onto the end of the target, so a ?? request reaches the metadata endpoint the object’s host exposes. And the via_n2t switch is the difference between resolving through your own infrastructure and delegating to the global Name-to-Thing resolver — the same ARK string, two resolution paths, which is exactly the autonomy the scheme is designed to give.
For global resolution to work, one operational step lives outside the code: registering your NAAN and its resolver base URL with the Name-to-Thing service. Once registered, N2T forwards any https://n2t.net/ark:/<your-naan>/... request to the host in your mapping, applying the same passthrough rules your local resolver does. Keeping that registration current is the whole of your persistence obligation — when your object host moves, you update the N2T mapping rather than re-minting anything. The ?? inflection is where you make that obligation legible to machines: have your host answer a full-metadata request with a commitment statement describing how long, and under what terms, you intend the identifier to keep resolving. That published commitment is what turns an ARK from a bare string into a promise a downstream service can reason about.
Verification
Pin the two properties that matter: that a freshly minted ARK validates against its own check character, and that a single-character corruption is caught. Seeding the random generator makes minting deterministic so the test is reproducible.
import random
def test_minted_ark_validates() -> None:
ark = mint_ark("12345", shoulder="x5", rng=random.Random(7))
assert validate(str(ark))
assert str(ark).startswith("ark:/12345/x5")
def test_single_character_corruption_fails_check() -> None:
ark = str(mint_ark("12345", rng=random.Random(7)))
# Flip the first character of the assigned name; the check must now fail.
corrupted = ark[:13] + ("c" if ark[13] != "c" else "d") + ark[14:]
assert not validate(corrupted)
def test_passthrough_and_inflection_resolve() -> None:
url = resolve("ark:/12345/x54xz321/s3/f8.05??")
assert url.startswith("https://data.example.org/ark/x54xz321")
assert url.endswith("/s3/f8.05??")
def test_n2t_delegation() -> None:
assert resolve("ark:/12345/x54xz321", via_n2t=True) == "https://n2t.net/ark:/12345/x54xz321"
Run it with pytest -q. A passing run proves that minting produces self-validating identifiers, that transcription errors are rejected by the check character, and that passthrough and inflection survive resolution intact — the three guarantees an ARK service rests on.
Gotchas
- The check character is computed over the shoulder plus core, not the core alone. Compute it over a different span than you validate and every ARK you mint will fail its own check. Fix: keep one function,
_check_char, and apply it to the samename[:-1]span in both minting and validation, as the code does. - Suffix passthrough is not the same as minting a component identifier. Appending
/s3/f8.05addresses a part of the base object; it does not create a new registered ARK. Fix: mint one ARK per intellectually distinct object and use passthrough for its internal components, rather than minting an identifier per file. - A NAAN with no registered resolver mapping resolves nowhere. Local resolution needs the NAAN in your host map, and global resolution needs the NAAN registered with N2T. Fix: register your NAAN with the Name-to-Thing resolver and keep the mapping current, or the
KeyErrorthis code raises becomes a dead link in production.
Related Guides
- Identifier Scheme Selection — the comparison that decides when an ARK is the right scheme over a DOI or the Handle System.
- DOI Minting with DataCite — the parallel path for the citable outputs that also need a DOI.