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::WriterTaskTerminated {
337 sqlite_full_codes: Some((primary_code, extended_code)),
338 ..
339 } if *primary_code == rusqlite::ffi::SQLITE_FULL
340 && *extended_code >= 0
341 && (*extended_code & 0xff) == *primary_code =>
342 {
343 Some(SqliteCapacityFailure::NativeFull {
344 primary_code: *primary_code,
345 extended_code: *extended_code,
346 })
347 }
348 StorageError::WriterTaskRequestFailed { source, .. } => {
349 sqlite_capacity_failure_from_storage(source)
350 }
351 StorageError::Driver { source, .. } => {
352 if let Some(sqlite) = source.downcast_ref::<khive_db::SqliteError>() {
353 sqlite_capacity_failure_from_sqlite(sqlite)
354 } else {
355 native_sqlite_full(source.as_ref())
356 }
357 }
358 _ => None,
359 }
360}
361
362fn sqlite_capacity_failure_from_sqlite(
363 error: &khive_db::SqliteError,
364) -> Option<SqliteCapacityFailure> {
365 match error {
366 khive_db::SqliteError::CapacityFloor {
367 volume,
368 available_bytes,
369 floor_bytes,
370 required_headroom_bytes,
371 } => Some(SqliteCapacityFailure::Refused {
372 volume: volume.clone(),
373 available_bytes: *available_bytes,
374 reserve_bytes: *floor_bytes,
375 required_headroom_bytes: *required_headroom_bytes,
376 }),
377 khive_db::SqliteError::CapacityUnavailable { phase, .. } => {
378 Some(SqliteCapacityFailure::Unavailable { phase: *phase })
379 }
380 khive_db::SqliteError::Rusqlite(sqlite) => native_sqlite_full(sqlite),
381 _ => None,
382 }
383}
384
385fn native_sqlite_full(error: &(dyn std::error::Error + 'static)) -> Option<SqliteCapacityFailure> {
386 let mut source = Some(error);
387 while let Some(current) = source {
388 if let Some(rusqlite::Error::SqliteFailure(code, _)) =
389 current.downcast_ref::<rusqlite::Error>()
390 {
391 if code.code == rusqlite::ErrorCode::DiskFull {
392 return Some(SqliteCapacityFailure::NativeFull {
393 primary_code: code.extended_code & 0xff,
394 extended_code: code.extended_code,
395 });
396 }
397 }
398 source = current.source();
399 }
400 None
401}
402
403fn storage_capability_wire_name(capability: StorageCapability) -> &'static str {
404 match capability {
405 StorageCapability::Sql => "sql",
406 StorageCapability::Notes => "notes",
407 StorageCapability::Entities => "entities",
408 StorageCapability::Graph => "graph",
409 StorageCapability::Events => "events",
410 StorageCapability::Vectors => "vectors",
411 StorageCapability::Sparse => "sparse",
412 StorageCapability::Text => "text",
413 StorageCapability::Blob => "blob",
414 StorageCapability::Attachments => "attachments",
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use super::runtime_error_value;
421 use crate::{
422 AuditObligationFailure, DenialAuditOutcome, DenialReceipt, DomainDisposition, RuntimeError,
423 };
424 use khive_storage::{CapacityUnavailablePhase, StorageCapability, StorageError};
425 use khive_types::{Details, ErrorCode, ErrorDomain, KhiveError};
426 use serde_json::json;
427
428 #[test]
429 fn capacity_phase_projection_preserves_writer_request_settlement() {
430 for phase in [
431 CapacityUnavailablePhase::Identity,
432 CapacityUnavailablePhase::Lock,
433 CapacityUnavailablePhase::Probe,
434 ] {
435 let value = runtime_error_value(
436 RuntimeError::Storage(StorageError::WriterTaskRequestFailed {
437 request_state: khive_storage::WriterTaskRequestState::TransactionRolledBack,
438 source: Box::new(StorageError::CapacityUnavailable {
439 capability: StorageCapability::Sql,
440 phase,
441 message: "unavailable".into(),
442 }),
443 }),
444 DomainDisposition::Unknown,
445 );
446 assert_eq!(value["code"], "sqlite_capacity_unavailable");
447 assert_eq!(value["phase"], phase.as_str());
448 assert_eq!(value["request_state"], "transaction_rolled_back");
449 assert_eq!(value["task_terminated"], false);
450 assert_eq!(value["retryable"], false);
451 }
452 }
453
454 #[test]
455 fn sqlite_capacity_stages_preserve_typed_evidence_and_do_not_retry() {
456 let refused = runtime_error_value(
457 RuntimeError::Storage(StorageError::CapacityFloor {
458 capability: StorageCapability::Sql,
459 volume: "/volume".to_string(),
460 available_bytes: 99,
461 floor_bytes: 100,
462 required_headroom_bytes: 12,
463 }),
464 DomainDisposition::Unknown,
465 );
466 assert_eq!(refused["stage"], "sqlite_capacity_refused");
467 assert_eq!(refused["available_bytes"], 99);
468 assert_eq!(refused["reserve_bytes"], 100);
469 assert_eq!(refused["required_headroom_bytes"], 12);
470 assert_eq!(refused["retryable"], false);
471
472 let unavailable = runtime_error_value(
473 RuntimeError::Storage(StorageError::CapacityUnavailable {
474 capability: StorageCapability::Sql,
475 phase: CapacityUnavailablePhase::Lock,
476 message: "bounded lease timed out".to_string(),
477 }),
478 DomainDisposition::Unknown,
479 );
480 assert_eq!(unavailable["stage"], "sqlite_capacity_unavailable");
481 assert_eq!(unavailable["phase"], "lock");
482 assert_eq!(unavailable["retryable"], false);
483
484 let full = runtime_error_value(
485 RuntimeError::Storage(StorageError::driver(
486 StorageCapability::Sql,
487 "write",
488 rusqlite::Error::SqliteFailure(
489 rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_FULL),
490 None,
491 ),
492 )),
493 DomainDisposition::Unknown,
494 );
495 assert_eq!(full["stage"], "sqlite_disk_full");
496 assert_eq!(full["sqlite_primary_code"], rusqlite::ffi::SQLITE_FULL);
497 assert_eq!(full["retryable"], false);
498 }
499
500 #[test]
501 fn unsettled_sqlite_writer_converts_to_side_effects_unknown() {
502 for error in [
503 khive_db::SqliteError::InheritedWriterTransaction,
504 khive_db::SqliteError::WriterSettlementUnknown,
505 ] {
506 let value = runtime_error_value(RuntimeError::from(error), DomainDisposition::Unknown);
507 assert_eq!(value["request_state"], "side_effects_unknown");
508 assert_eq!(value["task_terminated"], true);
509 assert_eq!(value["retryable"], false);
510 }
511 }
512
513 #[test]
514 fn poisoned_writer_refusal_converts_to_not_started() {
515 let value = runtime_error_value(
516 RuntimeError::from(khive_db::SqliteError::WriterPoisoned),
517 DomainDisposition::Unknown,
518 );
519 assert_eq!(value["request_state"], "not_started");
520 assert_eq!(value["task_terminated"], true);
521 assert_eq!(value["retryable"], false);
522 }
523
524 #[test]
525 fn missing_subject_projection_is_typed_without_matching_error_text() {
526 for disposition in [DomainDisposition::Unknown, DomainDisposition::NotCommitted] {
527 let error = RuntimeError::NotFound("subject missing".into());
528 let message = error.to_string();
529 let value = runtime_error_value(error, disposition);
530 assert_eq!(value["kind"], "not_found");
531 assert_eq!(value.get("code"), Some(&serde_json::Value::Null));
532 assert_eq!(value.get("details"), Some(&serde_json::Value::Null));
533 assert_eq!(value["message"], message);
534 assert_eq!(value["domain_disposition"], disposition.as_str());
535 }
536 for error in [
537 RuntimeError::Internal("not found: damaged store index".into()),
538 RuntimeError::InvalidInput("not found: malformed reference".into()),
539 ] {
540 let value = runtime_error_value(error, DomainDisposition::Unknown);
541 assert_eq!(value["kind"], "runtime_error");
542 }
543 }
544
545 #[test]
546 fn projection_preserves_structured_source_fields_and_serialized_bytes() {
547 let source = KhiveError::unavailable("append reply missing")
548 .with_code(ErrorCode::new(ErrorDomain::Db, 71))
549 .with_details(Details::new([
550 ("operation", "append"),
551 ("driver_phase", "awaiting_reply"),
552 ("retry_hint", "do_not_repeat"),
553 ("extra", "preserve me\nincluding escapes"),
554 ]));
555 let mut expected = serde_json::to_value(&source).unwrap();
556 expected["domain_disposition"] = json!("unknown");
557 let expected_bytes = serde_json::to_vec(&expected).unwrap();
558 let actual = runtime_error_value(source.into(), DomainDisposition::Unknown);
559 assert_eq!(actual, expected);
560 assert_eq!(serde_json::to_vec(&actual).unwrap(), expected_bytes);
561 }
562
563 #[test]
564 fn embedding_truncation_after_write_projects_committed_disposition() {
565 let error = KhiveError::internal("embedding input truncated").with_details(Details::new([
566 ("reason", "embedding_input_truncated"),
567 ("record_id", "00000000-0000-0000-0000-000000000001"),
568 ("committed", "true"),
569 ("retryable", "false"),
570 ]));
571 let value = runtime_error_value(error.into(), DomainDisposition::Unknown);
572 assert_eq!(value["domain_disposition"], "committed");
573 assert_eq!(
574 value["details"]["record_id"],
575 "00000000-0000-0000-0000-000000000001"
576 );
577 }
578
579 #[test]
580 fn resolution_wrapper_adds_details_without_changing_source_classification() {
581 let source = RuntimeError::Khive(KhiveError::conflict("source conflict").with_details(
582 Details::new([("reason", "seq_conflict"), ("extra", "retained")]),
583 ));
584 let selected = uuid::Uuid::from_u128(1);
585 let duplicate = uuid::Uuid::from_u128(2);
586 let error = source.with_resolution(crate::error::ResolutionFacts {
587 project_id: selected,
588 duplicate_anchor_ids: vec![duplicate],
589 slug_backfilled: true,
590 project_created: false,
591 orphaned_project_id: None,
592 orphaned_note_count: 0,
593 });
594 assert!(matches!(error.refusal_source(), RuntimeError::Khive(_)));
595 let value = runtime_error_value(error, DomainDisposition::Unknown);
596 assert_eq!(value["domain_disposition"], "not_committed");
597 assert_eq!(value["details"]["reason"], "seq_conflict");
598 assert_eq!(value["details"]["extra"], "retained");
599 assert_eq!(
600 value["details"]["resolution"]["project_id"],
601 selected.to_string()
602 );
603 assert_eq!(
604 value["details"]["resolution"]["duplicate_anchor_ids"],
605 json!([duplicate])
606 );
607 }
608
609 #[test]
610 fn resolution_wrapper_projects_remote_fetch_type_and_anchor_facts() {
611 let selected = uuid::Uuid::from_u128(3);
612 let error = RuntimeError::RemoteFetchError {
613 remote: "https://example.com/repo".into(),
614 message: "cache repair failed".into(),
615 }
616 .with_resolution(crate::error::ResolutionFacts {
617 project_id: selected,
618 duplicate_anchor_ids: vec![],
619 slug_backfilled: false,
620 project_created: false,
621 orphaned_project_id: None,
622 orphaned_note_count: 0,
623 });
624 let value = runtime_error_value(error, DomainDisposition::Unknown);
625 assert_eq!(value["kind"], "remote_fetch_error");
626 assert_eq!(value["remote"], "https://example.com/repo");
627 assert_eq!(value["message"], "cache repair failed");
628 assert_eq!(value["domain_disposition"], "unknown");
629 assert_eq!(
630 value["details"]["resolution"]["project_id"],
631 selected.to_string()
632 );
633 assert_eq!(value["details"]["resolution"]["project_created"], false);
634 }
635
636 #[test]
637 fn named_refusals_keep_their_override_without_classifying_arbitrary_conflicts() {
638 for (reason, expected) in [
639 ("seq_conflict", "not_committed"),
640 ("fence_conflict", "not_committed"),
641 ("arbitrary_conflict", "unknown"),
642 ] {
643 let source = KhiveError::conflict("same rendered message")
644 .with_details(Details::new([("reason", reason), ("extra", "retained")]));
645 let value = runtime_error_value(source.into(), DomainDisposition::Unknown);
646 assert_eq!(value["domain_disposition"], expected);
647 assert_eq!(value["details"]["extra"], "retained");
648 }
649 }
650
651 #[test]
652 fn idempotency_key_conflict_projects_not_committed() {
653 let source = KhiveError::conflict("different content under an existing key").with_details(
654 Details::new([
655 ("reason", "idempotency_key_conflict"),
656 ("key", "operation-1"),
657 ("existing_id", "holder-1"),
658 ]),
659 );
660 let value = runtime_error_value(source.into(), DomainDisposition::Unknown);
661 assert_eq!(value["kind"], "conflict");
662 assert_eq!(value["details"]["reason"], "idempotency_key_conflict");
663 assert_eq!(
664 value["domain_disposition"], "not_committed",
665 "keyed refusal must project not_committed"
666 );
667 }
668
669 #[test]
670 fn shared_projection_retains_obligation_result_and_denial_receipt() {
671 let domain_result = json!({"rows": [{"id": "recorded", "extra": [null, true, 17]}]});
672 let failure = Box::new(AuditObligationFailure::new(
673 "stream.append",
674 crate::audit_batch::AuditTerminalReason::StoreFailure,
675 ));
676 let expected = json!({
677 "kind": "obligation", "code": failure.wire_code(),
678 "message": failure.to_string(), "domain_result": domain_result,
679 "domain_disposition": "unknown",
680 });
681 let projected = runtime_error_value(
682 RuntimeError::AuditObligation {
683 failure,
684 domain_result,
685 },
686 DomainDisposition::Unknown,
687 );
688 assert_eq!(projected, expected);
689 assert_eq!(
690 serde_json::to_vec(&projected).unwrap(),
691 serde_json::to_vec(&expected).unwrap()
692 );
693
694 let event_id = uuid::Uuid::from_u128(17);
695 let denied = RuntimeError::PermissionDenied {
696 verb: "stream.append".into(),
697 reason: "policy".into(),
698 receipt: Box::new(DenialReceipt {
699 audit_event_id: Some(event_id),
700 audit_outcome: DenialAuditOutcome::Committed,
701 }),
702 };
703 let expected = json!({
704 "kind": "runtime_error", "code": "permission_denied", "message": denied.to_string(),
705 "verb": "stream.append", "reason": "policy", "audit_event_id": event_id.to_string(),
706 "audit_outcome": "committed", "domain_disposition": "not_committed",
707 });
708 assert_eq!(
709 runtime_error_value(denied, DomainDisposition::NotCommitted),
710 expected
711 );
712 }
713
714 #[test]
719 fn a_refusal_receipt_id_is_a_field_and_not_only_a_substring_of_the_message() {
720 let error = RuntimeError::RefusedWithReceipt(Box::new(crate::error::ReceiptRefusal {
721 code: "exec_refused",
722 message: "exec.run refused: tool not registered (receipt_id=r-1)".into(),
723 receipt_id: "r-1".into(),
724 reason: "tool not registered".into(),
725 detail: json!({ "effective_max_output_bytes": 65536, "receipt_id": "shadow" }),
726 }));
727
728 let value = runtime_error_value(error, DomainDisposition::Unknown);
731
732 assert_eq!(
733 value["receipt_id"], "r-1",
734 "a detail member cannot shadow a contract field"
735 );
736 assert_eq!(value["code"], "exec_refused");
737 assert_eq!(value["kind"], "runtime_error");
738 assert_eq!(value["domain_disposition"], "not_committed");
739 assert_eq!(value["reason"], "tool not registered");
740 assert_eq!(value["effective_max_output_bytes"], 65536);
741 assert_eq!(
742 value["message"], "exec.run refused: tool not registered (receipt_id=r-1)",
743 "the existing wording is kept so a reader that parses it today keeps working"
744 );
745 }
746
747 #[test]
751 fn an_ordinary_invalid_input_has_no_receipt_id() {
752 let value = runtime_error_value(
753 RuntimeError::InvalidInput("exec.run refused: tool not registered".into()),
754 DomainDisposition::Unknown,
755 );
756
757 assert!(
758 value.get("receipt_id").is_none(),
759 "an error with no durable receipt must not name one: {value}"
760 );
761 assert_eq!(value["domain_disposition"], "unknown");
762 }
763
764 #[test]
765 fn terminal_full_projection_requires_consistent_native_codes() {
766 for codes in [
767 None,
768 Some((5, 5)),
769 Some((13, 5)),
770 Some((5, 13)),
771 Some((13, -243)),
772 ] {
773 let error = StorageError::WriterTaskTerminated {
774 request_state: khive_storage::WriterTaskRequestState::SideEffectsUnknown,
775 sqlite_full_codes: codes,
776 };
777 let value = runtime_error_value(
778 crate::RuntimeError::Storage(error),
779 crate::DomainDisposition::Unknown,
780 );
781 assert_eq!(value["stage"], "writer_task_terminated");
782 assert!(value.get("sqlite_primary_code").is_none());
783 assert_eq!(value["retryable"], false);
784 }
785 let extended = rusqlite::ffi::SQLITE_FULL | (3 << 8);
786 let error = StorageError::WriterTaskTerminated {
787 request_state: khive_storage::WriterTaskRequestState::SideEffectsUnknown,
788 sqlite_full_codes: Some((rusqlite::ffi::SQLITE_FULL, extended)),
789 };
790 let value = runtime_error_value(
791 crate::RuntimeError::Storage(error),
792 crate::DomainDisposition::Unknown,
793 );
794 assert_eq!(value["stage"], "sqlite_disk_full");
795 assert_eq!(value["code"], "sqlite_disk_full");
796 assert_eq!(value["sqlite_primary_code"], rusqlite::ffi::SQLITE_FULL);
797 assert_eq!(value["sqlite_extended_code"], extended);
798 assert_eq!(value["request_state"], "side_effects_unknown");
799 assert_eq!(value["task_terminated"], true);
800 assert_eq!(value["retryable"], false);
801 assert_eq!(value["domain_disposition"], "unknown");
802 }
803
804 #[test]
805 fn projection_keeps_typed_writer_state_and_capability_spelling() {
806 let error = RuntimeError::Storage(khive_storage::StorageError::writer_task_terminated(
807 khive_storage::WriterTaskRequestState::SideEffectsUnknown,
808 ));
809 let value = runtime_error_value(error, DomainDisposition::Unknown);
810 assert_eq!(value["request_state"], "side_effects_unknown");
811 assert_eq!(value["task_terminated"], true);
812 assert_eq!(value["retryable"], false);
813 assert_eq!(value["domain_disposition"], "unknown");
814
815 let error = RuntimeError::Storage(khive_storage::StorageError::driver(
816 StorageCapability::Sql,
817 "append checkout",
818 khive_db::SqliteError::WriterPoolCheckoutTimeout {
819 timeout: std::time::Duration::from_millis(17),
820 },
821 ));
822 let value = runtime_error_value(error, DomainDisposition::Unknown);
823 assert_eq!(value["capability"], "sql");
824 assert_eq!(value["timeout_ms"], 17);
825 assert_eq!(value["operation"], "append checkout");
826 assert_eq!(value["domain_disposition"], "unknown");
827 }
828
829 #[test]
830 fn refusal_events_project_mixed_recording_without_changing_the_original_secret_error() {
831 use crate::{RefusalEventRecording, RefusalRecordingErrorClass};
832 let source = || {
833 RuntimeError::SecretDetected(crate::secret_gate::SecretMatch {
834 detector: "fixture",
835 trigger: None,
836 masked: "PRIVATE-MASKED-EXCERPT".into(),
837 location: Some("atoms[1].properties[0].value".into()),
838 })
839 };
840 let subject_a = uuid::Uuid::from_u128(11);
841 let subject_b = uuid::Uuid::from_u128(12);
842 let event = uuid::Uuid::from_u128(13);
843 let expected = runtime_error_value(source(), DomainDisposition::Unknown);
844 let mut actual = runtime_error_value(
845 source().with_refusal_events(vec![
846 RefusalEventRecording::Recorded {
847 item_index: 1,
848 subject: subject_a,
849 event_id: event,
850 },
851 RefusalEventRecording::Failed {
852 item_index: 4,
853 subject: subject_b,
854 error_class: RefusalRecordingErrorClass::EventAppendFailed,
855 },
856 ]),
857 DomainDisposition::Unknown,
858 );
859 assert_eq!(actual["refusal_recorded"], false);
860 assert_eq!(
861 actual["refusal_events"],
862 json!([
863 {"item_index":1, "subject":subject_a, "event_id":event},
864 {"item_index":4, "subject":subject_b, "error_class":"event_append_failed"},
865 ])
866 );
867 assert!(!actual.to_string().contains("PRIVATE-MASKED-EXCERPT"));
868 assert!(actual.get("receipt_id").is_none());
869 let object = actual.as_object_mut().unwrap();
870 object.remove("refusal_recorded");
871 object.remove("refusal_events");
872 assert_eq!(actual, expected);
873 }
874
875 #[test]
876 fn refusal_events_keep_typed_details_and_named_disposition_with_all_recorded() {
877 use crate::RefusalEventRecording;
878 let source = || {
879 RuntimeError::Khive(
880 KhiveError::conflict("fixed reason")
881 .with_code(ErrorCode::new(ErrorDomain::Db, 71))
882 .with_details(Details::new([
883 ("reason", "seq_conflict"),
884 ("extra", "unchanged"),
885 ])),
886 )
887 };
888 let mut expected = runtime_error_value(source(), DomainDisposition::Unknown);
889 expected["refusal_recorded"] = json!(true);
890 expected["refusal_events"] = json!([
891 {"item_index":0, "subject":uuid::Uuid::from_u128(1), "event_id":uuid::Uuid::from_u128(2)},
892 {"item_index":3, "subject":uuid::Uuid::from_u128(3), "event_id":uuid::Uuid::from_u128(4)},
893 ]);
894 let actual = runtime_error_value(
895 source().with_refusal_events(vec![
896 RefusalEventRecording::Recorded {
897 item_index: 0,
898 subject: uuid::Uuid::from_u128(1),
899 event_id: uuid::Uuid::from_u128(2),
900 },
901 RefusalEventRecording::Recorded {
902 item_index: 3,
903 subject: uuid::Uuid::from_u128(3),
904 event_id: uuid::Uuid::from_u128(4),
905 },
906 ]),
907 DomainDisposition::Unknown,
908 );
909 assert_eq!(actual, expected);
910 assert_eq!(actual["domain_disposition"], "not_committed");
911 }
912
913 #[test]
914 fn refusal_events_do_not_invent_receipts_for_failed_or_absent_recordings() {
915 use crate::{RefusalEventRecording, RefusalRecordingErrorClass};
916 for class in [
917 RefusalRecordingErrorClass::EventStoreUnavailable,
918 RefusalRecordingErrorClass::EventAppendFailed,
919 ] {
920 let error = RuntimeError::InvalidInput("validation refused".into())
921 .with_refusal_events(vec![RefusalEventRecording::Failed {
922 item_index: 0,
923 subject: uuid::Uuid::from_u128(1),
924 error_class: class,
925 }]);
926 let actual = runtime_error_value(error, DomainDisposition::Unknown);
927 assert_eq!(actual["refusal_recorded"], false);
928 assert_eq!(actual["refusal_events"][0]["error_class"], class.as_str());
929 assert!(actual["refusal_events"][0].get("event_id").is_none());
930 assert!(actual.get("receipt_id").is_none());
931 assert_eq!(actual["message"], "invalid input: validation refused");
932 }
933 let actual = runtime_error_value(
934 RuntimeError::InvalidInput("validation refused".into()).with_refusal_events(vec![]),
935 DomainDisposition::Unknown,
936 );
937 assert!(actual.get("refusal_recorded").is_none());
938 assert!(actual.get("refusal_events").is_none());
939 }
940}