Reconciling Credit Serial Numbers Across Registries
A carbon credit is not a quantity, it is an interval. Registries issue serial-number ranges, retire sub-ranges, cancel others, and transfer the rest, and a ledger that tracks only totals cannot represent any of those events distinctly. This guide reconciles at the granularity that actually exists, within carbon credit registry data integration in the MRV architecture and carbon accounting fundamentals stack, consuming the verified crawl output produced by handling registry API rate limits and idempotent retries.
The reason totals fail is simple arithmetic. A project issues 250,000 credits; you hold 50,000; the registry later cancels 40,000 following a reversal. Whether those cancelled credits were yours is not derivable from any of those three numbers — it depends on which serials were cancelled and which you hold. Reconciling on totals produces an answer that is right by luck or wrong invisibly, and the difference between those two outcomes is the whole of the exercise.
Root Cause Analysis
Three properties of serial ranges make them awkward enough that teams reach for totals, and each of the three is exactly why totals do not work.
Ranges are split by events rather than by design. A holding of 90,001–140,000 becomes three holdings the moment 100,000–110,000 is retired: two remaining sub-ranges and one retired one. Nothing about the original issuance anticipated the split, and the number of intervals a position occupies grows monotonically over its life. A schema with one row per holding will eventually be wrong; a schema with one row per contiguous sub-range with a state stays correct.
Registries express the same event in different vocabularies. One publishes explicit start and end serials, another publishes a block identifier plus a quantity, a third publishes a retirement record referencing a serial prefix with an implied range. Normalising all of them into half-open integer intervals at ingestion is the only way the arithmetic downstream stays simple, and doing it later means every consumer re-implements the parsing with its own off-by-one.
Off-by-one errors are systematically invisible. A range expressed inclusively as 1–100 holds a hundred credits; the same range read as half-open holds ninety-nine. That single credit per range disappears into rounding at portfolio level and produces a reconciliation that is off by exactly the number of ranges you hold — a discrepancy that looks like noise and is in fact a deterministic bug. Fixing the convention once at the boundary, and asserting the implied count against the registry’s published quantity, catches it immediately.
The failure these produce together is a ledger that agrees with the registry on totals and disagrees on which specific credits it holds — which surfaces the first time a cancellation lands anywhere near your position.
Diagnostic Pipeline / Pre-Flight Validation
Before reconciling, normalise and check the intervals themselves. Three defects make the arithmetic meaningless: overlapping ranges within one holder’s position, gaps in a range the registry says is contiguous, and an implied count that disagrees with the registry’s stated quantity.
from dataclasses import dataclass
import structlog
log = structlog.get_logger()
@dataclass(frozen=True, order=True)
class SerialRange:
"""A half-open interval [start, end). One convention, fixed at the boundary.
Registries publish inclusive ranges, block identifiers with quantities, and
prefixes with implied spans. Normalising all of them here means no consumer
downstream re-implements the off-by-one.
"""
start: int
end: int
project_id: str
state: str # issued | held | retired | cancelled | transferred
vintage: int
def __post_init__(self) -> None:
if self.end <= self.start:
raise ValueError(f"empty or inverted range [{self.start}, {self.end})")
@property
def count(self) -> int:
return self.end - self.start
def overlaps(self, other: "SerialRange") -> bool:
return self.project_id == other.project_id and \
self.start < other.end and other.start < self.end
def intersect(self, other: "SerialRange") -> "SerialRange | None":
if not self.overlaps(other):
return None
return SerialRange(max(self.start, other.start), min(self.end, other.end),
self.project_id, self.state, self.vintage)
def subtract(self, other: "SerialRange") -> list["SerialRange"]:
"""What remains of this range after removing another. Zero, one or two
pieces — the split that makes a one-row-per-holding schema wrong."""
if not self.overlaps(other):
return [self]
pieces = []
if self.start < other.start:
pieces.append(SerialRange(self.start, other.start, self.project_id,
self.state, self.vintage))
if other.end < self.end:
pieces.append(SerialRange(other.end, self.end, self.project_id,
self.state, self.vintage))
return pieces
def from_inclusive(start: int, end_inclusive: int, **kw) -> SerialRange:
"""The single place the inclusive-to-half-open conversion happens."""
return SerialRange(start, end_inclusive + 1, **kw)
def check_position(ranges: list[SerialRange], declared_quantity: int | None) -> dict:
"""Three defects that make the downstream arithmetic meaningless."""
ordered = sorted(ranges)
overlaps = [(a, b) for a, b in zip(ordered, ordered[1:]) if a.overlaps(b)]
implied = sum(r.count for r in ordered)
gaps = []
for a, b in zip(ordered, ordered[1:]):
if a.project_id == b.project_id and a.end < b.start:
gaps.append((a.end, b.start))
result = {
"ranges": len(ordered),
"implied_count": implied,
"declared_quantity": declared_quantity,
"overlaps": len(overlaps),
"gaps": len(gaps),
"count_matches": declared_quantity is None or implied == declared_quantity,
}
if overlaps:
# Two ranges in one position claiming the same serial is double counting
# inside a single holder — the most direct form of the error.
log.error("serials.overlap", count=len(overlaps),
example=[(overlaps[0][0].start, overlaps[0][0].end),
(overlaps[0][1].start, overlaps[0][1].end)])
if declared_quantity is not None and implied != declared_quantity:
log.error("serials.count_mismatch", implied=implied,
declared=declared_quantity, delta=implied - declared_quantity,
hint="a delta equal to the range count is an inclusive/half-open error")
if gaps:
log.info("serials.gaps", count=len(gaps), example=gaps[0],
note="expected where sub-ranges were retired or transferred away")
return result
The mismatch hint earns its place. A delta exactly equal to the number of ranges is the signature of an inclusive range read as half-open, and it is the single most common defect in a serial ledger — recognisable instantly once you know to look, and otherwise mistaken for a rounding artefact for months.
Deterministic Transformation Logic
The reconciler applies registry events to the local position as interval arithmetic, splitting ranges where events partially overlap, and separates the outcome into three buckets: unaffected, newly affected, and — the one that matters — affecting credits already retired against a published claim.
from dataclasses import dataclass, asdict
import structlog
log = structlog.get_logger()
@dataclass(frozen=True)
class Reconciliation:
project_id: str
snapshot: str
our_ranges_before: int
our_credits_before: int
our_ranges_after: int
our_credits_after: int
cancelled_from_held: int
cancelled_from_retired: int # the restatement trigger
transferred_away: int
restatement_required: bool
def apply_registry_events(position: list[SerialRange],
events: list[SerialRange]) -> tuple[list[SerialRange], dict]:
"""Apply cancellations and transfers to our position by interval arithmetic.
Each event may split one of our ranges into two, leave it untouched, or
consume it entirely — which is why the position is stored as ranges rather
than as one row per holding.
"""
remaining = list(position)
impact = {"cancelled_from_held": 0, "cancelled_from_retired": 0,
"transferred_away": 0}
for event in events:
if event.state not in ("cancelled", "transferred"):
continue
next_remaining: list[SerialRange] = []
for held in remaining:
hit = held.intersect(event)
if hit is None:
next_remaining.append(held)
continue
if event.state == "cancelled":
key = "cancelled_from_retired" if held.state == "retired" \
else "cancelled_from_held"
impact[key] += hit.count
else:
impact["transferred_away"] += hit.count
# Keep whatever of our range the event did not touch.
next_remaining.extend(held.subtract(event))
log.info("serials.event_applied", project=held.project_id,
event=event.state, our_state=held.state,
range=(hit.start, hit.end), credits=hit.count)
remaining = next_remaining
return remaining, impact
def reconcile(project_id: str, snapshot: str, position: list[SerialRange],
registry_events: list[SerialRange]) -> Reconciliation:
"""Reconcile one project's position against a registry snapshot."""
before_ranges, before_credits = len(position), sum(r.count for r in position)
after, impact = apply_registry_events(position, registry_events)
result = Reconciliation(
project_id=project_id, snapshot=snapshot,
our_ranges_before=before_ranges, our_credits_before=before_credits,
our_ranges_after=len(after), our_credits_after=sum(r.count for r in after),
cancelled_from_held=impact["cancelled_from_held"],
cancelled_from_retired=impact["cancelled_from_retired"],
transferred_away=impact["transferred_away"],
# Only cancellation of ALREADY-RETIRED credits reaches backwards into a
# figure that has been published. Everything else changes the future.
restatement_required=impact["cancelled_from_retired"] > 0,
)
if result.restatement_required:
log.error("serials.restatement_required", **asdict(result),
note="credits retired against a published claim have been cancelled")
else:
log.info("serials.reconciled", **asdict(result))
# Conservation check: nothing may appear from nowhere.
accounted = (result.our_credits_after + result.cancelled_from_held
+ result.cancelled_from_retired + result.transferred_away)
if accounted != before_credits:
raise RuntimeError(
f"credits not conserved: {accounted} accounted vs {before_credits} before")
return result
The conservation check at the end is the assertion worth keeping. Every credit in the position before reconciliation must appear afterwards in exactly one of four places — still held, cancelled from held, cancelled from retired, or transferred away — and a mismatch means the interval arithmetic dropped or duplicated something. It is cheap, it runs on every reconciliation, and it catches the class of bug that a totals-based ledger cannot even express.
Compliance Gating & Audit Trail Generation
The reconciliation record is evidence, and four fields make it usable years later.
The snapshot identity and its retrieval time, so a discrepancy can be attributed to timing rather than error. A reconciliation without it asserts something about “the registry” with no statement of when.
The per-range disposition, not just the totals. When a verifier asks which specific credits backed a retirement claim, the answer is a list of intervals with their states — and reconstructing that from totals after the fact is impossible.
The restatement flag with its cause. A cancellation touching already-retired credits invalidates a published claim, which is a disclosure event with its own obligations, and it is materially different from a cancellation touching unretired holdings. Recording the distinction is what lets the disclosure be scoped correctly rather than defensively over-broad.
The conservation assertion result, because a reconciliation that did not check conservation is a reconciliation whose arithmetic nobody verified.
Route these into the evidence chain through MRV data lineage and provenance tracking, keyed on the same run identifier as the crawl that produced the registry state, so the two can be joined without reconciling identifiers by hand.
Production Integration
- Normalise every registry vocabulary into half-open integer intervals at ingestion, in one place, and assert the implied count against the registry’s stated quantity.
- Store the position as ranges with states, one row per contiguous sub-range, never one row per holding.
- Reconcile against a verified crawl snapshot, refusing to run against a crawl whose completeness could not be established.
- Apply events as interval arithmetic, letting ranges split, and assert conservation afterwards.
- Separate the restatement case explicitly and route it to the disclosure process rather than treating all cancellations alike.
- Retain retired and cancelled ranges permanently — they are the record a claim is checked against, and deleting them makes the ledger unreconcilable.
For scale, interval arithmetic over a portfolio is trivial computationally: even a large holder tracks tens of thousands of ranges, which fits comfortably in memory and reconciles in seconds. The engineering effort goes into the normalisation at the boundary and the conservation assertions, not into performance.
Frequently Asked Questions
Why not just track quantities per project and vintage?
Because cancellations and retirements apply to specific serials, and quantities cannot express which. Two portfolios with identical quantities can be affected completely differently by the same cancellation, and no amount of totals arithmetic distinguishes them. Quantities are a summary you compute from intervals; treating them as the source of truth means the summary is the only thing you have when a specific question is asked.
How should a registry that publishes only quantities be handled?
Record that limitation explicitly and reconcile at the coarsest granularity the registry supports, marking the position as quantity-tracked rather than serial-tracked. Some registries genuinely do not expose serials through their API even though they maintain them internally, in which case a serial-level claim cannot be substantiated from your data alone and the honest record says so. Where serials are available only in a document export, parsing that export is usually worth the effort precisely because it upgrades every subsequent claim.
What causes a count mismatch equal to the number of ranges?
An inclusive range read as half-open, or the reverse. Each range is then off by exactly one credit, so the total is off by the range count — a signature specific enough to diagnose on sight. Fix the conversion at the ingestion boundary rather than adjusting the total, and add the implied-versus-declared assertion so the same defect cannot recur silently when a new registry is added.
Are gaps in a position always a problem?
No, and treating them as one produces noise. Gaps are expected wherever a sub-range has been retired or transferred away, which is normal for any position of age. What matters is that every gap is explained by a recorded event — a gap with no corresponding retirement, transfer or cancellation is unaccounted, and that is the case worth raising. Checking gaps against the event log rather than against contiguity is the useful test.
How are transfers between our own accounts handled?
As transfers, recorded with both account identifiers, and excluded from the transferred-away impact. An internal move changes custody without changing what the organisation holds, so netting it out of the impact figures while keeping it in the event log gives both the correct position and a complete audit trail. Collapsing internal moves entirely loses the ability to answer where a credit sat at a point in time, which registries increasingly ask about.
What if the registry’s own record and ours disagree on a range’s state?
Investigate before adjusting, and never silently adopt the registry’s view. A disagreement is either a timing artefact — an event we have not yet ingested — or a genuine discrepancy, and the two need different responses. Re-run the reconciliation against a fresh snapshot to eliminate timing, and if the disagreement survives, raise it with the registry with the specific interval and both states rather than adjusting the ledger to match. A ledger that silently converges on the registry cannot detect a registry error.
How often should serial-level reconciliation run?
Monthly for the full position, and before any figure is published. Serial-level reconciliation is cheap once the crawl has run, so the cadence is really the crawl’s cadence, and the publication-time run is the one that matters most — it is the last opportunity to catch a cancellation before it becomes a restatement rather than a correction.
Should the ledger store the registry’s raw event records too?
Yes, alongside the derived position. The position is what you reconcile and report against; the raw events are what let you rebuild it from scratch when a parsing bug is found, and they are what an auditor compares your interpretation against. Keeping both means a defect in the interval arithmetic is recoverable by replaying events rather than by re-crawling a registry that may no longer publish the same history.
The storage cost is negligible — a large portfolio generates thousands of events a year, not millions — and the recovery value is high. Treat the event log as the system of record and the position as a derived, rebuildable projection, which is the same relationship the raw and curated zones have elsewhere in the stack.
How does vintage interact with serial ranges?
Vintage is an attribute of the range rather than a separate dimension, and conflating the two is a common source of double counting. A project’s issuance for one vintage occupies a distinct serial range from the next vintage’s, so a position spanning several vintages is simply several ranges with different vintage values — no special handling required. What does require care is a claim that draws on multiple vintages: it retires sub-ranges from each, and the retirement record must name every interval rather than a total per vintage, or the same reconstruction problem reappears one level down.
Related guides
- Carbon Credit Registry Data Integration — the parent topic and the three failure modes this reconciliation defends against.
- Handling Registry API Rate Limits and Idempotent Retries — the verified crawl this consumes.
- MRV Data Lineage & Provenance Tracking — where the reconciliation record becomes audit evidence.
- Permanence, Reversal & Leakage Monitoring — the upstream event that produces most cancellations.