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 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 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 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}