Skip to main content

basil_core/core/
reconcile.rs

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