Skip to content

The Markdown Store Backend

The store layer is a generation-time choice (ADR 0001): gen_store takes a Backend discriminant and emits one CRUD module per entity wired to that backend’s primitives. With Backend::Markdown, your records are plain markdown files with YAML frontmatter — editable in any editor, diffable in git, navigable in Obsidian — and the generated store reads and writes them through the markdown-store runtime crate. Generated store code reaches records through the typed view VaultHandle::entity(dir_segment, type_name), which returns an EntityRecords (read_all, count, read_opt, create, modify, remove, admits). Under the Flat layout it filters by the type key, so entities sharing a directory stay apart.

The load-bearing invariant: everything above the store is byte-identical between backends. gen_api, gen_servers, and gen_clients output the same code whether the store talks to SQLite or to a folder of .md files — enforced in CI by tests/backend_parity.rs.

SeaORMMarkdown
Sweet spottransactional, high-write, large-Nsmall-N, human-editable, read-heavy
Recordsrowsvault/<entity>/<id>.md
Co-editingvia the appany editor, Obsidian, sed, an agent
List costindexed SQLparse-the-folder, capped (default 10k)
Multi-record atomicitytransactionsnone — single-record only
Idsyour callString, always (the id is the filename)

Knowledge bases, planning trackers, configuration vaults: markdown. Anything that needs joins at scale or batch transactions: SeaORM.

build.rs
Pipeline::new("src/schema")
.markdown_io(
"src/persistence/markdown/generated",
MarkdownIoOptions {
vault_root: "data/vault".into(),
layout: MarkdownLayout::PerEntityDir,
list_cap: 10_000,
okf: OkfOptions::default(), // index and provenance stamps: off
},
)
.store_id_strategy(IdStrategy::SlugFromField("title".into()))
.dtos("src/schema/dto")
.store("src/store/generated", Some("src/store/hooks"))
.api("src/api/v1/generated", "AppState")
.build()?;

With exactly one persistence stage configured, the store backend is inferred. Enable both seaorm(...) and markdown_io(...) and you must call .store_backend(StoreBackendChoice::…) to disambiguate.

An ontogen vault is an OKF 0.2 bundle: a directory of markdown files with YAML frontmatter, each carrying a type. Other OKF consumers can read it, and it stays plain markdown for everything else. The type stamp and the reserved names below are always on. Two further OKF artifacts are opt-in knobs: generated index files and provenance stamps. For how the pieces map onto the spec, see ontogen vaults are OKF bundles.

The generator owns the type: key and writes it as the first frontmatter key. It defaults to the struct name (Task), and #[ontology(entity, type_name = "...")] overrides it.

---
type: Task
title: Ship the emitter
status: closed/done
---
  • A record with no type still reads. It gains one on its next real write; a no-op update writes nothing, so merely reading or re-saving unchanged data never rewrites the file.
  • With MarkdownLayout::PerEntityDir the directory decides what a record is. A record whose type disagrees is tolerated on read and normalized on its next real write, so count stays a path walk.
  • With MarkdownLayout::Flat, entities share the vault root, so list, count and get filter by type: a record whose type differs from the entity’s is excluded (and get returns NotFound). Records with no type are included.

index and log are reserved for OKF’s index.md and log.md. They are rejected as ids, case-insensitively (macOS and Windows filesystems treat Index.md and index.md as the same file). A slug derived from a field that comes out as index is deduped like any collision and becomes index-2. The vault walk skips any index.md and log.md, so list, count and read_all never return them.

A new id also cannot be a Windows device name (con, prn, aux, nul, com0-com9, lpt0-lpt9), in any case, either as the whole id or as the part before its first .. Windows has no file of that name, so con.md could not exist there. A slug that comes out as con becomes con-2. The rule applies to creates only: on Linux and macOS an existing con.md stays listed and reachable. On Windows, a lookup of such an id, or of a superscript form (com¹-com³, lpt¹-lpt³), is NotFound without opening any file, because opening con.md there opens the console device. See Creating records for the whole id rule.

An entity directory cannot be a device name either. With MarkdownLayout::PerEntityDir, gen_markdown_io fails when an entity’s directory, or the default one taken from its name (Con gives con), is one, and names the entity.

OKF gives some frontmatter keys fixed meanings. gen_markdown_io checks every field’s effective key (see below) at build time. It takes the whole SchemaOutput rather than just the entities because the status check needs the enum definitions; the check lives in the markdown generator, so a SeaORM-only consumer never sees these diagnostics.

  • type is a build error. The generator owns it; the frontmatter struct does not model it, so reads tolerate it being absent or different.
  • status warns unless the field is an enum whose variants are all among draft, stable, deprecated.
  • resource and stale_after warn unless the field is String or Option<String>.
  • generated and usage_window warn unless the field is a struct. While okf.generated_by is set, a field stored under generated is a build error instead, because the generator owns the key. A well-known non-struct (Vec, HashMap, DateTime, NaiveDate, Uuid, and so on) does not count, nor does a primitive or a schema enum.
  • verified warns unless the field is a struct or a Vec of structs (OKF allows one verification or a list).
  • sources warns unless the field is a Vec of structs.
  • title, description and tags are never flagged.

A warning is a cargo:warning= line naming the entity, field and key. It is not an error: a vault with its own status vocabulary (like the tasks-tracker example, which mirrors SDLC statuses) is legitimate, it just isn’t OKF-conformant for that key. To resolve either, rename the key without touching the Rust field:

#[ontology(frontmatter_name = "task_status")]
pub status: String,

Renaming a key on an existing vault orphans the old key: existing records must be renamed by hand, otherwise reads fail with a missing-field error for a required field.

frontmatter_name renames only the frontmatter key. The Rust field, DTOs, API, servers, clients and SeaORM columns keep the Rust name; the generated #[serde(rename = "...")] and the {ENTITY}_FM_FIELDS constant carry the renamed key. The key must match [A-Za-z_][A-Za-z0-9_-]*, must not be type, must not be a YAML boolean or null word (true, false, null, ~, yes, no, on, off, y, n, in any case), and must not collide with another field’s effective key. Setting it on a field that is never stored in frontmatter (the id, the body, a skip field, a has_many view, or the legacy wikilinks and source_file fields) is a build error.

Both knobs live in one OkfOptions value on MarkdownIoOptions (the Pipeline form) or MarkdownIoConfig (the gen_markdown_io form). Both are off by default, and with both off no vault byte changes.

use ontogen::{MarkdownIoOptions, OkfOptions};
MarkdownIoOptions {
// vault_root, layout, list_cap ...
okf: OkfOptions {
index: true,
generated_by: Some(format!("my-app/{}", env!("CARGO_PKG_VERSION"))),
},
}
FieldDefaultEffect
indexfalseKeep a generated index.md in the vault root and every directory holding records.
generated_byNoneStamp generated: { by, at } on every real write, with this value as by.

gen_markdown_io also emits open_vault and VAULT_ROOT into the generated mod.rs of its output directory. They hold the vault’s settings from the build configuration, so the build script is the one place they are set. Consumers used to repeat the layout and list cap by hand in VaultHandle::new(...), and could drift from the build.

use my_app::persistence::markdown::generated::{VAULT_ROOT, open_vault};
let vault = open_vault(VAULT_ROOT); // tests pass a tempdir instead

VAULT_ROOT is the configured vault_root. A relative path resolves against the working directory the program runs from, not the crate root, so a binary started elsewhere needs an absolute path or a matching working directory. The caller decides the runtime path. open_vault(root) returns a markdown_store::VaultHandle with the layout, list cap and OKF options applied; it calls .with_okf(..) only when an OKF option is on.

The store emitter needs none of these settings, and the vault constructor is their one consumer, so open_vault is generated straight from the config rather than from MarkdownIoOutput.

A hand-built handle, in a test for example, sets the same runtime policy with one struct:

use markdown_store::OkfPolicy;
let vault = VaultHandle::new(root, layout).with_list_cap(list_cap)
.with_okf(OkfPolicy {
index: true,
generated_by: Some("my-app/0.1.0".into()),
clock: std::sync::Arc::new(|| fixed_time), // default: SystemTime::now
});

OkfPolicy::default() is index off, no stamp, SystemTime::now. okf() reads the policy back.

With okf.index on, the vault root and every directory that holds records, at any depth, carry a generated index.md (OKF §8). Each lists that directory’s direct records, grouped by type, and its direct subdirectories that contain records. The root index also declares okf_version: "0.2". The notes-kb example’s seed vault commits these two files:

---
okf_version: "0.2"
---
# Directories
* [notes](notes/)
# Note
* [Markdown as a store](markdown-as-store.md)
* [The vault is the database](the-vault-is-the-database.md)
* [Wikilinks are edges](wikilinks-are-edges.md)

Format rules, all byte-stable (LF, trailing newline):

  • One # <type> section per distinct string type, sorted by type name; records without one go last under # Untyped; # Directories comes after the record sections. Sections are separated by one blank line. # Untyped and # Directories belong to the store: a record type equal to either (or either followed by (type) suffixes) is headed with (type) appended, so no type takes those headings.
  • An entry is * [<title>](<file name>), plus - <description> when the record has a non-empty string description. The title is the frontmatter title, else the id. Whitespace in descriptions collapses to single spaces. In titles, id fallbacks, descriptions, type headings and directory link text only \ ` * _ [ ] < & # ~ $ % = ^ are backslash-escaped (CommonMark code, emphasis, brackets, HTML, autolinks, entities and closing #, plus Obsidian tags, ~~, $, %%, == and ^), so c_sweep appears as c\_sweep. Other punctuation (-, ., :, ', ?, (, )) is written as is, so raw files stay readable. Link URLs are percent-encoded. Entries sort by id. A record that cannot be read (non-UTF-8, say) or parsed is listed under Untyped, titled by its id, so a hand-broken file never blocks writes.
  • The keys read are the frontmatter keys title and description, not Rust field names.
  • Gitignore and hidden-file rules apply exactly as for listing.

Regeneration. After every real write (a create, an update that changed the record, a delete, or a side write such as a parent re-link) the store regenerates the index of that record’s directory and of each ancestor up to the root, under the vault write lock. An index is rewritten atomically and only if its bytes would change. A no-op update is not a real write and regenerates nothing. When a directory’s last record goes, the parent stops linking it.

Ownership. The store overwrites index.md in every directory that holds records, including a hand-written one. It removes an index.md from a directory with no records (in rebuild_indexes(), or when a write empties the directory) only when the file has exactly the store’s own shape (optional root frontmatter holding only okf_version, then # heading sections of * [text](link) entries, each with an optional - description, linking record files or dir/) and every link in it is dangling: the record file is missing, or dir/ is missing or holds no records. A store-shaped file whose links are all dangling is the store’s and goes. Any other index.md, and any hand-written one whose links still resolve even if store-shaped, is left alone.

Failure is non-fatal. The index refresh runs after the record is committed, and create, modify and remove return Ok once it is. If the refresh fails, that directory is marked stale. VaultHandle::stale_indexes() returns the stale index.md paths, sorted; the list lives in memory and is shared by clones of the handle. A later successful refresh of that directory, or a successful rebuild_indexes(), clears the mark. A crash between the record rename and the index rename is not recorded (the list is in memory): the index is stale but valid until the next real write there or a rebuild.

Repair. VaultHandle::rebuild_indexes() regenerates every index in the vault and removes the store’s own stale ones (see ownership above). It is how a seed vault gets its first indexes (turning the option on does not rewrite an existing vault by itself) and the repair path after hand edits or a stale mark. It works with the option off too, as an explicit request, and because it is explicit it returns Err on the first index it cannot write. Later writes maintain indexes only when the option is on.

Because the stale-index list is in memory and nothing generated reads it, call vault.rebuild_indexes() once at startup when okf.index is on: it repairs anything a crash or a hand edit left stale. The notes-kb example does this in its main.rs.

Cost. A write parses the records directly in each directory on the path to the root, so it is O(N) per directory, in line with the backend’s small-N stance. Under MarkdownLayout::Flat that is every record in the vault. Subdirectory probes stop at the first record. The index walk is not subject to list_cap.

With okf.generated_by set, every real write stamps the record with the OKF §5.2 generated key:

---
type: Note
generated:
by: notes-kb/0.1.0
at: 2026-10-03T14:05:09Z
---
  • at is an ISO 8601 UTC instant with second precision and a Z suffix.
  • Every create stamps. An update stamps only if the record is still changed after the mutation and type stamping, so a no-op update stays a no-op: the file’s bytes and mtime are untouched.
  • An existing generated value is replaced in place; otherwise the key is appended. type stays the first key.
  • All write paths share one hook, so the rules cannot differ between the typed and untyped APIs.
  • The clock defaults to SystemTime::now and is injectable through OkfPolicy::clock (set with VaultHandle::with_okf) for deterministic tests.
  • Records that already exist keep no stamp until their next real write.

The actor. generated_by is validated at build time against OKF §7. It must name a program: <producer>/<version> (both sides non-empty) or process:<id>, with no whitespace or control characters. human:<id> is rejected, whatever its case: the writer here is a program, and OKF trust tiers read human: as a person, so a program must not claim it. The error cites §7 and the spec URL.

The generated key. While generated_by is set, a schema field whose effective frontmatter key is generated is a build error, since the generator owns the key. Store the field under another key with #[ontology(frontmatter_name = "...")], or unset the option. With the option off, the warning in reserved frontmatter keys applies instead.

Under MarkdownLayout::Flat, remove of a record whose frontmatter does not parse fails: the store cannot tell which entity owns the file, and an unparseable file may belong to another entity. Fix the frontmatter by hand or delete the file directly. Under PerEntityDir the directory identifies the owner, so the store can delete it.

The entire delta from a SeaORM consumer:

pub struct Store {
vault: markdown_store::VaultHandle, // was: db: Arc<DatabaseConnection>
change_tx: broadcast::Sender<EntityChange>,
}
impl Store {
pub fn vault(&self) -> &markdown_store::VaultHandle { &self.vault } // was: db()
// emit_change()/subscribe(): identical. No sync_junction/load_junction_ids —
// many-to-many lives in frontmatter.
}

…and one error variant:

pub enum AppError {
TaskNotFound(String), // per-entity NotFound: same as SeaORM
TaskIdRequired(String), // per-entity, same as SeaORM
TaskAlreadyExists(String), // per-entity, same as SeaORM
Md(String), // replaces DbError
}
impl From<markdown_store::Error> for AppError { /* Md(e.to_string()) */ }

Construct the vault at startup with the generated open_vault, which applies the settings from your build script:

use my_app::persistence::markdown::generated::{VAULT_ROOT, open_vault};
let state = AppState::new(open_vault(VAULT_ROOT));
  • Relations are wikilinks. A belongs_to is epic_id: '[[E0042]]' in frontmatter; a many_to_many is a wikilink list on the owning record (the authoritative side — no junction tables). has_many is never stored: it’s a derived view, answered by walking the child folder and filtering on the foreign key. Generated code strips brackets at its typed boundary; your JSON API never sees them. (That stripping is a policy, not a hardcoded behaviour — the markdown backend just defaults to it.)
  • Creates can derive ids. POST without an id (Create DTOs carry #[serde(default)] on id) and the store’s id strategy fills it: SlugFromField slugifies the configured field, de-duplicating with -2, -3, … — atomically with the write. The strategy is a store setting (Pipeline::store_id_strategy, required, with per-entity overrides in the schema) shared with the SeaORM backend; Creating records has the full rules, including IdRequired and AlreadyExists.
  • An id that cannot exist is NotFound. get, update and delete of an id the vault cannot hold (one containing /, a reserved name, one over 252 bytes, and so on) answer {Entity}NotFound, as they do for an id that is merely absent.
  • Hand-named files stay reachable. Creates follow the id rule (lowercase [a-z0-9._~-], at most 200 bytes), but lookups check only that the id is a safe file stem, so a hand-written Draft.md or café.md is listed, read, updated and deleted under its stem.
  • Lookups match the stored name exactly, on every filesystem. macOS and Windows resolve KEPT.md to a stored kept.md (and macOS resolves either Unicode normalization form of café.md to the other). The store does not: get, update and delete of KEPT answer {Entity}NotFound and change nothing, and a relation id spelled Markdown-Backend does not resolve to markdown-backend.md. A create counts an id as taken when its file name resolves to an existing file under any spelling, since writing it would replace that file: on macOS, creating draft beside a hand-written Draft.md is {Entity}AlreadyExists, and a slug probes on to draft-2. The handle learns once, with a hidden probe file in the vault, whether the filesystem aliases names; where it does, each lookup also lists the record’s directory.
  • Hand edits survive. The runtime’s Document round-trip preserves unknown keys, key order, and the body; untouched files re-render byte-for-byte, and a no-op update doesn’t even touch the file.
  • Stable order, loud ceiling. list returns records in the order asked for, then in id byte order (a-2 before a2, and a hand-named B before a, whatever the layout or filesystem); with no order, in id byte order. It reads every record, sorts with the generated sort_{plural}, then cuts the page (see Ordering). It errors past list_cap — the deliberate “wrong backend for this N” signal.
  • Single-record atomicity only (same-dir tempfile + fsync + rename). Need batch transactions? That’s the other backend.

Wikilink stripping is a StoreConfig field, not a property of the markdown backend. Each backend supplies a default — markdown strips, SQL passes through — and wikilink_policy: None picks it up:

pub enum WikilinkPolicy {
Strip, // [[id]] -> id on every relation field
Passthrough, // relation ids pass through untouched
}

Almost everyone wants the default. Set it explicitly for the hybrid case: a SQL-backed store whose wire contract still accepts wikilinked ids — an API fed by markdown-authoring agents, or one migrating off a vault while keeping its callers working.

StoreConfig {
// ...
backend: Backend::Seaorm(Some(seaorm)),
wikilink_policy: Some(WikilinkPolicy::Strip),
}

The generated DTO From impls will then accept "[[task-42]]" and store "task-42".

Three examples, smallest to richest:

  • examples/iron-log-md — iron-log’s exact schema on markdown; diff -r its generated api/v1 against iron-log’s to watch the byte-identical invariant hold.
  • examples/tasks-tracker — a planning vault (this repo’s own docs/planning shape) over HTTP and the generated MCP tool registry.
  • examples/notes-kb — wikilinked notes rendered as a graph.

The CI-enforced reference consumer is crates/markdown-pilot: every workspace test run compiles and executes the generated markdown store.