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: given the same rule pack, the same working memory, and the same module focus stack, it produces the same decision. That's 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
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— optional. The caller can pass a representation of the facts asserted for this evaluation; Fathom does not snapshot working memory into this field automatically.modules_traversed/rules_fired— copied fromEvaluationResult.module_traceandrule_trace. The modules active during inference and the fully-qualifiedmodule::rulenames in fire order.decision/reason— theactionandreasonread off the last__fathom_decisionfact.Noneif no rule asserted a decision.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).
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):
Two 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.
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(). 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. - Run inference —
self._evaluator.evaluate()returns anEvaluationResultwithdecision,reason,rule_trace,module_trace, andduration_us. - Sign, if configured — if the engine was constructed with an
attestation_service, callsign(result, self._session_id)and store the returned JWT onresult.attestation_token. - 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, asserted_facts=...)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. The JWT ends up
on the
EvaluationResultthe caller receives but is not one of theAuditRecordfields — the log records the decision; the token is returned to the caller to store or forward separately.
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_hash": sha256(json.dumps(input_facts or [], 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 exported logs. An attacker who modifies an audit line
after export can't re-sign it without the private key;
verify_tokenfails. - Input substitution. The
input_hashcommits the token to a specific set of facts. Swap the facts and the hash stops matching.
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.