Auditability#
Auditability in factgraph is not a feature you switch on. It is a consequence of how the graph stores facts and how it explains conclusions. Because the ledger is append-only, every assertion carries its own provenance, and every evaluated conclusion comes back with an evidence structure that names the rule and the supporting facts, you can always trace a conclusion to its inputs. This page discusses why that is structural rather than a logging mode, and how the three pieces fit together.
Three pieces, one property#
A claim such as “this conclusion holds” is only as trustworthy as the answer to three questions: what is on the record, where did each fact come from, and how was the conclusion derived. factgraph answers each question with a different part of the system, and the three parts are designed to compose.
- The append-only ledger answers what is on the record, and what was it ever. It never loses a fact.
- The provenance carried on each assertion answers where did this fact come from. It travels with the assertion, not in a side channel.
- The explanation / evidence layer answers how was this conclusion derived. It names the rule and the supporting facts that produced a row.
None of the three is a log that runs alongside the real work and might drift from it. Each is the same data the runtime itself reads. That is what makes auditability structural: there is no separate audit copy that could disagree with the system of record, because the audit surface queries the system of record directly.
The ledger never forgets#
The ledger (factgraph.core.store.ledger.Ledger) is described in its own docstring as an
“append-only ledger backed by SQLite with write-through in-memory indexes”. The shape of the
storage enforces the “append-only” part. A fact is a Claim (an asrt_id, a pred_id, an
e_ref, and rest_terms), and writing one goes through append_assertion, which performs an
INSERT into a claims table whose primary key is seq INTEGER PRIMARY KEY AUTOINCREMENT.
There is no method that updates or deletes a claim once written, and the unique constraint on
asrt_id means a claim cannot be silently rewritten under the same identifier.
Retraction does not break this. When a fact is withdrawn, the ledger does not remove the
original claim. Instead append_revocation appends a separate Revokes row that points from
a new revoker asrt_id to the revoked_asrt_id. Whether a claim currently counts is then a
derived question, not a stored flag: is_active (in factgraph.core.policy.active) returns
not ledger.has_active_revocation(asrt_id). The retracted claim is still in the table, still
readable, still part of the history. “Delete” in the SDK three-layer API is, at this level, an
append of a revocation.
This has a direct consequence for auditing. Because nothing is overwritten, the question “what did the graph believe at the time this conclusion was drawn?” has an answer that survives later edits. The history is the data, not a snapshot of it. The trade-off, honestly stated, is that the ledger grows monotonically and that “is this fact active now” is a computed property rather than a column, so a reader must understand the revocation model to read the table correctly. For audit purposes that is the right trade: a deletable record is a record you cannot trust after the fact.
Provenance rides with the assertion#
A fact that you cannot attribute is hard to audit even if it is permanent. factgraph keeps
attribution on the assertion itself, in the same ledger, through MetaRow and AnnotationRow
entries keyed by asrt_id. These are written in the same append_assertion transaction as
the claim, so a fact and its provenance commit together or not at all.
The SDK surfaces this as AssertionMeta (in factgraph.sdk.facade), a frozen record whose
fields are the provenance you would ask about: source, trace_id, ingested_at,
approved_by, note, derived_rule_id, derived_rule_version, and the candidate_* fields,
with the full underlying mapping preserved in raw. The distinction between a fact that was
observed and one that was derived is encoded at the ledger level too: AnnotationRow.origin
is constrained to observed or derived (ANNOTATION_ORIGINS), and the validation in
_validate_annotation_rows requires a non-empty derivation whenever origin == "derived". A
derived fact, in other words, is not allowed onto the record without saying what derived it.
Because provenance is part of the assertion rather than an external journal, it cannot drift
out of sync with the fact it describes, and it is carried along by anything that reads the
fact. When fg.audit.explain(...) reports on a cell, it returns each active claim together with
a meta subset (ingested_at, source, run_id, candidate_id, candidate_key,
cand_key_digest) drawn from exactly these rows. The audit view is reading the same provenance
the writer recorded.
Explaining a conclusion: rule plus supporting facts#
The third piece is the evidence layer. Evaluating a rule does not just yield rows; each row can
be turned into an evidence structure that says how it was reached. The model lives in
factgraph.application.explain.evidence_tree. An EvidenceGraph holds one or more paths, each
an EvidenceTree; a tree is made of EvidenceRule occurrences (each tagged head or body
via RuleRole), and each rule occurrence carries EvidenceAtoms. An atom pairs a form (a
Fact, Compare, Builtin, or Aggregate) with a verdict.
The verdict types are where conclusion meets evidence. A Holds verdict carries a tuple of
Source objects: each Source names a ref, an optional field, a value, and meta. That
is the link from a conclusion back to the specific facts that support it. A Fails verdict
records that an atom did not hold, and a NotReached verdict records that an atom was never
evaluated, optionally naming what it was blocked_by. The presence of Fails and NotReached
as first-class verdicts matters for auditing: the structure explains not only why something
held but why an alternative did not, which is what a “why-not” question needs.
Every node in this structure is identified (tree_id, atom_id, join_id,
occurrence_alias), and the whole graph round-trips losslessly to and from plain dictionaries
via evidence_graph_to_dict / evidence_graph_from_dict. An explanation is therefore a
serializable artifact, not an ephemeral pretty-printed string. The same identifiers let the
narrated, human-readable form be checked against the structured form rather than standing in
for it.
Tying a conclusion to the inputs it was computed from#
A separate guarantee sits on the result itself. An EvaluateResult carries a
ResultFingerprint recording the digests of what produced it: expr_digest,
rule_set_digest, view_snapshot_digest, an optional config_digest, the result_digest,
and a run_id. These are constrained to be sha256 tokens. The fingerprint does not store the
inputs; it commits to them. If the rules, the queried expression, the snapshot of the graph, or
the engine configuration differ, the digests differ. So a recorded conclusion can be checked
against the world it was computed from: an audit can confirm that a stored result really does
correspond to a given rule set and graph state, rather than taking that correspondence on
trust.
The audit surface is a reader, not a writer#
The SDK exposes the audit capability as fg.audit, a read-only namespace
(_SDKAuditManager). Its docstring places diff_proof_frames there deliberately: it
“consumes recorded round events and compares persisted proof-frame outcomes” and is
characterized as “post-hoc audit, not hypothetical evaluation”. That phrase captures the whole
posture of the layer. fg.audit.explain(target) resolves a claim from the ledger and reports
the chosen-policy state of its predicate/entity cell, including which active claim is currently
chosen. fg.audit.conflicts(target) reports every active assertion competing for the same
cell alongside the chosen one. fg.audit.diff_proof_frames(...) compares two recorded rounds
of proof-frame events.
What unites these is that none of them changes anything. The audit namespace raises
FrozenSnapshotError on any attribute assignment, and its methods only read. Auditing is a
view over the system of record, computed on demand from the ledger and from recorded events,
not a parallel trail that the system maintains separately and that could fall out of step.
diff_proof_frames also illustrates a deliberate boundary. The query side ships in the SDK,
but capture (the RoundRecorder lifecycle of start_round / record_round_event /
finalize_round) stays in the advanced-importable factgraph.audit.round_events and the SDK
“never reads files internally”. Recording proof-frame rounds is an explicit act; the SDK gives
you the tools to compare what was recorded, not a hidden recorder you cannot see.
Why this is structural#
Put the pieces side by side. The ledger guarantees the record is complete and immutable, so
history cannot be quietly rewritten. Provenance is committed in the same transaction as the
fact, so attribution cannot drift from what it attributes. The evidence layer links each
conclusion to the rule occurrences and Source facts behind it, and the result fingerprint
binds a conclusion to the inputs it was computed from. Each layer is queried by the read-only
audit surface from the same data the runtime uses.
The alternative design, auditing as a logging mode, would mean a second copy written beside the real work: faster to ignore, possible to disable, and able to diverge from the truth it claims to describe. factgraph instead makes the record of what happened and the record of what is true the same record. Auditability falls out of that identity. The cost is the discipline it imposes: facts accumulate forever, “active” and “chosen” are computed rather than stored, and the reader has to understand revocation and policy to read the ledger correctly. For a system-of-record whose value is that you can trust what it says after the fact, that cost is the point.
Related: Audit and capabilities for the precise fg.audit and
fg.meta signatures; Evaluation and explanation for the
EvaluateResult, ResultFingerprint, and evidence types; About the append-only ledger and
provenance and About reasoning, evaluation, and
evidence for the two topics this page draws together;
and How to trace and audit a fact for acting on this.