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.

comments powered by Disqus