Skip to main content

zeph_memory/store/
trust.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use serde::{Deserialize, Serialize};
5use zeph_common::SkillTrustLevel;
6#[allow(unused_imports)]
7use zeph_db::sql;
8
9use super::SqliteStore;
10use crate::error::MemoryError;
11
12/// Discriminant for the skill source stored in the trust table.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum SourceKind {
16    Local,
17    Hub,
18    File,
19    /// Skills shipped with the binary and provisioned at startup via `bundled.rs`.
20    Bundled,
21}
22
23impl SourceKind {
24    fn as_str(&self) -> &'static str {
25        match self {
26            Self::Local => "local",
27            Self::Hub => "hub",
28            Self::File => "file",
29            Self::Bundled => "bundled",
30        }
31    }
32}
33
34impl std::fmt::Display for SourceKind {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        f.write_str(self.as_str())
37    }
38}
39
40impl std::str::FromStr for SourceKind {
41    type Err = String;
42
43    fn from_str(s: &str) -> Result<Self, Self::Err> {
44        match s {
45            "local" => Ok(Self::Local),
46            "hub" => Ok(Self::Hub),
47            "file" => Ok(Self::File),
48            "bundled" => Ok(Self::Bundled),
49            other => Err(format!("unknown source_kind: {other}")),
50        }
51    }
52}
53
54#[derive(Debug, Clone)]
55pub struct SkillTrustRow {
56    pub skill_name: String,
57    pub trust_level: SkillTrustLevel,
58    pub source_kind: SourceKind,
59    pub source_url: Option<String>,
60    pub source_path: Option<String>,
61    pub blake3_hash: String,
62    pub updated_at: String,
63    /// Upstream git commit hash at install time (from `x-git-hash` frontmatter field).
64    pub git_hash: Option<String>,
65    /// Whether to re-hash `SKILL.md` on every invocation and abort if the digest changed.
66    ///
67    /// Set via `skill trust --require-check <name>` or the trust management CLI.
68    pub requires_trust_check: bool,
69}
70
71type TrustTuple = (
72    String,
73    String,
74    String,
75    Option<String>,
76    Option<String>,
77    String,
78    String,
79    Option<String>,
80    i64,
81);
82
83fn row_from_tuple(t: TrustTuple) -> SkillTrustRow {
84    let source_kind = t.2.parse::<SourceKind>().unwrap_or(SourceKind::Local);
85    let trust_level = t.1.parse::<SkillTrustLevel>().unwrap_or_default();
86    SkillTrustRow {
87        skill_name: t.0,
88        trust_level,
89        source_kind,
90        source_url: t.3,
91        source_path: t.4,
92        blake3_hash: t.5,
93        updated_at: t.6,
94        git_hash: t.7,
95        requires_trust_check: t.8 != 0,
96    }
97}
98
99impl SqliteStore {
100    /// Upsert trust metadata for a skill.
101    ///
102    /// # Errors
103    ///
104    /// Returns an error if the database operation fails.
105    pub async fn upsert_skill_trust(
106        &self,
107        skill_name: &str,
108        trust_level: SkillTrustLevel,
109        source_kind: SourceKind,
110        source_url: Option<&str>,
111        source_path: Option<&str>,
112        blake3_hash: &str,
113    ) -> Result<(), MemoryError> {
114        self.upsert_skill_trust_with_git_hash(
115            skill_name,
116            trust_level,
117            source_kind,
118            source_url,
119            source_path,
120            blake3_hash,
121            None,
122        )
123        .await
124    }
125
126    /// Upsert trust metadata for a skill, including an optional upstream git hash.
127    ///
128    /// `git_hash` is the upstream commit hash from the `x-git-hash` SKILL.md frontmatter field.
129    /// It tracks the upstream commit at install time and is stored separately from `blake3_hash`
130    /// (which tracks content integrity).
131    ///
132    /// # Errors
133    ///
134    /// Returns an error if the database operation fails.
135    #[allow(clippy::too_many_arguments)] // function with many required inputs; a *Params struct would be more verbose without simplifying the call site
136    pub async fn upsert_skill_trust_with_git_hash(
137        &self,
138        skill_name: &str,
139        trust_level: SkillTrustLevel,
140        source_kind: SourceKind,
141        source_url: Option<&str>,
142        source_path: Option<&str>,
143        blake3_hash: &str,
144        git_hash: Option<&str>,
145    ) -> Result<(), MemoryError> {
146        zeph_db::query(
147            sql!("INSERT INTO skill_trust \
148             (skill_name, trust_level, source_kind, source_url, source_path, blake3_hash, git_hash, updated_at) \
149             VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) \
150             ON CONFLICT(skill_name) DO UPDATE SET \
151             trust_level = excluded.trust_level, \
152             source_kind = excluded.source_kind, \
153             source_url = excluded.source_url, \
154             source_path = excluded.source_path, \
155             blake3_hash = excluded.blake3_hash, \
156             git_hash = excluded.git_hash, \
157             updated_at = CURRENT_TIMESTAMP"),
158        )
159        .bind(skill_name)
160        .bind(trust_level.as_str())
161        .bind(source_kind.as_str())
162        .bind(source_url)
163        .bind(source_path)
164        .bind(blake3_hash)
165        .bind(git_hash)
166        .execute(&self.pool)
167        .await?;
168        Ok(())
169    }
170
171    /// Load trust metadata for a single skill.
172    ///
173    /// # Errors
174    ///
175    /// Returns an error if the query fails.
176    pub async fn load_skill_trust(
177        &self,
178        skill_name: &str,
179    ) -> Result<Option<SkillTrustRow>, MemoryError> {
180        let row: Option<TrustTuple> = zeph_db::query_as(sql!(
181            "SELECT skill_name, trust_level, source_kind, source_url, source_path, \
182             blake3_hash, updated_at, git_hash, requires_trust_check \
183             FROM skill_trust WHERE skill_name = ?"
184        ))
185        .bind(skill_name)
186        .fetch_optional(&self.pool)
187        .await?;
188        Ok(row.map(row_from_tuple))
189    }
190
191    /// Load all skill trust entries.
192    ///
193    /// # Errors
194    ///
195    /// Returns an error if the query fails.
196    pub async fn load_all_skill_trust(&self) -> Result<Vec<SkillTrustRow>, MemoryError> {
197        let rows: Vec<TrustTuple> = zeph_db::query_as(sql!(
198            "SELECT skill_name, trust_level, source_kind, source_url, source_path, \
199             blake3_hash, updated_at, git_hash, requires_trust_check \
200             FROM skill_trust ORDER BY skill_name"
201        ))
202        .fetch_all(&self.pool)
203        .await?;
204        Ok(rows.into_iter().map(row_from_tuple).collect())
205    }
206
207    /// Update only the trust level for a skill.
208    ///
209    /// # Errors
210    ///
211    /// Returns an error if the skill does not exist or the update fails.
212    pub async fn set_skill_trust_level(
213        &self,
214        skill_name: &str,
215        trust_level: SkillTrustLevel,
216    ) -> Result<bool, MemoryError> {
217        let result = zeph_db::query(
218            sql!("UPDATE skill_trust SET trust_level = ?, updated_at = CURRENT_TIMESTAMP WHERE skill_name = ?"),
219        )
220        .bind(trust_level.as_str())
221        .bind(skill_name)
222        .execute(&self.pool)
223        .await?;
224        Ok(result.rows_affected() > 0)
225    }
226
227    /// Delete trust entry for a skill.
228    ///
229    /// # Errors
230    ///
231    /// Returns an error if the delete fails.
232    pub async fn delete_skill_trust(&self, skill_name: &str) -> Result<bool, MemoryError> {
233        let result = zeph_db::query(sql!("DELETE FROM skill_trust WHERE skill_name = ?"))
234            .bind(skill_name)
235            .execute(&self.pool)
236            .await?;
237        Ok(result.rows_affected() > 0)
238    }
239
240    /// Set the `requires_trust_check` flag for a skill.
241    ///
242    /// When `true`, the agent re-hashes `SKILL.md` before each invocation and aborts
243    /// if the blake3 digest changed (tamper detection per #4293).
244    ///
245    /// # Errors
246    ///
247    /// Returns an error if the update fails.
248    pub async fn set_requires_trust_check(
249        &self,
250        skill_name: &str,
251        enabled: bool,
252    ) -> Result<bool, MemoryError> {
253        let flag = i64::from(enabled);
254        let result = zeph_db::query(
255            sql!("UPDATE skill_trust SET requires_trust_check = ?, updated_at = CURRENT_TIMESTAMP WHERE skill_name = ?"),
256        )
257        .bind(flag)
258        .bind(skill_name)
259        .execute(&self.pool)
260        .await?;
261        Ok(result.rows_affected() > 0)
262    }
263
264    /// Update the blake3 hash for a skill.
265    ///
266    /// # Errors
267    ///
268    /// Returns an error if the update fails.
269    pub async fn update_skill_hash(
270        &self,
271        skill_name: &str,
272        blake3_hash: &str,
273    ) -> Result<bool, MemoryError> {
274        let result = zeph_db::query(
275            sql!("UPDATE skill_trust SET blake3_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE skill_name = ?"),
276        )
277        .bind(blake3_hash)
278        .bind(skill_name)
279        .execute(&self.pool)
280        .await?;
281        Ok(result.rows_affected() > 0)
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    async fn test_store() -> SqliteStore {
290        SqliteStore::new(":memory:").await.unwrap()
291    }
292
293    #[tokio::test]
294    async fn upsert_and_load() {
295        let store = test_store().await;
296
297        store
298            .upsert_skill_trust(
299                "git",
300                SkillTrustLevel::Trusted,
301                SourceKind::Local,
302                None,
303                None,
304                "abc123",
305            )
306            .await
307            .unwrap();
308
309        let row = store.load_skill_trust("git").await.unwrap().unwrap();
310        assert_eq!(row.skill_name, "git");
311        assert_eq!(row.trust_level, SkillTrustLevel::Trusted);
312        assert_eq!(row.source_kind, SourceKind::Local);
313        assert_eq!(row.blake3_hash, "abc123");
314    }
315
316    #[tokio::test]
317    async fn upsert_updates_existing() {
318        let store = test_store().await;
319
320        store
321            .upsert_skill_trust(
322                "git",
323                SkillTrustLevel::Quarantined,
324                SourceKind::Local,
325                None,
326                None,
327                "hash1",
328            )
329            .await
330            .unwrap();
331        store
332            .upsert_skill_trust(
333                "git",
334                SkillTrustLevel::Trusted,
335                SourceKind::Local,
336                None,
337                None,
338                "hash2",
339            )
340            .await
341            .unwrap();
342
343        let row = store.load_skill_trust("git").await.unwrap().unwrap();
344        assert_eq!(row.trust_level, SkillTrustLevel::Trusted);
345        assert_eq!(row.blake3_hash, "hash2");
346    }
347
348    #[tokio::test]
349    async fn load_nonexistent() {
350        let store = test_store().await;
351        let row = store.load_skill_trust("nope").await.unwrap();
352        assert!(row.is_none());
353    }
354
355    #[tokio::test]
356    async fn load_all() {
357        let store = test_store().await;
358
359        store
360            .upsert_skill_trust(
361                "alpha",
362                SkillTrustLevel::Trusted,
363                SourceKind::Local,
364                None,
365                None,
366                "h1",
367            )
368            .await
369            .unwrap();
370        store
371            .upsert_skill_trust(
372                "beta",
373                SkillTrustLevel::Quarantined,
374                SourceKind::Hub,
375                Some("https://hub.example.com"),
376                None,
377                "h2",
378            )
379            .await
380            .unwrap();
381
382        let rows = store.load_all_skill_trust().await.unwrap();
383        assert_eq!(rows.len(), 2);
384        assert_eq!(rows[0].skill_name, "alpha");
385        assert_eq!(rows[1].skill_name, "beta");
386    }
387
388    #[tokio::test]
389    async fn set_trust_level() {
390        let store = test_store().await;
391
392        store
393            .upsert_skill_trust(
394                "git",
395                SkillTrustLevel::Quarantined,
396                SourceKind::Local,
397                None,
398                None,
399                "h1",
400            )
401            .await
402            .unwrap();
403
404        let updated = store
405            .set_skill_trust_level("git", SkillTrustLevel::Blocked)
406            .await
407            .unwrap();
408        assert!(updated);
409
410        let row = store.load_skill_trust("git").await.unwrap().unwrap();
411        assert_eq!(row.trust_level, SkillTrustLevel::Blocked);
412    }
413
414    #[tokio::test]
415    async fn set_trust_level_nonexistent() {
416        let store = test_store().await;
417        let updated = store
418            .set_skill_trust_level("nope", SkillTrustLevel::Blocked)
419            .await
420            .unwrap();
421        assert!(!updated);
422    }
423
424    #[tokio::test]
425    async fn delete_trust() {
426        let store = test_store().await;
427
428        store
429            .upsert_skill_trust(
430                "git",
431                SkillTrustLevel::Trusted,
432                SourceKind::Local,
433                None,
434                None,
435                "h1",
436            )
437            .await
438            .unwrap();
439
440        let deleted = store.delete_skill_trust("git").await.unwrap();
441        assert!(deleted);
442
443        let row = store.load_skill_trust("git").await.unwrap();
444        assert!(row.is_none());
445    }
446
447    #[tokio::test]
448    async fn delete_nonexistent() {
449        let store = test_store().await;
450        let deleted = store.delete_skill_trust("nope").await.unwrap();
451        assert!(!deleted);
452    }
453
454    #[tokio::test]
455    async fn update_hash() {
456        let store = test_store().await;
457
458        store
459            .upsert_skill_trust(
460                "git",
461                SkillTrustLevel::Verified,
462                SourceKind::Local,
463                None,
464                None,
465                "old_hash",
466            )
467            .await
468            .unwrap();
469
470        let updated = store.update_skill_hash("git", "new_hash").await.unwrap();
471        assert!(updated);
472
473        let row = store.load_skill_trust("git").await.unwrap().unwrap();
474        assert_eq!(row.blake3_hash, "new_hash");
475    }
476
477    #[tokio::test]
478    async fn source_with_url() {
479        let store = test_store().await;
480
481        store
482            .upsert_skill_trust(
483                "remote-skill",
484                SkillTrustLevel::Quarantined,
485                SourceKind::Hub,
486                Some("https://hub.example.com/skill"),
487                None,
488                "h1",
489            )
490            .await
491            .unwrap();
492
493        let row = store
494            .load_skill_trust("remote-skill")
495            .await
496            .unwrap()
497            .unwrap();
498        assert_eq!(row.source_kind, SourceKind::Hub);
499        assert_eq!(
500            row.source_url.as_deref(),
501            Some("https://hub.example.com/skill")
502        );
503    }
504
505    #[tokio::test]
506    async fn source_with_path() {
507        let store = test_store().await;
508
509        store
510            .upsert_skill_trust(
511                "file-skill",
512                SkillTrustLevel::Quarantined,
513                SourceKind::File,
514                None,
515                Some("/tmp/skill.tar.gz"),
516                "h1",
517            )
518            .await
519            .unwrap();
520
521        let row = store.load_skill_trust("file-skill").await.unwrap().unwrap();
522        assert_eq!(row.source_kind, SourceKind::File);
523        assert_eq!(row.source_path.as_deref(), Some("/tmp/skill.tar.gz"));
524    }
525
526    #[test]
527    fn source_kind_display_local() {
528        assert_eq!(SourceKind::Local.to_string(), "local");
529    }
530
531    #[test]
532    fn source_kind_display_hub() {
533        assert_eq!(SourceKind::Hub.to_string(), "hub");
534    }
535
536    #[test]
537    fn source_kind_display_file() {
538        assert_eq!(SourceKind::File.to_string(), "file");
539    }
540
541    #[test]
542    fn source_kind_from_str_local() {
543        let kind: SourceKind = "local".parse().unwrap();
544        assert_eq!(kind, SourceKind::Local);
545    }
546
547    #[test]
548    fn source_kind_from_str_hub() {
549        let kind: SourceKind = "hub".parse().unwrap();
550        assert_eq!(kind, SourceKind::Hub);
551    }
552
553    #[test]
554    fn source_kind_from_str_file() {
555        let kind: SourceKind = "file".parse().unwrap();
556        assert_eq!(kind, SourceKind::File);
557    }
558
559    #[test]
560    fn source_kind_from_str_unknown_returns_error() {
561        let result: Result<SourceKind, _> = "s3".parse();
562        assert!(result.is_err());
563        assert!(result.unwrap_err().contains("unknown source_kind"));
564    }
565
566    #[test]
567    fn source_kind_serde_json_roundtrip_local() {
568        let original = SourceKind::Local;
569        let json = serde_json::to_string(&original).unwrap();
570        assert_eq!(json, r#""local""#);
571        let back: SourceKind = serde_json::from_str(&json).unwrap();
572        assert_eq!(back, original);
573    }
574
575    #[test]
576    fn source_kind_serde_json_roundtrip_hub() {
577        let original = SourceKind::Hub;
578        let json = serde_json::to_string(&original).unwrap();
579        assert_eq!(json, r#""hub""#);
580        let back: SourceKind = serde_json::from_str(&json).unwrap();
581        assert_eq!(back, original);
582    }
583
584    #[test]
585    fn source_kind_serde_json_roundtrip_file() {
586        let original = SourceKind::File;
587        let json = serde_json::to_string(&original).unwrap();
588        assert_eq!(json, r#""file""#);
589        let back: SourceKind = serde_json::from_str(&json).unwrap();
590        assert_eq!(back, original);
591    }
592
593    #[test]
594    fn source_kind_serde_json_invalid_value_errors() {
595        let result: Result<SourceKind, _> = serde_json::from_str(r#""unknown""#);
596        assert!(result.is_err());
597    }
598
599    #[tokio::test]
600    async fn trust_row_includes_git_hash() {
601        let store = test_store().await;
602
603        store
604            .upsert_skill_trust_with_git_hash(
605                "versioned-skill",
606                SkillTrustLevel::Trusted,
607                SourceKind::Hub,
608                Some("https://hub.example.com/skill"),
609                None,
610                "blake3abc",
611                Some("deadbeef1234"),
612            )
613            .await
614            .unwrap();
615
616        let row = store
617            .load_skill_trust("versioned-skill")
618            .await
619            .unwrap()
620            .unwrap();
621        assert_eq!(row.git_hash.as_deref(), Some("deadbeef1234"));
622        assert_eq!(row.blake3_hash, "blake3abc");
623    }
624
625    #[tokio::test]
626    async fn upsert_without_git_hash_leaves_it_null() {
627        let store = test_store().await;
628
629        store
630            .upsert_skill_trust(
631                "git",
632                SkillTrustLevel::Trusted,
633                SourceKind::Local,
634                None,
635                None,
636                "hash1",
637            )
638            .await
639            .unwrap();
640
641        let row = store.load_skill_trust("git").await.unwrap().unwrap();
642        assert!(row.git_hash.is_none());
643    }
644
645    #[tokio::test]
646    async fn upsert_each_source_kind_roundtrip() {
647        let store = test_store().await;
648        let variants = [
649            ("skill-local", SourceKind::Local),
650            ("skill-hub", SourceKind::Hub),
651            ("skill-file", SourceKind::File),
652            ("skill-bundled", SourceKind::Bundled),
653        ];
654        for (name, kind) in &variants {
655            store
656                .upsert_skill_trust(
657                    name,
658                    SkillTrustLevel::Trusted,
659                    kind.clone(),
660                    None,
661                    None,
662                    "hash",
663                )
664                .await
665                .unwrap();
666            let row = store.load_skill_trust(name).await.unwrap().unwrap();
667            assert_eq!(&row.source_kind, kind);
668        }
669    }
670
671    #[test]
672    fn source_kind_display_bundled() {
673        assert_eq!(SourceKind::Bundled.to_string(), "bundled");
674    }
675
676    #[test]
677    fn source_kind_from_str_bundled() {
678        let kind: SourceKind = "bundled".parse().unwrap();
679        assert_eq!(kind, SourceKind::Bundled);
680    }
681
682    #[test]
683    fn source_kind_serde_json_roundtrip_bundled() {
684        let original = SourceKind::Bundled;
685        let json = serde_json::to_string(&original).unwrap();
686        assert_eq!(json, r#""bundled""#);
687        let back: SourceKind = serde_json::from_str(&json).unwrap();
688        assert_eq!(back, original);
689    }
690
691    #[test]
692    fn source_kind_from_str_unknown_falls_back_to_local_in_row_from_tuple() {
693        // Verify that unknown DB values (e.g., from a future version downgrade)
694        // deserialize gracefully via the unwrap_or(Local) in row_from_tuple.
695        let result: Result<SourceKind, _> = "future_variant".parse();
696        assert!(result.is_err());
697        // row_from_tuple uses unwrap_or(SourceKind::Local) — simulate that here.
698        let fallback = result.unwrap_or(SourceKind::Local);
699        assert_eq!(fallback, SourceKind::Local);
700    }
701
702    // Scenario 2: Bundled trust level is preserved when re-upserted with the same source_kind.
703    // This covers the hot-reload path where hash matches and source_kind is unchanged.
704    #[tokio::test]
705    async fn bundled_trust_preserved_on_same_source_kind_upsert() {
706        let store = test_store().await;
707
708        store
709            .upsert_skill_trust(
710                "web-search",
711                SkillTrustLevel::Trusted,
712                SourceKind::Bundled,
713                None,
714                None,
715                "hash1",
716            )
717            .await
718            .unwrap();
719
720        // Simulate a second startup (hash unchanged, source_kind unchanged) — trust must be preserved.
721        store
722            .upsert_skill_trust(
723                "web-search",
724                SkillTrustLevel::Trusted,
725                SourceKind::Bundled,
726                None,
727                None,
728                "hash1",
729            )
730            .await
731            .unwrap();
732
733        let row = store.load_skill_trust("web-search").await.unwrap().unwrap();
734        assert_eq!(row.source_kind, SourceKind::Bundled);
735        assert_eq!(row.trust_level, SkillTrustLevel::Trusted);
736    }
737
738    // Scenario 3: Migration from hub/quarantined to bundled/trusted when .bundled marker appears.
739    // The store upsert always overwrites source_kind and trust_level when called with new values.
740    #[tokio::test]
741    async fn migration_hub_quarantined_to_bundled_trusted() {
742        let store = test_store().await;
743
744        // Initial state: existing install has hub/quarantined.
745        store
746            .upsert_skill_trust(
747                "git",
748                SkillTrustLevel::Quarantined,
749                SourceKind::Hub,
750                None,
751                None,
752                "hash1",
753            )
754            .await
755            .unwrap();
756
757        let row = store.load_skill_trust("git").await.unwrap().unwrap();
758        assert_eq!(row.source_kind, SourceKind::Hub);
759        assert_eq!(row.trust_level, SkillTrustLevel::Quarantined);
760
761        // After runner detects .bundled marker: upsert with Bundled/trusted (initial_level from bundled_level).
762        store
763            .upsert_skill_trust(
764                "git",
765                SkillTrustLevel::Trusted,
766                SourceKind::Bundled,
767                None,
768                None,
769                "hash1",
770            )
771            .await
772            .unwrap();
773
774        let row = store.load_skill_trust("git").await.unwrap().unwrap();
775        assert_eq!(row.source_kind, SourceKind::Bundled);
776        assert_eq!(row.trust_level, SkillTrustLevel::Trusted);
777    }
778
779    // Regression test for C1: operator-blocked bundled skills must not be unblocked by migration.
780    // The store layer always overwrites; caller logic (runner/mod) must pass "blocked" through.
781    #[tokio::test]
782    async fn operator_blocked_bundled_skill_stays_blocked_when_upserted_with_blocked() {
783        let store = test_store().await;
784
785        // Existing install: hub/blocked (operator explicitly blocked this skill).
786        store
787            .upsert_skill_trust(
788                "web-search",
789                SkillTrustLevel::Blocked,
790                SourceKind::Hub,
791                None,
792                None,
793                "hash1",
794            )
795            .await
796            .unwrap();
797
798        // Migration: runner detects .bundled marker but preserves "blocked" (caller responsibility).
799        store
800            .upsert_skill_trust(
801                "web-search",
802                SkillTrustLevel::Blocked,
803                SourceKind::Bundled,
804                None,
805                None,
806                "hash1",
807            )
808            .await
809            .unwrap();
810
811        let row = store.load_skill_trust("web-search").await.unwrap().unwrap();
812        assert_eq!(row.source_kind, SourceKind::Bundled);
813        assert_eq!(
814            row.trust_level,
815            SkillTrustLevel::Blocked,
816            "operator block must survive source_kind migration"
817        );
818    }
819
820    // Scenario 4: Configurable bundled_level (e.g., "quarantined" for strict configs) is applied during classification.
821    // This tests that the store persists any trust level correctly for non-default configs.
822    #[tokio::test]
823    async fn bundled_skill_with_configured_quarantined_level() {
824        let store = test_store().await;
825
826        store
827            .upsert_skill_trust(
828                "git",
829                SkillTrustLevel::Quarantined,
830                SourceKind::Bundled,
831                None,
832                None,
833                "hash1",
834            )
835            .await
836            .unwrap();
837
838        let row = store.load_skill_trust("git").await.unwrap().unwrap();
839        assert_eq!(row.source_kind, SourceKind::Bundled);
840        assert_eq!(row.trust_level, SkillTrustLevel::Quarantined);
841    }
842}