"""ACT in-memory append-only audit ledger. Provides an in-memory reference implementation of the audit ledger interface. Enforces append-only semantics and hash-chain integrity. Reference: ACT §10 (Audit Ledger Interface). """ from __future__ import annotations import hashlib import json from typing import Any from .errors import ACTLedgerImmutabilityError from .token import ACTRecord class ACTLedger: """In-memory append-only audit ledger for ACT execution records. Records are stored in insertion order with monotonically increasing sequence numbers. A hash chain provides integrity verification. Reference: ACT §10. This is a reference implementation suitable for testing. Production deployments should use a persistent backend implementing the same interface. """ def __init__(self) -> None: self._records: list[tuple[int, ACTRecord, str]] = [] # jti → index mapping for efficient lookup self._jti_index: dict[str, int] = {} # wid → list of indices for workflow queries self._wid_index: dict[str | None, list[int]] = {} self._seq_counter: int = 0 # Hash chain: each entry's hash includes the previous hash self._chain_hashes: list[bytes] = [] def append(self, act_record: ACTRecord) -> int: """Append an execution record to the ledger. Returns the sequence number assigned to the record. Reference: ACT §10, requirement 1 (append-only), requirement 2 (ordering). Raises: ACTLedgerImmutabilityError: If a record with the same jti already exists. """ if act_record.jti in self._jti_index: raise ACTLedgerImmutabilityError( f"Record with jti {act_record.jti!r} already exists in ledger" ) seq = self._seq_counter self._seq_counter += 1 # Compute hash chain entry record_hash = self._hash_record(act_record, seq) if self._chain_hashes: chained = hashlib.sha256( self._chain_hashes[-1] + record_hash ).digest() else: chained = record_hash self._chain_hashes.append(chained) idx = len(self._records) self._records.append((seq, act_record, act_record.jti)) self._jti_index[act_record.jti] = idx wid = act_record.wid if wid not in self._wid_index: self._wid_index[wid] = [] self._wid_index[wid].append(idx) return seq def get(self, jti: str) -> ACTRecord | None: """Retrieve a record by jti. Reference: ACT §10, requirement 3 (lookup). """ idx = self._jti_index.get(jti) if idx is None: return None return self._records[idx][1] def list(self, wid: str | None = None) -> list[ACTRecord]: """List records, optionally filtered by workflow id. If wid is None, returns all records. If wid is a string, returns only records with that wid value. Reference: ACT §10. """ if wid is None: return [r[1] for r in self._records] indices = self._wid_index.get(wid, []) return [self._records[i][1] for i in indices] def verify_integrity(self) -> bool: """Verify the hash chain integrity of the ledger. Recomputes the hash chain from scratch and compares against stored chain hashes. Returns True if all hashes match. Reference: ACT §10, requirement 4 (integrity). """ if not self._records: return True prev_hash: bytes | None = None for i, (seq, record, _jti) in enumerate(self._records): record_hash = self._hash_record(record, seq) if prev_hash is not None: expected = hashlib.sha256(prev_hash + record_hash).digest() else: expected = record_hash if i >= len(self._chain_hashes): return False if self._chain_hashes[i] != expected: return False prev_hash = expected return True def __len__(self) -> int: return len(self._records) def _hash_record(self, record: ACTRecord, seq: int) -> bytes: """Compute a deterministic hash of a record for chain integrity.""" claims = record.to_claims() # Include sequence number in hash for ordering integrity claims["_seq"] = seq canonical = json.dumps(claims, sort_keys=True, separators=(",", ":")) return hashlib.sha256(canonical.encode("utf-8")).digest() def _immutable_guard(self) -> None: """Internal method — not callable externally. The ledger has no update/delete methods by design. This exists to make the intent explicit. """ raise ACTLedgerImmutabilityError( "Ledger records cannot be modified or deleted" )