Skip to main content

khive_runtime/
error_projection.rs

1//! Canonical structured projection of typed runtime failures.
2//!
3//! Transport nesting limits and removal of a nested operation's domain result
4//! belong to the transport boundary, not to this lossless shared projection.
5
6use khive_storage::{StorageCapability, StorageError};
7use serde_json::{json, Value};
8
9use crate::{DomainDisposition, RuntimeError};
10
11/// Project the original typed error, preserving every structured source field.
12///
13/// `disposition` describes the enclosing operation. Named runtime refusals keep
14/// their existing wire override. This function moves an obligation's result
15/// without cloning or recursively serializing it; callers apply their own depth
16/// limits before any recursive operation on the returned value.
17pub fn runtime_error_value(error: RuntimeError, disposition: DomainDisposition) -> Value {
18    // These named outcomes carry their own domain proof. Do not infer general
19    // write disposition from a conflict or unavailable variant.
20    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        // An immutable-stream policy refusal is a definite no-write. It is matched by the
24        // shared predicate rather than by a reason string so the two consumers of that
25        // predicate and this projection cannot drift into disagreeing about it.
26        error if error.is_stream_policy_refusal() => Some("not_committed"),
27        // A refusal that wrote a receipt is a definite no-write of the thing it
28        // refused: the receipt exists precisely to record that nothing ran.
29        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            // These keyed conflicts certify that this request wrote no domain record.
36            (
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            // ADR-174 A1.1: a stream member refusal carries
42            // `domain_disposition: not_committed` wherever it surfaces. In
43            // per-member mode it is the member's own value and the runtime
44            // writes the field itself; in atomic mode the refusal is raised
45            // as the call's error, where without these rows the boundary's
46            // `unknown` would stand and the caller could not tell a batch
47            // that wrote nothing from one whose outcome is unestablished.
48            (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            // An exact keyed replay whose holder was hard-deleted before its
60            // provenance read: the attempt's unit rolled back on the key claim.
61            (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    // The refusal text is the same Display string every consumer already
72    // matches on; the receipt fields ride beside it.
73    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            // Project the source first so named dispositions, code/details,
93            // detector location and refusal text retain their original meaning.
94            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            // Surface evidence first, contract fields last: a detail member that
135            // happens to share a name with a contract field cannot shadow it.
136            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    /// A refusal that wrote a durable receipt must name it as its own field.
703    /// The consumer contract is `error.receipt_id`, not a substring of the
704    /// sentence: the sentence is free to be reworded and a regular expression
705    /// over it breaks without failing anything.
706    #[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        // Disposition is deliberately the wrong one on the way in: a receipt-bearing
717        // refusal establishes its own no-write, so the boundary's guess is overridden.
718        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    /// The control for the arm above: a refusal carrying no receipt is a
736    /// different error entirely and must not grow a `receipt_id`. Without this,
737    /// an implementation that stamped the field unconditionally would pass.
738    #[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}