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 insideAuditLog.record()viadatetime.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 atlog: full;log: summaryomits it. A caller constructing anAuditRecordby hand may still pass its own list.modules_traversed/rules_fired— copied fromEvaluationResult.module_traceandrule_trace. The modules active during inference and the fully-qualifiedmodule::rulenames in fire order. Every firing is listed, including rules whosethenis only anassertblock: 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_decisionper firing to record this; an assert-only rule's carriesaction none.decision/reason— theactionandreasonread off the last__fathom_decisionfact.Noneif no rule asserted a decision, and the engine'sdefault_decision(with the reasondefault 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 RHSassertsblock (see below).match_evidence— which facts, with which slot values, made each rule fire.Noneunless the engine was built withmatch_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.Noneon an engine constructed without anattestation_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):
Three implementations ship with Fathom:
FileSink(path)— writesrecord.model_dump_json() + "\n"to the given file in append mode. The constructor creates parent directories andtouches the file, so pointing it at a fresh path Just Works.NullSink—write()is a no-op. This is the default when you construct anEnginewithout passingaudit_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 theattestationextra 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:
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:
- Pre-snapshot user facts — but only if
self._has_asserting_rulesis true. That flag is set at load time when any compiled rule declares a non-emptyassertsblock. If no loaded rule can assert new facts, the snapshot is skipped entirely — there's nothing to diff against. - Snapshot input facts —
self._snapshot_input_facts()captures the caller-supplied working memory the decision is about to be computed over. It is taken only when anattestation_serviceis configured or the audit log is recording, because it costs a query per template and nothing else consumes it. This snapshot is whatinput_hashbinds (see Attestation as signed proof) and whatlog: fullrecords. - Run inference —
self._evaluator.evaluate()returns anEvaluationResultwithdecision,reason,rule_trace,module_trace, andduration_us, plus the effective log level. - Sign, if configured — if the engine was constructed with an
attestation_service, callsign(result, self._session_id, input_facts=...)and store the returned JWT onresult.attestation_token. The input facts are not optional here:signrefusesNone, because a token signed without inputs binds nothing. - 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())). - Record —
self._audit_log.record(result, session_id, input_facts=..., asserted_facts=..., log_level=...)constructs theAuditRecordand hands it to the sink. - Metrics —
self._metrics.record_evaluation(...)runs in afinallyso metrics are updated even if recording raised.
Two things worth flagging:
asserted_factsisNonewhen no loaded rule has anassertsblock, and also when asserting rules exist but none fired. An empty list is collapsed toNone, 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
EvaluationResultin step 4 and copied ontoAuditRecord.attestation_tokenin 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:
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¶
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:
Two fields in the payload are worth calling out:
iatgives 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_hashbinds 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
decisionandrule_tracepin 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, andinput_hash. Change any of those in the line and it no longer matches the claimsverify_tokenreturns; 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.