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