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::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    /// A refusal that wrote a durable receipt must name it as its own field.
715    /// The consumer contract is `error.receipt_id`, not a substring of the
716    /// sentence: the sentence is free to be reworded and a regular expression
717    /// over it breaks without failing anything.
718    #[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        // Disposition is deliberately the wrong one on the way in: a receipt-bearing
729        // refusal establishes its own no-write, so the boundary's guess is overridden.
730        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    /// The control for the arm above: a refusal carrying no receipt is a
748    /// different error entirely and must not grow a `receipt_id`. Without this,
749    /// an implementation that stamped the field unconditionally would pass.
750    #[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}