Skip to content

Commit 4d5dcc7

Browse files
tcconnallyclaude
andcommitted
fix: adopt ~/mimir.db + warn on ambiguous default DB; codesign macOS build-from-source (closes #421, #422)
#421 — default DB-path split-brain: - Refactor default DB resolution into a pure, unit-tested `resolve_default_db(home, exists)` returning the chosen path plus any other existing candidates. - Fallback chain now includes the legacy single-user location `~/mimir.db`, positioned among the existing-DB fallbacks so an EXISTING ~/mimir.db is adopted BEFORE a fresh ~/.mimir/data/perseus-vault.db is created. Precedence (first existing wins): perseus-vault.db > mneme.db > mimir.db(dir) > ~/mimir.db. - When >1 candidate exists and no --db/$MIMIR_DB_PATH is set, emit a stderr warning naming the chosen file and the ignored ones. Explicit --db/env is unchanged and suppresses the warning (gated in check_legacy_db). - 5 new tests cover: adopt ~/mimir.db over creating fresh; canonical precedence; fresh-install fallback; multiple-candidate reporting; full precedence order. - README documents the canonical path and full resolution order. #422 — macOS Apple silicon Killed:9: - bootstrap.sh (build-from-source installer) now ad-hoc code-signs the built binary (`codesign --force --sign -`) guarded by a uname Darwin/arm64 check. - README build-from-source note updated to use `--force` and document the post-rebuild signing step. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a0557d4 commit 4d5dcc7

4 files changed

Lines changed: 257 additions & 33 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,23 @@ All notable changes to Perseus Vault (formerly Mimir/Mneme) are documented here.
8787
behavior is unchanged.
8888

8989
### Fixed
90+
- Default DB-path resolution no longer silently split-brains (#421): the
91+
fallback chain now includes the legacy single-user location `~/mimir.db`,
92+
positioned among the *existing-DB* fallbacks so an existing `~/mimir.db` is
93+
adopted **before** creating a fresh `~/.mimir/data/perseus-vault.db`. Full
94+
precedence (first existing wins): `~/.mimir/data/perseus-vault.db` >
95+
`~/.mimir/data/mneme.db` > `~/.mimir/data/mimir.db` > `~/mimir.db`; if none
96+
exist the canonical `perseus-vault.db` is created. When more than one
97+
candidate DB exists and no `--db`/`$MIMIR_DB_PATH` was given, a stderr
98+
warning names the chosen file and the ignored ones so the ambiguity is
99+
visible. Explicit `--db`/`$MIMIR_DB_PATH` is unchanged and suppresses the
100+
warning. Resolution was refactored into a pure, unit-tested
101+
`resolve_default_db(home, exists)` function.
102+
- macOS Apple silicon build-from-source binaries no longer fail with an
103+
unexplained `Killed: 9` on first run (#422): the `bootstrap.sh` build-and-
104+
install path now ad-hoc code-signs the binary (`codesign --force --sign -`)
105+
guarded by a `uname` Darwin/arm64 check, and the README build-from-source
106+
note documents the required signing step after every rebuild.
90107
- `mimir_purge` now honors its own "actually remove" contract (#398): purging
91108
an archived entity also DELETEs every superseded version of it from
92109
`entity_history` (matched by id and by category/key/workspace, so versions

README.md

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,20 @@ That's it. Perseus Vault is installed to `~/.local/bin/perseus-vault`. Start it:
2828
perseus-vault serve --db ~/.mimir/data/perseus-vault.db
2929
```
3030

31-
> **macOS note.** On Apple Silicon, an unsigned binary is killed on launch
32-
> (`Killed: 9`, no output) by the OS binary policy — even with no quarantine
33-
> attribute. The installer ad-hoc code-signs Perseus Vault for you. If you build or copy
34-
> the binary yourself (`cargo build --release && cp target/release/perseus-vault
35-
> ~/.cargo/bin/`), sign it once after each rebuild:
31+
> **macOS note (Apple Silicon).** A freshly built or copied binary is
32+
> SIGKILLed on first run (`Killed: 9`, no other output) by the OS binary
33+
> policy — even with no quarantine attribute. The one-line installer and the
34+
> `bootstrap.sh` build-from-source installer ad-hoc code-sign Perseus Vault for
35+
> you. If you build the binary yourself, sign it once **after each rebuild**:
3636
>
3737
> ```bash
38-
> codesign --sign - "$(command -v perseus-vault)"
38+
> cargo build --release
39+
> cp target/release/perseus-vault ~/.local/bin/perseus-vault
40+
> codesign --force --sign - ~/.local/bin/perseus-vault # required on Apple Silicon; fixes "Killed: 9"
3941
> ```
42+
>
43+
> `--force` re-signs an already-signed binary (needed after every rebuild); the
44+
> step is harmless on Intel macOS and unnecessary on Linux/Windows.
4045
4146
Connect any MCP host (Claude Desktop, Cursor, Hermes Agent, Perseus, etc.):
4247
@@ -278,6 +283,31 @@ perseus-vault keygen --key-file ~/.mimir/secret.key
278283
| `--embedding-endpoint` | OpenAI-compatible embedding endpoint |
279284
| `--connectors-config` | Path to connectors.yaml |
280285

286+
### Database location
287+
288+
The **canonical** database path is:
289+
290+
```
291+
~/.mimir/data/perseus-vault.db
292+
```
293+
294+
Always pass `--db` (or set `$MIMIR_DB_PATH`) in scripts, MCP host configs, and
295+
cron/harvest jobs so every invocation targets the same file. When neither is
296+
set, Perseus Vault resolves the default in this order and uses the **first that
297+
already exists** (so upgraders and legacy single-user installs are picked up
298+
instead of silently starting empty):
299+
300+
1. `~/.mimir/data/perseus-vault.db` — canonical (current name)
301+
2. `~/.mimir/data/mneme.db` — pre-rename
302+
3. `~/.mimir/data/mimir.db` — pre-rename
303+
4. `~/mimir.db` — legacy single-user install location
304+
305+
If none exist, it creates `~/.mimir/data/perseus-vault.db`. If **more than one**
306+
of these exists and you did not pass `--db`/`$MIMIR_DB_PATH`, Perseus Vault
307+
prints a stderr warning naming the chosen file and the others it ignored, so an
308+
ambiguous multi-database state is visible rather than silent. Setting `--db` or
309+
`$MIMIR_DB_PATH` explicitly always wins and suppresses the warning.
310+
281311
## Your AI Memory in Obsidian
282312

283313
Perseus Vault is your AI agent's long-term memory — and it doubles as **your** second

scripts/bootstrap.sh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,20 @@ mkdir -p "$MIMIR_BIN_DIR"
166166
cp "$BINARY" "$MIMIR_BIN_DIR/mimir"
167167
chmod +x "$MIMIR_BIN_DIR/mimir"
168168

169+
# macOS Apple silicon: a freshly built (unsigned) binary is SIGKILLed on first
170+
# run — `mimir --version` prints "Killed: 9" with no other output (#422). Apply
171+
# an ad-hoc code signature so it launches. Guarded by Darwin + arm64 so it is a
172+
# no-op on Intel macOS and other platforms.
173+
if [ "$(uname -s)" = "Darwin" ] && [ "$(uname -m)" = "arm64" ] && command -v codesign >/dev/null 2>&1; then
174+
info "Ad-hoc code-signing binary (macOS Apple silicon, #422)..."
175+
if codesign --force --sign - "$MIMIR_BIN_DIR/mimir" 2>/dev/null; then
176+
ok "Ad-hoc code-signed"
177+
else
178+
warn "Could not code-sign. If 'mimir' is Killed: 9, run:"
179+
warn " codesign --force --sign - $MIMIR_BIN_DIR/mimir"
180+
fi
181+
fi
182+
169183
# Ensure ~/.local/bin is on PATH
170184
case ":$PATH:" in
171185
*":$MIMIR_BIN_DIR:"*) ;;

src/main.rs

Lines changed: 190 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -491,45 +491,132 @@ fn apply_top_level_db(cli: &mut Cli) {
491491
}
492492
}
493493

494+
/// Outcome of resolving the default database path when no `--db`/`$MIMIR_DB_PATH`
495+
/// was given: the chosen path plus any *other* existing candidate databases that
496+
/// were passed over. When `other_candidates` is non-empty the caller should warn
497+
/// so an ambiguous multi-DB state is visible rather than silent (#421).
498+
#[derive(Debug, Clone, PartialEq, Eq)]
499+
struct DbResolution {
500+
chosen: String,
501+
other_candidates: Vec<String>,
502+
}
503+
504+
/// Pure, testable core of default DB-path resolution (#421).
505+
///
506+
/// Given the home directory and an existence check, decides which database the
507+
/// server should open when the user did not pass `--db` or set `$MIMIR_DB_PATH`.
508+
///
509+
/// Precedence (first existing wins):
510+
/// 1. `~/.mimir/data/perseus-vault.db` (canonical, current name)
511+
/// 2. `~/.mimir/data/mneme.db` (pre-rename)
512+
/// 3. `~/.mimir/data/mimir.db` (pre-rename)
513+
/// 4. `~/mimir.db` (legacy single-user install location)
514+
/// If none exist, fall back to creating (1), the canonical path.
515+
///
516+
/// Crucially `~/mimir.db` is chosen *before* falling through to create a fresh
517+
/// canonical DB, so an existing single-user install is picked up instead of
518+
/// silently starting empty. `other_candidates` reports every *other* database
519+
/// that also exists so the caller can warn about the ambiguity.
520+
fn resolve_default_db(home: &str, exists: &dyn Fn(&str) -> bool) -> DbResolution {
521+
let dir = format!("{}/.mimir/data", home);
522+
let vault_path = format!("{}/perseus-vault.db", dir);
523+
let mneme_path = format!("{}/mneme.db", dir);
524+
let mimir_path = format!("{}/mimir.db", dir);
525+
let home_legacy_path = format!("{}/mimir.db", home);
526+
527+
// Ordered candidate list; the first that exists is chosen.
528+
let candidates = [
529+
vault_path.clone(),
530+
mneme_path,
531+
mimir_path,
532+
home_legacy_path,
533+
];
534+
535+
let existing: Vec<String> = candidates
536+
.iter()
537+
.filter(|p| exists(p))
538+
.cloned()
539+
.collect();
540+
541+
// Chosen: first existing candidate in precedence order, else the canonical
542+
// path (which will be created fresh).
543+
let chosen = existing.first().cloned().unwrap_or(vault_path);
544+
let other_candidates = existing
545+
.into_iter()
546+
.filter(|p| *p != chosen)
547+
.collect();
548+
549+
DbResolution {
550+
chosen,
551+
other_candidates,
552+
}
553+
}
554+
494555
/// Resolve the default database path.
495556
///
496557
/// Perseus Vault rename: fresh installs default to `perseus-vault.db`. If a
497-
/// pre-rename `mneme.db` or `mimir.db` already exists at the same directory
498-
/// (and no `perseus-vault.db` does), we keep using it so upgraders don't
499-
/// silently start over with an empty database — same fallback shape as the
500-
/// legacy `~/mimir.db` -> `~/.mimir/data/`
501-
/// move handled by `check_legacy_db` below.
558+
/// pre-rename `mneme.db`/`mimir.db`, or a legacy single-user `~/mimir.db`,
559+
/// already exists we keep using it so upgraders don't silently start over with
560+
/// an empty database (#421).
561+
///
562+
/// This is intentionally side-effect free apart from creating the data dir: it
563+
/// is used both as clap's `default_value_t` (evaluated eagerly, even when the
564+
/// user passes `--db`) and in equality comparisons by `apply_top_level_db`, so
565+
/// it must NOT print warnings. The multi-candidate split-brain warning is
566+
/// emitted separately by `check_legacy_db`, which runs only at real startup and
567+
/// only when the default path was actually used.
502568
fn default_db_path() -> String {
503-
std::env::var("MIMIR_DB_PATH").unwrap_or_else(|_| {
504-
let home = std::env::var("HOME")
505-
.or_else(|_| std::env::var("USERPROFILE"))
506-
.unwrap_or_else(|_| {
507-
eprintln!("perseus-vault: could not determine home directory. Set MIMIR_DB_PATH or HOME/USERPROFILE.");
508-
std::process::exit(1);
509-
});
510-
let dir = format!("{}/.mimir/data", home);
511-
let _ = std::fs::create_dir_all(&dir);
512-
let vault_path = format!("{}/perseus-vault.db", dir);
513-
let mneme_path = format!("{}/mneme.db", dir);
514-
let mimir_path = format!("{}/mimir.db", dir);
515-
if std::path::Path::new(&vault_path).exists() {
516-
vault_path
517-
} else if std::path::Path::new(&mneme_path).exists() {
518-
mneme_path
519-
} else if std::path::Path::new(&mimir_path).exists() {
520-
mimir_path
521-
} else {
522-
vault_path
523-
}
524-
})
569+
if let Ok(explicit) = std::env::var("MIMIR_DB_PATH") {
570+
return explicit;
571+
}
572+
let home = std::env::var("HOME")
573+
.or_else(|_| std::env::var("USERPROFILE"))
574+
.unwrap_or_else(|_| {
575+
eprintln!("perseus-vault: could not determine home directory. Set MIMIR_DB_PATH or HOME/USERPROFILE.");
576+
std::process::exit(1);
577+
});
578+
let dir = format!("{}/.mimir/data", home);
579+
let _ = std::fs::create_dir_all(&dir);
580+
581+
resolve_default_db(&home, &|p| std::path::Path::new(p).exists()).chosen
525582
}
526583

527584
/// Check for a legacy database at ~/mimir.db and warn if the default path
528585
/// would create a new empty database instead.
586+
///
587+
/// Also emits the #421 split-brain warning: when the path being used is the
588+
/// resolved *default* (no `--db`, no `$MIMIR_DB_PATH`) and more than one
589+
/// candidate database exists on disk, name the chosen one and the others so the
590+
/// ambiguity is visible rather than silent. When `--db`/`$MIMIR_DB_PATH` is set
591+
/// explicitly, behavior is unchanged and no warning fires.
529592
fn check_legacy_db(db_path: &str) {
530593
let home = std::env::var("HOME")
531594
.or_else(|_| std::env::var("USERPROFILE"))
532595
.unwrap_or_else(|_| "/root".to_string());
596+
597+
// Was this path chosen implicitly (the resolved default) rather than via
598+
// --db / $MIMIR_DB_PATH? Only then do we own the resolution and warn.
599+
let env_set = std::env::var("MIMIR_DB_PATH").is_ok();
600+
let is_default = !env_set && db_path == default_db_path();
601+
602+
// #421: multiple candidate DBs but the user didn't pick one — surface the
603+
// split-brain instead of silently reading/creating one of them.
604+
if is_default {
605+
let resolution = resolve_default_db(&home, &|p| std::path::Path::new(p).exists());
606+
if !resolution.other_candidates.is_empty() {
607+
eprintln!(
608+
"perseus-vault: ⚠ multiple candidate databases found; using {}",
609+
resolution.chosen
610+
);
611+
for other in &resolution.other_candidates {
612+
eprintln!("perseus-vault: also present (ignored): {}", other);
613+
}
614+
eprintln!(
615+
"perseus-vault: pass --db <path> or set MIMIR_DB_PATH to choose explicitly and silence this warning."
616+
);
617+
}
618+
}
619+
533620
let legacy = std::path::PathBuf::from(format!("{}/mimir.db", home));
534621
let target = std::path::PathBuf::from(db_path);
535622
if legacy.exists() && !target.exists() {
@@ -1816,6 +1903,82 @@ mod tests {
18161903
assert!(cli.command.is_none());
18171904
}
18181905

1906+
// ---- #421: default DB-path resolution (split-brain) ----
1907+
1908+
/// Helper: existence checker over a fixed set of present paths.
1909+
fn present(set: &[String]) -> impl Fn(&str) -> bool + '_ {
1910+
move |p: &str| set.iter().any(|e| e == p)
1911+
}
1912+
1913+
#[test]
1914+
fn resolve_default_db_picks_home_legacy_over_creating_fresh() {
1915+
// #421 core: only ~/mimir.db exists. It must be selected instead of
1916+
// creating a fresh ~/.mimir/data/perseus-vault.db (the silent
1917+
// split-brain the issue reports).
1918+
let home = "/home/tester";
1919+
let home_legacy = format!("{}/mimir.db", home);
1920+
let existing = vec![home_legacy.clone()];
1921+
let r = resolve_default_db(home, &present(&existing));
1922+
assert_eq!(r.chosen, home_legacy, "should adopt existing ~/mimir.db");
1923+
assert!(r.other_candidates.is_empty());
1924+
}
1925+
1926+
#[test]
1927+
fn resolve_default_db_prefers_canonical_when_present() {
1928+
// Canonical perseus-vault.db wins over legacy names in precedence order.
1929+
let home = "/home/tester";
1930+
let vault = format!("{}/.mimir/data/perseus-vault.db", home);
1931+
let home_legacy = format!("{}/mimir.db", home);
1932+
let existing = vec![vault.clone(), home_legacy.clone()];
1933+
let r = resolve_default_db(home, &present(&existing));
1934+
assert_eq!(r.chosen, vault);
1935+
assert_eq!(r.other_candidates, vec![home_legacy]);
1936+
}
1937+
1938+
#[test]
1939+
fn resolve_default_db_falls_back_to_canonical_when_none_exist() {
1940+
// Fresh install: nothing exists -> create canonical path, no warning.
1941+
let home = "/home/tester";
1942+
let vault = format!("{}/.mimir/data/perseus-vault.db", home);
1943+
let r = resolve_default_db(home, &present(&[]));
1944+
assert_eq!(r.chosen, vault);
1945+
assert!(r.other_candidates.is_empty());
1946+
}
1947+
1948+
#[test]
1949+
fn resolve_default_db_reports_multiple_candidates() {
1950+
// Multiple candidate DBs -> chosen is highest-precedence, others named
1951+
// so the caller can warn about the ambiguity.
1952+
let home = "/home/tester";
1953+
let mneme = format!("{}/.mimir/data/mneme.db", home);
1954+
let mimir = format!("{}/.mimir/data/mimir.db", home);
1955+
let home_legacy = format!("{}/mimir.db", home);
1956+
let existing = vec![mneme.clone(), mimir.clone(), home_legacy.clone()];
1957+
let r = resolve_default_db(home, &present(&existing));
1958+
// perseus-vault.db absent -> mneme.db is highest precedence.
1959+
assert_eq!(r.chosen, mneme);
1960+
assert_eq!(r.other_candidates, vec![mimir, home_legacy]);
1961+
}
1962+
1963+
#[test]
1964+
fn resolve_default_db_precedence_order_is_stable() {
1965+
// The full documented order: vault > mneme > mimir(dir) > ~/mimir.db.
1966+
let home = "/home/tester";
1967+
let vault = format!("{}/.mimir/data/perseus-vault.db", home);
1968+
let mneme = format!("{}/.mimir/data/mneme.db", home);
1969+
let mimir = format!("{}/.mimir/data/mimir.db", home);
1970+
let home_legacy = format!("{}/mimir.db", home);
1971+
let all = vec![
1972+
vault.clone(),
1973+
mneme.clone(),
1974+
mimir.clone(),
1975+
home_legacy.clone(),
1976+
];
1977+
let r = resolve_default_db(home, &present(&all));
1978+
assert_eq!(r.chosen, vault);
1979+
assert_eq!(r.other_candidates, vec![mneme, mimir, home_legacy]);
1980+
}
1981+
18191982
#[test]
18201983
fn parses_top_level_db_without_subcommand() {
18211984
// Regression: the documented MCP host config is `mimir --db <path>`

0 commit comments

Comments
 (0)