Skip to main content

khive_runtime/graph_lint/
fix.rs

1use super::*;
2use crate::atomic_runner::{
3    run_prepared_atomic_unit, AtomicOpPlan, PreparedAtomicError, PreparedAtomicOp,
4    PreparedAtomicOutcome,
5};
6use crate::{KhiveRuntime, Namespace, NamespaceToken, RuntimeError, RuntimeResult, VerbRegistry};
7use khive_storage::{SqlStatement, StorageError};
8use serde_json::json;
9use uuid::Uuid;
10
11struct PreparedFix {
12    owner: KhiveRuntime,
13    token: NamespaceToken,
14    guards: Vec<SqlStatement>,
15    plan: AtomicOpPlan,
16    committed: CommittedFixAction,
17}
18enum ActionRefusal {
19    Conflict(String),
20    Failed(RuntimeError),
21}
22
23enum Candidate {
24    Ready(Box<PreparedFix>),
25    Unsupported(String),
26    Conflict(String),
27}
28impl LintCapture {
29    fn unique_owner(&self, ids: &[Uuid]) -> Option<usize> {
30        let owner = *self.record_owners.get(ids.first()?)?.first()?;
31        ids.iter()
32            .all(|id| {
33                self.record_owners
34                    .get(id)
35                    .is_some_and(|owners| owners.as_slice() == [owner])
36            })
37            .then_some(owner)
38    }
39    fn tombstone_guard(&self, id: Uuid) -> Option<SqlStatement> {
40        if let Some(entity) = self
41            .all_entities
42            .get(&id)
43            .filter(|entity| entity.deleted_at.is_some())
44        {
45            return Some(khive_db::graph_lint::entity_witness(entity));
46        }
47        if let Some(note) = self
48            .all_notes
49            .get(&id)
50            .filter(|note| note.deleted_at.is_some())
51        {
52            return Some(khive_db::graph_lint::note_witness(note));
53        }
54        self.all_edges
55            .get(&id)
56            .filter(|edge| edge.deleted_at.is_some())
57            .and_then(|_| self.edge_guards.get(&id).cloned())
58    }
59    async fn prepare_fix(
60        &self,
61        registry: &VerbRegistry,
62        token: &NamespaceToken,
63        identity: &ActionIdentity,
64        violation: &LintViolation,
65    ) -> RuntimeResult<Candidate> {
66        let id = violation.subject.id();
67        let captured_subject = match &violation.subject {
68            LintSubject::Entity {
69                namespace,
70                entity_id,
71                entity_name,
72                entity_kind,
73            } => self.entities.iter().any(|entity| {
74                entity.id == *entity_id
75                    && entity.namespace == *namespace
76                    && entity.name == *entity_name
77                    && entity.kind == *entity_kind
78            }),
79            LintSubject::Edge { namespace, edge_id } => self.edges.iter().any(|inspected| {
80                Uuid::from(inspected.edge.id) == *edge_id && inspected.edge.namespace == *namespace
81            }),
82            LintSubject::Note { .. } => false,
83        };
84        if !captured_subject {
85            return Err(RuntimeError::InvalidInput(
86                "lint action subject was not in the visible capture".into(),
87            ));
88        }
89
90        let unsupported = || {
91            Candidate::Unsupported(
92                "the observed action has no unique same-database guarded owner".into(),
93            )
94        };
95        let ns = Namespace::parse(violation.subject.namespace())
96            .map_err(|error| RuntimeError::InvalidInput(error.to_string()))?;
97        if !token.visible_namespaces().contains(&ns) {
98            return Err(RuntimeError::InvalidInput(
99                "lint action is outside its captured visible scope".into(),
100            ));
101        }
102        let write_token = token.with_namespace(ns).with_request_id(token.request_id());
103        let (owner, guards, verb, args) = match identity.action {
104            FixAction::SoftDeleteEdge => {
105                let Some(edge) = self
106                    .all_edges
107                    .get(&id)
108                    .filter(|edge| edge.deleted_at.is_none())
109                else {
110                    return Ok(Candidate::Conflict("captured edge is not live".into()));
111                };
112                let Some((owner, endpoint_guard)) = [edge.source_id, edge.target_id]
113                    .into_iter()
114                    .find_map(|endpoint| {
115                        let owner = self.unique_owner(&[id, endpoint])?;
116                        self.tombstone_guard(endpoint).map(|guard| (owner, guard))
117                    })
118                else {
119                    return Ok(unsupported());
120                };
121                (
122                    owner,
123                    vec![self.edge_guards[&id].clone(), endpoint_guard],
124                    "delete",
125                    json!({"id":id, "hard":false}),
126                )
127            }
128            FixAction::ProposeDuplicateMerge => {
129                let members = violation.member_ids.as_deref().ok_or_else(|| {
130                    RuntimeError::Internal("duplicate lint violation lacks members".into())
131                })?;
132                if members.len() < 2 {
133                    return Err(RuntimeError::Internal(
134                        "duplicate lint group needs two distinct members".into(),
135                    ));
136                }
137                let mut distinct = members.to_vec();
138                distinct.sort();
139                distinct.dedup();
140                let first = self.all_entities.get(&id).ok_or_else(|| {
141                    RuntimeError::Internal("duplicate representative was not captured".into())
142                })?;
143                if distinct.len() != members.len()
144                    || distinct.first() != Some(&id)
145                    || members.iter().any(|member| {
146                        self.all_entities.get(member).is_none_or(|entity| {
147                            entity.namespace != first.namespace
148                                || entity.kind != first.kind
149                                || entity.name != first.name
150                        })
151                    })
152                {
153                    return Err(RuntimeError::InvalidInput(
154                        "lint duplicate group does not match the captured identity".into(),
155                    ));
156                }
157                let Some(owner) = self.unique_owner(members) else {
158                    return Ok(unsupported());
159                };
160                let mut guards = Vec::new();
161                for member in members {
162                    let entity = self.all_entities.get(member).ok_or_else(|| {
163                        RuntimeError::Internal("lint duplicate member was not captured".into())
164                    })?;
165                    if entity.deleted_at.is_some() {
166                        return Ok(Candidate::Conflict(
167                            "captured duplicate member is not live".into(),
168                        ));
169                    }
170                    guards.push(khive_db::graph_lint::entity_witness(entity));
171                }
172                let content = format!("Possible duplicate entities in namespace {}: {}. Review these records before deciding whether to merge them; no winner has been selected.", violation.subject.namespace(), members.iter().map(Uuid::to_string).collect::<Vec<_>>().join(", "));
173                (
174                    owner,
175                    guards,
176                    "create",
177                    json!({"kind":"question", "content":content, "properties":{"lint_rule":"duplicate-name", "member_ids":members}}),
178                )
179            }
180            FixAction::MarkDeprecated => {
181                let Some(entity) = self.all_entities.get(&id).filter(|entity| {
182                    entity.deleted_at.is_none()
183                        && entity
184                            .properties
185                            .as_ref()
186                            .and_then(|properties| properties.get("status"))
187                            .and_then(serde_json::Value::as_str)
188                            != Some("deprecated")
189                }) else {
190                    return Ok(Candidate::Conflict(
191                        "captured entity is not an unmarked live target".into(),
192                    ));
193                };
194                let witness = self.edges.iter().find_map(|inspected| {
195                    let edge = &inspected.edge;
196                    if edge.target_id != id
197                        || edge.relation != khive_storage::EdgeRelation::Supersedes
198                        || !inspected.source_live
199                        || inspected.contract_valid != Some(true)
200                    {
201                        return None;
202                    }
203                    let source = self.all_entities.get(&edge.source_id)?;
204                    let owner = self.unique_owner(&[id, Uuid::from(edge.id), source.id])?;
205                    Some((owner, edge, source))
206                });
207                let Some((owner, edge, source)) = witness else {
208                    return Ok(unsupported());
209                };
210                (
211                    owner,
212                    vec![
213                        khive_db::graph_lint::entity_witness(entity),
214                        self.edge_guards[&Uuid::from(edge.id)].clone(),
215                        khive_db::graph_lint::entity_witness(source),
216                    ],
217                    "update",
218                    json!({"id":id,"properties":{"status":"deprecated"}}),
219                )
220            }
221        };
222        let runtime = &self.owners[owner];
223        {
224            let mut reader = runtime.sql().reader().await?;
225            for guard in &guards {
226                if reader.query_all(guard.clone()).await?.len() != 1 {
227                    return Ok(Candidate::Conflict(
228                        "an observed lint predicate changed before preparation".into(),
229                    ));
230                }
231            }
232        }
233        // Consult the normal verb gate with the effective target and original
234        // principal. Its ordinary audit obligations remain part of preparation.
235        registry
236            .authorize_effective_kg_read(&write_token, verb, args.clone(), id)
237            .await?;
238        let mut plan = match identity.action {
239            FixAction::SoftDeleteEdge => {
240                crate::atomic_prepare::prepare_delete(
241                    runtime,
242                    &write_token,
243                    &args,
244                    Some(crate::atomic_prepare::AtomicDeleteKind::Edge),
245                )
246                .await?
247            }
248            FixAction::ProposeDuplicateMerge => {
249                crate::atomic_prepare::prepare_add_note(runtime, &write_token, &args).await?
250            }
251            FixAction::MarkDeprecated => {
252                crate::atomic_prepare::prepare_update_entity_plan_with_version(
253                    runtime,
254                    &write_token,
255                    id,
256                    crate::curation::EntityPatch {
257                        properties: Some(json!({"status":"deprecated"})),
258                        ..Default::default()
259                    },
260                    Some(self.all_entities[&id].version),
261                )
262                .await?
263            }
264        };
265        if let AtomicOpPlan::AddNote(note) = &mut plan {
266            note.statements.extend(crate::atomic_prepare::event_append_statements(
267                &write_token, write_token.namespace().as_str(), "create",
268                khive_types::EventKind::NoteCreated, khive_types::SubstrateKind::Note,
269                note.note_id, json!({"id":note.note_id, "namespace":write_token.namespace().as_str(), "kind":"question", "salience":null}),
270            )?);
271        }
272        let committed = match &plan {
273            AtomicOpPlan::AddNote(plan) => CommittedFixAction::ProposeDuplicateMerge {
274                violation_index: identity.violation_index,
275                question_note_id: plan.note_id,
276            },
277            AtomicOpPlan::Delete(_) => CommittedFixAction::SoftDeleteEdge {
278                violation_index: identity.violation_index,
279            },
280            AtomicOpPlan::Update(_) => CommittedFixAction::MarkDeprecated {
281                violation_index: identity.violation_index,
282            },
283            _ => return Err(RuntimeError::Internal("unexpected graph lint plan".into())),
284        };
285        Ok(Candidate::Ready(Box::new(PreparedFix {
286            owner: runtime.clone(),
287            token: write_token,
288            guards,
289            plan,
290            committed,
291        })))
292    }
293
294    /// Prepare every candidate before the first domain write, then commit each
295    /// independent guarded action once. No failure is retried or replayed.
296    pub async fn apply_fixes(
297        &self,
298        registry: &VerbRegistry,
299        token: &NamespaceToken,
300        mut report: LintReport,
301    ) -> RuntimeResult<(LintReport, Vec<FixReceipt>)> {
302        if token.actor() != self.token.actor()
303            || token.namespace() != self.token.namespace()
304            || token.visible_namespaces() != self.token.visible_namespaces()
305            || token.request_id() != self.token.request_id()
306            || token.process_ref() != self.token.process_ref()
307        {
308            return Err(RuntimeError::InvalidInput(
309                "lint capture belongs to a different request scope".into(),
310            ));
311        }
312        let mut prepared = Vec::new();
313        for (index, violation) in report.violations.iter().enumerate() {
314            let action = match violation.rule {
315                LintRuleId::DanglingEdge => FixAction::SoftDeleteEdge,
316                LintRuleId::DuplicateName => FixAction::ProposeDuplicateMerge,
317                LintRuleId::SupersededNotMarked => FixAction::MarkDeprecated,
318                _ => continue,
319            };
320            let identity = ActionIdentity {
321                violation_index: index as u64,
322                action,
323            };
324            let candidate = self
325                .prepare_fix(registry, token, &identity, violation)
326                .await;
327            prepared.push((identity, candidate));
328        }
329        report.summary.fixed = Some(0);
330        let mut receipts = Vec::new();
331        for (identity, candidate) in prepared {
332            let candidate = candidate.map_err(|source| {
333                failure(
334                    source,
335                    LintFixOutcome::Failed,
336                    identity.clone(),
337                    &receipts,
338                    &report,
339                )
340            })?;
341            match candidate {
342                Candidate::Unsupported(reason) => {
343                    receipts.push(FixReceipt::Unsupported { identity, reason })
344                }
345                Candidate::Conflict(reason) => {
346                    receipts.push(FixReceipt::Conflict { identity, reason })
347                }
348                Candidate::Ready(prepared) => {
349                    let PreparedFix {
350                        owner,
351                        token,
352                        guards,
353                        plan,
354                        committed,
355                    } = *prepared;
356                    let operation: PreparedAtomicOp<(), ActionRefusal> = Box::new(move |writer| {
357                        Box::pin(async move {
358                            for guard in guards {
359                                if writer.query_all(guard).await?.len() != 1 {
360                                    return Err(PreparedAtomicError::Refused {
361                                        failure: ActionRefusal::Conflict(
362                                            "an observed lint predicate changed".into(),
363                                        ),
364                                        message: "graph lint witness conflict".into(),
365                                    });
366                                }
367                            }
368                            let applied = crate::atomic_runner::apply_plan_preserving_storage(
369                                writer, &plan, true,
370                            )
371                            .await
372                            .map_err(|failure| match failure {
373                                crate::atomic_runner::PlanExecutionFailure::Storage {
374                                    source,
375                                    ..
376                                } => PreparedAtomicError::Storage(source),
377                                crate::atomic_runner::PlanExecutionFailure::Operation(failure) => {
378                                    let failure = match failure {
379                                        crate::atomic_runner::AtomicOpFailure::SqlError {
380                                            message,
381                                            ..
382                                        } => ActionRefusal::Failed(RuntimeError::Internal(message)),
383                                        _ => ActionRefusal::Conflict(
384                                            "an ordinary prepared mutation guard changed".into(),
385                                        ),
386                                    };
387                                    PreparedAtomicError::Refused {
388                                        failure,
389                                        message: "graph lint plan refused".into(),
390                                    }
391                                }
392                            })?;
393                            Ok(((), applied.effect.into_iter().collect()))
394                        })
395                    });
396                    match run_prepared_atomic_unit(action_access(&owner).as_ref(), operation).await
397                    {
398                        Ok(PreparedAtomicOutcome::RolledBack(ActionRefusal::Conflict(reason))) => {
399                            receipts.push(FixReceipt::Conflict { identity, reason })
400                        }
401                        Ok(PreparedAtomicOutcome::RolledBack(ActionRefusal::Failed(source))) => {
402                            return Err(failure(
403                                source,
404                                LintFixOutcome::Failed,
405                                identity,
406                                &receipts,
407                                &report,
408                            ))
409                        }
410                        Ok(PreparedAtomicOutcome::Committed { post_commit, .. }) => {
411                            receipts.push(FixReceipt::Committed { action: committed });
412                            report.summary.fixed = Some(
413                                receipts
414                                    .iter()
415                                    .filter(|receipt| receipt.is_committed())
416                                    .count() as u64,
417                            );
418                            // Each prepared lint action has at most one ordinary
419                            // effect. Preserve its typed cause rather than the
420                            // multi-effect compatibility API's string aggregate.
421                            for effect in post_commit.into_effects() {
422                                if let Err(source) =
423                                    crate::atomic_prepare::apply_one_post_commit_effect(
424                                        &owner, &token, effect,
425                                    )
426                                    .await
427                                {
428                                    return Err(failure(
429                                        source,
430                                        LintFixOutcome::Failed,
431                                        identity,
432                                        &receipts,
433                                        &report,
434                                    ));
435                                }
436                            }
437                        }
438                        Err(source) => {
439                            let outcome = if unknown(&source) {
440                                LintFixOutcome::Unknown
441                            } else {
442                                LintFixOutcome::Failed
443                            };
444                            return Err(failure(
445                                source.into(),
446                                outcome,
447                                identity,
448                                &receipts,
449                                &report,
450                            ));
451                        }
452                    }
453                }
454            }
455        }
456        Ok((report, receipts))
457    }
458}
459fn unknown(error: &StorageError) -> bool {
460    if error
461        .sqlite_write_failure()
462        .is_some_and(|failure| failure.settlement_unknown)
463    {
464        return true;
465    }
466    match error.without_sqlite_write_stage() {
467        StorageError::WriterTaskRequestFailed { request_state, .. }
468        | StorageError::WriterTaskTerminated { request_state, .. } => {
469            *request_state == khive_storage::WriterTaskRequestState::SideEffectsUnknown
470        }
471        _ => false,
472    }
473}
474fn failure(
475    source: RuntimeError,
476    outcome: LintFixOutcome,
477    failed_action: ActionIdentity,
478    receipts: &[FixReceipt],
479    report: &LintReport,
480) -> RuntimeError {
481    RuntimeError::LintFix {
482        source: Box::new(source),
483        detail: Box::new(LintFixFailureDetail {
484            outcome,
485            failed_action,
486            confirmed_receipts: receipts.to_vec(),
487            report: report.clone(),
488        }),
489    }
490}
491
492fn action_access(owner: &KhiveRuntime) -> std::sync::Arc<dyn khive_storage::SqlAccess> {
493    #[cfg(test)]
494    if let Ok(access) = TEST_ACTION_ACCESS.try_with(std::sync::Arc::clone) {
495        return access;
496    }
497    owner.sql()
498}
499#[cfg(test)]
500tokio::task_local! {
501    pub(super) static TEST_ACTION_ACCESS: std::sync::Arc<dyn khive_storage::SqlAccess>;
502}