Reconciling DOI State Across Repositories

A Digital Object Identifier (DOI) has a lifecycle, and your local catalog holds an opinion about where each of yours sits in it. The registry — DataCite — holds the authoritative answer. This page is the concrete procedure for closing the gap between the two: for each DOI you manage, fetch its real state from the DataCite REST API, compare that against the state your catalog recorded, compute the smallest set of changes that reconciles them, and apply it. It is the DataCite-specific worked example behind the broader Repository Sync & Reconciliation walkthrough, which defines the general diff/patch model; here we bind that model to DataCite’s three lifecycle states and its JSON:API payload. The task assumes you already mint DOIs — the creation side is covered in DOI Minting with DataCite — and that you have repository credentials with write scope and Python 3.10+ with httpx available.

The three states are exact and matter. A DOI in the draft state exists only in your account, resolves nowhere, and can be deleted. A registered DOI is permanent and resolvable but deliberately not indexed for discovery. A findable DOI is registered and indexed — the state a published dataset must be in. DataCite moves a DOI between these states through an event attribute on the record: publish promotes a draft to findable, register promotes it to registered, and hide demotes a findable DOI back to registered. Reconciliation is the act of making the registry’s state and metadata match what your catalog says they should be — usually by sending the right event, sometimes by pulling an out-of-band change back into your catalog instead.

DOI state reconciliation decision flow For one DOI the flow fetches its DataCite state, compares it to the local catalog, and reaches a decision asking whether the two agree. If they agree, the DOI is skipped as in sync. If they diverge, the flow computes a patch and applies it through the DataCite REST API. Fetch DataCite state via REST Compare to local catalog record Agree? no Compute patch minimal action Apply via DataCite REST API yes Skip already in sync

State Reconciliation Table

The core reference artifact is a deterministic map from each (local state, upstream state) pair to exactly one action. The rows below cover every combination that occurs in practice, including the three most common real mismatches: a DOI the catalog thinks is live but DataCite still holds as a draft, upstream metadata that is newer than the local copy, and a DOI missing upstream entirely. absent means the DataCite REST API returned 404 for the DOI.

Local state Upstream state Action
findable findable No state change; compare metadata and patch only if it differs
findable draft Send event: publish — the mint was never published
findable registered Send event: publish — promote the hidden DOI to findable
registered findable Pull: upstream is more open; update local to findable and review policy
draft findable Pull: DOI was published out of band; update local to findable
findable absent (404) Re-register from the local record: create the DOI, then event: publish
any absent (404) Re-mint from the catalog, or flag if the DOI was intentionally never created
findable findable, upstream metadata newer Pull upstream metadata into the local catalog (manual edit wins)
findable findable, local metadata newer Push metadata update via PUT, keeping the findable state
registered registered No change; the DOI is intentionally reserved and not discoverable

The distinction in the last metadata rows is direction of repair, decided by comparing DataCite’s updated timestamp against the local synced_at marker: a curator’s edit made through the DataCite UI is authoritative and pulled down, whereas a newer local revision is pushed up. Bulk metadata pushes across many DOIs are their own operation, covered in batch-updating DataCite metadata for existing DOIs.

Production Implementation

The implementation fetches the DataCite state, resolves the (local, upstream) pair to an action through the table above, and applies it. Every payload conforms to the DataCite JSON:API shape, state transitions are driven by the event attribute, and the DataCite Metadata Schema governs the metadata body. Fetch failures are explicit: a 404 becomes the absent state rather than an exception, and any other error is raised so the caller can defer the DOI.

python
from __future__ import annotations

import enum
from dataclasses import dataclass

import httpx

DATACITE_API = "https://api.datacite.org"


class State(str, enum.Enum):
    DRAFT = "draft"
    REGISTERED = "registered"
    FINDABLE = "findable"
    ABSENT = "absent"  # 404 upstream


class Action(str, enum.Enum):
    SKIP = "skip"
    PUBLISH = "publish"          # send event=publish
    PULL_STATE = "pull_state"    # upstream changed out of band; update local
    PUSH_METADATA = "push_metadata"
    PULL_METADATA = "pull_metadata"
    RECREATE = "recreate"        # missing upstream; re-register then publish


@dataclass(frozen=True)
class DoiSnapshot:
    doi: str
    state: State
    url: str | None
    updated: str | None  # ISO-8601 from DataCite attributes.updated


def fetch_state(client: httpx.Client, doi: str) -> DoiSnapshot:
    """Read the authoritative DataCite state; a 404 maps to the ABSENT state."""
    resp = client.get(f"{DATACITE_API}/dois/{doi}")
    if resp.status_code == 404:
        return DoiSnapshot(doi=doi, state=State.ABSENT, url=None, updated=None)
    resp.raise_for_status()
    attrs = resp.json()["data"]["attributes"]
    return DoiSnapshot(
        doi=doi,
        state=State(attrs["state"]),
        url=attrs.get("url"),
        updated=attrs.get("updated"),
    )


def decide(
    local_state: State,
    local_updated: str | None,
    upstream: DoiSnapshot,
    *,
    metadata_differs: bool,
) -> Action:
    """Resolve one (local, upstream) pair to a single action per the reconciliation table."""
    if upstream.state is State.ABSENT:
        return Action.RECREATE
    if local_state is State.FINDABLE and upstream.state in (State.DRAFT, State.REGISTERED):
        return Action.PUBLISH
    if local_state in (State.DRAFT, State.REGISTERED) and upstream.state is State.FINDABLE:
        return Action.PULL_STATE
    if local_state is upstream.state and metadata_differs:
        # Direction of repair: newer upstream (manual edit) wins; else push local.
        if upstream.updated and local_updated and upstream.updated > local_updated:
            return Action.PULL_METADATA
        return Action.PUSH_METADATA
    return Action.SKIP


def apply_action(
    client: httpx.Client,
    doi: str,
    action: Action,
    *,
    local_metadata: dict[str, object] | None = None,
) -> None:
    """Apply the resolved action against the DataCite REST API. Idempotent per action."""
    if action is Action.SKIP or action is Action.PULL_STATE or action is Action.PULL_METADATA:
        # PULL_* actions mutate the local catalog, not DataCite; handle them upstream of this call.
        return

    body: dict[str, object] = {"data": {"type": "dois", "id": doi, "attributes": {}}}
    attrs = body["data"]["attributes"]

    if action is Action.PUBLISH:
        attrs["event"] = "publish"  # draft/registered -> findable; re-sending is a no-op
    elif action is Action.PUSH_METADATA:
        assert local_metadata is not None, "PUSH_METADATA requires local_metadata"
        attrs.update(local_metadata)
    elif action is Action.RECREATE:
        assert local_metadata is not None, "RECREATE requires local_metadata"
        attrs.update(local_metadata)
        attrs["event"] = "publish"

    resp = client.put(f"{DATACITE_API}/dois/{doi}", json=body)
    resp.raise_for_status()


def reconcile_one(
    client: httpx.Client,
    doi: str,
    local_state: State,
    local_updated: str | None,
    local_metadata: dict[str, object],
    *,
    metadata_differs: bool,
) -> Action:
    """Fetch, decide, and apply for a single DOI; returns the action taken."""
    upstream = fetch_state(client, doi)
    action = decide(local_state, local_updated, upstream, metadata_differs=metadata_differs)
    apply_action(client, doi, action, local_metadata=local_metadata)
    return action

Construct the client with your repository credentials and a timeout — httpx.Client(auth=(repo_id, password), timeout=30.0, headers={"Content-Type": "application/vnd.api+json"}) — and call reconcile_one per DOI. Note that PUT on an existing DOI with event: publish is safe to re-send: DataCite treats promoting an already-findable DOI as a no-op, which is what makes a retried run harmless.

Verification

Assert the decision logic without a live registry by exercising decide directly across the mismatch cases that matter. Because decide is a pure function, these tests are fast and deterministic, and they are the contract you wire into CI.

python
def _up(state: State, updated: str | None = "2026-07-01T00:00:00Z") -> DoiSnapshot:
    return DoiSnapshot(doi="10.5072/x", state=state, url="https://ex.org/x", updated=updated)


def test_minted_but_never_published_publishes() -> None:
    action = decide(State.FINDABLE, "2026-06-01T00:00:00Z", _up(State.DRAFT), metadata_differs=False)
    assert action is Action.PUBLISH


def test_missing_upstream_recreates() -> None:
    action = decide(State.FINDABLE, None, _up(State.ABSENT, updated=None), metadata_differs=False)
    assert action is Action.RECREATE


def test_newer_upstream_metadata_is_pulled() -> None:
    action = decide(
        State.FINDABLE, "2026-06-01T00:00:00Z",
        _up(State.FINDABLE, updated="2026-07-10T00:00:00Z"), metadata_differs=True,
    )
    assert action is Action.PULL_METADATA


def test_matching_state_and_metadata_skips() -> None:
    action = decide(State.FINDABLE, "2026-07-01T00:00:00Z", _up(State.FINDABLE), metadata_differs=False)
    assert action is Action.SKIP

Run with pytest -q; four passing cases confirm the table’s most consequential rows resolve to the right action. For a live smoke test, point the client at the DataCite test system (https://api.test.datacite.org) with a test-prefix DOI and confirm a draft is promoted to findable exactly once.

Gotchas

  • A 404 is a state, not a crash. Treating a missing upstream DOI as an exception aborts the batch on the very records reconciliation exists to repair. Fix: map 404 to an ABSENT state, as fetch_state does, and let the decision table route it to RECREATE.
  • Timestamp comparison decides direction — get the clock right. Using DataCite’s updated versus a local marker to choose pull-vs-push is correct only if both are true UTC ISO-8601 strings; a naive or local-time local marker will pull stale data over a fresh edit. Fix: store synced_at as UTC ISO-8601 and compare lexicographically, or fall back to PUSH_METADATA when either timestamp is missing.
  • event is write-once semantics, not a toggle you spam. Re-sending publish is a safe no-op, but interleaving publish and hide across racing jobs will thrash a DOI’s discoverability. Fix: run a single reconciliation writer per DOI and route the state field through one authority, never two jobs with opposite opinions.