About the append-only ledger and provenance#

factgraph never overwrites a fact and never deletes one. Every write you make through the SDK lands as a new row in an append-only ledger, and “removing” a fact is itself a new row that points back at the one being removed. This page explains why the store is shaped that way, how retraction works as a revocation rather than a delete, and how provenance metadata rides along with each assertion. The mechanism lives in factgraph.core.store.ledger.Ledger and in the write protocol that drives it.

The shape of the design: a log, not a table of current values#

The Ledger class describes itself as an “Append-only ledger backed by SQLite with write-through in-memory indexes.” The persistent truth is a small set of SQLite tables: claims, claim_args, meta_rows, annotation_rows, revokes, ingest_keys, and ledger_meta. The interesting property is what is missing. The write paths only ever INSERT. There is no UPDATE of a claim and no DELETE of a claim. A Claim (with fields asrt_id, pred_id, e_ref, rest_terms) is inserted once, into a claims table whose seq INTEGER PRIMARY KEY AUTOINCREMENT records the order in which facts arrived and whose asrt_id TEXT NOT NULL UNIQUE makes each fact addressable forever. The _insert_claim method even turns a primary-key collision into a ValueError("duplicate asrt_id: ..."), so the same fact identity cannot be written twice.

Because the rows only accumulate, the question “what is true right now?” is not answered by reading a cell. It is answered by reading the whole log and computing a view over it. That computation is small and explicit: a claim is active unless something later revoked it. The core.policy.active.is_active helper is the whole rule:

def is_active(ledger: Ledger, asrt_id: str) -> bool:
    ...
    return not ledger.has_active_revocation(asrt_id)

The current state of the world is therefore a derived quantity, not a stored one. Everything above this layer (the snapshots from fg.entities, the field values from fg.fields, the is_active flag on an AssertionRecord) is a projection of the append-only log through this single “not revoked” filter. The append-only ledger is the system of record; the present is a query.

Retraction is a revocation edge, not a delete#

Retracting a fact does not touch the row that holds the fact. Instead it appends a second assertion that records the act of retracting. This is the job of retract_by_asrt in core.evidence.write_protocol, which the public surface reaches through fg.assertions.retract(asrt_id) (and, by delegation, fg.fields.retract, fg.fields.delete, and fg.entities.delete).

When you retract revoked_asrt_id, retract_by_asrt does the following:

  • It mints a fresh revoker_asrt_id, a brand-new assertion id, separate from the one being retracted.
  • It calls ledger.append_revocation(...) with a Revokes(revoker_asrt_id=..., revoked_asrt_id=...) edge. The Revokes dataclass is just that ordered pair, and append_revocation inserts it into the revokes table. The original claim row is left exactly as it was.
  • It writes meta rows onto the revoker, including a revoked_asrt_id meta row of kind str, so the revocation carries a back-pointer to its target in addition to the structural edge.

After that, has_active_revocation(revoked_asrt_id) returns True, and so is_active(...) returns False. The fact is no longer part of the current state, but it is still in the claims table, still addressable by its asrt_id, and still retrievable through fg.assertions.all. Retraction adds knowledge (“this fact was withdrawn, by this revoker”) rather than destroying it.

Two design choices in retract_by_asrt are worth dwelling on:

Retraction is idempotent. Before appending anything, retract_by_asrt calls ledger.find_revoker(revoked_asrt_id); if a revoker already exists it returns that existing revoker id and writes nothing new. So retracting the same fact twice does not produce two revocation edges and does not grow the log a second time. The ledger keeps the first revoker (find_revoker is backed by _first_revoker_by_revoked_asrt_id, populated with setdefault, so the first revoker wins).

Retraction can be rejected by an invariant before it ever reaches the ledger. The Ledger itself is permissive: it will record a Revokes edge against any known claim. The meaning of “you may not delete this” lives one layer up, in the retract guard reached from fg.assertions.retract. Identity claims are immutable (INV-7c) and the per-entity :exists claim is co-emitted atomically with identity, so the guard refuses to revoke either in isolation and points you at fg.entities.delete(e_ref), which revokes the whole entity’s claims together. The ledger stays a dumb, honest log; the rules about which revocations are legitimate are enforced above it.

Provenance travels with the assertion, in its own rows#

A fact in factgraph is rarely interesting on its own. Who asserted it, where it came from, when it was ingested, which rule derived it: that context is provenance, and it is stored alongside each assertion rather than inferred after the fact. The ledger holds it in the meta_rows table, keyed by the assertion’s asrt_id, with a small typed vocabulary of kind values (META_KINDS = {"str", "int", "float", "bool", "time", "json"}).

Some of these keys are written by the system on every assertion. set_field records an ingested_at time (now_epoch_nanos, captured at write time) and an ingest_key; retract_by_asrt records ingested_at and revoked_asrt_id on the revoker. These are the _SYSTEM_MANAGED_META_KEYS = {"ingested_at", "ingest_key", "revoked_asrt_id"}. Other keys come from the meta= dictionary you pass into a write and follow a known convention: source, trace_id, approved_by, note, and the derivation keys among others. The SDK reads these back into the frozen AssertionMeta dataclass on factgraph.sdk.facade, whose fields are source, trace_id, ingested_at, approved_by, note, derived_rule_id, derived_rule_version, candidate_id, candidate_key, candidate_kind, and a raw dictionary holding the unparsed meta. AssertionMeta.from_raw does the reconstruction: it type-checks each field, and it turns the stored ingested_at integer (nanoseconds since the epoch) back into a timezone-aware datetime. Every AssertionRecord you read carries one of these meta objects.

Because provenance is stored per asrt_id and assertions are never overwritten, provenance is as durable as the fact itself. The metadata on a retracted assertion does not disappear when the assertion is revoked, and the revoker assertion carries its own provenance (it too can take a meta= dictionary, so a retraction can record who withdrew the fact and why). The append-only log thus preserves not just the sequence of facts but the sequence of justifications and withdrawals around them, which is what makes a fact’s full history reconstructable later.

The trade-off: the store only grows#

The cost of never deleting is that the ledger only ever gets bigger. Every correction, every retraction, every replace (replace_field retracts the old assertion and then appends a new one, so a single logical edit becomes two new rows plus a revocation edge) adds rows that are never reclaimed. A long-lived workspace that is edited frequently will accumulate far more rows than the count of facts currently considered true. There is no compaction or garbage-collection path in the ledger code: rebuild_indexes is a documented no-op, and the only bulk deletes are internal index-maintenance operations (_force_replace_meta_rows), not a way to prune history.

What you buy for that growth is the property the rest of factgraph depends on. Reads are computed over an immutable log, so the same asrt_id always denotes the same fact with the same provenance, forever. History is never lost to an in-place update, so you can answer “what did we believe at the time, and on what basis?” rather than only “what do we believe now”. And because state is derived from the log rather than stored separately, there is no risk of the “current value” and its audit trail drifting apart: they are the same data, read two different ways. For a system whose purpose is auditable reasoning, a monotonically growing log is the conservative choice, and the per-row overhead is the price of never having to trust a mutable cell.

This also constrains everything built on top. The reasoning engines see the same revocation-filtered view: the Soufflé adapter, for instance, exports the revokes table and derives active(A) :- claim(A,_,_,_), !revokes(_,A)., the exact analogue of is_active. Append-only-ness is not a storage detail hidden inside one module; it is the contract the whole pipeline is written against.

Related: Persistence: workspaces and attach for how the ledger is saved and reloaded; Audit and capabilities and Three-layer API: entities, fields, assertions for the precise read and retract surfaces (AssertionMeta, fg.assertions.retract); About auditability and About reasoning, evaluation, and evidence for how this log underpins provenance and proof. To act on this, see How to write and read facts and How to trace and audit a fact.

comments powered by Disqus