Skip to main content

clawft_kernel/
config_service.rs

1//! Configuration and secrets service (K5-G1).
2//!
3//! Provides [`ConfigService`] for runtime configuration management with
4//! change notification, typed values, and encrypted secret storage. Backed
5//! by in-memory stores (tree integration deferred to when `exochain`
6//! feature is enabled).
7
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::{Arc, RwLock};
10
11use async_trait::async_trait;
12use chrono::{DateTime, Duration, Utc};
13use dashmap::DashMap;
14use serde::{Deserialize, Serialize};
15use tracing::info;
16
17use crate::error::KernelError;
18use crate::health::HealthStatus;
19use crate::process::Pid;
20use crate::service::{ServiceType, SystemService};
21
22#[cfg(feature = "exochain")]
23use crate::chain::ChainManager;
24#[cfg(feature = "exochain")]
25use crate::gate::GateBackend;
26
27// ---------------------------------------------------------------------------
28// ConfigValue — typed configuration values
29// ---------------------------------------------------------------------------
30
31/// A typed configuration value.
32#[non_exhaustive]
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34pub enum ConfigValue {
35    /// Plain text.
36    Text(String),
37    /// 64-bit signed integer.
38    Integer(i64),
39    /// 64-bit floating point.
40    Float(f64),
41    /// Boolean flag.
42    Boolean(bool),
43    /// Arbitrary JSON blob.
44    Json(serde_json::Value),
45}
46
47impl ConfigValue {
48    /// Convert to a `serde_json::Value` representation.
49    pub fn to_json(&self) -> serde_json::Value {
50        match self {
51            ConfigValue::Text(s) => serde_json::Value::String(s.clone()),
52            ConfigValue::Integer(n) => serde_json::json!(n),
53            ConfigValue::Float(f) => serde_json::json!(f),
54            ConfigValue::Boolean(b) => serde_json::json!(b),
55            ConfigValue::Json(v) => v.clone(),
56        }
57    }
58}
59
60// ---------------------------------------------------------------------------
61// ConfigEntry
62// ---------------------------------------------------------------------------
63
64/// A stored configuration entry with metadata.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ConfigEntry {
67    /// Configuration key.
68    pub key: String,
69    /// Configuration namespace.
70    pub namespace: String,
71    /// Typed value.
72    pub value: ConfigValue,
73    /// When the entry was last updated.
74    pub updated_at: DateTime<Utc>,
75}
76
77// ---------------------------------------------------------------------------
78// ConfigChange
79// ---------------------------------------------------------------------------
80
81/// A change notification for a configuration key.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct ConfigChange {
84    /// Configuration namespace.
85    pub namespace: String,
86    /// Configuration key.
87    pub key: String,
88    /// Previous value (if any).
89    pub old_value: Option<serde_json::Value>,
90    /// New value (if any -- `None` for deletions).
91    pub new_value: Option<serde_json::Value>,
92    /// PID of the process that made the change.
93    pub changed_by: Pid,
94    /// When the change occurred.
95    pub timestamp: DateTime<Utc>,
96}
97
98// ---------------------------------------------------------------------------
99// SecretRef
100// ---------------------------------------------------------------------------
101
102/// Metadata about a stored secret.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct SecretRef {
105    /// Secret namespace.
106    pub namespace: String,
107    /// Secret key.
108    pub key: String,
109    /// When the secret expires.
110    pub expires_at: DateTime<Utc>,
111    /// PIDs allowed to read this secret.
112    pub scoped_to: Vec<Pid>,
113}
114
115// ---------------------------------------------------------------------------
116// ConfigService
117// ---------------------------------------------------------------------------
118
119/// Configuration and secrets service.
120///
121/// Stores configuration values at `/kernel/config/{namespace}/{key}` and
122/// secrets at `/kernel/secrets/{namespace}/{key}` (encrypted at rest).
123/// Supports change notification via subscriptions.
124pub struct ConfigService {
125    /// Config store: "namespace/key" -> value.
126    configs: DashMap<String, serde_json::Value>,
127    /// Typed config entries: "namespace/key" -> ConfigEntry.
128    entries: DashMap<String, ConfigEntry>,
129    /// Secret store: "namespace/key" -> encrypted bytes.
130    secrets: DashMap<String, Vec<u8>>,
131    /// Secret metadata: "namespace/key" -> SecretRef.
132    secret_refs: DashMap<String, SecretRef>,
133    /// Change subscribers: namespace -> list of subscription queues.
134    subscribers: DashMap<String, Vec<Arc<RwLock<Vec<ConfigChange>>>>>,
135    /// Encryption key (derived from genesis in production).
136    encryption_key: [u8; 32],
137    /// Change log for auditing.
138    change_log: RwLock<Vec<ConfigChange>>,
139    /// Total config sets.
140    set_count: AtomicU64,
141    /// ExoChain manager for event logging.
142    #[cfg(feature = "exochain")]
143    chain_manager: Option<Arc<ChainManager>>,
144    /// Governance gate for access control.
145    #[cfg(feature = "exochain")]
146    governance_gate: Option<Arc<dyn GateBackend>>,
147}
148
149impl ConfigService {
150    /// Create a new config service with a given encryption key.
151    pub fn new(encryption_key: [u8; 32]) -> Self {
152        Self {
153            configs: DashMap::new(),
154            entries: DashMap::new(),
155            secrets: DashMap::new(),
156            secret_refs: DashMap::new(),
157            subscribers: DashMap::new(),
158            encryption_key,
159            change_log: RwLock::new(Vec::new()),
160            set_count: AtomicU64::new(0),
161            #[cfg(feature = "exochain")]
162            chain_manager: None,
163            #[cfg(feature = "exochain")]
164            governance_gate: None,
165        }
166    }
167
168    /// Create a config service with a default (zero) encryption key (testing).
169    pub fn new_default() -> Self {
170        Self::new([0u8; 32])
171    }
172
173    /// Set the chain manager for exochain event logging.
174    #[cfg(feature = "exochain")]
175    pub fn with_chain_manager(mut self, cm: Arc<ChainManager>) -> Self {
176        self.chain_manager = Some(cm);
177        self
178    }
179
180    /// Set the governance gate for access control.
181    #[cfg(feature = "exochain")]
182    pub fn with_governance_gate(mut self, gate: Arc<dyn GateBackend>) -> Self {
183        self.governance_gate = Some(gate);
184        self
185    }
186
187    // ── Config operations ─────────────────────────────────────────
188
189    /// Set a configuration value.
190    pub fn set(
191        &self,
192        namespace: &str,
193        key: &str,
194        value: serde_json::Value,
195        changed_by: Pid,
196    ) -> Result<(), KernelError> {
197        // Governance gate: config changes are gated.
198        #[cfg(feature = "exochain")]
199        if let Some(ref gate) = self.governance_gate {
200            use crate::gate::GateDecision;
201            use crate::governance::{GateEffectKind, with_effect_context};
202            let ctx = with_effect_context(
203                serde_json::json!({
204                    "namespace": namespace,
205                    "key": key,
206                    "changed_by": changed_by,
207                }),
208                &GateEffectKind::ConfigSet.effect_vector(),
209            );
210            let decision = gate.check("config-service", "config.set", &ctx);
211            if let GateDecision::Deny { reason, .. } = decision {
212                return Err(KernelError::GovernanceDenied(format!(
213                    "config set denied: {reason}"
214                )));
215            }
216        }
217
218        let config_key = format!("{namespace}/{key}");
219        let old_value = self.configs.get(&config_key).map(|v| v.value().clone());
220        self.configs.insert(config_key, value.clone());
221
222        let change = ConfigChange {
223            namespace: namespace.to_string(),
224            key: key.to_string(),
225            old_value,
226            new_value: Some(value),
227            changed_by,
228            timestamp: Utc::now(),
229        };
230
231        // Notify subscribers.
232        self.notify_subscribers(namespace, &change);
233
234        // Record in change log.
235        if let Ok(mut log) = self.change_log.write() {
236            log.push(change);
237        }
238        self.set_count.fetch_add(1, Ordering::Relaxed);
239
240        #[cfg(feature = "exochain")]
241        if let Some(ref cm) = self.chain_manager {
242            cm.append(
243                "config",
244                crate::chain::EVENT_KIND_CONFIG_SET,
245                Some(serde_json::json!({
246                    "namespace": namespace,
247                    "key": key,
248                    "changed_by": changed_by,
249                })),
250            );
251        }
252
253        Ok(())
254    }
255
256    /// Get a configuration value.
257    pub fn get(&self, namespace: &str, key: &str) -> Option<serde_json::Value> {
258        let config_key = format!("{namespace}/{key}");
259        self.configs.get(&config_key).map(|v| v.value().clone())
260    }
261
262    /// Delete a configuration value.
263    pub fn delete(&self, namespace: &str, key: &str, changed_by: Pid) -> Result<(), KernelError> {
264        // Governance gate: config deletion is gated.
265        #[cfg(feature = "exochain")]
266        if let Some(ref gate) = self.governance_gate {
267            use crate::gate::GateDecision;
268            use crate::governance::{GateEffectKind, with_effect_context};
269            let ctx = with_effect_context(
270                serde_json::json!({
271                    "namespace": namespace,
272                    "key": key,
273                    "changed_by": changed_by,
274                }),
275                &GateEffectKind::ConfigDelete.effect_vector(),
276            );
277            let decision = gate.check("config-service", "config.delete", &ctx);
278            if let GateDecision::Deny { reason, .. } = decision {
279                return Err(KernelError::GovernanceDenied(format!(
280                    "config delete denied: {reason}"
281                )));
282            }
283        }
284
285        let config_key = format!("{namespace}/{key}");
286        let old_value = self.configs.remove(&config_key).map(|(_, v)| v);
287
288        let change = ConfigChange {
289            namespace: namespace.to_string(),
290            key: key.to_string(),
291            old_value,
292            new_value: None,
293            changed_by,
294            timestamp: Utc::now(),
295        };
296        self.notify_subscribers(namespace, &change);
297
298        #[cfg(feature = "exochain")]
299        if let Some(ref cm) = self.chain_manager {
300            cm.append(
301                "config",
302                crate::chain::EVENT_KIND_CONFIG_DELETE,
303                Some(serde_json::json!({
304                    "namespace": namespace,
305                    "key": key,
306                    "changed_by": changed_by,
307                })),
308            );
309        }
310
311        Ok(())
312    }
313
314    /// List all config keys in a namespace.
315    pub fn list_keys(&self, namespace: &str) -> Vec<String> {
316        let prefix = format!("{namespace}/");
317        self.configs
318            .iter()
319            .filter(|e| e.key().starts_with(&prefix))
320            .map(|e| e.key()[prefix.len()..].to_string())
321            .collect()
322    }
323
324    // ── Typed config operations ──────────────────────────────────
325
326    /// Store a typed configuration value.
327    pub fn set_typed(
328        &self,
329        namespace: &str,
330        key: &str,
331        value: ConfigValue,
332        changed_by: Pid,
333    ) -> Result<(), KernelError> {
334        let config_key = format!("{namespace}/{key}");
335        let json_value = value.to_json();
336
337        // Also store in the legacy JSON map for backward compatibility.
338        let old_value = self.configs.get(&config_key).map(|v| v.value().clone());
339        self.configs.insert(config_key.clone(), json_value.clone());
340
341        let entry = ConfigEntry {
342            key: key.to_string(),
343            namespace: namespace.to_string(),
344            value,
345            updated_at: Utc::now(),
346        };
347        self.entries.insert(config_key, entry);
348
349        let change = ConfigChange {
350            namespace: namespace.to_string(),
351            key: key.to_string(),
352            old_value,
353            new_value: Some(json_value),
354            changed_by,
355            timestamp: Utc::now(),
356        };
357        self.notify_subscribers(namespace, &change);
358
359        if let Ok(mut log) = self.change_log.write() {
360            log.push(change);
361        }
362        self.set_count.fetch_add(1, Ordering::Relaxed);
363        Ok(())
364    }
365
366    /// Retrieve a typed configuration entry.
367    pub fn get_typed(&self, namespace: &str, key: &str) -> Option<ConfigEntry> {
368        let config_key = format!("{namespace}/{key}");
369        self.entries.get(&config_key).map(|e| e.value().clone())
370    }
371
372    /// List all typed entries in a namespace.
373    pub fn list(&self, namespace: &str) -> Vec<ConfigEntry> {
374        let prefix = format!("{namespace}/");
375        self.entries
376            .iter()
377            .filter(|e| e.key().starts_with(&prefix))
378            .map(|e| e.value().clone())
379            .collect()
380    }
381
382    /// Delete a typed configuration entry. Returns `true` if it existed.
383    ///
384    /// # Errors
385    ///
386    /// Returns `KernelError::GovernanceDenied` if the governance gate
387    /// rejects the deletion (only when the `exochain` feature is enabled
388    /// and a gate is attached).
389    pub fn delete_typed(
390        &self,
391        namespace: &str,
392        key: &str,
393        changed_by: Pid,
394    ) -> Result<bool, KernelError> {
395        // Governance gate: typed config deletion is gated.
396        #[cfg(feature = "exochain")]
397        if let Some(ref gate) = self.governance_gate {
398            use crate::gate::GateDecision;
399            use crate::governance::{GateEffectKind, with_effect_context};
400            let ctx = with_effect_context(
401                serde_json::json!({
402                    "namespace": namespace,
403                    "key": key,
404                    "changed_by": changed_by,
405                }),
406                &GateEffectKind::ConfigDelete.effect_vector(),
407            );
408            let decision = gate.check("config-service", "config.delete_typed", &ctx);
409            if let GateDecision::Deny { reason, .. } = decision {
410                return Err(KernelError::GovernanceDenied(format!(
411                    "config delete_typed denied: {reason}"
412                )));
413            }
414        }
415
416        let config_key = format!("{namespace}/{key}");
417        let removed = self.entries.remove(&config_key).is_some();
418        // Also remove from legacy store.
419        let old_value = self.configs.remove(&config_key).map(|(_, v)| v);
420
421        let change = ConfigChange {
422            namespace: namespace.to_string(),
423            key: key.to_string(),
424            old_value,
425            new_value: None,
426            changed_by,
427            timestamp: Utc::now(),
428        };
429        self.notify_subscribers(namespace, &change);
430        Ok(removed)
431    }
432
433    // ── Subscription ──────────────────────────────────────────────
434
435    /// Subscribe to changes in a namespace.
436    ///
437    /// Returns a shared reference to the change queue. Callers can read
438    /// accumulated changes from the returned `Arc<RwLock<Vec<ConfigChange>>>`.
439    pub fn subscribe(&self, namespace: &str) -> Arc<RwLock<Vec<ConfigChange>>> {
440        let queue = Arc::new(RwLock::new(Vec::new()));
441        self.subscribers
442            .entry(namespace.to_string())
443            .or_default()
444            .push(queue.clone());
445        queue
446    }
447
448    /// Notify all subscribers for a namespace.
449    fn notify_subscribers(&self, namespace: &str, change: &ConfigChange) {
450        if let Some(mut subs) = self.subscribers.get_mut(namespace) {
451            subs.retain(|queue| {
452                if let Ok(mut q) = queue.write() {
453                    q.push(change.clone());
454                    true
455                } else {
456                    false // remove dead subscribers
457                }
458            });
459        }
460    }
461
462    // ── Secret operations ─────────────────────────────────────────
463
464    /// Store an encrypted secret.
465    pub fn set_secret(
466        &self,
467        namespace: &str,
468        key: &str,
469        value: &[u8],
470        scoped_to: Vec<Pid>,
471    ) -> Result<(), KernelError> {
472        // Governance gate: secret storage is a critical action.
473        #[cfg(feature = "exochain")]
474        if let Some(ref gate) = self.governance_gate {
475            use crate::gate::GateDecision;
476            use crate::governance::{GateEffectKind, with_effect_context};
477            let ctx = with_effect_context(
478                serde_json::json!({
479                    "namespace": namespace,
480                    "key": key,
481                }),
482                &GateEffectKind::ConfigSecretSet.effect_vector(),
483            );
484            let decision = gate.check("config-service", "config.secret.set", &ctx);
485            if let GateDecision::Deny { reason, .. } = decision {
486                return Err(KernelError::GovernanceDenied(format!(
487                    "secret set denied: {reason}"
488                )));
489            }
490        }
491
492        let secret_key = format!("{namespace}/{key}");
493
494        // Simple XOR encryption (production would use AEAD).
495        let encrypted = self.xor_encrypt(value);
496        self.secrets.insert(secret_key.clone(), encrypted);
497
498        let secret_ref = SecretRef {
499            namespace: namespace.to_string(),
500            key: key.to_string(),
501            expires_at: Utc::now() + Duration::hours(24),
502            scoped_to,
503        };
504        self.secret_refs.insert(secret_key, secret_ref);
505
506        #[cfg(feature = "exochain")]
507        if let Some(ref cm) = self.chain_manager {
508            cm.append(
509                "config",
510                crate::chain::EVENT_KIND_CONFIG_SECRET_SET,
511                Some(serde_json::json!({
512                    "namespace": namespace,
513                    "key": key,
514                })),
515            );
516        }
517
518        Ok(())
519    }
520
521    /// Retrieve a secret (decrypted). Checks PID authorization and expiry.
522    pub fn get_secret(
523        &self,
524        namespace: &str,
525        key: &str,
526        requester_pid: Pid,
527    ) -> Result<Vec<u8>, KernelError> {
528        let secret_key = format!("{namespace}/{key}");
529
530        let secret_ref = self
531            .secret_refs
532            .get(&secret_key)
533            .ok_or_else(|| KernelError::Service("secret not found".into()))?;
534
535        // Check authorization.
536        if !secret_ref.scoped_to.is_empty() && !secret_ref.scoped_to.contains(&requester_pid) {
537            return Err(KernelError::CapabilityDenied {
538                pid: requester_pid,
539                action: "read_secret".into(),
540                reason: format!(
541                    "PID {} not authorized for secret {secret_key}",
542                    requester_pid
543                ),
544            });
545        }
546
547        // Check expiry.
548        if Utc::now() > secret_ref.expires_at {
549            return Err(KernelError::Service("secret expired".into()));
550        }
551
552        let encrypted = self
553            .secrets
554            .get(&secret_key)
555            .ok_or_else(|| KernelError::Service("secret data missing".into()))?;
556
557        Ok(self.xor_decrypt(&encrypted))
558    }
559
560    /// Simple XOR encryption with the key (for testing; production uses AEAD).
561    fn xor_encrypt(&self, data: &[u8]) -> Vec<u8> {
562        data.iter()
563            .enumerate()
564            .map(|(i, b)| b ^ self.encryption_key[i % 32])
565            .collect()
566    }
567
568    /// Decrypt XOR-encrypted data.
569    fn xor_decrypt(&self, data: &[u8]) -> Vec<u8> {
570        // XOR is symmetric.
571        self.xor_encrypt(data)
572    }
573
574    /// Get the change log (for auditing).
575    pub fn change_log(&self) -> Vec<ConfigChange> {
576        self.change_log
577            .read()
578            .map(|l| l.clone())
579            .unwrap_or_default()
580    }
581
582    /// Total number of config sets performed.
583    pub fn set_count(&self) -> u64 {
584        self.set_count.load(Ordering::Relaxed)
585    }
586}
587
588#[async_trait]
589impl SystemService for ConfigService {
590    fn name(&self) -> &str {
591        "config-service"
592    }
593
594    fn service_type(&self) -> ServiceType {
595        ServiceType::Core
596    }
597
598    async fn start(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
599        info!("config service started");
600        Ok(())
601    }
602
603    async fn stop(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
604        info!(
605            configs = self.configs.len(),
606            secrets = self.secrets.len(),
607            "config service stopped"
608        );
609        Ok(())
610    }
611
612    async fn health_check(&self) -> HealthStatus {
613        HealthStatus::Healthy
614    }
615}
616
617// ── Tests ─────────────────────────────────────────────────────────────────
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622
623    fn pid(n: u64) -> Pid {
624        n
625    }
626
627    #[test]
628    fn set_and_get_config() {
629        let svc = ConfigService::new_default();
630        svc.set("app", "timeout", serde_json::json!(30), pid(1))
631            .unwrap();
632        let val = svc.get("app", "timeout").unwrap();
633        assert_eq!(val, serde_json::json!(30));
634    }
635
636    #[test]
637    fn get_nonexistent_returns_none() {
638        let svc = ConfigService::new_default();
639        assert!(svc.get("app", "missing").is_none());
640    }
641
642    #[test]
643    fn delete_config() {
644        let svc = ConfigService::new_default();
645        svc.set("app", "key", serde_json::json!("val"), pid(1))
646            .unwrap();
647        svc.delete("app", "key", pid(1)).unwrap();
648        assert!(svc.get("app", "key").is_none());
649    }
650
651    #[test]
652    fn list_keys_in_namespace() {
653        let svc = ConfigService::new_default();
654        svc.set("ns", "a", serde_json::json!(1), pid(1)).unwrap();
655        svc.set("ns", "b", serde_json::json!(2), pid(1)).unwrap();
656        svc.set("other", "c", serde_json::json!(3), pid(1)).unwrap();
657        let mut keys = svc.list_keys("ns");
658        keys.sort();
659        assert_eq!(keys, vec!["a", "b"]);
660    }
661
662    #[test]
663    fn config_change_notification() {
664        let svc = ConfigService::new_default();
665        let sub = svc.subscribe("watch");
666        svc.set("watch", "flag", serde_json::json!(true), pid(1))
667            .unwrap();
668        let changes = sub.read().unwrap();
669        assert_eq!(changes.len(), 1);
670        assert_eq!(changes[0].key, "flag");
671        assert_eq!(changes[0].new_value, Some(serde_json::json!(true)));
672    }
673
674    #[test]
675    fn config_change_includes_old_value() {
676        let svc = ConfigService::new_default();
677        let sub = svc.subscribe("ver");
678        svc.set("ver", "v", serde_json::json!(1), pid(1)).unwrap();
679        svc.set("ver", "v", serde_json::json!(2), pid(1)).unwrap();
680        let changes = sub.read().unwrap();
681        assert_eq!(changes.len(), 2);
682        assert_eq!(changes[1].old_value, Some(serde_json::json!(1)));
683        assert_eq!(changes[1].new_value, Some(serde_json::json!(2)));
684    }
685
686    #[test]
687    fn secret_set_and_get() {
688        let key = [0xAB; 32];
689        let svc = ConfigService::new(key);
690        svc.set_secret("creds", "api_key", b"secret123", vec![pid(1)])
691            .unwrap();
692        let val = svc.get_secret("creds", "api_key", pid(1)).unwrap();
693        assert_eq!(val, b"secret123");
694    }
695
696    #[test]
697    fn secret_encrypted_at_rest() {
698        let key = [0xAB; 32];
699        let svc = ConfigService::new(key);
700        svc.set_secret("creds", "pass", b"plaintext", vec![pid(1)])
701            .unwrap();
702        // Verify stored data is not plaintext.
703        let stored = svc.secrets.get("creds/pass").unwrap();
704        assert_ne!(stored.as_slice(), b"plaintext");
705    }
706
707    #[test]
708    fn unauthorized_pid_cannot_read_secret() {
709        let svc = ConfigService::new_default();
710        svc.set_secret("creds", "key", b"val", vec![pid(1)])
711            .unwrap();
712        let result = svc.get_secret("creds", "key", pid(99));
713        assert!(result.is_err());
714        let err = result.unwrap_err().to_string();
715        assert!(
716            err.contains("denied") || err.contains("authorized"),
717            "got: {err}"
718        );
719    }
720
721    #[test]
722    fn empty_scope_allows_any_pid() {
723        let svc = ConfigService::new_default();
724        svc.set_secret("open", "key", b"val", vec![]).unwrap();
725        let val = svc.get_secret("open", "key", pid(42)).unwrap();
726        assert_eq!(val, b"val");
727    }
728
729    #[test]
730    fn change_log_recorded() {
731        let svc = ConfigService::new_default();
732        svc.set("ns", "k", serde_json::json!("v"), pid(1)).unwrap();
733        let log = svc.change_log();
734        assert_eq!(log.len(), 1);
735        assert_eq!(log[0].namespace, "ns");
736    }
737
738    #[tokio::test]
739    async fn system_service_impl() {
740        let svc = ConfigService::new_default();
741        assert_eq!(svc.name(), "config-service");
742        assert_eq!(svc.service_type(), ServiceType::Core);
743        svc.start().await.unwrap();
744        assert_eq!(svc.health_check().await, HealthStatus::Healthy);
745        svc.stop().await.unwrap();
746    }
747
748    // ── Typed config value tests ─────────────────────────────────
749
750    #[test]
751    fn typed_set_get_roundtrip() {
752        let svc = ConfigService::new_default();
753        svc.set_typed("app", "name", ConfigValue::Text("myapp".into()), pid(1))
754            .unwrap();
755        let entry = svc.get_typed("app", "name").unwrap();
756        assert_eq!(entry.value, ConfigValue::Text("myapp".into()));
757        assert_eq!(entry.namespace, "app");
758        assert_eq!(entry.key, "name");
759    }
760
761    #[test]
762    fn typed_get_nonexistent_returns_none() {
763        let svc = ConfigService::new_default();
764        assert!(svc.get_typed("app", "missing").is_none());
765    }
766
767    #[test]
768    fn typed_list_returns_all_in_namespace() {
769        let svc = ConfigService::new_default();
770        svc.set_typed("db", "host", ConfigValue::Text("localhost".into()), pid(1))
771            .unwrap();
772        svc.set_typed("db", "port", ConfigValue::Integer(5432), pid(1))
773            .unwrap();
774        svc.set_typed("cache", "ttl", ConfigValue::Integer(60), pid(1))
775            .unwrap();
776
777        let mut entries = svc.list("db");
778        entries.sort_by(|a, b| a.key.cmp(&b.key));
779        assert_eq!(entries.len(), 2);
780        assert_eq!(entries[0].key, "host");
781        assert_eq!(entries[1].key, "port");
782    }
783
784    #[test]
785    fn typed_delete_removes_entry() {
786        let svc = ConfigService::new_default();
787        svc.set_typed("ns", "k", ConfigValue::Boolean(true), pid(1))
788            .unwrap();
789        assert!(svc.delete_typed("ns", "k", pid(1)).unwrap());
790        assert!(svc.get_typed("ns", "k").is_none());
791        // Second delete returns false.
792        assert!(!svc.delete_typed("ns", "k", pid(1)).unwrap());
793    }
794
795    #[test]
796    fn typed_subscribe_receives_change_notification() {
797        let svc = ConfigService::new_default();
798        let sub = svc.subscribe("typed-ns");
799        svc.set_typed("typed-ns", "flag", ConfigValue::Boolean(true), pid(1))
800            .unwrap();
801        let changes = sub.read().unwrap();
802        assert_eq!(changes.len(), 1);
803        assert_eq!(changes[0].key, "flag");
804        assert_eq!(changes[0].new_value, Some(serde_json::json!(true)));
805    }
806
807    #[test]
808    fn typed_set_updates_existing_value() {
809        let svc = ConfigService::new_default();
810        svc.set_typed("app", "level", ConfigValue::Integer(1), pid(1))
811            .unwrap();
812        svc.set_typed("app", "level", ConfigValue::Integer(2), pid(1))
813            .unwrap();
814        let entry = svc.get_typed("app", "level").unwrap();
815        assert_eq!(entry.value, ConfigValue::Integer(2));
816    }
817
818    #[test]
819    fn typed_namespace_isolation() {
820        let svc = ConfigService::new_default();
821        svc.set_typed("alpha", "key", ConfigValue::Text("a".into()), pid(1))
822            .unwrap();
823        svc.set_typed("beta", "key", ConfigValue::Text("b".into()), pid(1))
824            .unwrap();
825
826        let a = svc.get_typed("alpha", "key").unwrap();
827        let b = svc.get_typed("beta", "key").unwrap();
828        assert_eq!(a.value, ConfigValue::Text("a".into()));
829        assert_eq!(b.value, ConfigValue::Text("b".into()));
830
831        // list returns only matching namespace.
832        assert_eq!(svc.list("alpha").len(), 1);
833        assert_eq!(svc.list("beta").len(), 1);
834        assert_eq!(svc.list("gamma").len(), 0);
835    }
836
837    #[test]
838    fn typed_all_value_variants() {
839        let svc = ConfigService::new_default();
840
841        svc.set_typed("t", "text", ConfigValue::Text("hello".into()), pid(1))
842            .unwrap();
843        svc.set_typed("t", "int", ConfigValue::Integer(42), pid(1))
844            .unwrap();
845        svc.set_typed("t", "float", ConfigValue::Float(2.5), pid(1))
846            .unwrap();
847        svc.set_typed("t", "bool", ConfigValue::Boolean(false), pid(1))
848            .unwrap();
849        svc.set_typed(
850            "t",
851            "json",
852            ConfigValue::Json(serde_json::json!({"a": 1})),
853            pid(1),
854        )
855        .unwrap();
856
857        assert_eq!(
858            svc.get_typed("t", "text").unwrap().value,
859            ConfigValue::Text("hello".into())
860        );
861        assert_eq!(
862            svc.get_typed("t", "int").unwrap().value,
863            ConfigValue::Integer(42)
864        );
865        assert_eq!(
866            svc.get_typed("t", "float").unwrap().value,
867            ConfigValue::Float(2.5)
868        );
869        assert_eq!(
870            svc.get_typed("t", "bool").unwrap().value,
871            ConfigValue::Boolean(false)
872        );
873        assert_eq!(
874            svc.get_typed("t", "json").unwrap().value,
875            ConfigValue::Json(serde_json::json!({"a": 1}))
876        );
877    }
878}