Skip to content

symfonic.services.switching.audit

audit

SCP-AUD — the tamper-evident, append-only switch audit chain.

Each record's hash covers the previous record's hash, so an altered or removed record breaks every hash after it. The point is not that tampering becomes impossible; it is that tampering stops being silent, which is the only property a retirement gate can actually rely on.

AuditRecord dataclass

AuditRecord(seq: int, prev_hash: str, actor: str, role: str, bundle_id: str, prior_epoch: int, proposed_vector_hash: str, outcome: str, reason: str, timestamp: float)

One mutation attempt — accepted or denied, both recorded identically.

HashChainAuditLog

HashChainAuditLog(*, clock: Callable[[], float] = time.time)

An in-process append-only hash chain (the reference implementation).

Source code in src/symfonic/services/switching/audit.py
def __init__(self, *, clock: Callable[[], float] = time.time) -> None:
    self._records: list[AuditRecord] = []
    self._clock = clock

head property

head: str

The chain head the bundle record binds itself to (SCP-AUD-1).

assert_no_credentials staticmethod

assert_no_credentials(text: str) -> None

SCP-AUD-3 — refuse credential-shaped free text before it is chained.

Source code in src/symfonic/services/switching/audit.py
@staticmethod
def assert_no_credentials(text: str) -> None:
    """SCP-AUD-3 — refuse credential-shaped free text before it is chained."""
    if _CREDENTIAL_SHAPES.search(text):
        raise ValueError(
            "audit reasons carry identities, hashes and outcomes — never "
            "credential material; refusing to chain the supplied text."
        )

commit

commit(record: AuditRecord) -> AuditRecord

Chain a prepared record, refusing one that no longer fits the chain.

Source code in src/symfonic/services/switching/audit.py
def commit(self, record: AuditRecord) -> AuditRecord:
    """Chain a prepared record, refusing one that no longer fits the chain."""
    if record.seq != len(self._records) or record.prev_hash != self.head:
        raise ValueError(
            "the prepared audit record no longer chains onto the log head; "
            "prepare and commit must not be interleaved with another append."
        )
    self._records.append(record)
    return record

prepare

prepare(*, actor: str, role: str, bundle_id: str, prior_epoch: int, proposed_vector_hash: str, outcome: str, reason: str = '') -> AuditRecord

Build the next record without chaining it.

Preparing and committing are separate because the bundle record must store the audit head that attests it (SCP-AUD-1), and the record it attests is only durable once the CAS lands. Preparing lets the caller compute that head, commit the bundle, and only then chain the entry — so a lost CAS race never leaves an "accepted" record for a mutation that never happened.

Source code in src/symfonic/services/switching/audit.py
def prepare(
    self,
    *,
    actor: str,
    role: str,
    bundle_id: str,
    prior_epoch: int,
    proposed_vector_hash: str,
    outcome: str,
    reason: str = "",
) -> AuditRecord:
    """Build the next record without chaining it.

    Preparing and committing are separate because the bundle record must
    store the audit head that attests it (SCP-AUD-1), and the record it
    attests is only durable once the CAS lands. Preparing lets the caller
    compute that head, commit the bundle, and only then chain the entry —
    so a lost CAS race never leaves an "accepted" record for a mutation
    that never happened.
    """
    self.assert_no_credentials(reason)
    return AuditRecord(
        seq=len(self._records),
        prev_hash=self.head,
        actor=actor,
        role=role,
        bundle_id=bundle_id,
        prior_epoch=prior_epoch,
        proposed_vector_hash=proposed_vector_hash,
        outcome=outcome,
        reason=reason,
        timestamp=self._clock(),
    )

tamper_for_test

tamper_for_test(index: int, *, reason: str) -> None

Rewrite one record in place — the only way to exercise SCP-AUD-2.

It lives on the log rather than in a test helper so that "the chain detects this" is asserted against the same object production uses, and so any future storage backend inherits the same characterisation.

Source code in src/symfonic/services/switching/audit.py
def tamper_for_test(self, index: int, *, reason: str) -> None:
    """Rewrite one record in place — the only way to exercise SCP-AUD-2.

    It lives on the log rather than in a test helper so that "the chain
    detects this" is asserted against the same object production uses, and
    so any future storage backend inherits the same characterisation.
    """
    self._records[index] = replace(self._records[index], reason=reason)

verify

verify() -> bool

SCP-AUD-2 — recompute the chain from genesis.

Source code in src/symfonic/services/switching/audit.py
def verify(self) -> bool:
    """SCP-AUD-2 — recompute the chain from genesis."""
    expected = GENESIS_HASH
    for index, record in enumerate(self._records):
        if record.seq != index or record.prev_hash != expected:
            return False
        expected = record.digest()
    return True