# OKF Graph Wiki — Agent-Maintained Knowledge Graph for Context Retrieval

> Source: <https://gist.github.com/VivianBalakrishnan/83d1ea5f929d0ae51bca7fe25129b0d7>
> Published: 2026-08-14 17:01:15+00:00

I've been running [Andrej Karpathy's LLM Wiki](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f)
pattern for several months — reading sources, compiling them into a
compounding, git-diffable wiki instead of re-deriving everything from
scratch every session — and I'm a genuine convert. But two things kept
nagging at me: **query salience** (finding the right page reliably, not
just something plausible) and **token economy** (not re-reading half the
wiki to answer one question).

The missing piece turned out to be the
[Open Knowledge Format](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md)
(OKF): structured YAML frontmatter an agent can use to record the graph
it's already implicitly building in prose, as typed
subject–predicate–object triples rather than untyped markdown links. To
keep retrieval fast and deterministic — not another LLM call on every
question — that frontmatter compiles into a disposable SQLite index.
What follows specs that redesign for someone to actually build — an
**implementation spec**, not a blog post, where every section states
what to build rather than just the idea. It ships with a working
TypeScript/SQLite reference implementation (Appendix) and pitfalls
validated by building and running it, not guessed at (§10).

The original gist frames the wiki as a human-browsable, compounding
artifact: an agent reads sources, updates markdown pages, a person reads
the results in Obsidian. That's still true here, but it is **not the
primary purpose of this version**. The primary purpose is: **given a
question posed to an LLM, retrieve and assemble the right slice of the
graph into that LLM's context window.** Human browsing is a secondary,
free side benefit of keeping everything as linked markdown. Every design
decision below is made in service of the retrieval goal first.

Concretely, that reframing changed three things versus the original gist: the file format is now OKF (structured provenance/trust/lifecycle fields, not just prose), relationships are typed subject-predicate-object triples in frontmatter (not just untyped markdown links), and there is a retrieval pipeline spec (section 6) that did not exist in the original idea at all.

- Maintain a persistent, compounding knowledge base as a directory of plain markdown files, agent-written and agent-maintained.
- Represent facts as typed, sourced, subject-predicate-object triples that a query engine can traverse, not just prose an LLM re-reads from scratch each time.
- At question time, retrieve a bounded, relevance-ranked, trust-ranked set of facts and background prose that fits a context budget — this is the deliverable, not an afterthought.
- Stay diffable, portable, and toolable: git-diffable markdown as the
only source of truth; every other artifact (SQLite index,
`timeline.md`

,`index.md`

entries) is a regenerable build product, never hand-edited.

**Not a general agent-memory system.** This is not session memory, preference tracking, or conversational recall — see §9.3 for why an existing tool in that space (`mnemon-dev/mnemon`

) was evaluated and rejected as the storage engine.**Not a fixed ontology.** No upfront RDF/OWL schema. Both OKF's`type`

and this spec's predicate vocabulary are open and grow by convention, documented as they're coined (§8).**Not a graph database.** SQLite with JSON1 functions is sufficient at the scale this pattern is meant for (hundreds to low thousands of concepts); this spec does not call for Neo4j or similar.

Considered a fourth layer, `router.md`

(a hand-curated dispatch table
from question domain to subdirectory), and dropped it: `context_for()`

(§6) queries the whole bundle in one hybrid-retrieval pass and doesn't
benefit from a coarse pre-filter, and the one audience that would have
needed file-based dispatch — a human browsing the vault in Obsidian —
already has direct SQL access to `wiki.db`

, either via Datasette or a
Obsidian SQLite-query plugin (SQLite Explorer, SQLite DB, and similar all
support embedding live query results in a note). The only scenario where
a router file would still earn its keep is an agent given pure
filesystem read access with no shell or SQL tool at all; not designed
for by default.

```
bundle/
  CLAUDE.md / AGENTS.md   # schema doc: conventions, predicate vocabulary, ingest rules
  wiki.db                 # SQLite index, regenerated from frontmatter, gitignored or snapshotted
  log.md                  # OKF-native edit history (when the wiki changed)
  timeline.md             # world-event chronology (when things happened), regenerated
  wiki-trigger-keywords.txt  # concept titles + tags, regenerated — optional harness-hook pre-filter (§12)
  entities/
    index.md              # OKF-native per-directory catalog
    <concept>.md           # OKF concept files, one per entity/topic
  computations/
    ...                    # optional: OKF Attested Computation concepts, if the bundle needs sanctioned metrics
  references/
    ...                    # OKF convention: mirrored external material, executor/attester code
```

| File | Role | Maintained how |
|---|---|---|
`index.md` |
Per-directory catalog (OKF §8): concept list with one-line descriptions, for progressive disclosure. | Regenerable from concept frontmatter; may also be hand-edited. |
| Concept files | The atomic units of knowledge: frontmatter + prose body. Source of truth. |
Agent-written during ingest (§7.1). |
`timeline.md` |
Chronological ledger of world events (as opposed to `log.md` , which is edit history), built from every date-qualified `relations` entry across the bundle. |
Fully regenerated, never hand-edited. |
`wiki.db` |
SQLite index over all concept frontmatter, the substrate the retrieval pipeline (§6) queries. | Fully regenerated on every ingest. |
`wiki-trigger-keywords.txt` |
Flat, deduped list of every concept's `title` and `tags` — a deterministic pre-filter an optional harness hook can grep against, to nudge querying the wiki without paying `context_for()` 's cost on every turn. Not wired to anything by default; see §12. |
Fully regenerated on every Reindex. |

Every concept is one markdown file: YAML frontmatter + prose body, per
[OKF v0.2](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md).
Concept identity is its bundle-relative path with `.md`

removed (OKF §2)
— no separate ID scheme needed.

The original gist names the content a wiki should hold: "Summaries,
entity pages, concept pages, comparisons, an overview, a synthesis."
This spec keeps that taxonomy — it does not collapse into one generic
file kind. It maps onto OKF's open `type`

field (§4.2) plus the derived
timeline (§3):

| Page kind | `type` value |
Carries `relations` ? |
What it's for |
|---|---|---|---|
| Entity page | `Person` , `Organization` , `Product` , `Place` , `Country` , or `Entity` as a fallback |
Yes — the primary source of typed triples | A specific named person, organization, product, place, country (§4.5 worked example). Use the most specific value that fits — a person's file gets `type: Person` , not generic `Entity` ; fall back to `Entity` only when none of the starter set applies. |
| Concept page | `Concept` |
Optional — abstract topics rarely assert their own triples, but link and synthesize several entities' relations in prose | An idea, a trend, a comparison, a synthesis, an overview — the original gist's "comparisons, an overview, a synthesis." |
| World event chronology | (not a page type) |
n/a — derived view, not authored content | `timeline.md` (§3), built from every date-qualified `relations` entry across Entity and Concept pages. Not its own `type` — deliberately: it stays a regenerated aggregate, never hand-authored, per the source-of-truth rule in §1. |

Concept pages are where a synthesized answer worth keeping gets filed back into the wiki (§7.2) — this is the mechanism that keeps the bundle compounding beyond raw source ingest, not just entity extraction.

| Field | OKF section | Required? | Purpose |
|---|---|---|---|
`type` |
§4.1 | Required |
e.g. `Person` , `Entity` , `Playbook` , `Metric` . Open vocabulary — see §8. |
`title` , `description` , `tags` |
§4.1 | Recommended | Display, seed-matching, and index generation. |
`sources` |
§5.1 | Optional | Provenance: what this concept was derived from. Each `relations` entry cites into this by `id` . |
`generated` |
§5.2 | Recommended | `{ by, at }` — who/what wrote the current content, actor convention (§7 of OKF). |
`verified` |
§5.2–5.3 | Optional | Drives the trust tier used in retrieval ranking (§6.4): no `verified` → unverified; non-`human:` actor → machine-confirmed; any `human:` actor → human-reviewed. |
`status` , `stale_after` |
§5.4–5.5 | Optional | Lifecycle. `stale_after` is checked at retrieval time (§6.4), not just for display. |
`superseded_by` , `supersedes` |
extension, not in OKF core |
Optional | Concept-file-level supersession — a whole concept file explicitly replaced by another. See §7.7. Distinct from `stale_after` (time-based expiry, no replacement recorded) and from a per-fact contradiction (§7.3's `contradiction` check, which flags but does not resolve). |
`relations` |
extension, not in OKF core |
Optional | Typed triples. See §4.3. Valid per OKF §4.1 ("producers MAY include any additional keys... consumers MUST NOT reject documents with unrecognized fields") — this bundle stays OKF v0.2 conformant. |

**Provenance convention for locally-mirrored sources.** When a `sources[]`

entry's material was saved locally into `references/`

during ingest
(§7.1), record the bundle-relative path there as `resource`

— e.g.
`resource: references/tc-profile-2026.html`

— rather than just the
original external URL. A `sources[]`

entry whose `resource`

is *not* a
`references/...`

path (a bare external URL, or the field absent
entirely) marks a concept as not traceable to a locally mirrored
document — the signal §7.6's Rebuilding from references operation uses
to distinguish an ingest-derived concept file from a hand-assembled one
(a filed-back Concept page built from discussion and general web search,
for instance) without needing a separate flag.

```
relations:
  - { predicate: invested_in, object: /entities/openai.md, date: "2019-03-01", source: tc-profile-2026 }
  - { predicate: married_to, object: /entities/laura-arrillaga-andreessen.md }
  - { predicate: born_on, object: "1971-07-16" }
```

- The concept file itself is the implicit
**subject**— no separate subject field, identity is the file path (OKF §2). `predicate`

: free-text string. Open vocabulary, governed by §8.`object`

: either a bundle-relative path beginning with`/`

(an entity-to-entity edge — the target should resolve to another concept, but per OKF §6.1 consumers MUST tolerate a broken target, since it may represent not-yet-written knowledge) or a literal value (an entity-to-value fact, e.g. a birth date).`date`

(optional): an ISO 8601 date,**the field this spec uses for temporal qualification of a fact — do not name this field** See §10.1 for why.`on`

.`source`

(optional): joins to a`sources[].id`

on the same concept, reusing OKF's existing per-claim attribution mechanism (§5.1) rather than inventing a second citation scheme.

Prose links in the body (`[OpenAI](/entities/openai.md)`

) still work as
OKF intends — untyped, human-narrative traversal. `relations`

is the
machine layer for typed graph queries; it does not replace prose links,
it sits alongside them.

```
---
type: Person
title: Antony Blinken
description: United States Secretary of State (2021-2025).
tags: [person, united-states, foreign-policy]
relations:
  - { predicate: secretary_of, object: /entities/us-department-of-state.md, date: "2021-01-26", source: state-dept-bio }
  - { predicate: represents, object: /entities/united-states.md, source: state-dept-bio }
generated: { by: "reference_agent/claude-sonnet-5", at: "2026-08-09T10:00:00Z" }
verified: { by: "human:reviewer", at: "2026-08-09T11:00:00Z" }
sources:
  - { id: state-dept-bio, resource: "https://2021-2025.state.gov/biographies/antony-j-blinken/", title: "U.S. Department of State — Antony J. Blinken" }
---

# Summary

Antony Blinken served as [the United States](/entities/united-states.md)'s
Secretary of State from January 2021, heading the
[Department of State](/entities/us-department-of-state.md).[^state-dept-bio]

[^state-dept-bio]: U.S. Department of State — Antony J. Blinken
```

No `relations`

block required; it links to and synthesizes existing
Entity pages instead of asserting its own triples. This is what a filed-
back query answer (§7.2) typically looks like.

```
---
type: Concept
title: The U.S. Department of State under Antony Blinken
description: Synthesis of continuity in U.S. foreign-policy leadership during Antony Blinken's tenure as Secretary of State.
tags: [synthesis, united-states, foreign-policy]
generated: { by: "reference_agent/claude-sonnet-5", at: "2026-08-09T12:00:00Z" }
---

# Synthesis

From January 2021, [Antony Blinken](/entities/antony-blinken.md)
represented [the United States](/entities/united-states.md) as Secretary
of State — see [the timeline](/timeline.md#2021-01-26) for when the
appointment began relative to other tracked events.
```

**Do not build a bespoke database engine.** Load every concept file's
frontmatter into a `concepts`

table with a small loader — the reference
implementation's `build-index.ts`

does this in well under a hundred
lines using the `yaml`

npm package for parsing and `bun:sqlite`

for the
database; any TypeScript/JS equivalent works the same way.

List-valued fields (`relations`

, `sources`

, `tags`

) land as JSON text
columns automatically — query them with SQLite's built-in `json_each`

/
`json_extract`

, no separate `relations`

table or ETL step required. The
auto-generated `_path`

column doubles as concept identity, matching
OKF's own file-path-is-identity rule for free.

Add on top of the base `concepts`

table:

: an FTS5 virtual table over`concepts_fts`

`title`

,`description`

,`tags`

, and body text, for BM25 keyword search (§6.1).:`embeddings`

`(path TEXT PRIMARY KEY, vector TEXT)`

— one row per concept, vector as a JSON float array, produced by whichever embedding backend is configured (§6.1, §9.2).

Rebuild `wiki.db`

(and `concepts_fts`

/`embeddings`

) as the last step of
every ingest (§7.1). It is a cache, never a second source of truth.

An existing SQLite-backed, four-graph (temporal/entity/causal/semantic) agent memory tool was evaluated and rejected as the storage layer for this bundle. Recorded here so the implementing agent doesn't re-derive or re-litigate this:

- Its SQLite DB
**is** the source of truth — no per-node markdown files exist to keep git-diffable, which inverts this spec's core principle (§1, §3). - Its
`insights`

schema is a flat memory-snippet model with no room for OKF's`type`

/`sources`

/`verified`

/`status`

/`stale_after`

fields. - Its edges are inferred heuristically (embedding similarity thresholds, keyword regex, temporal proximity) from a fixed four-type enum, not deliberately asserted, sourced, open-vocabulary predicates.
- It has importance-decay and auto-pruning by design, which works against the "nothing gets silently dropped" compounding-wiki goal.

This is the actual deliverable. Given a question, produce a context block for the LLM answering it.

Three signals, fused with **Reciprocal Rank Fusion** (`score = Σ 1/(k + rank + 1)`

,
`k ≈ 60`

), exact-title match weighted 2x by being included in the fusion
twice:

**Exact title match**— the concept's full`title`

appears in the question at a whole-word/phrase boundary, not as a bare substring. Boundary-checked is load-bearing: a concept titled "AI" must not seed on the word "explain", nor "Go" on "ago". Cheap, high precision, and*necessary*: on a cross-linked wiki, a concept is routinely mentioned on other concepts' pages, so term-frequency methods alone cannot distinguish "the question is about X" from "X is merely mentioned here" (see §10.3 — this is a validated finding, not a hypothetical).**FTS5 BM25** over`concepts_fts`

— keyword recall for partial or non-exact mentions. Used for*ranking*candidates as-is; used for*confidence*only after a word-coverage check, not on raw presence alone — validated, not hypothetical (§10.4).**Vector cosine similarity** over`embeddings`

— semantic recall for paraphrased questions with no lexical overlap.**Embedding backend:**(`nomic-embed-text`

via a local Ollama endpoint`localhost:11434`

), with`search_document:`

/`search_query:`

prefixing per its convention — decided, not a placeholder. See §9.2 for why a TF-IDF stand-in was used during prototyping and is not carried forward.

Lightweight keyword classification into `WHY | WHEN | ENTITY | GENERAL`

(extend as needed). Drives predicate weighting in §6.3 — a pattern worth
keeping from Mnemon's intent-adaptive traversal even though its storage
engine was rejected (§5.1): a WHY question should weight causally-loaded
predicates higher, a WHEN question should weight the date-qualified
subset higher, and so on.

BFS from the seed concept(s), hop limit 2 by default. Score every traversed relation:

```
score = predicate_weight(intent, predicate) * hop_decay^hop * trust_multiplier(subject_trust_tier)
```

`predicate_weight`

: a small hand-maintained table per intent (grows with the predicate vocabulary, §8).`hop_decay`

: constant < 1 (e.g. 0.6) — relevance decays with distance from the seed.`trust_multiplier`

:`{human-reviewed: 1.0, machine-confirmed: 0.8, unverified: 0.6}`

, derived from`verified`

per OKF §5.3.

Do not return the whole neighborhood — this is what makes the retrieval bounded rather than a graph dump.

Sort scored triples descending, take the top N by context budget.
Exclude or down-rank facts where `today >= stale_after`

(OKF §5.5).
Trust tier is a first-class ranking input here, not just display
metadata — when more candidates exist than the budget allows, prefer
human-reviewed, non-stale facts over unverified ones at equal predicate
weight. A concept file with `status: superseded`

gets its own filtering
pass, distinct from `stale_after`

's — see §7.7.

Render for the LLM prompt as compact fact lines plus prose background from the seed concepts' bodies, not raw JSON:

```
- Andreessen Horowitz invested_in OpenAI, as of 2021-04-01 (trust: human-reviewed, source: a16z-portfolio)
```

Triples answer *that* a fact holds; the seed concepts' prose bodies are
included for facts that need more than a triple to convey correctly
(why, not just that).

- Agent reads a new source, extracts facts, discusses with the user
(or proceeds unsupervised, per the bundle's own policy in
`CLAUDE.md`

/`AGENTS.md`

). - Creates or updates the relevant concept file(s): body prose,
`relations`

,`sources`

,`generated`

. A single source may touch several concepts. **Hard rule — assert both directions of a relation in the same pass.** When Antony Blinken's concept file gets`{ predicate: secretary_of, object: /entities/us-department-of-state.md }`

, the Department's concept file gets the matching`{ predicate: secretary, object: /entities/antony-blinken.md }`

in the same ingest step. Chosen over computing inverses at query time from a predicate-inverse lookup table: it keeps the query layer (§6) simple, at the cost of ingest needing to touch both concepts. §7.3's asymmetric-relations lint check exists specifically to catch a violation of this rule.- Regenerates
`wiki.db`

(§5) and`timeline.md`

(§3) unconditionally — every ingest, no exceptions, regardless of how small the change — and any`index.md`

entries affected. - Appends an entry to
`log.md`

(OKF §9). **Never** hand-edits`timeline.md`

,`wiki.db`

, or auto-generated`index.md`

entries directly — they are step-4 outputs, not inputs.

The `context_for(question)`

pipeline, §6. This is the hot path and
should be fast — it's a handful of indexed SQLite queries, not a
full-bundle scan.

A synthesized answer worth keeping — a comparison, an analysis, a
connection the retrieval surfaced — should be filed back as a new
Concept page (§4.1) rather than left in chat history, the same
compounding behavior the original gist specified ("good answers can be
filed back into the wiki as new pages... your explorations compound in
the knowledge base just like ingested sources do"). Filing back runs the
same steps as §7.1: write the page, regenerate `wiki.db`

/`timeline.md`

,
update `index.md`

, log it.

Periodic health-check pass over `wiki.db`

, expressible as SQL:

- Orphan concepts: no inbound
`relations`

and no inbound prose links. - Broken relation targets:
`object`

paths that don't resolve to an existing concept (tolerate per OKF §6.1, but surface for review). - Stale concepts:
`stale_after`

in the past. - Predicate drift: near-duplicate predicates that should be merged
(
`invested_in`

vs`invested-in`

vs`backs`

) — prune during lint, don't let the vocabulary fork. - Contradictions: conflicting
`relations`

asserted about the same subject/predicate pair from different sources. Exempts a small, hand-maintained set of predicates that are inherently multi-valued (`has_part`

— a subject can genuinely have several parts at once), so the check only fires on predicates expected to hold one object. - Asymmetric relations: a
`relations`

entry with no corresponding reverse entry on the object concept — see the hard rule in §7.1; this is the check that catches a violation of it.

Lint detects; it does not itself rewrite files. A predicate-synonym finding, for example, gets fixed by a §7.4 Reindex-triggering bulk edit or a normal ingest-style edit, not by the lint pass itself.

A full rebuild of `wiki.db`

(§5), `timeline.md`

(§3), and
`wiki-trigger-keywords.txt`

(§3, §12 — every concept's `title`

and
`tags`

, deduped) from every concept file, independent of any single
content change. Idempotent — running it twice with no intervening edits
produces the same output. Needed for:

**Embedding backend changes**(§9.2): switching the embedding model (e.g. upgrading`nomic-embed-text`

to a newer version) requires re-embedding every concept, not just recently-touched ones.**Recovery**:`wiki.db`

is a disposable cache (§5); if it's lost or corrupted, Reindex is how it comes back, with no dependency on ingest history.**Bulk migrations**: executing a predicate rename or OKF-version upgrade that lint (§7.3) flagged, across every affected file, then rebuilding the index in one pass rather than file-by-file ingest edits.

Ingest, Query, and Lint have no feedback loop on retrieval *quality* —
only on data integrity. This matters specifically because `context_for()`

feeds an LLM automatically rather than a human reading the wiki directly,
so a ranking regression can go unnoticed. A commenter on the original
Karpathy gist measured this failure mode directly: a compiled wiki
answered *confidently* even for questions from a domain the wiki had
never covered, with no signal anywhere that the answer wasn't grounded —
every query returned a full set of "relevant" hits regardless.

Maintain a fixed regression set of questions with expected seed concepts
and/or expected facts, split between in-bundle and deliberately
out-of-bundle questions. Periodically (or on every Reindex) run
`context_for()`

against the set and check: does the right concept still
seed first (catches the exact-title/BM25/vector-ranking regressions from
§10.3), does an out-of-bundle question return a clear "no confident
match" rather than a plausible-looking wrong answer, and has trust-tier
ranking (§6.4) held. This is the retrieval-quality analog of §7.3's
data-integrity checks — both are lint in spirit, but this one needs a
labeled question set to run against, not just a schema/frontmatter scan.

A recovery/refresh operation, distinct from Ingest (§7.1) and Reindex
(§7.4): discards concept files that were produced by ingesting a source
now sitting in `references/`

, and reconstructs them by re-running Ingest
against those same references — e.g. after fixing a bad extraction,
adopting a new `type`

convention (§8), or wanting a later ingest
process's quality applied to older pages. Unlike Reindex, this **does**
touch concept files, deliberately — the goal is regenerating them, not
just the derived `wiki.db`

/`timeline.md`

cache.

**Scope, using the §4.2 provenance convention.** A concept file is
*ingest-derived* — and therefore in scope for replacement — only if its
`sources`

frontmatter cites at least one `references/...`

-rooted
`resource`

. A concept file with no `sources`

block, or whose sources are
all external URLs never mirrored locally, is out of scope by
construction: that is the shape of a filed-back Concept page (§4.1, §7.2)
built from discussion and general web search rather than a raw ingest of
a saved document, and this operation must never touch it.

Procedure:

- Identify candidates by scanning every concept's
`sources`

for a`references/...`

-rooted`resource`

— deterministic, run through the CLI (`rebuild-candidates`

, reads from`wiki.db`

) rather than a raw shell scan, same rationale as the rest of §7's operations. - Confirm scope with the user before deleting anything — an
ingest-derived file may since have accumulated a
`verified: {by: "human:..."}`

stamp or hand edits worth preserving; a passage worth keeping that isn't grounded in a reference should be moved into its own Concept page first, since Concept pages stay out of scope here by design. - Delete the confirmed concept files.
`references/`

itself,`log.md`

, and every out-of-scope concept file are untouched. - Re-run Ingest (§7.1) over the same
`references/`

material, one source at a time — this is what actually reconstructs the concept files, now under the bundle's current conventions (§8). - Reindex (§7.4) once every deleted concept file has been recreated.
- Run Lint (§7.3), then manually confirm prose links inside preserved
Concept pages still resolve — Lint's broken-target check covers typed
`relations`

only, not markdown prose links, so a concept recreated under a slightly different path won't necessarily surface there. - Append one
`log.md`

entry for the whole rebuild, not one per re-ingested reference — this is a single deliberate operation, not N separate ingests.

**Lint (extends §7.3).** One check, using the same provenance scan as
step 1 above, run in the opposite direction: **unreviewed-reference** — a
file under `references/`

that no concept's `sources[].resource`

cites
anywhere in the bundle. This is a deterministic set difference
(`references/`

directory listing minus every cited `resource`

path), not
a freshness or mtime check — there is no ingestion-tracking state
anywhere in this design (§9.3: regenerate, never persist-and-drift), so
this is the closest thing to "what's new in `references/`

" the pipeline
offers. It is a prompt to look, not a verdict: a reference can
legitimately have zero citing concepts because it was reviewed and
deliberately never promoted to a concept page — out of the bundle's
domain, or mirrored for context only. Don't treat every finding as
missed work; check the reference before assuming it needs ingesting.

A **concept-file-level** mechanism for recording that one whole concept
file has been replaced by another — not a per-fact confidence score with
continuous decay (that's out of scope; see the note at the end of this
section). Distinct from two things already in this spec that look
similar but do less:

`stale_after`

(§4.2, §6.4) marks a fact as expired by*date*, with no record of what, if anything, replaced it.- Lint's
`contradiction`

check (§7.3)*detects*that two relations conflict; it does not resolve which one is current or link them.

Supersession closes that gap for the concept-file granularity: an explicit, dated, bidirectional pointer from an outdated concept file to the one that replaced it, with the old file preserved (not deleted) and excluded from default retrieval rather than silently left ambiguous.

**When to use it.** Only when the old concept file remains independently
meaningful as a historical record — a former officeholder, a superseded
org structure, a deprecated architecture decision. If the old content was
simply *wrong* (a bad extraction, a typo, a source that turned out to be
inaccurate), fix it in place via a normal Ingest edit (§7.1) — no
supersession chain. Rule of thumb: if "what did we believe before
`<date>`

?" is a legitimate question about this concept, use supersession;
if it isn't, just correct the file.

**Frontmatter fields** (extension, like `relations`

— not OKF core):

```
# on the outdated concept file (entities/george-hw-bush-presidency.md)
status: superseded
superseded_by: /entities/bill-clinton-presidency.md
superseded_at: "1993-01-20"   # optional; defaults to the successor's generated.at if absent
# on the successor concept file (entities/bill-clinton-presidency.md)
supersedes: [/entities/george-hw-bush-presidency.md]
```

`superseded_by`

: bundle-relative path (with`.md`

, like a`relations`

`object`

— §4.3) to the one concept file that replaces this one. A concept has at most one`superseded_by`

at a time.`supersedes`

: array — a successor may replace more than one prior concept file (e.g. a merge).- Setting
`superseded_by`

without also setting`status: superseded`

is invalid —`status`

is what §6.4's default filtering and §7.3's lint actually key off;`superseded_by`

supplies the destination.

**Hard rule — assert both directions in the same edit**, exactly the
discipline §7.1 requires for `relations`

: when A gets `superseded_by: B`

,
B gets `supersedes: [..., A]`

in the same pass. §7.7's
`asymmetric-supersession`

lint check below exists specifically to catch
a violation of this rule, mirroring §7.3's existing
`asymmetric-relation`

check.

**Chains.** Supersession may chain (A → B → C — e.g. George H. W. Bush
→ Bill Clinton → George W. Bush) — no automatic mid-chain
resolution is attempted; a query-time chain walk (below) always resolves
to the end of the chain. Bound the walk (e.g. 5 hops) to survive an
accidentally-introduced cycle without hanging — the `supersession-cycle`

lint check below is what's supposed to catch that case before it ever
reaches query time.

**Retrieval integration (extends §6.4 and §6.3):**

- A concept file with
`status: superseded`

is excluded from seed matching by default (§6.1) — exact-title, BM25, and vector search must not surface it as a top-level seed. Instead, resolve transparently to the end of its`superseded_by`

chain, the concept-file analogue of how`stale_after`

already excludes a fact rather than presenting it as current. - This is exclude-from-seeding, not delete-from-graph: a superseded
concept remains a valid
`relations`

`object`

on other, still-active concepts (e.g.`{ predicate: preceded, object: /entities/george-hw-bush-presidency.md }`

) and must resolve normally, not be flagged as`broken-target`

. - During bounded graph expansion (§6.3), a hop that lands on a superseded
concept is down-weighted, not excluded outright — a
`SUPERSEDED_MULTIPLIER`

(e.g.`0.15`

) alongside the existing`trust_multiplier`

in the scoring formula, preserving "deprioritized, not deleted" rather than adding a second binary include/exclude gate next to`stale_after`

's. - An explicitly retrospective question ("what was true before
`<date>`

?", "who was President before Bill Clinton?") is exactly what a superseded concept still needs to answer — treat this as a new case for §6.2's intent detection (a`HISTORICAL`

bucket, or a modifier on`WHEN`

) that lifts the default seeding exclusion for that query only, rather than ever deleting the content. - Serialization (§6.5): a fact drawn from a superseded concept renders
with an explicit marker so the answering LLM can never present it as
current without the reader being able to tell:

```
- George H. W. Bush inaugurated President, as of 1989-01-20 (superseded by /entities/bill-clinton-presidency.md as of 1993-01-20)
```

**Lint (extends §7.3)** — two new checks, same "detects, does not
rewrite" discipline as the rest of §7.3:

**dangling-supersession**:`superseded_by`

doesn't resolve to an existing concept file. Parallel to`broken-target`

, scoped to this field.**asymmetric-supersession**:`superseded_by`

set on A with no matching`supersedes`

entry on the target, or vice versa. Parallel to`asymmetric-relation`

; catches a violation of the hard rule above.**supersession-cycle**: walking`superseded_by`

from any concept revisits a concept already in the chain. Catches an accidental loop before it breaks the bounded query-time walk above.

** entities/index.md.** A superseded concept's entry is not removed —
it's still real prior content, worth being able to find by browsing —
but gets annotated with the successor path, generated the same
non-hand-edited way as every other index entry (§3, §7.1 step 5).

**Interaction with §7.6.** Orthogonal: being `status: superseded`

does
not exempt a concept file from Rebuilding from references — scope there
is decided purely by the provenance convention (§4.2), independent of
lifecycle status.

**Non-goal, explicitly.** This is a discrete, binary predecessor→successor
pointer at the concept-file granularity — not the broader "continuous
confidence score with time-decay and reinforcement" some wiki-pattern
writeups propose at the individual-fact level. That's a materially
different, heavier mechanism (per-fact provenance counting, a decay
function, a reinforcement event); nothing here should be read as a first
step toward it without a separate design pass.

Both `type`

(OKF-native) and `predicate`

(this spec's extension) are
**open vocabularies**, not fixed enums — consistent with OKF's own
"producers pick descriptive values, consumers tolerate unknown ones"
stance (§4.2). `Concept`

, plus the Entity-page starter set — `Person`

,
`Organization`

, `Product`

, `Place`

, `Country`

— are the `type`

values every bundle
should expect to use from day one (§4.1); use the specific value whenever
a page clearly fits one — a person's file gets `type: Person`

, not
generic `Entity`

— and reach for plain `Entity`

only as a fallback when
none of the starter set applies. Everything else (`Playbook`

, `Metric`

,
`Attested Computation`

, or a bundle-specific type) is genuinely open and
grows as needed. Document predicates as they're
coined during ingest in `CLAUDE.md`

/`AGENTS.md`

, prune synonyms during
lint (§7.3). Do not pre-design an ontology before there's a bundle to
observe.

Surfaced as open questions during design, since closed. Recorded with
rationale rather than deleted, so the implementing agent knows *why*,
not just *what*:

`founded`

/ `founded_by`

, `invested_in`

/ `backed_by`

: **hard ingest
rule, both directions asserted in the same pass** (§7.1 step 3), enforced
by the asymmetric-relations lint check (§7.3). Rejected alternative:
computing inverses automatically from a predicate-inverse lookup table at
query time, which would need only one direction hand-asserted. Chosen
against, because it pushes complexity into the query layer (§6) for
every read, in exchange for saving one extra frontmatter edit at ingest
— the wrong trade given queries vastly outnumber ingests over the
bundle's life.

Decided (§6.1): `nomic-embed-text`

via a local Ollama endpoint
(`localhost:11434`

) — the same convention Mnemon uses, evaluated and
kept even though Mnemon's storage engine was not (§5.1). During early
prototyping, a TF-IDF vector stood in for the *mechanism*
(RRF fusion, ranking) because the sandbox used to build the first
prototype couldn't reach Hugging Face Hub to pull model weights — an
environment constraint, not a design choice. TF-IDF was never carried
forward: it's corpus-relative (needs refitting as the bundle grows,
unlike static per-document embeddings) and, being pure term-frequency,
won't generalize to paraphrased questions the way dense embeddings
should. The finished reference implementation calls Ollama directly for
every embedding (`ollama_embed`

, wired at both index-build and
query time) — there is no TF-IDF fallback in the production path.

`wiki.db`

and `timeline.md`

are regenerated on every ingest,
unconditionally (§7.1 step 4) — never hand-edited, never treated as a
second source of truth. This is the property the rest of the design
leans on (§1, §3): concept frontmatter is authoritative, everything else
is a rebuildable cache. Dropping `router.md`

(§3) removed the one file in
the bundle that didn't follow this rule.

These were hit while prototyping this exact design and cost real debugging time — treat as hard rules, not suggestions.

YAML 1.1 parses bare `on`

/ `off`

/ `yes`

/ `no`

as booleans (the
"Norway problem"). `on: 2009-07-06`

silently becomes key `true`

. Use
`date`

(§4.2).

An unquoted `2026-08-09`

-style scalar risks being auto-parsed into a
native date object under some YAML parsers' default schema, and naive
downstream serialization can then produce a broken, non-ISO string
instead of clean ISO text — silently breaking OKF's own ISO-8601
requirement for `generated.at`

(§5.2). The reference implementation's
frontmatter reader (`lib/frontmatter.ts`

) uses the `yaml`

npm package's
YAML 1.2 core schema, which does not have this failure mode — verified
directly, not assumed. Quote every date/datetime frontmatter value as a
string regardless of which parser is in use anyway — it's the portable
convention for any other tooling (a YAML 1.1 parser, an editor plugin)
that might read this bundle, and it costs nothing.

Validated directly: on a 4-concept test bundle, both FTS5 BM25 and a TF-IDF vector both ranked the wrong entity first for "Who is Marc Andreessen?" — because a related concept's page happened to mention "Marc Andreessen" in its own body text. Term-frequency and even embedding similarity alone cannot reliably distinguish "this page is about X" from "this page merely references X" on a cross-linked wiki, which is the normal case, not an edge case. §6.1's exact-title signal exists specifically to fix this and must not be dropped as a "redundant" simplification later.

Also validated directly: a naive substring check for this signal is
wrong, not just imprecise — a concept titled "AI" seeded on any question
containing "explain" (`expl-AI-n`

), and "Go" seeded on "ago". Match at a
whole-word/phrase boundary (Unicode letter/number boundaries, not ASCII
`\b`

, so this also holds for non-English titles), never plain
`.includes()`

.

Validated directly against a 66-concept real-world bundle (Northlight, Aug
2026), not a synthetic one: "How do I fix a flat bicycle tire?" was
gated as confident because FTS5 `MATCH`

returned a nonempty result set —
the original rule — and its top-ranked bm25() result outscored a
genuinely relevant question's top-ranked result by raw score magnitude.
The match was a coincidence: "flat" hit the target concept's
"apartment" sense inside an unrelated housing-policy essay,
"fix" is a generic word, and neither "bicycle" nor "tire" appeared
anywhere in that document at all — 2 of 4 query words genuinely present,
covering half the question, yet it out-scored a real match by bm25()'s
raw number.

The mechanism: bm25()'s IDF term rewards rarity. A document containing
one coincidentally rare query word scores *higher* than a document
covering several common ones, regardless of how much of the question it
actually accounts for — this is not a tunable edge case, it is what IDF
does by design, so no threshold on the raw score fixes it.

Measured across this same bundle: every genuinely relevant question
tested had **100%** of its extracted content words present in its
correct top-ranked match; every deliberately off-domain natural-English
question tested had **≤50%**. Gate BM25 confidence on word *coverage* of
the top match — the fraction of the question's content words that
actually appear in it, checked independently of bm25()'s score — not on
score magnitude and not on mere nonemptiness. `>0.5`

(strictly more than
half) separates the two clusters with margin on the cases tested; retune
per-bundle via `evaluate.json`

(§7.5) the same way the vector threshold
is retuned. This does not change which candidates get *ranked* by BM25
— only whether a BM25-only match is trusted enough to answer at all.

Everything in §1–§9 targets the scale this pattern is meant for — hundreds to low thousands of concepts (§2's non-goal: this is deliberately not a graph database). Nothing in this section should be built now. It's recorded so the trigger conditions are explicit if the bundle ever outgrows that range, rather than discovered as a production incident.

Document count alone is not the right scaling variable to watch — it's a proxy that tracks some components and not others.

| Component | Degrades along | Not along | Rough threshold | Fix |
|---|---|---|---|---|
| FTS5 keyword search | — | doc count | Scales to millions; no revisit needed. | — |
`relations` JSON column |
Edge count (concepts × avg relations/concept) — JSON1 has no index, so `json_each` is scanned per query. |
Doc count in isolation | Tens of thousands of edges, once §6.3's multi-hop expansion starts self-joining over an unindexed view. | §11.2 |
`embeddings` table |
Vector row count — the reference implementation's cosine-similarity scorer is a brute-force in-process loop over every row today. | Doc count in the abstract, though it tracks 1:1 (one vector per concept) | Low thousands of vectors, where per-query linear scan starts adding real latency. | §11.3 |
| Graph traversal | Edge density and required traversal depth. | Doc count | Only if requirements shift to unbounded-depth or whole-graph algorithms — not reached by §6.3's bounded 1–2 hop retrieval. | §11.4 |

**When**: multi-hop queries (§6.3) measurably slow down doing self-joins
over `json_each`

— in practice, tens of thousands of edges (a few
thousand concepts at typical relation density).

**What**: a build step folded into Reindex (§7.4) that flattens every
concept's `relations`

array into rows of a normalized, indexed table:

```
create table edges (
  subject_path   text not null,
  predicate      text not null,
  object_path    text,   -- null if the object is a literal, not a concept
  object_literal text,   -- populated when the object is not a concept path
  date           text,
  source_id      text
);
create index idx_edges_subject on edges(subject_path);
create index idx_edges_object  on edges(object_path);
```

Concept frontmatter's `relations`

field (§4.3) is unchanged — this is a
query-layer materialization, not a file-format change. `context_for()`

's
expansion query swaps `json_each`

/`json_extract`

calls for plain indexed
joins against `edges`

; same logic, faster execution.

**When**: cosine-similarity retrieval (§6.1) adds noticeable per-query
latency — roughly low thousands of concepts, since the current scorer
is an unindexed linear scan over every embedding row, run in-process
on each query.

**What**: replace the plain `embeddings(path, vector)`

table with a
`sqlite-vec`

`vec0`

virtual table
and query via SQL KNN instead of an in-process loop. It's real and actively
maintained — a single dependency-free C extension — but still pre-v1
(0.1.10-alpha at time of writing): pin a version and expect this
migration to need revisiting before it hits 1.0. Embedding generation
itself (Ollama + `nomic-embed-text`

, §9.2) is unaffected; only storage
and query of the resulting vectors change.

**Not triggered by concept count.** Triggered by either:

- Edge density growing far past a sparse wiki-style graph, dense enough that indexed-table joins (§11.2) stop being fast even bounded to 1–2 hops.
- A requirement change: if
`context_for()`

ever needs unbounded-depth traversal or whole-graph algorithms (shortest path across the entire bundle, centrality, community detection) rather than the bounded, budget-constrained expansion specified in §6.3 — a different query shape that SQL joins express poorly regardless of indexing, where Neo4j/Cypher-class engines earn their keep.

If neither condition holds, §11.2's indexed edge table keeps SQLite adequate indefinitely. §6's retrieval design was built to never need more than a bounded hop count in the first place, which is exactly the case a relational engine handles well.

§7.2 assumes the calling agent judges when a question is in-bundle and
invokes `context_for()`

deliberately. That judgment can silently fail to
fire in practice — a full stretch of clearly in-domain conversation can
pass with zero automatic queries, because nothing forces the check. This
section specs an optional mitigation, external to this bundle's own CLI:
a harness-level hook (Claude Code's `settings.json`

hooks —
`UserPromptSubmit`

) that nudges the agent to query when the incoming
message plausibly touches the bundle. Not installed by
`/add-llm-okf-graph-wiki-vb`

by default — a per-group opt-in, since it
adds a script and a `settings.json`

entry the operator should choose to
add, not something every bundle needs.

The hook's only job is a cheap, deterministic yes/no on whether the
incoming message is in-bundle. It must not run full `context_for()`

(§6)
itself — that pipeline (FTS + vector embedding + bounded graph expansion)
is comparatively expensive, and would be paid on every turn regardless of
topic if the hook triggered it directly, including the majority of turns
that have nothing to do with the bundle's domain. On a match, the hook
should only emit a short nudge string reminding the agent to consider
running `query`

— the agent still pays for `context_for()`

only if and
when it actually acts on the nudge.

`wiki-trigger-keywords.txt`

(§3, §7.4) is the input: every concept's
`title`

and `tags`

, deduped and regenerated on every Reindex — same
"cache, never hand-edited" treatment as `wiki.db`

/`timeline.md`

(§9.3).
Deliberately narrower than a full free-text scan: proper nouns and
specific tags have a much lower false-positive rate than generic topic
words matched against arbitrary prose (validated directly — see §12.4).

A plain shell script registered as an additional entry under the
harness's `hooks.UserPromptSubmit`

array (alongside any hook already
registered there, not replacing it):

- Read the harness's stdin JSON payload, extract the prompt text
(
`.prompt`

, falling back to other field names in case the harness's schema differs — fail silent-open, i.e. no nudge, on a schema mismatch rather than erroring). - Loop
`grep -qiFw`

(fixed-string, whole-word, case-insensitive) over each non-comment, non-blank line of`wiki-trigger-keywords.txt`

. - On any match, print one line naming the matched concept(s) and
suggesting the
`query`

op. Exit 0 either way — this hook must never block the turn.

The keywords file lives inside the bundle (§3); the hook script itself
lives in the group's own workspace (`/workspace/agent/hooks/`

by
convention), not under the shared, read-only skill directories
(`/app/skills/...`

) — those are mounted read-only to the agent and can't
host a script that needs per-group editing.

Concretely, the registration:

```
{
  "hooks": {
    "UserPromptSubmit": [
      { "hooks": [{ "type": "command", "command": "<existing-hook-if-any>" }] },
      {
        "hooks": [
          {
            "type": "command",
            "command": "/workspace/agent/hooks/wiki-trigger.sh",
            "timeout": 5
          }
        ]
      }
    ]
  }
}
```

A short `timeout`

matters: this hook must never meaningfully delay a
turn — if it hangs, the harness should cut it off well before it becomes
noticeable.

Three were hit building this pattern, worth carrying forward as hard rules rather than rediscovering:

**Whole-word matching, not substring.** A trigger list sourced from`tags`

(§7.4) routinely includes short slugs (`ai`

,`ft`

, and similar). Plain substring`grep -F`

matches those inside unrelated words (`ai`

inside "again,"`ft`

inside "often"), producing near-constant false positives. Use`-w`

(`grep -qiFw`

).**Harness-injected wrapper text can collide with keywords.** Some harnesses prepend boilerplate to every prompt (e.g. a context/metadata tag carrying the install timezone) before the hook ever sees it. A keyword that happens to match a token inside that boilerplate (a country or city name embedded in a timezone string, for instance) fires on every single turn regardless of what the user actually typed. Strip known wrapper markup from the extracted prompt text before matching.The reference container doesn't install it by default — a script that assumes it's on`jq`

is not guaranteed present in the agent image.`PATH`

fails silently (empty prompt extracted, hook effectively always a no-op) rather than erroring loudly. Either add`jq`

via the bundle's own`install_packages`

self-mod path (§ container skill's self-mod docs) before relying on it, or parse the stdin JSON with something already guaranteed available (e.g. a one-line`node -e`

fallback) so the hook degrades to "no nudge" only on a genuine schema mismatch, not on a missing binary.

The gate itself is free relative to `context_for()`

— a loop of `grep`

calls over a short keyword list, a few milliseconds, no LLM or embedding
call. Token cost only appears on an actual match, and even then is just
the fixed reminder string (no retrieved facts injected).

A finished TypeScript implementation (runs on Bun) demonstrates every mechanism above end-to-end, one file per concern:

`build-index.ts`

— reindex: walks`entities/`

/`computations/`

, rebuilds`concepts`

,`concepts_fts`

(FTS5), and`embeddings`

from scratch (§5, §7.1 step 4).`context-for.ts`

—`context_for(question)`

: hybrid seed matching (exact title + FTS5 + vector cosine, fused), bounded graph expansion, trust- and budget-aware filtering (§6).`lib/ollama-embed.ts`

— real`nomic-embed-text`

embeddings via a local Ollama endpoint, plus the cosine-similarity scorer (§9.2). No TF-IDF or other placeholder in this path.`lib/rrf.ts`

— reciprocal rank fusion across the three seed signals.`lib/graph-score.ts`

— bounded multi-hop expansion and predicate-aware scoring (§6.2–§6.3).`lib/frontmatter.ts`

— the YAML 1.2 core-schema frontmatter reader/writer (see §10.2's note on the YAML 1.1 date-coercion pitfall it sidesteps).`lint.ts`

,`evaluate.ts`

,`timeline.ts`

,`keywords.ts`

— the remaining CLI operations (§7.3–§7.4);`keywords.ts`

regenerates`wiki-trigger-keywords.txt`

(§12's optional hook input) from every concept's`title`

and`tags`

, skipping non-string tag values (frontmatter documents`tags`

as strings, but a bare unquoted number in YAML — e.g. a year written as`2009`

instead of`"2009"`

— parses as a JSON number; caught in practice, not hypothetical, and also the right call independent of the parse-safety angle, since a bare number is a high-false-positive trigger keyword regardless of type).

Each `lib/`

module has unit tests alongside it. Treat this as a
direct, runnable reference for shape and query patterns in any
language/runtime — nothing in it is a placeholder standing in for a
not-yet-built mechanism.
