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.
When to pick which backend
Section titled “When to pick which backend”| SeaORM | Markdown | |
|---|---|---|
| Sweet spot | transactional, high-write, large-N | small-N, human-editable, read-heavy |
| Records | rows | vault/<entity>/<id>.md |
| Co-editing | via the app | any editor, Obsidian, sed, an agent |
| List cost | indexed SQL | parse-the-folder, capped (default 10k) |
| Multi-record atomicity | transactions | none — single-record only |
| Ids | your call | String, always (the id is the filename) |
Knowledge bases, planning trackers, configuration vaults: markdown. Anything that needs joins at scale or batch transactions: SeaORM.
Wiring it
Section titled “Wiring it”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 OKF vault
Section titled “An OKF vault”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.
Every record carries a type
Section titled “Every record carries a type”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: Tasktitle: Ship the emitterstatus: closed/done---- A record with no
typestill 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::PerEntityDirthe directory decides what a record is. A record whosetypedisagrees is tolerated on read and normalized on its next real write, socountstays a path walk. - With
MarkdownLayout::Flat, entities share the vault root, solist,countandgetfilter bytype: a record whosetypediffers from the entity’s is excluded (andgetreturns NotFound). Records with notypeare included.
Reserved ids
Section titled “Reserved ids”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.
Reserved frontmatter keys
Section titled “Reserved frontmatter keys”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.
typeis a build error. The generator owns it; the frontmatter struct does not model it, so reads tolerate it being absent or different.statuswarns unless the field is an enum whose variants are all amongdraft,stable,deprecated.resourceandstale_afterwarn unless the field isStringorOption<String>.generatedandusage_windowwarn unless the field is a struct. Whileokf.generated_byis set, a field stored undergeneratedis 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.verifiedwarns unless the field is a struct or aVecof structs (OKF allows one verification or a list).sourceswarns unless the field is aVecof structs.title,descriptionandtagsare 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.
Turning on the OKF knobs
Section titled “Turning on the OKF knobs”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"))), },}| Field | Default | Effect |
|---|---|---|
index | false | Keep a generated index.md in the vault root and every directory holding records. |
generated_by | None | Stamp generated: { by, at } on every real write, with this value as by. |
The generated open_vault
Section titled “The generated open_vault”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 insteadVAULT_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.
Index files
Section titled “Index files”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 stringtype, sorted by type name; records without one go last under# Untyped;# Directoriescomes after the record sections. Sections are separated by one blank line.# Untypedand# Directoriesbelong 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 stringdescription. The title is the frontmattertitle, 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^), soc_sweepappears asc\_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
titleanddescription, 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.
Generated stamps
Section titled “Generated stamps”With okf.generated_by set, every real write stamps the record with the OKF
§5.2 generated key:
---type: Notegenerated: by: notes-kb/0.1.0 at: 2026-10-03T14:05:09Z---atis an ISO 8601 UTC instant with second precision and aZsuffix.- Every create stamps. An update stamps only if the record is still changed
after the mutation and
typestamping, so a no-op update stays a no-op: the file’s bytes and mtime are untouched. - An existing
generatedvalue is replaced in place; otherwise the key is appended.typestays 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::nowand is injectable throughOkfPolicy::clock(set withVaultHandle::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.
Deleting a record with broken frontmatter
Section titled “Deleting a record with broken frontmatter”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.
What the consumer writes by hand
Section titled “What the consumer writes by hand”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));Semantics worth knowing
Section titled “Semantics worth knowing”- Relations are wikilinks. A
belongs_toisepic_id: '[[E0042]]'in frontmatter; amany_to_manyis a wikilink list on the owning record (the authoritative side — no junction tables).has_manyis 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)]onid) and the store’s id strategy fills it:SlugFromFieldslugifies 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, includingIdRequiredandAlreadyExists. - An id that cannot exist is NotFound.
get,updateanddeleteof 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-writtenDraft.mdorcafé.mdis listed, read, updated and deleted under its stem. - Lookups match the stored name exactly, on every filesystem. macOS and
Windows resolve
KEPT.mdto a storedkept.md(and macOS resolves either Unicode normalization form ofcafé.mdto the other). The store does not:get,updateanddeleteofKEPTanswer{Entity}NotFoundand change nothing, and a relation id spelledMarkdown-Backenddoes not resolve tomarkdown-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, creatingdraftbeside a hand-writtenDraft.mdis{Entity}AlreadyExists, and a slug probes on todraft-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
Documentround-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.
listreturns records in the order asked for, then in id byte order (a-2beforea2, and a hand-namedBbeforea, whatever the layout or filesystem); with no order, in id byte order. It reads every record, sorts with the generatedsort_{plural}, then cuts the page (see Ordering). It errors pastlist_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.
Wikilinks on a SQL-backed store
Section titled “Wikilinks on a SQL-backed store”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".
See it running
Section titled “See it running”Three examples, smallest to richest:
examples/iron-log-md— iron-log’s exact schema on markdown;diff -rits generatedapi/v1against 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.