Skip to main content

basil_core/core/
reconcile.rs

1//! Startup catalog reconcile: apply each key's `missing` policy (design §3.7).
2//!
3//! After the [`BackendManager`] is built and **before** the broker binds its
4//! socket, [`BackendManager::reconcile`] walks every catalog key, checks whether
5//! its material exists in the routed [`Backend`], and applies the key's
6//! [`MissingPolicy`]:
7//!
8//! - **present** → no-op.
9//! - **`error`** (default) + absent → collected into a **fatal** reconcile error
10//!   (every missing required key is reported at once, not just the first).
11//! - **`warn`** + absent → a [`tracing::warn`] line; the key's ops fail at request
12//!   time until it exists.
13//! - **`generate`** + absent → create material: a crypto key (`asymmetric` /
14//!   `symmetric`) at its catalog path via the backend's named-create methods, a
15//!   `value` / `public` key by running its `generate` recipe and writing it as the
16//!   first KV-v2 version.
17//!
18//! # Absent vs. unreachable
19//!
20//! The existence probe distinguishes **absent** (a reachable backend with no
21//! material: a `404`, surfaced as [`BackendError::KeyNotFound`]) from
22//! **unreachable / failed** (a transport error or any other backend rejection). A
23//! backend that is *down* during reconcile is a clean **fatal** startup error
24//! ([`ReconcileError::Probe`]). It is never silently treated as "absent" and
25//! generated/over, which would mask an outage and could double-create material
26//! once the backend recovers. Fail closed.
27
28use crate::backend::BackendError;
29use crate::catalog::{Class, Engine, GenerateSpec, KeyAlgorithm, KeyEntry, MissingPolicy};
30use crate::manager::{BackendManager, ManagerError, Routed};
31use basil_proto::{AeadAlgorithm, KeyType};
32
33/// The outcome of a successful reconcile pass: a summary for the startup log.
34#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
35pub struct ReconcileSummary {
36    /// Keys whose material already existed (no action taken).
37    pub present: usize,
38    /// Keys that were absent and **generated** (crypto keys + value/public).
39    pub generated: usize,
40    /// Keys that were absent under `missing="warn"` (logged, left absent).
41    pub warned: usize,
42}
43
44/// A fatal reconcile failure (fail closed).
45///
46/// Either a backend was unreachable while probing for a key (never treated as
47/// "absent"), one or more required keys are missing, or generating an absent
48/// `missing="generate"` key failed.
49#[derive(Debug, thiserror::Error)]
50pub enum ReconcileError {
51    /// One or more `missing="error"` (or default) keys are absent from their
52    /// backend. Every such key is reported, not just the first.
53    #[error("required key(s) absent from backend: {}", .0.join(", "))]
54    RequiredMissing(Vec<String>),
55
56    /// A backend was **unreachable** (or otherwise failed) while probing a key's
57    /// existence. This is *not* "absent": a down backend at startup is fatal, so
58    /// reconcile fails closed rather than generating over a real-but-unreachable
59    /// key.
60    #[error("probing key `{key}` failed (backend unreachable or rejecting): {source}")]
61    Probe {
62        /// The key being probed.
63        key: String,
64        /// The underlying backend error (transport / rejection).
65        source: BackendError,
66    },
67
68    /// Generating material for an absent `missing="generate"` key failed.
69    #[error("generating key `{key}` failed: {source}")]
70    Generate {
71        /// The key being generated.
72        key: String,
73        /// The underlying manager/backend error.
74        source: ManagerError,
75    },
76}
77
78/// Whether a probe found the key present or cleanly absent. A fatal (unreachable)
79/// backend is surfaced as the surrounding [`Result`]'s `Err`, not a variant here.
80enum Existence {
81    Present,
82    Absent,
83}
84
85/// The state of one catalog key in a [`CheckReport`] (the read-only counterpart
86/// to reconcile's apply step).
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum KeyStatus {
89    /// The key's material exists in its backend (a clean probe hit).
90    Present,
91    /// The key's material is absent from its (reachable) backend. The key's
92    /// [`MissingPolicy`] tells the caller how reconcile *would* treat it:
93    /// `error` (would fail startup), `warn` (logged, left absent), or `generate`
94    /// (would be created). A `check` never creates anything.
95    Missing(MissingPolicy),
96}
97
98/// A single `(key, status)` row of a [`CheckReport`].
99#[derive(Debug, Clone)]
100pub struct KeyCheck {
101    /// The dotted catalog name.
102    pub name: String,
103    /// Whether the key is present or absent (and, if absent, its `missing` policy).
104    pub status: KeyStatus,
105}
106
107/// The result of a **read-only** [`BackendManager::check`] pass.
108///
109/// Every catalog key is classified as present or missing (with its `missing`
110/// policy), in catalog name order. Unlike [`BackendManager::reconcile`], `check`
111/// never creates or mutates anything; it only reports. A backend that is
112/// *unreachable* during the probe is still a fatal error (the surrounding
113/// [`Result`]'s `Err`), never silently read as "absent".
114#[derive(Debug, Clone, Default)]
115pub struct CheckReport {
116    /// One row per catalog key, in name order.
117    pub keys: Vec<KeyCheck>,
118}
119
120impl CheckReport {
121    /// Count of keys whose material is present.
122    #[must_use]
123    pub fn present_count(&self) -> usize {
124        self.keys
125            .iter()
126            .filter(|k| k.status == KeyStatus::Present)
127            .count()
128    }
129
130    /// All absent keys, in name order, with their `missing` policy.
131    pub fn missing(&self) -> impl Iterator<Item = (&str, MissingPolicy)> {
132        self.keys.iter().filter_map(|k| match k.status {
133            KeyStatus::Missing(policy) => Some((k.name.as_str(), policy)),
134            KeyStatus::Present => None,
135        })
136    }
137
138    /// The dotted names of absent keys whose `missing` policy is `error`: the
139    /// required keys whose absence would fail startup reconcile. This is the
140    /// predicate `--require` gates on: a CI check **should fail** iff this is
141    /// non-empty.
142    #[must_use]
143    pub fn required_missing(&self) -> Vec<&str> {
144        self.missing()
145            .filter(|(_, policy)| *policy == MissingPolicy::Error)
146            .map(|(name, _)| name)
147            .collect()
148    }
149
150    /// Whether a `--require` gate should fail: true iff at least one absent key
151    /// carries the `error` (required) policy. `warn`/`generate`-absent keys do
152    /// **not** trip this: they are not failures of a pre-deploy check.
153    #[must_use]
154    pub fn should_fail_required(&self) -> bool {
155        self.missing()
156            .any(|(_, policy)| policy == MissingPolicy::Error)
157    }
158}
159
160impl BackendManager {
161    /// Reconcile every catalog key against its backend, applying the key's
162    /// [`MissingPolicy`](crate::catalog::MissingPolicy) (§3.7).
163    ///
164    /// Returns a [`ReconcileSummary`] on success. Fails closed with a
165    /// [`ReconcileError`] if a backend is unreachable while probing, if any
166    /// required (`error`) key is absent, or if generating an absent
167    /// `generate` key fails. All required-missing keys are collected and reported
168    /// together.
169    ///
170    /// # Errors
171    ///
172    /// [`ReconcileError::Probe`] (unreachable backend), [`ReconcileError::RequiredMissing`]
173    /// (one or more `error`-policy keys absent), or [`ReconcileError::Generate`]
174    /// (a `generate` key could not be created).
175    pub async fn reconcile(&self) -> Result<ReconcileSummary, ReconcileError> {
176        use crate::catalog::MissingPolicy;
177
178        let mut summary = ReconcileSummary::default();
179        let mut required_missing: Vec<String> = Vec::new();
180
181        // Collect (name, entry) up front: `resolve` borrows `self` immutably and
182        // every op below is also `&self`, so a single immutable walk is fine.
183        let keys: Vec<(String, KeyEntry)> = self
184            .keys()
185            .map(|(name, entry)| (name.clone(), entry.clone()))
186            .collect();
187
188        for (name, entry) in &keys {
189            match self.probe(name).await? {
190                Existence::Present => summary.present += 1,
191                Existence::Absent => match entry.missing {
192                    MissingPolicy::Error => {
193                        required_missing.push(name.clone());
194                    }
195                    MissingPolicy::Warn => {
196                        tracing::warn!(
197                            key = %name,
198                            "catalog key absent from backend (missing=warn); ops will fail until it exists"
199                        );
200                        summary.warned += 1;
201                    }
202                    MissingPolicy::Generate => {
203                        self.generate_missing(name, entry).await?;
204                        tracing::info!(key = %name, "generated absent key (missing=generate)");
205                        summary.generated += 1;
206                    }
207                },
208            }
209        }
210
211        if !required_missing.is_empty() {
212            required_missing.sort();
213            return Err(ReconcileError::RequiredMissing(required_missing));
214        }
215        Ok(summary)
216    }
217
218    /// **Read-only** counterpart to [`reconcile`](Self::reconcile): probe every
219    /// catalog key and report whether it is present or absent (with its
220    /// [`MissingPolicy`](crate::catalog::MissingPolicy)), **without** generating
221    /// or mutating anything. For CI / pre-deploy lint checks.
222    ///
223    /// Reuses the same existence probe as `reconcile` (crypto via `key_metadata`,
224    /// value/public via `kv_get`). A backend that is unreachable during the probe
225    /// is *not* "absent": it is a fatal [`ReconcileError::Probe`] (fail closed),
226    /// exactly as in reconcile: a down backend must never be reported as a clean
227    /// "missing" that a caller might act on.
228    ///
229    /// Returns a [`CheckReport`] over every key in catalog name order. Use
230    /// [`CheckReport::should_fail_required`] to gate a `--require` CI check.
231    ///
232    /// # Errors
233    ///
234    /// [`ReconcileError::Probe`] if any backend is unreachable (or rejecting)
235    /// while probing a key's existence.
236    pub async fn check(&self) -> Result<CheckReport, ReconcileError> {
237        // Catalog iteration is name-ordered (BTreeMap), so the report is too.
238        let mut report = CheckReport::default();
239        for (name, entry) in self.keys() {
240            let status = match self.probe(name).await? {
241                Existence::Present => KeyStatus::Present,
242                Existence::Absent => KeyStatus::Missing(entry.missing),
243            };
244            report.keys.push(KeyCheck {
245                name: name.clone(),
246                status,
247            });
248        }
249        Ok(report)
250    }
251
252    /// Probe whether `name`'s material exists in its backend.
253    ///
254    /// Crypto keys (`asymmetric` / `symmetric`) probe via `key_metadata` (a
255    /// transit key-info read); `value` / `public` keys probe via `kv_get` of the
256    /// latest version. A [`BackendError::KeyNotFound`] (a backend `404`) is the
257    /// clean **absent** signal; **any other** error means the backend is
258    /// unreachable or rejecting and is a fatal [`ReconcileError::Probe`]: a down
259    /// backend is never silently treated as "absent".
260    async fn probe(&self, name: &str) -> Result<Existence, ReconcileError> {
261        let routed = self.resolve(name).map_err(|source| ReconcileError::Probe {
262            key: name.to_string(),
263            // An UnknownKey/UnknownBackend here is a manager-construction
264            // invariant violation, surfaced through the backend error channel.
265            source: match source {
266                ManagerError::Backend(e) => e,
267                other => BackendError::Backend(other.to_string()),
268            },
269        })?;
270
271        // Probe by where the material actually lives: transit keys via
272        // `key_metadata`, KV-backed material (value/public, and the
273        // materialize-to-use keys) via a `kv_get`. Branch on the effective engine,
274        // not just the class, so a materialize-to-sign key is probed in KV rather
275        // than against a transit name that would 404.
276        let result = match (routed.class(), routed.engine) {
277            // Materialize-to-use keys (sealing X25519, engine=kv2 Ed25519,
278            // §17.7): the private lives in KV, and basil-o86 provisions the
279            // public out of band too. BOTH halves must exist for the key to be
280            // "present": an absent public is as fatal (under missing=error) as an
281            // absent private, since wrap/get_public_key/verify need it.
282            (Class::Asymmetric, Engine::Kv2) | (Class::Sealing, _) => {
283                self.probe_materialize_to_use(&routed).await
284            }
285            (Class::Asymmetric | Class::Symmetric, _) => {
286                routed.backend.key_metadata(routed.path()).await.map(|_| ())
287            }
288            (Class::Value | Class::Public, _) => {
289                routed.backend.kv_get(routed.path(), None).await.map(|_| ())
290            }
291        };
292
293        match result {
294            Ok(()) => Ok(Existence::Present),
295            Err(BackendError::KeyNotFound(_)) => Ok(Existence::Absent),
296            Err(source) => Err(ReconcileError::Probe {
297                key: name.to_string(),
298                source,
299            }),
300        }
301    }
302
303    /// Existence probe for a materialize-to-use key (sealing X25519 / engine=kv2
304    /// Ed25519, §17.7): both the private (at `path`) **and** the
305    /// out-of-band-provisioned public (at `public_path`, basil-o86) must exist in
306    /// KV. A [`BackendError::KeyNotFound`] from either is the clean "absent"
307    /// signal the caller maps to [`Existence::Absent`]; any other error is fatal.
308    /// A materialize key with no `public_path` is a misprovisioned catalog (the
309    /// loader normally rejects it), surfaced as "absent" so it fails closed under
310    /// `missing=error` rather than booting a key whose public can never resolve.
311    async fn probe_materialize_to_use(&self, routed: &Routed<'_>) -> Result<(), BackendError> {
312        // The private half (materialized seed / X25519 private).
313        routed.backend.kv_get(routed.path(), None).await?;
314        // The public half, provisioned out of band.
315        let public_path = routed.public_path().ok_or_else(|| {
316            BackendError::KeyNotFound(format!(
317                "{} has no public_path; its public half is not provisioned",
318                routed.path()
319            ))
320        })?;
321        routed.backend.kv_get(public_path, None).await?;
322        Ok(())
323    }
324
325    /// Create material for an absent `missing="generate"` key: a crypto key at its
326    /// catalog path, or a value/public secret from its `generate` recipe written
327    /// as the first KV-v2 version.
328    async fn generate_missing(&self, name: &str, entry: &KeyEntry) -> Result<(), ReconcileError> {
329        let to_err = |source| ReconcileError::Generate {
330            key: name.to_string(),
331            source,
332        };
333        let routed = self.resolve(name).map_err(to_err)?;
334        let path = routed.path().to_string();
335
336        match entry.class {
337            // A materialize-to-sign Ed25519 key (`engine=kv2`, `vault-iiz`) cannot
338            // be generated through transit: Basil has no in-broker keygen for it,
339            // and writing a fresh seed in reconcile would mint signing authority
340            // silently. Its 32-byte seed is provisioned out of band (operator writes
341            // the base64 seed into KV). Fail closed, mirroring the sealing arm.
342            // Never log the (absent) value.
343            Class::Asymmetric if routed.engine == Engine::Kv2 => {
344                return Err(ReconcileError::Generate {
345                    key: name.to_string(),
346                    source: ManagerError::Backend(BackendError::Backend(format!(
347                        "value-store signing key `{name}` (engine=kv2) cannot be \
348                         generated in-broker; provision its Ed25519 seed out of band"
349                    ))),
350                });
351            }
352            Class::Asymmetric => {
353                let key_type = asym_key_type(name, entry.key_type).map_err(to_err)?;
354                routed
355                    .require_mint_key_type("generate", key_type)
356                    .map_err(to_err)?;
357                routed
358                    .backend
359                    .create_named_key(&path, key_type)
360                    .await
361                    .map_err(ManagerError::from)
362                    .map_err(to_err)?;
363            }
364            Class::Symmetric => {
365                let aead = sym_aead(name, entry.key_type).map_err(to_err)?;
366                routed
367                    .backend
368                    .create_named_aead(&path, aead)
369                    .await
370                    .map_err(ManagerError::from)
371                    .map_err(to_err)?;
372            }
373            Class::Sealing => {
374                // Basil cannot generate an X25519 keypair through transit (no such
375                // engine), so a sealing key's private must be provisioned out of
376                // band (BYOK `set` of the raw X25519 private). Fail closed rather
377                // than silently leaving the key absent.
378                return Err(ReconcileError::Generate {
379                    key: name.to_string(),
380                    source: ManagerError::Backend(BackendError::Backend(format!(
381                        "sealing key `{name}` cannot be generated in-broker; \
382                         provision its X25519 private out of band"
383                    ))),
384                });
385            }
386            Class::Value | Class::Public => {
387                // The loader guarantees a value/public key with missing=generate
388                // carries a recipe (GenerateWithoutRecipe is a fatal load error),
389                // so `generate` is Some here; fail closed if it somehow is not.
390                let spec: &GenerateSpec =
391                    entry
392                        .generate
393                        .as_ref()
394                        .ok_or_else(|| ReconcileError::Generate {
395                            key: name.to_string(),
396                            source: ManagerError::Backend(BackendError::Backend(
397                                "missing=generate value/public key has no generate recipe".into(),
398                            )),
399                        })?;
400                for write in self
401                    .generated_writes_for_key(name, spec)
402                    .await
403                    .map_err(to_err)?
404                {
405                    let write_route = self.resolve(&write.key_id).map_err(to_err)?;
406                    write_route
407                        .backend
408                        .kv_put(write_route.path(), &write.value)
409                        .await
410                        .map_err(ManagerError::from)
411                        .map_err(to_err)?;
412                }
413            }
414        }
415        Ok(())
416    }
417}
418
419/// The wire [`KeyType`] for an asymmetric catalog `key_type`. The loader requires
420/// asymmetric keys to carry a `key_type`; an absent or non-signing algorithm here
421/// is a misconfigured catalog and fails closed.
422fn asym_key_type(name: &str, key_type: Option<KeyAlgorithm>) -> Result<KeyType, ManagerError> {
423    match key_type {
424        Some(KeyAlgorithm::Ed25519) => Ok(KeyType::Ed25519),
425        Some(KeyAlgorithm::Ed25519Nkey) => Ok(KeyType::Ed25519Nkey),
426        Some(KeyAlgorithm::Rsa2048) => Ok(KeyType::Rsa2048),
427        Some(KeyAlgorithm::EcdsaP256) => Ok(KeyType::EcdsaP256),
428        Some(KeyAlgorithm::EcdsaP384) => Ok(KeyType::EcdsaP384),
429        Some(KeyAlgorithm::EcdsaP521) => Ok(KeyType::EcdsaP521),
430        // ML-DSA software-custodied signing keys are not transit keys: their seed
431        // is generated, sealed, and written as a custody record by the
432        // local-software provider (the `new_key` RPC path), never created through
433        // a backend `create_named_key` at startup reconcile. Fail closed if a
434        // catalog marks one `missing=generate`.
435        Some(KeyAlgorithm::MlDsa44 | KeyAlgorithm::MlDsa65 | KeyAlgorithm::MlDsa87) => {
436            Err(ManagerError::Backend(BackendError::Backend(format!(
437                "ML-DSA signing key `{name}` is software-custodied; provision it \
438                 via the `new_key` provider path, not startup generate"
439            ))))
440        }
441        Some(
442            KeyAlgorithm::Aes256Gcm
443            | KeyAlgorithm::ChaCha20Poly1305
444            | KeyAlgorithm::X25519
445            | KeyAlgorithm::MlKem512
446            | KeyAlgorithm::MlKem768
447            | KeyAlgorithm::MlKem1024,
448        )
449        | None => Err(ManagerError::Backend(BackendError::Backend(format!(
450            "asymmetric key `{name}` has no signing keyType to generate from"
451        )))),
452    }
453}
454
455/// The [`AeadAlgorithm`] for a symmetric catalog `key_type`. A symmetric key
456/// always carries an AEAD algorithm; anything else fails closed.
457fn sym_aead(name: &str, key_type: Option<KeyAlgorithm>) -> Result<AeadAlgorithm, ManagerError> {
458    match key_type {
459        Some(KeyAlgorithm::Aes256Gcm) => Ok(AeadAlgorithm::Aes256Gcm),
460        Some(KeyAlgorithm::ChaCha20Poly1305) => Ok(AeadAlgorithm::Chacha20Poly1305),
461        _ => Err(ManagerError::Backend(BackendError::Backend(format!(
462            "symmetric key `{name}` has no AEAD keyType to generate from"
463        )))),
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use crate::backend::{Backend, KeyMetadata, KvValue, NewKey, PublicKey};
471    use crate::catalog::Catalog;
472    use async_trait::async_trait;
473    use std::collections::BTreeMap;
474    use std::sync::Mutex;
475    use std::sync::atomic::{AtomicUsize, Ordering};
476
477    /// How a mock backend answers an existence probe (`key_metadata` / `kv_get`).
478    #[derive(Clone, Copy)]
479    enum Probe {
480        /// The key exists, so probes return `Ok` (Present).
481        Present,
482        /// The key is absent, so probes return `KeyNotFound` (a backend 404).
483        Absent,
484        /// The backend is unreachable, so probes return a transport error (fatal).
485        Unreachable,
486    }
487
488    /// A reconcile-focused mock: a single backend whose probe disposition is fixed
489    /// at construction, recording every create/write op so a test can assert which
490    /// generation path ran.
491    #[derive(Default)]
492    struct Recorder {
493        create_named_key_calls: AtomicUsize,
494        create_named_aead_calls: AtomicUsize,
495        kv_put_calls: AtomicUsize,
496        last_create_path: Mutex<Option<String>>,
497        last_kv_put: Mutex<Option<(String, Vec<u8>)>>,
498        last_aead: Mutex<Option<AeadAlgorithm>>,
499        last_key_type: Mutex<Option<KeyType>>,
500    }
501
502    struct MockBackend {
503        probe: Probe,
504        rec: std::sync::Arc<Recorder>,
505    }
506
507    impl MockBackend {
508        fn new(probe: Probe) -> (Self, std::sync::Arc<Recorder>) {
509            let rec = std::sync::Arc::new(Recorder::default());
510            (
511                Self {
512                    probe,
513                    rec: rec.clone(),
514                },
515                rec,
516            )
517        }
518    }
519
520    #[async_trait]
521    impl Backend for MockBackend {
522        fn kind(&self) -> &'static str {
523            "mock"
524        }
525
526        async fn new_key(&self, _key_type: KeyType) -> Result<NewKey, BackendError> {
527            Err(BackendError::Unsupported(
528                "new_key (unused in reconcile tests)",
529            ))
530        }
531
532        async fn public_key(&self, _key_id: &str) -> Result<Vec<u8>, BackendError> {
533            Err(BackendError::Unsupported("public_key"))
534        }
535
536        async fn public_key_with_meta(&self, _key_id: &str) -> Result<PublicKey, BackendError> {
537            Err(BackendError::Unsupported("public_key_with_meta"))
538        }
539
540        /// The crypto-key existence probe.
541        async fn key_metadata(&self, _key_id: &str) -> Result<KeyMetadata, BackendError> {
542            match self.probe {
543                Probe::Present => Ok(KeyMetadata {
544                    key_type: Some(KeyType::Ed25519),
545                    latest_version: 1,
546                }),
547                Probe::Absent => Err(BackendError::KeyNotFound("absent".into())),
548                Probe::Unreachable => Err(BackendError::Transport("connection refused".into())),
549            }
550        }
551
552        /// The value/public existence probe.
553        async fn kv_get(
554            &self,
555            _key_id: &str,
556            _version: Option<u32>,
557        ) -> Result<KvValue, BackendError> {
558            match self.probe {
559                Probe::Present => Ok(KvValue {
560                    value: b"present".to_vec(),
561                    version: 1,
562                }),
563                Probe::Absent => Err(BackendError::KeyNotFound("absent".into())),
564                Probe::Unreachable => Err(BackendError::Transport("connection refused".into())),
565            }
566        }
567
568        /// Sealing is never reconciled (a sealing private is operator-provisioned
569        /// out-of-band), so this mock mirrors the value/public probe shape.
570        async fn kv_get_secret(
571            &self,
572            _key_id: &str,
573            _version: Option<u32>,
574        ) -> Result<zeroize::Zeroizing<Vec<u8>>, BackendError> {
575            match self.probe {
576                Probe::Present => Ok(zeroize::Zeroizing::new(b"present".to_vec())),
577                Probe::Absent => Err(BackendError::KeyNotFound("absent".into())),
578                Probe::Unreachable => Err(BackendError::Transport("connection refused".into())),
579            }
580        }
581
582        async fn create_named_key(
583            &self,
584            key_id: &str,
585            key_type: KeyType,
586        ) -> Result<NewKey, BackendError> {
587            self.rec
588                .create_named_key_calls
589                .fetch_add(1, Ordering::SeqCst);
590            *self.rec.last_create_path.lock().unwrap() = Some(key_id.to_string());
591            *self.rec.last_key_type.lock().unwrap() = Some(key_type);
592            Ok(NewKey {
593                key_id: key_id.to_string(),
594                public_key: vec![1, 2, 3],
595            })
596        }
597
598        async fn create_named_aead(
599            &self,
600            key_id: &str,
601            aead: AeadAlgorithm,
602        ) -> Result<(), BackendError> {
603            self.rec
604                .create_named_aead_calls
605                .fetch_add(1, Ordering::SeqCst);
606            *self.rec.last_create_path.lock().unwrap() = Some(key_id.to_string());
607            *self.rec.last_aead.lock().unwrap() = Some(aead);
608            Ok(())
609        }
610
611        async fn kv_put(&self, key_id: &str, value: &[u8]) -> Result<u32, BackendError> {
612            self.rec.kv_put_calls.fetch_add(1, Ordering::SeqCst);
613            *self.rec.last_kv_put.lock().unwrap() = Some((key_id.to_string(), value.to_vec()));
614            Ok(1)
615        }
616
617        async fn sign(&self, _key_id: &str, _message: &[u8]) -> Result<Vec<u8>, BackendError> {
618            Err(BackendError::Unsupported("sign"))
619        }
620
621        async fn verify(
622            &self,
623            _key_id: &str,
624            _message: &[u8],
625            _signature: &[u8],
626        ) -> Result<bool, BackendError> {
627            Err(BackendError::Unsupported("verify"))
628        }
629    }
630
631    /// A catalog with one key of each (class, missing) combination this reconcile
632    /// exercises, all routed to the single mock backend `b`.
633    const CATALOG: &str = r#"{
634      "schemaVersion": 1,
635      "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
636      "keys": {
637        "req.signer": {
638          "class": "asymmetric", "keyType": "ed25519", "backend": "b",
639          "path": "req-signer", "writable": true, "missing": "error",
640          "description": "a required signing key"
641        },
642        "warn.value": {
643          "class": "value", "backend": "b", "engine": "kv2",
644          "path": "secret/data/warn/value", "writable": true, "missing": "warn",
645          "description": "a warn-on-missing value"
646        },
647        "gen.signer": {
648          "class": "asymmetric", "keyType": "ed25519", "backend": "b",
649          "path": "gen-signer", "writable": true, "missing": "generate",
650          "description": "a generate-on-missing signing key"
651        },
652        "gen.box": {
653          "class": "symmetric", "keyType": "aes-256-gcm", "backend": "b",
654          "path": "gen-box", "writable": true, "missing": "generate",
655          "description": "a generate-on-missing AEAD key"
656        },
657        "gen.value": {
658          "class": "value", "backend": "b", "engine": "kv2",
659          "path": "secret/data/gen/value", "writable": true, "missing": "generate",
660          "generate": { "format": "ascii-printable", "bytes": 24 },
661          "description": "a generate-on-missing value"
662        }
663      }
664    }"#;
665
666    fn parse() -> Catalog {
667        serde_json::from_str(CATALOG).expect("catalog parses")
668    }
669
670    /// Build a manager over the fixture catalog, every key routed to one mock with
671    /// the given probe disposition; returns the manager + the recorder.
672    fn manager_with(probe: Probe) -> (BackendManager, std::sync::Arc<Recorder>) {
673        let (backend, rec) = MockBackend::new(probe);
674        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
675        backends.insert("b".into(), Box::new(backend));
676        let mgr = BackendManager::new(parse(), backends).expect("manager constructs");
677        (mgr, rec)
678    }
679
680    #[tokio::test]
681    async fn all_present_is_a_noop_with_present_count() {
682        let (mgr, rec) = manager_with(Probe::Present);
683        let summary = mgr.reconcile().await.expect("all-present reconciles ok");
684        assert_eq!(summary.present, 5);
685        assert_eq!(summary.generated, 0);
686        assert_eq!(summary.warned, 0);
687        // Nothing was created when every key already existed.
688        assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
689        assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 0);
690        assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
691    }
692
693    #[tokio::test]
694    async fn required_missing_is_fatal_and_reports_the_key() {
695        // Every key is absent: req.signer (missing=error) must make reconcile fail,
696        // and the error names it. warn/generate keys do NOT make it fail.
697        let (mgr, _rec) = manager_with(Probe::Absent);
698        let err = mgr
699            .reconcile()
700            .await
701            .expect_err("a required-missing key must fail closed");
702        match err {
703            ReconcileError::RequiredMissing(keys) => {
704                assert_eq!(keys, vec!["req.signer".to_string()]);
705            }
706            other => panic!("expected RequiredMissing, got {other:?}"),
707        }
708    }
709
710    #[tokio::test]
711    async fn warn_missing_is_ok_and_counted() {
712        // Make ONLY the warn key absent by using a catalog where the error key is
713        // dropped; here we instead assert the warn count surfaces when the run
714        // otherwise succeeds. Use a generate-everything probe path: with all keys
715        // absent, the warn key is logged + counted but the required key still
716        // fails. So test warn in isolation via a catalog with no required keys.
717        const NO_REQUIRED: &str = r#"{
718          "schemaVersion": 1,
719          "backends": {
720            "b": {
721              "kind": "vault", "addr": "http://127.0.0.1:8200",
722              "engines": ["transit", "kv2"], "capabilities": [],
723              "mintKeyTypes": ["ed25519"]
724            }
725          },
726          "keys": {
727            "warn.value": {
728              "class": "value", "backend": "b", "engine": "kv2",
729              "path": "secret/data/warn/value", "writable": true, "missing": "warn",
730              "description": "a warn-on-missing value"
731            }
732          }
733        }"#;
734        let cat: Catalog = serde_json::from_str(NO_REQUIRED).expect("parses");
735        let (backend, _rec) = MockBackend::new(Probe::Absent);
736        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
737        backends.insert("b".into(), Box::new(backend));
738        let mgr = BackendManager::new(cat, backends).expect("constructs");
739        let summary = mgr.reconcile().await.expect("warn-only reconciles ok");
740        assert_eq!(summary.warned, 1);
741        assert_eq!(summary.present, 0);
742        assert_eq!(summary.generated, 0);
743    }
744
745    #[tokio::test]
746    async fn generate_missing_creates_crypto_and_value_keys() {
747        // A catalog with ONLY generate keys (asym + sym + value), all absent: each
748        // must be created via its class-specific path.
749        const GEN_ONLY: &str = r#"{
750          "schemaVersion": 1,
751          "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
752          "keys": {
753            "gen.signer": {
754              "class": "asymmetric", "keyType": "ed25519", "backend": "b",
755              "path": "gen-signer", "writable": true, "missing": "generate",
756              "description": "asym generate"
757            },
758            "gen.box": {
759              "class": "symmetric", "keyType": "aes-256-gcm", "backend": "b",
760              "path": "gen-box", "writable": true, "missing": "generate",
761              "description": "sym generate"
762            },
763            "gen.value": {
764              "class": "value", "backend": "b", "engine": "kv2",
765              "path": "secret/data/gen/value", "writable": true, "missing": "generate",
766              "generate": { "format": "ascii-printable", "bytes": 24 },
767              "description": "value generate"
768            }
769          }
770        }"#;
771        let cat: Catalog = serde_json::from_str(GEN_ONLY).expect("parses");
772        let (backend, rec) = MockBackend::new(Probe::Absent);
773        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
774        backends.insert("b".into(), Box::new(backend));
775        let mgr = BackendManager::new(cat, backends).expect("constructs");
776
777        let summary = mgr.reconcile().await.expect("generate reconciles ok");
778        assert_eq!(summary.generated, 3);
779        assert_eq!(summary.present, 0);
780        assert_eq!(summary.warned, 0);
781
782        // Crypto asym -> create_named_key at the catalog PATH.
783        assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 1);
784        assert_eq!(*rec.last_key_type.lock().unwrap(), Some(KeyType::Ed25519));
785        // Crypto sym -> create_named_aead with the catalog AEAD suite.
786        assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 1);
787        assert_eq!(
788            *rec.last_aead.lock().unwrap(),
789            Some(AeadAlgorithm::Aes256Gcm)
790        );
791        // Value -> generate_value + kv_put at the catalog PATH, a 24-byte printable.
792        assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 1);
793        let (path, value) = rec.last_kv_put.lock().unwrap().clone().expect("a kv write");
794        assert_eq!(path, "secret/data/gen/value");
795        assert_eq!(value.len(), 24);
796        assert!(value.iter().all(|b| (b'!'..=b'~').contains(b)));
797    }
798
799    fn generated_asymmetric_catalog(key_type: &str, mint_key_types: &str) -> String {
800        format!(
801            r#"{{
802          "schemaVersion": 1,
803          "backends": {{
804            "b": {{
805              "kind": "vault", "addr": "http://127.0.0.1:8200",
806              "engines": ["transit"], "capabilities": [],
807              "mintKeyTypes": [{mint_key_types}]
808            }}
809          }},
810          "keys": {{
811            "gen.signer": {{
812              "class": "asymmetric", "keyType": "{key_type}", "backend": "b",
813              "path": "gen-signer", "writable": true, "missing": "generate",
814              "description": "generated signer"
815            }}
816          }}
817        }}"#
818        )
819    }
820
821    #[tokio::test]
822    async fn generate_missing_creates_rsa_when_static_preset_allows_it() {
823        let cat: Catalog =
824            serde_json::from_str(&generated_asymmetric_catalog("rsa-2048", r#""rsa-2048""#))
825                .expect("parses");
826        let (backend, rec) = MockBackend::new(Probe::Absent);
827        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
828        backends.insert("b".into(), Box::new(backend));
829        let mgr = BackendManager::new(cat, backends).expect("constructs");
830
831        let summary = mgr.reconcile().await.expect("rsa generate reconciles ok");
832        assert_eq!(summary.generated, 1);
833        assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 1);
834        assert_eq!(
835            rec.last_create_path.lock().unwrap().as_deref(),
836            Some("gen-signer")
837        );
838        assert_eq!(*rec.last_key_type.lock().unwrap(), Some(KeyType::Rsa2048));
839    }
840
841    #[tokio::test]
842    async fn generate_missing_creates_ecdsa_p256_when_static_preset_allows_it() {
843        let cat: Catalog = serde_json::from_str(&generated_asymmetric_catalog(
844            "ecdsa-p256",
845            r#""ecdsa-p256""#,
846        ))
847        .expect("parses");
848        let (backend, rec) = MockBackend::new(Probe::Absent);
849        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
850        backends.insert("b".into(), Box::new(backend));
851        let mgr = BackendManager::new(cat, backends).expect("constructs");
852
853        let summary = mgr.reconcile().await.expect("ecdsa generate reconciles ok");
854        assert_eq!(summary.generated, 1);
855        assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 1);
856        assert_eq!(
857            rec.last_create_path.lock().unwrap().as_deref(),
858            Some("gen-signer")
859        );
860        assert_eq!(*rec.last_key_type.lock().unwrap(), Some(KeyType::EcdsaP256));
861    }
862
863    #[tokio::test]
864    async fn generate_missing_creates_nkey_when_static_preset_allows_it() {
865        let cat: Catalog = serde_json::from_str(&generated_asymmetric_catalog(
866            "ed25519-nkey",
867            r#""ed25519-nkey""#,
868        ))
869        .expect("parses");
870        let (backend, rec) = MockBackend::new(Probe::Absent);
871        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
872        backends.insert("b".into(), Box::new(backend));
873        let mgr = BackendManager::new(cat, backends).expect("constructs");
874
875        let summary = mgr.reconcile().await.expect("nkey generate reconciles ok");
876        assert_eq!(summary.generated, 1);
877        assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 1);
878        assert_eq!(
879            rec.last_create_path.lock().unwrap().as_deref(),
880            Some("gen-signer")
881        );
882        assert_eq!(
883            *rec.last_key_type.lock().unwrap(),
884            Some(KeyType::Ed25519Nkey)
885        );
886    }
887
888    #[tokio::test]
889    async fn generate_missing_rejects_key_type_absent_from_static_backend_preset() {
890        const GEN_RSA_UNSUPPORTED: &str = r#"{
891          "schemaVersion": 1,
892          "backends": {
893            "b": {
894              "kind": "vault", "addr": "http://127.0.0.1:8200",
895              "engines": ["transit"], "capabilities": [],
896              "mintKeyTypes": ["ed25519"]
897            }
898          },
899          "keys": {
900            "gen.rsa": {
901              "class": "asymmetric", "keyType": "rsa-2048", "backend": "b",
902              "path": "gen-rsa", "writable": true, "missing": "generate",
903              "description": "rsa generate"
904            }
905          }
906        }"#;
907        let cat: Catalog = serde_json::from_str(GEN_RSA_UNSUPPORTED).expect("parses");
908        let (backend, rec) = MockBackend::new(Probe::Absent);
909        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
910        backends.insert("b".into(), Box::new(backend));
911        let mgr = BackendManager::new(cat, backends).expect("constructs");
912
913        let err = mgr
914            .reconcile()
915            .await
916            .expect_err("rsa is absent from preset");
917        assert!(matches!(
918            err,
919            ReconcileError::Generate {
920                source: ManagerError::UnsupportedKeyType {
921                    backend,
922                    op: "generate",
923                    key_type: KeyType::Rsa2048
924                },
925                ..
926            } if backend == "b"
927        ));
928        assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
929    }
930
931    #[tokio::test]
932    async fn kv2_signer_missing_generate_refuses_in_broker() {
933        // A value-store Ed25519 signing key (engine=kv2, vault-iiz) with
934        // missing=generate must FAIL closed: Basil cannot mint its seed in-broker,
935        // and it must NOT call create_named_key (that would route to transit) or
936        // write a fresh seed (silently minting signing authority). The seed is
937        // provisioned out of band.
938        const KV2_SIGNER: &str = r#"{
939          "schemaVersion": 1,
940          "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
941          "keys": {
942            "kv2.signer": {
943              "class": "asymmetric", "keyType": "ed25519", "backend": "b", "engine": "kv2",
944              "path": "secret/data/kv2/signer", "writable": true, "missing": "generate",
945              "description": "a value-store materialize-to-sign key"
946            }
947          }
948        }"#;
949        let cat: Catalog = serde_json::from_str(KV2_SIGNER).expect("parses");
950        let (backend, rec) = MockBackend::new(Probe::Absent);
951        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
952        backends.insert("b".into(), Box::new(backend));
953        let mgr = BackendManager::new(cat, backends).expect("constructs");
954
955        let err = mgr
956            .reconcile()
957            .await
958            .expect_err("a kv2 signing key cannot be generated in-broker");
959        assert!(matches!(err, ReconcileError::Generate { .. }));
960        // Crucially: NOT created via transit, and no fresh seed written to KV.
961        assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
962        assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
963    }
964
965    #[tokio::test]
966    async fn kv2_signer_present_probes_kv_not_transit() {
967        // The existence probe for an engine=kv2 signing key must hit KV (kv_get),
968        // not key_metadata (a transit name that would 404). With Probe::Present the
969        // key resolves as present and reconcile is a clean no-op.
970        const KV2_SIGNER: &str = r#"{
971          "schemaVersion": 1,
972          "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
973          "keys": {
974            "kv2.signer": {
975              "class": "asymmetric", "keyType": "ed25519", "backend": "b", "engine": "kv2",
976              "path": "secret/data/kv2/signer",
977              "publicPath": "secret/data/kv2/signer-public",
978              "writable": true, "missing": "error",
979              "description": "a value-store materialize-to-sign key"
980            }
981          }
982        }"#;
983        let cat: Catalog = serde_json::from_str(KV2_SIGNER).expect("parses");
984        let (backend, rec) = MockBackend::new(Probe::Present);
985        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
986        backends.insert("b".into(), Box::new(backend));
987        let mgr = BackendManager::new(cat, backends).expect("constructs");
988
989        let summary = mgr
990            .reconcile()
991            .await
992            .expect("present kv2 signer reconciles");
993        assert_eq!(summary.present, 1);
994        assert_eq!(summary.generated, 0);
995        // It was probed via kv_get; no transit-style creation happened.
996        assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
997    }
998
999    #[tokio::test]
1000    async fn materialize_key_without_public_path_probes_absent() {
1001        // basil-o86: a materialize-to-use key whose public half is NOT provisioned
1002        // (no publicPath) is treated as ABSENT even when the private is Present,
1003        // so under missing=error it fails closed rather than booting a key whose
1004        // wrap/get_public_key can never resolve a public.
1005        const NO_PUB: &str = r#"{
1006          "schemaVersion": 1,
1007          "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
1008          "keys": {
1009            "kv2.signer": {
1010              "class": "asymmetric", "keyType": "ed25519", "backend": "b", "engine": "kv2",
1011              "path": "secret/data/kv2/signer", "writable": true, "missing": "error",
1012              "description": "a kv2 signer missing its publicPath"
1013            }
1014          }
1015        }"#;
1016        let cat: Catalog = serde_json::from_str(NO_PUB).expect("parses");
1017        // Probe::Present: the private would probe present, but the absent publicPath
1018        // makes the key absent overall.
1019        let (backend, _rec) = MockBackend::new(Probe::Present);
1020        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
1021        backends.insert("b".into(), Box::new(backend));
1022        let mgr = BackendManager::new(cat, backends).expect("constructs");
1023
1024        let err = mgr
1025            .reconcile()
1026            .await
1027            .expect_err("a materialize key with no publicPath must fail closed");
1028        match err {
1029            ReconcileError::RequiredMissing(keys) => assert_eq!(keys, vec!["kv2.signer"]),
1030            other => panic!("expected RequiredMissing, got {other:?}"),
1031        }
1032    }
1033
1034    #[tokio::test]
1035    async fn unreachable_backend_is_fatal_not_treated_as_absent() {
1036        // A down backend must FAIL reconcile (Probe error), never be read as
1037        // "absent" and generated/over.
1038        let (mgr, rec) = manager_with(Probe::Unreachable);
1039        let err = mgr
1040            .reconcile()
1041            .await
1042            .expect_err("an unreachable backend must fail closed");
1043        match err {
1044            ReconcileError::Probe { source, .. } => {
1045                assert!(matches!(source, BackendError::Transport(_)));
1046            }
1047            other => panic!("expected Probe (unreachable), got {other:?}"),
1048        }
1049        // Crucially: nothing was generated despite generate-policy keys being
1050        // present: an unreachable backend is never silently "absent".
1051        assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
1052        assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 0);
1053        assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
1054    }
1055
1056    #[tokio::test]
1057    async fn present_keys_are_never_created() {
1058        // Even generate-policy keys that already exist must NOT be re-created.
1059        let (mgr, rec) = manager_with(Probe::Present);
1060        mgr.reconcile().await.expect("reconciles");
1061        assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
1062        assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 0);
1063        assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
1064    }
1065
1066    // ---- check(): the read-only report (vault-roe) -------------------------
1067
1068    fn status_of<'a>(report: &'a CheckReport, name: &str) -> &'a KeyStatus {
1069        &report
1070            .keys
1071            .iter()
1072            .find(|k| k.name == name)
1073            .expect("key in report")
1074            .status
1075    }
1076
1077    #[tokio::test]
1078    async fn check_all_present_reports_every_key_present_and_creates_nothing() {
1079        let (mgr, rec) = manager_with(Probe::Present);
1080        let report = mgr.check().await.expect("check succeeds when reachable");
1081        // Every fixture key (5) is present; nothing is missing; nothing fails.
1082        assert_eq!(report.keys.len(), 5);
1083        assert_eq!(report.present_count(), 5);
1084        assert_eq!(report.missing().count(), 0);
1085        assert!(!report.should_fail_required());
1086        assert!(report.required_missing().is_empty());
1087        // A check is read-only: it never creates/mutates anything.
1088        assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
1089        assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 0);
1090        assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
1091    }
1092
1093    #[tokio::test]
1094    async fn check_all_absent_classifies_each_key_by_its_missing_policy() {
1095        // Every key is absent: the report must classify each by its catalog
1096        // `missing` policy, NOT generate or mutate anything.
1097        let (mgr, rec) = manager_with(Probe::Absent);
1098        let report = mgr.check().await.expect("check succeeds when reachable");
1099
1100        assert_eq!(report.present_count(), 0);
1101        // req.signer (error), warn.value (warn), gen.signer/gen.box/gen.value (generate).
1102        assert_eq!(
1103            *status_of(&report, "req.signer"),
1104            KeyStatus::Missing(MissingPolicy::Error)
1105        );
1106        assert_eq!(
1107            *status_of(&report, "warn.value"),
1108            KeyStatus::Missing(MissingPolicy::Warn)
1109        );
1110        assert_eq!(
1111            *status_of(&report, "gen.signer"),
1112            KeyStatus::Missing(MissingPolicy::Generate)
1113        );
1114        assert_eq!(
1115            *status_of(&report, "gen.box"),
1116            KeyStatus::Missing(MissingPolicy::Generate)
1117        );
1118        assert_eq!(
1119            *status_of(&report, "gen.value"),
1120            KeyStatus::Missing(MissingPolicy::Generate)
1121        );
1122
1123        // Read-only: not one create/write despite generate-policy keys absent.
1124        assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
1125        assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 0);
1126        assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
1127    }
1128
1129    #[tokio::test]
1130    async fn check_should_fail_required_iff_an_error_class_key_is_absent() {
1131        // All absent: req.signer is missing=error -> the --require gate must trip,
1132        // and required_missing names exactly it (warn/generate-absent excluded).
1133        let (mgr, _rec) = manager_with(Probe::Absent);
1134        let report = mgr.check().await.expect("check ok");
1135        assert!(report.should_fail_required());
1136        assert_eq!(report.required_missing(), vec!["req.signer"]);
1137
1138        // All present: nothing required is absent -> the gate must NOT trip.
1139        let (mgr, _rec) = manager_with(Probe::Present);
1140        let report = mgr.check().await.expect("check ok");
1141        assert!(!report.should_fail_required());
1142        assert!(report.required_missing().is_empty());
1143    }
1144
1145    #[tokio::test]
1146    async fn check_warn_and_generate_absent_do_not_trip_require() {
1147        // A catalog with ONLY warn + generate keys, all absent: missing keys are
1148        // reported, but NONE is error-class, so --require must NOT fail.
1149        const WARN_GEN_ONLY: &str = r#"{
1150          "schemaVersion": 1,
1151          "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
1152          "keys": {
1153            "warn.value": {
1154              "class": "value", "backend": "b", "engine": "kv2",
1155              "path": "secret/data/warn/value", "writable": true, "missing": "warn",
1156              "description": "warn"
1157            },
1158            "gen.signer": {
1159              "class": "asymmetric", "keyType": "ed25519", "backend": "b",
1160              "path": "gen-signer", "writable": true, "missing": "generate",
1161              "description": "generate"
1162            }
1163          }
1164        }"#;
1165        let cat: Catalog = serde_json::from_str(WARN_GEN_ONLY).expect("parses");
1166        let (backend, _rec) = MockBackend::new(Probe::Absent);
1167        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
1168        backends.insert("b".into(), Box::new(backend));
1169        let mgr = BackendManager::new(cat, backends).expect("constructs");
1170
1171        let report = mgr.check().await.expect("check ok");
1172        // Both keys absent and reported, but neither is error-class.
1173        assert_eq!(report.missing().count(), 2);
1174        assert!(!report.should_fail_required());
1175        assert!(report.required_missing().is_empty());
1176    }
1177
1178    #[tokio::test]
1179    async fn check_unreachable_backend_is_fatal_not_treated_as_absent() {
1180        // A down backend must FAIL check (Probe error), never be reported as a
1181        // clean "missing" that a --require gate might then wrongly decide.
1182        let (mgr, _rec) = manager_with(Probe::Unreachable);
1183        let err = mgr
1184            .check()
1185            .await
1186            .expect_err("an unreachable backend must fail closed");
1187        match err {
1188            ReconcileError::Probe { source, .. } => {
1189                assert!(matches!(source, BackendError::Transport(_)));
1190            }
1191            other => panic!("expected Probe (unreachable), got {other:?}"),
1192        }
1193    }
1194
1195    #[tokio::test]
1196    async fn check_report_keys_are_in_catalog_name_order() {
1197        // The report walks the catalog (a BTreeMap) so rows are name-sorted:
1198        // a stable, diffable order for CI output.
1199        let (mgr, _rec) = manager_with(Probe::Present);
1200        let report = mgr.check().await.expect("check ok");
1201        let names: Vec<&str> = report.keys.iter().map(|k| k.name.as_str()).collect();
1202        let mut sorted = names.clone();
1203        sorted.sort_unstable();
1204        assert_eq!(names, sorted);
1205    }
1206}