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::WithResolution { context } => {
85 let crate::error::ResolutionFailureContext { source, resolution } = context;
86 let mut value = runtime_error_value(*source, disposition);
87 let details = value
88 .as_object_mut()
89 .expect("runtime error projection is an object")
90 .entry("details")
91 .or_insert_with(|| json!({}));
92 if !details.is_object() {
93 *details = json!({});
94 }
95 details["resolution"] = json!(resolution);
96 return value;
97 }
98 RuntimeError::RefusedWithEvents { context } => {
99 let crate::error::RefusalEventContext { source, recordings } = context;
100 let mut value = runtime_error_value(*source, disposition);
103 if !recordings.is_empty() {
104 value["refusal_recorded"] = json!(recordings.iter().all(|recording| {
105 matches!(recording, crate::RefusalEventRecording::Recorded { .. })
106 }));
107 value["refusal_events"] = json!(recordings);
108 }
109 return value;
110 }
111 RuntimeError::PermissionDenied {
112 verb,
113 reason,
114 receipt,
115 } => json!({
116 "kind": "runtime_error",
117 "code": "permission_denied",
118 "message": denial_message.unwrap_or_default(),
119 "verb": verb,
120 "reason": reason,
121 "audit_event_id": receipt.audit_event_id.map(|id| id.to_string()),
122 "audit_outcome": receipt.audit_outcome.wire_code(),
123 }),
124 RuntimeError::SecretDetected(matched) => {
125 let message = RuntimeError::SecretDetected(matched.clone()).to_string();
126 json!({
127 "kind": "runtime_error",
128 "code": "secret_detected",
129 "detector": matched.detector,
130 "location": matched.location,
131 "message": message,
132 })
133 }
134 RuntimeError::RefusedWithReceipt(refusal) => {
135 let crate::error::ReceiptRefusal {
136 code,
137 message,
138 receipt_id,
139 reason,
140 detail,
141 } = *refusal;
142 let mut error = match detail {
145 Value::Object(members) => members,
146 _ => serde_json::Map::new(),
147 };
148 error.insert("kind".into(), json!("runtime_error"));
149 error.insert("code".into(), json!(code));
150 error.insert("message".into(), json!(message));
151 error.insert("receipt_id".into(), json!(receipt_id));
152 error.insert("reason".into(), json!(reason));
153 Value::Object(error)
154 }
155 RuntimeError::AuditObligation {
156 failure,
157 domain_result,
158 } => {
159 let mut error = serde_json::Map::from_iter([
160 ("kind".into(), json!("obligation")),
161 ("code".into(), json!(failure.wire_code())),
162 ("message".into(), json!(failure.to_string())),
163 ]);
164 error.insert("domain_result".into(), domain_result);
165 Value::Object(error)
166 }
167 RuntimeError::Khive(k) => {
168 let mut value = serde_json::to_value(&k)
169 .unwrap_or_else(|_| json!({"kind": "internal", "message": k.to_string()}));
170 if k.kind() == khive_types::ErrorKind::InvalidInput
171 && k.details().and_then(|d| d.get("reason")) == Some("external_id_unverifiable")
172 {
173 value["code"] = json!("external_id_unverifiable");
174 }
175 value
176 }
177 RuntimeError::RemoteFetchError { remote, message } => json!({
178 "kind": "remote_fetch_error",
179 "remote": remote,
180 "message": message,
181 }),
182 missing @ RuntimeError::NotFound(_) => json!({
183 "kind": "not_found",
184 "code": null,
185 "details": null,
186 "message": missing.to_string(),
187 }),
188 other @ (RuntimeError::Storage(_)
189 | RuntimeError::Query(_)
190 | RuntimeError::InvalidInput(_)
191 | RuntimeError::UnknownVerb(_)
192 | RuntimeError::Unconfigured(_)
193 | RuntimeError::UnknownModel(_)
194 | RuntimeError::Embedding(_)
195 | RuntimeError::Ambiguous(_)
196 | RuntimeError::Fusion(_)
197 | RuntimeError::UnknownFusionStrategy(_)
198 | RuntimeError::Internal(_)
199 | RuntimeError::IncompatibleEventStore(_)
200 | RuntimeError::GuardedWriteFailed(_)
201 | RuntimeError::MissingPackDependency(_)
202 | RuntimeError::MissingPackDependencies(_)
203 | RuntimeError::CircularPackDependency(_)
204 | RuntimeError::PackRedeclared { .. }
205 | RuntimeError::VerbCollision { .. }
206 | RuntimeError::ReservedEnvelopeParam { .. }
207 | RuntimeError::GateUnavailable { .. }
208 | RuntimeError::NamespaceMismatch { .. }
209 | RuntimeError::AmbiguousPrefix { .. }
210 | RuntimeError::CrossBackendMergeUnsupported { .. }
211 | RuntimeError::UnknownRemote { .. }
212 | RuntimeError::RemoteCacheMissing { .. }
213 | RuntimeError::AmbiguousId { .. }
214 | RuntimeError::CrossNamespaceWrite { .. }
215 | RuntimeError::WriteBudgetExceeded { .. }
216 | RuntimeError::DeadlineExceeded { .. }) => {
217 if let Some(capacity) = sqlite_capacity_failure(&other) {
218 let mut value = capacity.into_value(other.to_string());
219 if let Some(context) = other.writer_task_failure_context() {
220 value["request_state"] = json!(context.request_state.to_string());
221 value["task_terminated"] = json!(context.task_terminated);
222 }
223 value
224 } else if let Some(context) = other.writer_task_failure_context() {
225 json!({"kind":"storage", "code":context.stage, "stage":context.stage,
226 "message":other.to_string(), "retryable":context.retryable,
227 "request_state":context.request_state.to_string(), "task_terminated":context.task_terminated})
228 } else if let Some(context) = other.retryable_failure_context() {
229 let timeout_ms = u64::try_from(context.timeout.as_millis()).unwrap_or(u64::MAX);
230 let mut value = json!({"kind":"unavailable", "code":context.stage, "stage":context.stage,
231 "message":other.to_string(), "retryable":true, "timeout_ms":timeout_ms,
232 "capability":context.capability.map(storage_capability_wire_name),
233 "operation":context.operation, "scope":context.scope, "retry_after_ms":context.retry_after_ms});
234 if let Some(pool_identity) = context.pool_identity {
235 value["pool_identity"] = json!(pool_identity);
236 }
237 value
238 } else {
239 json!({"kind":"runtime_error", "message":other.to_string()})
240 }
241 }
242 };
243 let mut value = payload;
244 if let Some(failure) = sqlite_write_failure {
245 if !outcome_has_precedence
247 && !failure.settlement_unknown
248 && failure.primary_code != rusqlite::ffi::SQLITE_FULL
249 {
250 value["code"] = json!(failure.stage.as_str());
251 value["stage"] = json!(failure.stage.as_str());
252 if value["kind"] == "runtime_error" {
253 value["kind"] = json!("storage");
254 }
255 }
256 value["sqlite_write_stage"] = json!(failure.stage.as_str());
257 value["sqlite_primary_code"] = json!(failure.primary_code);
258 value["sqlite_extended_code"] = json!(failure.extended_code);
259 }
260 if let Some((retryable, _)) = receipt_projection {
261 value["retryable"] = json!(retryable);
262 }
263 value["domain_disposition"] = json!(disposition.as_str());
264 if let Some(named) = named_disposition {
265 value["domain_disposition"] = json!(named);
266 }
267 value
268}
269
270enum SqliteCapacityFailure {
271 Refused {
272 volume: String,
273 available_bytes: u64,
274 reserve_bytes: u64,
275 required_headroom_bytes: u64,
276 },
277 Unavailable {
278 phase: khive_storage::CapacityUnavailablePhase,
279 },
280 NativeFull {
281 primary_code: i32,
282 extended_code: i32,
283 },
284}
285
286impl SqliteCapacityFailure {
287 fn into_value(self, message: String) -> Value {
288 match self {
289 Self::Refused {
290 volume,
291 available_bytes,
292 reserve_bytes,
293 required_headroom_bytes,
294 } => json!({
295 "kind": "storage",
296 "code": "sqlite_capacity_refused",
297 "stage": "sqlite_capacity_refused",
298 "message": message,
299 "retryable": false,
300 "capability": "sql",
301 "volume": volume,
302 "available_bytes": available_bytes,
303 "reserve_bytes": reserve_bytes,
304 "required_headroom_bytes": required_headroom_bytes,
305 }),
306 Self::Unavailable { phase } => json!({
307 "kind": "storage",
308 "code": "sqlite_capacity_unavailable",
309 "stage": "sqlite_capacity_unavailable",
310 "message": message,
311 "retryable": phase == khive_storage::CapacityUnavailablePhase::LockContended,
312 "capability": "sql",
313 "phase": phase.as_str(),
314 }),
315 Self::NativeFull {
316 primary_code,
317 extended_code,
318 } => json!({
319 "kind": "storage",
320 "code": "sqlite_disk_full",
321 "stage": "sqlite_disk_full",
322 "message": message,
323 "retryable": false,
324 "capability": "sql",
325 "sqlite_primary_code": primary_code,
326 "sqlite_extended_code": extended_code,
327 }),
328 }
329 }
330}
331
332fn sqlite_capacity_failure(error: &RuntimeError) -> Option<SqliteCapacityFailure> {
333 match error {
334 RuntimeError::Storage(storage) => sqlite_capacity_failure_from_storage(storage),
335 _ => None,
336 }
337}
338
339fn sqlite_capacity_failure_from_storage(error: &StorageError) -> Option<SqliteCapacityFailure> {
340 match error {
341 StorageError::CapacityFloor {
342 capability: StorageCapability::Sql,
343 volume,
344 available_bytes,
345 floor_bytes,
346 required_headroom_bytes,
347 } => Some(SqliteCapacityFailure::Refused {
348 volume: volume.clone(),
349 available_bytes: *available_bytes,
350 reserve_bytes: *floor_bytes,
351 required_headroom_bytes: *required_headroom_bytes,
352 }),
353 StorageError::CapacityUnavailable {
354 capability: StorageCapability::Sql,
355 phase,
356 ..
357 } => Some(SqliteCapacityFailure::Unavailable { phase: *phase }),
358 StorageError::WriterTaskTerminated {
359 sqlite_full_codes: Some((primary_code, extended_code)),
360 ..
361 } if *primary_code == rusqlite::ffi::SQLITE_FULL
362 && *extended_code >= 0
363 && (*extended_code & 0xff) == *primary_code =>
364 {
365 Some(SqliteCapacityFailure::NativeFull {
366 primary_code: *primary_code,
367 extended_code: *extended_code,
368 })
369 }
370 StorageError::SqliteWrite { failure, .. }
371 if failure.primary_code == rusqlite::ffi::SQLITE_FULL =>
372 {
373 Some(SqliteCapacityFailure::NativeFull {
374 primary_code: failure.primary_code,
375 extended_code: failure.extended_code,
376 })
377 }
378 StorageError::WriterTaskRequestFailed { source, .. }
379 | StorageError::SqliteWrite { source, .. } => sqlite_capacity_failure_from_storage(source),
380 StorageError::Driver { source, .. } => {
381 if let Some(sqlite) = source.downcast_ref::<khive_db::SqliteError>() {
382 sqlite_capacity_failure_from_sqlite(sqlite)
383 } else {
384 native_sqlite_full(source.as_ref())
385 }
386 }
387 _ => None,
388 }
389}
390
391fn sqlite_capacity_failure_from_sqlite(
392 error: &khive_db::SqliteError,
393) -> Option<SqliteCapacityFailure> {
394 match error {
395 khive_db::SqliteError::CapacityFloor {
396 volume,
397 available_bytes,
398 floor_bytes,
399 required_headroom_bytes,
400 } => Some(SqliteCapacityFailure::Refused {
401 volume: volume.clone(),
402 available_bytes: *available_bytes,
403 reserve_bytes: *floor_bytes,
404 required_headroom_bytes: *required_headroom_bytes,
405 }),
406 khive_db::SqliteError::CapacityUnavailable { phase, .. } => {
407 Some(SqliteCapacityFailure::Unavailable { phase: *phase })
408 }
409 khive_db::SqliteError::Rusqlite(sqlite)
410 | khive_db::SqliteError::Write { source: sqlite, .. } => native_sqlite_full(sqlite),
411 _ => None,
412 }
413}
414
415fn native_sqlite_full(error: &(dyn std::error::Error + 'static)) -> Option<SqliteCapacityFailure> {
416 let mut source = Some(error);
417 while let Some(current) = source {
418 if let Some(rusqlite::Error::SqliteFailure(code, _)) =
419 current.downcast_ref::<rusqlite::Error>()
420 {
421 if code.code == rusqlite::ErrorCode::DiskFull {
422 return Some(SqliteCapacityFailure::NativeFull {
423 primary_code: code.extended_code & 0xff,
424 extended_code: code.extended_code,
425 });
426 }
427 }
428 source = current.source();
429 }
430 None
431}
432
433fn storage_capability_wire_name(capability: StorageCapability) -> &'static str {
434 match capability {
435 StorageCapability::Sql => "sql",
436 StorageCapability::Notes => "notes",
437 StorageCapability::Entities => "entities",
438 StorageCapability::Graph => "graph",
439 StorageCapability::Events => "events",
440 StorageCapability::Vectors => "vectors",
441 StorageCapability::Sparse => "sparse",
442 StorageCapability::Text => "text",
443 StorageCapability::Blob => "blob",
444 StorageCapability::Attachments => "attachments",
445 }
446}
447
448#[cfg(test)]
449mod tests {
450 use super::runtime_error_value;
451 use crate::{
452 AuditObligationFailure, DenialAuditOutcome, DenialReceipt, DomainDisposition, RuntimeError,
453 };
454 use khive_storage::{CapacityUnavailablePhase, StorageCapability, StorageError};
455 use khive_types::{Details, ErrorCode, ErrorDomain, KhiveError};
456 use serde_json::json;
457
458 #[test]
459 fn capacity_phase_projection_preserves_writer_request_settlement() {
460 for (phase, retryable) in [
461 (CapacityUnavailablePhase::Identity, false),
462 (CapacityUnavailablePhase::Lock, false),
463 (CapacityUnavailablePhase::LockContended, true),
464 (CapacityUnavailablePhase::Probe, false),
465 ] {
466 let direct = runtime_error_value(
467 RuntimeError::Storage(StorageError::CapacityUnavailable {
468 capability: StorageCapability::Sql,
469 phase,
470 message: "unavailable".into(),
471 }),
472 DomainDisposition::Unknown,
473 );
474 assert_eq!(direct["phase"], phase.as_str());
475 assert_eq!(direct["retryable"], retryable);
476 let value = runtime_error_value(
477 RuntimeError::Storage(StorageError::WriterTaskRequestFailed {
478 request_state: khive_storage::WriterTaskRequestState::TransactionRolledBack,
479 source: Box::new(StorageError::CapacityUnavailable {
480 capability: StorageCapability::Sql,
481 phase,
482 message: "unavailable".into(),
483 }),
484 }),
485 DomainDisposition::Unknown,
486 );
487 assert_eq!(value["code"], "sqlite_capacity_unavailable");
488 assert_eq!(value["phase"], phase.as_str());
489 assert_eq!(value["request_state"], "transaction_rolled_back");
490 assert_eq!(value["task_terminated"], false);
491 assert_eq!(value["retryable"], retryable);
492 }
493 }
494
495 #[test]
496 fn sqlite_capacity_stages_preserve_typed_evidence_and_do_not_retry() {
497 let refused = runtime_error_value(
498 RuntimeError::Storage(StorageError::CapacityFloor {
499 capability: StorageCapability::Sql,
500 volume: "/volume".to_string(),
501 available_bytes: 99,
502 floor_bytes: 100,
503 required_headroom_bytes: 12,
504 }),
505 DomainDisposition::Unknown,
506 );
507 assert_eq!(refused["stage"], "sqlite_capacity_refused");
508 assert_eq!(refused["available_bytes"], 99);
509 assert_eq!(refused["reserve_bytes"], 100);
510 assert_eq!(refused["required_headroom_bytes"], 12);
511 assert_eq!(refused["retryable"], false);
512
513 let unavailable = runtime_error_value(
514 RuntimeError::Storage(StorageError::CapacityUnavailable {
515 capability: StorageCapability::Sql,
516 phase: CapacityUnavailablePhase::Lock,
517 message: "lock directory missing".to_string(),
518 }),
519 DomainDisposition::Unknown,
520 );
521 assert_eq!(unavailable["stage"], "sqlite_capacity_unavailable");
522 assert_eq!(unavailable["phase"], "lock");
523 assert_eq!(unavailable["retryable"], false);
524
525 let full = runtime_error_value(
526 RuntimeError::Storage(StorageError::driver(
527 StorageCapability::Sql,
528 "write",
529 rusqlite::Error::SqliteFailure(
530 rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_FULL),
531 None,
532 ),
533 )),
534 DomainDisposition::Unknown,
535 );
536 assert_eq!(full["stage"], "sqlite_disk_full");
537 assert_eq!(full["sqlite_primary_code"], rusqlite::ffi::SQLITE_FULL);
538 assert_eq!(full["retryable"], false);
539 }
540
541 #[test]
542 fn unsettled_sqlite_writer_converts_to_side_effects_unknown() {
543 for error in [
544 khive_db::SqliteError::InheritedWriterTransaction,
545 khive_db::SqliteError::WriterSettlementUnknown,
546 ] {
547 let value = runtime_error_value(RuntimeError::from(error), DomainDisposition::Unknown);
548 assert_eq!(value["request_state"], "side_effects_unknown");
549 assert_eq!(value["task_terminated"], true);
550 assert_eq!(value["retryable"], false);
551 }
552 }
553
554 #[test]
555 fn poisoned_writer_refusal_converts_to_not_started() {
556 let value = runtime_error_value(
557 RuntimeError::from(khive_db::SqliteError::WriterPoisoned),
558 DomainDisposition::Unknown,
559 );
560 assert_eq!(value["request_state"], "not_started");
561 assert_eq!(value["task_terminated"], true);
562 assert_eq!(value["retryable"], false);
563 }
564
565 #[test]
566 fn missing_subject_projection_is_typed_without_matching_error_text() {
567 for disposition in [DomainDisposition::Unknown, DomainDisposition::NotCommitted] {
568 let error = RuntimeError::NotFound("subject missing".into());
569 let message = error.to_string();
570 let value = runtime_error_value(error, disposition);
571 assert_eq!(value["kind"], "not_found");
572 assert_eq!(value.get("code"), Some(&serde_json::Value::Null));
573 assert_eq!(value.get("details"), Some(&serde_json::Value::Null));
574 assert_eq!(value["message"], message);
575 assert_eq!(value["domain_disposition"], disposition.as_str());
576 }
577 for error in [
578 RuntimeError::Internal("not found: damaged store index".into()),
579 RuntimeError::InvalidInput("not found: malformed reference".into()),
580 ] {
581 let value = runtime_error_value(error, DomainDisposition::Unknown);
582 assert_eq!(value["kind"], "runtime_error");
583 }
584 }
585
586 #[test]
587 fn projection_preserves_structured_source_fields_and_serialized_bytes() {
588 let source = KhiveError::unavailable("append reply missing")
589 .with_code(ErrorCode::new(ErrorDomain::Db, 71))
590 .with_details(Details::new([
591 ("operation", "append"),
592 ("driver_phase", "awaiting_reply"),
593 ("retry_hint", "do_not_repeat"),
594 ("extra", "preserve me\nincluding escapes"),
595 ]));
596 let mut expected = serde_json::to_value(&source).unwrap();
597 expected["domain_disposition"] = json!("unknown");
598 let expected_bytes = serde_json::to_vec(&expected).unwrap();
599 let actual = runtime_error_value(source.into(), DomainDisposition::Unknown);
600 assert_eq!(actual, expected);
601 assert_eq!(serde_json::to_vec(&actual).unwrap(), expected_bytes);
602 }
603
604 #[test]
605 fn embedding_truncation_after_write_projects_committed_disposition() {
606 let error = KhiveError::internal("embedding input truncated").with_details(Details::new([
607 ("reason", "embedding_input_truncated"),
608 ("record_id", "00000000-0000-0000-0000-000000000001"),
609 ("committed", "true"),
610 ("retryable", "false"),
611 ]));
612 let value = runtime_error_value(error.into(), DomainDisposition::Unknown);
613 assert_eq!(value["domain_disposition"], "committed");
614 assert_eq!(
615 value["details"]["record_id"],
616 "00000000-0000-0000-0000-000000000001"
617 );
618 }
619
620 #[test]
621 fn resolution_wrapper_adds_details_without_changing_source_classification() {
622 let source = RuntimeError::Khive(KhiveError::conflict("source conflict").with_details(
623 Details::new([("reason", "seq_conflict"), ("extra", "retained")]),
624 ));
625 let selected = uuid::Uuid::from_u128(1);
626 let duplicate = uuid::Uuid::from_u128(2);
627 let error = source.with_resolution(crate::error::ResolutionFacts {
628 project_id: selected,
629 duplicate_anchor_ids: vec![duplicate],
630 slug_backfilled: true,
631 project_created: false,
632 orphaned_project_id: None,
633 orphaned_note_count: 0,
634 });
635 assert!(matches!(error.refusal_source(), RuntimeError::Khive(_)));
636 let value = runtime_error_value(error, DomainDisposition::Unknown);
637 assert_eq!(value["domain_disposition"], "not_committed");
638 assert_eq!(value["details"]["reason"], "seq_conflict");
639 assert_eq!(value["details"]["extra"], "retained");
640 assert_eq!(
641 value["details"]["resolution"]["project_id"],
642 selected.to_string()
643 );
644 assert_eq!(
645 value["details"]["resolution"]["duplicate_anchor_ids"],
646 json!([duplicate])
647 );
648 }
649
650 #[test]
651 fn resolution_wrapper_projects_remote_fetch_type_and_anchor_facts() {
652 let selected = uuid::Uuid::from_u128(3);
653 let error = RuntimeError::RemoteFetchError {
654 remote: "https://example.com/repo".into(),
655 message: "cache repair failed".into(),
656 }
657 .with_resolution(crate::error::ResolutionFacts {
658 project_id: selected,
659 duplicate_anchor_ids: vec![],
660 slug_backfilled: false,
661 project_created: false,
662 orphaned_project_id: None,
663 orphaned_note_count: 0,
664 });
665 let value = runtime_error_value(error, DomainDisposition::Unknown);
666 assert_eq!(value["kind"], "remote_fetch_error");
667 assert_eq!(value["remote"], "https://example.com/repo");
668 assert_eq!(value["message"], "cache repair failed");
669 assert_eq!(value["domain_disposition"], "unknown");
670 assert_eq!(
671 value["details"]["resolution"]["project_id"],
672 selected.to_string()
673 );
674 assert_eq!(value["details"]["resolution"]["project_created"], false);
675 }
676
677 #[test]
678 fn named_refusals_keep_their_override_without_classifying_arbitrary_conflicts() {
679 for (reason, expected) in [
680 ("seq_conflict", "not_committed"),
681 ("fence_conflict", "not_committed"),
682 ("arbitrary_conflict", "unknown"),
683 ] {
684 let source = KhiveError::conflict("same rendered message")
685 .with_details(Details::new([("reason", reason), ("extra", "retained")]));
686 let value = runtime_error_value(source.into(), DomainDisposition::Unknown);
687 assert_eq!(value["domain_disposition"], expected);
688 assert_eq!(value["details"]["extra"], "retained");
689 }
690 }
691
692 #[test]
693 fn idempotency_key_conflict_projects_not_committed() {
694 let source = KhiveError::conflict("different content under an existing key").with_details(
695 Details::new([
696 ("reason", "idempotency_key_conflict"),
697 ("key", "operation-1"),
698 ("existing_id", "holder-1"),
699 ]),
700 );
701 let value = runtime_error_value(source.into(), DomainDisposition::Unknown);
702 assert_eq!(value["kind"], "conflict");
703 assert_eq!(value["details"]["reason"], "idempotency_key_conflict");
704 assert_eq!(
705 value["domain_disposition"], "not_committed",
706 "keyed refusal must project not_committed"
707 );
708 }
709
710 #[test]
711 fn shared_projection_retains_obligation_result_and_denial_receipt() {
712 let domain_result = json!({"rows": [{"id": "recorded", "extra": [null, true, 17]}]});
713 let failure = Box::new(AuditObligationFailure::new(
714 "stream.append",
715 crate::audit_batch::AuditTerminalReason::StoreFailure,
716 ));
717 let expected = json!({
718 "kind": "obligation", "code": failure.wire_code(),
719 "message": failure.to_string(), "domain_result": domain_result,
720 "domain_disposition": "unknown",
721 });
722 let projected = runtime_error_value(
723 RuntimeError::AuditObligation {
724 failure,
725 domain_result,
726 },
727 DomainDisposition::Unknown,
728 );
729 assert_eq!(projected, expected);
730 assert_eq!(
731 serde_json::to_vec(&projected).unwrap(),
732 serde_json::to_vec(&expected).unwrap()
733 );
734
735 let event_id = uuid::Uuid::from_u128(17);
736 let denied = RuntimeError::PermissionDenied {
737 verb: "stream.append".into(),
738 reason: "policy".into(),
739 receipt: Box::new(DenialReceipt {
740 audit_event_id: Some(event_id),
741 audit_outcome: DenialAuditOutcome::Committed,
742 }),
743 };
744 let expected = json!({
745 "kind": "runtime_error", "code": "permission_denied", "message": denied.to_string(),
746 "verb": "stream.append", "reason": "policy", "audit_event_id": event_id.to_string(),
747 "audit_outcome": "committed", "domain_disposition": "not_committed",
748 });
749 assert_eq!(
750 runtime_error_value(denied, DomainDisposition::NotCommitted),
751 expected
752 );
753 }
754
755 #[test]
760 fn a_refusal_receipt_id_is_a_field_and_not_only_a_substring_of_the_message() {
761 let error = RuntimeError::RefusedWithReceipt(Box::new(crate::error::ReceiptRefusal {
762 code: "exec_refused",
763 message: "exec.run refused: tool not registered (receipt_id=r-1)".into(),
764 receipt_id: "r-1".into(),
765 reason: "tool not registered".into(),
766 detail: json!({ "effective_max_output_bytes": 65536, "receipt_id": "shadow" }),
767 }));
768
769 let value = runtime_error_value(error, DomainDisposition::Unknown);
772
773 assert_eq!(
774 value["receipt_id"], "r-1",
775 "a detail member cannot shadow a contract field"
776 );
777 assert_eq!(value["code"], "exec_refused");
778 assert_eq!(value["kind"], "runtime_error");
779 assert_eq!(value["domain_disposition"], "not_committed");
780 assert_eq!(value["reason"], "tool not registered");
781 assert_eq!(value["effective_max_output_bytes"], 65536);
782 assert_eq!(
783 value["message"], "exec.run refused: tool not registered (receipt_id=r-1)",
784 "the existing wording is kept so a reader that parses it today keeps working"
785 );
786 }
787
788 #[test]
792 fn an_ordinary_invalid_input_has_no_receipt_id() {
793 let value = runtime_error_value(
794 RuntimeError::InvalidInput("exec.run refused: tool not registered".into()),
795 DomainDisposition::Unknown,
796 );
797
798 assert!(
799 value.get("receipt_id").is_none(),
800 "an error with no durable receipt must not name one: {value}"
801 );
802 assert_eq!(value["domain_disposition"], "unknown");
803 }
804
805 #[test]
806 fn terminal_full_projection_requires_consistent_native_codes() {
807 for codes in [
808 None,
809 Some((5, 5)),
810 Some((13, 5)),
811 Some((5, 13)),
812 Some((13, -243)),
813 ] {
814 let error = StorageError::WriterTaskTerminated {
815 request_state: khive_storage::WriterTaskRequestState::SideEffectsUnknown,
816 sqlite_full_codes: codes,
817 };
818 let value = runtime_error_value(
819 crate::RuntimeError::Storage(error),
820 crate::DomainDisposition::Unknown,
821 );
822 assert_eq!(value["stage"], "writer_task_terminated");
823 assert!(value.get("sqlite_primary_code").is_none());
824 assert_eq!(value["retryable"], false);
825 }
826 let extended = rusqlite::ffi::SQLITE_FULL | (3 << 8);
827 let error = StorageError::WriterTaskTerminated {
828 request_state: khive_storage::WriterTaskRequestState::SideEffectsUnknown,
829 sqlite_full_codes: Some((rusqlite::ffi::SQLITE_FULL, extended)),
830 };
831 let value = runtime_error_value(
832 crate::RuntimeError::Storage(error),
833 crate::DomainDisposition::Unknown,
834 );
835 assert_eq!(value["stage"], "sqlite_disk_full");
836 assert_eq!(value["code"], "sqlite_disk_full");
837 assert_eq!(value["sqlite_primary_code"], rusqlite::ffi::SQLITE_FULL);
838 assert_eq!(value["sqlite_extended_code"], extended);
839 assert_eq!(value["request_state"], "side_effects_unknown");
840 assert_eq!(value["task_terminated"], true);
841 assert_eq!(value["retryable"], false);
842 assert_eq!(value["domain_disposition"], "unknown");
843 }
844
845 #[test]
846 fn projection_keeps_typed_writer_state_and_capability_spelling() {
847 let error = RuntimeError::Storage(khive_storage::StorageError::writer_task_terminated(
848 khive_storage::WriterTaskRequestState::SideEffectsUnknown,
849 ));
850 let value = runtime_error_value(error, DomainDisposition::Unknown);
851 assert_eq!(value["request_state"], "side_effects_unknown");
852 assert_eq!(value["task_terminated"], true);
853 assert_eq!(value["retryable"], false);
854 assert_eq!(value["domain_disposition"], "unknown");
855
856 let error = RuntimeError::Storage(khive_storage::StorageError::driver(
857 StorageCapability::Sql,
858 "append checkout",
859 khive_db::SqliteError::WriterPoolCheckoutTimeout {
860 timeout: std::time::Duration::from_millis(17),
861 },
862 ));
863 let value = runtime_error_value(error, DomainDisposition::Unknown);
864 assert_eq!(value["capability"], "sql");
865 assert_eq!(value["timeout_ms"], 17);
866 assert_eq!(value["operation"], "append checkout");
867 assert_eq!(value["domain_disposition"], "unknown");
868 }
869
870 #[test]
871 fn refusal_events_project_mixed_recording_without_changing_the_original_secret_error() {
872 use crate::{RefusalEventRecording, RefusalRecordingErrorClass};
873 let source = || {
874 RuntimeError::SecretDetected(crate::secret_gate::SecretMatch {
875 detector: "fixture",
876 trigger: None,
877 masked: "PRIVATE-MASKED-EXCERPT".into(),
878 location: Some("atoms[1].properties[0].value".into()),
879 })
880 };
881 let subject_a = uuid::Uuid::from_u128(11);
882 let subject_b = uuid::Uuid::from_u128(12);
883 let event = uuid::Uuid::from_u128(13);
884 let expected = runtime_error_value(source(), DomainDisposition::Unknown);
885 let mut actual = runtime_error_value(
886 source().with_refusal_events(vec![
887 RefusalEventRecording::Recorded {
888 item_index: 1,
889 subject: subject_a,
890 event_id: event,
891 },
892 RefusalEventRecording::Failed {
893 item_index: 4,
894 subject: subject_b,
895 error_class: RefusalRecordingErrorClass::EventAppendFailed,
896 },
897 ]),
898 DomainDisposition::Unknown,
899 );
900 assert_eq!(actual["refusal_recorded"], false);
901 assert_eq!(
902 actual["refusal_events"],
903 json!([
904 {"item_index":1, "subject":subject_a, "event_id":event},
905 {"item_index":4, "subject":subject_b, "error_class":"event_append_failed"},
906 ])
907 );
908 assert!(!actual.to_string().contains("PRIVATE-MASKED-EXCERPT"));
909 assert!(actual.get("receipt_id").is_none());
910 let object = actual.as_object_mut().unwrap();
911 object.remove("refusal_recorded");
912 object.remove("refusal_events");
913 assert_eq!(actual, expected);
914 }
915
916 #[test]
917 fn refusal_events_keep_typed_details_and_named_disposition_with_all_recorded() {
918 use crate::RefusalEventRecording;
919 let source = || {
920 RuntimeError::Khive(
921 KhiveError::conflict("fixed reason")
922 .with_code(ErrorCode::new(ErrorDomain::Db, 71))
923 .with_details(Details::new([
924 ("reason", "seq_conflict"),
925 ("extra", "unchanged"),
926 ])),
927 )
928 };
929 let mut expected = runtime_error_value(source(), DomainDisposition::Unknown);
930 expected["refusal_recorded"] = json!(true);
931 expected["refusal_events"] = json!([
932 {"item_index":0, "subject":uuid::Uuid::from_u128(1), "event_id":uuid::Uuid::from_u128(2)},
933 {"item_index":3, "subject":uuid::Uuid::from_u128(3), "event_id":uuid::Uuid::from_u128(4)},
934 ]);
935 let actual = runtime_error_value(
936 source().with_refusal_events(vec![
937 RefusalEventRecording::Recorded {
938 item_index: 0,
939 subject: uuid::Uuid::from_u128(1),
940 event_id: uuid::Uuid::from_u128(2),
941 },
942 RefusalEventRecording::Recorded {
943 item_index: 3,
944 subject: uuid::Uuid::from_u128(3),
945 event_id: uuid::Uuid::from_u128(4),
946 },
947 ]),
948 DomainDisposition::Unknown,
949 );
950 assert_eq!(actual, expected);
951 assert_eq!(actual["domain_disposition"], "not_committed");
952 }
953
954 #[test]
955 fn refusal_events_do_not_invent_receipts_for_failed_or_absent_recordings() {
956 use crate::{RefusalEventRecording, RefusalRecordingErrorClass};
957 for class in [
958 RefusalRecordingErrorClass::EventStoreUnavailable,
959 RefusalRecordingErrorClass::EventAppendFailed,
960 ] {
961 let error = RuntimeError::InvalidInput("validation refused".into())
962 .with_refusal_events(vec![RefusalEventRecording::Failed {
963 item_index: 0,
964 subject: uuid::Uuid::from_u128(1),
965 error_class: class,
966 }]);
967 let actual = runtime_error_value(error, DomainDisposition::Unknown);
968 assert_eq!(actual["refusal_recorded"], false);
969 assert_eq!(actual["refusal_events"][0]["error_class"], class.as_str());
970 assert!(actual["refusal_events"][0].get("event_id").is_none());
971 assert!(actual.get("receipt_id").is_none());
972 assert_eq!(actual["message"], "invalid input: validation refused");
973 }
974 let actual = runtime_error_value(
975 RuntimeError::InvalidInput("validation refused".into()).with_refusal_events(vec![]),
976 DomainDisposition::Unknown,
977 );
978 assert!(actual.get("refusal_recorded").is_none());
979 assert!(actual.get("refusal_events").is_none());
980 }
981}