1use khive_storage::{StorageCapability, StorageError};
7use serde_json::{json, Value};
8
9use crate::{DomainDisposition, RuntimeError};
10
11pub fn runtime_error_value(error: RuntimeError, disposition: DomainDisposition) -> Value {
18 let sqlite_write_failure = match error.refusal_source() {
21 RuntimeError::Storage(error) => error.sqlite_write_failure(),
22 _ => None,
23 };
24 let outcome_has_precedence = error.writer_task_failure_context().is_some_and(|context| {
25 context.task_terminated
26 || context.request_state == khive_storage::WriterTaskRequestState::SideEffectsUnknown
27 });
28 let receipt_projection = crate::visibility_receipts::receipt_error_projection(&error);
29 let named_disposition = match &error {
30 _ if receipt_projection.is_some_and(|(_, replay)| replay) => Some("not_committed"),
31 error if error.is_stream_policy_refusal() => Some("not_committed"),
35 RuntimeError::RefusedWithReceipt(_) => Some("not_committed"),
38 RuntimeError::Khive(k) => match (k.kind(), k.details().and_then(|d| d.get("reason"))) {
39 (
40 khive_types::ErrorKind::Internal,
41 Some("post_commit_degraded" | "embedding_input_truncated"),
42 ) => Some("committed"),
43 (
45 khive_types::ErrorKind::Conflict,
46 Some("key_conflict" | "fence_conflict" | "idempotency_key_conflict"),
47 ) => Some("not_committed"),
48 (khive_types::ErrorKind::Unavailable, Some("key_holder_unresolved")) => Some("unknown"),
49 (khive_types::ErrorKind::Conflict, Some("seq_conflict")) => Some("not_committed"),
57 (khive_types::ErrorKind::Conflict, Some("unknown_op")) => Some("not_committed"),
58 (khive_types::ErrorKind::Conflict, Some("version_conflict" | "identity_conflict")) => {
59 Some("not_committed")
60 }
61 (khive_types::ErrorKind::Conflict, Some("expired" | "live_until_unreadable")) => {
62 Some("not_committed")
63 }
64 (khive_types::ErrorKind::NotFound, Some("stream_write_not_found")) => {
65 Some("not_committed")
66 }
67 (khive_types::ErrorKind::NotFound, Some("keyed_replay_holder_missing")) => {
70 Some("not_committed")
71 }
72 (khive_types::ErrorKind::InvalidInput, Some("member_unavailable")) => {
73 Some("not_committed")
74 }
75 _ => None,
76 },
77 _ => None,
78 };
79 let denial_message =
82 matches!(error, RuntimeError::PermissionDenied { .. }).then(|| error.to_string());
83 let payload = match error {
84 RuntimeError::LintFix { source, detail } => {
85 let mut value = runtime_error_value(*source, DomainDisposition::Unknown);
86 let object = value
87 .as_object_mut()
88 .expect("runtime error projection is an object");
89 let details = object.entry("details").or_insert_with(|| json!({}));
90 if !details.is_object() {
91 *details = json!({});
92 }
93 details["lint_fix"] = json!(detail);
94 value["domain_disposition"] = json!("unknown");
97 value["retryable"] = json!(false);
98 return value;
99 }
100
101 RuntimeError::WithResolution { context } => {
102 let crate::error::ResolutionFailureContext { source, resolution } = context;
103 let mut value = runtime_error_value(*source, disposition);
104 let details = value
105 .as_object_mut()
106 .expect("runtime error projection is an object")
107 .entry("details")
108 .or_insert_with(|| json!({}));
109 if !details.is_object() {
110 *details = json!({});
111 }
112 details["resolution"] = json!(resolution);
113 return value;
114 }
115 RuntimeError::RefusedWithEvents { context } => {
116 let crate::error::RefusalEventContext { source, recordings } = context;
117 let mut value = runtime_error_value(*source, disposition);
120 if !recordings.is_empty() {
121 value["refusal_recorded"] = json!(recordings.iter().all(|recording| {
122 matches!(recording, crate::RefusalEventRecording::Recorded { .. })
123 }));
124 value["refusal_events"] = json!(recordings);
125 }
126 return value;
127 }
128 RuntimeError::PermissionDenied {
129 verb,
130 reason,
131 receipt,
132 } => json!({
133 "kind": "runtime_error",
134 "code": "permission_denied",
135 "message": denial_message.unwrap_or_default(),
136 "verb": verb,
137 "reason": reason,
138 "audit_event_id": receipt.audit_event_id.map(|id| id.to_string()),
139 "audit_outcome": receipt.audit_outcome.wire_code(),
140 }),
141 RuntimeError::SecretDetected(matched) => {
142 let message = RuntimeError::SecretDetected(matched.clone()).to_string();
143 json!({
144 "kind": "runtime_error",
145 "code": "secret_detected",
146 "detector": matched.detector,
147 "location": matched.location,
148 "message": message,
149 })
150 }
151 RuntimeError::RefusedWithReceipt(refusal) => {
152 let crate::error::ReceiptRefusal {
153 code,
154 message,
155 receipt_id,
156 reason,
157 detail,
158 } = *refusal;
159 let mut error = match detail {
162 Value::Object(members) => members,
163 _ => serde_json::Map::new(),
164 };
165 error.insert("kind".into(), json!("runtime_error"));
166 error.insert("code".into(), json!(code));
167 error.insert("message".into(), json!(message));
168 error.insert("receipt_id".into(), json!(receipt_id));
169 error.insert("reason".into(), json!(reason));
170 Value::Object(error)
171 }
172 RuntimeError::AuditObligation {
173 failure,
174 domain_result,
175 } => {
176 let mut error = serde_json::Map::from_iter([
177 ("kind".into(), json!("obligation")),
178 ("code".into(), json!(failure.wire_code())),
179 ("message".into(), json!(failure.to_string())),
180 ]);
181 error.insert("domain_result".into(), domain_result);
182 Value::Object(error)
183 }
184 RuntimeError::Khive(k) => {
185 let mut value = serde_json::to_value(&k)
186 .unwrap_or_else(|_| json!({"kind": "internal", "message": k.to_string()}));
187 if k.kind() == khive_types::ErrorKind::InvalidInput
188 && k.details().and_then(|d| d.get("reason")) == Some("external_id_unverifiable")
189 {
190 value["code"] = json!("external_id_unverifiable");
191 }
192 value
193 }
194 RuntimeError::RemoteFetchError { remote, message } => json!({
195 "kind": "remote_fetch_error",
196 "remote": remote,
197 "message": message,
198 }),
199 missing @ RuntimeError::NotFound(_) => json!({
200 "kind": "not_found",
201 "code": null,
202 "details": null,
203 "message": missing.to_string(),
204 }),
205 other @ (RuntimeError::Storage(_)
206 | RuntimeError::Query(_)
207 | RuntimeError::InvalidInput(_)
208 | RuntimeError::UnknownVerb(_)
209 | RuntimeError::Unconfigured(_)
210 | RuntimeError::UnknownModel(_)
211 | RuntimeError::Embedding(_)
212 | RuntimeError::Ambiguous(_)
213 | RuntimeError::Fusion(_)
214 | RuntimeError::UnknownFusionStrategy(_)
215 | RuntimeError::Internal(_)
216 | RuntimeError::IncompatibleEventStore(_)
217 | RuntimeError::GuardedWriteFailed(_)
218 | RuntimeError::MissingPackDependency(_)
219 | RuntimeError::MissingPackDependencies(_)
220 | RuntimeError::CircularPackDependency(_)
221 | RuntimeError::PackRedeclared { .. }
222 | RuntimeError::VerbCollision { .. }
223 | RuntimeError::ReservedEnvelopeParam { .. }
224 | RuntimeError::GateUnavailable { .. }
225 | RuntimeError::NamespaceMismatch { .. }
226 | RuntimeError::AmbiguousPrefix { .. }
227 | RuntimeError::CrossBackendMergeUnsupported { .. }
228 | RuntimeError::UnknownRemote { .. }
229 | RuntimeError::RemoteCacheMissing { .. }
230 | RuntimeError::AmbiguousId { .. }
231 | RuntimeError::CrossNamespaceWrite { .. }
232 | RuntimeError::WriteBudgetExceeded { .. }
233 | RuntimeError::DeadlineExceeded { .. }) => {
234 if let Some(capacity) = sqlite_capacity_failure(&other) {
235 let mut value = capacity.into_value(other.to_string());
236 if let Some(context) = other.writer_task_failure_context() {
237 value["request_state"] = json!(context.request_state.to_string());
238 value["task_terminated"] = json!(context.task_terminated);
239 }
240 value
241 } else if let Some(context) = other.writer_task_failure_context() {
242 json!({"kind":"storage", "code":context.stage, "stage":context.stage,
243 "message":other.to_string(), "retryable":context.retryable,
244 "request_state":context.request_state.to_string(), "task_terminated":context.task_terminated})
245 } else if let Some(context) = other.retryable_failure_context() {
246 let timeout_ms = u64::try_from(context.timeout.as_millis()).unwrap_or(u64::MAX);
247 let mut value = json!({"kind":"unavailable", "code":context.stage, "stage":context.stage,
248 "message":other.to_string(), "retryable":true, "timeout_ms":timeout_ms,
249 "capability":context.capability.map(storage_capability_wire_name),
250 "operation":context.operation, "scope":context.scope, "retry_after_ms":context.retry_after_ms});
251 if let Some(pool_identity) = context.pool_identity {
252 value["pool_identity"] = json!(pool_identity);
253 }
254 value
255 } else {
256 json!({"kind":"runtime_error", "message":other.to_string()})
257 }
258 }
259 };
260 let mut value = payload;
261 if let Some(failure) = sqlite_write_failure {
262 if !outcome_has_precedence
264 && !failure.settlement_unknown
265 && failure.primary_code != rusqlite::ffi::SQLITE_FULL
266 {
267 value["code"] = json!(failure.stage.as_str());
268 value["stage"] = json!(failure.stage.as_str());
269 if value["kind"] == "runtime_error" {
270 value["kind"] = json!("storage");
271 }
272 }
273 value["sqlite_write_stage"] = json!(failure.stage.as_str());
274 value["sqlite_primary_code"] = json!(failure.primary_code);
275 value["sqlite_extended_code"] = json!(failure.extended_code);
276 }
277 if let Some((retryable, _)) = receipt_projection {
278 value["retryable"] = json!(retryable);
279 }
280 value["domain_disposition"] = json!(disposition.as_str());
281 if let Some(named) = named_disposition {
282 value["domain_disposition"] = json!(named);
283 }
284 value
285}
286
287enum SqliteCapacityFailure {
288 Refused {
289 volume: String,
290 available_bytes: u64,
291 reserve_bytes: u64,
292 required_headroom_bytes: u64,
293 },
294 Unavailable {
295 phase: khive_storage::CapacityUnavailablePhase,
296 },
297 NativeFull {
298 primary_code: i32,
299 extended_code: i32,
300 },
301}
302
303impl SqliteCapacityFailure {
304 fn into_value(self, message: String) -> Value {
305 match self {
306 Self::Refused {
307 volume,
308 available_bytes,
309 reserve_bytes,
310 required_headroom_bytes,
311 } => json!({
312 "kind": "storage",
313 "code": "sqlite_capacity_refused",
314 "stage": "sqlite_capacity_refused",
315 "message": message,
316 "retryable": false,
317 "capability": "sql",
318 "volume": volume,
319 "available_bytes": available_bytes,
320 "reserve_bytes": reserve_bytes,
321 "required_headroom_bytes": required_headroom_bytes,
322 }),
323 Self::Unavailable { phase } => json!({
324 "kind": "storage",
325 "code": "sqlite_capacity_unavailable",
326 "stage": "sqlite_capacity_unavailable",
327 "message": message,
328 "retryable": phase == khive_storage::CapacityUnavailablePhase::LockContended,
329 "capability": "sql",
330 "phase": phase.as_str(),
331 }),
332 Self::NativeFull {
333 primary_code,
334 extended_code,
335 } => json!({
336 "kind": "storage",
337 "code": "sqlite_disk_full",
338 "stage": "sqlite_disk_full",
339 "message": message,
340 "retryable": false,
341 "capability": "sql",
342 "sqlite_primary_code": primary_code,
343 "sqlite_extended_code": extended_code,
344 }),
345 }
346 }
347}
348
349fn sqlite_capacity_failure(error: &RuntimeError) -> Option<SqliteCapacityFailure> {
350 match error {
351 RuntimeError::Storage(storage) => sqlite_capacity_failure_from_storage(storage),
352 _ => None,
353 }
354}
355
356fn sqlite_capacity_failure_from_storage(error: &StorageError) -> Option<SqliteCapacityFailure> {
357 match error {
358 StorageError::CapacityFloor {
359 capability: StorageCapability::Sql,
360 volume,
361 available_bytes,
362 floor_bytes,
363 required_headroom_bytes,
364 } => Some(SqliteCapacityFailure::Refused {
365 volume: volume.clone(),
366 available_bytes: *available_bytes,
367 reserve_bytes: *floor_bytes,
368 required_headroom_bytes: *required_headroom_bytes,
369 }),
370 StorageError::CapacityUnavailable {
371 capability: StorageCapability::Sql,
372 phase,
373 ..
374 } => Some(SqliteCapacityFailure::Unavailable { phase: *phase }),
375 StorageError::WriterTaskTerminated {
376 sqlite_full_codes: Some((primary_code, extended_code)),
377 ..
378 } if *primary_code == rusqlite::ffi::SQLITE_FULL
379 && *extended_code >= 0
380 && (*extended_code & 0xff) == *primary_code =>
381 {
382 Some(SqliteCapacityFailure::NativeFull {
383 primary_code: *primary_code,
384 extended_code: *extended_code,
385 })
386 }
387 StorageError::SqliteWrite { failure, .. }
388 if failure.primary_code == rusqlite::ffi::SQLITE_FULL =>
389 {
390 Some(SqliteCapacityFailure::NativeFull {
391 primary_code: failure.primary_code,
392 extended_code: failure.extended_code,
393 })
394 }
395 StorageError::WriterTaskRequestFailed { source, .. }
396 | StorageError::SqliteWrite { source, .. } => sqlite_capacity_failure_from_storage(source),
397 StorageError::Driver { source, .. } => {
398 if let Some(sqlite) = source.downcast_ref::<khive_db::SqliteError>() {
399 sqlite_capacity_failure_from_sqlite(sqlite)
400 } else {
401 native_sqlite_full(source.as_ref())
402 }
403 }
404 _ => None,
405 }
406}
407
408fn sqlite_capacity_failure_from_sqlite(
409 error: &khive_db::SqliteError,
410) -> Option<SqliteCapacityFailure> {
411 match error {
412 khive_db::SqliteError::CapacityFloor {
413 volume,
414 available_bytes,
415 floor_bytes,
416 required_headroom_bytes,
417 } => Some(SqliteCapacityFailure::Refused {
418 volume: volume.clone(),
419 available_bytes: *available_bytes,
420 reserve_bytes: *floor_bytes,
421 required_headroom_bytes: *required_headroom_bytes,
422 }),
423 khive_db::SqliteError::CapacityUnavailable { phase, .. } => {
424 Some(SqliteCapacityFailure::Unavailable { phase: *phase })
425 }
426 khive_db::SqliteError::Rusqlite(sqlite)
427 | khive_db::SqliteError::Write { source: sqlite, .. } => native_sqlite_full(sqlite),
428 _ => None,
429 }
430}
431
432fn native_sqlite_full(error: &(dyn std::error::Error + 'static)) -> Option<SqliteCapacityFailure> {
433 let mut source = Some(error);
434 while let Some(current) = source {
435 if let Some(rusqlite::Error::SqliteFailure(code, _)) =
436 current.downcast_ref::<rusqlite::Error>()
437 {
438 if code.code == rusqlite::ErrorCode::DiskFull {
439 return Some(SqliteCapacityFailure::NativeFull {
440 primary_code: code.extended_code & 0xff,
441 extended_code: code.extended_code,
442 });
443 }
444 }
445 source = current.source();
446 }
447 None
448}
449
450fn storage_capability_wire_name(capability: StorageCapability) -> &'static str {
451 match capability {
452 StorageCapability::Sql => "sql",
453 StorageCapability::Notes => "notes",
454 StorageCapability::Entities => "entities",
455 StorageCapability::Graph => "graph",
456 StorageCapability::Events => "events",
457 StorageCapability::Vectors => "vectors",
458 StorageCapability::Sparse => "sparse",
459 StorageCapability::Text => "text",
460 StorageCapability::Blob => "blob",
461 StorageCapability::Attachments => "attachments",
462 }
463}
464
465#[cfg(test)]
466#[path = "error_projection_tests.rs"]
467mod tests;