khive_runtime/operations/
quarantine_note_retention.rs1use 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 #[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 #[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 #[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}