1use khive_storage::{SqlStatement, SqlValue, SqlWriter, StorageError};
4use khive_types::{Details, KhiveError};
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8use crate::{KhiveRuntime, NamespaceToken, RuntimeError, RuntimeResult};
9
10#[cfg(test)]
26pub(crate) mod race_seam {
27 use std::sync::Arc;
28 use tokio::sync::Barrier;
29
30 tokio::task_local! {
31 pub(crate) static AFTER_PREPARE_BARRIER: Arc<Barrier>;
32 }
33
34 pub(crate) async fn pause_after_prepare() {
39 if let Ok(barrier) = AFTER_PREPARE_BARRIER.try_with(Arc::clone) {
40 barrier.wait().await;
41 barrier.wait().await;
42 }
43 }
44}
45
46pub const MAX_NOTE_FENCES: usize = 100;
48
49#[derive(Clone, Debug, Serialize)]
50pub struct NoteFence {
51 pub key: String,
52 pub kind: String,
53 pub expected_version: Option<i64>,
55 #[serde(skip_serializing_if = "Option::is_none")]
61 pub live_until: Option<String>,
62 #[serde(skip_serializing_if = "Option::is_none")]
67 pub id: Option<Uuid>,
68}
69
70impl<'de> Deserialize<'de> for NoteFence {
71 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
72 #[derive(Deserialize)]
73 #[serde(deny_unknown_fields)]
74 struct Fields {
75 key: String,
76 kind: String,
77 #[serde(alias = "version")]
78 expected_version: Option<i64>,
79 live_until: Option<String>,
80 id: Option<Uuid>,
81 }
82
83 let value = serde_json::Value::deserialize(deserializer)?;
84 if value.get("expected_version").is_none() && value.get("version").is_none() {
86 return Err(serde::de::Error::custom(
87 "fence requires expected_version (positive integer or null)",
88 ));
89 }
90 let fields = Fields::deserialize(value).map_err(|error| {
91 serde::de::Error::custom(format!(
92 "fence requires expected_version (positive integer or null): {error}"
93 ))
94 })?;
95 Ok(Self {
96 key: fields.key,
97 kind: fields.kind,
98 expected_version: fields.expected_version,
99 live_until: fields.live_until,
100 id: fields.id,
101 })
102 }
103}
104
105impl NoteFence {
106 pub fn validate(&self) -> RuntimeResult<()> {
107 crate::keyed_memory::validate_memory_key(&self.key)?;
108 if self.kind.is_empty() {
109 return Err(RuntimeError::InvalidInput(
110 "fence requires a note kind".into(),
111 ));
112 }
113 if self.expected_version.is_some_and(|version| version < 1) {
114 return Err(RuntimeError::InvalidInput(
115 "fence requires expected_version (positive integer or null)".into(),
116 ));
117 }
118 if self.live_until.is_some() && self.expected_version.is_none() {
119 return Err(RuntimeError::InvalidInput(
122 "fence live_until requires a positive expected_version".into(),
123 ));
124 }
125 if self.live_until.as_ref().is_some_and(|path| path.is_empty()) {
126 return Err(RuntimeError::InvalidInput(
127 "fence live_until requires a document path".into(),
128 ));
129 }
130 if self.id.is_some() && self.expected_version.is_none() {
131 return Err(RuntimeError::InvalidInput(
134 "fence id requires a positive expected_version".into(),
135 ));
136 }
137 Ok(())
138 }
139}
140
141#[derive(Clone, Debug, Serialize)]
143#[serde(untagged)]
144pub enum NoteFences {
145 One(NoteFence),
146 Many(Vec<NoteFence>),
147}
148
149impl From<NoteFence> for NoteFences {
150 fn from(fence: NoteFence) -> Self {
151 Self::One(fence)
152 }
153}
154
155impl NoteFences {
156 fn validate_count(count: usize) -> RuntimeResult<()> {
157 crate::note_validation::validate_fence_count(count, MAX_NOTE_FENCES, || {
158 format!(
159 "fence list admits at most {MAX_NOTE_FENCES} entries; this call sent {count}: each fence is a read taken while holding the writer"
160 )
161 })
162 }
163
164 pub fn entries(&self) -> &[NoteFence] {
165 match self {
166 Self::One(fence) => std::slice::from_ref(fence),
167 Self::Many(fences) => fences,
168 }
169 }
170
171 pub fn validate(&self) -> RuntimeResult<()> {
172 Self::validate_count(self.entries().len())?;
173 if self.entries().is_empty() {
174 return Err(RuntimeError::InvalidInput(
175 "fence list must not be empty".into(),
176 ));
177 }
178 let mut seen = std::collections::HashMap::new();
179 for (index, fence) in self.entries().iter().enumerate() {
180 fence.validate()?;
181 if let Some(first) = seen.insert((&fence.kind, &fence.key), index) {
182 return Err(RuntimeError::InvalidInput(format!(
183 "duplicate fence (kind, key) at indices {first} and {index}"
184 )));
185 }
186 }
187 Ok(())
188 }
189}
190
191impl<'de> Deserialize<'de> for NoteFences {
192 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
193 let value = serde_json::Value::deserialize(deserializer)?;
194 let fences = match value {
195 serde_json::Value::Object(_) => {
196 Self::One(NoteFence::deserialize(value).map_err(serde::de::Error::custom)?)
197 }
198 serde_json::Value::Array(entries) => {
199 Self::validate_count(entries.len()).map_err(serde::de::Error::custom)?;
200 let mut fences = Vec::with_capacity(entries.len());
201 for (index, entry) in entries.into_iter().enumerate() {
202 fences.push(NoteFence::deserialize(entry).map_err(|error| {
203 serde::de::Error::custom(format!("fence entry {index}: {error}"))
204 })?);
205 }
206 Self::Many(fences)
207 }
208 _ => {
209 return Err(serde::de::Error::custom(
210 "fence requires an object or a non-empty list of objects with key, kind and expected_version (positive integer or null)",
211 ));
212 }
213 };
214 fences.validate().map_err(serde::de::Error::custom)?;
215 Ok(fences)
216 }
217}
218
219pub fn deserialize_optional_fences<'de, D: serde::Deserializer<'de>>(
221 deserializer: D,
222) -> Result<Option<NoteFences>, D::Error> {
223 NoteFences::deserialize(deserializer).map(Some)
224}
225
226#[derive(Clone, Debug, Default)]
227pub struct NoteWriteOptions {
228 pub key: Option<String>,
229 pub expected_version: Option<i64>,
230 pub fence: Option<NoteFences>,
231 pub embed: Option<bool>,
232}
233
234impl NoteWriteOptions {
235 pub fn validate(&self) -> RuntimeResult<()> {
236 if let Some(key) = &self.key {
237 crate::keyed_memory::validate_memory_key(key)?;
238 }
239 if self.expected_version.is_some_and(|version| version < 1) {
240 return Err(RuntimeError::InvalidInput(
241 "expected_version must be positive".into(),
242 ));
243 }
244 if let Some(fence) = &self.fence {
245 fence.validate()?;
246 }
247 Ok(())
248 }
249}
250
251#[derive(Clone, Debug)]
257pub(crate) struct CreateKeyClaim {
258 pub kind: String,
259 pub key: String,
260 pub content: String,
261 pub properties: Option<serde_json::Value>,
262 pub replay_signal: bool,
267}
268
269#[derive(Clone, Debug)]
270pub(crate) struct NoteWriteGuard {
271 pub namespace: String,
272 pub target_id: Uuid,
273 pub expected_version: Option<i64>,
274 pub fence: Option<NoteFences>,
275 pub create_key: Option<CreateKeyClaim>,
276}
277
278#[derive(Clone, Debug)]
279pub(crate) struct NoteVectors {
280 namespace: String,
281 subject_id: Uuid,
282}
283
284impl NoteVectors {
285 pub(crate) fn new(namespace: String, subject_id: Uuid) -> Self {
286 Self {
287 namespace,
288 subject_id,
289 }
290 }
291
292 async fn tables(writer: &mut dyn SqlWriter) -> Result<Vec<String>, StorageError> {
293 let tables = writer
296 .query_all(
297 SqlStatement::new(
298 "SELECT name FROM pragma_table_list \
299 WHERE schema='main' AND type='virtual' AND name GLOB 'vec_*' ORDER BY name",
300 vec![],
301 )
302 .labelled("note-write-guard"),
303 )
304 .await?;
305 let mut names = Vec::with_capacity(tables.len());
306 for row in tables {
307 let Some(SqlValue::Text(table)) = row.get("name") else {
308 return Err(StorageError::Internal(
309 "invalid vector table catalog row".into(),
310 ));
311 };
312 if !table.strip_prefix("vec_").is_some_and(|key| {
313 !key.is_empty() && key.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_')
314 }) {
315 return Err(StorageError::Internal(
316 "invalid persisted vector table name".into(),
317 ));
318 }
319 names.push(table.clone());
320 }
321 Ok(names)
322 }
323
324 pub(crate) async fn has_rows(&self, writer: &mut dyn SqlWriter) -> Result<bool, StorageError> {
325 for table in Self::tables(writer).await? {
326 if writer
327 .query_scalar(
328 SqlStatement::new(
329 format!(
330 "SELECT 1 FROM main.{table} WHERE namespace=?1 AND subject_id=?2 LIMIT 1"
331 ),
332 vec![
333 SqlValue::Text(self.namespace.clone()),
334 SqlValue::Text(self.subject_id.to_string()),
335 ],
336 )
337 .labelled("note-write-guard"),
338 )
339 .await?
340 .is_some()
341 {
342 return Ok(true);
343 }
344 }
345 Ok(false)
346 }
347
348 pub(crate) async fn apply(&self, writer: &mut dyn SqlWriter) -> Result<(), StorageError> {
349 for table in Self::tables(writer).await? {
350 let scope = vec![
351 SqlValue::Text(self.namespace.clone()),
352 SqlValue::Text(self.subject_id.to_string()),
353 ];
354 writer
355 .execute(
356 SqlStatement::new(
357 format!(
358 "INSERT INTO ann_write_log (namespace,embedding_model,kind,field,subject_id,op) \
359 SELECT namespace,embedding_model,kind,field,subject_id,'delete' \
360 FROM main.{table} WHERE namespace=?1 AND subject_id=?2"),
361 scope.clone(),
362 )
363 .labelled("note-write-guard"),
364 )
365 .await?;
366 writer
367 .execute(
368 SqlStatement::new(
369 format!("DELETE FROM main.{table} WHERE namespace=?1 AND subject_id=?2"),
370 scope,
371 )
372 .labelled("note-write-guard"),
373 )
374 .await?;
375 let model_key = table.strip_prefix("vec_").ok_or_else(|| {
376 StorageError::Internal("invalid persisted vector table name".into())
377 })?;
378 writer
379 .execute(
380 SqlStatement::new(
381 "DELETE FROM vector_provenance \
382 WHERE model_key=?1 AND namespace=?2 AND subject_id=?3",
383 vec![
384 SqlValue::Text(model_key.to_string()),
385 SqlValue::Text(self.namespace.clone()),
386 SqlValue::Text(self.subject_id.to_string()),
387 ],
388 )
389 .labelled("note-write-guard"),
390 )
391 .await?;
392 }
393 Ok(())
394 }
395}
396
397#[derive(Clone, Debug)]
398pub(crate) struct NoteEmbeddingInheritance {
399 pub vectors: NoteVectors,
400 pub kind: String,
401}
402
403#[derive(Clone, Debug, PartialEq, Eq)]
404pub enum NoteWriteConflict {
405 Version {
406 expected: i64,
407 current: i64,
408 },
409 Fence {
410 key: String,
411 expected: Option<i64>,
412 current: Option<i64>,
413 index: Option<usize>,
414 },
415 Key {
416 key: String,
417 existing_id: String,
418 equal: Option<bool>,
429 },
430 FenceDeadline(Box<FenceDeadline>),
434 FenceIdentity(Box<FenceIdentity>),
438}
439
440#[derive(Clone, Debug, PartialEq, Eq)]
443pub struct FenceIdentity {
444 pub key: String,
445 pub kind: String,
446 pub version: i64,
447 pub index: Option<usize>,
448 pub evidence: Vec<(&'static str, String)>,
449}
450
451#[derive(Clone, Debug, PartialEq, Eq)]
454pub struct FenceDeadline {
455 pub key: String,
456 pub kind: String,
457 pub version: i64,
458 pub field: String,
459 pub index: Option<usize>,
460 pub reason: &'static str,
461 pub evidence: Vec<(&'static str, String)>,
462}
463
464impl NoteWriteConflict {
465 pub fn into_error(self) -> KhiveError {
466 self.into_error_at_member(None)
467 }
468
469 pub(crate) fn into_error_at_member(self, member: Option<usize>) -> KhiveError {
470 let (message, mut details) = match self {
471 Self::Version { expected, current } => (
472 "note version precondition failed",
473 vec![
474 ("reason", "version_conflict".into()),
475 ("expected_version", expected.to_string()),
476 ("current_version", current.to_string()),
477 ],
478 ),
479 Self::Fence {
480 key,
481 expected,
482 current,
483 index,
484 } => {
485 let mut fields = vec![
486 ("reason", "fence_conflict".into()),
487 ("key", key),
488 (
489 "expected_version",
490 expected.map_or_else(|| "absent".into(), |version| version.to_string()),
491 ),
492 ];
493 if let Some(current) = current {
494 fields.push(("current_version", current.to_string()));
495 }
496 if let Some(index) = index {
497 fields.push(("index", index.to_string()));
498 }
499 ("note fence precondition failed", fields)
500 }
501 Self::Key {
502 key,
503 existing_id,
504 equal,
505 } => {
506 let mut fields = vec![
507 ("reason", "key_conflict".into()),
508 ("key", key),
509 ("existing_id", existing_id),
510 ];
511 if let Some(equal) = equal {
512 fields.push(("equal", equal.to_string()));
513 }
514 ("a live note already holds this key", fields)
515 }
516 Self::FenceDeadline(deadline) => {
517 let FenceDeadline {
518 key,
519 kind,
520 version,
521 field,
522 index,
523 reason,
524 evidence,
525 } = *deadline;
526 let mut fields = vec![
527 ("reason", reason.into()),
528 ("key", key),
529 ("kind", kind),
530 ("version", version.to_string()),
531 ("field", field),
532 ];
533 if let Some(index) = index {
534 fields.push(("index", index.to_string()));
535 }
536 fields.extend(evidence);
537 ("note fence time precondition failed", fields)
538 }
539 Self::FenceIdentity(identity) => {
540 let FenceIdentity {
541 key,
542 kind,
543 version,
544 index,
545 evidence,
546 } = *identity;
547 let mut fields = vec![
548 ("reason", "identity_conflict".into()),
549 ("key", key),
550 ("kind", kind),
551 ("version", version.to_string()),
552 ];
553 fields.extend(evidence);
554 if let Some(index) = index {
555 fields.push(("index", index.to_string()));
556 }
557 ("note fence identity precondition failed", fields)
558 }
559 };
560 if let Some(member) = member {
561 details.push(("member", member.to_string()));
562 }
563 KhiveError::conflict(message).with_details(Details::new_owned(details))
564 }
565}
566
567impl NoteWriteGuard {
568 pub(crate) async fn check_fence(
569 &self,
570 writer: &mut dyn SqlWriter,
571 ) -> Result<Option<NoteWriteConflict>, StorageError> {
572 let Some(fences) = &self.fence else {
573 return Ok(None);
574 };
575 let mut now: Option<i64> = None;
579 for (index, fence) in fences.entries().iter().enumerate() {
580 let holder = crate::fence_identity::read_holder(
581 writer,
582 &self.namespace,
583 &fence.kind,
584 &fence.key,
585 "note-write-guard",
586 )
587 .await?;
588 if let (Some(asserted), Some(holder)) = (fence.id, holder.as_ref()) {
594 if asserted != holder.id {
595 return Ok(Some(NoteWriteConflict::FenceIdentity(Box::new(
596 FenceIdentity {
597 key: fence.key.clone(),
598 kind: fence.kind.clone(),
599 version: fence.expected_version.unwrap_or_default(),
602 index: matches!(fences, NoteFences::Many(_)).then_some(index),
603 evidence: crate::fence_identity::identity_evidence(asserted, holder.id),
604 },
605 ))));
606 }
607 }
608 let current = holder.map(|holder| holder.version);
609 if current != fence.expected_version {
610 return Ok(Some(NoteWriteConflict::Fence {
611 key: fence.key.clone(),
612 expected: fence.expected_version,
613 current,
614 index: matches!(fences, NoteFences::Many(_)).then_some(index),
615 }));
616 }
617 if let Some(field) = &fence.live_until {
618 let clock = match now {
619 Some(clock) => clock,
620 None => {
621 let clock =
622 crate::live_until::writer_clock(writer, "note-write-guard-clock")
623 .await?;
624 now = Some(clock);
625 clock
626 }
627 };
628 if let Some(refusal) = crate::live_until::evaluate(
629 writer,
630 &self.namespace,
631 &fence.kind,
632 &fence.key,
633 field,
634 clock,
635 "note-write-guard-live-until",
636 )
637 .await?
638 {
639 return Ok(Some(NoteWriteConflict::FenceDeadline(Box::new(
640 FenceDeadline {
641 key: fence.key.clone(),
642 kind: fence.kind.clone(),
643 version: fence.expected_version.unwrap_or_default(),
646 field: field.clone(),
647 index: matches!(fences, NoteFences::Many(_)).then_some(index),
648 reason: refusal.reason(),
649 evidence: refusal.details(),
650 },
651 ))));
652 }
653 }
654 }
655 Ok(None)
656 }
657
658 pub(crate) async fn classify_refusal(
659 &self,
660 writer: &mut dyn SqlWriter,
661 ) -> Result<Option<NoteWriteConflict>, StorageError> {
662 if let Some(claim) = &self.create_key {
663 let holder = writer
668 .query_row(
669 SqlStatement::new(
670 "SELECT id, content, properties FROM notes \
671 WHERE namespace=?1 AND kind=?2 AND key=?3 AND deleted_at IS NULL",
672 vec![
673 SqlValue::Text(self.namespace.clone()),
674 SqlValue::Text(claim.kind.clone()),
675 SqlValue::Text(claim.key.clone()),
676 ],
677 )
678 .labelled("note-write-guard"),
679 )
680 .await?;
681 if let Some(row) = holder {
682 let existing_id = match row.get("id") {
683 Some(SqlValue::Text(id)) => id.clone(),
684 _ => {
685 return Err(StorageError::Internal(
686 "invalid keyed note holder identity".into(),
687 ))
688 }
689 };
690 let existing_content = match row.get("content") {
691 Some(SqlValue::Text(content)) => content.clone(),
692 _ => {
693 return Err(StorageError::Internal(
694 "invalid keyed note holder content".into(),
695 ))
696 }
697 };
698 let existing_properties: Option<serde_json::Value> = match row.get("properties") {
699 Some(SqlValue::Text(text)) => {
700 Some(serde_json::from_str(text).map_err(|_| {
701 StorageError::Internal("invalid keyed note holder properties".into())
702 })?)
703 }
704 Some(SqlValue::Null) | None => None,
705 _ => {
706 return Err(StorageError::Internal(
707 "invalid keyed note holder properties".into(),
708 ))
709 }
710 };
711 let equal = claim.replay_signal.then(|| {
725 existing_content == claim.content && existing_properties == claim.properties
726 });
727 return Ok(Some(NoteWriteConflict::Key {
728 key: claim.key.clone(),
729 existing_id,
730 equal,
731 }));
732 }
733 }
734 if let Some(expected) = self.expected_version {
735 let current = writer
736 .query_scalar(
737 SqlStatement::new(
738 "SELECT version FROM notes WHERE id=?1 AND deleted_at IS NULL",
739 vec![SqlValue::Text(self.target_id.to_string())],
740 )
741 .labelled("note-write-guard"),
742 )
743 .await?;
744 if let Some(SqlValue::Integer(current)) = current {
745 if current != expected {
746 return Ok(Some(NoteWriteConflict::Version { expected, current }));
747 }
748 }
749 }
750 Ok(None)
751 }
752}
753
754async fn check_keyed_create_holder(
768 access: &dyn khive_storage::SqlAccess,
769 guard: NoteWriteGuard,
770) -> RuntimeResult<Option<NoteWriteConflict>> {
771 use crate::atomic_runner::{
772 run_prepared_atomic_unit, PreparedAtomicError, PreparedAtomicOp, PreparedAtomicOutcome,
773 };
774 let op: PreparedAtomicOp<(), NoteWriteConflict> = Box::new(move |writer| {
775 Box::pin(async move {
776 if let Some(conflict) = guard.check_fence(writer).await? {
777 return Err(PreparedAtomicError::Refused {
778 failure: conflict,
779 message: "keyed create: fence stale at holder check".into(),
780 });
781 }
782 if let Some(conflict) = guard.classify_refusal(writer).await? {
783 return Err(PreparedAtomicError::Refused {
784 failure: conflict,
785 message: "keyed create: live holder found at holder check".into(),
786 });
787 }
788 Ok(((), Vec::new()))
789 })
790 });
791 match run_prepared_atomic_unit(access, op).await? {
792 PreparedAtomicOutcome::Committed { .. } => Ok(None),
793 PreparedAtomicOutcome::RolledBack(conflict) => Ok(Some(conflict)),
794 }
795}
796
797pub(crate) fn validate_head(note: &khive_storage::note::Note) -> RuntimeResult<()> {
798 if note.kind != "head" {
799 return Ok(());
800 }
801 if note.name.is_some() {
802 return Err(RuntimeError::InvalidInput("head notes have no name".into()));
803 }
804 serde_json::from_str::<serde_json::Value>(¬e.content).map_err(|error| {
805 RuntimeError::InvalidInput(format!("head content must be JSON text: {error}"))
806 })?;
807 if let Some(tags) = note
808 .properties
809 .as_ref()
810 .and_then(|p| p.get("tags"))
811 .and_then(|t| t.as_array())
812 {
813 for tag in tags.iter().filter_map(|tag| tag.as_str()) {
814 if let Some(kind) = tag.strip_prefix("kind:") {
815 if kind.len() > 64 || kind.contains('\0') {
816 return Err(RuntimeError::InvalidInput(
817 "head document kind must be at most 64 bytes without U+0000".into(),
818 ));
819 }
820 }
821 }
822 }
823 Ok(())
824}
825
826impl KhiveRuntime {
827 pub async fn get_note_by_key(
828 &self,
829 token: &NamespaceToken,
830 key: &str,
831 kind: Option<&str>,
832 after_key: bool,
833 ) -> RuntimeResult<khive_storage::note::Note> {
834 self.get_note_by_key_in_scope(token, key, kind, after_key, None)
835 .await
836 }
837
838 pub async fn get_note_by_key_in_scope(
842 &self,
843 token: &NamespaceToken,
844 key: &str,
845 kind: Option<&str>,
846 after_key: bool,
847 mailbox: Option<&khive_storage::note::NoteMailboxScope>,
848 ) -> RuntimeResult<khive_storage::note::Note> {
849 crate::keyed_memory::validate_memory_key(key)?;
850 let mut matches = self
851 .notes(token)?
852 .get_live_notes_by_key(token.namespace().as_str(), key, kind)
853 .await?;
854 if let Some(scope) = mailbox {
855 matches.retain(|note| crate::MailboxView::scope_permits_message_note(scope, note));
856 }
857 match matches.len() {
858 0 => {
859 let error = KhiveError::not_found("note key", key);
860 Err(if after_key {
861 error.with_details(Details::new_owned([
862 ("reason", "after_key_missing".into()),
863 ("key", key.into()),
864 ]))
865 } else {
866 error
867 }
868 .into())
869 }
870 1 => Ok(matches.remove(0)),
871 _ => {
872 let kinds = matches
873 .iter()
874 .map(|note| note.kind.as_str())
875 .collect::<Vec<_>>()
876 .join(",");
877 Err(KhiveError::conflict("note key is ambiguous")
878 .with_details(Details::new_owned([
879 ("reason", "key_ambiguous".into()),
880 ("key", key.into()),
881 ("kinds", kinds),
882 ]))
883 .into())
884 }
885 }
886 }
887
888 pub(crate) async fn prepare_versioned_note_update(
889 &self,
890 token: &NamespaceToken,
891 snapshot: khive_storage::note::Note,
892 patch: crate::curation::NotePatch,
893 ) -> RuntimeResult<(khive_storage::note::Note, crate::atomic_plan::UpdatePlan)> {
894 let options = patch.write_options.clone();
895 options.validate()?;
896 if options.key.is_some() {
897 return Err(RuntimeError::InvalidInput("key is immutable".into()));
898 }
899 if let Some(fences) = &options.fence {
900 for fence in fences.entries() {
901 self.validate_note_kind(&fence.kind)?;
902 }
903 }
904 let (note, text_changed, changed) = self
905 .prepare_update_note_from_snapshot(token, snapshot.clone(), patch)
906 .await?;
907 let metadata_only = self.stream_member_error(¬e).await?.is_some();
908 Self::materialize_versioned_note_update(
909 token,
910 snapshot,
911 note,
912 text_changed,
913 changed,
914 options,
915 metadata_only,
916 chrono::Utc::now().timestamp_micros(),
917 )
918 }
919
920 #[allow(clippy::too_many_arguments)]
924 pub(crate) fn materialize_versioned_note_update(
925 token: &NamespaceToken,
926 snapshot: khive_storage::Note,
927 mut note: khive_storage::Note,
928 text_changed: bool,
929 changed: bool,
930 options: NoteWriteOptions,
931 metadata_only: bool,
932 now: i64,
933 ) -> RuntimeResult<(khive_storage::Note, crate::atomic_plan::UpdatePlan)> {
934 use crate::atomic_plan::{AffectedRowGuard, PlanStatement, PostCommitEffect, UpdatePlan};
935 let expected_updated_at = snapshot.updated_at;
936 let expected_deleted_at = snapshot.deleted_at;
937 let expected_snapshot_version = snapshot.version;
938 validate_head(¬e)?;
939 crate::secret_gate::check_reserved_carry_forward(
940 snapshot.properties.as_ref(),
941 note.properties.as_ref(),
942 note.name == snapshot.name
943 && note.content == snapshot.content
944 && crate::secret_gate::without_reserved_properties(note.properties.as_ref())
945 == crate::secret_gate::without_reserved_properties(
946 snapshot.properties.as_ref(),
947 ),
948 )?;
949 if !changed && options.embed.is_none() && options.expected_version.is_none() {
956 let assertion = SqlStatement {
957 sql: "SELECT 1 FROM notes WHERE id=?1 AND updated_at=?2 AND deleted_at IS ?3 AND version=?4"
958 .into(),
959 params: vec![
960 SqlValue::Text(note.id.to_string()),
961 SqlValue::Integer(expected_updated_at),
962 expected_deleted_at
963 .map(SqlValue::Integer)
964 .unwrap_or(SqlValue::Null),
965 SqlValue::Integer(expected_snapshot_version),
966 ],
967 label: Some("note-noop-assertion".into()),
968 };
969 let plan = UpdatePlan {
970 graph_effects: Vec::new(),
971 target_id: note.id,
972 statements: vec![PlanStatement {
973 statement: assertion,
974 guard: Some(AffectedRowGuard::exactly(1)),
975 }],
976 post_commit: PostCommitEffect::None,
977 edge_natural_key: None,
978 idempotent_noop: true,
979 entity_guard: None,
980 note_guard: Some(NoteWriteGuard {
981 namespace: token.namespace().as_str().into(),
982 target_id: note.id,
983 expected_version: options.expected_version,
984 fence: options.fence,
985 create_key: None,
986 }),
987 note_vector_purge: None,
988 note_embedding_inheritance: None,
989 };
990 return Ok((note, plan));
991 }
992 if !changed {
993 let minimum_updated_at = note.updated_at.checked_add(1).ok_or_else(|| {
998 RuntimeError::Internal(format!(
999 "note {} updated_at is already at i64::MAX and cannot advance",
1000 note.id
1001 ))
1002 })?;
1003 note.updated_at = now.max(minimum_updated_at);
1004 }
1005 let next_version = note
1006 .version
1007 .checked_add(1)
1008 .ok_or_else(|| RuntimeError::InvalidInput("note version exhausted".into()))?;
1009 note.version = next_version;
1010 let mut update = if metadata_only {
1011 khive_db::stores::note::note_metadata_replace_if_unchanged_statement(
1012 ¬e,
1013 expected_updated_at,
1014 expected_deleted_at,
1015 )
1016 } else {
1017 khive_db::stores::note::note_replace_if_unchanged_statement(
1018 ¬e,
1019 expected_updated_at,
1020 expected_deleted_at,
1021 )
1022 };
1023 update
1027 .params
1028 .push(SqlValue::Integer(expected_snapshot_version));
1029 update
1030 .sql
1031 .push_str(&format!(" AND version = ?{}", update.params.len()));
1032 if let Some(version) = options.expected_version {
1033 update.params.push(SqlValue::Integer(version));
1034 update
1035 .sql
1036 .push_str(&format!(" AND version = ?{}", update.params.len()));
1037 }
1038 let mut statements = vec![PlanStatement {
1039 statement: update,
1040 guard: Some(AffectedRowGuard::exactly(1)),
1041 }];
1042 if text_changed {
1043 for sql in khive_db::stores::text::delete_document_statements(
1044 "fts_notes",
1045 ¬e.namespace,
1046 note.id,
1047 )
1048 .into_iter()
1049 .chain(khive_db::stores::text::insert_document_statements(
1050 "fts_notes",
1051 &crate::curation::note_fts_document(¬e),
1052 )) {
1053 statements.push(PlanStatement {
1054 statement: sql,
1055 guard: None,
1056 });
1057 }
1058 }
1059 statements.extend(crate::atomic_prepare::event_append_statements(
1063 token,
1064 ¬e.namespace,
1065 "update",
1066 khive_types::EventKind::NoteUpdated,
1067 khive_types::SubstrateKind::Note,
1068 note.id,
1069 serde_json::json!({
1070 "id": note.id,
1071 "namespace": note.namespace,
1072 "version": note.version,
1073 "text_changed": text_changed,
1074 }),
1075 )?);
1076 let post_commit =
1079 if options.embed != Some(false) && (text_changed || options.embed == Some(true)) {
1080 PostCommitEffect::ReindexNote {
1081 note_id: note.id,
1082 version: note.version,
1083 }
1084 } else if text_changed || options.embed == Some(false) {
1085 PostCommitEffect::NoteChanged {
1086 note_id: note.id,
1087 kind: note.kind.clone(),
1088 }
1089 } else {
1090 PostCommitEffect::None
1091 };
1092 let plan = UpdatePlan {
1093 graph_effects: Vec::new(),
1094 target_id: note.id,
1095 statements,
1096 post_commit,
1097 edge_natural_key: None,
1098 idempotent_noop: false,
1099 entity_guard: None,
1100 note_guard: Some(NoteWriteGuard {
1101 namespace: token.namespace().as_str().into(),
1102 target_id: note.id,
1103 expected_version: options.expected_version,
1104 fence: options.fence,
1105 create_key: None,
1106 }),
1107 note_vector_purge: (options.embed == Some(false))
1108 .then(|| NoteVectors::new(note.namespace.clone(), note.id)),
1109 note_embedding_inheritance: (text_changed && options.embed.is_none()).then(|| {
1110 NoteEmbeddingInheritance {
1111 vectors: NoteVectors::new(note.namespace.clone(), note.id),
1112 kind: note.kind.clone(),
1113 }
1114 }),
1115 };
1116 Ok((note, plan))
1117 }
1118
1119 #[allow(clippy::too_many_arguments)]
1120 pub async fn create_note_with_options(
1121 &self,
1122 token: &NamespaceToken,
1123 kind: &str,
1124 name: Option<&str>,
1125 content: &str,
1126 embedding_content: Option<&str>,
1127 salience: Option<f64>,
1128 decay_factor: Option<f64>,
1129 properties: Option<serde_json::Value>,
1130 annotates: Vec<Uuid>,
1131 embedding_model: Option<&str>,
1132 options: NoteWriteOptions,
1133 ) -> RuntimeResult<(
1134 khive_storage::note::Note,
1135 crate::retrieval::EmbeddingTruncationReport,
1136 )> {
1137 self.create_note_with_options_resolving_annotations(
1138 token,
1139 kind,
1140 name,
1141 content,
1142 embedding_content,
1143 salience,
1144 decay_factor,
1145 properties,
1146 std::future::ready(Ok(annotates)),
1147 embedding_model,
1148 options,
1149 )
1150 .await
1151 }
1152
1153 #[allow(clippy::too_many_arguments)]
1157 pub async fn create_note_with_options_resolving_annotations<F>(
1158 &self,
1159 token: &NamespaceToken,
1160 kind: &str,
1161 name: Option<&str>,
1162 content: &str,
1163 embedding_content: Option<&str>,
1164 salience: Option<f64>,
1165 decay_factor: Option<f64>,
1166 properties: Option<serde_json::Value>,
1167 annotations: F,
1168 embedding_model: Option<&str>,
1169 options: NoteWriteOptions,
1170 ) -> RuntimeResult<(
1171 khive_storage::note::Note,
1172 crate::retrieval::EmbeddingTruncationReport,
1173 )>
1174 where
1175 F: std::future::Future<Output = RuntimeResult<Vec<Uuid>>> + Send,
1176 {
1177 use crate::atomic_message::{AtomicNoteOptions, AtomicNoteSpec};
1178 use crate::atomic_runner::{run_atomic_unit, AtomicOpFailure, AtomicRunOutcome};
1179 use crate::note_create::{prepare_note_create, KeyPublication};
1180 options.validate()?;
1181 if options.expected_version.is_some() {
1182 return Err(RuntimeError::InvalidInput(
1183 "expected_version applies only to update".into(),
1184 ));
1185 }
1186 if let Some(fences) = &options.fence {
1187 for fence in fences.entries() {
1188 self.validate_note_kind(&fence.kind)?;
1189 }
1190 }
1191 if let Some(prefix) = embedding_content {
1192 if prefix.is_empty() || prefix.len() >= content.len() || !content.starts_with(prefix) {
1193 return Err(RuntimeError::InvalidInput(
1194 "embedding_content must be a non-empty proper prefix of content".into(),
1195 ));
1196 }
1197 crate::secret_gate::check_at(prefix, "note", "embedding_content")?;
1198 }
1199 let mut candidate =
1200 khive_storage::note::Note::new(token.namespace().as_str(), kind, content);
1201 candidate.name = name.map(str::to_owned);
1202 candidate.properties = properties.clone();
1203 validate_head(&candidate)?;
1204
1205 let Some(key) = options.key.as_deref() else {
1210 let annotates = annotations.await?;
1211 let (mut prepared, _) = prepare_note_create(
1212 self,
1213 AtomicNoteSpec {
1214 token,
1215 id: None,
1216 kind,
1217 name,
1218 content,
1219 properties,
1220 },
1221 AtomicNoteOptions {
1222 salience,
1223 decay_factor,
1224 embedding_model,
1225 embedding_content,
1226 embed: Some(options.embed.unwrap_or(kind != "head")),
1227 key: None,
1228 memory_visibility_receipt: false,
1229 replay_receipt: true,
1230 fence: options.fence.as_ref(),
1231 properties_already_derived: false,
1232 },
1233 &annotates,
1234 KeyPublication::AtInsert,
1235 )
1236 .await?;
1237 let note = prepared.notes.remove(0);
1238 return match run_atomic_unit(self.sql().as_ref(), prepared.plans).await {
1239 Ok(AtomicRunOutcome::Committed { .. }) => {
1240 self.fire_note_mutation_hook(¬e.kind, note.id).await;
1241 Ok((note, prepared.embedding_truncation))
1242 }
1243 Ok(AtomicRunOutcome::RolledBack {
1244 failure: AtomicOpFailure::NoteConflict(conflict),
1245 ..
1246 }) => Err(conflict.into_error().into()),
1247 Ok(AtomicRunOutcome::RolledBack { failure, .. }) => Err(RuntimeError::Internal(
1248 format!("note creation rolled back: {failure:?}"),
1249 )),
1250 Err(error) => Err(RuntimeError::Storage(error.0)),
1251 };
1252 };
1253
1254 let derived_properties =
1264 self.derive_note_write_properties(kind, token, properties.clone())?;
1265 let namespace: String = token.namespace().as_str().into();
1266
1267 if let Some(conflict) = check_keyed_create_holder(
1273 self.sql().as_ref(),
1274 NoteWriteGuard {
1275 namespace: namespace.clone(),
1276 target_id: candidate.id,
1277 expected_version: None,
1278 fence: options.fence.clone(),
1279 create_key: Some(CreateKeyClaim {
1280 kind: kind.to_owned(),
1281 key: key.to_owned(),
1282 content: content.to_owned(),
1283 properties: derived_properties.clone(),
1284 replay_signal: true,
1285 }),
1286 },
1287 )
1288 .await?
1289 {
1290 return Err(conflict.into_error().into());
1291 }
1292
1293 let prep_result = async {
1300 let annotates = annotations.await?;
1301 prepare_note_create(
1302 self,
1303 AtomicNoteSpec {
1304 token,
1305 id: None,
1306 kind,
1307 name,
1308 content,
1309 properties: derived_properties.clone(),
1310 },
1311 AtomicNoteOptions {
1312 salience,
1313 decay_factor,
1314 embedding_model,
1315 embedding_content,
1316 embed: Some(options.embed.unwrap_or(kind != "head")),
1317 key: Some(key),
1318 memory_visibility_receipt: false,
1319 replay_receipt: true,
1320 fence: options.fence.as_ref(),
1321 properties_already_derived: true,
1322 },
1323 &annotates,
1324 KeyPublication::AtInsert,
1325 )
1326 .await
1327 }
1328 .await;
1329
1330 #[cfg(test)]
1336 race_seam::pause_after_prepare().await;
1337
1338 match prep_result {
1339 Ok((mut prepared, _)) => {
1340 let note = prepared.notes.remove(0);
1341 match run_atomic_unit(self.sql().as_ref(), prepared.plans).await {
1342 Ok(AtomicRunOutcome::Committed { .. }) => {
1343 self.fire_note_mutation_hook(¬e.kind, note.id).await;
1344 Ok((note, prepared.embedding_truncation))
1345 }
1346 Ok(AtomicRunOutcome::RolledBack {
1347 failure: AtomicOpFailure::NoteConflict(conflict),
1348 ..
1349 }) => Err(conflict.into_error().into()),
1350 Ok(AtomicRunOutcome::RolledBack { failure, .. }) => Err(
1351 RuntimeError::Internal(format!("note creation rolled back: {failure:?}")),
1352 ),
1353 Err(error) => Err(RuntimeError::Storage(error.0)),
1354 }
1355 }
1356 Err(prep_error) => {
1357 match check_keyed_create_holder(
1363 self.sql().as_ref(),
1364 NoteWriteGuard {
1365 namespace,
1366 target_id: candidate.id,
1367 expected_version: None,
1368 fence: options.fence.clone(),
1369 create_key: Some(CreateKeyClaim {
1370 kind: kind.to_owned(),
1371 key: key.to_owned(),
1372 content: content.to_owned(),
1373 properties: derived_properties,
1374 replay_signal: true,
1375 }),
1376 },
1377 )
1378 .await?
1379 {
1380 Some(conflict) => Err(conflict.into_error().into()),
1381 None => Err(prep_error),
1382 }
1383 }
1384 }
1385 }
1386}