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 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(¬e).await? {
21 return Err(error);
22 }
23 }
24 let pack_owned = self.is_pack_owned_note_kind(¬e.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 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 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 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(¬e, "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 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 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(¬e.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 let owned_keys = kind_owned_properties(¬e.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 if pack_owned {
201 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 ¬e.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 for key in crate::secret_gate::RESERVED_PROPERTY_KEYS {
275 properties.remove(key);
276 }
277 crate::secret_gate::check_at(¬e.content, "note", "content")?;
278 if let Some(name) = ¬e.name {
279 crate::secret_gate::check_at(name, "note", "name")?;
280 }
281 if let Some(properties) = ¬e.properties {
282 crate::secret_gate::check_json_at(properties, "note", "properties")?;
283 }
284 }
285 } else {
286 crate::secret_gate::reject_reserved_secret_gate_property(note.properties.as_ref())?;
289 }
290
291 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, ¬e.properties)
303 }
304 || original_status != note.status;
305 if !changed {
306 return Ok((stored, text_changed, false));
307 }
308
309 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 #[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 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 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}