Date: 2026-05-20
Status: Architecture A shipped as of 2026-05-28. Landed across vault#380 (admin SPA git-mirror UI + manual-trigger endpoint), vault#382 (event-driven exports via HookRegistry + deletion handling + sync_mode schema reframe), and vault#383 (0.4.9-rc.5 release bump). The polling-shape v0.7-bootstrap from vault#346 has retired in favor of hook-driven exports. Bidirectional sync (Architecture B) and vault-as-thick-UI-over-git (Architecture C) remain deferred; this doc is preserved as the design record. Propagation tracked at parachute-patterns/migrations/2026-05-28-mirror-event-driven.md.
Companions:
2026-05-20-vault-as-git-canonical-thought-experiment.md — focused exploration of Architecture C as a thought experiment (not a commitment)2026-05-18-v06-deploy-architecture.md — single-container deploy shape (the substrate this lives inside)2026-05-20-multi-user-phase-1.md — multi-user foundation (each user pinned to a vault; mirror config is per-vault)2026-04-20-module-architecture.md — module + scope shapeparachute-patterns/cookbook/vault-portable-export.md — the lossless export this builds onparachute-patterns/patterns/trust-gradient-isolation.md — the owner-operated framing this assumesv0.7+ moves vault toward being architecturally aware of its git projection. Vault remains the runtime (live SQLite + REST + MCP); a configured git mirror becomes the canonical at-rest store, the audit trail, and the portability surface. Like git's working tree vs .git store — inverted. Vault is the working tree clients edit; the git mirror is what survives the box.
The arc has three stations: (A) sidecar projection (vault → mirror, one-way, ships in v0.7), (B) bidirectional sync (mirror → vault flows back, ships if real demand materializes), (C) vault-as-thick-UI-over-git (git is canonical, SQLite is a runtime cache — deferred indefinitely). Ship A; defer B and C until operators are loudly asking.
Two threads converge here. Gitcoin Brain (vault-as-job-substrate per the "Gitcoin Brain (2026-05)" pattern — internal design notes) treats jobs as notes, runs as notes, and wants git history of the team brain for free — audit, time-travel, code-review-able diffs over decisions, commitments, run outputs. The existing portable-markdown export already gets them most of the way (see the vault-portable-export cookbook); they wire it via cron + shell loop today. Owner-operator export/import workflows (the broader pattern the cookbook codifies) want the same shape: vault as live store, git as the artifact you back up, diff, share, and read offline in Obsidian / IDE.
The friction the current setup leaves on the floor:
inbox/2026-05-12-meeting.md in Obsidian or Vim sees the change clobbered on the next re-export. The export is a one-way projection; mirror-side edits are a fork that gets stomped.The end-state Aaron is gesturing at: vault has a mirror mode. Configure the mirror once; every write triggers an incremental export + commit (+ optional push). Eventually, edits to .md files in the mirror flow back into vault. Eventually, the SPA renders git history natively.
The portable-markdown export is mature and lossless. The vault-portable-export cookbook is authoritative; the headline shape:
parachute-vault export <dir> [--since <ISO>] [--vault <name>] — full or incremental.parachute-vault import <dir> [--blow-away --yes] — round-trips to byte-equivalent state.core/src/portable-md.ts.This is the primitive everything below builds on. Re-architecting around git as canonical (option C) would discard this; building on top of it (options A + B) treats it as the load-bearing substrate.
A small step from "shell loop + cron" toward "vault knows about its mirror":
parachute-vault export <dir> --watch — long-running mode that re-exports incrementally on a polling interval (default 5s). Polling rather than filesystem watchers because vault DB writes are HTTP-mediated and opaque to fsevents — there's no filesystem signal to subscribe to.parachute-vault export <dir> --git-commit — runs git add -A && git commit -m <message> after each export pass when the diff is non-empty.parachute-vault export <dir> --git-push — optional, runs git push after commit; failures non-fatal (logged, loop continues).parachute-vault export ~/mirror --watch --git-commit --git-push is the "fire-and-forget shell loop, but the process owns it" version of the cookbook recipe.This stays inside the CLI surface. No schema changes, no hub-side config, no UI surface. It validates the timing model (polling re-export on a fixed interval) before committing to architectural awareness — and surfaces the cost (a 5s upper bound on projection latency, every interval whether anything changed or not) that Architecture A's event-driven post-write hooks improve on.
The next architectural step — vault becomes config-bound to its mirror, not external-CLI-bound:
Three increasingly-ambitious shapes. Each is a strict superset of the prior.
Vault writes trigger export to a configured mirror directory. The configuration is bound — vault reads it on boot, registers post-write hooks, runs incremental re-exports through the existing --since machinery. Auto-commit (and optional auto-push) shells out to git.
This refinement adds two axes of operator choice (location + watch mode) while keeping the architecture A-shaped. The mirror is still a one-way projection; the underlying data flow doesn't change.
/admin/vault-mirrors (mirroring the multi-user phase-1 /admin/users pattern). One mirror config per vault.--watch + --git-commit CLI mode — the same primitive, just hub-managed instead of operator-managed.This is the "vault mirrors itself" pattern. It operationalizes what cookbook readers wire by hand today.
A's surface area opens along two independent axes:
~/.parachute/vault/data/<name>/mirror/. Hidden under the vault's own data dir; the operator never has to think about where it lives. Can't be borked — if it goes missing, vault recreates it on next boot. Not designed for direct operator browsing.~/Documents/my-vault-mirror/, ~/notes/team/, etc.). Visible. Designed for the operator to open in Obsidian, push to GitHub, share, back up alongside other documents.parachute-vault export --vault <name> when they want a fresh snapshot. Explicit control over export cadence.All four combinations are technically valid; the system surfaces three as presets.
When the operator enables vault-sync, they pick from three presets. The form pre-fills location + watch + commit settings to match.
Preset 1 — "History" (internal + auto-watch). Vault auto-exports to a hidden mirror at ~/.parachute/vault/data/<name>/mirror/. Always-on; the mirror stays current. The operator doesn't think about it and never sees it unless they go looking. Use case: "give me an audit trail without operating overhead." This is the lowest-friction option — one click, no path picker, no permissions to worry about.
Preset 2 — "Live Mirror" (external + auto-watch). The operator picks a path (~/Documents/my-vault-mirror/, etc.). Vault keeps it live via the watch loop. Optional auto-commit + auto-push for the GitHub-backed scenario. Use case: "I want to push to GitHub, open in Obsidian, or share with my team." The visible-folder + always-current shape that matches the cookbook recipe most operators wire by hand today.
Preset 3 — "Manual Export" (external + manual). The operator picks a path. No auto-watch; they run parachute-vault export --vault <name> when they want a fresh snapshot. Optional auto-commit on each manual export. Use case: "I want explicit control over when exports happen." For operators who'd rather batch exports (end-of-day snapshot, pre-meeting freeze, etc.) than have a process polling in the background.
The fourth combination — internal + manual — is technically supported but isn't surfaced as a preset. Internal mirrors get most of their value from being always-current; if an operator wants manual control, external is the natural shape (they can browse the result). Advanced operators can still configure it via the raw config form if a use case shows up.
The defaults framing is opt-in always. No mirror exists until the operator explicitly enables vault-sync. When they do, the three presets cover the common cases; the raw config form covers the rest.
A filesystem watcher (the same primitive options A uses, extended to read events) watches the mirror dir for .md file changes. On a change: parse the markdown via the existing import machinery, diff against in-vault state, apply changes. Conflict resolution becomes load-bearing.
This is the "edit your notes in Obsidian" pattern. It assumes the operator is the same person editing in both places; cross-tenant bidirectional sync is a different problem (see "What this doesn't cover").
Git repo is the canonical store. SQLite is a runtime cache, rebuilt on demand from a git checkout. The operations log becomes git commits. parachute-vault clone <git-url> becomes the install method; git pull becomes vault sync; conflicts resolve via git merge semantics.
This is the "vault is just a fancy git client" model. Powerful — and a different product. Cite Foam, Logseq's git mode, the git-bug model as priors. Don't commit to it; reserve it as a possibility.
Ship A. Defer B behind demand signal. Don't commit to C.
The pattern this follows is the same one trust-gradient-isolation.md named for runtime primitives: name the audience first, ship the lightest viable thing for them, don't span audiences in one primitive. A is the lightest thing that closes Aaron's stated friction. B is a different audience (the Obsidian-first editor). C is a different product entirely.
One new hub_settings key per vault, namespaced by vault name. The shape carries both axes (location + watch mode) plus the commit/push settings:
// hub_settings table — one row per vault that has a mirror configured
{
"key": "vault_mirror_<name>", // e.g. "vault_mirror_gitcoin"
"value": {
"enabled": true,
"location": "internal", // "internal" | "external"
"external_path": null, // path string when location === "external", null when internal
"watch": false, // true = auto-watch loop runs; false = manual export only
"auto_commit": true,
"auto_push": false,
"commit_template": "export: {{date}} ({{notes_changed}} note{{plural}})"
}
}
(commit_template above is the default; the admin SPA help text lists {{change_summary}} and other variables operators can include for richer commit messages — see the vault-side variable list further down.)
Field semantics:
enabled — explicit on/off. Mirror config rows with enabled: false are kept for restore but ignored at runtime. Disabling is non-destructive (the mirror dir stays where it is).location: "internal" — vault uses the canonical ~/.parachute/vault/data/<name>/mirror/. external_path is null. Vault bootstraps the directory as a git repo on first enable (git init + initial commit) — internal mirrors are vault-managed end to end, so auto-init is right here in a way it isn't for external.location: "external" — external_path is required and must be a string pointing at an existing directory that's already a git repo. Validated at config save time; clear error otherwise. Vault never auto-git inits an external path — the operator made the location decision consciously and the repo's lifecycle is theirs.watch: true — vault keeps the mirror live via the export-watch loop (post-write hooks + debounced incremental re-export). Same primitive as the parallel PR's --watch mode, just process-owned.watch: false — no auto-export. Operator runs parachute-vault export --vault <name> themselves. If auto_commit: true, each manual export still commits.The presets map to these fields as:
| Preset | location |
external_path |
watch |
auto_commit (default) |
auto_push (default) |
|---|---|---|---|---|---|
| History | internal |
null |
true |
true |
false |
| Live Mirror | external |
operator-supplied | true |
true |
false (toggle to enable for GitHub-backed flow) |
| Manual Export | external |
operator-supplied | false |
true |
false |
The wiring decision (vault config vs hub_settings) is open question 5 below. The shape above assumes hub_settings; if vault config wins, the same JSON moves under a mirror: block in vault's config.yaml. The fields are identical either way.
enabled: true && watch: true, register post-write hooks. If enabled: true && watch: false, do nothing on writes — the operator drives via CLI.watch: true replaces vault#346's setInterval polling with event-driven post-write hooks + a 2s debounce window. The CLI primitive informed the shape; A's vault-internal version is a new implementation that wakes only on writes (lower CPU at idle, lower latency on the burst-after-quiet case, no fixed-interval upper bound).~/.parachute/vault/data/<name>/mirror/, runs git init, performs the initial full export, and commits an initial revision. The operator sees a one-time "initial sync in progress" status; subsequent passes are incremental.watch: true: schedule a debounced re-export. Debounce window starts at 2 seconds — long enough that a burst of 50 writes from a paste-import collapses to one export, short enough that the operator's "I just saved a note" expectation lands within human-perceptible time. The window is tunable per vault if real usage demands.--since <cursor> machinery. The cursor lives inside the mirror's .parachute/ sidecar (same convention as the cookbook recipe), updated atomically after each successful export.auto_commit: true: after the export, shell out to git add -A && git commit -m "<rendered_template>". If auto_push: true: follow with git push. (Push behavior on internal mirrors is undefined — internal mirrors have no remote by default; setting auto_push: true with location: "internal" is rejected at config-save time.){{date}} (ISO timestamp of the export), {{notes_changed}} (number of notes touched), {{plural}} ("" or "s" for grammatical agreement), {{change_summary}} (a short human-readable summary built from the changed-note tags + count), {{since}} (the export cursor that produced this pass), {{vault_name}}.A new admin SPA page at /admin/vault-mirrors. Mirrors the multi-user phase-1 /admin/users pattern in chrome + interaction shape.
The three presets are the primary entry point. Operators pick a preset; the form pre-fills location + watch + commit settings. Advanced operators can drop into the raw config to customize (or land on the fourth combination — internal + manual).
Surface:
watch: false mirrors, the button is the primary "make the mirror current" surface.git init for external — that hides the "where does this live" decision the operator should make consciously. Internal mirrors are different (vault manages them end to end), so first-enable auto-init is correct there.~/notes/<vault>/, vault must be running as a user with write access — surface the error clearly if not. In v0.6's single-container deploy, external mirrors live under a configured /parachute/mirrors/<vault>/ on the same mounted disk; permissions are not a concern there either.rm -rf'd it, the disk unmounted, etc.): vault logs a warning, doesn't crash, the admin SPA shows "mirror unreachable" with the option to re-validate the path or unset the config. Internal mirrors are recreated on next boot if the directory is gone (vault owns the lifecycle).watch: false mirrors, the admin SPA shows the exact CLI invocation (parachute-vault export --vault <name>) the operator runs, so there's no guesswork about where the export entry point lives.The trade-offs the bidirectional case forces a pick on:
Recommendation for v0.7.5: LWW with audit log. Cheapest to ship; matches the audience (one operator editing in both places, conflicts mostly transient — say, "I edited in vault while my offline laptop was still queued to push"). If loss-of-work complaints accumulate, escalate to a conflict UI. Three-way merge is overkill for the v0.7.5 audience.
The audit log lives in vault as tag:conflict-resolution notes (one note per conflict, with metadata.original, metadata.overridden, metadata.winner). This is recursive but appropriate: vault's audit shape is "audit lives in vault," same as the run-output convention Gitcoin Brain uses.
Once the mirror is git-backed, vault SPA can render git history natively:
.md file. Each entry: commit sha, timestamp, commit message, author.Implementation: hub queries the mirror git repo via git log --follow <file> (handles path renames as long as git's rename detection catches them), parses the output, renders. No new persistent storage — the git repo is the storage.
This surface is gated on a mirror being configured. Vaults without mirrors have no history tab. The UI degrades gracefully — "Configure a git mirror in the admin SPA to see note history" — not "broken feature."
Could ship in v0.7 alongside A or split into v0.7.5 (open question 3). Splitting is the safer ship; the history surface is "nice to have" while the mirror itself is the load-bearing primitive.
Two operator preferences, both supportable:
~/parachute-mirror/vaults/<name>/). Easier to manage many vaults — one git pull, one set of credentials, one push target. Right for the multi-user phase-1 "twenty vaults for twenty people" case where the operator wants one git remote covering everything.Support both. The schema is just a path — vault doesn't care whether the path is a repo root or a subdirectory of a larger repo. The admin SPA's path validation accepts either ("is this directory inside a git repo?") and exposes the resolved repo root in the status display so the operator knows what they're committing to.
No opinion at the schema level. The cookbook entry grows two recipes — "one repo per vault" and "monorepo" — and points to each from the admin SPA's help text.
git-crypt), or (c) not mirror. Vault doesn't make the call. Document the trade-off in the admin SPA's help text; don't pretend mirror-side encryption is automatic.--since machinery, which currently materializes the full vault in memory before iterating (per the cookbook's "1M-note bulk-load ceiling" note). At v0.7 scale this is fine; at 100k+ notes per vault the export pass needs the streaming follow-up tracked at vault#317 F5. Validate performance against real workloads before promising sub-second projection for a 10k-note vault.--watch + --git-commit is the dress rehearsal; A formalizes it.run: daily-tweet-drafts/2026-05-12 commits. This is what we want — the audit trail of runs is the value — but the commit-noise is real. Mitigation: per-mirror commit-strategy field (every-write vs every-N-minutes vs every-N-writes). Default every-write; let operators with high-write-rate vaults tune. Not in v0.7's minimum surface but worth flagging.These need an explicit call before any code starts:
watch: false → watch: true and keeps the same path, or it could force the operator to re-enter all settings as if configuring a new mirror. Lean: one-click flip with confirmation — the presets are independent axes, so the path doesn't change when only watch flips, and full re-entry would add friction for no safety gain. Full re-entry only if the operator wants to also change external_path (or location) simultaneously; that's a different flow ("change where this mirror lives") and deserves the more deliberate UX.Nothing in 2026-05-18-v06-deploy-architecture.md changes — the mirror lives on the same persistent disk hub already manages (/parachute/mirrors/<vault>/). Nothing in 2026-04-20-module-architecture.md changes — the mirror is internal to vault, not a new module. The vault-portable-export cookbook grows a new recipe ("hub-managed mirror") once A ships, and the existing "nightly git projection" recipe becomes the bootstrap path for operators on pre-v0.7 hubs.
The multi-user phase-1 design (2026-05-20-multi-user-phase-1.md) gets a small future ripple: the admin SPA's /admin/vault-mirrors page sits next to /admin/users, and the "assign vault to user" flow can grow a "with mirror configured" badge so the operator sees at a glance which user-vaults are git-backed. Optional and not v0.7-gating.
Note: git_branch is dropped from the v0.7 ship. Earlier prototypes of the config shape carried a git_branch: "main" field; the refined shape removes it. Branch handling for git push defaults to the repo's current branch (git push with no refspec), which matches the cookbook's existing mental model. Explicit branch targeting can return as a Phase 2 polish if real usage shows operators want to push to a non-current branch.
The criterion that locks A in: the existing portable-markdown export is the load-bearing primitive, and A is the smallest config-bound wrapper around it. Everything that's already deterministic about the export — fixed key order, byte-equivalent re-emit, lossless round-trip — applies to A's commits unchanged. The git diffs stay clean. The round-trip stays lossless. The cookbook readers' mental model stays intact; the only thing that changes is "you don't have to run cron anymore."
B and C re-derive useful properties (real two-way editing, git as canonical) at significant cost. They're real audiences; they're just not the audience Aaron's current friction is calling out. Shipping A first keeps the door open for both without committing to either prematurely. That's the same shape the trust-gradient-isolation pattern names at the runtime layer, applied here at the storage layer: name the audience, ship the lightest thing, don't try to span audiences in one primitive.