Skip to main content

khive_runtime/atomic_prepare/
add_update.rs

1#[cfg(doc)]
2use super::AtomicDeleteKind;
3use super::{
4    entity_fts_document, entity_name_patch, entity_replace_if_unchanged_statement,
5    entity_upsert_statement, event_append_statements, insert_document_statements,
6    note_fts_document, note_upsert_statement, obj, optional_create_string,
7    optional_entity_type_patch, optional_f64_patch, optional_properties, optional_str,
8    optional_string_patch, optional_tags, prepare_delete, prepare_delete_edge, prepare_link,
9    prepare_merge, prepare_update_edge, prepare_update_entity_plan_with_version,
10    refuse_pack_registry_tags, require_str, require_uuid, AddEntityPlan, AddNotePlan,
11    AffectedRowGuard, AtomicOpPlan, EdgeUpsertDisposition, EventKind, KhiveRuntime, NamespaceToken,
12    PlanStatement, PostCommitEffect, Resolved, RuntimeError, RuntimeResult, SubstrateKind,
13    UpdatePlan, Uuid, Value,
14};
15
16// ---------------------------------------------------------------------------
17// dispatch
18// ---------------------------------------------------------------------------
19
20/// Build the prepared [`AtomicOpPlan`] for one KG-substrate admissible op
21/// (`update`, `delete`, `link`, `merge`). Returns a loud [`RuntimeError`] for
22/// `propose`/`review`/`withdraw` (known scope gap, see module doc) and any
23/// other verb (the CLI boundary must reject those before calling this — a
24/// verb reaching here is either KG-substrate-admissible or a bug upstream).
25pub async fn prepare_op(
26    runtime: &KhiveRuntime,
27    token: &NamespaceToken,
28    tool: &str,
29    args: &Value,
30) -> RuntimeResult<AtomicOpPlan> {
31    match tool {
32        // `expected_kind: None` here — same reasoning as the `"delete"` arm
33        // below: callers that need `update(kind=...)` parity must resolve
34        // the kind spec themselves (it needs a `VerbRegistry`, unreachable
35        // from this crate: see `AtomicUpdateKind`'s doc comment) and call
36        // `prepare_update` directly with the resolved value; `kkernel`'s
37        // `--atomic` seam does exactly this and bypasses this dispatch arm.
38        // A caller reaching `prepare_op("update", ...)` without going
39        // through that seam gets kind-unchecked behavior.
40        "update" => prepare_update(runtime, token, args, None).await,
41        // `expected_kind: None` here — callers that need `delete(kind=...)`
42        // parity must resolve the kind spec themselves (it needs a
43        // `VerbRegistry`, unreachable from this crate: see
44        // `AtomicDeleteKind`'s doc comment) and call `prepare_delete`
45        // directly with the resolved value; `kkernel`'s `--atomic` seam does
46        // exactly this and bypasses this dispatch arm. A caller reaching
47        // `prepare_op("delete", ...)` without going through that seam gets
48        // kind-unchecked behavior.
49        "delete" => prepare_delete(runtime, token, args, None).await,
50        "link" => prepare_link(runtime, token, args).await,
51        "merge" => prepare_merge(runtime, token, args).await,
52        "propose" | "review" | "withdraw" => prepare_governance_unimplemented(tool),
53        other => Err(RuntimeError::InvalidInput(format!(
54            "{other:?} has no atomic_prepare::prepare_op implementation; the CLI \
55             admissibility check should have rejected this before prepare"
56        ))),
57    }
58}
59
60fn prepare_governance_unimplemented(tool: &str) -> RuntimeResult<AtomicOpPlan> {
61    Err(RuntimeError::InvalidInput(format!(
62        "{tool:?} is on the ADR-099 v1 admissible verb list but has no --atomic \
63         prepare/apply implementation yet: its lifecycle (ADR-046) is an \
64         event-sourced changeset-interpreter over a dedicated `proposals_open` \
65         table, not a small guarded-DML plan — a faithful non-stub atomic \
66         prepare for it is tracked as ADR-099 follow-up work, not implemented \
67         in slice B3. No write was attempted."
68    )))
69}
70
71// ---------------------------------------------------------------------------
72// create (AddEntity / AddNote)
73// ---------------------------------------------------------------------------
74
75/// Build the prepared plan for an `AddEntity` proposal change. The entity
76/// row and FTS document are committed together; vector indexing is deferred
77/// until after commit because embedding may suspend. `kind` must already be
78/// canonicalized by the caller because pack-aware resolution requires a
79/// `VerbRegistry`.
80pub async fn prepare_add_entity(
81    runtime: &KhiveRuntime,
82    token: &NamespaceToken,
83    args: &Value,
84) -> RuntimeResult<AtomicOpPlan> {
85    prepare_add_entity_record(runtime, token, args, None)
86        .await
87        .map(|(_, plan)| plan)
88}
89
90pub(crate) async fn prepare_add_entity_record(
91    runtime: &KhiveRuntime,
92    token: &NamespaceToken,
93    args: &Value,
94    canonical_id: Option<Uuid>,
95) -> RuntimeResult<(khive_storage::Entity, AtomicOpPlan)> {
96    let kind = require_str(args, "kind")?;
97    let name = require_str(args, "name")?;
98    runtime.validate_entity_kind(kind)?;
99    if name.trim().is_empty() {
100        return Err(RuntimeError::InvalidInput(
101            "name must not be empty".to_string(),
102        ));
103    }
104
105    let description = optional_create_string(args, "description")?;
106    let properties = optional_properties(args, "properties")?;
107    let tags = optional_tags(args)?.unwrap_or_default();
108
109    crate::secret_gate::check_at(name, "entity", "name")?;
110    if let Some(ref d) = description {
111        crate::secret_gate::check_at(d, "entity", "description")?;
112    }
113    if let Some(ref p) = properties {
114        crate::secret_gate::check_json_at(p, "entity", "properties")?;
115    }
116    crate::secret_gate::check_tags_at(&tags, "entity", "tags")?;
117    crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())?;
118
119    let ns = token.namespace().as_str();
120    let mut entity = khive_storage::Entity::new(ns, kind, name);
121    if let Some(id) = canonical_id {
122        entity.id = id;
123        entity.entity_type =
124            runtime.validate_entity_type_for_kind(kind, optional_str(args, "entity_type"))?;
125    }
126    if let Some(d) = description {
127        entity = entity.with_description(d);
128    }
129    if let Some(p) = properties {
130        entity = entity.with_properties(p);
131    }
132    if !tags.is_empty() {
133        entity = entity.with_tags(tags);
134    }
135
136    let mut statements = vec![PlanStatement {
137        statement: if canonical_id.is_some() {
138            khive_db::stores::entity::entity_insert_if_absent_statement(&entity)
139        } else {
140            entity_upsert_statement(&entity)
141        },
142        guard: Some(AffectedRowGuard::exactly(1)),
143    }];
144    // Order-sensitive pair — see `insert_document_statements`'s adjacency
145    // contract: the map upsert's `last_insert_rowid()` must read back the
146    // FTS insert immediately before it.
147    for statement in insert_document_statements("fts_entities", &entity_fts_document(&entity)) {
148        statements.push(PlanStatement {
149            statement,
150            guard: None,
151        });
152    }
153
154    Ok((
155        entity.clone(),
156        AtomicOpPlan::AddEntity(AddEntityPlan {
157            entity_id: entity.id,
158            statements,
159            post_commit: PostCommitEffect::ReindexEntity {
160                entity_id: entity.id,
161            },
162        }),
163    ))
164}
165
166/// Build the prepared plan for an `AddNote` proposal change. Mirrors
167/// [`prepare_add_entity`]'s shape and the same
168/// `kind`-already-canonicalized split. `annotates` is out of scope: the
169/// proposal `NoteDraft` this backs carries no annotates targets, unlike
170/// `KhiveRuntime::create_note`'s general-purpose signature.
171pub async fn prepare_add_note(
172    runtime: &KhiveRuntime,
173    token: &NamespaceToken,
174    args: &Value,
175) -> RuntimeResult<AtomicOpPlan> {
176    prepare_add_note_record(runtime, token, args, None)
177        .await
178        .map(|(_, plan)| plan)
179}
180
181pub(crate) async fn prepare_add_note_record(
182    runtime: &KhiveRuntime,
183    token: &NamespaceToken,
184    args: &Value,
185    canonical_id: Option<Uuid>,
186) -> RuntimeResult<(khive_storage::Note, AtomicOpPlan)> {
187    let kind = require_str(args, "kind")?;
188    let content = require_str(args, "content")?;
189    runtime.validate_note_kind(kind)?;
190
191    let name = optional_create_string(args, "name")?;
192    let properties = optional_properties(args, "properties")?;
193    // Same note-write validator `create_note_inner` runs: this path builds its
194    // args itself and dispatches no pack hook, so without this call a proposal
195    // changeset would be the one note-write that stores caller-supplied owned
196    // identity properties verbatim. The token here is the applying caller's
197    // (threaded in by the apply worker), not the proposer's.
198    let properties = runtime.derive_note_write_properties(kind, token, properties)?;
199
200    crate::secret_gate::check_note_fields_at(
201        content,
202        name.as_deref(),
203        properties.as_ref(),
204        "note",
205    )?;
206    crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())?;
207
208    let ns = token.namespace().as_str();
209    let mut note = khive_storage::note::Note::new(ns, kind, content);
210    if let Some(id) = canonical_id {
211        note.id = id;
212        note.salience = optional_f64_patch(args, "salience")?.flatten();
213        note.decay_factor = optional_f64_patch(args, "decay_factor")?.flatten();
214        if note
215            .salience
216            .is_some_and(|value| !value.is_finite() || !(0.0..=1.0).contains(&value))
217            || note
218                .decay_factor
219                .is_some_and(|value| !value.is_finite() || value < 0.0)
220        {
221            return Err(RuntimeError::InvalidInput(
222                "invalid canonical note salience or decay_factor".into(),
223            ));
224        }
225    }
226    if let Some(n) = name {
227        note = note.with_name(n);
228    }
229    if let Some(p) = properties {
230        note = note.with_properties(p);
231    }
232
233    let mut statements = vec![PlanStatement {
234        statement: if canonical_id.is_some() {
235            khive_db::stores::note::note_insert_if_absent_statement(&note)
236        } else {
237            note_upsert_statement(&note)
238        },
239        guard: Some(AffectedRowGuard::exactly(1)),
240    }];
241    // Order-sensitive pair — see `insert_document_statements`'s adjacency
242    // contract.
243    for statement in insert_document_statements("fts_notes", &note_fts_document(&note)) {
244        statements.push(PlanStatement {
245            statement,
246            guard: None,
247        });
248    }
249
250    Ok((
251        note.clone(),
252        AtomicOpPlan::AddNote(Box::new(AddNotePlan {
253            note_guard: None,
254            note_id: note.id,
255            statements,
256            post_commit: PostCommitEffect::ReindexNote {
257                note_id: note.id,
258                version: note.version,
259            },
260        })),
261    ))
262}
263
264// ---------------------------------------------------------------------------
265// update
266// ---------------------------------------------------------------------------
267
268/// Mirrors `khive-pack-kg::handlers::update::reject_inapplicable_fields`: a
269/// hard `InvalidInput` when a caller passes a field that does not apply to
270/// the resolved substrate (e.g. `salience` on an entity, or
271/// `description` on a note). That function has no dependency edge
272/// back to `khive-runtime`, so its exact field-applicability check list and
273/// error message shape are reimplemented here rather than imported: same
274/// pattern as `optional_string_patch` above. Presence is checked directly on
275/// the raw args object (this module has no `UpdateParams` struct). Nullable
276/// description, salience, decay_factor, and entity_type preserve key presence;
277/// the remaining field checks retain their existing rules.
278pub(super) fn reject_inapplicable_update_fields(
279    args: &Value,
280    substrate: &str,
281) -> RuntimeResult<()> {
282    let o = obj(args)?;
283    if substrate == "edge" && o.get("expected_version").is_some_and(|v| !v.is_null()) {
284        return Err(RuntimeError::InvalidInput(
285            "expected_version applies only to entities and notes".into(),
286        ));
287    }
288    if substrate != "note"
289        && ["embed", "fence"]
290            .iter()
291            .any(|field| o.contains_key(*field))
292    {
293        return Err(RuntimeError::InvalidInput(
294            "embed and fence apply only to notes".into(),
295        ));
296    }
297    let present = |k: &str| o.get(k).is_some_and(|v| !v.is_null());
298    let (bad_field, valid): (Option<&str>, &str) = match substrate {
299        "entity" => {
300            let bad = if present("content") {
301                Some("content")
302            } else if o.contains_key("salience") {
303                Some("salience")
304            } else if o.contains_key("decay_factor") {
305                Some("decay_factor")
306            } else if present("relation") {
307                Some("relation")
308            } else if present("weight") {
309                Some("weight")
310            } else {
311                None
312            };
313            (bad, "name, description, tags, properties, entity_type")
314        }
315        "note" => {
316            let bad = if o.contains_key("description") {
317                Some("description")
318            } else if present("relation") {
319                Some("relation")
320            } else if present("weight") {
321                Some("weight")
322            } else if o.contains_key("entity_type") {
323                // ADR-014 tri-state: a PRESENT key (including JSON `null`,
324                // the explicit clear) is inapplicable to notes.
325                Some("entity_type")
326            } else {
327                None
328            };
329            (
330                bad,
331                "name, content, salience, decay_factor, properties, tags",
332            )
333        }
334        // `update` admits `kind="edge"` per `ATOMIC_ADMISSIBLE_VERBS`, so
335        // this arm must reject entity/note-only fields (e.g. `name`) on an
336        // edge update rather than silently skip the guard, mirroring
337        // `khive-pack-kg::handlers::update::reject_inapplicable_fields`'s
338        // `KindSpec::Edge` arm.
339        "edge" => {
340            let bad = if present("name") {
341                Some("name")
342            } else if o.contains_key("description") {
343                Some("description")
344            } else if present("content") {
345                Some("content")
346            } else if present("tags") {
347                Some("tags")
348            } else if o.contains_key("salience") {
349                Some("salience")
350            } else if o.contains_key("decay_factor") {
351                Some("decay_factor")
352            } else if o.contains_key("entity_type") {
353                // ADR-014 tri-state: a PRESENT key (including JSON `null`,
354                // the explicit clear) is inapplicable to edges.
355                Some("entity_type")
356            } else {
357                None
358            };
359            (bad, "relation, weight, properties")
360        }
361        _ => (None, ""),
362    };
363    if let Some(field) = bad_field {
364        let substrate_label = match substrate {
365            "entity" => "an entity",
366            "note" => "a note",
367            "edge" => "an edge",
368            other => other,
369        };
370        return Err(RuntimeError::InvalidInput(format!(
371            "field '{field}' is not valid for {substrate_label}; valid fields: {valid}"
372        )));
373    }
374    Ok(())
375}
376
377/// Caller-supplied update-kind expectation, resolved via the canonical
378/// `resolve_kind_spec` at the kkernel `--atomic` seam: the same pattern
379/// [`AtomicDeleteKind`] uses. Without this check, `update(kind="document",
380/// id=<concept>)` would be canonically `NotFound` but the atomic path would
381/// ignore the explicit kind and mutate the resolved entity anyway.
382/// `khive-runtime` must not depend on `khive-pack-kg`, so this is a plain
383/// substrate-level shape rather than `khive_pack_kg::handlers::KindSpec`
384/// itself: the kkernel seam does the pack-aware resolution and passes down
385/// only what `prepare_update` needs to enforce the mismatch check.
386pub enum AtomicUpdateKind {
387    Entity {
388        specific: Option<String>,
389        entity_type: Option<String>,
390    },
391    Note {
392        specific: Option<String>,
393    },
394    Edge,
395}
396
397/// Enforce a caller's explicit update-kind discriminator against a resolved
398/// note before any pack hook can inspect or normalize the request. Canonical
399/// KG dispatch performs this mismatch check before its hook; the atomic
400/// adapter calls this same helper to preserve that error ordering.
401pub fn validate_note_update_expected_kind(
402    note: &khive_storage::note::Note,
403    expected_kind: &Option<AtomicUpdateKind>,
404) -> RuntimeResult<()> {
405    let id = note.id;
406    match expected_kind {
407        None => Ok(()),
408        Some(AtomicUpdateKind::Note {
409            specific: Some(expected),
410        }) if &note.kind != expected => Err(RuntimeError::NotFound(format!("note {id}"))),
411        Some(AtomicUpdateKind::Note { .. }) => Ok(()),
412        Some(AtomicUpdateKind::Entity { .. }) => {
413            Err(RuntimeError::NotFound(format!("entity {id}")))
414        }
415        Some(AtomicUpdateKind::Edge) => Err(RuntimeError::NotFound(format!("edge {id}"))),
416    }
417}
418
419async fn prepare_note_update_plan_from_snapshot(
420    runtime: &KhiveRuntime,
421    token: &NamespaceToken,
422    args: &Value,
423    expected_kind: &Option<AtomicUpdateKind>,
424    note: khive_storage::note::Note,
425    policy: crate::NoteUpdatePolicy,
426    registry: Option<&crate::VerbRegistry>,
427) -> RuntimeResult<(khive_storage::Note, UpdatePlan)> {
428    let id = require_uuid(args, "id")?;
429    if note.id != id {
430        return Err(RuntimeError::NotFound(format!("note {id}")));
431    }
432    validate_note_update_expected_kind(&note, expected_kind)?;
433
434    reject_inapplicable_update_fields(args, "note")?;
435    let mut normalized_args = args.clone();
436    crate::curation::normalize_note_update_tags(&mut normalized_args)?;
437    let args = &normalized_args;
438    let name = optional_string_patch(args, "name")?;
439    let content = optional_str(args, "content").map(str::to_string);
440    let properties = optional_properties(args, "properties")?;
441    let salience = optional_f64_patch(args, "salience")?;
442    let decay_factor = optional_f64_patch(args, "decay_factor")?;
443    let options = crate::note_write::NoteWriteOptions {
444        expected_version: obj(args)?
445            .get("expected_version")
446            .filter(|v| !v.is_null())
447            .map(|v| {
448                v.as_i64().ok_or_else(|| {
449                    RuntimeError::InvalidInput("expected_version must be an integer".into())
450                })
451            })
452            .transpose()?,
453        fence: obj(args)?
454            .get("fence")
455            .map(|v| {
456                serde_json::from_value(v.clone())
457                    .map_err(|error| RuntimeError::InvalidInput(format!("invalid fence: {error}")))
458            })
459            .transpose()?,
460        embed: obj(args)?
461            .get("embed")
462            .filter(|v| !v.is_null())
463            .map(|v| {
464                v.as_bool()
465                    .ok_or_else(|| RuntimeError::InvalidInput("embed must be boolean".into()))
466            })
467            .transpose()?,
468        key: None,
469    };
470    let patch = crate::curation::NotePatch::new(name, content, salience, decay_factor, properties)
471        .with_update_policy(policy)
472        .with_write_options(options);
473    let (updated, mut plan) = runtime
474        .prepare_versioned_note_update(token, note.clone(), patch.clone())
475        .await?;
476    if let Some(registry) = registry {
477        attach_note_update_effects(runtime, token, registry, &note, &patch, &mut plan).await?;
478    }
479    Ok((updated, plan))
480}
481
482/// Build an atomic update plan from the exact note snapshot already supplied
483/// to a pack update hook. Persistence is guarded by that snapshot's revision
484/// and deletion marker, so hook normalization cannot race a second read. The
485/// caller must first run the registry normalizer/validator against this snapshot.
486/// This shared canonical/atomic seam prepares all patch fields with the owning
487/// kind's property policy (the value `VerbRegistry::prepare_note_update_policy`
488/// returned for this snapshot), then derives and attaches the owner's typed
489/// graph effects. It returns the projected note and one atomic plan, preserving
490/// the caller's operation index.
491pub async fn prepare_update_from_note_snapshot(
492    runtime: &KhiveRuntime,
493    token: &NamespaceToken,
494    args: &Value,
495    expected_kind: Option<AtomicUpdateKind>,
496    note: khive_storage::note::Note,
497    policy: crate::NoteUpdatePolicy,
498    registry: &crate::VerbRegistry,
499) -> RuntimeResult<(khive_storage::Note, AtomicOpPlan)> {
500    if obj(args)?.get("entity_kind").is_some_and(|v| !v.is_null()) {
501        return Err(RuntimeError::InvalidInput(
502            "entity_kind is immutable; to change kind, delete then re-create the entity, \
503             or use merge() if this is a deduplication correction"
504                .into(),
505        ));
506    }
507    let (note, plan) = prepare_note_update_plan_from_snapshot(
508        runtime,
509        token,
510        args,
511        &expected_kind,
512        note,
513        policy,
514        Some(registry),
515    )
516    .await?;
517    Ok((note, AtomicOpPlan::Update(Box::new(plan))))
518}
519
520/// The only companion attachment site. Canonical, CLI atomic, and stream note
521/// updates all enter through `prepare_update_from_note_snapshot` after running
522/// the registry's normalizer/validator on the same snapshot.
523pub(crate) async fn attach_note_update_effects(
524    runtime: &KhiveRuntime,
525    token: &NamespaceToken,
526    registry: &crate::VerbRegistry,
527    snapshot: &khive_storage::Note,
528    patch: &crate::curation::NotePatch,
529    plan: &mut UpdatePlan,
530) -> RuntimeResult<()> {
531    use crate::atomic_plan::NoteUpdateStatement;
532    use crate::NoteUpdateEffect;
533
534    let Some(hook) = registry.find_kind_hook(&snapshot.kind) else {
535        return Ok(());
536    };
537    // Refuse already-known stale input before asking the owner to derive effects.
538    // A change after this read remains protected by the note's first CAS statement.
539    if let Some(expected) = patch.write_options.expected_version {
540        if expected != snapshot.version {
541            return Err(crate::note_write::NoteWriteConflict::Version {
542                expected,
543                current: snapshot.version,
544            }
545            .into_error()
546            .into());
547        }
548    }
549    let current = runtime.notes(token)?.get_note(snapshot.id).await?;
550    if !current.is_some_and(|current| {
551        current.updated_at == snapshot.updated_at
552            && current.deleted_at == snapshot.deleted_at
553            && current.version == snapshot.version
554    }) {
555        return Err(crate::curation::stale_note_snapshot_error(snapshot.id));
556    }
557    let effects = hook
558        .note_update_effects(runtime, token, snapshot, patch)
559        .await?;
560    if plan.idempotent_noop && !effects.is_empty() {
561        return Err(RuntimeError::InvalidInput(
562            "an unchanged note update cannot carry graph effects".into(),
563        ));
564    }
565    let edge_token = token.with_namespace(
566        crate::Namespace::parse(&snapshot.namespace)
567            .map_err(|error| RuntimeError::Internal(format!("invalid note namespace: {error}")))?,
568    );
569    for effect in effects {
570        match effect {
571            NoteUpdateEffect::Link(spec) => {
572                if spec.source_id != snapshot.id
573                    || spec
574                        .namespace
575                        .as_deref()
576                        .is_some_and(|ns| ns != snapshot.namespace)
577                {
578                    return Err(RuntimeError::InvalidInput(
579                        "note update links must originate from the note in its namespace".into(),
580                    ));
581                }
582                let mut args = serde_json::json!({
583                    "source_id": spec.source_id, "target_id": spec.target_id,
584                    "relation": spec.relation, "weight": spec.weight,
585                    "resurrect": spec.resurrect,
586                });
587                if let Some(metadata) = spec.metadata {
588                    args["metadata"] = metadata;
589                }
590                let AtomicOpPlan::Link(link) = prepare_link(runtime, &edge_token, &args).await?
591                else {
592                    return Err(RuntimeError::Internal("expected a link plan".into()));
593                };
594                // A live annotation inserted between the owner's read and this
595                // lookup belongs to that writer. Refuse instead of replacing its
596                // weight/metadata; a fresh preparation can preserve it explicitly.
597                if link.disposition == EdgeUpsertDisposition::Updated {
598                    return Err(khive_types::KhiveError::conflict(
599                        "a live edge appeared while preparing the note update; retry with fresh state",
600                    ).into());
601                }
602                plan.graph_effects
603                    .extend(link.statements.into_iter().map(NoteUpdateStatement::Write));
604            }
605            NoteUpdateEffect::DeleteEdge(edge) => {
606                let id = Uuid::from(edge.id);
607                if edge.source_id != snapshot.id
608                    || edge.namespace != snapshot.namespace
609                    || edge.deleted_at.is_some()
610                {
611                    return Err(RuntimeError::InvalidInput(
612                        "note update deletes must select an outgoing edge in the note namespace"
613                            .into(),
614                    ));
615                }
616                plan.graph_effects
617                    .push(NoteUpdateStatement::Assert(PlanStatement {
618                        statement: khive_db::stores::graph::edge_snapshot_assertion_statement(
619                            &edge, false,
620                        ),
621                        guard: Some(AffectedRowGuard::exactly(1)),
622                    }));
623                let actor = format!("{}:{}", token.actor().kind, token.actor().id);
624                let AtomicOpPlan::Delete(delete) =
625                    prepare_delete_edge(&edge_token, id, edge, false, &actor).await?
626                else {
627                    return Err(RuntimeError::Internal(
628                        "expected an edge delete plan".into(),
629                    ));
630                };
631                if delete.post_commit != PostCommitEffect::None {
632                    return Err(RuntimeError::Internal(
633                        "edge delete has a deferred effect".into(),
634                    ));
635                }
636                plan.graph_effects.extend(
637                    delete
638                        .statements
639                        .into_iter()
640                        .map(NoteUpdateStatement::Write),
641                );
642            }
643            NoteUpdateEffect::AssertLink(edge) => {
644                if edge.source_id != snapshot.id
645                    || edge.namespace != snapshot.namespace
646                    || edge.deleted_at.is_some()
647                {
648                    return Err(RuntimeError::InvalidInput(
649                        "note update assertions must select a live outgoing edge in the note namespace".into(),
650                    ));
651                }
652                plan.graph_effects
653                    .push(NoteUpdateStatement::Assert(PlanStatement {
654                        statement: khive_db::stores::graph::edge_snapshot_assertion_statement(
655                            &edge, true,
656                        ),
657                        guard: Some(AffectedRowGuard::exactly(1)),
658                    }));
659            }
660        }
661    }
662    Ok(())
663}
664
665/// `expected_kind`: `None` when the caller omitted `kind` (no check, parity
666/// with canonical's own optional discriminator); `Some(_)` enforces an
667/// exact-parity mismatch check against the resolved record's actual
668/// substrate/specific kind, mirroring `handle_update`'s
669/// `entity.kind != *k` / note kind checks (update.rs:200-201, :229-234).
670/// Task notes must pass the GTD pack hook before this or
671/// `prepare_op("update", ..)`; neither runs it.
672pub async fn prepare_update(
673    runtime: &KhiveRuntime,
674    token: &NamespaceToken,
675    args: &Value,
676    expected_kind: Option<AtomicUpdateKind>,
677) -> RuntimeResult<AtomicOpPlan> {
678    let id = require_uuid(args, "id")?;
679
680    // Mirrors update.rs's entity_kind immutability guard: entity_kind is a
681    // legacy top-level field, independent of the `kind` substrate
682    // discriminator handled elsewhere.
683    if obj(args)?.get("entity_kind").is_some_and(|v| !v.is_null()) {
684        return Err(RuntimeError::InvalidInput(
685            "entity_kind is immutable; to change kind, delete then re-create the entity, \
686             or use merge() if this is a deduplication correction"
687                .into(),
688        ));
689    }
690
691    match runtime.resolve_by_id(token, id).await? {
692        Some(Resolved::Entity(entity)) => {
693            match &expected_kind {
694                None => {}
695                Some(AtomicUpdateKind::Entity {
696                    specific: Some(expected),
697                    ..
698                }) if &entity.kind != expected => {
699                    return Err(RuntimeError::NotFound(format!("entity {id}")));
700                }
701                Some(AtomicUpdateKind::Entity { .. }) => {}
702                Some(AtomicUpdateKind::Note { .. }) => {
703                    return Err(RuntimeError::NotFound(format!("note {id}")));
704                }
705                Some(AtomicUpdateKind::Edge) => {
706                    return Err(RuntimeError::NotFound(format!("edge {id}")));
707                }
708            }
709            if let Some(AtomicUpdateKind::Entity {
710                entity_type: Some(expected),
711                ..
712            }) = &expected_kind
713            {
714                if entity
715                    .entity_type
716                    .as_deref()
717                    .is_some_and(|actual| actual != expected.as_str())
718                {
719                    return Err(RuntimeError::NotFound(format!("entity {id}")));
720                }
721            }
722            refuse_pack_registry_tags(&entity.tags, "update")?;
723            // Decide step lives in curation.rs's `prepare_update_entity` —
724            // the SAME function canonical `update_entity` calls. Only the
725            // arg-extraction (raw JSON -> `EntityPatch`) and the plan-shape
726            // wiring are atomic-path-specific: the domain object becomes a
727            // `PlanStatement` via `entity_replace_if_unchanged_statement`,
728            // whose CAS predicate binds the snapshot's revision and deletion
729            // marker, under an exactly-one affected-row guard.
730            reject_inapplicable_update_fields(args, "entity")?;
731            let name = entity_name_patch(args)?;
732            let description = optional_entity_type_patch(args, "description")?;
733            let properties = optional_properties(args, "properties")?;
734            let tags = optional_tags(args)?;
735            if let Some(ref tags) = tags {
736                refuse_pack_registry_tags(tags, "update")?;
737            }
738            let entity_type = optional_entity_type_patch(args, "entity_type")?;
739
740            let expected_version = obj(args)?
741                .get("expected_version")
742                .filter(|v| !v.is_null())
743                .map(|value| {
744                    value.as_i64().ok_or_else(|| {
745                        RuntimeError::InvalidInput("expected_version must be an integer".into())
746                    })
747                })
748                .transpose()?;
749            let required_entity_type = match &expected_kind {
750                Some(AtomicUpdateKind::Entity { entity_type, .. }) => entity_type.as_deref(),
751                _ => None,
752            };
753            prepare_update_entity_plan_with_version_and_type(
754                runtime,
755                token,
756                id,
757                crate::curation::EntityPatch {
758                    name,
759                    description,
760                    properties,
761                    tags,
762                    entity_type,
763                },
764                expected_version,
765                required_entity_type,
766            )
767            .await
768        }
769        Some(Resolved::Note(note)) => {
770            // Patch application lives in curation.rs's
771            // `prepare_update_note_from_snapshot` — the same implementation
772            // canonical guarded update calls, including salience/decay range
773            // validation. The plan retains this exact snapshot's revision.
774            let (_, plan) = prepare_note_update_plan_from_snapshot(
775                runtime,
776                token,
777                args,
778                &expected_kind,
779                note,
780                crate::NoteUpdatePolicy::default(),
781                None,
782            )
783            .await?;
784            Ok(AtomicOpPlan::Update(Box::new(plan)))
785        }
786        Some(_) => Err(RuntimeError::InvalidInput(format!(
787            "update target {id} must be an entity, note, or edge"
788        ))),
789        // `Resolved` (khive-runtime::operations) has no `Edge` variant — an
790        // id that isn't an entity/note/pack-private/event record is checked
791        // against the graph store directly, mirroring
792        // `khive-pack-kg::handlers::KgPack::infer_kind_from_uuid`'s own
793        // entity/note-then-edge fallback order. `update` admits
794        // `kind="edge"`, so this arm must be able to build a plan for one.
795        None => match &expected_kind {
796            Some(AtomicUpdateKind::Entity { .. }) => {
797                Err(RuntimeError::NotFound(format!("entity/note {id}")))
798            }
799            Some(AtomicUpdateKind::Note { .. }) => {
800                Err(RuntimeError::NotFound(format!("entity/note {id}")))
801            }
802            Some(AtomicUpdateKind::Edge) | None => match runtime.get_edge(token, id).await? {
803                Some(edge) => prepare_update_edge(runtime, token, id, edge, args).await,
804                None => Err(RuntimeError::NotFound(format!("entity/note/edge {id}"))),
805            },
806        },
807    }
808}
809
810/// Build an entity update plan from a typed patch. Proposal changesets use
811/// this entry point to preserve the typed patch directly, without translating
812/// it through raw verb parameters.
813pub async fn prepare_update_entity_plan(
814    runtime: &KhiveRuntime,
815    token: &NamespaceToken,
816    id: Uuid,
817    patch: crate::curation::EntityPatch,
818) -> RuntimeResult<AtomicOpPlan> {
819    prepare_update_entity_plan_with_version(runtime, token, id, patch, None).await
820}
821
822pub(super) async fn prepare_update_entity_plan_with_version_and_type(
823    runtime: &KhiveRuntime,
824    token: &NamespaceToken,
825    id: Uuid,
826    patch: crate::curation::EntityPatch,
827    expected_version: Option<i64>,
828    required_entity_type: Option<&str>,
829) -> RuntimeResult<AtomicOpPlan> {
830    crate::entity_write::validate_expected_version(expected_version)?;
831    let explicit_entity_type_patch = patch.entity_type.is_some();
832    let (entity, reindex_required, changed_fields, expected_updated_at, expected_deleted_at) =
833        runtime.prepare_update_entity(token, id, patch).await?;
834    if required_entity_type.is_some_and(|expected| {
835        (explicit_entity_type_patch || entity.entity_type.is_some())
836            && entity.entity_type.as_deref() != Some(expected)
837    }) {
838        return Err(RuntimeError::InvalidInput(
839            "kind subtype contradicts the requested entity_type update".into(),
840        ));
841    }
842    let mut statements = vec![PlanStatement {
843        statement: entity_replace_if_unchanged_statement(
844            &entity,
845            expected_updated_at,
846            expected_deleted_at,
847        ),
848        guard: Some(AffectedRowGuard::exactly(1)),
849    }];
850    statements.extend(event_append_statements(
851        token,
852        &entity.namespace,
853        "update",
854        EventKind::EntityUpdated,
855        SubstrateKind::Entity,
856        id,
857        serde_json::json!({
858            "id": id,
859            "namespace": entity.namespace,
860            "changed_fields": changed_fields,
861        }),
862    )?);
863    let post_commit = if reindex_required {
864        PostCommitEffect::ReindexEntity { entity_id: id }
865    } else {
866        PostCommitEffect::None
867    };
868    Ok(AtomicOpPlan::Update(Box::new(UpdatePlan {
869        graph_effects: Vec::new(),
870        note_vector_purge: None,
871        note_embedding_inheritance: None,
872        entity_guard: expected_version.map(|expected_version| {
873            crate::entity_write::EntityWriteGuard {
874                id,
875                expected_version,
876            }
877        }),
878        note_guard: None,
879        target_id: id,
880        statements,
881        post_commit,
882        edge_natural_key: None,
883        idempotent_noop: false,
884    })))
885}