What a note and a connection are, in plain Markdown, for anyone changing how B2 reads or writes them. This is the yardstick the engine is measured against: the SQLite schema in index-engine.md §3 is derived from this model and must satisfy it, never the reverse.
Related pages: invariants.md is the normative register, cited by id. index-engine.md is the how. The why behind each choice is an ADR: why connections live where they do (ADR-0010), why B2 never writes the body (ADR-0004), why identity is the path (ADR-0003), why there is no suggestion queue (ADR-0009).
The model has exactly two source-of-truth objects, both plain Markdown:
- A note. One
.mdfile: YAML frontmatter plus a Markdown body. - A connection (edge). A directed link from one note to another: a plain link you write
in the body, or a typed relation in frontmatter
b2_relations:(§0).
Both are authored by a human. A real vault also holds resources: every non-.md file. A
resource is a peer vault member, not a third authored object. B2 can read it but cannot author
structure into it, because Markdown is the only format whose bytes B2 may write. So the source
tier is the whole vault directory, while the two authored objects stay note plus edge.
Resources are defined in §10; they change nothing in §0 to §9.
- Markdown: the source of truth for knowledge. Your notes plus every committed edge, on your disk, fully usable with no B2. The files stay pristine, and the body is 100% yours (W2). The short list of on-command writes is W3. Reading your vault writes nothing at all (W1).
- The index (
.b2/b2.sqlite): a disposable cache. The search indexes and the keyed graph. It holds nothing that cannot be rebuilt from the Markdown.
The rules: two tiers, sharply split (S1); index = projection of (the vault directory) (S2,
ADR-0002). Drop b2.sqlite, reindex, get an identical index (S3). No durable B2-derived state
lives outside your notes (S4). A resource (§10) contributes only derived rows, so the
guarantee is unchanged.
Your vault directory carries two kinds of authored material: the Markdown files (content) and the directory tree itself (structure). A folder, empty or not, is user-authored exactly like a note, and the filesystem is authoritative for it. B2 proxies the OS rather than modeling folders:
create_dirmakes missing parents but refuses an occupied target. There is nomkdir -pidempotence: you asked to create.move_diris onerename.delete_dirisremove_dir_all.- Each resolves its target against the disk, never the index, so empty folders work everywhere.
Folders are never projected into the index. They carry nothing to chunk, embed, or link. The
tree listing (Vault::list_dirs) is a live filesystem walk (dot-folders skipped, §1), so the
tree matches the vault's managed subtree by construction, in both directions. S4 scopes to
B2-derived data; your own structure is vault material, not B2 state.
Ruled by ADR-0010 and ADR-0004. A connection lives in exactly one of two homes, by origin. The homes split by what they can say, not just by who writes them:
| Origin of the edge | Where it lives | Source of truth |
|---|---|---|
| A plain body link | The body: a bare [[path|title]], a Markdown [text](path), an embed. Ordinary Markdown, always an untyped references edge |
The body. B2 reads it, never writes it |
A typed relation (committed by b2 link, or written by you or an importer) |
Frontmatter b2_relations:, as a typed-link string - "<verb> [[path|title]] — …" (§2). The only home of a verb plus explanation |
Frontmatter, B2's managed metadata zone |
b2 link writes frontmatter, not the body. Committing appends one typed-link string to
the source note's b2_relations: (Markdown first, index reconciled after). The edge then
materializes as an origin=frontmatter edge derived from that Markdown line. Committing is
the projection of an authored line, not a bespoke index write (§3).
In one line: the body holds the plain links you write (all references); frontmatter
b2_relations: holds every typed relation, verb and explanation; both are authored
Markdown, and the graph is their union (G2).
Each edge has exactly one home, and B2 never copies between them. So there is nothing to keep
in sync, only a one-way projection to rebuild. A b2_relations: entry may deliberately target
a note the body already links: that is the augment flow (§2). The overlap case, the same
(target, type) in both homes (necessarily references, the only type a body link can
carry), is resolved at projection time by frontmatter-wins dedup. The frontmatter row is kept,
because only it can carry an explanation. The redundant body reference is ignored as a
duplicate, never auto-removed from your file.
A note is one .md file whose vault-relative path has no dot-prefixed segment: YAML
frontmatter, then a Markdown body. Any segment counts, so notes/.templates/daily.md is no
more a note than .scratch.md is.
---
type: concept # optional, defaults to `note`; OKF entity discriminator
title: "Spaced repetition" # optional, INERT — the title is the filename (L2)
description: "Why expanding intervals beat massed practice."
tags: [learning, memory]
created: 2026-06-20
updated: 2026-06-29
aliases: [SRS] # optional Obsidian-native extra titles
b2_relations: # B2's managed zone: typed edges (§2). origin=frontmatter
- "contradicts [[notes/cramming-works|Cramming works]] — short-term recall only"
provenance: # optional; defaults to {by: human}
by: human
---
Spaced repetition schedules reviews at expanding intervals…
It builds on [[concepts/memory|Human memory]] — the forgetting curve is the mechanism.The body link is human-authored (origin=inline) and untyped: an ordinary references edge.
The surrounding prose is just prose. The b2_relations: entry is a typed edge
(origin=frontmatter).
Hidden means hidden
A dot-prefixed name is outside the managed subtree, whatever it is: a folder (.git/,
.obsidian/, B2's own .b2/), a resource (.DS_Store), or a Markdown file (.scratch.md).
The convention is the filesystem's, not B2's, so B2 reads it the same way everywhere (S2,
GH #136):
- The walk skips it before it routes it (
pathspec::is_hidden, applied above the note/resource dispatch incollect_vault_files). So "hidden" cannot mean one thing for a PDF and another for a Markdown draft. A dot-prefixed.mdis never a note: no chunks, embeddings, graph presence, listing, search, or file-tree appearance. - The file itself is untouched. Skipping is not deleting (W4). That is the point: keep a scratch draft out of B2's way without leaving the folder you work in.
- B2 will not author one either. Every authoring destination refuses a dot-prefixed segment, because creating a member the walk would never see is a silent desync between disk and index.
Migration note: rows for a previously indexed dot-prefixed .md are pruned on the next
reindex, and inbound links re-dangle (G5). Renaming it back restores it exactly.
Required: nothing. A note is a .md file; that is the whole entry requirement. Identity
is the vault-relative path (L1, ADR-0003), not a key in the file. There is no frontmatter B2
needs present, none it will add, and a note with no frontmatter block at all is an ordinary,
fully indexed note.
Optional keys B2 recognizes:
type: what kind of note this is (note,concept,source,person,daily, …). Controlled but extensible; unknown values are tolerated; the OKF entity discriminator (§5). Absent means untyped. Its only consumer is display, so nothing keys on its presence and the new-note template does not seed it (GH #80: the template stamps only what cannot be reconstructed later). Notb2-namespaced on purpose: a courtesy you own, not a key B2 machines on.title: recognized but inert (L2). A note's title is its filename (basename minus.md). The key is parsed and round-tripped losslessly like any other, and never drives display, link aliases, or search.description: a one-line summary. Round-tripped; nothing in B2 reads it yet.tags: a list of strings.created/updated: ISO-8601 date or datetime.createdis set by B2 at creation (b2 add);updatedis yours (or another tool's) to maintain. B2 does not stamp it.aliases: Obsidian-native additional titles. Round-tripped; B2 does not yet resolve a link written against an alias (a[[SRS]]namingsrs.mdby its alias dangles, G5).provenance: optional, opt-in note-level authorship:{by: human | agent:<model-id>, source?, confidence?}. Absent means{by: human}. B2 neither requires nor manages it. Edges carry no provenance (§4).b2_relations: B2's managed zone for typed edges (§2). A YAML list of typed-link strings, the only place a relation verb and explanation live. Namespaced, and B2's only key, so it can never collide with yourrelations:key or another tool's. For the same reason a generic un-namespacedrelations:is not read; it is just another unknown key, preserved verbatim. B2 appends here onb2 link(never the body); you and importers may write it too.
Unknown keys are preserved verbatim and byte-for-byte on round trip (W5, §6). A b2id:
line an older B2 left behind is exactly that: an unknown key, never read, never removed
(ADR-0003).
A normal [[path|title]] anywhere in prose is a connection of type references,
origin=inline: the untyped graph Obsidian already gives you, typed and materialized. It is
directed (A→B, the literal fact that A's text points at B). That preserves the split between
backlinks and forward links (b2 neighbors shows it as referenced-by from B's side), is the
information-preserving default, and keeps the explicit symmetric verb (contradicts)
meaningful as a deliberate choice.
The typed-link syntax is <verb> [[path|title]] — explanation, as a quoted string in a
b2_relations: list. This is the one and only home of a typed relation. The optional trailing
text after an em dash (or :) is the edge's explanation.
b2_relations:
- "supports [[concepts/forgetting-curve|Forgetting curve]] — the schedule exploits it"
- "contradicts [[notes/cramming-works|Cramming works]]"- Quoted, so
[[,|, and:are always YAML-safe. The reader accepts quoted or unquoted. - An entry that is just a bare
[[path|title]](no verb) is accepted and reads asreferences. - You and importers may write this block too. B2 appends to it on
b2 linkand never authors the body.
A b2_relations: entry may target a note the body already links. It augments that
connection: the body keeps its plain, clickable link exactly as written, and the frontmatter
carries the stance. A different verb (supports [[x]] over a body [[x]]) adds the typed
edge alongside the untyped reference; both are real, separately authored facts. The same verb
(references [[x]] — why) collapses into one edge, frontmatter-wins (§0, §3), so the
explanation survives. This is the intended UI gesture: select a body link, choose a verb and
optionally an explanation, and B2 appends one b2_relations: entry. The body is never
touched.
Small, orthogonal, stable core; expressiveness in the tail (G3, ADR-0010).
The core (a closed set; the b2 link palette, and what queries can rely on):
| Verb | Stance | Direction | Inverse (display only) |
|---|---|---|---|
references |
neutral | directed | referenced-by |
supports |
for | directed | supported-by |
contradicts |
against | symmetric | contradicts |
Boundary notes: references is both the automatic type of a bare link and the deliberate
"see also". supports is a directed "A backs B". contradicts is a deliberate symmetric
"these state opposites"; tension has no aggressor, so no direction is recorded.
Extensibility: the core is stable across versions. Any other verb you write (elaborates,
part-of, supersedes, …) is tolerated and stored verbatim, never dropped. Tooling treats
tail verbs as opaque strings: no inverse label, no special traversal. A tail verb that proves
common can be promoted into the core later (gaining an inverse label). Demotion is just
removal from the palette; stored data is untouched.
Typing guidance: use a stance verb whenever the notes take a position on each other.
references is the honest default when they don't.
Conventions: lowercase kebab-case, named from the source's perspective (derived-from, not
DerivedFrom). Edges are directed and stored once (G4): inbound edges are computed by
scanning dst_path and labelled with the inverse; the symmetric verb is its own inverse. B2
never writes a reciprocal link into the target file. That would amplify writes and edit a note
you didn't touch.
An authored edge, body or frontmatter, is identified by the tuple
(src path, dst path, type, occurrence-index), all recoverable from the Markdown alone. No
edge id is ever written into the file. A committed edge carries no provenance at all: it is a
pristine authored line, nothing more (§4).
Every edge projects to one record. This is the shape the index-engine.md §3
edges table holds. The Markdown is the source; this is the index.
| Field | Values | Source |
|---|---|---|
id |
derived | edge identity, from (src, dst, type, occurrence_index) |
src_path |
note path | the authoring note (always a note, G6) |
dst_path |
note path, or NULL | resolved from the authored [[path]] at parse time |
dst_resource_path |
resource path, or NULL | set instead of dst_path when the target is a non-.md file (§10) |
dst_path_raw |
text | the target exactly as authored; what a dangling edge keeps |
type |
relation verb (§2) | the b2_relations: verb; references for every body link |
origin |
inline | frontmatter |
which of the two homes (§0) the edge came from |
explanation |
free text, optional | trailing text after — or : (frontmatter entries only) |
caption |
text, optional | a Markdown link's text, or an embed's alt text |
- Every edge is authored and active (G1).
originrecords which home it came from. There is no lifecycle and nostatuscolumn: an edge exists exactly when it is written in the Markdown, which is what keepsindex = projection of (Markdown)exact. srcanddstare vault-relative paths, resolved at parse time. The authored[[path]]is normalized by the resolver (the wikilink+ ".md"ladder) and stored as the path it named.dst_path_rawkeeps the text exactly as written. So an edge stores what you authored, and the graph carries no identifier the vault does not. A B2-performed move is therefore one operation: rewrite the inbound[[path|title]]text and rename the file (all or nothing on the vault), then re-key the moved note's rows and re-project the inbound sources (index-engine.md §8). That is how L1's "rename keeps every backlink resolving" holds. A move made outside B2 is a delete plus a create, and its inbound links project as surfaced dangling edges (G5).- The edge set is the union of the two homes, deduped (G2). A frontmatter entry with a
different verb than a body link to the same target is no duplicate; that is the augment
case (§2), and both edges project. If the same
(src, dst, type)is authored in both homes (necessarilyreferences), projection keeps the frontmatter row. Frontmatter wins because only it can carry an explanation. The file is never auto-edited. - A
dstmay be a resource, not a note. A body embed or link to a non-.mdfile (![[photo.png]],[[papers/x.pdf]]) resolves against theresourcestable and recordsdst_resource_path.srcis still a note (§10). - A
dstthat resolves to nothing is a surfaced dangling edge, not a dropped one (G5). A note is one.mdfile (§1), so a[[Hermes]]naming a folder, or a plain typo, matches no note and no resource. The edge is still projected, with bothdst_pathanddst_resource_pathNULL.b2 neighborsandb2 explain(and the desktop Connections pane) present these distinctly, as unresolved with the authored target, so a mistyped link reads as broken rather than silently vanishing (GH #12). Resolving the target turns the same edge into an ordinary connection on the next reindex. Folder-note resolution (Obsidian-styleHermes/Hermes.md) is a possible later refinement, deliberately out of scope.
Why this projection is materialized rather than computed on read, and why that does not make it a third source of truth (G6), is index-engine.md §3. The standing cost is its §8.
There is no suggestion lifecycle, no review queue, no rejection memory, and no event log (ADR-0009). A connection becomes real in exactly two ways, both authored in Markdown:
- A body link you write. A plain
[[path|title]], Markdown link, or embed: an untypedreferencesedge (§2). B2 reads it on the next reindex; it never writes the body. b2 link <src> <dst> [--type <verb>] [--explanation …]. B2 appends one typed-link string to the source note's frontmatterb2_relations:(Markdown first, never the body), then re-projects the note so the edge materializes asorigin=frontmatter.--typedefaults toreferences; the palette is the core vocabulary (§2). B2 writes the target as a bare[[path]]with no|alias: the filename is the note's title (L2), so the path already reads as the title. (You may still write any|aliasyou like in a body link; B2 reads it and never rewrites it.)
The GUI adds one more authoring gesture over the same model: dragging a Similar card onto a
line of the note being edited types a [[wikilink]] at that line's end. That is the untyped,
body kind of link, landing in the editor's buffer exactly as [[ completion does. B2 still
authors nothing (W1), and you are still the precision gate.
No provenance tier. A committed edge is pristine: no by, no confidence, no source.
Nothing is stapled to the note beyond the <verb> [[path|title]] line itself. Provenance is
decision fuel for a review step B2 doesn't have. (The optional note-level provenance:
frontmatter stays yours to write, §1, and is separate from edges.)
Build like OKF for cheap interop; don't depend on it. Export is a no-op, not a migration. The model already lines up:
typeis the OKF entity discriminator: recognized frontmatter, defaulting tonote(§1).- Resource URI: a per-note URI is derivable from its vault-relative path under a configured base. It is exactly as durable as the path (L1), the same handle Obsidian, a static-site generator, and a file manager all give you (ADR-0003).
index.md: a vault-root manifest listing notes and types, derivable from the frontmatter, so an OKF consumer has a collection entry point. Generated, never a second source of truth.
Net: "export to OKF" is selecting and re-shaping fields that already exist. The export surface itself (minting URIs, emitting the manifest) is not built; GH #103 tracks it.
W5's lossless round trip is what makes the two-tier split safe. It is a property of the parser/serializer, so it is specified here:
- Unknown frontmatter keys are preserved verbatim and in order. Body text, whitespace, and comment tokens are byte-preserved.
- The only bytes B2 ever changes are the specific mechanical edits it is asked to make (W3).
Two of those reach the body, and they differ in kind: the editor save splices in body bytes
you supplied, verbatim, while the move repair is the one body edit B2 composes itself,
rewriting an inbound
[[oldpath|title]]to[[newpath|title]], aliases preserved verbatim (W2). An operation you did not invoke changes no byte at all (W1). - Nothing is reformatted, reordered, or normalized in passing. Not YAML quoting style, not list indentation, not line endings.
These are the tripwires index-engine.md §8 budgets: this doc defines them, that one enforces them in the store.
The reasoning for each rejection lives in the ADR that made the call; this is the index.
| Rejected | Why, in one line | Recorded in |
|---|---|---|
B2 authoring the body (a ## Relations section) |
The body is the rendered/exported document and must stay 100% yours | ADR-0004 |
| Typed-line syntax parsed from body prose | Would make B2 an interpreter of prose shape: - see [[x]] for background becoming verb see (L4) |
ADR-0004, ADR-0010 |
| The body as the home for committed edges | Accepted trade: a frontmatter edge isn't guaranteed clickable in vanilla Obsidian, which can't render edge types regardless (§0) | ADR-0010 |
| A suggestion review layer / per-pair LLM relator | Notes × candidates model calls, and exactly the model-compensating machinery M1 defers | ADR-0009, ADR-0005 |
A durable event-log tier (.b2/log/) |
It would exist to hold a queue and rejection memory B2 doesn't have; anything durable outside the notes weakens S2/S4 | ADR-0002 |
A stamped machine identity (b2id:), removed 2026-08-13 (GH #170) |
Its one real buy (re-binding after an out-of-band move) cost an unbidden write, a machine key in every file, a collision subsystem, and a carve-out on S3 | ADR-0003 |
| Content-hash identity | Churns the graph on a typo fix. (Hash-keying the vector store is the opposite case and shipped with the same change: derived data, where content-addressing is exactly right, M4) | ADR-0003, ADR-0006 |
| Stored reciprocal links | Inverse edges are derived at query time (G4); writing them back amplifies writes and edits notes you didn't touch | ADR-0010 |
| Per-edge ULIDs in the file | Authored edge identity is derived (§2); explicit ids clutter the note for no gain | ADR-0010 |
| Edge provenance in Markdown | With no review step there is no provenance to keep (§4) | ADR-0009 |
The smallest fixture that exercises the whole model: an authored typed edge and a bare reference.
concepts/memory.md
---
type: concept
title: "Human memory"
created: 2026-06-20
---
The brain encodes, stores, and retrieves information…notes/spaced-repetition.md
---
type: concept
title: "Spaced repetition"
created: 2026-06-20
b2_relations:
- "supports [[concepts/memory|Human memory]] — applies the forgetting curve"
---
Spaced repetition exploits the [[concepts/memory|Human memory]] retrieval curve.
Expanding review intervals exploit the forgetting curve.The body holds one plain link (origin=inline, references), and the b2_relations: entry
types the same connection with a stance (origin=frontmatter, supports): the augment shape
from §2, exercising both homes at once. The derived graph (no live model needed to assert):
references: spaced-repetition → memory (origin=inline), from the prose wikilink.supports: spaced-repetition → memory (origin=frontmatter, explanation "applies…").
b2 neighbors concepts/memory returns spaced-repetition twice (referenced-by, supported-by).
Both files round-trip byte-identical. Dropping and rebuilding the index reproduces the
identical graph.
The data model is locked; nothing is open. Every decision and its rejected alternatives are in ADRs/, chiefly ADR-0002 (two tiers), ADR-0003 (path identity), ADR-0004 (write discipline), ADR-0009 (no suggestion queue), and ADR-0010 (the two homes, the dedup rule, and the verb core). The normative claims are the register's S, W, L, and G entries.
§0 to §9 define the authored objects. A real vault also holds resources: every non-.md
file. This section defines what a resource is in the model; the schema is
index-engine.md §3.
A resource is a peer vault member, not a lesser one, and not a generalized note. The single asymmetry is the authoring surface, not status:
A note is where structure is authored; a resource is a peer document B2 cannot write. Notes have frontmatter, authored edges, and B2's write guarantees, because Markdown is the one format whose bytes B2 may touch. Resources have bytes, derivable text and vectors, and inbound links. Identity is not part of the asymmetry: both are keyed by their vault-relative path (L3), so the note rules are the resource rules with an authoring surface added, not a second identity model.
What the asymmetry does not mean: a resource is never required to be attached to a note. An unlinked resource fully exists: walked, classified, in the file tree, in the index, openable.
Identity: path-keyed, index-only. There is nowhere to stamp a machine id (binary bytes are not B2's to edit; a sidecar file would be durable state outside Markdown, violating S4) and nothing one would protect. That reasoning is now the whole vault's; ADR-0003 read it back onto notes.
b2 mvon a resource is simply the move: rewrite the inbound[[path]]/text, move the file, re-project. Identical terms to a note (§3).- Placing one is not authoring one. "B2 cannot write a resource" is about its content:
B2 never edits the bytes, and there is no format-specific writer. Putting a file into the
vault on explicit command (the desktop's drag-from-Finder import,
Vault::import_file) is file management, the same act as moving or deleting it (W3). The copy is byte-honest: B2 places exactly the bytes it was handed, then projects them, routing on the extension exactly as the walk does. A dropped.mdis therefore a note arriving, frontmatter and all. - An out-of-band move degrades exactly as a note's does: identification, not repair. The
old path's rows prune, the new path projects as a new member, and every inbound link
surfaces as a dangling edge (G5). The index keeps a blake3 content hash per resource, and
notes carry a
body_hash, so a proposed-repair feature stays buildable on data already stored. It is recorded as future investigation in GH #170 and deliberately not built: a proposal is still yours to accept (W4).
Edges: src is a note in v1; dst may be anything (G6). This is a consequence of the
invariant, not a status rule: every edge must trace to an authored line in Markdown (§3), and
a resource has no writable home for one. Two relief valves keep this from becoming an
expressiveness wall: (a) today, the tolerated tail already authors the inverse direction from
the note side (- "supported-by [[papers/x.pdf]]"); (b) if needed, resource-sourced edges get
a designed future home, a vault-level B2-managed relations file, so the edge is still authored
Markdown and the invariant holds (GH #102,
deferred until the need is real).
What is built, and what is designed. The inventory-and-graph half ships: the walk
classifies every non-.md file by extension into one of six classes (text, html, pdf,
image, media, binary, the total fallback), records
(path, class, size, mtime, content_hash), prunes what the walk no longer meets, and resolves
body embeds and links at them into dst_resource_path edges. Classification is by extension
only: deterministic, and a misclassification degrades rather than mis-executes.
The content half is locked design, not shipped: every class funnels to text. Native for
text, extracted for html (tag-strip) and pdf (text layer), and, for an image, the
aggregated alt-text and captions from the notes that embed it (a pure projection of authored
Markdown). That text then flows through the existing bge space with zero new discipline
(chunks plus a per-document centroid).
GH #108 tracks it,
#109 the PDF extraction dependency, and
#107 render mechanisms. Until they land, a
resource is an inventoried, linkable, openable peer that search and discovery do not reach.
Multimodal image embedding and an LLM/OCR describer are documented future seams, off by
default (M3, GH #110).
Why a separate object, not a kind column on the note. Two tables, two contracts, zero
"unless it's a resource" clauses. Generalizing notes to hold resources would staple a caveat
onto every invariant, write guarantee, and frontmatter behavior in §0 to §9. A distinct
resources table isolates the different write contract instead of threading it through the
note rules.