An append-only fact engine. Every fact is a triple (subject predicate object);
lifecycle is derived, never stored; every write serializes through one coordinator;
and the text is a view of the graph you can always walk away with.
Fram stores facts — relational triples — in a durable append-only log, folds them into a queryable in-memory graph, derives over them with stratified Datalog, and serializes all writes through a sole-writer coordinator. You derive answers, you don't maintain them. References carry identity, not spelling (rename a thing once, not in N files), and immutability buys corruption-freedom + lock-free reads — not merge-freedom. The long argument, written to survive a skeptic with the negative space conceded, is in docs/WHY_FRAM_EXISTS.md.
"Isn't this just Datomic / Datahike / an RDF store?" No — and the reason is the atom, not the features. Fram's unit is the fact-object: a fact that is itself addressable and reifiable at per-fact granularity. A datom isn't; an RDF store treats statement-level reification as a bolt-on. Concurrency, Datalog, and schema-as-data are not why Fram exists (off-the-shelf stores tie or win there) — the primitive is.
Contents: Terminology · Consumers · Reasoning + repair · Try it · How it works · Multi-agent safety · AI-native surface · Pull · Worlds & claims · Identity-addressed code · Anti-rot · Measured · Isolation · Built on Beagle · What it isn't · Tests · License
The substrate atom is a fact: an immutable, addressable triple (subject predicate object). The graph stores facts; lifecycle is derived from the fact set, never stored.
Always call it a fact. The name follows the Datalog / Datomic / Prolog prior — the stored
unit there has always been a fact — so a model reaching for the word gets it right with no
translation.
Keep the epistemics honest: a fact here records what was ASSERTED, not what is verified. It is asserted and defeasible, not settled view-free truth — it can be superseded, retracted, disputed, and coexist with its rival. Supersession, retraction, and views (store, Fact Normal Form — the model the machinery still carries) hold the disagreement, so the atom stays a plain fact. The word keeps two extra-precise senses that don't conflict with the general one:
- Datalog — a ground tuple in a relation (the
d/factsAPI, "delta fact", EDB/IDB). Strict Datalog-evaluation vocabulary. - Views model — a fact selected/accepted as true inside a view (docs/VIEWS_AND_BRANCHES.md §0). Always view-relative; the substrate has no view-free settled facts.
Everywhere — docs, MCP instructions, prompts, CLI help, lifecycle prose — the word is
fact. Why names win the job here at all — and how "world" won its own fight — is
recorded in the naming ledger: docs/naming.md.
Fram is the engine, not an app. The relational structure is shared, so the same engine answers questions for very different domains — each in its own graph:
- North — life/work coordination
(the
ready/blocked/leverageverbs live there, not in the engine). - Codegraph — code-as-facts (a module inside this repo): a Beagle
module's AST is the facts, the
.bcljtext is a view. - Beagle — the typed Lisp Fram itself is authored in; it projects source into the graph through Codegraph.
The engine ships no domain verbs of its own — new domain, new graph, same engine. How each consumer projects onto the facts is the How it works section below; the deep code story is Identity-addressed code.
These are the two reasons to put something in a fact graph instead of files.
Reasoning — relational questions are cheap, exact, and always current. "What depends on this? what's unused? who calls this? what unblocks the most other work?" are relationship questions, and over a graph a relationship question is a Datalog query — no reconstruction tax, because the graph is canonical and incremental, not rebuilt per question. Pointed at code (Codegraph), the same engine answers "what breaks if I change this?" scope-correctly: a call binds the definition in its own module, so two same-named functions in different modules don't collide — what bare-text grep gets wrong.
Repair — change one node, the blast radius re-derives. Because the graph knows the real edges, a change propagates to exactly the affected sites, deterministically — a graph operation, not a model guessing. Reasoning reads the graph; repair reads it and acts.
git clone https://github.com/Autonymy/fram && cd fram
./demo.sh # import the bundled example threads, validate, round-tripThe bundled threads are a fictional "launch a personal website" project — no personal
data. Under the hood ./demo.sh runs the engine loop:
bin/fram import # fold the Markdown threads into the fact graph (coordination.log — facts by nature)
bin/fram validate # structural integrity: cycles, dangling refs, closed vocab
bin/fram call show '{:subject "2026-01-01-090500"}' # all facts on the thread (title, owner, deps…)
bin/fram call ask '{:query {:find "dep" :rules [{:head {:rel "dep" :args [{:var "x"}]} :body [{:rel "fact" :args [{:var "x"} "depends_on" "@2026-01-01-090200"]}]}]}}' # reverse edge via ask
bin/fram export /tmp/regen # regenerate the Markdown from the graph (lossless round-trip)Counts (facts, threads) are computed from threads/, never asserted here — run the
commands and read them. The round-trip is verified fact-set-identical by
tests/roundtrip_test.clj, so files are a view, not a competing source of truth — you
can always walk away with your data.
Point it at your own corpus:
export FRAM_THREADS=/path/to/threads
export FRAM_LOG=/path/to/coordination.log
bin/fram importThe engine is domain-neutral. Its only unit is the fact — a triple
(left predicate right), e.g. (@X depends_on @Y). Facts append to a durable log; the log
folds into an in-memory graph; consumers query and derive over that graph; every write
serializes through one coordinator. There is no notion of "thread", "module", or "task" in
the engine — only facts:
facts ──assert──▶ coordination.log (append-only) ──fold──▶ in-memory fact graph
│
coordinator daemon ◀── agents query + assert concurrently
│
a consumer derives its own views over the facts (Datalog)
- Entities referenced by
@are interned — rename a thing once, not in N files. - Derived state is never stored. No
statefield exists in the engine; a consumer readscommitted/outcome/ready/ blast-radius off the facts.
A consumer is a projection + a vocabulary onto that neutral engine. Two ship today, and they look nothing alike — which is the whole point:
Fram with North (life/work). North models work as threads — one Markdown file
each (@id header of fact triples, ---, prose body; see
THREAD-FORMAT.md). bin/fram import folds those files into facts, and
North derives ready / blocked / leverage from them. The bundled threads/ corpus is
North-shaped only because Fram was extracted from North — that's the one reason
"threads" appear in the engine repo at all. export is the verified-lossless inverse of
import (tests/roundtrip_test.clj): the files are a view, not a second source of truth.
Fram with Beagle (code-as-facts). Codegraph projects Beagle source into
the graph with the fact log canonical: a module's AST is the facts; the .bclj text is
a rendered view. No threads here — the unit is the def, the projection is the resolver, and
references carry the binding's identity (bound_to), so a rename is a ~2-fact edit and code
intelligence (call graphs, blast radius) is Datalog.
One engine, many memory-spaces. Each consumer lives in its own graph (a separate log), and one coordinator can host several — one log per account/tenant. So a hosted North and a code graph are separate memory-spaces in the same engine, never co-mingled (see Isolation). (Hosting North as a tenant of a shared engine is a direction, not yet shipped.)
All writes go through one coordinator, so the AI agents you already run can keep the
graph current concurrently, without corrupting state. It's a single-writer daemon:
agents query and assert over a localhost socket; writes serialize through one lock with
optimistic versioning (each assert carries a base_version; conflicts are rejected
and retried); rule-breaking writes (dependency cycles, dangling refs) are rejected at
commit. Backed by an adversarial concurrency + durability suite
(tests/coord_test.clj).
The rule-check guarantees referential integrity — references resolve, the vocabulary is closed, structure is sound. It does not judge whether a write is semantically what you meant; that stays with the author. Honest framing: proven under local test load on a single machine — not distributed consensus.
bin/fram-up # start the warm, multi-agent-safe daemon
bin/fram tell 2026-01-01-090700 committed 2026-06-21 # writes route through the coordinatorThe primary query author is a model, so the surface is tuned for what a model emits correctly with zero examples — a CLOSED, O(1) tool catalog plus a structured query escape hatch. The catalog is a fixed ten tools, never minted per-predicate: the vocabulary is data in the graph, not tools.
- The closed TELL/ASK catalog — exactly ten tools.
tell(assert a fact) /retract(remove one) /show(all facts on a subject) /ask(structured query) /validate, plus the five code-authoring verbs the resolver adds (add-def/set-body/rename-def/insert-after/replace-in-body). A single-valued predicate replaces its value; a multi-valued one accumulates — and cardinality is itself a fact (tell <pred> cardinality single|multi), sotell= assert subsumes the oldset-P/add-Pwith no per-predicate tools. Predicates are entities:show <pred>reveals a predicate'scardinality/value_kind/acyclicfacts, andaskenumerates the vocabulary — so the tool count stays O(1) while the vocabulary lives in the graph as data. A missing required param is rejected server-side. ask— a structured Datalog escape hatch for multi-hop questions no read covers. The model emits data, not text (the shape is the engine's internal rule data), so the only added layer is total validation at the boundary: it can't parse-fail, reference an undefined relation, leave a head variable unbound, or smuggle in unstratified negation. Same fixpoint as everything else (recursion + stratified negation), no query-library dependency.
bin/fram tools # the closed catalog (count + signatures)
bin/fram query '{:find "po" :rules [{:head {:rel "po" :args [{:var "x"} {:var "y"}]}
:body [{:rel "fact" :args [{:var "x"} "part_of" {:var "y"}]}]}]}'The catalog is served over MCP (bin/fram-mcp, JSON-RPC over stdio); the CLI
(fram tools / fram call <tool> <edn> / fram query <edn>) is the same surface for
humans. tools/call accepts untell as an alias for retract and query for ask.
A large generated per-predicate catalog was a per-session context tax buying no safety
the engine doesn't already give (every write is serialized + rule-checked at the
coordinator; single-vs-multi cardinality is a fact in the log, so a cold CLI fold and
the warm daemon classify identically), so the surface is closed — the vocabulary is data,
reached through show/ask, not through the tool list.
:find can name a grouped aggregate spec instead of a bare relation name, applied
after the Datalog fixpoint — so it composes with recursion and stratified negation for
free, not a bolt-on that has to re-derive them:
;; deg(x,y) :- fact(x,"depends_on",y) ; count out-edges per subject
{:find {:rel "deg" :group [0] :agg [{:op :count}]}
:rules [{:head {:rel "deg" :args [{:var "x"} {:var "y"}]}
:body [{:rel "fact" :args [{:var "x"} "depends_on" {:var "y"}]}]}]}
;; => {:ok [["@a" 2] ["@b" 1]]};; reaches = transitive closure of depends_on ; distinct reachable targets per source
{:find {:rel "reaches" :group [0] :agg [{:op :count-distinct :arg 1}]}
:rules [...]}
;; => {:ok [["@a" 3] ["@b" 2] ["@c" 1]]}Ops: :count (:arg optional), :count-distinct, :sum, :avg, :min, :max
(the last four take :arg, the group position to aggregate). :group [] is one global
group (a single row, no group columns). count over an empty relation is [], not a
zero row. sum/avg/min/max parse string values numerically — sum stays integer
when every value is a long, avg is always a double — and a non-numeric value at the
aggregated position is a hard {:error} naming the position. Result rows are sorted
canonically; the output group count is capped by FRAM_MAX_RESULTS.
:having filters the grouped rows by their aggregate outputs:
;; subjects with more than 5 out-edges
{:find {:rel "deg" :group [0] :agg [{:op :count}]
:having [{:op :gt :agg 0 :val 5}]}
:rules [...]}:having is a vector of clauses, ANDed. A clause is {:op <op> :agg <i> :val <n>}:
the operand {:agg i} addresses the i-th :agg entry (layout-independent, not a
column position), :op is one of :eq :ne :lt :le :gt :ge (all numeric here —
agg outputs are always numbers, so :eq on :avg is fragile), and :val is a
required number. :having [] filters nothing; a clause set that excludes every group
yields {:ok []}. The cap fires post-having — FRAM_MAX_RESULTS bounds the
survivor count, so a query with a huge group set trimmed to a few by :having
returns {:ok}. Limits: an aggregate result can't feed back into a rule body;
aggregate :find is not pageable (run-page rejects it).
A rule body can carry a filter literal alongside :rel clauses:
;; big(x) :- fact(x,"count",c), c > 100
{:find "big"
:rules [{:head {:rel "big" :args [{:var "x"}]}
:body [{:rel "fact" :args [{:var "x"} "count" {:var "c"}]}
{:pred :gt :args [{:var "c"} 100]}]}]}Ops: :eq :ne (raw string equality/disequality) and :lt :le :gt :ge (numeric
ordering — a non-numeric operand silently drops the row, never errors). A predicate
never binds a variable: every var it reads must already be bound by an earlier :rel
clause in the same body — the same range-restriction rule negation follows.
A body can also carry an arithmetic fn clause that computes a value and binds it to a fresh variable:
;; h = c + s (then usable by later clauses / the head)
{:fn :+ :args [{:var "c"} {:var "s"}] :bind "h"}Ops: :+ :- :* :/ :mod, binary. Both args must be already-bound vars or numeric
constants. The result is bound as a canonical string (keeping the query db all-String):
8 → "8", 3.5 → "3.5", 8.0 → "8.0". Integer preservation mirrors :sum —
:+ :- :* stay long when both operands parse as longs, else fall to double; :/ is
always double. Any failure (a non-numeric operand, division or modulus by zero, or
:mod of a non-long) drops the row — never an error, exactly like a predicate filter.
The fresh :bind var counts as a binding for later :pred/:neg clauses and head
vars (its :args vars do not). A fn clause is forbidden in a recursive rule (a rule
whose head rel reaches itself): fn-introduced values could grow without bound through the
fixpoint, so it is rejected at validation to preserve termination.
The daemon speaks a {:op :pull} wire query alongside ask: name a root entity and a
declarative pattern, get back a nested map — no rule-writing for "give me this thing and
its dependencies' titles":
{:op :pull :root "@x" :pattern [{"depends_on" ["title"]} :* "_part_of"] :provenance true}Pattern grammar, per element: "pred" (flat attr), :* (all non-reserved attrs on this
node, refs rendered as name strings — no recursion), {"pred" [sub-pattern]} (recurse
into a ref with a sub-pattern), {"pred" N} (recurse N levels deep), {"pred" :...}
(recurse until a cap or a cycle), "_pred" (reverse ref — subjects pointing at this
node via pred). Rendering is cardinality-driven: a single-valued predicate comes back
scalar, multi-valued comes back a vector.
;; @x depends_on @dep1 ; pull nested title + per-value provenance
(pull/run store "@x" [{"depends_on" ["title"]} "status"] {:provenance true})
;; => {:fram/id "@x"
;; "depends_on" [{:fram/id "@dep1" "title" "Design"}]
;; "status" {:val "open" :cid 2 :by "u" :seq 2 :withdrawn false
;; :ts "2026-07-21T04:39:34.924Z"}}:provenance true turns each value into {:val :cid :by :seq :withdrawn :ts} (plus
:withdrawn_by/:withdrawn_at/:withdrawn_reason when withdrawn) — per-fact
provenance is possible in the first place because every Fram fact is itself an
addressable, reifiable object, not a row. :as-of <seq> composes with :provenance to
read a historical snapshot. :max-depth and :max-nodes cap traversal; a node beyond
either cap comes back as a {:fram/truncated true} stub, and a cycle revisit on the
current path comes back as {:fram/cycle true} — both keep pull total (never hangs,
never throws) instead of erroring. :ts is the asserting tx's wall-clock instant
(stamped once at commit, identical in the log and after replay); it is display
metadata only — :as-of stays addressed by causal :seq, and the key is omitted for
txs whose log records predate it. Limit: pull is daemon-only (needs the live
index) — the cold CLI fold doesn't serve it.
Two optional layers, both pure derivations over the same fact substrate (zero engine change — their executable specs prove it):
- Worlds — a named, forkable lineage of immutable versions; a version fixes which facts are in scope, the thing you evaluate queries "at." Fork is O(1) (one head fact); a new version is an immutable base + a sparse overlay, so incremental ingestion supersedes only what changed and inherits the rest — old generations stay queryable. Why it's called a world: the naming ledger.
- fram.claims — assertion under verification: a claim is an ordinary fact + evidence
edges + a status derived from view membership (
verifiedis never stored, never even interned). Rejection is a view convention; addevidence.worldand "which verified claims does this generation-transition invalidate?" is one Datalog rule between two world heads. Design: docs/claims-design.md; contract: tests/claims_spec_test.clj. - Both composed, end to end: an addendum to a plan set as a fork + sparse overlay, with exactly the affected verified claims dropping back to pending — tests/world_claims_addendum_demo.clj.
Codegraph points the engine at code. The fact log is canonical: a
module's AST is the facts, and the .bclj source text is a rendered view of the log.
- References carry identity, not spelling. A call site resolves to the binding's
stable id (
bound_to @module#int), so renaming a definition is a ~2-fact edit and every reference re-points by identity — where a text tool must rewrite every site. Measured on the honeysql corpus: 238 distinct reference sites that text must re-derive and rewrite, vs a 2-fact graph edit (external receipt: theafter-textexperiments package,experiments/EXP-002-owned-resolution-forcing/— an external corpus, not regenerable from this repo). - The render is a pure function of the log.
render(log) == render(text), byte-identical to each other (both derived from the graph). The general round-trip is datum-identical, not byte-identical to hand-authored source — comments and exact whitespace are not preserved (codegraph/). - Code intelligence as Datalog. Scope-correct call graphs and transitive blast radius are queries, computed scope-correctly by binding identity (not name-match).
The categorical line under all of this is node-identity vs no-node-identity: text and git lack a stable per-node id, so they re-derive the program to answer a relational question or to coordinate a concurrent edit. (Identity-addressed concurrency itself is not unique — a node-id CRDT has it too; what's distinctive here is pairing it with a faithful typed projection into an existing language.)
Static reference docs rot. So this README hardcodes as little of the surface as possible — the engine and its generators are the truth:
| You want… | Source of truth (always current) |
|---|---|
| the engine verbs | bin/fram (no args prints the full usage) |
| the AI tool catalog | bin/fram tools (generated from the vocabulary) |
| the fact-authoring API + signatures | bb bin/fram-primer (generated from src/fram/*.bclj) |
| the predicate vocabulary | bin/fram doctor (with FRAM_SINGLE_VALUED to override) |
| what's tested | tests/ + .github/workflows/ci.yml |
Every fenced command in this README is executed in CI by
scripts/readme-check.sh: it runs each block against a scratch
copy of the bundled threads, asserts every bin/fram <verb> is real, test -e's every
referenced path, and fails on a stale repo URL. A command that stops working turns CI red.
- Construction-path scaling vs zerolang — building a medium app by incremental
authoring, Fram is flat per-op while zerolang's per-patch cost rises (it reloads +
validates + rewrites the whole graph each edit): 2.3× @250 defs, 4.2× @500, 7.5×
@1000, the gap growing with size — "O(N²)-shaped" (curve + source, not a
formal fit). This is construction-path scaling, not language speed — and a fully
external receipt: measured against zerolang 0.3.4 and a non-current Fram build
(
e3f5df5+ uncommitted optimizations), so it is not reproducible from a committed Fram sha. The honest companion is that Fram trades away the single-small-edit case — but that loss is receipted only pre-optimization (its siblingexperiments/EXP-009-zerolang-single-op/RESULTS.md); the later authoring-path cuts flattened Fram's per-op cost, so whether it still loses a single small edit at current code is not established here. Receipt:experiments/EXP-010-zerolang-construction-scaling/RESULTS.md(in theafter-textexperiments package). - Propagation under K concurrent disjoint writers — graph propagation stays flat
where a git merge-queue climbs. External receipt (
after-textexperiments/EXP-007-propagation-ksweep/RESULTS.md): graph ~1.6–2.2 ms (K=1…8) vs a git push-hook queue ~50→314 ms. Mirror cost, stated honestly: the graph loses the write column (~175 ms eager-index vs git's ~22–80 ms) — it front-loads at write to keep reads + propagation cheap. A separate FRAM-local perf-regression gate lives here asbench/propagation/(regen below): it reproduces the shape (flat graph vs climbing git) and enforces the budget against this repo's own build — its absolute numbers are its own, not the external EXP-007 figures.
One coordinator process owns the writes; clients connect over a socket. The same design runs on your laptop, a server you own, or a service you host — one coordinator + log per account — only the transport differs.
Be honest about what isolates what. Fram has no access control. Isolation is
process + log + network only: the coordinator binds loopback (127.0.0.1) by
default; remote/multi-tenant hosting puts an authenticated gateway (bearer token → tenant
→ that tenant's coordinator) in front. The rule is one graph per trust domain — your
personal life-graph, a client's data, and public code tooling are separate logs in
separate processes, never one. Share machinery across domains freely; never share
data.
- Hosting from an edge platform (Cloudflare Workers). Workers are ephemeral and hold
no state, so they can't be the writer; put the coordinator in a Docker container as the
durable single writer, and a small bearer-token HTTP shim in front of it to bridge the
Worker's
fetch()to the coordinator's TCP protocol (Workers can't present a client cert on a raw socket, so engine-terminated mTLS isn't reachable directly today). Exact procedure, Dockerfiles, and an observed local smoke test: deploy/cloudflare/PROCEDURE.md. - Your data is two plain-text things you can
grep: your Markdown and an append-onlycoordination.log. No proprietary format, no telemetry, no lock-in. - The log is the recoverable history. Each line records who and when;
fram history <id>replays an entity's timeline intxorder. - Nothing to build. Compiled Clojure is committed under
out/. The CLI + MCP run on babashka (fast startup); the long-lived coordinator runs on the JVM (real threads,SSLServerSocketfor engine-terminated mTLS). An optional GraalVM native binary (native/build.sh) targets ~0.2 s/command.
The logic (kernel, fold, Datalog, import/export, CLI) is authored in
Beagle — a typed Lisp that compiles to Clojure —
with host interop in a thin Clojure runtime (src/fram/rt.clj). The compiled Clojure is
committed and runs on babashka, so you don't need Beagle to run Fram — only to rebuild
from the .bclj sources (build.sh). (Beagle is a personal language and a real
dependency risk, disclosed plainly.)
- Not a database you'd pick for features. Concurrency, Datalog, schema-as-data — an off-the-shelf store ties or wins. The reason to use Fram is the fact-object atom.
- Not access control. Isolation is process + log + network; co-mingling trust domains is an incident, not a mess.
- Not distributed consensus. The concurrency guarantees are proven under local test load on one machine.
- Not stable. Early and experimental; the kernel still ships the original lifecycle
vocabulary as overridable defaults (
FRAM_SINGLE_VALUED).
Every suite lives in tests/ and runs on babashka against the committed out/:
bb -cp out tests/roundtrip_test.clj # facts <-> files round-trip is lossless
bb -cp out tests/coord_test.clj # adversarial concurrency + durability
bb -cp out tests/query_test.clj # structured Datalog query + boundary rejectionsls tests/*_test.clj is the full list; CI runs them all
(.github/workflows/ci.yml).
Engine surface & project layout
bin/fram with no arguments prints the canonical verb list (the source of truth — don't
trust a copy here). The daemon is bin/fram-daemon / bin/fram-up; the AI surface is
also served over MCP by bin/fram-mcp. The life verbs (ready / blocked / leverage
/ next / capture) belong to the consumer (North), not the engine.
src/fram/*.bclj— the engine, authored in Beagle: kernel, fold, Datalog, schema, import/export, CLI.src/fram/rt.clj— the thin Clojure host-interop runtime.resolve.clj— the engine's lexical resolver + minimal-op AST edit verbs (load-file'd by the coordinator daemon).out/— the committed compiled Clojure (so Fram runs without Beagle).codegraph/— code-as-facts: minimal-op authoring verbs, code intelligence.docs/— conceptual sources of truth:WHY_FRAM_EXISTS.md,VIEWS_AND_BRANCHES.md(the write/read model),adr/(project boundaries).tests/— the suites.bench/— perf-regression gates (the propagation budget). The measured receipts cited above live in the separateafter-textpackage.
- Removed, not deprecated. No back-compat shims; correctness and the desired design decide, never "things depend on it."
- Derive, don't store. Lifecycle and code intelligence are views over the facts, not maintained fields.
- One graph per trust domain. Share machinery, never data.
See docs/WHY_FRAM_EXISTS.md and the ADRs for the full argument.
Fram is dual-licensed under your choice of the MIT License or
the Apache License, Version 2.0
(MIT OR Apache-2.0).