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