Parts-Traceability Ledger Design for Aviation Components

A parts-traceability ledger is the append-only record that answers a regulator’s hardest question: for this exact component, prove every install, removal, and repair back to the day it was born. This stage designs that ledger as an immutable, hash-chained event log rather than a mutable inventory table, so that a component’s history can be reconstructed and independently verified long after the technicians who touched it have moved on. It sits inside the broader Aviation MRO Logbook Architecture & Standards Mapping and turns the standards defined there into a concrete, queryable lifecycle store.

Where this stage sits

Upstream boundary. The ledger consumes validated, canonical component events — never raw documents. Field definitions, allowable value sets, and part-number taxonomies arrive from MRO Data Schema Design, which fixes the shape of a ComponentEvent before it is ever appended. By the time an event reaches the ledger it already has a canonical part_number, a verified serial_number, an ATA reference such as 27-51 (trailing-edge flap system), and a source-document hash. The ledger does not parse, normalize, or correct; it commits.

Downstream boundary. The ledger emits two things: a verifiable append confirmation, and trace queries. Continuing-airworthiness management, back-to-birth cycle accounting, and Airworthiness Review reporting all read from it. Because those consumers make certification decisions, the ledger’s contract with them is that any historical entry they read is either intact or provably tampered — there is no third, silent state.

Append-only parts ledger

Append-only parts ledgerBirthrecordInstall /removeHashchainLedgerstoreTracequery

Problem & regulatory driver

Conventional inventory systems overwrite state: a component’s row shows where it is now. Airworthiness demands the opposite — the full history, unaltered. A repair station operating under FAA Part 145 must, per § 145.219, retain records of the work it performs and make them available; and § 43.9 requires each maintenance record to carry a description of the work, its date, and the signature of the person approving return to service. A mutable table that has been UPDATEd cannot demonstrate that a two-year-old removal entry reads today exactly as it did when the technician signed it. The FAA Part-145 Recordkeeping Standards that govern this facility therefore push the design toward an event log where entries are only ever appended and never edited in place. On the European side, EASA Part-M M.A.305 requires the aircraft continuing-airworthiness record system to preserve component history including total accumulated hours and cycles, and each accessory arrives accompanied by an EASA Form 1 or FAA Form 8130-3 whose particulars must be captured at the birth event. The ledger’s job is to make those obligations structural rather than procedural.

Interface contract

The ledger exposes exactly two operations. append(event) takes a canonical ComponentEvent and returns a committed LedgerEntry carrying its sequence number and chain hash. trace(serial_number) returns the ordered, verified history for one physical part.

A ComponentEvent is the atomic unit and must supply:

  • part_number and serial_number — the canonical identity; together they key the trace query.
  • event_type — one of BIRTH, INSTALL, REMOVAL, REPAIR, SCRAP.
  • aircraft_registration — e.g. G-EZUK, present for install/removal events, null at birth.
  • ata_chapter — the iSpec 2200 reference the component belongs to.
  • event_timestamp — ISO 8601 UTC, never local time.
  • authorizing_release — the 8130-3 or Form 1 reference, or the certifying-staff identifier for a return-to-service.
  • source_document_hash — SHA-256 of the underlying record, binding the ledger entry to its evidence.

The output LedgerEntry adds sequence, prev_hash, and entry_hash. Every consumer that reads an entry can recompute entry_hash from the event plus prev_hash and confirm it matches — that is the whole verification story.

Design decisions

Append-only, enforced at the type level. Entries are frozen once created. The store offers no update or delete path; correction of a mistaken entry is itself a new, appended REPAIR or reversing event that references the erroneous sequence. This mirrors how a paper logbook is corrected — a struck line and a fresh entry, never an erasure.

Hash-chain per component, anchored at birth. Each entry commits to the previous entry’s hash, so the chain for one serial cannot be reordered or have a middle entry silently rewritten without breaking every downstream hash. The chain’s genesis is always the BIRTH event, which makes back-to-birth traceability a property of the data structure rather than a report you assemble by hand.

Content-addressed evidence. Storing source_document_hash rather than the document itself keeps the ledger compact while still binding each event to the exact scanned Form 8130-3 or work card it came from. If the stored evidence is later altered, its hash no longer matches the ledger and the discrepancy surfaces immediately.

Serial-scoped ordering. Global ordering across a fleet is unnecessary and expensive; what certification needs is a total order within one component’s life. Scoping the chain per serial keeps appends cheap and traces local.

Production Python implementation

The following module implements the append and per-serial chaining logic with Pydantic v2. It is deliberately storage-agnostic — the LedgerStore protocol can back onto Postgres, an object store, or an append-only file — while keeping the immutability and chaining guarantees in the domain layer.

from __future__ import annotations

import hashlib
import json
import logging
from datetime import datetime, timezone
from enum import Enum
from typing import Optional, Protocol

from pydantic import BaseModel, ConfigDict, Field, field_validator

logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
logger = logging.getLogger("mro.parts_ledger")

GENESIS_HASH = "0" * 64


class EventType(str, Enum):
    BIRTH = "BIRTH"
    INSTALL = "INSTALL"
    REMOVAL = "REMOVAL"
    REPAIR = "REPAIR"
    SCRAP = "SCRAP"


class ComponentEvent(BaseModel):
    """Canonical, immutable component lifecycle event."""

    model_config = ConfigDict(frozen=True, extra="forbid")

    part_number: str = Field(min_length=3, max_length=32)
    serial_number: str = Field(min_length=1, max_length=32)
    event_type: EventType
    aircraft_registration: Optional[str] = Field(default=None, pattern=r"^[A-Z0-9\-]{2,10}$")
    ata_chapter: str = Field(pattern=r"^\d{2}(-\d{2})?$")
    event_timestamp: datetime
    authorizing_release: str = Field(min_length=1)
    source_document_hash: str = Field(pattern=r"^[0-9a-f]{64}$")

    @field_validator("event_timestamp")
    @classmethod
    def require_utc(cls, v: datetime) -> datetime:
        # EASA Part-M M.A.305 requires the record system to preserve component
        # history; storing every timestamp in UTC keeps cross-source ordering
        # unambiguous when accessories move between operators and time zones.
        return v.astimezone(timezone.utc)

    def canonical_bytes(self) -> bytes:
        return json.dumps(self.model_dump(mode="json"), sort_keys=True, separators=(",", ":")).encode()


class LedgerEntry(BaseModel):
    model_config = ConfigDict(frozen=True)

    sequence: int
    event: ComponentEvent
    prev_hash: str
    entry_hash: str


class LedgerStore(Protocol):
    def latest_for(self, serial_number: str) -> Optional[LedgerEntry]: ...
    def append_entry(self, entry: LedgerEntry) -> None: ...
    def history_for(self, serial_number: str) -> list[LedgerEntry]: ...


def _entry_hash(sequence: int, prev_hash: str, event: ComponentEvent) -> str:
    hasher = hashlib.sha256()
    hasher.update(str(sequence).encode())
    hasher.update(prev_hash.encode())
    hasher.update(event.canonical_bytes())
    return hasher.hexdigest()


class PartsLedger:
    """Append-only, per-serial hash-chained component ledger."""

    def __init__(self, store: LedgerStore) -> None:
        self._store = store

    def append(self, event: ComponentEvent) -> LedgerEntry:
        prev = self._store.latest_for(event.serial_number)
        if prev is None and event.event_type is not EventType.BIRTH:
            raise ValueError(f"first event for {event.serial_number} must be BIRTH")

        sequence = 0 if prev is None else prev.sequence + 1
        prev_hash = GENESIS_HASH if prev is None else prev.entry_hash
        entry = LedgerEntry(
            sequence=sequence,
            event=event,
            prev_hash=prev_hash,
            entry_hash=_entry_hash(sequence, prev_hash, event),
        )
        self._store.append_entry(entry)
        logger.info(
            "appended seq=%d type=%s sn=%s ata=%s hash=%s",
            sequence, event.event_type.value, event.serial_number,
            event.ata_chapter, entry.entry_hash[:12],
        )
        return entry

    def trace(self, serial_number: str) -> list[LedgerEntry]:
        history = self._store.history_for(serial_number)
        expected_prev = GENESIS_HASH
        for entry in history:
            recomputed = _entry_hash(entry.sequence, expected_prev, entry.event)
            if recomputed != entry.entry_hash or entry.prev_hash != expected_prev:
                raise ValueError(
                    f"chain broken at seq {entry.sequence} for {serial_number}"
                )
            expected_prev = entry.entry_hash
        return history

Validation & testing

Testing an append-only ledger is mostly testing what it refuses. Assert that appending a non-BIRTH event to an empty serial raises; that a second BIRTH for the same serial is rejected by the sequence logic; and that trace() raises the moment any stored entry_hash is mutated by a byte. A useful property test appends a random but legal sequence of events for a serial such as an actuator on G-EZUK, then flips one character in a middle entry’s authorizing_release and confirms trace() detects the break at exactly that sequence. Round-trip every event through model_validate to prove frozen=True and extra="forbid" reject both mutation and unexpected fields.

Failure modes & troubleshooting

Out-of-order arrival. Events can reach the ledger later than they occurred — a removal scanned days after the fact. Because ordering is chain position, not wall-clock time, never insert into the middle; append the late event at the tail and let event_timestamp carry the real chronology for reporting. Downstream cycle accounting must sort by timestamp, not sequence.

Duplicate submission. A retried pipeline can present the same event twice. Deduplicate on source_document_hash plus event_type before calling append, or the chain will contain two commits for one physical action and inflate the history.

Missing birth record. A component transferred in from another operator may arrive with no BIRTH event on file. Rather than fabricate one, append a synthetic birth carrying the incoming Form 8130-3 reference and flag it as reconstructed, so the trace is honest about where verifiable history begins.

Storage that permits UPDATE. The single most dangerous failure is a backing store whose permissions allow in-place edits. Enforce append-only at the database grant level as well as in code; the type-level immutability protects the domain, but only revoked UPDATE/DELETE privileges protect the bytes. Designed this way, the parts-traceability ledger stops being a report you assemble under audit pressure and becomes a structure that is either provably intact or provably not — which is exactly the standard continuing airworthiness demands.