Skip to main content

khive_runtime/curation/
note_curation.rs

1use super::{
2    kind_owned_properties, merge_properties, note_update_values_equal,
3    owner_established_property_named_in, reject_pack_managed_schedule_mutation,
4    stale_note_snapshot_error, EntityDedupMergePolicy, KhiveRuntime, NamespaceToken, NotePatch,
5    RuntimeError, RuntimeResult, Uuid, Value,
6};
7
8impl KhiveRuntime {
9    /// Apply a note patch to exactly the supplied read snapshot without
10    /// fetching the row again. The caller must persist it through
11    /// [`Self::update_note_from_snapshot_with_embedding_report`] or a write
12    /// plan guarded by the snapshot's `updated_at`/`deleted_at` values.
13    pub(crate) async fn prepare_update_note_from_snapshot(
14        &self,
15        _token: &NamespaceToken,
16        note: khive_storage::note::Note,
17        patch: NotePatch,
18    ) -> RuntimeResult<(khive_storage::note::Note, bool, bool)> {
19        if patch.content.is_some() || patch.properties.is_some() {
20            if let Some(error) = self.stream_member_error(&note).await? {
21                return Err(error);
22            }
23        }
24        let pack_owned = self.is_pack_owned_note_kind(&note.kind);
25        Self::apply_note_update_fields(
26            note,
27            patch,
28            pack_owned,
29            false,
30            false,
31            chrono::Utc::now().timestamp_micros(),
32        )
33    }
34
35    /// Shared field validation and mutation. Canonical callers replace the
36    /// complete property map; ordinary updates retain their merge semantics.
37    pub(crate) fn apply_note_update_fields(
38        mut note: khive_storage::note::Note,
39        patch: NotePatch,
40        pack_owned: bool,
41        replace_properties: bool,
42        canonical: bool,
43        now: i64,
44    ) -> RuntimeResult<(khive_storage::note::Note, bool, bool)> {
45        if note.properties.as_ref().is_some_and(|properties| {
46            properties
47                .as_object()
48                .is_some_and(|map| map.contains_key(crate::secret_gate::RESERVED_WEB_RECEIPT_KEY))
49        }) {
50            return Err(RuntimeError::InvalidInput(
51                "web receipt notes are immutable through generic update".into(),
52            ));
53        }
54        // The stored row as read. A no-op answers with this, not with the
55        // patched snapshot: the patch may differ from the row in ways the
56        // no-op decision ignores (tag order), and nothing was written.
57        let stored = note.clone();
58        let original_name = note.name.clone();
59        let original_content = note.content.clone();
60        let original_salience = note.salience;
61        let original_decay_factor = note.decay_factor;
62        let original_properties = note.properties.clone();
63        let original_status = note.status.clone();
64        if patch
65            .update_policy
66            .kind
67            .as_deref()
68            .is_some_and(|kind| kind != note.kind)
69        {
70            return Err(RuntimeError::InvalidInput(
71                "note update policy does not match the stored note kind".into(),
72            ));
73        }
74        // Canonical writes follow ADR-115 §4: an incoming reserved value that echoes the
75        // stored one is dropped, any other incoming reserved value is refused, and the stored
76        // value is carried below only while the bytes it vouches for are unchanged.
77        let mut patch = patch;
78        let stored_reserved = if canonical {
79            if let Some(properties) = patch.properties.as_mut() {
80                crate::secret_gate::strip_reserved_echo(properties, note.properties.as_ref())?;
81            }
82            crate::secret_gate::reserved_properties(note.properties.as_ref())
83        } else {
84            Vec::new()
85        };
86        crate::secret_gate::reject_reserved_secret_gate_property(patch.properties.as_ref())?;
87        if let Some(ref content) = patch.content {
88            crate::secret_gate::check_at(content, "note", "content")?;
89        }
90        if let Some(Some(ref name)) = patch.name {
91            crate::secret_gate::check_at(name, "note", "name")?;
92        }
93        if let Some(ref props) = patch.properties {
94            crate::secret_gate::check_json_at(props, "note", "properties")?;
95        }
96
97        reject_pack_managed_schedule_mutation(&note, "update")?;
98
99        let mut text_changed = false;
100
101        if let Some(name_patch) = patch.name {
102            text_changed |= note.name != name_patch;
103            note.name = name_patch;
104        }
105        if let Some(content) = patch.content {
106            text_changed |= note.content != content;
107            note.content = content;
108        }
109        if let Some(salience_patch) = patch.salience {
110            // Reject invalid salience rather than silently clamping caller input.
111            if let Some(s) = salience_patch {
112                if !s.is_finite() || !(0.0..=1.0).contains(&s) {
113                    return Err(crate::RuntimeError::InvalidInput(format!(
114                        "salience must be a finite value in [0.0, 1.0]; got {s}"
115                    )));
116                }
117            }
118            note.salience = salience_patch;
119        }
120        if let Some(decay_patch) = patch.decay_factor {
121            // Reject invalid decay_factor rather than silently clamping caller input.
122            if let Some(d) = decay_patch {
123                if !d.is_finite() || d < 0.0 {
124                    return Err(crate::RuntimeError::InvalidInput(format!(
125                        "decay_factor must be a finite value >= 0.0; got {d}"
126                    )));
127                }
128            }
129            note.decay_factor = decay_patch;
130        }
131        if let Some(props) = patch.properties {
132            if replace_properties {
133                let replacement = props.as_object().ok_or_else(|| {
134                    RuntimeError::InvalidInput("canonical note properties must be an object".into())
135                })?;
136                let original = note.properties.as_ref().and_then(Value::as_object);
137                for key in kind_owned_properties(&note.kind).iter().copied().chain(
138                    super::OWNER_ESTABLISHED_PROPERTIES
139                        .iter()
140                        .copied()
141                        .filter(|_| pack_owned),
142                ) {
143                    if original.and_then(|map| map.get(key)) != replacement.get(key) {
144                        return Err(RuntimeError::InvalidInput(format!(
145                            "canonical replacement cannot change owner-established property `{key}` on a `{}` note",
146                            note.kind
147                        )));
148                    }
149                }
150                note.properties = Some(props);
151            } else {
152                // Kind-owned identity is protected below pack hooks, including
153                // direct runtime and atomic/proposal update preparation. The merge
154                // path restores these same keys on its surviving row.
155                let owned_keys = kind_owned_properties(&note.kind);
156                if !owned_keys.is_empty() {
157                    let object = props.as_object().ok_or_else(|| {
158                        if note.kind == "message" {
159                            RuntimeError::InvalidInput(
160                                "properties on a `message` note must be patched with an object: a \
161                             non-object patch would replace the transport-owned quarantine and \
162                             channel provenance established by `comm.ingest`"
163                                    .into(),
164                            )
165                        } else {
166                            RuntimeError::InvalidInput(format!(
167                                "properties on a `{}` note must be patched with an object; \
168                             a non-object patch would erase its owner-established identity",
169                                note.kind
170                            ))
171                        }
172                    })?;
173                    if let Some(named) = owned_keys.iter().find(|key| object.contains_key(**key)) {
174                        if note.kind == "message" {
175                            return Err(RuntimeError::InvalidInput(format!(
176                            "`{named}` is transport-owned on a `message` note and cannot be patched; \
177                             only `comm.ingest` may establish quarantine disposition and channel \
178                             provenance"
179                        )));
180                        }
181                        return Err(RuntimeError::InvalidInput(format!(
182                            "`{named}` is not patchable on a `{}` note; \
183                         use `comm.heartbeat` to report health without changing the row's identity",
184                            note.kind
185                        )));
186                    }
187                }
188                // On a pack-owned note kind, the properties in
189                // `OWNER_ESTABLISHED_PROPERTIES` are established by the owning pack
190                // and read back by it to decide something structural — who wrote
191                // the record and when, which author-side record it copies, which
192                // conversation it belongs to. A caller cannot patch them here.
193                // Only a patch that *names* one of them is refused, and naming is
194                // the exact test: the merge below is `PreferFrom`, so a patch that
195                // names an owned key would overwrite it while a patch that does
196                // not name it leaves it intact. Every other key still merges
197                // normally — arbitrary metadata on a pack-owned record (a
198                // `blocked_on` note on a `task`) has no other write path and must
199                // keep working.
200                if pack_owned {
201                    // A non-object patch names nothing, so it slips past the
202                    // named-key check below and then takes `merge_json`'s
203                    // non-object `PreferFrom` arm, which replaces the whole
204                    // property object rather than merging into it — erasing
205                    // every owned key. Refused on every pack-owned kind, not only
206                    // rows that currently carry an owned key, so an identical
207                    // call cannot succeed or fail on state the caller cannot see.
208                    if !props.is_object() {
209                        return Err(RuntimeError::InvalidInput(format!(
210                        "properties on a `{}` note must be patched with an object: a non-object \
211                         patch names no key, so it would replace the whole property object rather \
212                         than merging into it. Pass an object containing the keys you intend to \
213                         set.",
214                        note.kind
215                    )));
216                    }
217                    if let Some(named) = owner_established_property_named_in(&props) {
218                        return Err(RuntimeError::InvalidInput(format!(
219                            "`{named}` is not patchable on a `{}` note: the pack that owns this \
220                         kind establishes it and reads it back — to decide how the record is \
221                         attributed and grouped, or to reproduce it verbatim when the record \
222                         is re-emitted — so it is written by the owner and immutable to a \
223                         caller patch. Patch any other property key here, or omit \
224                         `{named}` from this patch.",
225                            note.kind
226                        )));
227                    }
228                }
229                let incoming_properties = Some(props);
230                let (mut merged, _) = merge_properties(
231                    &note.properties,
232                    &incoming_properties,
233                    EntityDedupMergePolicy::PreferFrom,
234                );
235                if let Some(properties) = merged.as_mut().and_then(Value::as_object_mut) {
236                    for key in patch.update_policy.null_clearing_properties {
237                        if incoming_properties
238                            .as_ref()
239                            .and_then(|incoming| incoming.get(*key))
240                            .is_some_and(Value::is_null)
241                        {
242                            properties.remove(*key);
243                        }
244                    }
245                }
246                note.properties = merged;
247            }
248        }
249        if let Some(status) = patch.kind_status {
250            note.status = status;
251        }
252
253        if !stored_reserved.is_empty() {
254            let unchanged = original_name == note.name
255                && original_content == note.content
256                && crate::secret_gate::without_reserved_properties(original_properties.as_ref())
257                    == crate::secret_gate::without_reserved_properties(note.properties.as_ref());
258            let properties = note
259                .properties
260                .get_or_insert_with(|| Value::Object(Default::default()))
261                .as_object_mut()
262                .ok_or_else(|| {
263                    RuntimeError::InvalidInput("canonical note properties must be an object".into())
264                })?;
265            if unchanged {
266                properties.extend(
267                    stored_reserved
268                        .into_iter()
269                        .map(|(key, value)| (key.to_string(), value)),
270                );
271            } else {
272                // Canonical writes take no manifest exemption, so a changed row loses the
273                // stamp and must pass the strict scans as a whole.
274                for key in crate::secret_gate::RESERVED_PROPERTY_KEYS {
275                    properties.remove(key);
276                }
277                crate::secret_gate::check_at(&note.content, "note", "content")?;
278                if let Some(name) = &note.name {
279                    crate::secret_gate::check_at(name, "note", "name")?;
280                }
281                if let Some(properties) = &note.properties {
282                    crate::secret_gate::check_json_at(properties, "note", "properties")?;
283                }
284            }
285        } else {
286            // The whole-note CAS persists the merged properties, including keys
287            // carried from the snapshot when the patch changes another field.
288            crate::secret_gate::reject_reserved_secret_gate_property(note.properties.as_ref())?;
289        }
290
291        // JSON object key order is not meaningful to callers. Tags are also
292        // set-like in every existing note reader, so their order is ignored
293        // for the no-op decision while duplicate entries remain meaningful.
294        // All other arrays retain ordinary JSON ordering semantics.
295        let changed = original_name != note.name
296            || original_content != note.content
297            || original_salience != note.salience
298            || original_decay_factor != note.decay_factor
299            || if canonical {
300                original_properties != note.properties
301            } else {
302                !note_update_values_equal(&original_properties, &note.properties)
303            }
304            || original_status != note.status;
305        if !changed {
306            return Ok((stored, text_changed, false));
307        }
308
309        // `updated_at` is also the optimistic-concurrency revision for
310        // full-note replacement. Make it strictly advance even when two
311        // operations land inside one clock microsecond. Saturation is not a
312        // valid fallback: reusing i64::MAX would make the CAS accept a write
313        // without advancing its revision.
314        let minimum_updated_at = note.updated_at.checked_add(1).ok_or_else(|| {
315            RuntimeError::Internal(format!(
316                "note {} updated_at is already at i64::MAX and cannot advance",
317                note.id
318            ))
319        })?;
320        note.updated_at = now.max(minimum_updated_at);
321        Ok((note, text_changed, true))
322    }
323
324    /// Patch-style note update.
325    #[cfg(test)]
326    pub(crate) async fn update_note(
327        &self,
328        token: &NamespaceToken,
329        id: Uuid,
330        patch: NotePatch,
331    ) -> RuntimeResult<khive_storage::note::Note> {
332        Ok(self
333            .update_note_with_embedding_report(token, id, patch)
334            .await?
335            .0)
336    }
337
338    pub async fn update_note_with_embedding_report(
339        &self,
340        token: &NamespaceToken,
341        id: Uuid,
342        patch: NotePatch,
343    ) -> RuntimeResult<(
344        khive_storage::note::Note,
345        crate::retrieval::EmbeddingTruncationReport,
346    )> {
347        let snapshot = self
348            .notes(token)?
349            .get_note(id)
350            .await?
351            .ok_or_else(|| RuntimeError::NotFound(format!("note {id}")))?;
352        self.update_note_from_snapshot_with_embedding_report(token, snapshot, patch)
353            .await
354    }
355
356    /// Patch and persist one note from a caller-owned read snapshot.
357    ///
358    /// This is the canonical seam for kind hooks that normalize coupled
359    /// fields from the current note. The same snapshot feeds normalization,
360    /// patch application, and the compare-and-swap write; a concurrent note
361    /// change therefore refuses the write instead of persisting derivations
362    /// computed from stale state.
363    pub async fn update_note_from_snapshot_with_embedding_report(
364        &self,
365        token: &NamespaceToken,
366        snapshot: khive_storage::note::Note,
367        patch: NotePatch,
368    ) -> RuntimeResult<(
369        khive_storage::note::Note,
370        crate::retrieval::EmbeddingTruncationReport,
371    )> {
372        let (note, plan) = self
373            .prepare_versioned_note_update(token, snapshot, patch)
374            .await?;
375        self.commit_prepared_note_update(token, note, crate::AtomicOpPlan::Update(Box::new(plan)))
376            .await
377    }
378
379    /// Commit a normalized and validated kind-owned update, including its typed
380    /// graph companions. Callers must first run `prepare_note_update_policy`
381    /// against this exact snapshot and pass the policy it returned; the shared
382    /// atomic prepare seam checks all patch fields before asking the kind hook
383    /// to derive any graph effects.
384    pub async fn update_note_from_snapshot_with_kind_effects(
385        &self,
386        token: &NamespaceToken,
387        snapshot: khive_storage::Note,
388        args: &Value,
389        policy: crate::NoteUpdatePolicy,
390        registry: &crate::VerbRegistry,
391    ) -> RuntimeResult<(
392        khive_storage::Note,
393        crate::retrieval::EmbeddingTruncationReport,
394    )> {
395        let (note, plan) = crate::atomic_prepare::prepare_update_from_note_snapshot(
396            self, token, args, None, snapshot, policy, registry,
397        )
398        .await?;
399        self.commit_prepared_note_update(token, note, plan).await
400    }
401
402    async fn commit_prepared_note_update(
403        &self,
404        token: &NamespaceToken,
405        note: khive_storage::Note,
406        plan: crate::AtomicOpPlan,
407    ) -> RuntimeResult<(
408        khive_storage::Note,
409        crate::retrieval::EmbeddingTruncationReport,
410    )> {
411        let id = note.id;
412        use crate::atomic_runner::{run_atomic_unit, AtomicOpFailure, AtomicRunOutcome};
413        match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
414            Ok(AtomicRunOutcome::Committed { post_commit }) => {
415                let outcomes = crate::atomic_prepare::apply_post_commit_effects_with_report(
416                    self,
417                    token,
418                    post_commit,
419                )
420                .await?;
421                let report = outcomes
422                    .first()
423                    .map(|outcome| outcome.truncation.clone())
424                    .unwrap_or_default();
425                let degradations = super::note_reindex::NoteReindexReport {
426                    truncation: report.clone(),
427                    failures: outcomes
428                        .into_iter()
429                        .flat_map(|outcome| outcome.failures)
430                        .collect(),
431                }
432                .post_commit_degradations();
433                crate::operations::legacy_post_commit_result(
434                    "update_note",
435                    id,
436                    (note, report),
437                    degradations,
438                )
439            }
440            Ok(AtomicRunOutcome::RolledBack {
441                failure: AtomicOpFailure::NoteConflict(conflict),
442                ..
443            }) => Err(conflict.into_error().into()),
444            Ok(AtomicRunOutcome::RolledBack {
445                failure: AtomicOpFailure::GuardFailed { .. },
446                ..
447            }) => Err(stale_note_snapshot_error(id)),
448            Ok(AtomicRunOutcome::RolledBack { failure, .. }) => Err(RuntimeError::Internal(
449                format!("note update rolled back: {failure:?}"),
450            )),
451            Err(error) => Err(RuntimeError::Storage(error.0)),
452        }
453    }
454}