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