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.pad(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 set_requires_trust_check_flag() {
478        let store = test_store().await;
479
480        store
481            .upsert_skill_trust(
482                "git",
483                SkillTrustLevel::Trusted,
484                SourceKind::Local,
485                None,
486                None,
487                "hash1",
488            )
489            .await
490            .unwrap();
491
492        let row = store.load_skill_trust("git").await.unwrap().unwrap();
493        assert!(
494            !row.requires_trust_check,
495            "requires_trust_check must default to false on insert"
496        );
497
498        let updated = store.set_requires_trust_check("git", true).await.unwrap();
499        assert!(updated);
500        let row = store.load_skill_trust("git").await.unwrap().unwrap();
501        assert!(row.requires_trust_check);
502
503        let updated = store.set_requires_trust_check("git", false).await.unwrap();
504        assert!(updated);
505        let row = store.load_skill_trust("git").await.unwrap().unwrap();
506        assert!(!row.requires_trust_check);
507    }
508
509    #[tokio::test]
510    async fn set_requires_trust_check_nonexistent() {
511        let store = test_store().await;
512        let updated = store.set_requires_trust_check("nope", true).await.unwrap();
513        assert!(!updated);
514    }
515
516    #[tokio::test]
517    async fn update_hash() {
518        let store = test_store().await;
519
520        store
521            .upsert_skill_trust(
522                "git",
523                SkillTrustLevel::Verified,
524                SourceKind::Local,
525                None,
526                None,
527                "old_hash",
528            )
529            .await
530            .unwrap();
531
532        let updated = store.update_skill_hash("git", "new_hash").await.unwrap();
533        assert!(updated);
534
535        let row = store.load_skill_trust("git").await.unwrap().unwrap();
536        assert_eq!(row.blake3_hash, "new_hash");
537    }
538
539    #[tokio::test]
540    async fn source_with_url() {
541        let store = test_store().await;
542
543        store
544            .upsert_skill_trust(
545                "remote-skill",
546                SkillTrustLevel::Quarantined,
547                SourceKind::Hub,
548                Some("https://hub.example.com/skill"),
549                None,
550                "h1",
551            )
552            .await
553            .unwrap();
554
555        let row = store
556            .load_skill_trust("remote-skill")
557            .await
558            .unwrap()
559            .unwrap();
560        assert_eq!(row.source_kind, SourceKind::Hub);
561        assert_eq!(
562            row.source_url.as_deref(),
563            Some("https://hub.example.com/skill")
564        );
565    }
566
567    #[tokio::test]
568    async fn source_with_path() {
569        let store = test_store().await;
570
571        store
572            .upsert_skill_trust(
573                "file-skill",
574                SkillTrustLevel::Quarantined,
575                SourceKind::File,
576                None,
577                Some("/tmp/skill.tar.gz"),
578                "h1",
579            )
580            .await
581            .unwrap();
582
583        let row = store.load_skill_trust("file-skill").await.unwrap().unwrap();
584        assert_eq!(row.source_kind, SourceKind::File);
585        assert_eq!(row.source_path.as_deref(), Some("/tmp/skill.tar.gz"));
586    }
587
588    #[test]
589    fn source_kind_display_local() {
590        assert_eq!(SourceKind::Local.to_string(), "local");
591    }
592
593    #[test]
594    fn source_kind_display_hub() {
595        assert_eq!(SourceKind::Hub.to_string(), "hub");
596    }
597
598    #[test]
599    fn source_kind_display_file() {
600        assert_eq!(SourceKind::File.to_string(), "file");
601    }
602
603    /// Locks in the `f.pad` fix (#6066): `f.write_str` ignores width/fill/align flags.
604    /// `f.pad` must reproduce the same padding a plain `&str` would get under an
605    /// identical width specifier.
606    #[test]
607    fn source_kind_display_respects_width() {
608        assert_eq!(
609            format!("{:<10}", SourceKind::Local),
610            format!("{:<10}", "local")
611        );
612        assert_eq!(
613            format!("{:>10}", SourceKind::Bundled),
614            format!("{:>10}", "bundled")
615        );
616    }
617
618    #[test]
619    fn source_kind_from_str_local() {
620        let kind: SourceKind = "local".parse().unwrap();
621        assert_eq!(kind, SourceKind::Local);
622    }
623
624    #[test]
625    fn source_kind_from_str_hub() {
626        let kind: SourceKind = "hub".parse().unwrap();
627        assert_eq!(kind, SourceKind::Hub);
628    }
629
630    #[test]
631    fn source_kind_from_str_file() {
632        let kind: SourceKind = "file".parse().unwrap();
633        assert_eq!(kind, SourceKind::File);
634    }
635
636    #[test]
637    fn source_kind_from_str_unknown_returns_error() {
638        let result: Result<SourceKind, _> = "s3".parse();
639        assert!(result.is_err());
640        assert!(result.unwrap_err().contains("unknown source_kind"));
641    }
642
643    #[test]
644    fn source_kind_serde_json_roundtrip_local() {
645        let original = SourceKind::Local;
646        let json = serde_json::to_string(&original).unwrap();
647        assert_eq!(json, r#""local""#);
648        let back: SourceKind = serde_json::from_str(&json).unwrap();
649        assert_eq!(back, original);
650    }
651
652    #[test]
653    fn source_kind_serde_json_roundtrip_hub() {
654        let original = SourceKind::Hub;
655        let json = serde_json::to_string(&original).unwrap();
656        assert_eq!(json, r#""hub""#);
657        let back: SourceKind = serde_json::from_str(&json).unwrap();
658        assert_eq!(back, original);
659    }
660
661    #[test]
662    fn source_kind_serde_json_roundtrip_file() {
663        let original = SourceKind::File;
664        let json = serde_json::to_string(&original).unwrap();
665        assert_eq!(json, r#""file""#);
666        let back: SourceKind = serde_json::from_str(&json).unwrap();
667        assert_eq!(back, original);
668    }
669
670    #[test]
671    fn source_kind_serde_json_invalid_value_errors() {
672        let result: Result<SourceKind, _> = serde_json::from_str(r#""unknown""#);
673        assert!(result.is_err());
674    }
675
676    #[tokio::test]
677    async fn trust_row_includes_git_hash() {
678        let store = test_store().await;
679
680        store
681            .upsert_skill_trust_with_git_hash(
682                "versioned-skill",
683                SkillTrustLevel::Trusted,
684                SourceKind::Hub,
685                Some("https://hub.example.com/skill"),
686                None,
687                "blake3abc",
688                Some("deadbeef1234"),
689            )
690            .await
691            .unwrap();
692
693        let row = store
694            .load_skill_trust("versioned-skill")
695            .await
696            .unwrap()
697            .unwrap();
698        assert_eq!(row.git_hash.as_deref(), Some("deadbeef1234"));
699        assert_eq!(row.blake3_hash, "blake3abc");
700    }
701
702    #[tokio::test]
703    async fn upsert_without_git_hash_leaves_it_null() {
704        let store = test_store().await;
705
706        store
707            .upsert_skill_trust(
708                "git",
709                SkillTrustLevel::Trusted,
710                SourceKind::Local,
711                None,
712                None,
713                "hash1",
714            )
715            .await
716            .unwrap();
717
718        let row = store.load_skill_trust("git").await.unwrap().unwrap();
719        assert!(row.git_hash.is_none());
720    }
721
722    #[tokio::test]
723    async fn upsert_each_source_kind_roundtrip() {
724        let store = test_store().await;
725        let variants = [
726            ("skill-local", SourceKind::Local),
727            ("skill-hub", SourceKind::Hub),
728            ("skill-file", SourceKind::File),
729            ("skill-bundled", SourceKind::Bundled),
730        ];
731        for (name, kind) in &variants {
732            store
733                .upsert_skill_trust(
734                    name,
735                    SkillTrustLevel::Trusted,
736                    kind.clone(),
737                    None,
738                    None,
739                    "hash",
740                )
741                .await
742                .unwrap();
743            let row = store.load_skill_trust(name).await.unwrap().unwrap();
744            assert_eq!(&row.source_kind, kind);
745        }
746    }
747
748    #[test]
749    fn source_kind_display_bundled() {
750        assert_eq!(SourceKind::Bundled.to_string(), "bundled");
751    }
752
753    #[test]
754    fn source_kind_from_str_bundled() {
755        let kind: SourceKind = "bundled".parse().unwrap();
756        assert_eq!(kind, SourceKind::Bundled);
757    }
758
759    #[test]
760    fn source_kind_serde_json_roundtrip_bundled() {
761        let original = SourceKind::Bundled;
762        let json = serde_json::to_string(&original).unwrap();
763        assert_eq!(json, r#""bundled""#);
764        let back: SourceKind = serde_json::from_str(&json).unwrap();
765        assert_eq!(back, original);
766    }
767
768    #[test]
769    fn source_kind_from_str_unknown_falls_back_to_local_in_row_from_tuple() {
770        // Verify that unknown DB values (e.g., from a future version downgrade)
771        // deserialize gracefully via the unwrap_or(Local) in row_from_tuple.
772        let result: Result<SourceKind, _> = "future_variant".parse();
773        assert!(result.is_err());
774        // row_from_tuple uses unwrap_or(SourceKind::Local) — simulate that here.
775        let fallback = result.unwrap_or(SourceKind::Local);
776        assert_eq!(fallback, SourceKind::Local);
777    }
778
779    // Scenario 2: Bundled trust level is preserved when re-upserted with the same source_kind.
780    // This covers the hot-reload path where hash matches and source_kind is unchanged.
781    #[tokio::test]
782    async fn bundled_trust_preserved_on_same_source_kind_upsert() {
783        let store = test_store().await;
784
785        store
786            .upsert_skill_trust(
787                "web-search",
788                SkillTrustLevel::Trusted,
789                SourceKind::Bundled,
790                None,
791                None,
792                "hash1",
793            )
794            .await
795            .unwrap();
796
797        // Simulate a second startup (hash unchanged, source_kind unchanged) — trust must be preserved.
798        store
799            .upsert_skill_trust(
800                "web-search",
801                SkillTrustLevel::Trusted,
802                SourceKind::Bundled,
803                None,
804                None,
805                "hash1",
806            )
807            .await
808            .unwrap();
809
810        let row = store.load_skill_trust("web-search").await.unwrap().unwrap();
811        assert_eq!(row.source_kind, SourceKind::Bundled);
812        assert_eq!(row.trust_level, SkillTrustLevel::Trusted);
813    }
814
815    // Scenario 3: Migration from hub/quarantined to bundled/trusted when .bundled marker appears.
816    // The store upsert always overwrites source_kind and trust_level when called with new values.
817    #[tokio::test]
818    async fn migration_hub_quarantined_to_bundled_trusted() {
819        let store = test_store().await;
820
821        // Initial state: existing install has hub/quarantined.
822        store
823            .upsert_skill_trust(
824                "git",
825                SkillTrustLevel::Quarantined,
826                SourceKind::Hub,
827                None,
828                None,
829                "hash1",
830            )
831            .await
832            .unwrap();
833
834        let row = store.load_skill_trust("git").await.unwrap().unwrap();
835        assert_eq!(row.source_kind, SourceKind::Hub);
836        assert_eq!(row.trust_level, SkillTrustLevel::Quarantined);
837
838        // After runner detects .bundled marker: upsert with Bundled/trusted (initial_level from bundled_level).
839        store
840            .upsert_skill_trust(
841                "git",
842                SkillTrustLevel::Trusted,
843                SourceKind::Bundled,
844                None,
845                None,
846                "hash1",
847            )
848            .await
849            .unwrap();
850
851        let row = store.load_skill_trust("git").await.unwrap().unwrap();
852        assert_eq!(row.source_kind, SourceKind::Bundled);
853        assert_eq!(row.trust_level, SkillTrustLevel::Trusted);
854    }
855
856    // Regression test for C1: operator-blocked bundled skills must not be unblocked by migration.
857    // The store layer always overwrites; caller logic (runner/mod) must pass "blocked" through.
858    #[tokio::test]
859    async fn operator_blocked_bundled_skill_stays_blocked_when_upserted_with_blocked() {
860        let store = test_store().await;
861
862        // Existing install: hub/blocked (operator explicitly blocked this skill).
863        store
864            .upsert_skill_trust(
865                "web-search",
866                SkillTrustLevel::Blocked,
867                SourceKind::Hub,
868                None,
869                None,
870                "hash1",
871            )
872            .await
873            .unwrap();
874
875        // Migration: runner detects .bundled marker but preserves "blocked" (caller responsibility).
876        store
877            .upsert_skill_trust(
878                "web-search",
879                SkillTrustLevel::Blocked,
880                SourceKind::Bundled,
881                None,
882                None,
883                "hash1",
884            )
885            .await
886            .unwrap();
887
888        let row = store.load_skill_trust("web-search").await.unwrap().unwrap();
889        assert_eq!(row.source_kind, SourceKind::Bundled);
890        assert_eq!(
891            row.trust_level,
892            SkillTrustLevel::Blocked,
893            "operator block must survive source_kind migration"
894        );
895    }
896
897    // Scenario 4: Configurable bundled_level (e.g., "quarantined" for strict configs) is applied during classification.
898    // This tests that the store persists any trust level correctly for non-default configs.
899    #[tokio::test]
900    async fn bundled_skill_with_configured_quarantined_level() {
901        let store = test_store().await;
902
903        store
904            .upsert_skill_trust(
905                "git",
906                SkillTrustLevel::Quarantined,
907                SourceKind::Bundled,
908                None,
909                None,
910                "hash1",
911            )
912            .await
913            .unwrap();
914
915        let row = store.load_skill_trust("git").await.unwrap().unwrap();
916        assert_eq!(row.source_kind, SourceKind::Bundled);
917        assert_eq!(row.trust_level, SkillTrustLevel::Quarantined);
918    }
919}