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        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(&note).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    /// Build the existing guarded note plan without acquiring another reader.
921    /// Canonical operations call this with the writer-current row after their
922    /// preimage comparison; ordinary callers pass their prepared snapshot.
923    #[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(&note)?;
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        // ADR-172 Amendment 5: the no-op answer is for unfenced updates only.
950        // A write that names `expected_version` is asking for exactly one
951        // accepted write at that version, and the version it mints is the only
952        // thing a rival can fail against, so an identical fenced patch is still
953        // a write. This branch serves `update` and the `stream.batch` write
954        // member alike, and a batch write member is always fenced.
955        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            // A fenced write of an identical patch rewrites the row as it is,
994            // and the replace statement admits only a strictly newer
995            // `updated_at`, so advance the revision here the way a changed
996            // patch does (see `prepare_update_note_from_snapshot`).
997            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                &note,
1013                expected_updated_at,
1014                expected_deleted_at,
1015            )
1016        } else {
1017            khive_db::stores::note::note_replace_if_unchanged_statement(
1018                &note,
1019                expected_updated_at,
1020                expected_deleted_at,
1021            )
1022        };
1023        // A writer such as gtd.repair may change the row while retaining its
1024        // updated_at. The snapshot version is therefore part of every CAS,
1025        // including an unfenced update.
1026        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                &note.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(&note),
1052            )) {
1053                statements.push(PlanStatement {
1054                    statement: sql,
1055                    guard: None,
1056                });
1057            }
1058        }
1059        // The note update event, in the same atomic unit as the row it
1060        // describes, exactly as the entity update plan does. This is the third
1061        // of the three domain kinds that had no emitter anywhere in the tree.
1062        statements.extend(crate::atomic_prepare::event_append_statements(
1063            token,
1064            &note.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        // This is a potential reindex. Inherited membership is resolved by the
1077        // writer because vector publication can occur without a note revision.
1078        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    /// Defer annotation target resolution until a keyed-create holder check
1154    /// has decided whether this request is an exact replay. Resolution errors
1155    /// still take part in the final holder recheck before they are returned.
1156    #[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        // Unkeyed create: unchanged. There is no key to hold, so there is
1206        // nothing for an initial holder check to decide, so preparation runs
1207        // the same way it always has, and the single writer transaction at
1208        // the end is the only admission decision.
1209        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(&note.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        // Keyed singleton create (ADR-172 Amendment 6): staged.
1255        //
1256        // Properties are derived exactly once, here, before either holder
1257        // check, because the installed note-write validator is not guaranteed
1258        // idempotent (a kg regression test counts its calls), and equality
1259        // compares the DERIVED value, so the initial check, the note this
1260        // preparation would insert, and any final-check fallback must all
1261        // compare/store the SAME derived value rather than three
1262        // independently hook-derived ones.
1263        let derived_properties =
1264            self.derive_note_write_properties(kind, token, properties.clone())?;
1265        let namespace: String = token.namespace().as_str().into();
1266
1267        // Initial writer transaction: supplied fences and the live holder
1268        // for (namespace, kind, key), before any creation-only work runs.
1269        // Equal + disclosable => replay, no preparation. Different => the
1270        // existing key_conflict. A stale fence => fence_conflict, also
1271        // before preparation. Absent (and fences pass) => continue.
1272        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        // Creation-only preparation (annotation-target resolution,
1294        // embedding-model resolution, embedding computation), outside the
1295        // writer, now that the key was observed absent. A failure here is
1296        // captured rather than returned immediately: the final writer
1297        // transaction below still gets to revalidate the holder before
1298        // either this plan or this failure is consumed.
1299        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        // Test-only pause, reached whether preparation succeeded or failed:
1331        // both the ordinary insert path and the prep-failure fallback below
1332        // re-derive the current holder fresh, so both need to be
1333        // interruptible by an independent writer for the race regression
1334        // tests (ADR-172 Amendment 6, acceptance items 3 and 5).
1335        #[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(&note.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                // Preparation failed. Revalidate fences and the current
1358                // holder one more time before consuming that failure: an
1359                // equal holder now yields replay; a different payload
1360                // conflicts; continued absence preserves the preparation
1361                // failure (ADR-172 Amendment 6, acceptance item 5).
1362                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}