// Copyright 2026 AlphaOne LLC
// SPDX-License-Identifier: Apache-2.0

//! Typed CRUD over the `governance_rules` table (migration
//! `0024_v07_governance_rules.sql`).
//!
//! The table holds the substrate-level agent-action rules consulted
//! by [`crate::governance::agent_action::check_agent_action`]. This
//! module owns the SQL — no other code path is allowed to SELECT /
//! INSERT / UPDATE / DELETE from `governance_rules` directly.
//!
//! The shape is deliberately small: five verbs ([`insert`],
//! [`get`], [`list`], [`list_enabled_by_kind`], [`remove`]) plus
//! two state mutators ([`set_enabled`], [`update_signature`]). All
//! verbs are idempotent against a missing row (`get` / `remove`
//! return `None` / `Ok(false)` rather than erroring).
//!
//! # Operator-mutation gating (NOT enforced here)
//!
//! The CRUD functions are pure SQL. The operator-keypair-on-disk
//! gating lives in `src/cli/rules.rs` and the HTTP handler — both
//! verify the signature header / file presence BEFORE calling these
//! verbs. The MCP read-only `rule_list` tool calls [`list`] /
//! [`get`]; mutation tools over MCP are explicitly disabled per
//! issue #691 design revision 2026-05-13.

use crate::models::field_names;
use anyhow::{Context, Result};
use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize};

/// `attest_level` value carried by operator-signed governance rules —
/// shared with `governance install-defaults` (#1558 batch 6).
pub(crate) const ATTEST_OPERATOR_SIGNED: &str = "operator_signed";

/// One row of `governance_rules`. Field order matches the SQL column
/// order so projection / debugging is symmetric.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Rule {
    pub id: String,
    pub kind: String,
    pub matcher: String,
    pub severity: String,
    pub reason: String,
    pub namespace: String,
    pub created_by: String,
    pub created_at: i64,
    pub enabled: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signature: Option<Vec<u8>>,
    pub attest_level: String,
}

/// Insert a fresh rule. Returns an error if `id` already exists —
/// callers that want upsert semantics should call [`get`] first.
///
/// # Errors
///
/// Propagates SQLite errors. The `severity` value is enforced by the
/// table CHECK constraint (one of `refuse`/`warn`/`log`); a bad
/// value here will surface as a constraint error.
pub fn insert(conn: &Connection, rule: &Rule) -> Result<()> {
    conn.execute(
        "INSERT INTO governance_rules (
             id, kind, matcher, severity, reason, namespace,
             created_by, created_at, enabled, signature, attest_level
         ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
        params![
            rule.id,
            rule.kind,
            rule.matcher,
            rule.severity,
            rule.reason,
            rule.namespace,
            rule.created_by,
            rule.created_at,
            i64::from(rule.enabled),
            rule.signature,
            rule.attest_level,
        ],
    )
    .with_context(|| format!("rules_store::insert: id={}", rule.id))?;
    Ok(())
}

/// Fetch a rule by id. Returns `None` when no row matches.
///
/// # Errors
///
/// Propagates SQLite errors. A row whose `enabled` column is not 0/1
/// or whose `severity` is outside the CHECK list will still
/// deserialize — the engine treats unknown severities as `Log`
/// (defensive), so the returned row is consumable.
pub fn get(conn: &Connection, id: &str) -> Result<Option<Rule>> {
    let row = conn
        .query_row(
            "SELECT id, kind, matcher, severity, reason, namespace,
                    created_by, created_at, enabled, signature, attest_level
             FROM governance_rules WHERE id = ?1",
            params![id],
            row_to_rule,
        )
        .optional()
        .with_context(|| format!("rules_store::get: id={id}"))?;
    Ok(row)
}

/// List every rule in the table (enabled and disabled). Ordered by
/// `id ASC` for deterministic CLI output.
///
/// # Errors
///
/// Propagates SQLite errors.
pub fn list(conn: &Connection) -> Result<Vec<Rule>> {
    let mut stmt = conn
        .prepare(
            "SELECT id, kind, matcher, severity, reason, namespace,
                    created_by, created_at, enabled, signature, attest_level
             FROM governance_rules ORDER BY id ASC",
        )
        .context("rules_store::list: prepare")?;
    let rows = stmt
        .query_map([], row_to_rule)
        .context("rules_store::list: query_map")?;
    let mut out = Vec::new();
    for r in rows {
        out.push(r.context("rules_store::list: row")?);
    }
    Ok(out)
}

/// List only the rules of `kind` that are enabled AND pass the L1-6
/// load-time signature verification. The dominant query shape called
/// once per [`crate::governance::agent_action::check_agent_action`]
/// invocation; covered by `idx_governance_rules_kind_enabled`.
///
/// Ordered by `id ASC` to make first-refusal-wins deterministic for
/// audit reproduction.
///
/// # L1-6 enforcement policy
///
/// Activation is driven by the OPERATOR PUBKEY presence — the
/// substrate stays in pre-L1-6 mode until the operator places a
/// pubkey on disk (or sets `AI_MEMORY_OPERATOR_PUBKEY`).
///
/// - Pubkey NOT resolved (cold start, fresh install, or test
///   environment): every `enabled = 1` row passes through unchanged —
///   this preserves the pre-L1-6 contract that
///   `agent_action::check_agent_action` evaluated rules without any
///   signature pre-check.
/// - Pubkey resolved + row is `attest_level = 'operator_signed'` +
///   signature verifies: rule is enforced.
/// - Pubkey resolved + row is `attest_level = 'operator_signed'`
///   but signature does NOT verify (tampered row, post-sign direct
///   SQL mutation, wrong key): `tracing::error!` and SKIP. The
///   daemon does NOT crash; a tampered rule must never bring down
///   the substrate.
/// - Pubkey resolved + row is `attest_level = 'unsigned'` (a
///   freshly-seeded row that the operator has not yet signed):
///   misconfiguration → `tracing::warn!` and SKIP. Enforced rules
///   MUST be operator-signed once the operator has activated L1-6
///   by placing the pubkey.
///
/// The activation cliff (place pubkey ⇒ require signatures) is the
/// operator's switch. It avoids breaking the pre-L1-6 test fleet that
/// inserts unsigned-enabled rules + asserts they fire, while
/// guaranteeing the bypass-prevention property the moment the
/// operator opts in.
///
/// # Errors
///
/// Propagates SQLite errors. Verification failures are NOT errors —
/// they are logged and the rule is filtered out.
pub fn list_enabled_by_kind(conn: &Connection, kind: &str) -> Result<Vec<Rule>> {
    let mut stmt = conn
        .prepare(
            "SELECT id, kind, matcher, severity, reason, namespace,
                    created_by, created_at, enabled, signature, attest_level
             FROM governance_rules
             WHERE kind = ?1 AND enabled = 1
             ORDER BY id ASC",
        )
        .context("rules_store::list_enabled_by_kind: prepare")?;
    let rows = stmt
        .query_map(params![kind], row_to_rule)
        .context("rules_store::list_enabled_by_kind: query_map")?;
    let operator_pubkey = resolve_operator_pubkey();
    let mut out = Vec::new();
    for r in rows {
        let rule = r.context("rules_store::list_enabled_by_kind: row")?;
        if enforced_rule_passes(&rule, operator_pubkey.as_ref()) {
            out.push(rule);
        }
    }
    Ok(out)
}

/// L1-6 — decide whether `rule` (already filtered to `enabled = 1`
/// by the SQL WHERE clause) should pass to the enforcement engine.
/// See [`list_enabled_by_kind`] for the policy summary. Pulled out so
/// the L1-6 integration tests can exercise the matrix
/// (signed/tampered/unsigned-enabled/no-key) directly without driving
/// SQLite.
#[must_use]
pub fn enforced_rule_passes(
    rule: &Rule,
    operator_pubkey: Option<&ed25519_dalek::VerifyingKey>,
) -> bool {
    match (operator_pubkey, rule.attest_level.as_str()) {
        (Some(pk), ATTEST_OPERATOR_SIGNED) => match verify_rule_signature(rule, pk) {
            Ok(()) => true,
            Err(_) => {
                tracing::error!(
                    rule_id = %rule.id,
                    "L1-6: operator_signed rule failed signature verification — \
                     skipping. Tampered row OR rule was directly modified after \
                     signing (e.g. `UPDATE governance_rules SET enabled = 1`). \
                     Re-sign with `ai-memory rules sign-seed` after audit."
                );
                false
            }
        },
        (Some(_), _) => {
            // Pubkey is available (operator has activated L1-6) but
            // the rule is not operator_signed. It has `enabled = 1`
            // (SQL filter); refuse to enforce unsigned-enabled rules
            // as misconfiguration.
            tracing::warn!(
                rule_id = %rule.id,
                attest_level = %rule.attest_level,
                "L1-6: enabled rule is not operator_signed — skipping. Run \
                 `ai-memory rules sign-seed` to commit the operator signature."
            );
            false
        }
        (None, _) => {
            // No operator pubkey configured — substrate is in pre-L1-6
            // mode. Every `enabled = 1` row passes through unchanged
            // (preserves the pre-L1-6 contract that
            // `check_agent_action` evaluated rules without any
            // signature pre-check). The operator activates L1-6 by
            // placing the pubkey at the default path or setting the
            // env var.
            true
        }
    }
}

/// Base64url-no-pad (or standard) encoded verifying key written by
/// `ai-memory rules keygen` (Layout 2 in
/// [`crate::cli::rules::load_operator_signing_key_from_dir`]).
const OPERATOR_PUBKEY_KEYGEN_FILE: &str = "operator.key.pub";
/// Raw 32-byte verifying key written by the legacy `kp::save` keypair
/// layout (Layout 1 — pairs with `operator.priv`).
const OPERATOR_PUBKEY_LEGACY_FILE: &str = "operator.pub";

/// `attest_level` stamped on operator-signed governance rows AND on the
/// `signed_events` audit emissions that record their lifecycle.
///
/// Single source of truth for the `"operator_signed"` attestation
/// string — the CLI re-exports it as
/// [`crate::cli::rules::OPERATOR_SIGNED_LEVEL`] so the rules table
/// (`update_signature`) and the audit chain (`remove_signed`) cannot
/// drift apart on the literal.
pub const OPERATOR_SIGNED_ATTEST_LEVEL: &str = "operator_signed";

/// Resolve the operator verifying key. The lookup order mirrors the
/// SIGNER's key-dir resolution
/// ([`crate::cli::rules::load_operator_signing_key_from_dir`]) so the
/// verify path can never diverge from the sign path:
///
/// 1. `AI_MEMORY_OPERATOR_PUBKEY` env var (base64, URL-safe-no-pad or
///    standard padded — same as the rest of the codebase).
/// 2. The resolved key directory ([`crate::identity::keypair::default_key_dir`],
///    which honors `AI_MEMORY_KEY_DIR`):
///    a. `<key_dir>/operator.key.pub` (base64, keygen Layout 2);
///    b. `<key_dir>/operator.pub` (raw 32 bytes, legacy Layout 1).
/// 3. The key directory's PARENT (the singleton operator-key location —
///    `<config>/ai-memory/operator.key.pub` when `AI_MEMORY_KEY_DIR` is
///    unset, since the default key dir is `<config>/ai-memory/keys`):
///    a. `<parent>/operator.key.pub` (base64);
///    b. `<parent>/operator.pub` (raw 32 bytes).
///
/// v0.7.0 H5 (HIGH) — pre-fix the verifier read ONLY the env var and
/// `<config>/ai-memory/operator.key.pub`, ignoring `AI_MEMORY_KEY_DIR`
/// and the raw-bytes legacy layout. An operator who relocated the key
/// store via `AI_MEMORY_KEY_DIR` (the documented production override)
/// signed rules with a key the verifier could not find, so
/// `resolve_operator_pubkey` returned `None` and every `enabled = 1`
/// rule silently fell through the `(None, _) => true` pre-L1-6 arm of
/// [`enforced_rule_passes`] WITHOUT a signature check — a fail-open
/// signature bypass. Honoring the same key-dir ladder as the signer
/// closes the divergence: when a key exists the verifier finds it and
/// enforcement moves to the `(Some(pk), _)` verify arm.
///
/// Returns `None` when no source resolves a 32-byte verifying key. A
/// failure to decode any source is silently treated as "no key" (the
/// once-per-process diagnostic in [`log_missing_operator_pubkey_once`]
/// surfaces the misconfig to the operator).
///
/// Exposed `pub` so the daemon startup path
/// (`bootstrap_serve`) can call this directly to enforce the SEC-2
/// fail-closed posture (refuse to boot when `enabled = 1` rules are
/// present but no pubkey is resolved).
#[must_use]
pub fn resolve_operator_pubkey() -> Option<ed25519_dalek::VerifyingKey> {
    // Test-only escape hatch (issue #819). On dev hosts where the
    // operator has staged a real operator.key.pub at
    // `~/Library/Application Support/ai-memory/operator.key.pub`,
    // the unit-test fixtures in `src/governance/agent_action.rs` +
    // various integration tests insert unsigned rules and then call
    // `check_agent_action` expecting Warn/Refuse. With a real pubkey
    // resolvable on disk, `enforced_rule_passes` correctly skips the
    // unsigned rules and the assertions fail.
    //
    // The thread-local guard below — only compiled in under
    // `#[cfg(test)]` — lets a specific test scope force this
    // function to return `None` so the dev-host posture matches the
    // clean-HOME CI posture. Production code paths are entirely
    // unaffected.
    #[cfg(test)]
    if force_no_operator_pubkey_active() {
        return None;
    }

    use base64::Engine;
    let from_bytes = |bytes: &[u8]| -> Option<ed25519_dalek::VerifyingKey> {
        if bytes.len() != ed25519_dalek::PUBLIC_KEY_LENGTH {
            return None;
        }
        let mut arr = [0u8; ed25519_dalek::PUBLIC_KEY_LENGTH];
        arr.copy_from_slice(bytes);
        ed25519_dalek::VerifyingKey::from_bytes(&arr).ok()
    };
    let try_decode = |s: &str| -> Option<ed25519_dalek::VerifyingKey> {
        let trimmed = s.trim();
        let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
            .decode(trimmed)
            .or_else(|_| base64::engine::general_purpose::STANDARD.decode(trimmed))
            .ok()?;
        from_bytes(&bytes)
    };
    // Read a base64-encoded `.pub` (keygen layout); fall back to raw
    // 32-byte bytes (legacy layout) when the text does not base64-decode
    // to a valid key.
    let try_pub_file = |path: &std::path::Path| -> Option<ed25519_dalek::VerifyingKey> {
        let raw = std::fs::read(path).ok()?;
        if let Ok(text) = std::str::from_utf8(&raw)
            && let Some(pk) = try_decode(text)
        {
            return Some(pk);
        }
        from_bytes(&raw)
    };

    if let Ok(v) = std::env::var("AI_MEMORY_OPERATOR_PUBKEY")
        && !v.is_empty()
        && let Some(pk) = try_decode(&v)
    {
        return Some(pk);
    }

    // Resolve the same key directory the signer uses (honors
    // AI_MEMORY_KEY_DIR), then walk the key-dir + its parent for both
    // the base64 keygen layout and the raw-bytes legacy layout.
    let key_dir = crate::identity::keypair::default_key_dir().ok()?;
    let mut candidates: Vec<std::path::PathBuf> = vec![
        key_dir.join(OPERATOR_PUBKEY_KEYGEN_FILE),
        key_dir.join(OPERATOR_PUBKEY_LEGACY_FILE),
    ];
    if let Some(parent) = key_dir.parent() {
        candidates.push(parent.join(OPERATOR_PUBKEY_KEYGEN_FILE));
        candidates.push(parent.join(OPERATOR_PUBKEY_LEGACY_FILE));
    }
    candidates.iter().find_map(|p| try_pub_file(p))
}

/// Issue #819 — test-only escape hatch for [`resolve_operator_pubkey`].
///
/// Returns true when the current thread has an active
/// [`ForceNoOperatorPubkeyGuard`] in scope. Production code does
/// not call this (the `#[cfg(test)]` gate strips it from non-test
/// builds entirely).
#[cfg(test)]
fn force_no_operator_pubkey_active() -> bool {
    FORCE_NO_OPERATOR_PUBKEY.with(std::cell::Cell::get)
}

#[cfg(test)]
thread_local! {
    static FORCE_NO_OPERATOR_PUBKEY: std::cell::Cell<bool> =
        const { std::cell::Cell::new(false) };
}

/// Issue #819 — return a RAII guard that forces
/// [`resolve_operator_pubkey`] to return `None` for the duration of
/// the current scope on the current thread.
///
/// Per-thread (not process-wide) so parallel tests in the same
/// binary don't race on env mutation. The guard restores the prior
/// state on drop.
///
/// Use:
/// ```ignore
/// let _no_pubkey = force_no_operator_pubkey_for_test();
/// let decision = check_agent_action(&conn, "agent:t", &action)?;
/// // ... assertions ...
/// // `_no_pubkey` drops at end of scope, restoring resolver behavior.
/// ```
#[cfg(test)]
#[must_use = "the guard must be held for its scope to suppress pubkey resolution"]
pub fn force_no_operator_pubkey_for_test() -> ForceNoOperatorPubkeyGuard {
    let prior = FORCE_NO_OPERATOR_PUBKEY.with(|c| c.replace(true));
    ForceNoOperatorPubkeyGuard { prior }
}

/// RAII guard returned by [`force_no_operator_pubkey_for_test`].
/// Restores the prior value on drop so nested scopes compose.
#[cfg(test)]
pub struct ForceNoOperatorPubkeyGuard {
    prior: bool,
}

#[cfg(test)]
impl Drop for ForceNoOperatorPubkeyGuard {
    fn drop(&mut self) {
        FORCE_NO_OPERATOR_PUBKEY.with(|c| c.set(self.prior));
    }
}

/// v0.7.0 SEC-2 (Cluster D, issue #767) — count of `enabled = 1`
/// rows in `governance_rules`. Used by the daemon startup path to
/// decide whether to surface the fail-OPEN error
/// (`tracing::error!`) and, when
/// `[governance] require_operator_pubkey = true`, refuse to boot.
///
/// Returns `Ok(0)` when the table is empty or the migration that
/// creates it has not yet run — the caller treats absent table as
/// "no enabled rules" rather than a hard error so a cold-start
/// daemon can complete its migration pass before the L1-6 audit
/// runs.
///
/// # Errors
///
/// Propagates SQLite errors OTHER than the "no such table" case,
/// which is mapped to `Ok(0)`.
pub fn count_enabled_rules(conn: &Connection) -> Result<i64> {
    let result = conn.query_row(
        "SELECT COUNT(*) FROM governance_rules WHERE enabled = 1",
        [],
        |row| row.get::<_, i64>(0),
    );
    match result {
        Ok(n) => Ok(n),
        Err(rusqlite::Error::SqliteFailure(_, Some(msg)))
            if msg.contains("no such table") || msg.contains("does not exist") =>
        {
            Ok(0)
        }
        Err(rusqlite::Error::SqliteFailure(_, None)) => Ok(0),
        Err(e) => Err(anyhow::Error::new(e).context("rules_store::count_enabled_rules")),
    }
}

/// v0.7.0 SEC-2 (Cluster D, issue #767) — `true` when the substrate
/// is in pre-L1-6 mode (no operator pubkey resolved). Exposed so the
/// capabilities-v3 envelope can surface `l1_6_attest: false` without
/// re-decoding the pubkey on every call.
#[must_use]
pub fn l1_6_attest_active() -> bool {
    resolve_operator_pubkey().is_some()
}

/// v0.7.0 SEC-2 (Cluster D, issue #767) — once-per-process diagnostic
/// surfaced from the daemon startup path when the substrate is in
/// the fail-OPEN posture (any `enabled = 1` row exists but no
/// operator pubkey is resolved). Idempotent across repeated calls;
/// re-invocation from a test harness (or a `bootstrap_serve` reuse)
/// stays silent on the second+ trip.
pub fn log_missing_operator_pubkey_once(enabled_rule_count: i64) {
    use std::sync::OnceLock;
    static LOGGED: OnceLock<()> = OnceLock::new();
    if LOGGED.set(()).is_err() {
        return;
    }
    tracing::error!(
        enabled_rule_count,
        "SEC-2: governance_rules contains {enabled_rule_count} enabled row(s) but no operator \
         pubkey is resolved (AI_MEMORY_OPERATOR_PUBKEY unset AND \
         ~/.config/ai-memory/operator.key.pub absent). Substrate is in FAIL-OPEN posture: every \
         enabled rule passes through without signature verification, so a SQL-write gadget that \
         can mutate `governance_rules` can install or flip rules without operator consent. \
         Activate L1-6 by either (a) running `ai-memory rules keygen` + `ai-memory rules \
         sign-seed` to place an operator key + sign the existing rows, or (b) setting `[governance] \
         require_operator_pubkey = true` in config.toml to refuse boot until the pubkey is in \
         place."
    );
}

/// Remove a rule by id. Returns `true` when a row was deleted,
/// `false` when no row matched.
///
/// # Errors
///
/// Propagates SQLite errors.
pub fn remove(conn: &Connection, id: &str) -> Result<bool> {
    let affected = conn
        .execute("DELETE FROM governance_rules WHERE id = ?1", params![id])
        .with_context(|| format!("rules_store::remove: id={id}"))?;
    Ok(affected > 0)
}

/// Remove a rule by id, emitting an operator-signed audit event to the
/// `signed_events` chain BEFORE the row is deleted — atomically within
/// a single `IMMEDIATE` transaction.
///
/// Returns `true` when a row was deleted (and an audit event emitted),
/// `false` when no row matched (no event emitted).
///
/// v0.7.0 SR — closes the unaudited/unsigned-deletion gap. A bare
/// `DELETE FROM governance_rules` (the old [`remove`] path the CLI
/// called) left no tamper-evident trace of WHICH rule was removed or
/// under whose authority, so a SQL-write gadget — or a careless
/// operator — could erase a refuse-severity rule with zero audit
/// residue. The emitted event's `payload_hash` is the SHA-256 over the
/// removed rule's [`canonical_bytes_for_signing`] and its `signature`
/// is the operator's Ed25519 signature over that hash, so an auditor
/// can both prove the deletion was operator-authorized and reconstruct
/// the removed rule's identity from the append-only chain.
///
/// Atomicity: the audit INSERT and the row DELETE share one
/// transaction. On any error the transaction rolls back, so the rule
/// row is never deleted unless its audit event was durably written
/// (and vice versa).
///
/// # Errors
///
/// Propagates SQLite errors, signing-key/serialisation errors, and
/// audit-chain INSERT errors.
pub fn remove_signed(
    conn: &Connection,
    id: &str,
    signing_key: &ed25519_dalek::SigningKey,
    operator_agent_id: &str,
) -> Result<bool> {
    use ed25519_dalek::Signer;

    let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)
        .context("rules_store::remove_signed: begin IMMEDIATE tx")?;

    let Some(rule) = get(&tx, id)? else {
        // No matching row — nothing to delete or audit. Commit the empty
        // transaction so the IMMEDIATE write-lock is released cleanly.
        tx.commit()
            .context("rules_store::remove_signed: commit empty tx")?;
        return Ok(false);
    };

    let canonical = canonical_bytes_for_signing(&rule)?;
    let payload_hash = crate::signed_events::payload_hash(&canonical);
    let signature = signing_key.sign(&payload_hash);

    let event = crate::signed_events::SignedEvent {
        id: uuid::Uuid::new_v4().to_string(),
        agent_id: operator_agent_id.to_string(),
        event_type: crate::signed_events::event_types::GOVERNANCE_RULE_REMOVED.to_string(),
        payload_hash,
        signature: Some(signature.to_bytes().to_vec()),
        attest_level: OPERATOR_SIGNED_ATTEST_LEVEL.to_string(),
        timestamp: chrono::Utc::now().to_rfc3339(),
        ..crate::signed_events::SignedEvent::default()
    };
    crate::signed_events::append_signed_event_no_tx(&tx, &event)?;

    let affected = tx
        .execute("DELETE FROM governance_rules WHERE id = ?1", params![id])
        .with_context(|| format!("rules_store::remove_signed: delete id={id}"))?;

    tx.commit()
        .context("rules_store::remove_signed: commit tx")?;
    Ok(affected > 0)
}

/// Flip the `enabled` column on an existing rule. Returns `true`
/// when a row was updated, `false` when no row matched.
///
/// Used by `ai-memory rules enable` / `ai-memory rules disable` —
/// the CLI verifies the operator signature before calling here.
///
/// # Errors
///
/// Propagates SQLite errors.
pub fn set_enabled(conn: &Connection, id: &str, enabled: bool) -> Result<bool> {
    let affected = conn
        .execute(
            "UPDATE governance_rules SET enabled = ?1 WHERE id = ?2",
            params![i64::from(enabled), id],
        )
        .with_context(|| format!("rules_store::set_enabled: id={id} enabled={enabled}"))?;
    Ok(affected > 0)
}

/// Persist an operator signature + bump `attest_level` to
/// `operator_signed` on an existing rule. Used by `ai-memory rules
/// enable --sign` / `ai-memory rules add --sign` after the CLI
/// computes the Ed25519 signature over the canonical row encoding.
///
/// # Errors
///
/// Propagates SQLite errors. Returns `false` when no row matched.
pub fn update_signature(
    conn: &Connection,
    id: &str,
    signature: &[u8],
    attest_level: &str,
) -> Result<bool> {
    let affected = conn
        .execute(
            "UPDATE governance_rules
             SET signature = ?1, attest_level = ?2
             WHERE id = ?3",
            params![signature, attest_level, id],
        )
        .with_context(|| format!("rules_store::update_signature: id={id}"))?;
    Ok(affected > 0)
}

/// Canonical byte encoding of a rule for signature input. Stable
/// across versions: the field order is fixed; the wire format is
/// `serde_json` compact (no whitespace). A future signature-format
/// migration recomputes the bytes via a different canonicalizer
/// without touching the CLI call sites.
///
/// # Note on `enabled`
///
/// This historical helper (used by `ai-memory rules add --sign`)
/// does NOT include `enabled` in the canonical payload — it pre-dates
/// the L1-6 bypass-prevention design. Use
/// [`canonical_bytes_for_signing`] for L1-6 sign + verify call sites;
/// that variant commits to `enabled` so direct
/// `UPDATE governance_rules SET enabled = 1` after signing fails
/// verification at load time.
///
/// # Errors
///
/// Propagates `serde_json` encoding errors.
pub fn canonical_bytes(rule: &Rule) -> Result<Vec<u8>> {
    let canonical = serde_json::json!({
        "id": rule.id,
        "kind": rule.kind,
        "matcher": rule.matcher,
        "severity": rule.severity,
        "reason": rule.reason,
        "namespace": rule.namespace,
        (field_names::CREATED_BY): rule.created_by,
        (field_names::CREATED_AT): rule.created_at,
    });
    serde_json::to_vec(&canonical).context("rules_store::canonical_bytes: serialize")
}

/// v0.7.0 L1-6 — canonical byte encoding for the substrate-rules
/// signing pipeline. Commits to the same row fields as
/// [`canonical_bytes`] PLUS `enabled`. The latter is the
/// bypass-prevention property: a direct
/// `UPDATE governance_rules SET enabled = 1` between sign and load
/// changes the canonical bytes → the recorded signature no longer
/// verifies → the rule is skipped at enforcement time
/// (no panic, audit-row logged at `error!`).
///
/// `created_at` is intentionally OMITTED so a re-signing pass (idempotent
/// `ai-memory rules sign-seed` invocations) produces the same bytes
/// regardless of whether the operator re-ran the migration that seeded
/// `created_at = 0`. The signed property is "what the rule does", not
/// "when the seed row landed". A future rotation-policy verb can layer
/// timestamp commitments on top without changing this primitive.
///
/// # NSA CSI MCP Security mapping
///
/// Addresses **NSA recommendation (e) Sign and verify MCP messages** and
/// **NSA concern (b) Insecure context or data serialization** per the
/// NSA Cybersecurity Information document on MCP security
/// (U/OO/6030316-26 \| PP-26-1834, May 2026, Version 1.0). The canonical
/// bytes discipline is the substrate's primary cryptographic-integrity
/// primitive — every signed governance rule traces back to this
/// function. The mapping is documented in
/// [`docs/compliance/nsa-csi-mcp.html`](../../docs/compliance/nsa-csi-mcp.html)
/// §3.2 (NSA concern b) and §4.5 (NSA recommendation e); the
/// capability inventory anchor is `form_7_canonical_bytes_signing` in
/// [`docs/compliance/_inventory/v0.7.0-capabilities.json`](../../docs/compliance/_inventory/v0.7.0-capabilities.json).
///
/// # Errors
///
/// Propagates `serde_json` encoding errors.
pub fn canonical_bytes_for_signing(rule: &Rule) -> Result<Vec<u8>> {
    let canonical = serde_json::json!({
        "id": rule.id,
        "kind": rule.kind,
        "matcher": rule.matcher,
        "severity": rule.severity,
        "reason": rule.reason,
        "namespace": rule.namespace,
        (field_names::CREATED_BY): rule.created_by,
        "enabled": rule.enabled,
    });
    serde_json::to_vec(&canonical).context("rules_store::canonical_bytes_for_signing: serialize")
}

/// v0.7.0 L1-6 — verify the operator signature recorded on `rule`
/// against `operator_pubkey`. Returns `Ok(())` when the signature is
/// present, well-formed, and verifies against
/// [`canonical_bytes_for_signing`] of the row.
///
/// # Errors
///
/// - Returns a `SignatureError` when `rule.signature` is absent.
/// - Returns a `SignatureError` when the signature is not 64 bytes.
/// - Returns a `SignatureError` when the Ed25519 verify call fails
///   (tampered row, wrong operator key, or post-signing direct SQL
///   mutation — which is exactly the bypass attempt this catches).
pub fn verify_rule_signature(
    rule: &Rule,
    operator_pubkey: &ed25519_dalek::VerifyingKey,
) -> Result<(), ed25519_dalek::SignatureError> {
    use ed25519_dalek::{Signature, Verifier};

    let Some(sig_bytes) = rule.signature.as_ref() else {
        return Err(ed25519_dalek::SignatureError::new());
    };
    if sig_bytes.len() != ed25519_dalek::SIGNATURE_LENGTH {
        return Err(ed25519_dalek::SignatureError::new());
    }
    let mut sig_arr = [0u8; ed25519_dalek::SIGNATURE_LENGTH];
    sig_arr.copy_from_slice(sig_bytes);
    let signature = Signature::from_bytes(&sig_arr);
    // canonical_bytes_for_signing can only fail on a serde_json
    // internal error, which does not happen for the field shapes
    // here. Map any such failure to a verification error rather than
    // a panic — the caller is in the load-time enforcement path and
    // must NOT crash the daemon.
    let canonical =
        canonical_bytes_for_signing(rule).map_err(|_| ed25519_dalek::SignatureError::new())?;
    operator_pubkey.verify(&canonical, &signature)
}

fn row_to_rule(row: &rusqlite::Row<'_>) -> rusqlite::Result<Rule> {
    Ok(Rule {
        id: row.get(0)?,
        kind: row.get(1)?,
        matcher: row.get(2)?,
        severity: row.get(3)?,
        reason: row.get(4)?,
        namespace: row.get(5)?,
        created_by: row.get(6)?,
        created_at: row.get(7)?,
        enabled: row.get::<_, i64>(8)? != 0,
        signature: row.get(9)?,
        attest_level: row.get(10)?,
    })
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    fn fresh_conn() -> Connection {
        let conn = Connection::open_in_memory().unwrap();
        conn.execute_batch(
            "CREATE TABLE governance_rules (
                 id TEXT PRIMARY KEY,
                 kind TEXT NOT NULL,
                 matcher TEXT NOT NULL,
                 severity TEXT NOT NULL CHECK (severity IN ('refuse','warn','log','escalate')),
                 reason TEXT NOT NULL,
                 namespace TEXT NOT NULL DEFAULT '_global',
                 created_by TEXT NOT NULL,
                 created_at INTEGER NOT NULL,
                 enabled INTEGER NOT NULL DEFAULT 1,
                 signature BLOB,
                 attest_level TEXT NOT NULL DEFAULT 'unsigned'
             );",
        )
        .unwrap();
        conn
    }

    fn make_rule(id: &str, kind: &str, enabled: bool) -> Rule {
        Rule {
            id: id.to_string(),
            kind: kind.to_string(),
            matcher: r#"{"k":"v"}"#.to_string(),
            severity: "refuse".to_string(),
            reason: "test".to_string(),
            namespace: "_global".to_string(),
            created_by: "test".to_string(),
            created_at: 12345,
            enabled,
            signature: None,
            attest_level: "unsigned".to_string(),
        }
    }

    #[test]
    fn insert_then_get_roundtrip() {
        let conn = fresh_conn();
        let rule = make_rule("R1", "bash", true);
        insert(&conn, &rule).unwrap();
        let got = get(&conn, "R1").unwrap();
        assert_eq!(got.as_ref(), Some(&rule));
    }

    #[test]
    fn get_returns_none_when_missing() {
        let conn = fresh_conn();
        assert_eq!(get(&conn, "nope").unwrap(), None);
    }

    #[test]
    fn insert_duplicate_id_errors() {
        let conn = fresh_conn();
        let rule = make_rule("R1", "bash", true);
        insert(&conn, &rule).unwrap();
        assert!(insert(&conn, &rule).is_err());
    }

    #[test]
    fn list_orders_by_id_ascending() {
        let conn = fresh_conn();
        insert(&conn, &make_rule("R3", "bash", true)).unwrap();
        insert(&conn, &make_rule("R1", "bash", true)).unwrap();
        insert(&conn, &make_rule("R2", "bash", true)).unwrap();
        let all = list(&conn).unwrap();
        let ids: Vec<&str> = all.iter().map(|r| r.id.as_str()).collect();
        assert_eq!(ids, vec!["R1", "R2", "R3"]);
    }

    #[test]
    fn list_enabled_by_kind_filters_correctly() {
        // #1263 — on dev hosts where the operator already has an
        // `operator.key.pub` configured, the substrate's enrichment
        // hook reads + applies signed-rule semantics that this test
        // doesn't seed; the result is a spurious failure isolated to
        // dev hosts. Pin the no-pubkey posture via the RAII guard
        // pattern documented in `governance/agent_action.rs::no_operator_pubkey`.
        let _no_pubkey = force_no_operator_pubkey_for_test();
        let conn = fresh_conn();
        insert(&conn, &make_rule("R1", "bash", true)).unwrap();
        insert(&conn, &make_rule("R2", "bash", false)).unwrap();
        insert(&conn, &make_rule("R3", "filesystem_write", true)).unwrap();
        let bash_rules = list_enabled_by_kind(&conn, "bash").unwrap();
        assert_eq!(bash_rules.len(), 1);
        assert_eq!(bash_rules[0].id, "R1");
        let fs_rules = list_enabled_by_kind(&conn, "filesystem_write").unwrap();
        assert_eq!(fs_rules.len(), 1);
        assert_eq!(fs_rules[0].id, "R3");
        let other = list_enabled_by_kind(&conn, "network_request").unwrap();
        assert!(other.is_empty());
    }

    #[test]
    fn remove_returns_true_on_hit_false_on_miss() {
        let conn = fresh_conn();
        insert(&conn, &make_rule("R1", "bash", true)).unwrap();
        assert!(remove(&conn, "R1").unwrap());
        assert!(!remove(&conn, "R1").unwrap());
        assert_eq!(get(&conn, "R1").unwrap(), None);
    }

    /// Build a connection carrying BOTH the `governance_rules` table
    /// (from [`fresh_conn`]) and the `signed_events` audit table so the
    /// [`remove_signed`] audit emission has somewhere to land.
    fn fresh_conn_with_audit() -> Connection {
        let conn = fresh_conn();
        conn.execute_batch(
            "CREATE TABLE signed_events (
                 id TEXT PRIMARY KEY,
                 agent_id TEXT NOT NULL,
                 event_type TEXT NOT NULL,
                 payload_hash BLOB NOT NULL,
                 signature BLOB,
                 attest_level TEXT NOT NULL DEFAULT 'unsigned',
                 timestamp TEXT NOT NULL,
                 prev_hash BLOB,
                 sequence INTEGER
             );
             CREATE UNIQUE INDEX idx_signed_events_sequence ON signed_events(sequence);",
        )
        .unwrap();
        conn
    }

    /// v0.7.0 SR regression — `remove_signed` must (1) delete the rule
    /// and (2) leave an operator-signed `governance.rule_removed` row on
    /// the audit chain whose signature verifies against the operator
    /// pubkey over the removed rule's canonical payload hash. Pre-fix the
    /// CLI called the bare `remove`, deleting the rule with zero audit
    /// residue.
    #[test]
    fn remove_signed_emits_operator_signed_audit_event_and_deletes() {
        use ed25519_dalek::Verifier;
        let conn = fresh_conn_with_audit();
        let rule = make_rule("R1", "bash", true);
        insert(&conn, &rule).unwrap();

        let mut csprng = rand_core::OsRng;
        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
        let operator_pubkey = signing.verifying_key();

        let removed = remove_signed(&conn, "R1", &signing, "operator").unwrap();
        assert!(removed, "remove_signed must report the row was deleted");
        assert_eq!(get(&conn, "R1").unwrap(), None, "rule row must be gone");

        // Exactly one audit row, of the expected type + operator level.
        let (event_type, attest_level, payload_hash, sig): (String, String, Vec<u8>, Vec<u8>) =
            conn.query_row(
                "SELECT event_type, attest_level, payload_hash, signature FROM signed_events",
                [],
                |row| {
                    Ok((
                        row.get::<_, String>(0)?,
                        row.get::<_, String>(1)?,
                        row.get::<_, Vec<u8>>(2)?,
                        row.get::<_, Vec<u8>>(3)?,
                    ))
                },
            )
            .expect("exactly one signed_events row must exist");
        assert_eq!(
            event_type,
            crate::signed_events::event_types::GOVERNANCE_RULE_REMOVED
        );
        assert_eq!(attest_level, OPERATOR_SIGNED_ATTEST_LEVEL);

        // payload_hash must be the SHA-256 over the removed rule's
        // canonical bytes, and the signature must verify against it.
        let expected_hash =
            crate::signed_events::payload_hash(&canonical_bytes_for_signing(&rule).unwrap());
        assert_eq!(payload_hash, expected_hash);
        assert_eq!(
            sig.len(),
            ed25519_dalek::SIGNATURE_LENGTH,
            "signature must be 64 bytes"
        );
        let mut sig_arr = [0u8; ed25519_dalek::SIGNATURE_LENGTH];
        sig_arr.copy_from_slice(&sig);
        let signature = ed25519_dalek::Signature::from_bytes(&sig_arr);
        operator_pubkey
            .verify(&payload_hash, &signature)
            .expect("operator signature over payload_hash must verify");
    }

    /// v0.7.0 SR regression — `remove_signed` on a missing id returns
    /// `false` and emits NO audit event (no spurious chain rows).
    #[test]
    fn remove_signed_missing_id_returns_false_and_emits_nothing() {
        let conn = fresh_conn_with_audit();
        let mut csprng = rand_core::OsRng;
        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);

        let removed = remove_signed(&conn, "nope", &signing, "operator").unwrap();
        assert!(!removed);
        let count: i64 = conn
            .query_row("SELECT COUNT(*) FROM signed_events", [], |row| row.get(0))
            .unwrap();
        assert_eq!(count, 0, "no audit row may be emitted for a no-op removal");
    }

    #[test]
    fn set_enabled_toggles() {
        let conn = fresh_conn();
        insert(&conn, &make_rule("R1", "bash", false)).unwrap();
        assert!(set_enabled(&conn, "R1", true).unwrap());
        assert!(get(&conn, "R1").unwrap().unwrap().enabled);
        assert!(set_enabled(&conn, "R1", false).unwrap());
        assert!(!get(&conn, "R1").unwrap().unwrap().enabled);
    }

    #[test]
    fn set_enabled_missing_returns_false() {
        let conn = fresh_conn();
        assert!(!set_enabled(&conn, "nope", true).unwrap());
    }

    #[test]
    fn update_signature_persists_blob_and_attest_level() {
        let conn = fresh_conn();
        insert(&conn, &make_rule("R1", "bash", true)).unwrap();
        let sig = vec![1u8, 2, 3, 4];
        assert!(update_signature(&conn, "R1", &sig, "operator_signed").unwrap());
        let got = get(&conn, "R1").unwrap().unwrap();
        assert_eq!(got.signature, Some(sig));
        assert_eq!(got.attest_level, "operator_signed");
    }

    #[test]
    fn update_signature_missing_returns_false() {
        let conn = fresh_conn();
        assert!(!update_signature(&conn, "nope", &[1, 2, 3], "operator_signed").unwrap());
    }

    #[test]
    fn canonical_bytes_excludes_signature_fields() {
        let mut rule = make_rule("R1", "bash", true);
        rule.signature = Some(vec![9, 9, 9]);
        rule.attest_level = "operator_signed".to_string();
        let bytes = canonical_bytes(&rule).unwrap();
        let s = std::str::from_utf8(&bytes).unwrap();
        // The signature itself must NOT appear in the canonical
        // input (otherwise we'd be signing the signature).
        assert!(!s.contains("signature"));
        assert!(!s.contains("attest_level"));
        assert!(s.contains("\"id\":\"R1\""));
        assert!(s.contains("\"kind\":\"bash\""));
    }

    #[test]
    fn severity_check_constraint_rejects_unknown() {
        let conn = fresh_conn();
        let mut rule = make_rule("R1", "bash", true);
        rule.severity = "unknown".to_string();
        assert!(insert(&conn, &rule).is_err());
    }

    #[test]
    fn rule_serde_roundtrip() {
        let rule = make_rule("R1", "bash", true);
        let v = serde_json::to_value(&rule).unwrap();
        let back: Rule = serde_json::from_value(v).unwrap();
        assert_eq!(back, rule);
    }

    // -----------------------------------------------------------------
    // L1-6 — canonical_bytes_for_signing + verify_rule_signature
    // -----------------------------------------------------------------

    #[test]
    fn canonical_bytes_for_signing_includes_enabled() {
        let mut rule = make_rule("R1", "bash", true);
        let bytes_enabled = canonical_bytes_for_signing(&rule).unwrap();
        rule.enabled = false;
        let bytes_disabled = canonical_bytes_for_signing(&rule).unwrap();
        assert_ne!(
            bytes_enabled, bytes_disabled,
            "flipping `enabled` must change canonical bytes"
        );
        // Both encodings must literally contain the `"enabled"` field.
        for b in [&bytes_enabled, &bytes_disabled] {
            let s = std::str::from_utf8(b).unwrap();
            assert!(s.contains("\"enabled\""), "missing enabled in: {s}");
        }
    }

    #[test]
    fn canonical_bytes_for_signing_excludes_signature_and_attest_level() {
        let mut rule = make_rule("R1", "bash", true);
        rule.signature = Some(vec![1, 2, 3, 4]);
        rule.attest_level = "operator_signed".to_string();
        let bytes = canonical_bytes_for_signing(&rule).unwrap();
        let s = std::str::from_utf8(&bytes).unwrap();
        assert!(!s.contains("signature"), "got: {s}");
        assert!(!s.contains("attest_level"), "got: {s}");
        // created_at is also excluded so a re-sign on the same row
        // (even after a migration replay that wrote a fresh `now()`
        // timestamp) produces stable bytes.
        assert!(!s.contains("created_at"), "got: {s}");
    }

    #[test]
    fn verify_rule_signature_round_trips_under_correct_key() {
        use ed25519_dalek::Signer;
        let mut rule = make_rule("R1", "bash", false);
        let mut csprng = rand_core::OsRng;
        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
        let verifying = signing.verifying_key();
        let canonical = canonical_bytes_for_signing(&rule).unwrap();
        let sig = signing.sign(&canonical);
        rule.signature = Some(sig.to_bytes().to_vec());
        assert!(verify_rule_signature(&rule, &verifying).is_ok());
    }

    #[test]
    fn verify_rule_signature_fails_on_enabled_flip() {
        use ed25519_dalek::Signer;
        let mut rule = make_rule("R1", "bash", false);
        let mut csprng = rand_core::OsRng;
        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
        let verifying = signing.verifying_key();
        let canonical = canonical_bytes_for_signing(&rule).unwrap();
        let sig = signing.sign(&canonical);
        rule.signature = Some(sig.to_bytes().to_vec());
        // Now flip `enabled` after signing — verify must fail.
        rule.enabled = true;
        assert!(
            verify_rule_signature(&rule, &verifying).is_err(),
            "signature must not verify after `enabled` flip"
        );
    }

    #[test]
    fn verify_rule_signature_fails_on_matcher_tamper() {
        use ed25519_dalek::Signer;
        let mut rule = make_rule("R1", "bash", false);
        let mut csprng = rand_core::OsRng;
        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
        let verifying = signing.verifying_key();
        let canonical = canonical_bytes_for_signing(&rule).unwrap();
        let sig = signing.sign(&canonical);
        rule.signature = Some(sig.to_bytes().to_vec());
        // Tamper with matcher.
        rule.matcher = r#"{"k":"tampered"}"#.to_string();
        assert!(verify_rule_signature(&rule, &verifying).is_err());
    }

    #[test]
    fn verify_rule_signature_fails_under_wrong_key() {
        use ed25519_dalek::Signer;
        let mut rule = make_rule("R1", "bash", false);
        let mut csprng = rand_core::OsRng;
        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
        let other = ed25519_dalek::SigningKey::generate(&mut csprng);
        let canonical = canonical_bytes_for_signing(&rule).unwrap();
        let sig = signing.sign(&canonical);
        rule.signature = Some(sig.to_bytes().to_vec());
        // Verify under the wrong public key.
        assert!(verify_rule_signature(&rule, &other.verifying_key()).is_err());
    }

    #[test]
    fn verify_rule_signature_fails_on_missing_signature() {
        let mut csprng = rand_core::OsRng;
        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
        let rule = make_rule("R1", "bash", false);
        assert!(rule.signature.is_none());
        assert!(verify_rule_signature(&rule, &signing.verifying_key()).is_err());
    }

    #[test]
    fn verify_rule_signature_fails_on_wrong_length_signature() {
        let mut csprng = rand_core::OsRng;
        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
        let mut rule = make_rule("R1", "bash", false);
        rule.signature = Some(vec![0u8; 8]); // not 64
        assert!(verify_rule_signature(&rule, &signing.verifying_key()).is_err());
    }

    // -----------------------------------------------------------------
    // L1-6 — enforced_rule_passes
    // -----------------------------------------------------------------

    fn signed_rule(id: &str, enabled: bool, signing: &ed25519_dalek::SigningKey) -> Rule {
        use ed25519_dalek::Signer;
        let mut rule = make_rule(id, "bash", enabled);
        rule.attest_level = "operator_signed".to_string();
        let canonical = canonical_bytes_for_signing(&rule).unwrap();
        let sig = signing.sign(&canonical);
        rule.signature = Some(sig.to_bytes().to_vec());
        rule
    }

    #[test]
    fn enforced_rule_passes_when_no_pubkey_configured() {
        // Pre-L1-6 compat: every enabled rule passes through when no
        // operator pubkey is configured.
        let rule = make_rule("R1", "bash", true);
        assert!(enforced_rule_passes(&rule, None));
        // Even a row marked operator_signed but without a real
        // signature passes through when there is no key to verify
        // against — the substrate is in pre-L1-6 mode.
        let mut signed_ish = make_rule("R2", "bash", true);
        signed_ish.attest_level = "operator_signed".to_string();
        assert!(enforced_rule_passes(&signed_ish, None));
    }

    #[test]
    fn enforced_rule_passes_signed_under_correct_key() {
        let mut csprng = rand_core::OsRng;
        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
        let pk = signing.verifying_key();
        let rule = signed_rule("R1", false, &signing);
        assert!(enforced_rule_passes(&rule, Some(&pk)));
    }

    #[test]
    fn enforced_rule_passes_rejects_tampered_signed_row() {
        let mut csprng = rand_core::OsRng;
        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
        let pk = signing.verifying_key();
        let mut rule = signed_rule("R1", false, &signing);
        // Direct enabled-flip bypass attempt — sig no longer verifies.
        rule.enabled = true;
        assert!(!enforced_rule_passes(&rule, Some(&pk)));
    }

    #[test]
    fn enforced_rule_passes_rejects_unsigned_with_pubkey_configured() {
        let mut csprng = rand_core::OsRng;
        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
        let pk = signing.verifying_key();
        let rule = make_rule("R1", "bash", true); // attest_level = unsigned
        assert!(!enforced_rule_passes(&rule, Some(&pk)));
    }

    #[test]
    fn enforced_rule_passes_rejects_signed_under_wrong_key() {
        let mut csprng = rand_core::OsRng;
        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
        let other = ed25519_dalek::SigningKey::generate(&mut csprng);
        let rule = signed_rule("R1", false, &signing);
        assert!(!enforced_rule_passes(&rule, Some(&other.verifying_key())));
    }

    // -----------------------------------------------------------------
    // v0.7-polish coverage recovery (issue #767) — count_enabled_rules
    // edge cases + log_missing_operator_pubkey_once invocation.
    // -----------------------------------------------------------------

    #[test]
    fn count_enabled_rules_returns_zero_when_table_empty() {
        let conn = fresh_conn();
        assert_eq!(count_enabled_rules(&conn).unwrap(), 0);
    }

    #[test]
    fn count_enabled_rules_returns_zero_when_table_missing() {
        // Bare in-memory connection — never created governance_rules.
        // Maps `no such table` to Ok(0) per docstring contract.
        let conn = Connection::open_in_memory().unwrap();
        assert_eq!(count_enabled_rules(&conn).unwrap(), 0);
    }

    #[test]
    fn count_enabled_rules_counts_only_enabled_rows() {
        let conn = fresh_conn();
        insert(&conn, &make_rule("R1", "bash", true)).unwrap();
        insert(&conn, &make_rule("R2", "bash", false)).unwrap();
        insert(&conn, &make_rule("R3", "filesystem_write", true)).unwrap();
        // Two of three rows are enabled = 1.
        assert_eq!(count_enabled_rules(&conn).unwrap(), 2);
    }

    #[test]
    fn count_enabled_rules_single_enabled_row() {
        let conn = fresh_conn();
        insert(&conn, &make_rule("R1", "bash", true)).unwrap();
        assert_eq!(count_enabled_rules(&conn).unwrap(), 1);
    }

    #[test]
    fn log_missing_operator_pubkey_once_is_idempotent() {
        // The once-guard means repeat invocations are silent no-ops.
        // Drive it twice and confirm neither panics. The tracing
        // emission goes to the global subscriber; the assertion here
        // is that the once-cell mechanic works (no panic on second
        // call).
        log_missing_operator_pubkey_once(7);
        log_missing_operator_pubkey_once(99);
        // Reaching this line is the assertion: the second call did
        // not panic and returned cleanly via the `OnceLock::set` early
        // return.
    }

    #[test]
    fn resolve_operator_pubkey_returns_none_when_env_and_file_absent() {
        // The cert harness for resolve_operator_pubkey is platform-bound
        // (XDG paths differ on macOS / Linux). The trivial smoke is to
        // call it under a wiped env: in either platform, the env is
        // unset and the disk file does not exist for the test runner's
        // user, so we receive None. This pins the early-out path.
        //
        // SAFETY: tests in this module run serially within this binary
        // because they share a fresh_conn fixture; but other binaries
        // run in parallel — we therefore use the `_TEST_BENIGN` suffix
        // to avoid collision with any real env any other test may set.
        let prior = std::env::var("AI_MEMORY_OPERATOR_PUBKEY").ok();
        // SAFETY: temporarily clearing then restoring; cargo test runs
        // in a process not shared with prod, so this transient unset
        // is safe.
        unsafe { std::env::remove_var("AI_MEMORY_OPERATOR_PUBKEY") };
        let _ = resolve_operator_pubkey();
        // l1_6_attest_active just wraps resolve_operator_pubkey; smoke.
        let _ = l1_6_attest_active();
        if let Some(v) = prior {
            unsafe { std::env::set_var("AI_MEMORY_OPERATOR_PUBKEY", v) };
        }
    }

    #[test]
    fn resolve_operator_pubkey_accepts_url_safe_no_pad_base64() {
        use base64::Engine;
        // Generate a real verifying key and encode it.
        let mut csprng = rand_core::OsRng;
        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
        let vk = signing.verifying_key();
        let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(vk.as_bytes());

        let prior = std::env::var("AI_MEMORY_OPERATOR_PUBKEY").ok();
        unsafe { std::env::set_var("AI_MEMORY_OPERATOR_PUBKEY", &encoded) };
        let got = resolve_operator_pubkey();
        assert!(got.is_some(), "expected to decode URL_SAFE_NO_PAD pubkey");
        assert_eq!(got.unwrap().as_bytes(), vk.as_bytes());
        // Restore prior state.
        match prior {
            Some(v) => unsafe { std::env::set_var("AI_MEMORY_OPERATOR_PUBKEY", v) },
            None => unsafe { std::env::remove_var("AI_MEMORY_OPERATOR_PUBKEY") },
        }
    }

    /// H5 regression — the verifier must resolve the operator pubkey from
    /// the SAME key directory the signer writes to (honoring
    /// `AI_MEMORY_KEY_DIR`), reading the base64 keygen-layout
    /// `operator.key.pub` file. Pre-fix the verifier ignored
    /// `AI_MEMORY_KEY_DIR`, so a custom key-dir deployment resolved no
    /// pubkey and L1-6 attestation silently failed open.
    #[test]
    fn resolve_operator_pubkey_reads_keygen_layout_from_key_dir() {
        use base64::Engine;
        let _lock = crate::identity::keypair::key_dir_env_lock()
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);

        let mut csprng = rand_core::OsRng;
        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
        let vk = signing.verifying_key();
        let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(vk.as_bytes());

        let dir = tempfile::TempDir::new().expect("tempdir");
        std::fs::write(dir.path().join(OPERATOR_PUBKEY_KEYGEN_FILE), encoded).unwrap();

        // The env-var path takes precedence, so it MUST be unset for this
        // test to exercise the key-dir resolution branch.
        let prior_pubkey = std::env::var("AI_MEMORY_OPERATOR_PUBKEY").ok();
        let prior_key_dir = std::env::var("AI_MEMORY_KEY_DIR").ok();
        unsafe {
            std::env::remove_var("AI_MEMORY_OPERATOR_PUBKEY");
            std::env::set_var("AI_MEMORY_KEY_DIR", dir.path());
        }

        let got = resolve_operator_pubkey();

        // Restore env before asserting so a failed assertion never leaks
        // state into sibling tests.
        unsafe {
            match prior_pubkey {
                Some(v) => std::env::set_var("AI_MEMORY_OPERATOR_PUBKEY", v),
                None => std::env::remove_var("AI_MEMORY_OPERATOR_PUBKEY"),
            }
            match prior_key_dir {
                Some(v) => std::env::set_var("AI_MEMORY_KEY_DIR", v),
                None => std::env::remove_var("AI_MEMORY_KEY_DIR"),
            }
        }

        assert!(
            got.is_some(),
            "verifier must resolve operator.key.pub from AI_MEMORY_KEY_DIR"
        );
        assert_eq!(got.unwrap().as_bytes(), vk.as_bytes());
    }

    /// H5 regression — the verifier must also accept the legacy raw
    /// 32-byte `operator.pub` layout from the resolved key directory, so
    /// pre-keygen deployments keep verifying without re-enrolment.
    #[test]
    fn resolve_operator_pubkey_reads_legacy_raw_layout_from_key_dir() {
        let _lock = crate::identity::keypair::key_dir_env_lock()
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);

        let mut csprng = rand_core::OsRng;
        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
        let vk = signing.verifying_key();

        let dir = tempfile::TempDir::new().expect("tempdir");
        // Legacy layout: raw 32 bytes, no base64 wrapper.
        std::fs::write(dir.path().join(OPERATOR_PUBKEY_LEGACY_FILE), vk.as_bytes()).unwrap();

        let prior_pubkey = std::env::var("AI_MEMORY_OPERATOR_PUBKEY").ok();
        let prior_key_dir = std::env::var("AI_MEMORY_KEY_DIR").ok();
        unsafe {
            std::env::remove_var("AI_MEMORY_OPERATOR_PUBKEY");
            std::env::set_var("AI_MEMORY_KEY_DIR", dir.path());
        }

        let got = resolve_operator_pubkey();

        unsafe {
            match prior_pubkey {
                Some(v) => std::env::set_var("AI_MEMORY_OPERATOR_PUBKEY", v),
                None => std::env::remove_var("AI_MEMORY_OPERATOR_PUBKEY"),
            }
            match prior_key_dir {
                Some(v) => std::env::set_var("AI_MEMORY_KEY_DIR", v),
                None => std::env::remove_var("AI_MEMORY_KEY_DIR"),
            }
        }

        assert!(
            got.is_some(),
            "verifier must resolve legacy operator.pub from AI_MEMORY_KEY_DIR"
        );
        assert_eq!(got.unwrap().as_bytes(), vk.as_bytes());
    }
}
