Skip to Content
文档📐 ADR 决策记录ADR-0013: Vault Schema Distribution from Manifest, not .scm

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:

  1. ADR-0006 scope (runtime-loadable ontology over Python-registered logic) declares that business behaviour — state, guards, effects, cascades — must be .scm so 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 like runex ontology manifest itself (a Python method in facade.py:298).

  2. 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. A vault sync run ends when the writes finish; it has no on_commit, no cascade, no work item. Same tier as runex skill install (copies skill/ to ~/.claude/).

  3. AGENT.md §11.2 (“logic in .scm, not Python”) constrains business behaviour (machines/actions), not tooling. runex ontology manifest, runex schema, runex doctor are 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:

  1. creates a RegistryBinding node with 绑定键="default" and 来源路径=<RUNEX_HOME>/runex-vault
  2. 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 userrunex init~/.runex/runex-vault/ ready to open in Obsidian; later, the user may runex vault bind <key> <otherPath> to distribute the same manifest into their own vault shape.
  • Existing-vault userrunex skill vault-adopt orchestrates survey → new go-forward vault + IngestJob; runex init’s default runex-vault remains as a stable reference / scratch vault / fast-feedback sandbox for the user’s own experiments. The adopt flow additionally creates a second RegistryBinding pointing 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 .scm moves to ~/.runex/ontology/. runex init seeds it from the wheel; afterwards the on-disk files in ~/.runex/ontology/ are the truth for this machine. runex ontology load/check reads 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 install continues 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 later

The 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):

ArtefactPathPurpose
Template<template_dir>/tpl.<Type>.mdObsidian 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>.baseObsidian 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: true field → empty value present in template
  • field type → YAML ahead-of-time form (text"", boolfalse, date&#123;&#123;date:YYYY-MM-DD&#125;&#125;, ref → empty [] or "")
  • options_supertag present → [[OtherType]] placeholder
  • projection.ontology_owned → field stays defaulted (engine controls it)
  • projection.human_owned → field stays user-fillable
  • extends chain (when include_inherited = true) → parent fields appended under a # inherited from <Parent> comment block

Layouts

Three built-in layout modes (per RegistryBinding.layout.mode):

  1. flat — all notes live in a single root directory; supertag distinguished by type: frontmatter. Base files give the per-type table views. This is the simplest and most flexible (most newcomers). All templates live together too.
  2. per-supertag — every supertag gets its own sub-directory; templates and bases live in shared template_dir / base_dir. Mirrors today’s HH-Mem convention.
  3. custom — explicit supertag → relative directory map in RegistryBinding.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 emit
  • drift-schema — manifest hash differs from runex ontology manifest → schema changed since last sync; sync --apply regenerates
  • drift-user-edited — file content differs from recorded emit hash → user manually edited a registry-owned file; sync --apply refuses without --force; --force overwrites with a one-line backup note written into <vault>/.runex/registry-backups/<flatname>.bak
  • orphan — file in registry-owned directory not registered to any enabled supertag of this binding → vault prune --apply deletes

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.yaml and the ontology manifest collapses; one edit site (.scm business/field declarations plus a small RegistryBinding for layout per vault).
  • Adding Feishu/Notion distribution later is a new renderer file under registry_renderers/, not a new engine seam.
  • runex skill install and runex vault sync are 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 init now ends with a usable surface: the user opens ~/.runex/runex-vault in 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 second vault bind ceremony 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 .scm moves 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 .scm only 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 vault and 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.
  • RegistryBinding is a new supertag. The contract stays minimal (no machine, no actions) and the load pack name packs/registry.scm mirrors the existing packs/ 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, and runex vault prune default or 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 install continues 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 init now not only loads ontology but also creates a RegistryBinding and runs vault sync default --apply. Failure halfway (e.g. disk full mid-sync) must leave the system in a recoverable state: idempotent re-init continues from where it left off, not from scratch. Tested in US-010.
  • The Feishu/Notion future work is not design-validated yet; the ArtifactSpec.target shape 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 scan work)
  • new supertag schemas (the schema is whatever .scm declares; 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

  1. Should RegistryBinding.layout.dir_map accept a templated directory like 42_Project/*/Sessions/*/ directly, or should that be expressed as subdirectory_pattern per 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.
  2. Whether to include 62_Type/<Type>.md type notes at all. Default include_type_notes = false; revisit after M2 dogfooding.
  3. The shape of last_synced_manifest_hash on RegistryBinding when multiple bindings share a manifest — safe because hash is content-only and the same manifest naturally hashes identically everywhere.
  4. Whether to upstream a “custom skill discovery path” PR to opencode / claude-code / codex so ~/.runex/skill/ could become a real agent discovery source. Today runex skill install correctly copy-stuffs to each platform’s hardcoded path; ~/.runex/skill/ is a human- readable mirror only. Defer until there is concrete demand.
  5. Whether runex init should 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 how data.db is created. A runex init --refresh-ontology escape 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.
Last updated on