Skip to main content

ai_memory/cli/
governance_install_defaults.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! v0.7.0 7th-form closeout (issue #760) — `ai-memory governance
5//! install-defaults` CLI subcommand.
6//!
7//! Bulk-activates the four seeded operator hard rules (R001-R004) that
8//! migration `0024_v07_governance_rules.sql` lands at `enabled = 0`:
9//!
10//! | Rule | Kind             | Matcher                                       | Reason                                              |
11//! |------|------------------|-----------------------------------------------|-----------------------------------------------------|
12//! | R001 | filesystem_write | `{"glob":"/tmp/**"}`                          | No `/tmp` writes (project hard rule, #691).         |
13//! | R002 | filesystem_write | `{"glob":"/var/tmp/**"}`                      | No `/var/tmp` writes.                                |
14//! | R003 | filesystem_write | `{"glob":"/private/tmp/**"}`                  | No `/private/tmp` writes (macOS realpath of `/tmp`).|
15//! | R004 | process_spawn    | `{"binary":"cargo","disk_free_min_gib":20}`   | Refuse `cargo` on low-disk (<20 GiB) host.          |
16//!
17//! ## Operator flow
18//!
19//! ```text
20//!   $ ai-memory governance install-defaults
21//!   The following seed rules will be enabled (R001-R004):
22//!     R001  filesystem_write  /tmp/**           refuse
23//!     R002  filesystem_write  /var/tmp/**       refuse
24//!     R003  filesystem_write  /private/tmp/**   refuse
25//!     R004  process_spawn     cargo (<20 GiB)   refuse
26//!   Proceed? [y/N]: y
27//!   Activated 4 rule(s).
28//! ```
29//!
30//! ## Why not `rules enable` per-id?
31//!
32//! `ai-memory rules enable <id> --sign` is the per-rule path; it
33//! requires the operator's Ed25519 key on disk and re-signs each row.
34//! For the bootstrap step where the operator just wants the seeded
35//! hard rules ON, `install-defaults` is a single confirmed batch.
36//! It does NOT touch the signature column — the seeded rows ship
37//! `attest_level = 'unsigned'` and the operator may pair this verb
38//! with a separate `ai-memory rules sign-seed --key …` to upgrade the
39//! attestation level.
40//!
41//! ## Audit honesty
42//!
43//! Activating the rule is **mechanical at the harness hook boundary**
44//! (per `src/governance/agent_action.rs` module docs). It is not a
45//! "100% can't be bypassed" claim — see the audit-honest wording in
46//! the agent_action module and `docs/governance/agent-action-rules.md`.
47
48use anyhow::{Context, Result};
49use clap::Args;
50use rusqlite::params;
51
52use crate::cli::CliOutput;
53
54/// The four seed rule ids defined in migration `0024_v07_governance_rules.sql`.
55/// Kept here as a typed constant so unit tests can iterate without
56/// relying on the migration text.
57pub const SEED_RULE_IDS: &[&str] = &["R001", "R002", "R003", "R004"];
58
59/// CLI args for `ai-memory governance install-defaults`.
60#[derive(Args, Debug, Clone)]
61pub struct InstallDefaultsArgs {
62    /// Skip the interactive `Proceed? [y/N]:` confirmation prompt.
63    /// Required for non-interactive contexts (CI, scripts).
64    #[arg(long)]
65    pub yes: bool,
66
67    /// Emit a JSON envelope instead of the human-readable summary.
68    /// Stable wire shape: `{ "verb": "governance.install-defaults",
69    /// "result": { "activated": [...], "missing": [...], "already_enabled": [...] } }`.
70    #[arg(long)]
71    pub json: bool,
72}
73
74/// Outcome of the install-defaults run; surfaced both to the JSON
75/// envelope and to the human summary line.
76#[derive(Debug, Default, serde::Serialize)]
77pub struct InstallDefaultsReport {
78    /// Rule ids that flipped from `enabled = 0` to `enabled = 1`.
79    pub activated: Vec<String>,
80    /// Rule ids that were already enabled at the start.
81    pub already_enabled: Vec<String>,
82    /// Rule ids that were not present in the DB (migration skipped or
83    /// row hand-deleted). Surfaced so the operator can investigate.
84    pub missing: Vec<String>,
85}
86
87/// Dispatch entry called from the daemon-runtime `GovernanceAction`
88/// match arm.
89///
90/// # Errors
91///
92/// Returns an error if the DB cannot be opened, the SELECT/UPDATE
93/// queries fail, or the operator declines the prompt and the JSON
94/// envelope cannot be serialised. Declining the prompt is NOT an error
95/// — it returns `Ok(())` after writing `aborted: true` to stdout.
96pub fn run(
97    db_path: &std::path::Path,
98    args: InstallDefaultsArgs,
99    out: &mut CliOutput<'_>,
100) -> Result<()> {
101    let conn = rusqlite::Connection::open(db_path).with_context(|| {
102        format!(
103            "governance install-defaults: open db at {}",
104            db_path.display()
105        )
106    })?;
107
108    // Confirm the four rules exist + grab their current state so we
109    // can render the preview block and decide what to activate.
110    let mut preview: Vec<SeedRuleRow> = Vec::with_capacity(SEED_RULE_IDS.len());
111    let mut missing: Vec<String> = Vec::new();
112    for id in SEED_RULE_IDS {
113        match load_seed_row(&conn, id)? {
114            Some(row) => preview.push(row),
115            None => missing.push((*id).to_string()),
116        }
117    }
118
119    // v0.7.0 #1042 (Agent-6 #5) — when an operator pubkey is
120    // resolved (env `AI_MEMORY_OPERATOR_PUBKEY` set OR
121    // `operator.key.pub` present on disk), the engine's
122    // `enforced_rule_passes` silently DROPS every row whose
123    // `attest_level != "operator_signed"`. Pre-#1042 this CLI
124    // happily activated the seeded R001-R004 rows (shipped at
125    // `attest_level = "unsigned"`), printed "Activated 4 rule(s)",
126    // and left the operator believing the rules were effective —
127    // even though the engine would skip them at every wire-action.
128    // The operator-visible message was MISLEADING.
129    //
130    // Post-#1042 we detect the misconfiguration BEFORE the
131    // activation UPDATE and bail with a clear pointer to
132    // `ai-memory rules sign-seed`. The operator has two recovery
133    // paths:
134    //   1. Run `ai-memory rules sign-seed --key <path>` first to
135    //      upgrade the seed rows' attest_level to operator_signed.
136    //      Then re-run `install-defaults` with the rules properly
137    //      enrolled.
138    //   2. Temporarily unset `AI_MEMORY_OPERATOR_PUBKEY` and
139    //      remove any stored `operator.key.pub` to drop into the
140    //      no-pubkey-resolved posture where `enforced_rule_passes`
141    //      treats unsigned-enabled rows as enforceable. (Strongly
142    //      discouraged — leaves the L1-6 bypass-impossibility
143    //      story broken.)
144    let operator_pubkey = crate::governance::rules_store::resolve_operator_pubkey();
145    if operator_pubkey.is_some() {
146        let unsigned_seed_rows: Vec<&SeedRuleRow> = preview
147            .iter()
148            .filter(|r| r.attest_level != crate::governance::rules_store::ATTEST_OPERATOR_SIGNED)
149            .collect();
150        if !unsigned_seed_rows.is_empty() {
151            let unsigned_ids: Vec<&str> =
152                unsigned_seed_rows.iter().map(|r| r.id.as_str()).collect();
153            anyhow::bail!(
154                "governance install-defaults: refused (#1042) — operator pubkey is resolved \
155                 (AI_MEMORY_OPERATOR_PUBKEY env or operator.key.pub on disk) but the \
156                 following seed rule(s) are still attest_level=unsigned: {}. \
157                 Activating them now would print 'Activated' but the engine's \
158                 enforced_rule_passes() would silently drop every one at wire-action time. \
159                 First run `ai-memory rules sign-seed --key <path-to-private-key>` to upgrade \
160                 the seed rows to operator_signed, THEN re-run install-defaults.",
161                unsigned_ids.join(", "),
162            );
163        }
164    }
165
166    // Interactive prompt unless --yes / --json was supplied.
167    if !args.yes {
168        // JSON-mode callers MUST pass --yes; an interactive prompt on
169        // a JSON path would corrupt the envelope. Refuse early.
170        if args.json {
171            anyhow::bail!("governance install-defaults: --json requires --yes (non-interactive)");
172        }
173        render_preview(out, &preview, &missing)?;
174        if !confirm_proceed(out)? {
175            writeln!(out.stdout, "Aborted. No rules were activated.")?;
176            return Ok(());
177        }
178    }
179
180    // Flip enabled = 1 on every row whose enabled = 0.
181    let mut report = InstallDefaultsReport {
182        missing: missing.clone(),
183        ..Default::default()
184    };
185    for row in &preview {
186        if row.enabled {
187            report.already_enabled.push(row.id.clone());
188            continue;
189        }
190        let affected = conn
191            .execute(
192                "UPDATE governance_rules SET enabled = 1 WHERE id = ?1 AND enabled = 0",
193                params![row.id],
194            )
195            .with_context(|| format!("install-defaults: UPDATE enabled=1 for {}", row.id))?;
196        if affected > 0 {
197            report.activated.push(row.id.clone());
198        }
199    }
200
201    if args.json {
202        let envelope = serde_json::json!({
203            "verb": "governance.install-defaults",
204            "result": &report,
205        });
206        writeln!(
207            out.stdout,
208            "{}",
209            serde_json::to_string(&envelope)
210                .context("install-defaults: serialise JSON envelope")?
211        )?;
212    } else {
213        writeln!(
214            out.stdout,
215            "Activated {} rule(s); {} already-enabled; {} missing.",
216            report.activated.len(),
217            report.already_enabled.len(),
218            report.missing.len(),
219        )?;
220        if !report.activated.is_empty() {
221            writeln!(out.stdout, "  activated: {}", report.activated.join(", "))?;
222        }
223        if !report.missing.is_empty() {
224            writeln!(out.stdout, "  missing:   {}", report.missing.join(", "))?;
225        }
226    }
227    Ok(())
228}
229
230/// Snapshot of one row from `governance_rules` for the preview block.
231struct SeedRuleRow {
232    id: String,
233    kind: String,
234    matcher: String,
235    severity: String,
236    enabled: bool,
237    /// v0.7.0 #1042 — attest_level needed for the operator-pubkey
238    /// pre-flight check (see `run()` body). When pubkey is
239    /// resolved, only `operator_signed` rows pass
240    /// `enforced_rule_passes()`; activating an unsigned seed row
241    /// would silently fail enforcement.
242    attest_level: String,
243}
244
245fn load_seed_row(conn: &rusqlite::Connection, id: &str) -> Result<Option<SeedRuleRow>> {
246    use rusqlite::OptionalExtension;
247    conn.query_row(
248        "SELECT id, kind, matcher, severity, enabled, attest_level \
249         FROM governance_rules WHERE id = ?1",
250        params![id],
251        |r| {
252            Ok(SeedRuleRow {
253                id: r.get::<_, String>(0)?,
254                kind: r.get::<_, String>(1)?,
255                matcher: r.get::<_, String>(2)?,
256                severity: r.get::<_, String>(3)?,
257                enabled: r.get::<_, i64>(4)? != 0,
258                attest_level: r.get::<_, String>(5)?,
259            })
260        },
261    )
262    .optional()
263    .with_context(|| format!("install-defaults: SELECT governance_rules id={id}"))
264}
265
266fn render_preview(
267    out: &mut CliOutput<'_>,
268    preview: &[SeedRuleRow],
269    missing: &[String],
270) -> Result<()> {
271    writeln!(
272        out.stdout,
273        "The following seed rules will be enabled (R001-R004):"
274    )?;
275    for row in preview {
276        let state = if row.enabled {
277            "already-on"
278        } else {
279            "will-enable"
280        };
281        writeln!(
282            out.stdout,
283            "  {:<5} {:<17} {:<32} {:<8} [{}]",
284            row.id, row.kind, row.matcher, row.severity, state,
285        )?;
286    }
287    if !missing.is_empty() {
288        writeln!(
289            out.stdout,
290            "Warning: the following seed rule ids were not found in the DB: {}",
291            missing.join(", ")
292        )?;
293        writeln!(
294            out.stdout,
295            "  (re-run `ai-memory schema-init` or check migration 0024 applied)"
296        )?;
297    }
298    Ok(())
299}
300
301fn confirm_proceed(out: &mut CliOutput<'_>) -> Result<bool> {
302    write!(out.stdout, "Proceed? [y/N]: ")?;
303    out.stdout.flush().ok();
304    let mut answer = String::new();
305    std::io::stdin()
306        .read_line(&mut answer)
307        .context("install-defaults: read stdin")?;
308    let trimmed = answer.trim().to_ascii_lowercase();
309    Ok(matches!(trimmed.as_str(), "y" | "yes"))
310}
311
312// ---------------------------------------------------------------------------
313// Unit tests
314// ---------------------------------------------------------------------------
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    /// Seed `db_path` with the `governance_rules` table + the four
321    /// seeded rows at `enabled = 0`. Avoids pulling in the full
322    /// migration ladder (which would also drag in fts5 / hnsw).
323    fn seed_db_at(db_path: &std::path::Path) {
324        let conn = rusqlite::Connection::open(db_path).unwrap();
325        conn.execute_batch(
326            "CREATE TABLE IF NOT EXISTS governance_rules (
327                 id TEXT PRIMARY KEY,
328                 kind TEXT NOT NULL,
329                 matcher TEXT NOT NULL,
330                 severity TEXT NOT NULL,
331                 reason TEXT NOT NULL,
332                 namespace TEXT NOT NULL DEFAULT '_global',
333                 created_by TEXT NOT NULL,
334                 created_at INTEGER NOT NULL,
335                 enabled INTEGER NOT NULL DEFAULT 1,
336                 signature BLOB,
337                 attest_level TEXT NOT NULL DEFAULT 'unsigned'
338             );",
339        )
340        .unwrap();
341        for (id, kind, matcher) in [
342            ("R001", "filesystem_write", r#"{"glob":"/tmp/**"}"#),
343            ("R002", "filesystem_write", r#"{"glob":"/var/tmp/**"}"#),
344            ("R003", "filesystem_write", r#"{"glob":"/private/tmp/**"}"#),
345            (
346                "R004",
347                "process_spawn",
348                r#"{"binary":"cargo","disk_free_min_gib":20}"#,
349            ),
350        ] {
351            conn.execute(
352                "INSERT INTO governance_rules (id, kind, matcher, severity, reason, \
353                 namespace, created_by, created_at, enabled, signature, attest_level) \
354                 VALUES (?1, ?2, ?3, 'refuse', 'seed', '_global', 'system:seed', 0, 0, NULL, 'unsigned')",
355                params![id, kind, matcher],
356            )
357            .unwrap();
358        }
359    }
360
361    /// Build an `InstallDefaultsArgs` with `--yes` set so the prompt
362    /// is skipped.
363    fn yes_args() -> InstallDefaultsArgs {
364        InstallDefaultsArgs {
365            yes: true,
366            json: false,
367        }
368    }
369
370    #[test]
371    fn seed_rule_ids_is_the_canonical_four() {
372        assert_eq!(SEED_RULE_IDS, &["R001", "R002", "R003", "R004"]);
373    }
374
375    /// Build a fresh on-disk DB in a scoped tempdir and seed it.
376    fn fresh_db() -> (tempfile::TempDir, std::path::PathBuf) {
377        let dir = tempfile::tempdir().unwrap();
378        let db_path = dir.path().join("governance.db");
379        seed_db_at(&db_path);
380        (dir, db_path)
381    }
382
383    /// v0.7.0 #1042 lock — env-var manipulation in these tests races
384    /// when run in parallel. Use a process-wide mutex to serialise.
385    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
386        use std::sync::{Mutex, OnceLock};
387        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
388        LOCK.get_or_init(|| Mutex::new(()))
389            .lock()
390            .unwrap_or_else(std::sync::PoisonError::into_inner)
391    }
392
393    /// Generate a fresh Ed25519 keypair and stuff the verifying key
394    /// into `AI_MEMORY_OPERATOR_PUBKEY` so
395    /// `resolve_operator_pubkey()` returns `Some(_)`. Returns a
396    /// guard that clears the env var on drop.
397    struct TestPubkeyGuard;
398    impl Drop for TestPubkeyGuard {
399        fn drop(&mut self) {
400            // SAFETY: env mutation; the env_lock guard's lifetime
401            // brackets the test region so no sibling test races.
402            unsafe { std::env::remove_var("AI_MEMORY_OPERATOR_PUBKEY") };
403        }
404    }
405    fn install_test_pubkey() -> TestPubkeyGuard {
406        use base64::Engine;
407        use ed25519_dalek::SigningKey;
408        use rand_core::OsRng;
409        let signing = SigningKey::generate(&mut OsRng);
410        let pubkey_b64 =
411            base64::engine::general_purpose::STANDARD.encode(signing.verifying_key().to_bytes());
412        // SAFETY: serialised via env_lock by caller.
413        unsafe { std::env::set_var("AI_MEMORY_OPERATOR_PUBKEY", pubkey_b64) };
414        TestPubkeyGuard
415    }
416
417    #[test]
418    fn install_defaults_refuses_when_pubkey_resolved_seed_rows_unsigned_1042() {
419        // v0.7.0 #1042 (Agent-6 #5) — when an operator pubkey is
420        // resolved AND the seed rows are still attest_level=unsigned,
421        // install-defaults refuses with a clear pointer to
422        // `ai-memory rules sign-seed`. Pre-#1042 the command would
423        // happily activate the rows + print "Activated 4 rule(s)"
424        // even though the engine would silently drop every one.
425        let _g = env_lock();
426        let _pk = install_test_pubkey();
427        let (_dir, db_path) = fresh_db();
428
429        let mut so = Vec::<u8>::new();
430        let mut se = Vec::<u8>::new();
431        let mut out = CliOutput::from_std(&mut so, &mut se);
432        let result = run(&db_path, yes_args(), &mut out);
433        let err = result
434            .expect_err("#1042: install-defaults MUST refuse when pubkey + unsigned seed rows");
435        let msg = format!("{err:#}");
436        assert!(
437            msg.contains("operator pubkey is resolved")
438                && msg.contains("attest_level=unsigned")
439                && msg.contains("sign-seed"),
440            "#1042: refusal MUST cite pubkey + unsigned + sign-seed remediation; got: {msg}"
441        );
442        // Confirm no rule was actually activated — the refusal must
443        // fire BEFORE the UPDATE.
444        let conn = rusqlite::Connection::open(&db_path).unwrap();
445        for id in SEED_RULE_IDS {
446            let enabled: i64 = conn
447                .query_row(
448                    "SELECT enabled FROM governance_rules WHERE id = ?1",
449                    params![id],
450                    |r| r.get(0),
451                )
452                .unwrap();
453            assert_eq!(
454                enabled, 0,
455                "#1042: refusal MUST fire BEFORE the UPDATE — rule {id} must stay disabled"
456            );
457        }
458    }
459
460    #[test]
461    fn install_defaults_flips_enabled_on_seeded_rows() {
462        let _g = env_lock();
463        // v0.7.0 #1042 — force resolve_operator_pubkey() to return
464        // None for this test, so the dev-host pubkey gate doesn't
465        // fire on hosts where ~/Library/Application Support/ai-memory/
466        // operator.key.pub is staged.
467        let _no_pubkey = crate::governance::rules_store::force_no_operator_pubkey_for_test();
468        let (_dir, db_path) = fresh_db();
469        // Sanity: confirm all four start disabled.
470        {
471            let conn = rusqlite::Connection::open(&db_path).unwrap();
472            for id in SEED_RULE_IDS {
473                let enabled: i64 = conn
474                    .query_row(
475                        "SELECT enabled FROM governance_rules WHERE id = ?1",
476                        params![id],
477                        |r| r.get(0),
478                    )
479                    .unwrap();
480                assert_eq!(enabled, 0, "rule {id} must start disabled");
481            }
482        }
483
484        let mut so = Vec::<u8>::new();
485        let mut se = Vec::<u8>::new();
486        let mut out = CliOutput::from_std(&mut so, &mut se);
487        run(&db_path, yes_args(), &mut out).unwrap();
488
489        let conn = rusqlite::Connection::open(&db_path).unwrap();
490        for id in SEED_RULE_IDS {
491            let enabled: i64 = conn
492                .query_row(
493                    "SELECT enabled FROM governance_rules WHERE id = ?1",
494                    params![id],
495                    |r| r.get(0),
496                )
497                .unwrap();
498            assert_eq!(enabled, 1, "rule {id} must be activated");
499        }
500        let stdout = String::from_utf8(so).unwrap();
501        assert!(stdout.contains("Activated 4 rule(s)"));
502    }
503
504    #[test]
505    fn install_defaults_idempotent_when_already_enabled() {
506        let _g = env_lock();
507        let _no_pubkey = crate::governance::rules_store::force_no_operator_pubkey_for_test();
508        let (_dir, db_path) = fresh_db();
509        // Pre-flip all rows to enabled = 1.
510        {
511            let conn = rusqlite::Connection::open(&db_path).unwrap();
512            conn.execute(
513                "UPDATE governance_rules SET enabled = 1 WHERE id IN ('R001','R002','R003','R004')",
514                [],
515            )
516            .unwrap();
517        }
518
519        let mut so = Vec::<u8>::new();
520        let mut se = Vec::<u8>::new();
521        let mut out = CliOutput::from_std(&mut so, &mut se);
522        run(&db_path, yes_args(), &mut out).unwrap();
523
524        let stdout = String::from_utf8(so).unwrap();
525        assert!(stdout.contains("Activated 0 rule(s)"));
526        assert!(stdout.contains("4 already-enabled"));
527    }
528
529    #[test]
530    fn install_defaults_reports_missing_rows() {
531        let _g = env_lock();
532        let _no_pubkey = crate::governance::rules_store::force_no_operator_pubkey_for_test();
533        let (_dir, db_path) = fresh_db();
534        // Hand-delete R003.
535        {
536            let conn = rusqlite::Connection::open(&db_path).unwrap();
537            conn.execute("DELETE FROM governance_rules WHERE id = 'R003'", [])
538                .unwrap();
539        }
540
541        let mut so = Vec::<u8>::new();
542        let mut se = Vec::<u8>::new();
543        let mut out = CliOutput::from_std(&mut so, &mut se);
544        run(&db_path, yes_args(), &mut out).unwrap();
545
546        let stdout = String::from_utf8(so).unwrap();
547        assert!(
548            stdout.contains("1 missing") || stdout.contains("missing:   R003"),
549            "stdout was: {stdout}",
550        );
551    }
552
553    #[test]
554    fn json_mode_emits_envelope() {
555        let _g = env_lock();
556        let _no_pubkey = crate::governance::rules_store::force_no_operator_pubkey_for_test();
557        let (_dir, db_path) = fresh_db();
558        let mut so = Vec::<u8>::new();
559        let mut se = Vec::<u8>::new();
560        let mut out = CliOutput::from_std(&mut so, &mut se);
561        run(
562            &db_path,
563            InstallDefaultsArgs {
564                yes: true,
565                json: true,
566            },
567            &mut out,
568        )
569        .unwrap();
570        let stdout = String::from_utf8(so).unwrap();
571        let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
572        assert_eq!(v["verb"], "governance.install-defaults");
573        assert_eq!(v["result"]["activated"].as_array().unwrap().len(), 4);
574    }
575
576    #[test]
577    fn json_without_yes_refuses() {
578        let _g = env_lock();
579        let _no_pubkey = crate::governance::rules_store::force_no_operator_pubkey_for_test();
580        let (_dir, db_path) = fresh_db();
581        let mut so = Vec::<u8>::new();
582        let mut se = Vec::<u8>::new();
583        let mut out = CliOutput::from_std(&mut so, &mut se);
584        let err = run(
585            &db_path,
586            InstallDefaultsArgs {
587                yes: false,
588                json: true,
589            },
590            &mut out,
591        )
592        .expect_err("expected refusal");
593        assert!(
594            err.to_string().contains("--json requires --yes"),
595            "got: {err}"
596        );
597    }
598
599    // ------------------------------------------------------------------
600    // Coverage-uplift block (2026-05-19): exercise helper functions
601    // (render_preview, load_seed_row) and additional run() branches that
602    // the original 6 tests did not cover.
603    // ------------------------------------------------------------------
604
605    #[test]
606    fn render_preview_emits_one_row_per_seeded_rule() {
607        let preview = vec![
608            SeedRuleRow {
609                id: "R001".into(),
610                kind: "filesystem_write".into(),
611                matcher: r#"{"glob":"/tmp/**"}"#.into(),
612                severity: "refuse".into(),
613                enabled: false,
614                attest_level: "unsigned".into(),
615            },
616            SeedRuleRow {
617                id: "R002".into(),
618                kind: "filesystem_write".into(),
619                matcher: r#"{"glob":"/var/tmp/**"}"#.into(),
620                severity: "refuse".into(),
621                enabled: true,
622                attest_level: "unsigned".into(),
623            },
624        ];
625        let missing: Vec<String> = vec![];
626
627        let mut so = Vec::<u8>::new();
628        let mut se = Vec::<u8>::new();
629        let mut out = CliOutput::from_std(&mut so, &mut se);
630        render_preview(&mut out, &preview, &missing).unwrap();
631        drop(out);
632        let stdout = String::from_utf8(so).unwrap();
633        // Header line is present.
634        assert!(stdout.contains("The following seed rules will be enabled"));
635        // Both rule ids appear in the preview.
636        assert!(stdout.contains("R001"));
637        assert!(stdout.contains("R002"));
638        // Disabled row prints "will-enable"; enabled row prints
639        // "already-on" — both arms exercised.
640        assert!(stdout.contains("will-enable"));
641        assert!(stdout.contains("already-on"));
642        // No "Warning" line — the missing list is empty.
643        assert!(!stdout.contains("Warning"));
644    }
645
646    #[test]
647    fn render_preview_emits_warning_block_when_missing_present() {
648        let preview: Vec<SeedRuleRow> = vec![];
649        let missing = vec!["R003".to_string(), "R004".to_string()];
650
651        let mut so = Vec::<u8>::new();
652        let mut se = Vec::<u8>::new();
653        let mut out = CliOutput::from_std(&mut so, &mut se);
654        render_preview(&mut out, &preview, &missing).unwrap();
655        drop(out);
656        let stdout = String::from_utf8(so).unwrap();
657        // Warning + remediation lines fire.
658        assert!(stdout.contains("Warning"));
659        assert!(stdout.contains("R003"));
660        assert!(stdout.contains("R004"));
661        assert!(stdout.contains("re-run `ai-memory schema-init`"));
662    }
663
664    #[test]
665    fn load_seed_row_returns_none_for_unknown_id() {
666        let (_dir, db_path) = fresh_db();
667        let conn = rusqlite::Connection::open(&db_path).unwrap();
668        let row = load_seed_row(&conn, "R999-nonexistent").unwrap();
669        assert!(row.is_none());
670    }
671
672    #[test]
673    fn load_seed_row_returns_typed_row_with_disabled_default() {
674        let (_dir, db_path) = fresh_db();
675        let conn = rusqlite::Connection::open(&db_path).unwrap();
676        let row = load_seed_row(&conn, "R001").unwrap();
677        let row = row.expect("R001 seeded");
678        assert_eq!(row.id, "R001");
679        assert_eq!(row.kind, "filesystem_write");
680        assert_eq!(row.severity, "refuse");
681        assert!(!row.enabled, "seeded rows ship at enabled = 0");
682    }
683
684    #[test]
685    fn install_defaults_human_render_emits_activated_and_missing_lines() {
686        let _g = env_lock();
687        let _no_pubkey = crate::governance::rules_store::force_no_operator_pubkey_for_test();
688        // Drives both `if !report.activated.is_empty()` and
689        // `if !report.missing.is_empty()` writeln arms (lines ~173-178)
690        // in a single run by hand-deleting one row before invoking run.
691        let (_dir, db_path) = fresh_db();
692        {
693            let conn = rusqlite::Connection::open(&db_path).unwrap();
694            conn.execute("DELETE FROM governance_rules WHERE id = 'R002'", [])
695                .unwrap();
696        }
697        let mut so = Vec::<u8>::new();
698        let mut se = Vec::<u8>::new();
699        let mut out = CliOutput::from_std(&mut so, &mut se);
700        run(&db_path, yes_args(), &mut out).unwrap();
701        drop(out);
702        let stdout = String::from_utf8(so).unwrap();
703        // Summary header with non-zero counts.
704        assert!(stdout.contains("Activated 3 rule(s)"));
705        assert!(stdout.contains("1 missing"));
706        // Per-id "activated:" line fires when activated is non-empty.
707        assert!(stdout.contains("  activated:"));
708        // Per-id "missing:" line fires when missing is non-empty.
709        assert!(stdout.contains("  missing:"));
710        assert!(stdout.contains("R002"));
711    }
712
713    #[test]
714    fn install_defaults_json_envelope_pins_wire_shape_when_partial_missing() {
715        let _g = env_lock();
716        let _no_pubkey = crate::governance::rules_store::force_no_operator_pubkey_for_test();
717        // Hand-delete two rows, run with --json --yes, parse envelope.
718        let (_dir, db_path) = fresh_db();
719        {
720            let conn = rusqlite::Connection::open(&db_path).unwrap();
721            conn.execute(
722                "DELETE FROM governance_rules WHERE id IN ('R003','R004')",
723                [],
724            )
725            .unwrap();
726        }
727        let mut so = Vec::<u8>::new();
728        let mut se = Vec::<u8>::new();
729        let mut out = CliOutput::from_std(&mut so, &mut se);
730        run(
731            &db_path,
732            InstallDefaultsArgs {
733                yes: true,
734                json: true,
735            },
736            &mut out,
737        )
738        .unwrap();
739        drop(out);
740        let stdout = String::from_utf8(so).unwrap();
741        let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
742        assert_eq!(v["verb"], "governance.install-defaults");
743        let result = &v["result"];
744        // R001 + R002 activated; R003 + R004 missing.
745        let activated = result["activated"].as_array().unwrap();
746        assert_eq!(activated.len(), 2);
747        let missing = result["missing"].as_array().unwrap();
748        assert_eq!(missing.len(), 2);
749        assert!(missing.iter().any(|x| x == "R003"));
750        assert!(missing.iter().any(|x| x == "R004"));
751    }
752
753    #[test]
754    fn run_propagates_open_error_for_non_existent_db_with_unwritable_parent() {
755        // db path under a non-existent directory cannot be opened —
756        // exercises the with_context closure on Connection::open (lines
757        // 101-106). The closure body fires only on the error path.
758        let dir = tempfile::tempdir().unwrap();
759        let db_path = dir.path().join("nonexistent-dir/missing.db");
760        let mut so = Vec::<u8>::new();
761        let mut se = Vec::<u8>::new();
762        let mut out = CliOutput::from_std(&mut so, &mut se);
763        let err = run(&db_path, yes_args(), &mut out).expect_err("must fail");
764        // The with_context closure runs and the formatted context is
765        // attached to the error chain.
766        let chain = format!("{err:#}");
767        assert!(
768            chain.contains("governance install-defaults: open db at"),
769            "expected context, got: {chain}"
770        );
771    }
772}