//! Permission tiers, risk classification, and human-in-the-loop approval
//! as **durable harness state**.
//!
//! Motivated by the "Code as Agent Harness" survey (arXiv 2605.18747)
//! §3.4.3 and §5.2.5: a harness must act as a *safety governor* between
//! model intent and real-world consequence, not merely a tool executor.
//! Two ideas from that section are made concrete here:
//!
//! 1. **A multi-tier permission model.** Every action is classified by
//!    risk into [`PermissionTier::ReadOnly`], [`PermissionTier::SandboxEdit`],
//!    or [`PermissionTier::FullAccess`]. The session holds a *granted*
//!    standing tier; an action whose required tier exceeds it cannot run
//!    autonomously.
//! 2. **Human-in-the-loop as durable, auditable state.** Top-tier
//!    (externally-consequential / irreversible) actions are gated behind a
//!    mandatory human decision. That decision is not an ephemeral prompt:
//!    it is recorded in an [`ApprovalLedger`] — who approved or rejected
//!    what, when, on what evidence — that persists and feeds back into
//!    every later evaluation. "Each approval, rejection, policy exception,
//!    or reviewer correction should become durable harness state."
//!
//! The classifier and gate are pure and synchronous; the engine bridges
//! [`PermissionGate`] into its async authorization pipeline (see
//! `car-engine`'s `TierPermissionHandler`).

use car_ir::{Action, ActionType};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::io::Write as _;
use std::path::PathBuf;

/// Permission tiers, ordered by risk (survey §3.4.3). The `Ord` derive
/// makes `ReadOnly < SandboxEdit < FullAccess`, so "does the granted tier
/// cover the required tier?" is a single `>=`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PermissionTier {
    /// Observation only — state reads, retrieval, static inspection, log
    /// analysis. No mutation, no external effect.
    ReadOnly,
    /// Reversible local mutation — state writes, local patches, sandboxed
    /// tool calls, temporary dependency installs inside an isolated
    /// workspace.
    SandboxEdit,
    /// Externally-consequential or irreversible — network egress,
    /// credentials/secrets, deployment, destructive filesystem or VCS
    /// operations, financial/medical actions, physical control. Effects
    /// can extend beyond the sandbox.
    FullAccess,
}

impl PermissionTier {
    /// Does a session granted `self` cover an action requiring `required`?
    pub fn covers(self, required: PermissionTier) -> bool {
        self >= required
    }

    pub fn as_str(self) -> &'static str {
        match self {
            PermissionTier::ReadOnly => "read_only",
            PermissionTier::SandboxEdit => "sandbox_edit",
            PermissionTier::FullAccess => "full_access",
        }
    }

    pub fn from_str_opt(s: &str) -> Option<PermissionTier> {
        match s {
            "read_only" | "readonly" | "read" => Some(PermissionTier::ReadOnly),
            "sandbox_edit" | "sandbox" | "edit" => Some(PermissionTier::SandboxEdit),
            "full_access" | "full" => Some(PermissionTier::FullAccess),
            _ => None,
        }
    }
}

/// Substrings that, when found in a tool name or string parameter, mark an
/// action as [`PermissionTier::FullAccess`]. Conservative and additive:
/// the cost of over-classifying is an approval prompt; the cost of
/// under-classifying is an ungated consequential action.
const FULL_ACCESS_KEYWORDS: &[&str] = &[
    // Deploy / publish / release
    "deploy",
    "publish",
    "release",
    "kubectl",
    "terraform",
    "helm",
    "docker push",
    "npm publish",
    "cargo publish",
    "aws ",
    "gcloud",
    "az ",
    "apply",
    "rollout",
    // Credentials / secrets
    "credential",
    "secret",
    "token",
    "password",
    "api_key",
    "apikey",
    "ssh",
    "private key",
    "private_key",
    // Destructive filesystem / VCS
    "delete",
    "destroy",
    "drop",
    "drop table",
    "delete from",
    "truncate",
    "rm ",
    "rmdir",
    "unlink",
    "mkfs",
    "dd ",
    "format",
    "wipe",
    "git push",
    "push",
    "force-push",
    "force_push",
    "reset --hard",
    "git clean",
    "git reset",
    // Network egress
    "network",
    "http",
    "https",
    "curl",
    "wget",
    "fetch",
    "request",
    "egress",
    "upload",
    "download",
    // Money / messaging
    "payment",
    "charge",
    "refund",
    "transfer",
    "wire",
    "email",
    "send",
    "sms",
    // Privilege
    "sudo",
    "chmod",
    "chown",
    "setuid",
];

/// Tool-name segments that signal an irreversible / externally-consequential
/// capability.
///
/// Curated for IDENTIFIER matching (whole snake/camel segments), NOT free text
/// like [`FULL_ACCESS_KEYWORDS`]. Two deliberate differences from that list:
/// 1. Short verbs that collide with benign tool names as substrings are
///    EXCLUDED — `http` (`http_get` is a read), `token` (`count_tokens`,
///    `tokenize`), `request` (`request_id`), `fetch` (`prefetch`), `apply`
///    (`apply_template`), `network`, `format`, `transfer`. A substring matcher
///    would mis-route every one of those to quality-first.
/// 2. The space-bearing free-text keywords (`git push`, `rm -rf`, `git reset`)
///    are represented by their bare COMMAND segments (`push`, `rm`, `reset`, …)
///    so they actually fire on an identifier — a substring scan of the
///    free-text list never could (no tool name contains the literal `"rm "`).
const FULL_ACCESS_NAME_SEGMENTS: &[&str] = &[
    // deploy / publish / release
    "deploy",
    "publish",
    "release",
    "kubectl",
    "terraform",
    "helm",
    "rollout",
    // credentials / secrets
    "credential",
    "credentials",
    "secret",
    "secrets",
    "password",
    "passwd",
    // destructive filesystem / VCS
    "delete",
    "destroy",
    "drop",
    "truncate",
    "rm",
    "rmdir",
    "unlink",
    "mkfs",
    "dd",
    "wipe",
    "push",
    "reset",
    "clean",
    // network egress / external I/O
    "curl",
    "wget",
    "egress",
    "upload",
    "download",
    "send",
    // money
    "payment",
    "charge",
    "refund",
    "wire",
    // privilege
    "sudo",
    "chmod",
    "chown",
    "setuid",
];

/// Split a tool name into lowercase segments on non-alphanumeric boundaries AND
/// camelCase transitions, so `gitPush`, `git_push`, and `git-push` all yield
/// `["git", "push"]`. Matching whole segments (not substrings) is what makes the
/// name check honest: `count_tokens` → `["count", "tokens"]` does NOT match the
/// `token`-class danger, and `git_reset` → `["git", "reset"]` DOES match `reset`.
fn name_segments(name: &str) -> Vec<String> {
    let mut segs = Vec::new();
    let mut cur = String::new();
    let mut prev_lower_or_digit = false;
    for ch in name.chars() {
        if ch.is_alphanumeric() {
            // camelCase boundary: a lower/digit followed by an uppercase letter
            // starts a new segment (`gitPush` → `git` | `push`).
            if prev_lower_or_digit && ch.is_uppercase() && !cur.is_empty() {
                segs.push(std::mem::take(&mut cur));
            }
            cur.extend(ch.to_lowercase());
            prev_lower_or_digit = ch.is_lowercase() || ch.is_numeric();
        } else {
            if !cur.is_empty() {
                segs.push(std::mem::take(&mut cur));
            }
            prev_lower_or_digit = false;
        }
    }
    if !cur.is_empty() {
        segs.push(cur);
    }
    segs
}

/// True if a tool *name* names an irreversible / externally-consequential
/// capability — the stakes signal at the granularity available *before* an
/// action is built: a planning/agent loop knows its tool palette, not yet the
/// concrete action. Matches whole identifier segments against the curated
/// [`FULL_ACCESS_NAME_SEGMENTS`] set.
///
/// This is a SEGMENT-LEVEL APPROXIMATION of the irreversibility signal, NOT the
/// per-[`Action`] [`RiskClassifier::classify`] — it sees only the tool name, so
/// param-derived escalation (e.g. `rm -rf /` in a `cmd` arg under a generic
/// `shell` tool) is invisible here by construction. It is deliberately cleaner
/// than substring-scanning [`FULL_ACCESS_KEYWORDS`] over a name (no
/// `count_tokens`/`http_get` false positives, no space-keyword false negatives).
/// Used by the in-process autonomous loops (active-planner, agents) to route
/// generation quality-first — the analogue of the daemon's session-tier
/// `high_stakes` gate for the paths that bypass it. Mis-classification only
/// changes which model generates (cost), never an authz decision.
pub fn tool_name_is_full_access(name: &str) -> bool {
    name_segments(name)
        .iter()
        .any(|seg| FULL_ACCESS_NAME_SEGMENTS.contains(&seg.as_str()))
}

/// True if *any* of the supplied tool names is full-access — convenience over
/// [`tool_name_is_full_access`] for a loop assessing its whole tool palette.
/// The `AsRef<str>` item bound lets callers pass `&[String]`, `&HashSet<String>`,
/// or `&[&str]` without an explicit `.map(String::as_str)`.
pub fn any_tool_full_access<I>(names: I) -> bool
where
    I: IntoIterator,
    I::Item: AsRef<str>,
{
    names
        .into_iter()
        .any(|n| tool_name_is_full_access(n.as_ref()))
}

/// Append every string scalar reachable under `v` to `out`, space-
/// separated. Arrays are flattened in order, so an argv array like
/// `["git","push","--force"]` becomes `"git push --force"` and matches a
/// space-containing keyword that the raw JSON (`["git","push"]`) would
/// hide (neo review). Non-string scalars are stringified too.
fn collect_strings(v: &serde_json::Value, out: &mut String) {
    use serde_json::Value;
    match v {
        Value::String(s) => {
            out.push_str(s);
            out.push(' ');
        }
        Value::Array(items) => {
            for it in items {
                collect_strings(it, out);
            }
        }
        Value::Object(map) => {
            for val in map.values() {
                collect_strings(val, out);
            }
        }
        Value::Number(_) | Value::Bool(_) | Value::Null => {
            out.push_str(&v.to_string());
            out.push(' ');
        }
    }
}

/// Classifies an [`Action`] into the minimum [`PermissionTier`] required
/// to perform it. Combines a built-in heuristic with optional custom
/// rules; the result is the **highest** tier any signal implies, since
/// risk is monotonic (one high-risk signal escalates the whole action).
pub struct RiskClassifier {
    rules: Vec<ClassifierRule>,
}

struct ClassifierRule {
    #[allow(dead_code)]
    name: String,
    tier: PermissionTier,
    matcher: Box<dyn Fn(&Action) -> bool + Send + Sync>,
}

impl std::fmt::Debug for RiskClassifier {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("RiskClassifier")
            .field("rules", &self.rules.len())
            .finish()
    }
}

impl RiskClassifier {
    /// A classifier with only the built-in heuristic.
    pub fn new() -> Self {
        Self { rules: Vec::new() }
    }

    /// Add a custom rule. A matching rule can only *raise* the required
    /// tier (via `max`), never lower it — safety is the default.
    pub fn add_rule<F>(&mut self, name: &str, tier: PermissionTier, matcher: F)
    where
        F: Fn(&Action) -> bool + Send + Sync + 'static,
    {
        self.rules.push(ClassifierRule {
            name: name.to_string(),
            tier,
            matcher: Box::new(matcher),
        });
    }

    /// The built-in, keyword-free baseline from the action's *type*.
    fn baseline(action: &Action) -> PermissionTier {
        match action.action_type {
            // Reads and assertions never mutate or reach outside.
            ActionType::StateRead | ActionType::Assertion => PermissionTier::ReadOnly,
            // Local state mutation is reversible (snapshot/rollback).
            ActionType::StateWrite => PermissionTier::SandboxEdit,
            // A tool call's effects are opaque to static analysis; assume
            // it can mutate, but escalate to FullAccess only on a signal.
            ActionType::ToolCall => PermissionTier::SandboxEdit,
        }
    }

    /// Does the action's tool name or any (possibly nested, possibly
    /// argv-array) string parameter contain a full-access keyword? Builds
    /// one normalized haystack so array-encoded commands are matched.
    fn hits_full_access_keyword(action: &Action) -> bool {
        let mut hay = String::new();
        if let Some(tool) = &action.tool {
            hay.push_str(tool);
            hay.push(' ');
        }
        for v in action.parameters.values() {
            collect_strings(v, &mut hay);
        }
        let hay = hay.to_ascii_lowercase();
        FULL_ACCESS_KEYWORDS.iter().any(|k| hay.contains(k))
    }

    /// Classify an action into its minimum required tier.
    pub fn classify(&self, action: &Action) -> PermissionTier {
        let mut tier = Self::baseline(action);
        if action.action_type == ActionType::ToolCall && Self::hits_full_access_keyword(action) {
            tier = tier.max(PermissionTier::FullAccess);
        }
        for rule in &self.rules {
            if (rule.matcher)(action) {
                tier = tier.max(rule.tier);
            }
        }
        tier
    }
}

impl Default for RiskClassifier {
    fn default() -> Self {
        Self::new()
    }
}

/// Recursively canonicalize a JSON value so logically-identical values
/// produce byte-identical serializations: every object's keys are sorted
/// at *every* depth. Without this, two semantically identical params that
/// differ only in nested-object key order would fingerprint differently —
/// which would let a model evade a standing **rejection** simply by
/// permuting nested keys (neo review).
fn canonical_json(v: &serde_json::Value) -> serde_json::Value {
    use serde_json::Value;
    match v {
        Value::Object(map) => {
            let sorted: BTreeMap<&String, Value> = map
                .iter()
                .map(|(k, val)| (k, canonical_json(val)))
                .collect();
            Value::Object(
                sorted
                    .into_iter()
                    .map(|(k, val)| (k.clone(), val))
                    .collect(),
            )
        }
        Value::Array(items) => Value::Array(items.iter().map(canonical_json).collect()),
        other => other.clone(),
    }
}

/// The serde (snake_case) name of an action type — a **stable** wire
/// representation, unlike `Debug`, which carries no stability contract and
/// must never anchor a persisted key.
fn action_type_tag(t: &ActionType) -> &'static str {
    match t {
        ActionType::ToolCall => "tool_call",
        ActionType::StateWrite => "state_write",
        ActionType::StateRead => "state_read",
        ActionType::Assertion => "assertion",
    }
}

/// A stable fingerprint identifying "this kind of operation" so a human
/// approval/rejection can be matched against future occurrences — across
/// processes and builds. Built from the action type (stable serde tag),
/// tool, and **recursively** canonicalized parameters — *not* the action
/// id, which is not stable across proposals.
pub fn action_fingerprint(action: &Action) -> String {
    let canonical: BTreeMap<&String, serde_json::Value> = action
        .parameters
        .iter()
        .map(|(k, v)| (k, canonical_json(v)))
        .collect();
    let params = serde_json::to_string(&canonical).unwrap_or_default();
    let tool = action.tool.as_deref().unwrap_or("-");
    format!(
        "{}|{}|{}",
        action_type_tag(&action.action_type),
        tool,
        params
    )
}

/// Whether a recorded human decision approved or rejected an operation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalDecision {
    Approved,
    Rejected,
}

/// A durable record of a human-in-the-loop decision — the auditable state
/// transition §5.2.5 calls for: what was proposed, who decided, why, and
/// against what evidence.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalRecord {
    /// Stable [`action_fingerprint`] this decision applies to.
    pub fingerprint: String,
    /// Tier the action required when the decision was made.
    pub required_tier: PermissionTier,
    pub decision: ApprovalDecision,
    /// Identity of the human (or delegated authority) who decided.
    pub reviewer: String,
    /// Why the decision was made — the recorded rationale.
    pub reason: String,
    /// Evidence shown at decision time (diff summary, risk surface, etc.).
    #[serde(default)]
    pub evidence: Option<String>,
    /// RFC3339 timestamp of the decision.
    pub decided_at: String,
}

/// An append-only ledger of human-in-the-loop decisions, keyed by
/// fingerprint (last decision wins). Optionally persisted as JSONL so the
/// approval state survives restarts — HITL decisions are *durable* harness
/// state, not transient prompts.
///
/// Concurrency: a single writer per journal file is assumed. The
/// stateless FFI opens a fresh ledger per call, so a product that drives
/// approvals from multiple processes against one journal must serialize
/// those writes itself (e.g. route them through the daemon). Reads
/// tolerate the writer appending concurrently; a torn final line is
/// skipped on load and counted in [`ApprovalLedger::skipped_on_load`].
#[derive(Debug, Default)]
pub struct ApprovalLedger {
    records: HashMap<String, ApprovalRecord>,
    journal: Option<PathBuf>,
    /// Count of unparseable lines skipped during the last load — nonzero
    /// signals journal corruption or a concurrent torn write, so callers
    /// can surface it rather than silently trusting a partial ledger.
    skipped_on_load: usize,
}

impl ApprovalLedger {
    pub fn new() -> Self {
        Self::default()
    }

    /// Create a ledger backed by a JSONL journal at `path`, loading any
    /// existing decisions. Each line is one [`ApprovalRecord`]; the last
    /// line for a fingerprint wins, so a later rejection overrides an
    /// earlier approval.
    pub fn with_journal(path: impl Into<PathBuf>) -> std::io::Result<Self> {
        let path = path.into();
        let mut ledger = Self {
            records: HashMap::new(),
            journal: Some(path.clone()),
            skipped_on_load: 0,
        };
        if path.exists() {
            let contents = std::fs::read_to_string(&path)?;
            // Lines are read in file (append) order, so a later decision
            // for a fingerprint overwrites an earlier one — last wins.
            for line in contents.lines() {
                let line = line.trim();
                if line.is_empty() {
                    continue;
                }
                match serde_json::from_str::<ApprovalRecord>(line) {
                    Ok(rec) => {
                        ledger.records.insert(rec.fingerprint.clone(), rec);
                    }
                    Err(_) => ledger.skipped_on_load += 1,
                }
            }
        }
        Ok(ledger)
    }

    /// Number of unparseable lines skipped during the load. Nonzero means
    /// the journal is corrupt or was torn by a concurrent writer.
    pub fn skipped_on_load(&self) -> usize {
        self.skipped_on_load
    }

    /// Record a decision, persisting it to the journal when configured.
    /// Returns the stored record.
    ///
    /// A journal write failure is an **error, not best-effort** (review A7):
    /// callers emit `ApprovalRecorded` audit events on the strength of this
    /// call, so a decision that only landed in memory must not be reported
    /// as durable. On `Err` the decision is NOT stored (memory and journal
    /// stay consistent — both lack it) and the caller must surface the
    /// failure. An in-memory ledger (no journal) cannot fail.
    pub fn record(&mut self, record: ApprovalRecord) -> std::io::Result<&ApprovalRecord> {
        if let Some(path) = &self.journal {
            // Durable append: write the whole line in one buffered call
            // then flush, so a crash can't leave the decision only in
            // memory while the caller believes it persisted.
            let mut f = std::fs::OpenOptions::new()
                .create(true)
                .append(true)
                .open(path)?;
            let mut line = serde_json::to_string(&record)
                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
            line.push('\n');
            f.write_all(line.as_bytes())?;
            f.flush()?;
        }
        // Last-wins: overwrite any prior decision for this fingerprint.
        use std::collections::hash_map::Entry;
        Ok(match self.records.entry(record.fingerprint.clone()) {
            Entry::Occupied(mut o) => {
                o.insert(record);
                o.into_mut()
            }
            Entry::Vacant(v) => v.insert(record),
        })
    }

    /// The current decision for a fingerprint, if any.
    pub fn lookup(&self, fingerprint: &str) -> Option<&ApprovalRecord> {
        self.records.get(fingerprint)
    }

    pub fn all(&self) -> impl Iterator<Item = &ApprovalRecord> {
        self.records.values()
    }

    /// Build and [`record`](Self::record) a decision against an explicit
    /// fingerprint — the ledger-level twin of
    /// [`PermissionGate::record_for_fingerprint`], for callers that share
    /// ONE ledger across many gates/sessions (e.g. the daemon's shared
    /// approval substrate) and so must not route the write through any
    /// single session's gate. Errs when the journal write fails (the
    /// decision is then NOT recorded — see [`Self::record`]).
    #[allow(clippy::too_many_arguments)]
    pub fn record_decision(
        &mut self,
        fingerprint: &str,
        required_tier: PermissionTier,
        decision: ApprovalDecision,
        reviewer: &str,
        reason: &str,
        evidence: Option<String>,
    ) -> std::io::Result<ApprovalRecord> {
        let record = ApprovalRecord {
            fingerprint: fingerprint.to_string(),
            required_tier,
            decision,
            reviewer: reviewer.to_string(),
            reason: reason.to_string(),
            evidence,
            decided_at: chrono::Utc::now().to_rfc3339(),
        };
        self.record(record.clone())?;
        Ok(record)
    }
}

/// A set of hazards partitioned against the durable [`ApprovalLedger`]: those
/// that must not run, and those still awaiting a human decision (paired with the
/// fingerprint the decision is keyed by).
///
/// The shared shape behind the per-domain HITL bridges
/// ([`crate::flow_gate::enforce_flow`], [`crate::intent_gate::enforce_intent`]):
/// each maps this into its own domain-specific enforcement struct + reason.
#[derive(Debug, Clone)]
pub struct LedgerPartition<V> {
    /// Hazards that must not run: the hard blocks passed in, plus anything a
    /// human previously **rejected**.
    pub blocked: Vec<V>,
    /// Novel hazards awaiting a human decision, each paired with its ledger
    /// fingerprint.
    pub pending: Vec<(String, V)>,
}

/// Resolve a `require_approval` hazard set against the durable ledger, the one
/// place that fixes the HITL semantics: a hazard a human previously **approved**
/// is dropped (let through), one they **rejected** joins `hard_blocked`, and an
/// **unseen** one becomes pending. `fingerprint` maps a hazard to its stable
/// ledger key (the per-domain part callers keep specialized).
pub fn partition_by_ledger<V, F>(
    hard_blocked: Vec<V>,
    needs_approval: &[V],
    fingerprint: F,
    ledger: &ApprovalLedger,
) -> LedgerPartition<V>
where
    V: Clone,
    F: Fn(&V) -> String,
{
    let mut blocked = hard_blocked;
    let mut pending = Vec::new();
    for v in needs_approval {
        let fp = fingerprint(v);
        match ledger.lookup(&fp).map(|r| r.decision) {
            Some(ApprovalDecision::Approved) => { /* a human OK'd this hazard before */ }
            Some(ApprovalDecision::Rejected) => blocked.push(v.clone()),
            None => pending.push((fp, v.clone())),
        }
    }
    LedgerPartition { blocked, pending }
}

/// The outcome of evaluating an action against the gate.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "decision", rename_all = "snake_case")]
pub enum GateDecision {
    /// May proceed autonomously. `required` and `granted` explain why.
    Allow {
        required: PermissionTier,
        granted: PermissionTier,
    },
    /// Autonomy is suspended pending a human decision (survey §5.2.5).
    /// The caller surfaces the request; a later [`PermissionGate::approve`]
    /// or [`PermissionGate::reject`] resolves it.
    NeedsApproval {
        required: PermissionTier,
        granted: PermissionTier,
        fingerprint: String,
        reason: String,
    },
    /// Refused — a human already rejected this operation.
    Deny {
        required: PermissionTier,
        fingerprint: String,
        reason: String,
    },
}

impl GateDecision {
    pub fn is_allow(&self) -> bool {
        matches!(self, GateDecision::Allow { .. })
    }
}

/// The permission gate: a session's standing authority plus the classifier
/// and the durable approval ledger. Pure and synchronous so it can be
/// embedded anywhere; the engine wraps it for its async pipeline.
#[derive(Debug)]
pub struct PermissionGate {
    /// Standing authority granted to this session.
    granted: PermissionTier,
    /// Actions at or above this tier *always* require a human decision,
    /// even when the granted tier would cover them — the "mandatory HITL
    /// gate" for consequential actions (§5.2.5). Default: `FullAccess`.
    require_approval_at: PermissionTier,
    classifier: RiskClassifier,
    ledger: ApprovalLedger,
}

impl PermissionGate {
    /// A gate with the given standing tier, default classifier, mandatory
    /// approval at `FullAccess`, and an in-memory ledger.
    pub fn new(granted: PermissionTier) -> Self {
        Self {
            granted,
            require_approval_at: PermissionTier::FullAccess,
            classifier: RiskClassifier::new(),
            ledger: ApprovalLedger::new(),
        }
    }

    pub fn with_classifier(mut self, classifier: RiskClassifier) -> Self {
        self.classifier = classifier;
        self
    }

    pub fn with_ledger(mut self, ledger: ApprovalLedger) -> Self {
        self.ledger = ledger;
        self
    }

    /// Override the tier at and above which approval is mandatory.
    pub fn with_mandatory_approval_at(mut self, tier: PermissionTier) -> Self {
        self.require_approval_at = tier;
        self
    }

    pub fn granted_tier(&self) -> PermissionTier {
        self.granted
    }

    pub fn set_granted_tier(&mut self, tier: PermissionTier) {
        self.granted = tier;
    }

    pub fn classifier(&self) -> &RiskClassifier {
        &self.classifier
    }

    pub fn ledger(&self) -> &ApprovalLedger {
        &self.ledger
    }

    /// Evaluate an action. Precedence:
    /// 1. A prior **rejection** denies (a human said no).
    /// 2. A prior **approval** allows (a human elevated this operation).
    /// 3. Actions at/above the mandatory-approval tier need approval.
    /// 4. Otherwise the granted tier must cover the required tier.
    /// 5. Exceeding standing authority escalates to a human, not a hard
    ///    deny — autonomy is suspended, not the task abandoned.
    pub fn evaluate(&self, action: &Action) -> GateDecision {
        self.evaluate_with_granted(action, self.granted, &self.ledger)
    }

    /// [`Self::evaluate`], but consulting an **external** ledger instead of the
    /// gate's own. For deployments that share ONE approval ledger across many
    /// per-session gates (per-session *tier*, shared *approvals*): the daemon
    /// keeps a single journal-backed ledger on its server state so an approval
    /// recorded on one connection is visible to every other and survives
    /// restart — a per-connection in-memory ledger would strand the approver's
    /// decision where the runner never reads it.
    pub fn evaluate_against(&self, action: &Action, ledger: &ApprovalLedger) -> GateDecision {
        self.evaluate_with_granted(action, self.granted, ledger)
    }

    /// Evaluate an action as if the session's standing authority were capped at
    /// `ceiling` — the join between skill-trust governance (arXiv 2602.12430) and
    /// the action-level gate. The effective authority is `min(granted, ceiling)`,
    /// so an action driven by a skill whose deployment ceiling is `read_only`
    /// cannot perform a `sandbox_edit` operation even in a `full_access` session;
    /// it escalates to a human instead. A `None` ceiling is identical to
    /// [`Self::evaluate`].
    ///
    /// The caller supplies the ceiling — it is the persisted
    /// `SkillMeta::deployment_tier` of the skill that drove the action, which the
    /// caller already knows (it retrieved the skill). The gate does not invent
    /// action→skill provenance; it honours the ceiling it is handed.
    pub fn evaluate_with_ceiling(
        &self,
        action: &Action,
        ceiling: Option<PermissionTier>,
    ) -> GateDecision {
        self.evaluate_with_ceiling_against(action, ceiling, &self.ledger)
    }

    /// [`Self::evaluate_with_ceiling`] against an external ledger — see
    /// [`Self::evaluate_against`] for when a shared ledger is the substrate.
    pub fn evaluate_with_ceiling_against(
        &self,
        action: &Action,
        ceiling: Option<PermissionTier>,
        ledger: &ApprovalLedger,
    ) -> GateDecision {
        let effective = match ceiling {
            Some(c) => self.granted.min(c),
            None => self.granted,
        };
        self.evaluate_with_granted(action, effective, ledger)
    }

    /// Core evaluation against an explicit effective `granted` tier (so a skill
    /// ceiling can cap standing authority without mutating the gate). Precedence:
    /// 1. A prior **rejection** denies (a human said no).
    /// 2. A prior **approval** allows (a human elevated this operation).
    /// 3. Actions at/above the mandatory-approval tier need approval.
    /// 4. Otherwise the effective granted tier must cover the required tier.
    /// 5. Exceeding standing authority escalates to a human, not a hard
    ///    deny — autonomy is suspended, not the task abandoned.
    fn evaluate_with_granted(
        &self,
        action: &Action,
        granted: PermissionTier,
        ledger: &ApprovalLedger,
    ) -> GateDecision {
        let required = self.classifier.classify(action);
        let fingerprint = action_fingerprint(action);

        if let Some(rec) = ledger.lookup(&fingerprint) {
            match rec.decision {
                ApprovalDecision::Rejected => {
                    return GateDecision::Deny {
                        required,
                        fingerprint,
                        reason: format!("previously rejected by {} ({})", rec.reviewer, rec.reason),
                    };
                }
                // An approval is scoped to the risk that was actually
                // reviewed. If the operation has since been reclassified
                // *upward* (a new keyword, a new custom rule), the stale
                // approval must not bypass the mandatory gate — re-prompt
                // instead (neo review: tier-blind stale approvals).
                ApprovalDecision::Approved if required <= rec.required_tier => {
                    return GateDecision::Allow { required, granted };
                }
                ApprovalDecision::Approved => {
                    return GateDecision::NeedsApproval {
                        required,
                        granted,
                        fingerprint,
                        reason: format!(
                            "operation reclassified {} → {} since it was approved; re-approval required",
                            rec.required_tier.as_str(),
                            required.as_str()
                        ),
                    };
                }
            }
        }

        if required >= self.require_approval_at {
            return GateDecision::NeedsApproval {
                required,
                granted,
                fingerprint,
                reason: format!(
                    "{} actions require human approval before execution",
                    required.as_str()
                ),
            };
        }

        if granted.covers(required) {
            GateDecision::Allow { required, granted }
        } else {
            GateDecision::NeedsApproval {
                required,
                granted,
                fingerprint,
                reason: format!(
                    "action requires {} but session is granted only {}",
                    required.as_str(),
                    granted.as_str()
                ),
            }
        }
    }

    /// Record a human approval for the operation `action` represents.
    /// Errs when the ledger journal write fails (the decision is then NOT
    /// recorded — see [`ApprovalLedger::record`]).
    pub fn approve(
        &mut self,
        action: &Action,
        reviewer: &str,
        reason: &str,
        evidence: Option<String>,
    ) -> std::io::Result<ApprovalRecord> {
        self.record_decision(
            action,
            ApprovalDecision::Approved,
            reviewer,
            reason,
            evidence,
        )
    }

    /// Record a human rejection for the operation `action` represents.
    /// Errs when the ledger journal write fails (the decision is then NOT
    /// recorded — see [`ApprovalLedger::record`]).
    pub fn reject(
        &mut self,
        action: &Action,
        reviewer: &str,
        reason: &str,
        evidence: Option<String>,
    ) -> std::io::Result<ApprovalRecord> {
        self.record_decision(
            action,
            ApprovalDecision::Rejected,
            reviewer,
            reason,
            evidence,
        )
    }

    /// Record a decision against an explicit fingerprint (when the caller
    /// holds the fingerprint from a prior `NeedsApproval`, not the action).
    /// Errs when the ledger journal write fails (the decision is then NOT
    /// recorded — see [`ApprovalLedger::record`]).
    pub fn record_for_fingerprint(
        &mut self,
        fingerprint: &str,
        required_tier: PermissionTier,
        decision: ApprovalDecision,
        reviewer: &str,
        reason: &str,
        evidence: Option<String>,
    ) -> std::io::Result<ApprovalRecord> {
        self.ledger.record_decision(
            fingerprint,
            required_tier,
            decision,
            reviewer,
            reason,
            evidence,
        )
    }

    /// Build (but do NOT store) the [`ApprovalRecord`] for a decision on
    /// `action` — the gate classifies the action and derives its fingerprint;
    /// the caller records the result on whichever ledger is the substrate
    /// (its own via [`ApprovalLedger::record`], or a shared daemon ledger).
    /// This is what lets a per-session gate keep its tier/classifier while the
    /// approval store is shared.
    pub fn decision_record(
        &self,
        action: &Action,
        decision: ApprovalDecision,
        reviewer: &str,
        reason: &str,
        evidence: Option<String>,
    ) -> ApprovalRecord {
        ApprovalRecord {
            fingerprint: action_fingerprint(action),
            required_tier: self.classifier.classify(action),
            decision,
            reviewer: reviewer.to_string(),
            reason: reason.to_string(),
            evidence,
            decided_at: chrono::Utc::now().to_rfc3339(),
        }
    }

    fn record_decision(
        &mut self,
        action: &Action,
        decision: ApprovalDecision,
        reviewer: &str,
        reason: &str,
        evidence: Option<String>,
    ) -> std::io::Result<ApprovalRecord> {
        let record = self.decision_record(action, decision, reviewer, reason, evidence);
        self.ledger.record(record.clone())?;
        Ok(record)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use car_ir::{ActionType, FailureBehavior};
    use std::collections::HashMap as Map;

    fn action(
        action_type: ActionType,
        tool: Option<&str>,
        params: Map<String, serde_json::Value>,
    ) -> Action {
        Action {
            id: "a1".to_string(),
            action_type,
            tool: tool.map(str::to_string),
            parameters: params,
            preconditions: vec![],
            expected_effects: HashMap::new(),
            state_dependencies: vec![],
            read_set: vec![],
            write_set: vec![],
            assumptions: vec![],
            invocation_mode: Default::default(),
            idempotent: false,
            max_retries: 3,
            failure_behavior: FailureBehavior::Abort,
            timeout_ms: None,
            metadata: HashMap::new(),
        }
    }

    fn tool_call(tool: &str) -> Action {
        action(ActionType::ToolCall, Some(tool), Map::new())
    }

    #[test]
    fn tool_name_full_access_matches_irreversible_capabilities() {
        // Irreversible / externally-consequential tool names are full-access —
        // including the space-bearing-keyword cases a substring scan would MISS:
        // `git_reset`/`git_clean` have no bare `reset`/`clean` in the free-text
        // list, only `"git reset"`/`"git clean"` (with spaces), which can't occur
        // in an identifier. Segment matching catches them.
        for name in [
            "deploy",
            "git_push",
            "gitPush",
            "delete_file",
            "kubectl_apply",
            "git_reset",
            "git_clean",
            "send_email",
            "sudo_run",
            "rm_rf_dir",
            "drop_table",
            "upload_artifact",
        ] {
            assert!(
                tool_name_is_full_access(name),
                "{name} should classify as full-access"
            );
        }
    }

    #[test]
    fn tool_name_full_access_does_not_false_positive_on_benign_names() {
        // The collision cases that a raw substring scan of the free-text keyword
        // list gets WRONG. `count_tokens`/`tokenize` (vs `token`), `http_get`
        // (vs `http`), `apply_template` (vs `apply`), `request_id` (vs
        // `request`), `prefetch_cache` (vs `fetch`) must all be benign — segment
        // matching + a curated name set is what buys this.
        for name in [
            "read_file",
            "grep",
            "search",
            "summarize",
            "classify",
            "count_tokens",
            "tokenize",
            "token_usage",
            "http_get",
            "https_health",
            "apply_template",
            "request_id",
            "parse_request",
            "prefetch_cache",
            "transfer_learning",
            "format_date",
            "network_topology",
            "dropdown_open",
        ] {
            assert!(
                !tool_name_is_full_access(name),
                "{name} should NOT classify as full-access (false positive)"
            );
        }
    }

    #[test]
    fn name_segments_splits_snake_and_camel() {
        assert_eq!(name_segments("git_push"), vec!["git", "push"]);
        assert_eq!(name_segments("gitPush"), vec!["git", "push"]);
        assert_eq!(name_segments("git-push"), vec!["git", "push"]);
        assert_eq!(name_segments("count_tokens"), vec!["count", "tokens"]);
        // `tokens` is its own segment and never equals the `token` danger word.
        assert!(!name_segments("count_tokens").iter().any(|s| s == "token"));
    }

    #[test]
    fn any_tool_full_access_scans_the_palette() {
        // Works over &[&str], owned String collections, and is false on empty.
        assert!(any_tool_full_access(["read_file", "grep", "deploy"]));
        assert!(!any_tool_full_access(["read_file", "grep", "summarize"]));
        assert!(!any_tool_full_access(std::iter::empty::<&str>()));
        let owned: Vec<String> = vec!["read_file".into(), "git_push".into()];
        assert!(any_tool_full_access(&owned));
    }

    #[test]
    fn tier_ordering() {
        assert!(PermissionTier::FullAccess.covers(PermissionTier::ReadOnly));
        assert!(PermissionTier::SandboxEdit.covers(PermissionTier::SandboxEdit));
        assert!(!PermissionTier::ReadOnly.covers(PermissionTier::SandboxEdit));
    }

    #[test]
    fn classifier_baseline_by_type() {
        let c = RiskClassifier::new();
        assert_eq!(
            c.classify(&action(ActionType::StateRead, None, Map::new())),
            PermissionTier::ReadOnly
        );
        assert_eq!(
            c.classify(&action(ActionType::Assertion, None, Map::new())),
            PermissionTier::ReadOnly
        );
        assert_eq!(
            c.classify(&action(ActionType::StateWrite, None, Map::new())),
            PermissionTier::SandboxEdit
        );
        assert_eq!(c.classify(&tool_call("echo")), PermissionTier::SandboxEdit);
    }

    #[test]
    fn classifier_escalates_on_keyword_in_tool_name() {
        let c = RiskClassifier::new();
        assert_eq!(
            c.classify(&tool_call("deploy_service")),
            PermissionTier::FullAccess
        );
        assert_eq!(
            c.classify(&tool_call("http_get")),
            PermissionTier::FullAccess
        );
    }

    #[test]
    fn classifier_escalates_on_keyword_in_params() {
        let mut params = Map::new();
        params.insert("cmd".to_string(), serde_json::json!("rm -rf /tmp/x"));
        let a = action(ActionType::ToolCall, Some("shell"), params);
        assert_eq!(
            RiskClassifier::new().classify(&a),
            PermissionTier::FullAccess
        );
    }

    #[test]
    fn custom_rule_only_raises() {
        let mut c = RiskClassifier::new();
        c.add_rule("flag_search", PermissionTier::FullAccess, |a| {
            a.tool.as_deref() == Some("search")
        });
        assert_eq!(c.classify(&tool_call("search")), PermissionTier::FullAccess);
        // A read action a rule matches at a lower tier stays at the
        // higher baseline — rules never lower.
        let mut c2 = RiskClassifier::new();
        c2.add_rule("noop", PermissionTier::ReadOnly, |_| true);
        assert_eq!(
            c2.classify(&action(ActionType::StateWrite, None, Map::new())),
            PermissionTier::SandboxEdit
        );
    }

    #[test]
    fn gate_allows_within_granted_tier() {
        let gate = PermissionGate::new(PermissionTier::SandboxEdit);
        let d = gate.evaluate(&action(ActionType::StateWrite, None, Map::new()));
        assert!(d.is_allow(), "{d:?}");
    }

    #[test]
    fn gate_escalates_above_granted_tier() {
        let gate = PermissionGate::new(PermissionTier::ReadOnly);
        let d = gate.evaluate(&action(ActionType::StateWrite, None, Map::new()));
        assert!(matches!(d, GateDecision::NeedsApproval { .. }), "{d:?}");
    }

    #[test]
    fn skill_ceiling_caps_below_granted_tier() {
        // The session can perform sandbox edits...
        let gate = PermissionGate::new(PermissionTier::SandboxEdit);
        let act = action(ActionType::StateWrite, None, Map::new()); // classifies SandboxEdit
        assert!(gate.evaluate(&act).is_allow());
        // ...but an action driven by a skill capped at read_only cannot: the
        // same action escalates to a human instead of running.
        let capped = gate.evaluate_with_ceiling(&act, Some(PermissionTier::ReadOnly));
        assert!(
            matches!(capped, GateDecision::NeedsApproval { .. }),
            "{capped:?}"
        );
        // No ceiling is identical to evaluate.
        assert!(gate.evaluate_with_ceiling(&act, None).is_allow());
    }

    #[test]
    fn skill_ceiling_at_or_above_granted_is_noop() {
        let gate = PermissionGate::new(PermissionTier::SandboxEdit);
        let act = action(ActionType::StateWrite, None, Map::new());
        // A ceiling at or above the granted tier leaves the outcome unchanged
        // (effective authority is min(granted, ceiling)).
        assert!(gate
            .evaluate_with_ceiling(&act, Some(PermissionTier::SandboxEdit))
            .is_allow());
        assert!(gate
            .evaluate_with_ceiling(&act, Some(PermissionTier::FullAccess))
            .is_allow());
    }

    #[test]
    fn gate_full_access_always_needs_approval_even_when_granted() {
        // Mandatory HITL: a FullAccess action is gated even for a
        // FullAccess session.
        let gate = PermissionGate::new(PermissionTier::FullAccess);
        let d = gate.evaluate(&tool_call("deploy"));
        assert!(matches!(d, GateDecision::NeedsApproval { .. }), "{d:?}");
    }

    #[test]
    fn approval_makes_future_evaluation_allow() {
        let mut gate = PermissionGate::new(PermissionTier::ReadOnly);
        let a = tool_call("deploy");
        assert!(matches!(
            gate.evaluate(&a),
            GateDecision::NeedsApproval { .. }
        ));
        gate.approve(&a, "matt", "reviewed the deploy plan", None)
            .unwrap();
        assert!(gate.evaluate(&a).is_allow());
    }

    #[test]
    fn rejection_denies_future_evaluation() {
        let mut gate = PermissionGate::new(PermissionTier::FullAccess);
        let a = tool_call("transfer_funds");
        gate.reject(&a, "matt", "not authorized", None).unwrap();
        assert!(matches!(gate.evaluate(&a), GateDecision::Deny { .. }));
    }

    #[test]
    fn shared_ledger_approval_is_visible_across_gates() {
        // The daemon substrate: per-session gates (tier state), ONE shared
        // ledger. An approval recorded through gate A's classification must
        // flip gate B's evaluation — with per-gate in-memory ledgers the
        // approver's decision would be stranded where the runner never reads
        // it (kernel review C1).
        let gate_a = PermissionGate::new(PermissionTier::ReadOnly);
        let gate_b = PermissionGate::new(PermissionTier::ReadOnly);
        let mut shared = ApprovalLedger::new();
        let a = tool_call("deploy");

        assert!(matches!(
            gate_b.evaluate_against(&a, &shared),
            GateDecision::NeedsApproval { .. }
        ));
        // Approver (gate A's session) builds the record; the SHARED ledger
        // stores it.
        let rec = gate_a.decision_record(&a, ApprovalDecision::Approved, "matt", "ok", None);
        shared.record(rec).unwrap();
        // Runner (gate B's session) sees it.
        assert!(gate_b.evaluate_against(&a, &shared).is_allow());
        // The gates' own (empty) ledgers still gate — nothing leaked into them.
        assert!(matches!(
            gate_b.evaluate(&a),
            GateDecision::NeedsApproval { .. }
        ));
    }

    #[test]
    fn ledger_record_decision_round_trips_fingerprint() {
        let mut ledger = ApprovalLedger::new();
        let rec = ledger
            .record_decision(
                "harness:retry:abcd1234",
                PermissionTier::SandboxEdit,
                ApprovalDecision::Approved,
                "conn:1",
                "reviewed",
                None,
            )
            .unwrap();
        assert_eq!(rec.fingerprint, "harness:retry:abcd1234");
        assert_eq!(
            ledger.lookup("harness:retry:abcd1234").map(|r| r.decision),
            Some(ApprovalDecision::Approved)
        );
    }

    #[test]
    fn classifier_escalates_on_argv_array_command() {
        // The dangerous command is split across an argv array, so the raw
        // JSON never contains the literal "git push" substring — the
        // recursive haystack must still catch it.
        let mut params = Map::new();
        params.insert(
            "args".to_string(),
            serde_json::json!(["git", "push", "--force", "origin", "main"]),
        );
        let a = action(ActionType::ToolCall, Some("shell"), params);
        assert_eq!(
            RiskClassifier::new().classify(&a),
            PermissionTier::FullAccess
        );
    }

    #[test]
    fn fingerprint_canonicalizes_nested_object_key_order() {
        // Two semantically identical actions whose params differ only in
        // NESTED object key order must share a fingerprint — otherwise a
        // standing rejection is evadable by permuting nested keys.
        let mk = |json: serde_json::Value| {
            let mut p = Map::new();
            p.insert("opts".to_string(), json);
            action(ActionType::ToolCall, Some("t"), p)
        };
        let a = mk(serde_json::json!({"a": 1, "b": {"x": 1, "y": 2}}));
        let b = mk(serde_json::json!({"b": {"y": 2, "x": 1}, "a": 1}));
        assert_eq!(action_fingerprint(&a), action_fingerprint(&b));
    }

    #[test]
    fn fingerprint_uses_stable_type_tag_not_debug() {
        // The fingerprint must carry the stable serde tag, never the
        // Debug spelling.
        let fp = action_fingerprint(&tool_call("x"));
        assert!(fp.starts_with("tool_call|"), "got {fp}");
        assert!(!fp.contains("ToolCall"));
    }

    #[test]
    fn approval_does_not_survive_upward_reclassification() {
        // An operation approved at SandboxEdit must NOT remain allowed
        // after a classifier change pushes it to FullAccess.
        let a = tool_call("safe_tool");
        // Record an approval at SandboxEdit (the tier when reviewed).
        let mut classifier = RiskClassifier::new();
        let mut gate = PermissionGate::new(PermissionTier::SandboxEdit).with_classifier(classifier);
        gate.approve(&a, "matt", "looked fine", None).unwrap();
        assert!(gate.evaluate(&a).is_allow());

        // Now a stricter classifier reclassifies the same op as FullAccess.
        classifier = RiskClassifier::new();
        classifier.add_rule("now_dangerous", PermissionTier::FullAccess, |act| {
            act.tool.as_deref() == Some("safe_tool")
        });
        let gate = gate.with_classifier(classifier);
        assert!(
            matches!(gate.evaluate(&a), GateDecision::NeedsApproval { .. }),
            "stale low-tier approval must not bypass the FullAccess gate"
        );
    }

    #[test]
    fn fingerprint_is_param_sensitive_and_stable() {
        let a1 = {
            let mut p = Map::new();
            p.insert("x".to_string(), serde_json::json!(1));
            p.insert("y".to_string(), serde_json::json!(2));
            action(ActionType::ToolCall, Some("t"), p)
        };
        let a2 = {
            // same params, inserted in a different order → same fingerprint
            let mut p = Map::new();
            p.insert("y".to_string(), serde_json::json!(2));
            p.insert("x".to_string(), serde_json::json!(1));
            action(ActionType::ToolCall, Some("t"), p)
        };
        assert_eq!(action_fingerprint(&a1), action_fingerprint(&a2));
        // different params → different fingerprint
        let a3 = {
            let mut p = Map::new();
            p.insert("x".to_string(), serde_json::json!(99));
            action(ActionType::ToolCall, Some("t"), p)
        };
        assert_ne!(action_fingerprint(&a1), action_fingerprint(&a3));
    }

    #[test]
    fn ledger_journal_round_trips() {
        let dir = std::env::temp_dir();
        let path = dir.join(format!("car-approvals-test-{}.jsonl", std::process::id()));
        let _ = std::fs::remove_file(&path);

        let a = tool_call("deploy");
        {
            let ledger = ApprovalLedger::with_journal(&path).unwrap();
            let mut gate = PermissionGate::new(PermissionTier::ReadOnly).with_ledger(ledger);
            gate.approve(&a, "matt", "ok", Some("diff: +1 -0".to_string()))
                .unwrap();
        }
        // A fresh ledger loading the same journal sees the decision.
        let ledger2 = ApprovalLedger::with_journal(&path).unwrap();
        let gate2 = PermissionGate::new(PermissionTier::ReadOnly).with_ledger(ledger2);
        assert!(gate2.evaluate(&a).is_allow());

        let _ = std::fs::remove_file(&path);
    }

    #[test]
    fn unwritable_journal_errors_and_stores_nothing() {
        // Review A7: a journal write failure must surface as an error, and
        // the decision must NOT land in memory either — otherwise callers
        // emit ApprovalRecorded audit events for a decision that isn't
        // durable. A directory as the journal path makes the append fail.
        let dir = std::env::temp_dir().join(format!("car-approvals-dir-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();

        let mut ledger = ApprovalLedger {
            records: HashMap::new(),
            journal: Some(dir.clone()),
            skipped_on_load: 0,
        };
        let a = tool_call("deploy");
        let fp = action_fingerprint(&a);
        let err = ledger.record(ApprovalRecord {
            fingerprint: fp.clone(),
            required_tier: PermissionTier::FullAccess,
            decision: ApprovalDecision::Approved,
            reviewer: "matt".into(),
            reason: "ok".into(),
            evidence: None,
            decided_at: chrono::Utc::now().to_rfc3339(),
        });
        assert!(err.is_err(), "journal write failure must surface");
        assert!(
            ledger.lookup(&fp).is_none(),
            "failed record must not be stored in memory"
        );

        let _ = std::fs::remove_dir_all(&dir);
    }
}
