Skip to main content

khive_runtime/operations/
quarantine_note_retention.rs

1use std::collections::BTreeMap;
2
3use khive_storage::note::{NotePropertyPatch, NotePropertyPrecondition};
4use khive_storage::types::SqlValue;
5use khive_storage::{ContentRef, NewAttachment, Note};
6use serde_json::{json, Value};
7use uuid::Uuid;
8
9use crate::{KhiveRuntime, NamespaceToken, RuntimeResult};
10
11impl KhiveRuntime {
12    /// Like [`Self::try_create_note`] but permits the caller to establish the
13    /// transport-owned `message` properties that `try_create_note` refuses.
14    ///
15    /// This is a deliberately named, separate entry point rather than a flag
16    /// on `try_create_note` so the trust decision is visible at every call
17    /// site: `comm.ingest` (`khive-pack-comm/src/handlers/ingest.rs`) is the sole
18    /// legitimate caller, because it is the only code that has just derived
19    /// quarantine disposition and channel provenance from the inbound
20    /// transport itself. The caller set is bounded by possession, not
21    /// documentation: the required [`crate::ChannelIngestCapability`] is
22    /// constructible only inside this crate and granted at pack registration
23    /// exclusively to channel-transport packs. Every other write path uses
24    /// `try_create_note`, which rejects those properties
25    /// unconditionally.
26    #[allow(clippy::too_many_arguments)]
27    pub async fn try_create_note_as_trusted_ingest(
28        &self,
29        _capability: &crate::pack::ChannelIngestCapability,
30        token: &NamespaceToken,
31        kind: &str,
32        name: Option<&str>,
33        content: &str,
34        properties: Option<serde_json::Value>,
35        expires_after: Option<std::time::Duration>,
36    ) -> RuntimeResult<Option<Note>> {
37        self.try_create_note_impl(
38            token,
39            kind,
40            name,
41            content,
42            properties,
43            true,
44            None,
45            expires_after,
46        )
47        .await
48    }
49
50    /// Publish a trusted inbound message and its original-byte attachment in
51    /// one database transaction. Channel quarantine must not advertise a
52    /// reference in note metadata before GC can see its attachment owner.
53    #[allow(clippy::too_many_arguments)]
54    pub async fn try_create_note_as_trusted_ingest_with_attachment(
55        &self,
56        _capability: &crate::pack::ChannelIngestCapability,
57        token: &NamespaceToken,
58        kind: &str,
59        name: Option<&str>,
60        content: &str,
61        properties: Option<serde_json::Value>,
62        attachment: NewAttachment,
63        expires_after: Option<std::time::Duration>,
64    ) -> RuntimeResult<Option<Note>> {
65        self.try_create_note_impl(
66            token,
67            kind,
68            name,
69            content,
70            properties,
71            true,
72            Some(attachment),
73            expires_after,
74        )
75        .await
76    }
77
78    /// Repair the original-byte reference and retention of a matching quarantined message.
79    /// The capability permits only this fixed property set; storage rechecks the
80    /// channel and original-byte identity against the current row before writing.
81    #[allow(clippy::too_many_arguments)]
82    pub async fn try_repair_quarantined_note_retention(
83        &self,
84        _capability: &crate::ChannelIngestCapability,
85        token: &NamespaceToken,
86        id: Uuid,
87        channel_kind: &str,
88        channel_slug: &str,
89        content_ref: &ContentRef,
90        extend_expires_at: Option<i64>,
91        updated_at: i64,
92    ) -> RuntimeResult<bool> {
93        let set = BTreeMap::from([
94            ("channel_slug".into(), Value::String(channel_slug.into())),
95            (
96                "quarantine_content_ref".into(),
97                Value::String(content_ref.to_string()),
98            ),
99        ]);
100        crate::secret_gate::reject_reserved_secret_gate_property(Some(&json!(&set)))?;
101        let patch = NotePropertyPatch {
102            preconditions: vec![
103                NotePropertyPrecondition::AbsentOrExtractEquals {
104                    key: "quarantine_content_ref".into(),
105                    value: SqlValue::Text(content_ref.to_string()),
106                },
107                NotePropertyPrecondition::ExtractEquals {
108                    key: "channel_kind".into(),
109                    value: SqlValue::Text(channel_kind.into()),
110                },
111                NotePropertyPrecondition::AbsentOrTextEquals {
112                    key: "channel_slug".into(),
113                    value: channel_slug.into(),
114                },
115                NotePropertyPrecondition::TrueOrTextTrue {
116                    key: "quarantined".into(),
117                },
118            ],
119            set,
120            extend_expires_at,
121            updated_at,
122        };
123        Ok(self
124            .raw_notes(token)?
125            .try_patch_note_properties(id, token.namespace().as_str(), "message", &patch)
126            .await?)
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::{BackendId, RuntimeConfig, StorageBackend};
134    use std::sync::Arc;
135
136    #[tokio::test]
137    async fn trusted_retention_repair_uses_current_backend_and_preserves_unrelated_properties() {
138        let main = Arc::new(StorageBackend::memory().unwrap());
139        let channel = Arc::new(StorageBackend::memory().unwrap());
140        main.prepare_core_schema().unwrap();
141        channel.prepare_core_schema().unwrap();
142        let mut config = RuntimeConfig::no_embeddings();
143        config.backend_id = BackendId::parse("channel").unwrap();
144        let runtime = KhiveRuntime::from_backend(channel, config).with_core_backend(main);
145        let token = NamespaceToken::local();
146        let capability = crate::ChannelIngestCapability::grant_for_direct_composition();
147        let content_ref = ContentRef::from_hex("a".repeat(64)).unwrap();
148        let mut note = Note::new("local", "message", "quarantine").with_properties(json!({
149            "channel_kind": "email",
150            "quarantined": "true",
151            "unrelated": {"keep": [1, true, null]},
152            "khive:secret_gate": {"preserve": true},
153        }));
154        note.updated_at = 100;
155        note.expires_at = Some(400);
156        let raw = runtime.raw_notes(&token).unwrap();
157        raw.upsert_note(note.clone()).await.unwrap();
158        let main_notes = runtime.core().raw_notes(&token).unwrap();
159        main_notes.upsert_note(note.clone()).await.unwrap();
160        let main_before = main_notes.get_note(note.id).await.unwrap();
161
162        assert!(runtime
163            .try_repair_quarantined_note_retention(
164                &capability,
165                &token,
166                note.id,
167                "email",
168                "mailbox",
169                &content_ref,
170                Some(300),
171                90,
172            )
173            .await
174            .unwrap());
175        let repaired = raw.get_note(note.id).await.unwrap().unwrap();
176        assert_eq!(repaired.updated_at, 100);
177        assert_eq!(repaired.expires_at, Some(400));
178        assert_eq!(
179            repaired.properties.as_ref().unwrap()["channel_slug"],
180            "mailbox"
181        );
182        assert_eq!(
183            repaired.properties.as_ref().unwrap()["quarantine_content_ref"],
184            content_ref.to_string()
185        );
186        for key in ["unrelated", "khive:secret_gate"] {
187            assert_eq!(
188                repaired.properties.as_ref().unwrap()[key],
189                note.properties.as_ref().unwrap()[key]
190            );
191        }
192        assert_eq!(main_notes.get_note(note.id).await.unwrap(), main_before);
193
194        assert!(!runtime
195            .try_repair_quarantined_note_retention(
196                &capability,
197                &token,
198                note.id,
199                "email",
200                "other-mailbox",
201                &content_ref,
202                Some(500),
203                200,
204            )
205            .await
206            .unwrap());
207        assert_eq!(raw.get_note(note.id).await.unwrap().unwrap(), repaired);
208    }
209}