Repository Sync & Reconciliation: Keeping Identifier State Consistent Across Registries

Any system that mints and manages persistent identifiers holds two copies of the truth: a local catalog that the deposit pipeline writes to, and the external registry — DataCite, or a repository like Zenodo, Figshare, or Dataverse — that the wider world resolves against. Those two copies drift. A Digital Object Identifier (DOI) gets minted as a draft and the publish call silently fails, so the catalog records it as findable while the registry still holds a draft. A curator tombstones a record upstream through the web console, and nothing tells the local system. A metadata field is edited out of band, and the next automated push overwrites a human correction. Left alone, drift compounds until the catalog is a confident record of a state that no longer exists — a direct threat to the Findable and Accessible guarantees that persistent identifiers are supposed to underwrite. This guide sits inside the Persistent Identifiers & Repository Integration area as its consistency layer: it treats the registry as the system of record for resolution and builds a scheduled reconciliation loop that measures the gap between local and upstream state, emits a minimal patch, and applies it idempotently. The concrete DataCite-specific walkthrough — fetching each DOI’s draft/registered/findable state and computing the patch — lives in reconciling DOI state across repositories; this page is the model and the machinery around it.

The Reconciliation Loop

Reconciliation is a control loop, not a one-shot migration. On a schedule, it fetches the authoritative upstream state for a set of identifiers, compares that state against the local catalog, decides whether the two agree, and — when they do not — applies a patch that moves one side toward the other. The loop is deliberately boring: it makes no assumptions, it repeats safely, and every pass either converges the two stores or produces an auditable record of an unresolved conflict.

The scheduled reconciliation loop A scheduled job fetches upstream registry state, computes a state diff against the local catalog, and reaches a decision node asking whether the two are in sync. If they agree, the run logs a no-action outcome. If they diverge, the job emits a patch set and applies it idempotently, then appends the applied change to an event log. Fetch upstream registry state Compute state diff local vs upstream In sync? no Emit patch set minimal delta Apply idempotently retry-safe PATCH yes Log no action record checkpoint Append to event log

The loop’s value is that every pass is a measurement. If it runs and finds nothing to do, that is a positive assertion that the two stores agree for the identifiers checked — a checkpoint you can put on a dashboard. If it finds drift, it repairs the smallest possible change and records why. Because the patch is the difference between two observed states rather than a blind re-push of everything, a reconciliation run is cheap, targeted, and safe to run far more often than a full re-deposit.

Concept & Specification: State, Drift, and the Diff/Patch Model

The unit of reconciliation is the identifier’s state: a small, comparable projection of everything that matters for consistency. For a DOI managed through the DataCite Metadata Schema, that projection is the lifecycle state (draft, registered, or findable), the landing-page URL, the metadata payload (or a hash of it), and a modification timestamp. The registry exposes all of these through its REST API; the local catalog stores its own copy plus a synced_at marker recording when it last agreed with upstream. Drift is any observable difference between the local projection and the upstream projection for the same identifier.

Three drift classes recur across every registry and repository:

  • Lifecycle drift. The two sides disagree about where the identifier is in its lifecycle. The classic case is a DOI minted as a draft whose publish step failed: the catalog says findable, the registry says draft. Its mirror is an upstream tombstone — a curator retracts a record through the web console, DataCite flips it out of the findable state, and the local catalog never hears about it.
  • Metadata drift. The lifecycle agrees but the payloads differ. Someone edited a creator, a title, or a rights statement directly in the repository UI, or an older automated push landed a stale version. A content hash of the normalized metadata detects this in one comparison instead of a field-by-field walk.
  • Existence drift. One side has the identifier and the other does not. A DOI is missing upstream (a mint that never committed), or present upstream but absent locally (imported by a colleague, or deleted from the catalog by mistake).

The remediation model is diff then patch. Rather than pushing the entire local record on every run, reconciliation fetches the upstream state, computes the exact set of changes needed to reconcile the two, and emits a patch set — an ordered list of field-level operations. A patch is applied idempotently: applying it once and applying it three times leave the registry in the same state, so a retry after a network timeout is always safe. This is the same idempotency contract the batch layer relies on, described in Async Batch Processing, applied here to registry mutations instead of ingestion writes.

Patches are also event-sourced. Each applied patch is appended to an immutable event log keyed by identifier before the local synced_at marker is advanced. The current local state is a fold over that event stream, which means reconciliation history is fully auditable: you can replay exactly which run changed which field and why, reconstruct the catalog at any past point, and prove to a funder audit that a rights statement was corrected on a specific date. Direction of repair is a policy decision — for a lifecycle failure the local pipeline usually re-drives the upstream transition, whereas for a human edit made upstream the registry is authoritative and the correction is pulled down — and the loop records that decision rather than hard-coding one winner.

Step-by-Step Implementation

The implementation is four ordered stages: define a comparable state projection, compute the diff, drive the reconcile loop, and apply the patch idempotently while appending to the event log. Every stage is pure and testable in isolation, which is what keeps a job that mutates a public registry trustworthy.

Step 1 — Define a comparable state projection

Both sides must be reduced to the same shape before they can be compared. A frozen model gives you a normalized, hashable projection where equality is meaningful: two states are equal only when every reconciliation-relevant field matches. Normalizing the metadata into a stable hash means a whitespace-only or key-order difference never registers as spurious drift.

python
from __future__ import annotations

import hashlib
import json
from enum import Enum

from pydantic import BaseModel, ConfigDict, Field


class DoiState(str, Enum):
    DRAFT = "draft"
    REGISTERED = "registered"
    FINDABLE = "findable"
    TOMBSTONED = "tombstoned"
    ABSENT = "absent"  # not present on this side at all


class IdentifierState(BaseModel):
    """A normalized, comparable projection of one identifier's state."""

    model_config = ConfigDict(frozen=True)

    doi: str
    lifecycle: DoiState
    url: str | None = None
    metadata_hash: str | None = None
    updated: str | None = None  # ISO-8601 timestamp reported by the source

    @staticmethod
    def hash_metadata(metadata: dict[str, object]) -> str:
        """Order-independent hash of the metadata payload for drift detection."""
        canonical = json.dumps(metadata, sort_keys=True, separators=(",", ":"))
        return hashlib.sha256(canonical.encode("utf-8")).hexdigest()

    @classmethod
    def from_record(
        cls, doi: str, lifecycle: DoiState, url: str | None,
        metadata: dict[str, object] | None, updated: str | None,
    ) -> "IdentifierState":
        return cls(
            doi=doi.lower(),
            lifecycle=lifecycle,
            url=url,
            metadata_hash=cls.hash_metadata(metadata) if metadata is not None else None,
            updated=updated,
        )

The ABSENT lifecycle value is what lets existence drift flow through the same comparison as every other kind: a DOI missing upstream is simply an upstream state whose lifecycle is ABSENT, not a special case bolted on outside the diff.

Step 2 — Compute the state diff

The diff is a pure function of two states. It walks the reconciliation-relevant fields, and for each divergence it emits one patch operation naming the field, the observed local value, the observed upstream value, and the target the patch should move toward. Keeping this function free of I/O is deliberate: it is the part most worth exhaustively unit-testing, and a pure function is trivial to test.

python
from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class PatchOp:
    field: str
    local: object
    upstream: object
    target: object          # value to converge on
    direction: str          # "push" (local -> registry) or "pull" (registry -> local)


def diff_state(
    local: IdentifierState, upstream: IdentifierState, *, upstream_authoritative_fields: set[str],
) -> list[PatchOp]:
    """Return the minimal ordered patch set that reconciles local with upstream."""
    ops: list[PatchOp] = []

    def add(field: str, lv: object, uv: object) -> None:
        pull = field in upstream_authoritative_fields
        ops.append(
            PatchOp(
                field=field, local=lv, upstream=uv,
                target=uv if pull else lv,
                direction="pull" if pull else "push",
            )
        )

    if local.lifecycle != upstream.lifecycle:
        add("lifecycle", local.lifecycle, upstream.lifecycle)
    if local.url != upstream.url:
        add("url", local.url, upstream.url)
    if local.metadata_hash != upstream.metadata_hash:
        add("metadata", local.metadata_hash, upstream.metadata_hash)

    return ops

An empty list is the in-sync signal — no ops means no work. The upstream_authoritative_fields argument is where the direction-of-repair policy lives: put metadata in that set when curators edit records in the repository UI and their edits should win, and lifecycle transitions default to push because the local pipeline owns publishing.

Step 3 — Drive the reconcile loop

The driver ties fetch, diff, and apply together over a batch of identifiers. It fetches upstream state through a client that must degrade gracefully — a registry outage should downgrade an identifier to “unresolved this pass”, never crash the run — which is exactly the resilience posture set out in API Routing & Fallbacks. Each identifier is reconciled independently so that one poison record cannot stall the batch.

python
from __future__ import annotations

import logging
from typing import Callable, Protocol

logger = logging.getLogger("pid.reconcile")


class RegistryClient(Protocol):
    def fetch_state(self, doi: str) -> IdentifierState: ...
    def apply_patch(self, doi: str, ops: list[PatchOp]) -> None: ...


class ReconcileReport(BaseModel):
    checked: int = 0
    in_sync: int = 0
    patched: int = 0
    unresolved: list[str] = Field(default_factory=list)


def reconcile_batch(
    dois: list[str],
    load_local: Callable[[str], IdentifierState],
    client: RegistryClient,
    *,
    upstream_authoritative_fields: set[str],
    dry_run: bool = False,
) -> ReconcileReport:
    """Fetch, diff, and (optionally) patch each identifier; never abort mid-batch."""
    report = ReconcileReport()
    for doi in dois:
        report.checked += 1
        try:
            local = load_local(doi)
            upstream = client.fetch_state(doi)
        except Exception as exc:  # transient fetch failure: defer, do not fail the run
            logger.warning("fetch failed for %s: %s", doi, exc)
            report.unresolved.append(doi)
            continue

        ops = diff_state(local, upstream, upstream_authoritative_fields=upstream_authoritative_fields)
        if not ops:
            report.in_sync += 1
            continue

        logger.info("drift on %s: %s", doi, [op.field for op in ops])
        if not dry_run:
            client.apply_patch(doi, ops)
        report.patched += 1
    return report

Running with dry_run=True first is the standard operational move: it produces the full drift report — how many identifiers diverge and on which fields — without mutating a single registry record, so a human can sanity-check the blast radius before a live pass.

Step 4 — Apply the patch idempotently and record the event

Applying the patch is the only stage that mutates external state, so it carries the idempotency and audit burden. Each patch is appended to the event log keyed by a deterministic event ID derived from the identifier and the observed states, so replaying the same reconciliation twice appends the event once. Only after the registry call and the log append succeed does the local synced_at marker advance.

python
from __future__ import annotations

import hashlib


def event_id(doi: str, ops: list[PatchOp]) -> str:
    """Deterministic id: same doi + same observed delta => same event."""
    material = doi + "|" + ";".join(
        f"{op.field}:{op.local!r}->{op.upstream!r}" for op in sorted(ops, key=lambda o: o.field)
    )
    return hashlib.sha256(material.encode("utf-8")).hexdigest()[:32]


def apply_and_record(
    doi: str,
    ops: list[PatchOp],
    client: RegistryClient,
    append_event: Callable[[str, str, list[PatchOp]], bool],
    mark_synced: Callable[[str], None],
) -> None:
    """Apply a patch, append exactly one event, then advance the sync marker."""
    eid = event_id(doi, ops)
    client.apply_patch(doi, ops)              # idempotent: re-applying is a no-op upstream
    appended = append_event(eid, doi, ops)    # returns False if this event id already exists
    if appended:
        logger.info("recorded reconciliation event %s for %s", eid, doi)
    mark_synced(doi)

The append_event callback is expected to be a conditional insert keyed on eid (INSERT ... ON CONFLICT DO NOTHING), so a crash between the registry call and the marker update leaves a recoverable state: the next run recomputes the identical event ID, the insert is a no-op, and the marker simply advances.

Reference: Drift Scenarios, Detection Signals, and Remediation

Every row below is a real divergence a running catalog encounters, the concrete signal that detects it, and the remediation the patch applies. There are no placeholder rows; these are the cases a reconciliation loop is built to close.

Drift scenario Detection signal Remediation
DOI minted but never published Local lifecycle=findable, upstream lifecycle=draft Re-drive the publish transition upstream (push); on repeated failure, quarantine for review
Record tombstoned upstream Upstream leaves findable / HTTP 410 on resolution; local still findable Pull tombstone into local state, flag landing page and downstream indexes
Metadata edited out of band upstream metadata_hash differs, upstream updated newer than local synced_at Pull upstream metadata into local record (registry authoritative for manual edits)
Stale automated push metadata_hash differs, local updated newer than upstream Re-push local metadata to registry (pipeline authoritative)
DOI missing upstream Upstream fetch returns 404 → lifecycle=absent Re-register the record from the local catalog, then verify
Present upstream, absent locally Local load returns absent, upstream resolvable Import upstream record into catalog, or tombstone if intentionally removed
Landing-page URL drift url fields differ Patch the registry URL to the current canonical landing page (push)
Metadata schema version mismatch Upstream schemaVersion older than local target Re-map to the current DataCite Metadata Schema and re-push

Error Handling & Edge Cases

The dangerous edge case in reconciliation is not a failed fetch — that is just a deferred identifier — but a lost update. Between the moment the job fetches upstream state and the moment it applies its patch, a curator can change the record from the web console. If the patch is applied blindly, it silently clobbers that human edit. The fix is optimistic concurrency: capture the registry’s version token (an ETag, a version, or the updated timestamp) at fetch time and send it back as a precondition on the write. If the token no longer matches, the registry rejects the write, and the job re-fetches, re-diffs against the newer state, and tries again.

Optimistic concurrency on patch apply A patch is applied with the version token captured at fetch time. A decision node asks whether the version still matches. If it matches, the change commits and an event is recorded. If it does not, the run re-fetches upstream and re-diffs, looping back to the apply step; when retries are exhausted the identifier is routed to a conflict queue for manual review. Apply patch with version token Version match? yes Commit & record append event no Re-fetch & re-diff retry retries exhausted Conflict queue manual review

Three further edge cases deserve explicit handling. Rate limiting: a full-catalog pass can exceed a registry’s request budget, so the fetch stage must honor Retry-After and back off — the same throttling discipline the Async Batch Processing layer applies to bulk deposits. Partial upstream reads: a registry that returns metadata but omits the lifecycle field should mark the identifier unresolved rather than diffing against a half-populated state and emitting a bogus patch. And clock skew: never treat a raw timestamp comparison as authoritative for direction of repair when the two clocks can differ by seconds — prefer an explicit version token, and use timestamps only as a tie-breaker.

Verification & Testing

The two properties worth pinning are that the diff is truly minimal (identical states produce an empty patch, and a single-field change produces exactly one op) and that applying a patch is idempotent (a second identical apply records no new event). Because the diff is a pure function, both are fast unit tests that need no live registry.

python
import pytest


def _state(**kw) -> IdentifierState:
    base = dict(doi="10.1234/abc", lifecycle=DoiState.FINDABLE,
                url="https://ex.org/1", metadata_hash="h1", updated="2026-07-01T00:00:00Z")
    base.update(kw)
    return IdentifierState(**base)


def test_identical_states_produce_empty_patch() -> None:
    ops = diff_state(_state(), _state(), upstream_authoritative_fields=set())
    assert ops == []


def test_lifecycle_drift_produces_one_push_op() -> None:
    local = _state(lifecycle=DoiState.FINDABLE)
    upstream = _state(lifecycle=DoiState.DRAFT)
    ops = diff_state(local, upstream, upstream_authoritative_fields=set())
    assert len(ops) == 1
    assert ops[0].field == "lifecycle" and ops[0].direction == "push"


def test_manual_metadata_edit_pulls_from_upstream() -> None:
    local = _state(metadata_hash="h1")
    upstream = _state(metadata_hash="h2")
    ops = diff_state(local, upstream, upstream_authoritative_fields={"metadata"})
    assert ops[0].direction == "pull" and ops[0].target == "h2"


def test_event_id_is_stable_for_same_delta() -> None:
    local, upstream = _state(lifecycle=DoiState.FINDABLE), _state(lifecycle=DoiState.DRAFT)
    ops = diff_state(local, upstream, upstream_authoritative_fields=set())
    assert event_id("10.1234/abc", ops) == event_id("10.1234/abc", ops)

Run the suite with pytest -q. Wire it into CI so any change to the diff logic or the state projection must keep these invariants green; a reconciliation job that can emit a non-minimal or non-deterministic patch is one that will eventually fight a curator or thrash a registry.

Gotchas & Known Pitfalls

  • Treating the local catalog as the system of record for resolution. The registry is what the world resolves against; if reconciliation always pushes local over upstream, it will happily overwrite legitimate curator edits. Root cause: one hard-coded winner. Fix: make direction per-field policy via upstream_authoritative_fields, and default manual-edit-prone fields to pull.
  • Diffing raw metadata instead of a normalized hash. Key ordering, whitespace, and null-vs-absent differences trigger endless false-positive drift and a patch that changes nothing. Root cause: comparing serialized bytes. Fix: hash a canonical, sorted, separator-normalized form, as hash_metadata does.
  • Non-idempotent patch application. Re-running after a timeout double-applies transitions or appends duplicate events. Root cause: auto-increment event keys and unconditional writes. Fix: derive a deterministic event ID from the observed delta and insert it conditionally.
  • Blind writes that lose concurrent updates. Applying a patch without the version token captured at fetch time silently clobbers an edit made mid-run. Root cause: no optimistic concurrency. Fix: send the ETag/version as a write precondition and re-diff on rejection.
  • Reconciling the whole catalog in one unbounded pass. A single job over hundreds of thousands of DOIs blows the registry’s rate budget and never finishes. Root cause: no batching or backpressure. Fix: page the identifier set and throttle fetches, honoring Retry-After.

Frequently Asked Questions

How often should the reconciliation job run?

Match the cadence to how the drift arises. Lifecycle failures from your own pipeline are best caught within minutes by a post-deposit verification pass, so a short-interval job scoped to recently touched identifiers pays off immediately. Out-of-band curator edits and upstream tombstones accrue slowly, so a full-catalog sweep nightly or weekly is usually enough. Running the same loop at two cadences over two scopes — hot recent identifiers frequently, the whole catalog occasionally — is the common production pattern.

Should reconciliation ever delete a local record?

Almost never. If an identifier is present upstream but absent locally, import it or flag it, but treat deletion as a human decision. Persistent identifiers are a promise of permanence, so the safe default when the two sides disagree about existence is to preserve and surface the record for review, not to silently drop it. A tombstone — retaining the identifier and metadata while marking it withdrawn — is the correct terminal state, never a hard delete.

How is this different from just re-pushing all local metadata on a schedule?

A blind re-push is a write amplifier: it hammers the registry with unchanged records, races against curator edits, and gives you no signal about what actually diverged. The diff/patch loop touches only genuinely drifted fields, produces an auditable report of every change, and — crucially — can run in dry_run mode to measure drift without mutating anything. You get consistency and observability instead of consistency you cannot see.

Where does the DOI-minting side fit relative to reconciliation?

Minting creates the identifier and its first upstream state; reconciliation keeps that state honest over the identifier’s lifetime. The two are complementary halves of the same lifecycle, and the minting mechanics — draft creation, metadata registration, and the publish transition — are covered in DOI Minting with DataCite. Reconciliation assumes those operations exist and re-drives them when it detects that one never completed.