Skip to main content

khive_runtime/
note_write.rs

1//! Key and revision guards evaluated by the transaction owner.
2
3use 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/// Test-only pause point between a keyed singleton create's preparation
11/// (annotation-target validation and embedding-model resolution, both
12/// outside the writer) and its writer-transaction admission, so a race
13/// between two independent callers of `create_note_with_options`, or an
14/// independent writer proceeding while one caller's preparation is parked,
15/// can be reproduced deterministically (ADR-172 Amendment 6, acceptance
16/// items 3 and 5) instead of relying on scheduler luck or sleeps. A no-op
17/// unless the calling task runs inside `AFTER_PREPARE_BARRIER.scope(...)`;
18/// production code never establishes that scope, so `pause_after_prepare`
19/// costs nothing outside these regression tests, and it does not exist at
20/// all in non-test builds. Deliberately a separate barrier from
21/// `crate::curation::race_seam`: that module's pause point is a different
22/// production entry point's read/write boundary, and sharing one
23/// `Barrier` between two unrelated pause points would make a test of one
24/// silently depend on the party count of the other.
25#[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    /// Meets the other party twice: the first meeting tells it this caller
35    /// has finished its initial holder check and its preparation, the
36    /// second is the release. Two callers sharing one barrier simply meet
37    /// twice.
38    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
46/// Each fence adds a keyed read while holding the writer, like a batch observation.
47pub const MAX_NOTE_FENCES: usize = 100;
48
49#[derive(Clone, Debug, Serialize)]
50pub struct NoteFence {
51    pub key: String,
52    pub kind: String,
53    /// None asserts absence; the JSON field is still required.
54    pub expected_version: Option<i64>,
55    /// Dotted document path whose RFC 3339 value must exceed the writer clock,
56    /// on the same semantics a batch observation's `live_until` carries. A
57    /// version comparison alone cannot express live ownership: a lease that
58    /// merely ran out changes no document and moves no version, so the version
59    /// still matches and the write commits.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub live_until: Option<String>,
62    /// The note that must hold this key, on the same semantics a batch
63    /// observation's `id` carries. A recreated note starts at version 1, so a
64    /// version comparison alone cannot tell the note the caller read from a
65    /// different note sitting at the same number.
66    #[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        // Option alone treats omission as null. Absence must be asserted explicitly.
85        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            // An absence assertion has no document, so there is nothing to read
120            // a deadline out of. The batch route refuses the same pairing.
121            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            // An absence assertion has no note, so there is nothing for an
132            // identity to name. The batch route refuses the same pairing.
133            return Err(RuntimeError::InvalidInput(
134                "fence id requires a positive expected_version".into(),
135            ));
136        }
137        Ok(())
138    }
139}
140
141/// An object retains the original refusal shape; a list identifies its failing entry.
142#[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
219/// Missing optional fences default to None; explicitly supplied null is invalid.
220pub 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/// The candidate identity and fully-validated payload a keyed singleton
252/// `create` is claiming: kind and key resolve the live holder, and content
253/// plus properties let [`NoteWriteGuard::classify_refusal`] decide, inside
254/// the writer transaction, whether a live holder is this same create
255/// replayed (ADR-172 Amendment 6) or a genuine conflict.
256#[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    /// Set only by the singleton `create` route (`AtomicNoteOptions::replay_receipt`).
263    /// Every other keyed-write caller leaves this `false`, so
264    /// [`NoteWriteGuard::classify_refusal`] never reports a comparison
265    /// outcome for their conflicts.
266    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        // vec_* is the backend's reserved vector-table namespace. Catalog
294        // types exclude sqlite-vec shadow tables without guessing suffixes.
295        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        /// Whether the live holder's validated content and properties equal
419        /// the candidate's (ADR-172 Amendment 6). `None` unless the write
420        /// that produced this conflict asked for the comparison
421        /// (`CreateKeyClaim::replay_signal`, set only by the singleton
422        /// `create` route): every other keyed route never carries this
423        /// signal, so it can never reach their callers. Internal signal
424        /// only even when present: it never reaches a client verbatim. The
425        /// create handler turns an equal, disclosed conflict into a
426        /// successful replay result, and rebuilds `Details` without this
427        /// field on every other outcome.
428        equal: Option<bool>,
429    },
430    /// A fence whose `live_until` path did not admit the write. Separate from
431    /// `Fence` because the version matched: what failed is the deadline. Boxed
432    /// so this variant does not set the size of every conflict result.
433    FenceDeadline(Box<FenceDeadline>),
434    /// A fence whose `id` did not hold the key. Separate from `Fence` because
435    /// the version is not what disagreed, and it may well agree: a recreated
436    /// note starts at version 1. Boxed for the same reason as `FenceDeadline`.
437    FenceIdentity(Box<FenceIdentity>),
438}
439
440/// The fields an identity refusal reports: what was fenced, at which version,
441/// and both identities.
442#[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/// The fields a deadline refusal reports: what was fenced, at which version and
452/// path, and the predicate's own reason and evidence.
453#[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        // One clock reading for this transaction, taken only when some entry
576        // asks for one, and shared by all of them: two entries in one write
577        // must not be judged against two instants.
578        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            // Identity first, then version, then the deadline: the batch route
589            // orders them this way, and a caller must not learn a different
590            // failure for the same state depending on which route it used. An
591            // absent holder has no identity to name, so it falls through to the
592            // version comparison that already reports it.
593            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                            // validate() refuses id without a positive
600                            // expected_version, so the version is present here.
601                            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                            // validate() refuses live_until without a positive
644                            // expected_version, so the version is present here.
645                            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            // ADR-172 Amendment 6: the final holder comparison happens here,
664            // inside the writer transaction, reading the same row the failed
665            // guarded INSERT just contended on. Never a later, separate
666            // lookup after the transaction has settled.
667            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                // Decoded content is byte-exact text comparison; properties
712                // use typed JSON equality (object member order irrelevant,
713                // member presence/array order/value types significant,
714                // `None` distinct from `Some(Value::Object(empty))`).
715                // `serde_json::Value`'s own `PartialEq` already has exactly
716                // this shape (an `IndexMap`/`Map` compares order-insensitively,
717                // a `Vec` compares order-sensitively, `Number` compares its
718                // typed representation without coercion), so no extra
719                // normalization runs here. Only reported back when the
720                // caller asked for it: every route besides singleton
721                // `create` leaves `replay_signal` false and gets `None`,
722                // which `into_error_at_member` renders as no `equal` key at
723                // all in the returned Details.
724                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
754/// The ADR-172 Amendment 6 authoritative holder check, run with no insert
755/// statement of its own: [`NoteWriteGuard::check_fence`] then
756/// [`NoteWriteGuard::classify_refusal`], inside their own single writer
757/// checkout ([`crate::atomic_runner::run_prepared_atomic_unit`], the same
758/// "one `atomic_unit`" seam [`crate::atomic_runner::apply_plan`] uses for the
759/// real guarded insert). `create_note_with_options` calls this twice for a
760/// keyed create: once before preparation (creation-only annotation
761/// resolution and embedding work must not run ahead of this decision), and
762/// once more, only when preparation itself failed, to revalidate before
763/// falling back to that failure. `Ok(None)` means fences passed and no live
764/// holder exists yet; `Ok(Some(conflict))` is the caller's answer (already
765/// in the same shape a failed guarded insert reports): replay, key
766/// conflict, or a stale fence.
767async 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>(&note.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    /// [`Self::get_note_by_key`] counting only the rows `mailbox` admits, so
839    /// a message the caller's mailbox hides answers as a missing key and
840    /// never enters the ambiguity count.
841    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        use crate::atomic_plan::{AffectedRowGuard, PlanStatement, PostCommitEffect, UpdatePlan};
895        let options = patch.write_options.clone();
896        options.validate()?;
897        if options.key.is_some() {
898            return Err(RuntimeError::InvalidInput("key is immutable".into()));
899        }
900        if let Some(fences) = &options.fence {
901            for fence in fences.entries() {
902                self.validate_note_kind(&fence.kind)?;
903            }
904        }
905        let expected_updated_at = snapshot.updated_at;
906        let expected_deleted_at = snapshot.deleted_at;
907        let expected_snapshot_version = snapshot.version;
908        let (mut note, text_changed, changed) = self
909            .prepare_update_note_from_snapshot(token, snapshot, patch)
910            .await?;
911        validate_head(&note)?;
912        // ADR-172 Amendment 5: the no-op answer is for unfenced updates only.
913        // A write that names `expected_version` is asking for exactly one
914        // accepted write at that version, and the version it mints is the only
915        // thing a rival can fail against, so an identical fenced patch is still
916        // a write. This branch serves `update` and the `stream.batch` write
917        // member alike, and a batch write member is always fenced.
918        if !changed && options.embed.is_none() && options.expected_version.is_none() {
919            let assertion = SqlStatement {
920                sql: "SELECT 1 FROM notes WHERE id=?1 AND updated_at=?2 AND deleted_at IS ?3 AND version=?4"
921                    .into(),
922                params: vec![
923                    SqlValue::Text(note.id.to_string()),
924                    SqlValue::Integer(expected_updated_at),
925                    expected_deleted_at
926                        .map(SqlValue::Integer)
927                        .unwrap_or(SqlValue::Null),
928                    SqlValue::Integer(expected_snapshot_version),
929                ],
930                label: Some("note-noop-assertion".into()),
931            };
932            let plan = UpdatePlan {
933                graph_effects: Vec::new(),
934                target_id: note.id,
935                statements: vec![PlanStatement {
936                    statement: assertion,
937                    guard: Some(AffectedRowGuard::exactly(1)),
938                }],
939                post_commit: PostCommitEffect::None,
940                edge_natural_key: None,
941                idempotent_noop: true,
942                entity_guard: None,
943                note_guard: Some(NoteWriteGuard {
944                    namespace: token.namespace().as_str().into(),
945                    target_id: note.id,
946                    expected_version: options.expected_version,
947                    fence: options.fence,
948                    create_key: None,
949                }),
950                note_vector_purge: None,
951                note_embedding_inheritance: None,
952            };
953            return Ok((note, plan));
954        }
955        if !changed {
956            // A fenced write of an identical patch rewrites the row as it is,
957            // and the replace statement admits only a strictly newer
958            // `updated_at`, so advance the revision here the way a changed
959            // patch does (see `prepare_update_note_from_snapshot`).
960            let minimum_updated_at = note.updated_at.checked_add(1).ok_or_else(|| {
961                RuntimeError::Internal(format!(
962                    "note {} updated_at is already at i64::MAX and cannot advance",
963                    note.id
964                ))
965            })?;
966            note.updated_at = chrono::Utc::now()
967                .timestamp_micros()
968                .max(minimum_updated_at);
969        }
970        let next_version = note
971            .version
972            .checked_add(1)
973            .ok_or_else(|| RuntimeError::InvalidInput("note version exhausted".into()))?;
974        note.version = next_version;
975        let mut update = if self.stream_member_error(&note).await?.is_some() {
976            khive_db::stores::note::note_metadata_replace_if_unchanged_statement(
977                &note,
978                expected_updated_at,
979                expected_deleted_at,
980            )
981        } else {
982            khive_db::stores::note::note_replace_if_unchanged_statement(
983                &note,
984                expected_updated_at,
985                expected_deleted_at,
986            )
987        };
988        // A writer such as gtd.repair may change the row while retaining its
989        // updated_at. The snapshot version is therefore part of every CAS,
990        // including an unfenced update.
991        update
992            .params
993            .push(SqlValue::Integer(expected_snapshot_version));
994        update
995            .sql
996            .push_str(&format!(" AND version = ?{}", update.params.len()));
997        if let Some(version) = options.expected_version {
998            update.params.push(SqlValue::Integer(version));
999            update
1000                .sql
1001                .push_str(&format!(" AND version = ?{}", update.params.len()));
1002        }
1003        let mut statements = vec![PlanStatement {
1004            statement: update,
1005            guard: Some(AffectedRowGuard::exactly(1)),
1006        }];
1007        if text_changed {
1008            for sql in khive_db::stores::text::delete_document_statements(
1009                "fts_notes",
1010                &note.namespace,
1011                note.id,
1012            )
1013            .into_iter()
1014            .chain(khive_db::stores::text::insert_document_statements(
1015                "fts_notes",
1016                &crate::curation::note_fts_document(&note),
1017            )) {
1018                statements.push(PlanStatement {
1019                    statement: sql,
1020                    guard: None,
1021                });
1022            }
1023        }
1024        // The note update event, in the same atomic unit as the row it
1025        // describes, exactly as the entity update plan does. This is the third
1026        // of the three domain kinds that had no emitter anywhere in the tree.
1027        statements.extend(crate::atomic_prepare::event_append_statements(
1028            token,
1029            &note.namespace,
1030            "update",
1031            khive_types::EventKind::NoteUpdated,
1032            khive_types::SubstrateKind::Note,
1033            note.id,
1034            serde_json::json!({
1035                "id": note.id,
1036                "namespace": note.namespace,
1037                "version": note.version,
1038                "text_changed": text_changed,
1039            }),
1040        )?);
1041        // This is a potential reindex. Inherited membership is resolved by the
1042        // writer because vector publication can occur without a note revision.
1043        let post_commit =
1044            if options.embed != Some(false) && (text_changed || options.embed == Some(true)) {
1045                PostCommitEffect::ReindexNote {
1046                    note_id: note.id,
1047                    version: note.version,
1048                }
1049            } else if text_changed || options.embed == Some(false) {
1050                PostCommitEffect::NoteChanged {
1051                    note_id: note.id,
1052                    kind: note.kind.clone(),
1053                }
1054            } else {
1055                PostCommitEffect::None
1056            };
1057        let plan = UpdatePlan {
1058            graph_effects: Vec::new(),
1059            target_id: note.id,
1060            statements,
1061            post_commit,
1062            edge_natural_key: None,
1063            idempotent_noop: false,
1064            entity_guard: None,
1065            note_guard: Some(NoteWriteGuard {
1066                namespace: token.namespace().as_str().into(),
1067                target_id: note.id,
1068                expected_version: options.expected_version,
1069                fence: options.fence,
1070                create_key: None,
1071            }),
1072            note_vector_purge: (options.embed == Some(false))
1073                .then(|| NoteVectors::new(note.namespace.clone(), note.id)),
1074            note_embedding_inheritance: (text_changed && options.embed.is_none()).then(|| {
1075                NoteEmbeddingInheritance {
1076                    vectors: NoteVectors::new(note.namespace.clone(), note.id),
1077                    kind: note.kind.clone(),
1078                }
1079            }),
1080        };
1081        Ok((note, plan))
1082    }
1083
1084    #[allow(clippy::too_many_arguments)]
1085    pub async fn create_note_with_options(
1086        &self,
1087        token: &NamespaceToken,
1088        kind: &str,
1089        name: Option<&str>,
1090        content: &str,
1091        embedding_content: Option<&str>,
1092        salience: Option<f64>,
1093        decay_factor: Option<f64>,
1094        properties: Option<serde_json::Value>,
1095        annotates: Vec<Uuid>,
1096        embedding_model: Option<&str>,
1097        options: NoteWriteOptions,
1098    ) -> RuntimeResult<(
1099        khive_storage::note::Note,
1100        crate::retrieval::EmbeddingTruncationReport,
1101    )> {
1102        self.create_note_with_options_resolving_annotations(
1103            token,
1104            kind,
1105            name,
1106            content,
1107            embedding_content,
1108            salience,
1109            decay_factor,
1110            properties,
1111            std::future::ready(Ok(annotates)),
1112            embedding_model,
1113            options,
1114        )
1115        .await
1116    }
1117
1118    /// Defer annotation target resolution until a keyed-create holder check
1119    /// has decided whether this request is an exact replay. Resolution errors
1120    /// still take part in the final holder recheck before they are returned.
1121    #[allow(clippy::too_many_arguments)]
1122    pub async fn create_note_with_options_resolving_annotations<F>(
1123        &self,
1124        token: &NamespaceToken,
1125        kind: &str,
1126        name: Option<&str>,
1127        content: &str,
1128        embedding_content: Option<&str>,
1129        salience: Option<f64>,
1130        decay_factor: Option<f64>,
1131        properties: Option<serde_json::Value>,
1132        annotations: F,
1133        embedding_model: Option<&str>,
1134        options: NoteWriteOptions,
1135    ) -> RuntimeResult<(
1136        khive_storage::note::Note,
1137        crate::retrieval::EmbeddingTruncationReport,
1138    )>
1139    where
1140        F: std::future::Future<Output = RuntimeResult<Vec<Uuid>>> + Send,
1141    {
1142        use crate::atomic_message::{AtomicNoteOptions, AtomicNoteSpec};
1143        use crate::atomic_runner::{run_atomic_unit, AtomicOpFailure, AtomicRunOutcome};
1144        use crate::note_create::{prepare_note_create, KeyPublication};
1145        options.validate()?;
1146        if options.expected_version.is_some() {
1147            return Err(RuntimeError::InvalidInput(
1148                "expected_version applies only to update".into(),
1149            ));
1150        }
1151        if let Some(fences) = &options.fence {
1152            for fence in fences.entries() {
1153                self.validate_note_kind(&fence.kind)?;
1154            }
1155        }
1156        if let Some(prefix) = embedding_content {
1157            if prefix.is_empty() || prefix.len() >= content.len() || !content.starts_with(prefix) {
1158                return Err(RuntimeError::InvalidInput(
1159                    "embedding_content must be a non-empty proper prefix of content".into(),
1160                ));
1161            }
1162            crate::secret_gate::check_at(prefix, "note", "embedding_content")?;
1163        }
1164        let mut candidate =
1165            khive_storage::note::Note::new(token.namespace().as_str(), kind, content);
1166        candidate.name = name.map(str::to_owned);
1167        candidate.properties = properties.clone();
1168        validate_head(&candidate)?;
1169
1170        // Unkeyed create: unchanged. There is no key to hold, so there is
1171        // nothing for an initial holder check to decide, so preparation runs
1172        // the same way it always has, and the single writer transaction at
1173        // the end is the only admission decision.
1174        let Some(key) = options.key.as_deref() else {
1175            let annotates = annotations.await?;
1176            let (mut prepared, _) = prepare_note_create(
1177                self,
1178                AtomicNoteSpec {
1179                    token,
1180                    id: None,
1181                    kind,
1182                    name,
1183                    content,
1184                    properties,
1185                },
1186                AtomicNoteOptions {
1187                    salience,
1188                    decay_factor,
1189                    embedding_model,
1190                    embedding_content,
1191                    embed: Some(options.embed.unwrap_or(kind != "head")),
1192                    key: None,
1193                    memory_visibility_receipt: false,
1194                    replay_receipt: true,
1195                    fence: options.fence.as_ref(),
1196                    properties_already_derived: false,
1197                },
1198                &annotates,
1199                KeyPublication::AtInsert,
1200            )
1201            .await?;
1202            let note = prepared.notes.remove(0);
1203            return match run_atomic_unit(self.sql().as_ref(), prepared.plans).await {
1204                Ok(AtomicRunOutcome::Committed { .. }) => {
1205                    self.fire_note_mutation_hook(&note.kind, note.id).await;
1206                    Ok((note, prepared.embedding_truncation))
1207                }
1208                Ok(AtomicRunOutcome::RolledBack {
1209                    failure: AtomicOpFailure::NoteConflict(conflict),
1210                    ..
1211                }) => Err(conflict.into_error().into()),
1212                Ok(AtomicRunOutcome::RolledBack { failure, .. }) => Err(RuntimeError::Internal(
1213                    format!("note creation rolled back: {failure:?}"),
1214                )),
1215                Err(error) => Err(RuntimeError::Storage(error.0)),
1216            };
1217        };
1218
1219        // Keyed singleton create (ADR-172 Amendment 6): staged.
1220        //
1221        // Properties are derived exactly once, here, before either holder
1222        // check, because the installed note-write validator is not guaranteed
1223        // idempotent (a kg regression test counts its calls), and equality
1224        // compares the DERIVED value, so the initial check, the note this
1225        // preparation would insert, and any final-check fallback must all
1226        // compare/store the SAME derived value rather than three
1227        // independently hook-derived ones.
1228        let derived_properties =
1229            self.derive_note_write_properties(kind, token, properties.clone())?;
1230        let namespace: String = token.namespace().as_str().into();
1231
1232        // Initial writer transaction: supplied fences and the live holder
1233        // for (namespace, kind, key), before any creation-only work runs.
1234        // Equal + disclosable => replay, no preparation. Different => the
1235        // existing key_conflict. A stale fence => fence_conflict, also
1236        // before preparation. Absent (and fences pass) => continue.
1237        if let Some(conflict) = check_keyed_create_holder(
1238            self.sql().as_ref(),
1239            NoteWriteGuard {
1240                namespace: namespace.clone(),
1241                target_id: candidate.id,
1242                expected_version: None,
1243                fence: options.fence.clone(),
1244                create_key: Some(CreateKeyClaim {
1245                    kind: kind.to_owned(),
1246                    key: key.to_owned(),
1247                    content: content.to_owned(),
1248                    properties: derived_properties.clone(),
1249                    replay_signal: true,
1250                }),
1251            },
1252        )
1253        .await?
1254        {
1255            return Err(conflict.into_error().into());
1256        }
1257
1258        // Creation-only preparation (annotation-target resolution,
1259        // embedding-model resolution, embedding computation), outside the
1260        // writer, now that the key was observed absent. A failure here is
1261        // captured rather than returned immediately: the final writer
1262        // transaction below still gets to revalidate the holder before
1263        // either this plan or this failure is consumed.
1264        let prep_result = async {
1265            let annotates = annotations.await?;
1266            prepare_note_create(
1267                self,
1268                AtomicNoteSpec {
1269                    token,
1270                    id: None,
1271                    kind,
1272                    name,
1273                    content,
1274                    properties: derived_properties.clone(),
1275                },
1276                AtomicNoteOptions {
1277                    salience,
1278                    decay_factor,
1279                    embedding_model,
1280                    embedding_content,
1281                    embed: Some(options.embed.unwrap_or(kind != "head")),
1282                    key: Some(key),
1283                    memory_visibility_receipt: false,
1284                    replay_receipt: true,
1285                    fence: options.fence.as_ref(),
1286                    properties_already_derived: true,
1287                },
1288                &annotates,
1289                KeyPublication::AtInsert,
1290            )
1291            .await
1292        }
1293        .await;
1294
1295        // Test-only pause, reached whether preparation succeeded or failed:
1296        // both the ordinary insert path and the prep-failure fallback below
1297        // re-derive the current holder fresh, so both need to be
1298        // interruptible by an independent writer for the race regression
1299        // tests (ADR-172 Amendment 6, acceptance items 3 and 5).
1300        #[cfg(test)]
1301        race_seam::pause_after_prepare().await;
1302
1303        match prep_result {
1304            Ok((mut prepared, _)) => {
1305                let note = prepared.notes.remove(0);
1306                match run_atomic_unit(self.sql().as_ref(), prepared.plans).await {
1307                    Ok(AtomicRunOutcome::Committed { .. }) => {
1308                        self.fire_note_mutation_hook(&note.kind, note.id).await;
1309                        Ok((note, prepared.embedding_truncation))
1310                    }
1311                    Ok(AtomicRunOutcome::RolledBack {
1312                        failure: AtomicOpFailure::NoteConflict(conflict),
1313                        ..
1314                    }) => Err(conflict.into_error().into()),
1315                    Ok(AtomicRunOutcome::RolledBack { failure, .. }) => Err(
1316                        RuntimeError::Internal(format!("note creation rolled back: {failure:?}")),
1317                    ),
1318                    Err(error) => Err(RuntimeError::Storage(error.0)),
1319                }
1320            }
1321            Err(prep_error) => {
1322                // Preparation failed. Revalidate fences and the current
1323                // holder one more time before consuming that failure: an
1324                // equal holder now yields replay; a different payload
1325                // conflicts; continued absence preserves the preparation
1326                // failure (ADR-172 Amendment 6, acceptance item 5).
1327                match check_keyed_create_holder(
1328                    self.sql().as_ref(),
1329                    NoteWriteGuard {
1330                        namespace,
1331                        target_id: candidate.id,
1332                        expected_version: None,
1333                        fence: options.fence.clone(),
1334                        create_key: Some(CreateKeyClaim {
1335                            kind: kind.to_owned(),
1336                            key: key.to_owned(),
1337                            content: content.to_owned(),
1338                            properties: derived_properties,
1339                            replay_signal: true,
1340                        }),
1341                    },
1342                )
1343                .await?
1344                {
1345                    Some(conflict) => Err(conflict.into_error().into()),
1346                    None => Err(prep_error),
1347                }
1348            }
1349        }
1350    }
1351}