Skip to content

Audit & Attestation

The Five Primitives page describes what rules look like and how they compile. The Runtime & Working Memory page describes what happens when you call evaluate(). This page is about what Fathom writes down afterwards — the record of each decision, and the optional cryptographic signature that turns that record into something you can show a third party months later.

Two separate mechanisms share this page because they solve two halves of the same problem:

  • The audit log answers "what did the engine decide, on what inputs, citing which rules?" for every evaluation. It's a local, append-only record.
  • Attestation answers "prove it." An Ed25519 signature over the decision and input digest lets an off-site verifier confirm the record is genuine without trusting the box that produced it.

The audit log is always available (default sink is a no-op). Attestation is opt-in — you construct the engine with a key.

Why a decision engine keeps records

Fathom is deterministic in a specific, limited sense: given the same rule pack, the same working memory in the same order, and the same module focus stack, it produces the same decision. Order matters because the winning decision is last-write-wins — two rules at equal salience are resolved by CLIPS activation order, which follows assertion order. The same fact set supplied in a different order can therefore produce a different decision; salience is how you make a rule's precedence explicit rather than incidental.

That determinism is only useful if you can reconstruct what happened on a specific call weeks later — which facts were present, which rules fired, what the final decision was.

Stateless policy engines can get away with "replay the request" — the input fully determines the output. Fathom can't, because working memory persists across evaluations. The fact that caused a deny today was asserted by a request three hours ago. Without a record written at decision time, that context is gone.

The audit log is that record. Attestation adds one property on top: a signature bound to the decision and inputs, so the record can survive leaving the box it was written on.

Audit log shape

Every successful evaluation produces one AuditRecord (src/fathom/models.py):

class AuditRecord(BaseModel):
    timestamp: str
    session_id: str
    input_facts: list[dict[str, Any]] | None = None
    modules_traversed: list[str]
    rules_fired: list[str]
    decision: str | None
    reason: str | None
    duration_us: int
    metadata: dict[str, str] = Field(default_factory=dict)
    asserted_facts: list[AssertedFact] | None = None
    match_evidence: list[MatchEvidence] | None = None
    attestation_token: str | None = None

Field by field:

  • timestamp — UTC ISO-8601, set inside AuditLog.record() via datetime.now(UTC).isoformat(). Not taken from the caller, so clients can't back-date entries.
  • session_id — the engine's session identifier. Lets you stitch evaluations together when reconstructing what a single agent did.
  • input_facts — the working memory the decision was computed from, snapshotted by the engine before inference so rule-asserted facts are not folded in. Each entry is {"template": <name>, "slots": {...}}. Populated only at log: full; log: summary omits it. A caller constructing an AuditRecord by hand may still pass its own list.
  • modules_traversed / rules_fired — copied from EvaluationResult.module_trace and rule_trace. The modules active during inference and the fully-qualified module::rule names in fire order. Every firing is listed, including rules whose then is only an assert block: the forward-chaining step that derived the fact a later rule decided on is the part of the chain an auditor most needs. A rule that fires twice appears twice. Each compiled rule asserts one __fathom_decision per firing to record this; an assert-only rule's carries action none.
  • decision / reason — the action and reason read off the last __fathom_decision fact. None if no rule asserted a decision, and the engine's default_decision (with the reason default decision (no rule rendered a decision)) when one is configured.
  • duration_us — microseconds spent in the inference loop.
  • metadata — arbitrary string key/value pairs propagated from the decision's rule.
  • asserted_facts — populated only when at least one loaded rule declares an RHS asserts block (see below).
  • match_evidence — which facts, with which slot values, made each rule fire. None unless the engine was built with match_evidence=True (see below).
  • attestation_token — the JWT signed for this same evaluation, copied onto the record so an exported line can be checked without the caller's copy of the token. None on an engine constructed without an attestation_service.

Records are written one-per-line as JSON. JSON Lines is trivially grep-able, jq-able, and concatenatable; it's what most log aggregators expect. Append-only at the process level means a local attacker can truncate or overwrite the file but not silently rewrite a past entry without touching its bytes — detection lives at the filesystem boundary (log shipper, immutable volume, or WORM bucket underneath).

Audit sinks

AuditSink is a tiny Protocol with one method (src/fathom/audit.py):

@runtime_checkable
class AuditSink(Protocol):
    def write(self, record: AuditRecord) -> None: ...

Three implementations ship with Fathom:

  • FileSink(path) — writes record.model_dump_json() + "\n" to the given file in append mode. The constructor creates parent directories and touches the file, so pointing it at a fresh path Just Works.
  • NullSinkwrite() is a no-op. This is the default when you construct an Engine without passing audit_sink.
  • ChainedAttestationLog(path, service) (fathom.chained_log) — the same JSON Lines shape, but each line is signed and commits to the hash of the line before it, so deleting or reordering entries is detectable and not just discouraged. It needs the attestation extra and a signing service:
from fathom import Engine
from fathom.attestation import AttestationService
from fathom.chained_log import ChainedAttestationLog

log = ChainedAttestationLog("/var/log/fathom/audit.jsonl", AttestationService.generate_keypair())
engine = Engine(audit_sink=log)

Verify it later with fathom verify-chain <log> --pubkey <log>.pub.pem. That sidecar is only a convenience: it lives in the same trust domain as the log, so a writer who forges the chain can forge the key beside it. Pin the key_fingerprint the verification reports out-of-band, and verify against a copy of the public key kept where the log's writer cannot reach it. The private half is written through a fresh 0600 temporary file, so a planted <key>.tmp can neither capture it nor relax its mode.

One writer per path. The chain's seq and prev_sha256 are held in the writer's memory, derived when it opens the log. Sharing one log object across threads is fine — appends serialise on the log's own lock — but a second ChainedAttestationLog on the same path, in this process or another, would start from a stale head and write lines describing a file that no longer exists. Every append would succeed and the log would verify as malformed line 3: seq 1, expected 2, which reads as tampering. The second writer is therefore refused: its first append raises AttestationError. The lock is OS-level and lives on a <log>.lock file beside the log, never on the log itself — Windows locks are mandatory, so locking the log would make it unreadable to verify() and fathom verify-chain while a writer held it. Readers are unaffected; network filesystems do not honour the lock reliably.

Anything satisfying the protocol is a valid sink. A production deployment might write to S3, publish to Kafka, call out to syslog, or fan out to several of those — none of which Fathom provides out of the box, but all of which are ten lines of Python on top of the protocol.

Default is off

from fathom import Engine
from fathom.audit import FileSink

engine = Engine(audit_sink=FileSink("/var/log/fathom/audit.jsonl"))

Without that argument, Engine.__init__ installs a NullSink:

self._audit_log = AuditLog(audit_sink or NullSink())

Audit is opt-in for a reason: many embedding contexts — tests, notebooks, short-lived agents — have no use for a durable log, and making file I/O mandatory would turn every evaluate() into a write. Production passes a real sink; everything else keeps working with zero ceremony.

What gets recorded when

The recording happens inside Engine.evaluate(), under the engine's re-entrant lock, so a concurrent caller cannot interleave its own facts into the snapshots below. The sequence:

  1. Pre-snapshot user facts — but only if self._has_asserting_rules is true. That flag is set at load time when any compiled rule declares a non-empty asserts block. If no loaded rule can assert new facts, the snapshot is skipped entirely — there's nothing to diff against.
  2. Snapshot input factsself._snapshot_input_facts() captures the caller-supplied working memory the decision is about to be computed over. It is taken only when an attestation_service is configured or the audit log is recording, because it costs a query per template and nothing else consumes it. This snapshot is what input_hash binds (see Attestation as signed proof) and what log: full records.
  3. Run inferenceself._evaluator.evaluate() returns an EvaluationResult with decision, reason, rule_trace, module_trace, and duration_us, plus the effective log level.
  4. Sign, if configured — if the engine was constructed with an attestation_service, call sign(result, self._session_id, input_facts=...) and store the returned JWT on result.attestation_token. The input facts are not optional here: sign refuses None, because a token signed without inputs binds nothing.
  5. Diff pre/post snapshots — a second _snapshot_user_facts() call, differenced against the pre-snapshot, yields the facts the rules asserted during this evaluation. Order is preserved from the post snapshot; equality is keyed on (template, sorted(slots.items())).
  6. Recordself._audit_log.record(result, session_id, input_facts=..., asserted_facts=..., log_level=...) constructs the AuditRecord and hands it to the sink.
  7. Metricsself._metrics.record_evaluation(...) runs in a finally so metrics are updated even if recording raised.

Two things worth flagging:

  • asserted_facts is None when no loaded rule has an asserts block, and also when asserting rules exist but none fired. An empty list is collapsed to None, so the record distinguishes "didn't try to capture this" from "captured nothing."
  • Signing happens before the audit record is written, which is what lets the record carry the token: the JWT is set on the EvaluationResult in step 4 and copied onto AuditRecord.attestation_token in step 6. The caller gets the same token on the result to forward separately if it wants to.

Match evidence: which facts fired the rule

rules_fired names the rules; it doesn't say what they matched. Over a working memory of a hundred facts that leaves a deny unexplainable after the fact — you know deny-uncleared fired, not which agent tripped it.

Engine(match_evidence=True) records the basis of every firing:

engine = Engine.from_rules("policy/", match_evidence=True)
engine.assert_fact("agent", {"id": "alpha", "clearance": "secret"})
engine.assert_fact("agent", {"id": "bravo", "clearance": "none"})

result = engine.evaluate()
for firing in result.match_evidence:
    print(firing.rule)                    # "governance::deny-uncleared"
    for fact in firing.facts:
        print(fact.template, fact.slots)  # agent {'id': 'bravo', 'clearance': 'none'}

One MatchEvidence entry per firing — a rule that fires twice on different facts appears twice — and one AssertedFact per condition element on the rule's left-hand side, in the order the conditions were written. Rules with no then.action are covered too, so an assert-only rule that quietly seeded a fact still explains itself. rules_fired already names those firings; the evidence adds the facts behind each one.

The same list is copied onto the AuditRecord, so evidence survives to the sink and through JSON serialization.

Why it is opt-in

clipspy exposes no accessor for the facts behind an activation, so the evidence has to be compiled in: with the flag on, every condition element gains a pattern-address binding and every rule gains an extra assert on its right-hand side. That is a change to the generated CLIPS, not a Python wrapper around it.

Leaving the flag off is therefore free rather than merely cheap — the compiler emits byte-identical CLIPS to what it emitted before the feature existed, the __fathom_evidence template is never built, and the evaluator returns an empty list without touching working memory. scripts/benchmark.py holds single-rule evaluation to the same <100µs target with the feature in the tree.

Turn it on when you need to explain decisions after the fact — compliance review, incident forensics, debugging a rule that fires on the wrong join. Leave it off for steady-state enforcement.

Attestation as signed proof

AttestationService (src/fathom/attestation.py) turns an evaluation into a JWT signed with an Ed25519 key. Construct one of two ways:

from fathom.attestation import AttestationService

# Ephemeral keypair — fine for tests, wrong for production.
service = AttestationService.generate_keypair()

# Stable key — load from secure storage at startup.
service = AttestationService.from_private_key_bytes(pem_bytes)

Pass it to the engine alongside (or instead of) a sink:

engine = Engine(
    audit_sink=FileSink("/var/log/fathom/audit.jsonl"),
    attestation_service=service,
)

The algorithm is EdDSA (PyJWT's name for Ed25519-over-JWT). Ed25519 was picked because signatures are 64 bytes, verification is fast, and the public-key PEM is small enough to embed in a verifier image.

The payload is deliberately narrow:

{
    "iss":        "fathom",
    "iat":        int(time.time()),
    "decision":   result.decision,
    "rule_trace": result.rule_trace,
    # input_facts is the pre-inference working-memory snapshot: a list of
    # {"template": <name>, "slots": {...}} entries, in template-registry
    # order and then working-memory order within each template.
    "input_hash": sha256(json.dumps(input_facts, sort_keys=True)).hexdigest(),
    "session_id": session_id,
}

What's in the signature: the decision, the rules that produced it, the session, an issuance timestamp, and a hash of the caller-supplied input facts. What's not: the facts themselves (they're hashed, not embedded), the reason string, the metadata dict, and the evaluation duration — those remain in the audit log but sit outside the signed envelope. The JWT alone proves what was decided; to prove why, pair it with the matching audit-log line.

Verifying an attestation

from fathom.attestation import verify_token

payload = verify_token(jwt_string, service.public_key)

verify_token re-decodes the JWT with algorithms=["EdDSA"] and the supplied public key, returning the payload dict. Any failure — bad signature, malformed token, wrong algorithm — raises AttestationError.

The public key can be serialised for distribution:

pem = service.public_key_pem()  # PEM SubjectPublicKeyInfo bytes

Two fields in the payload are worth calling out:

  • iat gives freshness. A verifier that has its own clock and a known signing-key issuance window can reject tokens from outside it without contacting the signer.
  • input_hash binds the token to a specific input fact set. A verifier reconstructs the hash from the inputs it has and compares; a mismatch means someone changed either the facts or the token.

Threat model

What audit + attestation do protect against:

  • Disputes about what was decided. A signed decision and rule_trace pin down the answer and the rules that produced it.
  • Tampering with the decision in an exported log. Each line carries the JWT signed for that evaluation, and the payload commits to decision, rule_trace, session_id, iat, and input_hash. Change any of those in the line and it no longer matches the claims verify_token returns; re-signing needs the private key.

What this does not cover: reason, metadata, duration_us, input_facts, and asserted_facts sit outside the signed payload, so a line whose token verifies is not thereby proof that those fields are untouched. Nor does a per-line signature detect a line being deleted — for that you need order and continuity, which is what fathom.chained_log.ChainedAttestationLog and fathom verify-chain provide: each entry commits to the hash of the one before it, so a removed or reordered entry breaks the chain. - Input substitution. The input_hash commits the token to a specific set of facts — template name and slot values both. Swap a fact, change a slot, or move a fact to a different template, and the hash stops matching.

Two limits worth knowing. The snapshot is grouped by template (registry order) and then by working-memory order within each template, so reordering facts of the same template changes the hash, while interleaving facts of different templates differently does not. And since cross-template order can change the decision (last-write-wins, above) while leaving the hash alone, input_hash is not by itself proof that a given fact set could only have produced that decision.

What they don't protect against:

  • A compromised engine. If the process producing audit records is controlled by an attacker, it simply never calls AuditLog.record(), or signs a fabricated result. Fathom cannot attest to its own integrity; that's the job of whatever loads the binary.
  • Private-key theft. Ed25519 is only as strong as the secrecy of the signing key. Key custody is out of scope.
  • Side channels. Nothing here prevents an observer from inferring decisions from timing, cache behaviour, or downstream effects.

Declaring attestation on a rule

ThenBlock carries an attestation: bool field (src/fathom/models.py). It is compiled into the __fathom_decision fact's attestation slot — the TRUE/FALSE value is visible to anything reading the decision fact and surfaces through the audit log's decision chain. It is not a switch that turns JWT signing on or off: the engine decides whether to sign based on whether an attestation_service was passed to Engine(...), not on the flag in the rule. Think of the rule-level attestation field as declarative metadata — "this rule claims its decisions should be attested" — that downstream consumers (audit readers, policy linters) can act on.

How they fit together

Audit is the always-on local story: every evaluation gets one AuditRecord, written synchronously to whatever sink the engine was given. NullSink by default, FileSink for development, anything that satisfies the AuditSink protocol in production.

Attestation is the optional portable story: construct the engine with an AttestationService and each EvaluationResult comes back carrying an Ed25519-signed JWT. The token travels independently of the log; the log keeps the full context; together they give a downstream auditor everything they need.

See Runtime & Working Memory for the evaluation loop these records describe, and Writing Rules for the YAML-level attestation flag on a rule's then block.