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