Skip to content

Commit 52a9a6a

Browse files
tcconnallytcconnallyclaude
authored
fix: add ~/mimir.db to default DB fallback chain + warn on ambiguity; document macOS ad-hoc codesign (closes #421, closes #422) (#423)
#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: tcconnally <hermes@perseus.observer> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent f70dc3a commit 52a9a6a

4 files changed

Lines changed: 262 additions & 33 deletions

File tree

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,28 @@ All notable changes to Perseus Vault (formerly Mimir/Mneme) are documented here.
100100
behavior is unchanged.
101101

102102
### Fixed
103+
- Default DB-path resolution surfaces the split-brain instead of hiding it
104+
(#421): the legacy single-user location `~/mimir.db` is now **added to the
105+
fallback chain** (adopted when it is the *only* existing DB, instead of
106+
creating a fresh empty `~/.mimir/data/perseus-vault.db`). Full precedence
107+
(first existing wins): `~/.mimir/data/perseus-vault.db` >
108+
`~/.mimir/data/mneme.db` > `~/.mimir/data/mimir.db` > `~/mimir.db`; if none
109+
exist the canonical `perseus-vault.db` is created. Note this is
110+
precedence-only, not emptiness-aware: in the issue's reported scenario where
111+
a live `~/mimir.db` **and** a stale-empty `~/.mimir/data/mimir.db` both
112+
exist, the higher-precedence dir DB still wins — `~/mimir.db` is **surfaced
113+
via the warning, not auto-adopted**. When more than one candidate DB exists
114+
and no `--db`/`$MIMIR_DB_PATH` was given, a stderr warning names the chosen
115+
file and the ignored candidate(s) so the ambiguity is visible; passing
116+
`--db`/`$MIMIR_DB_PATH` explicitly is the deterministic remedy and suppresses
117+
the warning. Resolution was refactored into a pure, unit-tested
118+
`resolve_default_db(home, exists)` function. (Emptiness-aware precedence is a
119+
filed follow-up.)
120+
- macOS Apple silicon build-from-source binaries no longer fail with an
121+
unexplained `Killed: 9` on first run (#422): the `bootstrap.sh` build-and-
122+
install path now ad-hoc code-signs the binary (`codesign --force --sign -`)
123+
guarded by a `uname` Darwin/arm64 check, and the README build-from-source
124+
note documents the required signing step after every rebuild.
103125
- `mimir_purge` now honors its own "actually remove" contract (#398): purging
104126
an archived entity also DELETEs every superseded version of it from
105127
`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
@@ -492,45 +492,132 @@ fn apply_top_level_db(cli: &mut Cli) {
492492
}
493493
}
494494

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

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

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

0 commit comments

Comments
 (0)