ADR-0013: Vault Schema Distribution from Manifest, not .scm
- Status: Proposed
- Date: 2026-06-29
Context
ADR-0011 establishes Obsidian as the human-sovereign mirror of ontology state.
The ontology manifest is authoritative for schema; the graph is authoritative
for state; Obsidian is the authoritative editing surface for human-owned
fields. Sync uses ingest-in (Connector + Recipe + IngestJob) and guarded
projection out (file-hash guard → writeback or Conflict).
One seam in ADR-0011 is not closed: schema distribution. PRD §6.6 says:
“manifest 可生成 Obsidian template/frontmatter schema. Agent 不裸写 YAML;Channel/Skill 根据当前 schema 生成.”
This is not built. Today, an agent creating a note in an Obsidian vault has no machine-readable contract for the frontmatter shape of a given supertag. The human hand-maintains templates; the agent guesses; drift is inevitable.
A second precedent makes the seam harder, not easier: the user runs a
hand-rolled scripts/registry/ on vault HH-Mem that distributes tpl.<Type>.md
templates, 62_Type/<Type>.md type notes, and .base files to fixed
directories. It works but its schema SoT is a hand-maintained types.yaml,
separate from the ontology manifest — exactly the dual-SoT anti-pattern
ADR-0011 forbids. Generalising it into runex means making the manifest the
SoT, and never replicating the dual-maintenance strategy.
Why this is also a future-proofing decision
The user’s longer-term plan is that the next two platforms after Obsidian are Feishu Bitable and Notion. They share the same conceptual shape as Obsidian for our purposes:
- they are front-ends: humans view and edit through them
- they have type-ish layout primitives Feishu “多维表格 / 字段”, Notion “database / property”), but no real contract enforcement
- they are out-of-process targets reached through HTTP APIs
Anything we design for the Obsidian seam now must not be retrofitted when Feishu/Notion arrive. The abstraction is “platform as a register-rendered view of the manifest”, not “Obsidian as a special case”.
Why .scm is the wrong home for this
The first draft proposed a (registry ...) sub-clause inside supertag
declarions, with filename-pattern, tidy-rules, base-view order, etc.
It does not belong there. Three ADR-level arguments:
-
ADR-0006 scope (
runtime-loadable ontology over Python-registered logic) declares that business behaviour — state, guards, effects, cascades — must be.scmso the reactive bus can advance it and manifest can reflect it. Template distribution has no state, no guard, no effect, no trigger from events. It is a pure read of structured ontology state turned into a side artefact, exactly likerunex ontology manifestitself (a Python method infacade.py:298). -
ADR-0010 spirit (
durable agent work outside the reactive bus) — one short, stateless, side-effectful operation triggered by a human should not occupy the synchronous bus. Avault syncrun ends when the writes finish; it has noon_commit, no cascade, no work item. Same tier asrunex skill install(copiesskill/to~/.claude/). -
AGENT.md §11.2 (“logic in .scm, not Python”) constrains business behaviour (machines/actions), not tooling.
runex ontology manifest,runex schema,runex doctorare Python today and correctly so. A manifest-driven renderer sits in the same category — a CLI view over ontology state, not an action that the engine commits.
The cost of putting layout in .scm is concrete: every time a vault layout
changes (“HH-Mem uses 60_Template, RunexVaultTest uses Templates”), the
entire ontology file changes — and anyone who has a hand-curated .scm
extension pack has to reload. Layout is per-vault binding, not per-ontology
contract. It belongs in the layer of IngestJob, which is also per-vault
binding and also graph state, not .scm (ADR-0011 §“Ingest Boundary”).
Why we still write a small .scm pack
We need one new supertag — RegistryBinding — to durably record
per-vault distribution parameters (target platform, layout, enabled
supertag list). This mirrors IngestJob exactly: it is ordinary graph
state with no special table, no machine that the bus needs to advance
through a state transition (initial = active; optional paused is a
flag, not a guarded transition). It is data about the platform
relationship, persisted as data, exactly as IngestJob persists the
ingest relationship.
A RegistryBinding is not an external adapter — it is the binding
the renderer reads. The renderer itself is Python (next section).
Decision
runex adds a new L4 CLI tool group runex vault and a new L2 adapter
module that renders manifest data to platform-specific artefacts. There is
no new kernel, no new reactive-bus action, no new state machine.
Opinionated default binding (runex-vault)
A core onboarding principle: runex init ends with a usable surface, not
a question. After runex init, the user is not asked “where do you want
templates to go?” — runex has already placed them in ~/.runex/runex-vault/,
a vault the user can immediately open in Obsidian and see type-safe templates
plus database-like Base views for every supertag the ontology declares.
Concretely, runex init performs two extra steps at the end of its existing
flow:
- creates a
RegistryBindingnode with绑定键="default"and来源路径=<RUNEX_HOME>/runex-vault - runs
runex vault sync default --apply— writes templates/bases into~/.runex/runex-vault/60_Template/,…/61_Base/, plus the sidecar index
Name choice — runex-vault (not “vault-mirror”): this directory’s name is
what Obsidian shows in its vault switcher. “runex-vault” reads as a vault
called “runex” the user has every right to open, browse, and add notes to.
The earlier working name “vault-mirror” described the mechanism, not the
artefact the user sees — naming it after what it is in Obsidian is the
right contract.
This default binding is always created. Two onboarding paths reduce to the same end-state:
- Fresh user —
runex init→~/.runex/runex-vault/ready to open in Obsidian; later, the user mayrunex vault bind <key> <otherPath>to distribute the same manifest into their own vault shape. - Existing-vault user —
runex skill vault-adoptorchestrates survey → new go-forward vault +IngestJob; runex init’s defaultrunex-vaultremains as a stable reference / scratch vault / fast-feedback sandbox for the user’s own experiments. The adopt flow additionally creates a secondRegistryBindingpointing at the adopted vault so templates now live there too.
The ~/.runex/ layout is therefore:
~/.runex/
├── data/
│ ├── data.db ← SQLite graph store (existing)
│ ├── blobs/ ← blob store (existing)
│ └── worker-host-cursor.json ← existing
│
├── ontology/ ← NEW: live .scm single source of truth
│ ├── core-memory.scm ← copied from wheel on `init`;
│ │ user edits here, `runex ontology
│ │ load` picks it up; wheel contents
│ │ only seed first install
│ ├── ingest.scm
│ ├── agent.scm
│ └── packs/
│ ├── pkm.scm
│ ├── time.scm
│ ├── wiki-links.scm
│ └── registry.scm ← NEW this scope
│
├── skill/ ← NEW: human-readable mirror of the
│ ├── SKILL.md bundled skill tree (not for agent
│ ├── capture/ agenda/ distill/ ... discovery — `runex skill install`
│ ├── vault/SKILL.md continues to copy to each platform's
│ └── references/ hardcoded discovery path)
│
├── runex-vault/ ← NEW: default opinionated binding
│ ├── 60_Template/tpl.<Type>.md target; Obsidian vault name visible
│ ├── 61_Base/<Type>.base to the user is literally "runex-vault"
│ ├── 62_Type/<Type>.md (per-supertag mode, conventional
│ └── .runex/registry-index.json directory names — defaults only,
│ editable via the binding node)
│
├── extensions/ ← existing
│ ├── agents/
│ └── sources/
│
└── config/ ← NEW (lightweight): only for
└── vault-bindings.json binding-key → binding-node-natural-key
lookup when the user manages many
vaults; data-of-record stays in the
graph (the IngestJob pattern)Two implications worth flagging:
- Live
.scmmoves to~/.runex/ontology/.runex initseeds it from the wheel; afterwards the on-disk files in~/.runex/ontology/are the truth for this machine.runex ontology load/checkreads there first, falls back to bundled. This makes “I forked an ontology experiment” a local edit, not a fork + reinstall. ~/.runex/skill/is a human-readable mirror, not an agent-discovery path. Agent discovery paths are platform-hardcoded (~/.claude/skills/,~/.config/opencode/skills/, …).runex skill installcontinues to copy to those paths unchanged. The new~/.runex/skill/copy exists so the user can open the whole~/.runex/in a file browser / Obsidian and have a single place to read every contract the system honours. A future opencode/claude PR to support custom skill discovery paths would let~/.runex/skill/become a real discovery source — deferred, not designed against.
Architectural placement
┌──────────────────────────┐
.scm (unchanged) ── load ──→ │ Ontology Engine │
│ (L3 reactive bus) │
└────────────┬─────────────┘
│ read-only view
▼
┌──────────────────────────┐
│ Ontology.manifest() │
│ (Python, L4 facade) │ ← unchanged
│ JSON: supertags with │
│ fields + projection + │
│ ownership + extends │
└────────────┬─────────────┘
│
┌────────────────────┴──────────────┐
│ │
RegistryBinding (new supertag, graph node) │
- platform (obsidian | feishu | notion | …) │
- vault_source (path / bitable_id / database_id) │
- layout JSON (flat | per-supertag | custom) │
- enabled_supertags (whitelist) │
- renderer_options (template dir names, etc.) │
(per-vault, ordinary node — same pattern as IngestJob)│
│ │
▼ │
src/runex/adapters/registry_renderers/ │
├─ __init__.py exports RegistryRenderer Protocol │
├─ obsidian.py ← manifest + RegistryBinding │
│ → list[ArtifactSpec] │
├─ (future) feishu.py │
└─ (future) notion.py │
│ │
▼ │
runex vault sync <bindingKey> [--check|--apply] │
runex vault status [--vault KEY] │
runex vault prune <bindingKey> │
(L4 CLI; one-shot; not on reactive bus) │
│ │
▼ │
Obsidian vault / Feishu bitable / Notion database │
+ sidecar `.runex/registry-index.json` │
(per-platform renderer writes what it needs) │The Renderer Protocol is platform-agnostic:
class RegistryRenderer(Protocol):
platform: str # "obsidian" | "feishu" | "notion" | ...
def render(self, manifest: ManifestJSON,
binding: RegistryBinding) -> list[ArtifactSpec]: ...
def apply(self, artefacts: list[ArtifactSpec],
binding: RegistryBinding,
*, dry_run: bool, prune: bool) -> ApplyReport: ...ArtifactSpec is the decoupling layer between “what the manifest says a
supertag looks like” and “how a specific platform represents that”:
@dataclass(frozen=True)
class ArtifactSpec:
kind: str # "template" | "base" | "type_note" | "bitable_field" | ...
target: str # platform-relative: "60_Template/tpl.Thought.md"
# or "feishu:bitable/<id>/fields/<field_key>"
supertag: str
content: bytes
manifest_hash: str # source manifest checksum, written into the artefact's
# own header / metadata so drift is detectable laterThe Obsidian renderer interprets target as a vault-relative file path and
writes bytes. A future Feishu renderer interprets target as a bitable field
URL and PUTs JSON. The protocol — and vault sync — never change shape when
a new platform is added.
What is distributed
For the Obsidian renderer (this ADR scope):
| Artefact | Path | Purpose |
|---|---|---|
| Template | <template_dir>/tpl.<Type>.md | Obsidian Templates plugin inserts it → empty note with correct frontmatter, including type: wikilink and created: template dates. Agent uses the same shape, never hand-rolls YAML. |
| Base | <base_dir>/<Type>.base | Obsidian Bases plugin treats a directory as a table view of one supertag; columns = the supertag’s manifest fields. Lets humans browse notes by type without writing queries. |
Optional (per RegistryBinding.renderer_options.include_type_notes):
a human-readable 62_Type/<Type>.md reference note. Not core; default off.
Field rendering rules (all driven from manifest.supertags[*].schema.fields):
- every
required: truefield → empty value present in template - field
type→ YAML ahead-of-time form (text→"",bool→false,date→{{date:YYYY-MM-DD}},ref→ empty[]or"") options_supertagpresent →[[OtherType]]placeholderprojection.ontology_owned→ field stays defaulted (engine controls it)projection.human_owned→ field stays user-fillableextendschain (wheninclude_inherited = true) → parent fields appended under a# inherited from <Parent>comment block
Layouts
Three built-in layout modes (per RegistryBinding.layout.mode):
flat— all notes live in a single root directory; supertag distinguished bytype:frontmatter. Base files give the per-type table views. This is the simplest and most flexible (most newcomers). All templates live together too.per-supertag— every supertag gets its own sub-directory; templates and bases live in sharedtemplate_dir/base_dir. Mirrors today’s HH-Mem convention.custom— explicit supertag → relative directory map inRegistryBinding.layout.dir_map. Supports shared directories, nested directories (42_Project/*/Sessions/*/), and “off” (omit a supertag from distribution).
Layout values are never hard-coded in the Python renderer. The Obsidian
renderer reads binding.layout and only defaults at NEW binding creation time
(the user can edit later). Default for a fresh Obsidian binding is
per-supertag with conventional directory names (60_Template, 61_Base,
plus a per-supertag folder per manifest supertag) — but it is a default, not
a constant.
Multi-platform and the “two-axis” story
The thinking that motivates this design is that Obsidian today occupies two axes of platform integration, and so will each future platform:
-
Axis A — schema distribution (this ADR): render the manifest to platform artefacts so humans/agents on that platform can create type-safe notes by viewing-as-database and inserting-as-template. Pure read of manifest; pure one-shot CLI; outside reactive bus.
-
Axis B — bidirectional mirror (ADR-0011 for Obsidian; future ADR for Feishu): ingest platform edits → graph; project graph → platform. Uses Connector + Recipe + IngestJob; uses reactive bus; uses
_sync_file_hash/ Conflict / projection guards. PRD §6.7 (Feishu Receiver/Sender) is explicitly late — Channel, not a new truth store.
A RegistryBinding and an IngestJob can both point at the same vault
(and link to each other); they are independent responsibilities on the
same surface. This ADR only covers axis A. Axis B for non-Obsidian platforms
remains a future ADR.
Drift protection and orphan tracking
The HH-Mem registry had no drift protection — generators.write_all is
unconditional path.write_text(), and 60_Template/ accumulates orphaned
templates for supertags long since removed. We will not repeat this. Every
artefact carries manifest_hash in its YAML header / sidecar metadata;
vault status classifies each file as:
in-sync— manifest hash equal to last shipped, file unchanged since emitdrift-schema— manifest hash differs fromrunex ontology manifest→ schema changed since lastsync;sync --applyregeneratesdrift-user-edited— file content differs from recorded emit hash → user manually edited a registry-owned file;sync --applyrefuses without--force;--forceoverwrites with a one-line backup note written into<vault>/.runex/registry-backups/<flatname>.bakorphan— file in registry-owned directory not registered to any enabled supertag of this binding →vault prune --applydeletes
A sidecar <vault>/.runex/registry-index.json records the emit hashes per
file; it is the renderer’s commit log, not runex’s. Why a sidecar over a
node: the manifest hash is platform-local state, and platform-local
state is the renderer’s responsibility. The RegistryBinding node itself
records only the binding parameters and a single last_synced_manifest_hash
field (for status quick-check without scanning files).
Legacy migration is NOT this ADR’s concern
A separate concern — “existing user notes need their frontmatter updated
when a supertag adds a required field” — is referenced in PRD §6.6 as a
MigrationPlan / BusinessRule + scheduled scan flow. That stays in
ADR-0010’s territory: it is reactive-bus work, with guarded actions in
.scm. Templates answer “how do new notes look correctly”; migrations
answer “how do existing notes become correctly shaped”; only the latter
involves graph mutations and therefore belongs in the bus.
ADR-0013 is only about axis A — schema distribution.
Consequences
Positive:
- The dual-maintenance regime between HH-Mem
types.yamland the ontology manifest collapses; one edit site (.scmbusiness/field declarations plus a smallRegistryBindingfor layout per vault). - Adding Feishu/Notion distribution later is a new renderer file under
registry_renderers/, not a new engine seam. runex skill installandrunex vault syncare now conceptually paired: one ships the agent contract to agent platforms, the other ships the type contract to note platforms. Both are L4 CLI tools shipping artefacts derived from the manifest.- A new agent using a skill that creates Obsidian notes can read the template body and emit compliant frontmatter, removing the “LLM guesses YAML” failure mode end-to-end.
- Onboarding collapses to one command.
runex initnow ends with a usable surface: the user opens~/.runex/runex-vaultin Obsidian and immediately sees type-safe templates and Base views for every supertag the ontology declares. No “where do you want templates to go?” prompt, no secondvault bindceremony before first use. New-user and existing-vault paths converge to the same end-state (existing-vault users additionally get a second binding into their own vault). - Live
.scmmoves on-disk to~/.runex/ontology/. Editing an ontology experiment becomes a local file edit +runex ontology load, not a fork + reinstall. The wheel’s bundled.scmonly seeds the first install; truth-of-record for this machine’s active ontology is in~/.runex/ontology/. ~/.runex/skill/gives humans one readable root. Opening~/.runex/in a file browser shows the engine, the active ontology, the skill contracts, and the distributed vault side-by-side — one place to read every contract the system honours.
Negative / accepted costs:
- Introduces a new top-level command group
runex vaultand a new adapter module. The surface grows. This is the cost of treating platforms as first-class projection surfaces. - Drift protection requires a sidecar per vault. New file format. We accept it because the property (registry-owned file should not be silently clobbered) is what distinguishes this from the dual-SoT world it replaces.
RegistryBindingis a new supertag. The contract stays minimal (no machine, no actions) and the load pack namepacks/registry.scmmirrors the existingpacks/style.~/.runex/runex-vault/is created even for users who will never open Obsidian. The opinionated default means a few hundred KB of generated files exist on every install. We accept the cost because the alternative (interactive prompts) is worse for the agent-driven usage model runex targets, andrunex vault prune defaultor simply ignoring the directory are both valid.~/.runex/skill/is a mirror, not an agent-discovery path. Agent discovery paths are platform-hardcoded (~/.claude/skills/,~/.codex/skills/,~/.config/opencode/skills/,~/.agents/skills/);runex skill installcontinues to copy to those paths unchanged. A future opencode / claude-code PR to accept a custom skill discovery path would let~/.runex/skill/become a real discovery source; deferred, not designed against.- Two-step init introduces ordering hazards.
runex initnow not only loads ontology but also creates aRegistryBindingand runsvault sync default --apply. Failure halfway (e.g. disk full mid-sync) must leave the system in a recoverable state: idempotent re-initcontinues from where it left off, not from scratch. Tested in US-010. - The Feishu/Notion future work is not design-validated yet; the
ArtifactSpec.targetshape assumes file paths can generalise to URLs. The Obsidian-only scope makes this a bet, not a commitment — the Renderer Protocol can evolve when the second platform actually arrives.
Non-goals
This ADR does not decide:
- the Feishu / Notion ingest protocols (axis B for those platforms; future ADR per PRD §6.7)
- legacy-file schema migration (covered by ADR-0010 + PRD §6.6 +
ontology scanwork) - new supertag schemas (the schema is whatever
.scmdeclares; the renderer reads it, never owns it) - a UI for browsing/managing bindings (CLI is sufficient; GUI per roadmap.md §“operability/backlog” is later)
Open questions
- Should
RegistryBinding.layout.dir_mapaccept a templated directory like42_Project/*/Sessions/*/directly, or should that be expressed assubdirectory_patternper supertag? Resolving in M1 — preference is for templates in the layout map for now, since the supertag’s(projection ... path-field)already carries the per-node path. - Whether to include
62_Type/<Type>.mdtype notes at all. Defaultinclude_type_notes = false; revisit after M2 dogfooding. - The shape of
last_synced_manifest_hashonRegistryBindingwhen multiple bindings share a manifest — safe because hash is content-only and the same manifest naturally hashes identically everywhere. - Whether to upstream a “custom skill discovery path” PR to opencode /
claude-code / codex so
~/.runex/skill/could become a real agent discovery source. Todayrunex skill installcorrectly copy-stuffs to each platform’s hardcoded path;~/.runex/skill/is a human- readable mirror only. Defer until there is concrete demand. - Whether
runex initshould seed~/.runex/ontology/from the wheel unconditionally on every run, or only when the directory is empty (preserving user hand-edits). M1 implements “seed if missing”, matching howdata.dbis created. Arunex init --refresh-ontologyescape hatch re-seeds unconditionally; decided in US-010.
Relationships
- Builds on ADR-0008 (Connector/Recipe/IngestJob split) — applies the same “binding as graph state, not config table” pattern, this time for distribution rather than ingest.
- Builds on ADR-0010 (durable work outside reactive bus) — the renderer is intentionally not on the bus.
- Realises a slice of PRD §6.6 / §6.7 — manifest → artefact, with the future second axis (Feishu Receiver/Sender) explicitly deferred.
- Does NOT extend ADR-0011’s projection writeback guard — distribution writes registry-owned files, not projected node files. The two do not touch each other on disk.