Skip to main content

meerkat_core/lifecycle/
core_executor.rs

1//! CoreExecutor trait — the interface core exposes to the runtime layer.
2//!
3//! The runtime layer implements this trait (as `AgentCoreExecutor`) to bridge
4//! RunPrimitive into Agent session mutations. The trait lives in core so both
5//! layers can reference it without circular dependencies.
6
7use super::RunId;
8use super::run_primitive::RunPrimitive;
9use super::run_receipt::RunBoundaryReceiptDraft;
10use crate::error::AgentError;
11use crate::lifecycle::run_primitive::TurnRequestContext;
12use crate::service::SessionError;
13use crate::turn_execution_authority::{TurnTerminalCauseKind, TurnTerminalOutcome};
14use crate::types::{RunResult, SessionId};
15use crate::{TurnErrorMetadata, event::AgentEvent, interaction::InteractionId};
16use serde_json::Value;
17use sha2::{Digest, Sha256};
18use std::sync::Arc;
19
20/// Exact fixed-size authority returned by the store after committing one
21/// session boundary.
22///
23/// This is deliberately one exhaustive carrier rather than one optional hook
24/// per persistence profile. Store-backed wrappers must forward the carrier as
25/// a whole, so adding a new authority shape forces every exhaustive forwarding
26/// match to be reviewed at compile time.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum CommittedSessionBoundaryAuthority {
29    WholeBlob {
30        session_id: SessionId,
31        committed_store_revision: u64,
32        committed_blob_sha256: String,
33    },
34    HeadCanonical {
35        session_id: SessionId,
36        committed_head_token: String,
37    },
38    Provisional {
39        session_id: SessionId,
40        committed_store_revision: u64,
41        committed_authority_token: String,
42    },
43}
44
45impl CommittedSessionBoundaryAuthority {
46    #[must_use]
47    pub fn session_id(&self) -> &SessionId {
48        match self {
49            Self::WholeBlob { session_id, .. }
50            | Self::HeadCanonical { session_id, .. }
51            | Self::Provisional { session_id, .. } => session_id,
52        }
53    }
54}
55
56/// Closed classifier for failures observed while applying a run primitive.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum CoreApplyFailureCauseKind {
60    PrimitiveRejected,
61    RuntimeContextApply,
62    RuntimeTurn,
63    HookDenied,
64    HookRuntimeFailure,
65    ExecutorStopped,
66    ExecutorControlFailed,
67    ExecutorInternal,
68    Unknown,
69}
70
71impl CoreApplyFailureCauseKind {
72    pub fn as_str(self) -> &'static str {
73        match self {
74            Self::PrimitiveRejected => "PrimitiveRejected",
75            Self::RuntimeContextApply => "RuntimeContextApply",
76            Self::RuntimeTurn => "RuntimeTurn",
77            Self::HookDenied => "HookDenied",
78            Self::HookRuntimeFailure => "HookRuntimeFailure",
79            Self::ExecutorStopped => "ExecutorStopped",
80            Self::ExecutorControlFailed => "ExecutorControlFailed",
81            Self::ExecutorInternal => "ExecutorInternal",
82            Self::Unknown => "Unknown",
83        }
84    }
85
86    pub fn from_wire_str(value: &str) -> Option<Self> {
87        match value {
88            "PrimitiveRejected" => Some(Self::PrimitiveRejected),
89            "RuntimeContextApply" => Some(Self::RuntimeContextApply),
90            "RuntimeTurn" => Some(Self::RuntimeTurn),
91            "HookDenied" => Some(Self::HookDenied),
92            "HookRuntimeFailure" => Some(Self::HookRuntimeFailure),
93            "ExecutorStopped" => Some(Self::ExecutorStopped),
94            "ExecutorControlFailed" => Some(Self::ExecutorControlFailed),
95            "ExecutorInternal" => Some(Self::ExecutorInternal),
96            "Unknown" => Some(Self::Unknown),
97            _ => None,
98        }
99    }
100}
101
102/// Typed apply-failure cause plus its human-readable display projection.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct CoreApplyFailureCause {
105    pub kind: CoreApplyFailureCauseKind,
106    pub message: String,
107}
108
109impl CoreApplyFailureCause {
110    pub fn new(kind: CoreApplyFailureCauseKind, message: impl Into<String>) -> Self {
111        Self {
112            kind,
113            message: message.into(),
114        }
115    }
116
117    pub fn primitive_rejected(message: impl Into<String>) -> Self {
118        Self::new(CoreApplyFailureCauseKind::PrimitiveRejected, message)
119    }
120
121    pub fn runtime_context_apply(message: impl Into<String>) -> Self {
122        Self::new(CoreApplyFailureCauseKind::RuntimeContextApply, message)
123    }
124
125    pub fn runtime_turn(message: impl Into<String>) -> Self {
126        Self::new(CoreApplyFailureCauseKind::RuntimeTurn, message)
127    }
128
129    pub fn hook_denied(message: impl Into<String>) -> Self {
130        Self::new(CoreApplyFailureCauseKind::HookDenied, message)
131    }
132
133    pub fn hook_runtime_failure(message: impl Into<String>) -> Self {
134        Self::new(CoreApplyFailureCauseKind::HookRuntimeFailure, message)
135    }
136
137    pub fn executor_stopped() -> Self {
138        Self::new(
139            CoreApplyFailureCauseKind::ExecutorStopped,
140            "executor is stopped",
141        )
142    }
143
144    pub fn executor_control_failed(message: impl Into<String>) -> Self {
145        Self::new(CoreApplyFailureCauseKind::ExecutorControlFailed, message)
146    }
147
148    pub fn executor_internal(message: impl Into<String>) -> Self {
149        Self::new(CoreApplyFailureCauseKind::ExecutorInternal, message)
150    }
151
152    pub fn unknown(message: impl Into<String>) -> Self {
153        Self::new(CoreApplyFailureCauseKind::Unknown, message)
154    }
155
156    pub fn from_agent_error(error: &AgentError) -> Self {
157        match error {
158            AgentError::HookDenied { .. } => Self::hook_denied(error.to_string()),
159            AgentError::HookTimeout { .. }
160            | AgentError::HookExecutionFailed { .. }
161            | AgentError::HookConfigInvalid { .. } => Self::hook_runtime_failure(error.to_string()),
162            _ => Self::runtime_turn(error.to_string()),
163        }
164    }
165
166    pub fn from_session_error(error: &SessionError) -> Self {
167        match error {
168            SessionError::Agent(agent_error) => Self::from_agent_error(agent_error),
169            _ => Self::runtime_turn(error.to_string()),
170        }
171    }
172
173    pub fn message(&self) -> &str {
174        &self.message
175    }
176}
177
178impl std::fmt::Display for CoreApplyFailureCause {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        f.write_str(&self.message)
181    }
182}
183
184/// Closed classifier for failures observed while applying control commands.
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186#[non_exhaustive]
187pub enum CoreControlFailureCauseKind {
188    RuntimeControl,
189    ExecutorInternal,
190    Unknown,
191}
192
193/// Typed control-failure cause plus its human-readable display projection.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct CoreControlFailureCause {
196    pub kind: CoreControlFailureCauseKind,
197    pub message: String,
198}
199
200/// Machine-independent reason an executor can no longer own its live session.
201///
202/// This is a handoff request, not an ordinary apply failure: the runtime loop
203/// must close the staged run, publish the exact executor, and let the
204/// machine-owned unregister saga perform external cleanup. Executors must not
205/// call unregister (or discard their session) from inside `apply`.
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207#[non_exhaustive]
208pub enum CoreExecutorTeardownReason {
209    ArchivedSession,
210    SessionUnavailable,
211    DurableProjectionAuthorityUnknown,
212}
213
214impl CoreExecutorTeardownReason {
215    pub fn as_str(self) -> &'static str {
216        match self {
217            Self::ArchivedSession => "ArchivedSession",
218            Self::SessionUnavailable => "SessionUnavailable",
219            Self::DurableProjectionAuthorityUnknown => "DurableProjectionAuthorityUnknown",
220        }
221    }
222
223    pub fn from_wire_str(value: &str) -> Option<Self> {
224        match value {
225            "ArchivedSession" => Some(Self::ArchivedSession),
226            "SessionUnavailable" => Some(Self::SessionUnavailable),
227            "DurableProjectionAuthorityUnknown" => Some(Self::DurableProjectionAuthorityUnknown),
228            _ => None,
229        }
230    }
231}
232
233impl CoreControlFailureCause {
234    pub fn new(kind: CoreControlFailureCauseKind, message: impl Into<String>) -> Self {
235        Self {
236            kind,
237            message: message.into(),
238        }
239    }
240
241    pub fn runtime_control(message: impl Into<String>) -> Self {
242        Self::new(CoreControlFailureCauseKind::RuntimeControl, message)
243    }
244
245    pub fn executor_internal(message: impl Into<String>) -> Self {
246        Self::new(CoreControlFailureCauseKind::ExecutorInternal, message)
247    }
248
249    pub fn unknown(message: impl Into<String>) -> Self {
250        Self::new(CoreControlFailureCauseKind::Unknown, message)
251    }
252}
253
254impl std::fmt::Display for CoreControlFailureCause {
255    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256        f.write_str(&self.message)
257    }
258}
259
260/// Errors from CoreExecutor operations.
261#[derive(Debug, Clone, thiserror::Error)]
262#[non_exhaustive]
263pub enum CoreExecutorError {
264    /// The primitive could not be applied (conversation mutation failed).
265    #[error("Apply failed: {cause}")]
266    ApplyFailed { cause: CoreApplyFailureCause },
267
268    /// The core executor observed a machine-owned terminal turn failure while
269    /// applying a runtime turn. The runtime loop must preserve this typed
270    /// terminal cause instead of reclassifying it as a runtime apply failure.
271    #[error("Terminal failure: {outcome:?} ({cause_kind:?}): {message}")]
272    TerminalFailure {
273        outcome: TurnTerminalOutcome,
274        cause_kind: TurnTerminalCauseKind,
275        message: String,
276    },
277
278    /// The executor's owned session reached a terminal/unavailable condition
279    /// that requires canonical teardown after the runtime loop hands off the
280    /// exact executor. This variant must never enter failed-batch backlog
281    /// retry, and must never be realized by unregistering inside `apply`.
282    #[error("Executor requires teardown ({reason:?}): {message}")]
283    TeardownRequired {
284        reason: CoreExecutorTeardownReason,
285        message: String,
286    },
287
288    /// The control command could not be executed.
289    #[error("Control failed: {cause}")]
290    ControlFailed { cause: CoreControlFailureCause },
291
292    /// The executor is in a terminal state and cannot accept more work.
293    #[error("Executor is stopped")]
294    Stopped,
295
296    /// The applied turn reached the canonical cancellation terminal.
297    #[error("Run was cancelled")]
298    Cancelled,
299
300    /// Internal error.
301    #[error("Internal error: {0}")]
302    Internal(String),
303}
304
305impl CoreExecutorError {
306    pub fn apply_failed(cause: CoreApplyFailureCause) -> Self {
307        Self::ApplyFailed { cause }
308    }
309
310    pub fn apply_failed_primitive_rejected(message: impl Into<String>) -> Self {
311        Self::apply_failed(CoreApplyFailureCause::primitive_rejected(message))
312    }
313
314    pub fn apply_failed_runtime_context(message: impl Into<String>) -> Self {
315        Self::apply_failed(CoreApplyFailureCause::runtime_context_apply(message))
316    }
317
318    pub fn apply_failed_runtime_turn(message: impl Into<String>) -> Self {
319        Self::apply_failed(CoreApplyFailureCause::runtime_turn(message))
320    }
321
322    pub fn terminal_failure(
323        outcome: TurnTerminalOutcome,
324        cause_kind: TurnTerminalCauseKind,
325        message: impl Into<String>,
326    ) -> Self {
327        Self::TerminalFailure {
328            outcome,
329            cause_kind,
330            message: message.into(),
331        }
332    }
333
334    pub fn teardown_required(
335        reason: CoreExecutorTeardownReason,
336        message: impl Into<String>,
337    ) -> Self {
338        Self::TeardownRequired {
339            reason,
340            message: message.into(),
341        }
342    }
343
344    pub fn archived_session_requires_teardown(message: impl Into<String>) -> Self {
345        Self::teardown_required(CoreExecutorTeardownReason::ArchivedSession, message)
346    }
347
348    pub fn session_unavailable_requires_teardown(message: impl Into<String>) -> Self {
349        Self::teardown_required(CoreExecutorTeardownReason::SessionUnavailable, message)
350    }
351
352    pub fn durable_projection_authority_unknown_requires_teardown(
353        message: impl Into<String>,
354    ) -> Self {
355        Self::teardown_required(
356            CoreExecutorTeardownReason::DurableProjectionAuthorityUnknown,
357            message,
358        )
359    }
360
361    pub fn apply_failed_from_session_error(error: SessionError) -> Self {
362        if error.requests_runtime_executor_stop() {
363            return Self::Stopped;
364        }
365        match error {
366            SessionError::Agent(AgentError::Cancelled) => Self::Cancelled,
367            SessionError::Agent(AgentError::StickyModelFallbackAuthorityUnknown { message }) => {
368                Self::session_unavailable_requires_teardown(message)
369            }
370            SessionError::Agent(AgentError::SessionDurableProjectionAuthorityUnknown {
371                message,
372            }) => Self::durable_projection_authority_unknown_requires_teardown(message),
373            SessionError::Agent(AgentError::TerminalFailure {
374                outcome,
375                cause_kind,
376                message,
377            }) if cause_kind.is_specific_failure_cause() => {
378                Self::terminal_failure(outcome, cause_kind, message)
379            }
380            SessionError::Agent(AgentError::TerminalFailure { cause_kind, .. }) => Self::Internal(
381                format!("runtime turn returned unknown machine terminal cause: {cause_kind:?}"),
382            ),
383            error => Self::apply_failed(CoreApplyFailureCause::from_session_error(&error)),
384        }
385    }
386
387    pub fn apply_failed_unknown(message: impl Into<String>) -> Self {
388        Self::apply_failed(CoreApplyFailureCause::unknown(message))
389    }
390
391    pub fn cancelled() -> Self {
392        Self::Cancelled
393    }
394
395    pub fn is_cancelled(&self) -> bool {
396        matches!(self, Self::Cancelled)
397    }
398
399    pub fn requires_runtime_teardown(&self) -> bool {
400        matches!(self, Self::TeardownRequired { .. })
401    }
402
403    pub fn control_failed(cause: CoreControlFailureCause) -> Self {
404        Self::ControlFailed { cause }
405    }
406
407    pub fn control_failed_runtime(message: impl Into<String>) -> Self {
408        Self::control_failed(CoreControlFailureCause::runtime_control(message))
409    }
410
411    pub fn apply_failure_cause(&self) -> CoreApplyFailureCause {
412        match self {
413            Self::ApplyFailed { cause } => cause.clone(),
414            Self::TerminalFailure { cause_kind, .. } => {
415                CoreApplyFailureCause::executor_internal(format!(
416                    "typed machine terminal failure escaped runtime-loop handling: {cause_kind:?}"
417                ))
418            }
419            Self::TeardownRequired { reason, message } => CoreApplyFailureCause::new(
420                CoreApplyFailureCauseKind::ExecutorStopped,
421                format!("executor requested {} teardown: {message}", reason.as_str()),
422            ),
423            Self::ControlFailed { cause } => {
424                CoreApplyFailureCause::executor_control_failed(cause.message.clone())
425            }
426            Self::Stopped => CoreApplyFailureCause::executor_stopped(),
427            Self::Cancelled => CoreApplyFailureCause::runtime_turn("cancelled"),
428            Self::Internal(message) => CoreApplyFailureCause::executor_internal(message.clone()),
429        }
430    }
431}
432
433/// Successful result of applying a run primitive.
434#[derive(Debug, Clone)]
435pub enum CoreApplyTerminal {
436    /// The run completed and produced a result.
437    RunResult(Box<RunResult>),
438    /// A resume-pending request reached the session with no pending boundary.
439    NoPendingBoundary,
440    /// The exact admitted runtime turn reached a generated hard-failure
441    /// terminal after mutating the session. The runtime must atomically commit
442    /// the accompanying receipt/session snapshot with failed-run lifecycle;
443    /// this is a completed application, not an executor-mechanism error.
444    MachineTerminalFailure { error: TurnErrorMetadata },
445    /// The run committed a continuation boundary and is waiting for external
446    /// tool results before it can continue.
447    CallbackPending {
448        tool_use_id: String,
449        tool_name: String,
450        args: Value,
451    },
452    /// The run committed one assistant batch containing multiple external
453    /// callback calls. All results must be supplied as one exact set.
454    CallbackBatchPending {
455        pending_tool_calls: Vec<crate::error::PendingCallbackToolCall>,
456    },
457}
458
459/// Failure to materialize the whole-blob representation of a prepared session
460/// boundary.
461///
462/// `serde_json::Error` is not cloneable, while the single-assignment lazy cell
463/// must publish the same terminal result to every racing reader. Preserve its
464/// diagnostic text in a cloneable typed error instead of retrying serialization
465/// after a failure.
466#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
467#[error("failed to encode prepared session boundary: {message}")]
468pub struct SessionBoundaryEncodeError {
469    message: std::sync::Arc<str>,
470}
471
472impl SessionBoundaryEncodeError {
473    fn from_serde(error: serde_json::Error) -> Self {
474        Self {
475            message: std::sync::Arc::from(error.to_string()),
476        }
477    }
478
479    /// The serializer diagnostic retained by the prepared boundary.
480    #[must_use]
481    pub fn message(&self) -> &str {
482        &self.message
483    }
484}
485
486/// One sealed physical mutation admitted at a HeadCanonical boundary.
487///
488/// Ordinary appends and same-session rewrites remain disjoint carriers. The
489/// shared accessors expose only the exact authority facts needed by runtime
490/// adoption; stores must match the variant before consuming physical rows.
491#[derive(Debug, Clone)]
492pub enum PreparedHeadCanonicalPhysicalMutation {
493    Ordinary(crate::session_store::PreparedHeadCanonicalMutation),
494    Rewrite(crate::session_store::PreparedHeadCanonicalRewriteMutation),
495}
496
497impl PreparedHeadCanonicalPhysicalMutation {
498    #[must_use]
499    pub fn session_id(&self) -> &crate::types::SessionId {
500        match self {
501            Self::Ordinary(mutation) => mutation.session_id(),
502            Self::Rewrite(mutation) => mutation.session_id(),
503        }
504    }
505
506    #[must_use]
507    pub fn predecessor_head(&self) -> Option<&crate::session_store::SessionHead> {
508        match self {
509            Self::Ordinary(mutation) => mutation.predecessor_head(),
510            Self::Rewrite(mutation) => Some(mutation.predecessor_head()),
511        }
512    }
513
514    #[must_use]
515    pub fn predecessor_head_token(&self) -> Option<&str> {
516        match self {
517            Self::Ordinary(mutation) => mutation.predecessor_head_token(),
518            Self::Rewrite(mutation) => Some(mutation.predecessor_head_token()),
519        }
520    }
521
522    #[must_use]
523    pub fn successor_head(&self) -> &crate::session_store::SessionHead {
524        match self {
525            Self::Ordinary(mutation) => mutation.successor_head(),
526            Self::Rewrite(mutation) => mutation.successor_head(),
527        }
528    }
529
530    #[must_use]
531    pub fn successor_head_token(&self) -> &str {
532        match self {
533            Self::Ordinary(mutation) => mutation.successor_head_token(),
534            Self::Rewrite(mutation) => mutation.successor_head_token(),
535        }
536    }
537
538    #[must_use]
539    pub fn ordinary(&self) -> Option<&crate::session_store::PreparedHeadCanonicalMutation> {
540        match self {
541            Self::Ordinary(mutation) => Some(mutation),
542            Self::Rewrite(_) => None,
543        }
544    }
545
546    #[must_use]
547    pub fn rewrite(&self) -> Option<&crate::session_store::PreparedHeadCanonicalRewriteMutation> {
548        match self {
549            Self::Ordinary(_) => None,
550            Self::Rewrite(mutation) => Some(mutation),
551        }
552    }
553
554    pub(crate) fn validate_live_successor(
555        &self,
556        session: &crate::Session,
557    ) -> Result<(), crate::SessionStoreError> {
558        match self {
559            Self::Ordinary(mutation) => mutation.validate_live_successor(session),
560            Self::Rewrite(mutation) => mutation.validate_live_successor(session),
561        }
562    }
563
564    pub fn acknowledge_session(
565        &self,
566        session: &mut crate::Session,
567        committed_head_token: &str,
568    ) -> Result<(), crate::SessionStoreError> {
569        match self {
570            Self::Ordinary(mutation) => mutation.acknowledge_session(session, committed_head_token),
571            Self::Rewrite(mutation) => mutation.acknowledge_session(session, committed_head_token),
572        }
573    }
574}
575
576impl From<crate::session_store::PreparedHeadCanonicalMutation>
577    for PreparedHeadCanonicalPhysicalMutation
578{
579    fn from(mutation: crate::session_store::PreparedHeadCanonicalMutation) -> Self {
580        Self::Ordinary(mutation)
581    }
582}
583
584impl From<crate::session_store::PreparedHeadCanonicalRewriteMutation>
585    for PreparedHeadCanonicalPhysicalMutation
586{
587    fn from(mutation: crate::session_store::PreparedHeadCanonicalRewriteMutation) -> Self {
588        Self::Rewrite(mutation)
589    }
590}
591
592/// A bounded store-prepared HeadCanonical mutation.
593///
594/// Runtime/store authority is deliberately absent. The store transaction owns
595/// predecessor observation, fencing, and the committed receipt; this carrier
596/// binds only the already-prepared physical delta to its live domain Session.
597#[derive(Debug, Clone)]
598pub struct PreparedHeadCanonicalBoundary {
599    mutation: PreparedHeadCanonicalPhysicalMutation,
600    compaction_projection_intents: std::sync::Arc<[crate::CompactionProjectionIntent]>,
601    catalog_labels: std::collections::BTreeMap<String, String>,
602    catalog_lifecycle_terminal: Option<crate::SessionLifecycleTerminal>,
603}
604
605impl PreparedHeadCanonicalBoundary {
606    #[must_use]
607    pub fn mutation(&self) -> &PreparedHeadCanonicalPhysicalMutation {
608        &self.mutation
609    }
610
611    /// Validated small outbox facts carried by the prepared successor.
612    #[must_use]
613    pub fn compaction_projection_intents(&self) -> &[crate::CompactionProjectionIntent] {
614        self.compaction_projection_intents.as_ref()
615    }
616
617    /// Exact bounded label projection captured from the same live successor.
618    #[must_use]
619    pub fn catalog_labels(&self) -> &std::collections::BTreeMap<String, String> {
620        &self.catalog_labels
621    }
622
623    /// Exact bounded lifecycle projection captured from the same live successor.
624    #[must_use]
625    pub const fn catalog_lifecycle_terminal(&self) -> Option<crate::SessionLifecycleTerminal> {
626        self.catalog_lifecycle_terminal
627    }
628}
629
630/// One disjoint session-persistence boundary.
631///
632/// Whole-blob backends receive either a typed document with lazy single-encode
633/// bytes or explicitly untyped compatibility bytes. Head-canonical backends
634/// receive only a sealed prepared suffix and small successor authority; that
635/// variant cannot expose a `Session` or materialize whole-document bytes.
636/// Keeping the variants disjoint makes an accidental O(document) fallback on
637/// the ordinary O(delta) path a typed error rather than a performance
638/// convention.
639#[derive(Debug, Clone)]
640enum BoundSessionCommitKind {
641    WholeBlobTyped {
642        session: std::sync::Arc<crate::Session>,
643        whole_blob: std::sync::Arc<
644            std::sync::OnceLock<
645                Result<
646                    std::sync::Arc<crate::SerializedSessionArtifact>,
647                    SessionBoundaryEncodeError,
648                >,
649            >,
650        >,
651    },
652    WholeBlobUntyped {
653        whole_blob: std::sync::Arc<
654            std::sync::OnceLock<
655                Result<
656                    std::sync::Arc<crate::SerializedSessionArtifact>,
657                    SessionBoundaryEncodeError,
658                >,
659            >,
660        >,
661    },
662    HeadCanonical {
663        boundary: std::sync::Arc<PreparedHeadCanonicalBoundary>,
664    },
665    /// Final promotion of a provisional physical tail already written by the
666    /// exact active run. This variant deliberately carries neither a Session
667    /// nor a lazy WholeBlob artifact nor a HeadCanonical delta.
668    ProvisionalPromotion {
669        receipt: crate::RunCheckpointReceipt,
670    },
671}
672
673#[derive(Debug, Clone)]
674pub struct BoundSessionCommit {
675    kind: BoundSessionCommitKind,
676    #[cfg(test)]
677    whole_blob_encode_count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
678}
679
680impl BoundSessionCommit {
681    /// Seal a typed session as the exact document this boundary will commit.
682    ///
683    /// This mint intentionally does not serialize. Head-canonical stores can
684    /// consume the typed document without ever constructing a whole blob;
685    /// whole-blob stores materialize it exactly once through
686    /// [`Self::whole_blob_bytes`].
687    ///
688    /// The fallible return is retained for source compatibility with callers
689    /// that previously observed eager JSON serialization here. Construction no
690    /// longer has a serialization failure mode.
691    pub fn sealed(session: std::sync::Arc<crate::Session>) -> Result<Self, serde_json::Error> {
692        Ok(Self {
693            kind: BoundSessionCommitKind::WholeBlobTyped {
694                session,
695                whole_blob: std::sync::Arc::new(std::sync::OnceLock::new()),
696            },
697            #[cfg(test)]
698            whole_blob_encode_count: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
699        })
700    }
701
702    /// Bytes carrying no typed certification: a consumer that needs a
703    /// `Session` must deserialize and validate these bytes itself.
704    #[must_use]
705    pub fn untyped(snapshot: Vec<u8>) -> Self {
706        Self {
707            kind: BoundSessionCommitKind::WholeBlobUntyped {
708                whole_blob: std::sync::Arc::new(std::sync::OnceLock::from(Ok(
709                    std::sync::Arc::new(crate::SerializedSessionArtifact::from_raw_bytes(snapshot)),
710                ))),
711            },
712            #[cfg(test)]
713            whole_blob_encode_count: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
714        }
715    }
716
717    /// Seal an already serialized WholeBlob artifact without copying its bytes
718    /// or recomputing its physical row digest.
719    ///
720    /// The carrier remains intentionally untyped: callers that need a
721    /// `Session` must retain or decode one separately. This constructor exists
722    /// for control-plane writers that already own the exact one-pass artifact
723    /// accepted by the store.
724    #[must_use]
725    pub fn from_serialized_artifact(
726        artifact: std::sync::Arc<crate::SerializedSessionArtifact>,
727    ) -> Self {
728        Self {
729            kind: BoundSessionCommitKind::WholeBlobUntyped {
730                whole_blob: std::sync::Arc::new(std::sync::OnceLock::from(Ok(artifact))),
731            },
732            #[cfg(test)]
733            whole_blob_encode_count: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
734        }
735    }
736
737    /// Seal the exact latest provisional receipt as the final persistence
738    /// boundary for its run.
739    #[must_use]
740    pub fn provisional_promotion(receipt: crate::RunCheckpointReceipt) -> Self {
741        Self {
742            kind: BoundSessionCommitKind::ProvisionalPromotion { receipt },
743            #[cfg(test)]
744            whole_blob_encode_count: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
745        }
746    }
747
748    /// Convert a typed whole-blob carrier into a bounded head mutation.
749    ///
750    /// This compatibility constructor validates the typed successor and then
751    /// drops it; the returned carrier is the disjoint head-only variant. New
752    /// live actor paths should call [`Self::head_canonical_from_session`]
753    /// directly while borrowing the actor-owned session.
754    pub fn with_head_canonical_mutation(
755        self,
756        mutation: crate::session_store::PreparedHeadCanonicalMutation,
757    ) -> Result<Self, crate::SessionStoreError> {
758        let mutation_session_id = mutation.session_id().clone();
759        let invalid = |reason: String| crate::SessionStoreError::InvalidTranscriptRewrite {
760            id: mutation_session_id.clone(),
761            reason,
762        };
763        let session = match &self.kind {
764            BoundSessionCommitKind::WholeBlobTyped { session, .. } => {
765                std::sync::Arc::clone(session)
766            }
767            BoundSessionCommitKind::WholeBlobUntyped { .. } => {
768                return Err(invalid(
769                    "head-canonical persistence requires a typed session boundary".to_string(),
770                ));
771            }
772            BoundSessionCommitKind::HeadCanonical { .. } => {
773                return Err(invalid(
774                    "head-canonical mutation was already attached to this boundary".to_string(),
775                ));
776            }
777            BoundSessionCommitKind::ProvisionalPromotion { .. } => {
778                return Err(invalid(
779                    "provisional promotion cannot be converted into a head-canonical mutation"
780                        .to_string(),
781                ));
782            }
783        };
784        Self::head_canonical_from_session(session.as_ref(), mutation)
785    }
786
787    /// Mint a bounded head-canonical carrier from a borrowed live session.
788    ///
789    /// The session is used only while validating the prepared mutation and
790    /// small compaction outbox facts. It is deliberately not retained by the
791    /// returned carrier: ordinary head-canonical persistence must never turn
792    /// an O(delta) suffix into an O(document) `Session` clone or whole-blob
793    /// encode merely to cross the runtime boundary.
794    pub fn head_canonical_from_session(
795        session: &crate::Session,
796        mutation: crate::session_store::PreparedHeadCanonicalMutation,
797    ) -> Result<Self, crate::SessionStoreError> {
798        Self::head_canonical_physical_from_session(session, mutation.into())
799    }
800
801    /// Mint a bounded HeadCanonical carrier from either disjoint physical
802    /// mutation kind.
803    pub fn head_canonical_physical_from_session(
804        session: &crate::Session,
805        mutation: PreparedHeadCanonicalPhysicalMutation,
806    ) -> Result<Self, crate::SessionStoreError> {
807        let boundary = Self::prepare_head_canonical_boundary(session, mutation)?;
808        Ok(Self {
809            kind: BoundSessionCommitKind::HeadCanonical {
810                boundary: std::sync::Arc::new(boundary),
811            },
812            #[cfg(test)]
813            whole_blob_encode_count: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
814        })
815    }
816
817    /// Mint a bounded same-session rewrite carrier from a borrowed live
818    /// session without retaining or encoding the accumulated document.
819    pub fn head_canonical_rewrite_from_session(
820        session: &crate::Session,
821        mutation: crate::session_store::PreparedHeadCanonicalRewriteMutation,
822    ) -> Result<Self, crate::SessionStoreError> {
823        Self::head_canonical_physical_from_session(session, mutation.into())
824    }
825
826    fn prepare_head_canonical_boundary(
827        session: &crate::Session,
828        mutation: PreparedHeadCanonicalPhysicalMutation,
829    ) -> Result<PreparedHeadCanonicalBoundary, crate::SessionStoreError> {
830        let mutation_session_id = mutation.session_id().clone();
831        let invalid = |reason: String| crate::SessionStoreError::InvalidTranscriptRewrite {
832            id: mutation_session_id.clone(),
833            reason,
834        };
835        if session.id() != mutation.session_id() {
836            return Err(invalid(format!(
837                "prepared mutation belongs to session {}, not sealed session {}",
838                mutation.session_id(),
839                session.id()
840            )));
841        }
842
843        mutation.validate_live_successor(session)?;
844
845        let compaction_projection_intents = session
846            .validated_compaction_projection_intents()
847            .map_err(|error| {
848                invalid(format!(
849                    "head-canonical successor carries invalid compaction projection intents: {error}"
850                ))
851            })?
852            .into();
853        let catalog_labels = session
854            .metadata()
855            .get("session_labels")
856            .map(|value| {
857                serde_json::from_value::<std::collections::BTreeMap<String, String>>(value.clone())
858                    .map_err(|error| {
859                        invalid(format!(
860                            "head-canonical successor carries malformed catalog labels: {error}"
861                        ))
862                    })
863            })
864            .transpose()?
865            .unwrap_or_default();
866        let catalog_lifecycle_terminal = session.try_lifecycle_terminal().map_err(|error| {
867            invalid(format!(
868                "head-canonical successor carries malformed lifecycle-terminal metadata: {error}"
869            ))
870        })?;
871
872        Ok(PreparedHeadCanonicalBoundary {
873            mutation,
874            compaction_projection_intents,
875            catalog_labels,
876            catalog_lifecycle_terminal,
877        })
878    }
879
880    /// Prepared bounded mutation and independent authority proofs, when this
881    /// boundary is eligible for `HeadCanonicalV1`.
882    #[must_use]
883    pub fn head_canonical(&self) -> Option<&PreparedHeadCanonicalBoundary> {
884        match &self.kind {
885            BoundSessionCommitKind::HeadCanonical { boundary } => Some(boundary.as_ref()),
886            BoundSessionCommitKind::WholeBlobTyped { .. }
887            | BoundSessionCommitKind::WholeBlobUntyped { .. }
888            | BoundSessionCommitKind::ProvisionalPromotion { .. } => None,
889        }
890    }
891
892    /// Store-issued provisional physical identity carried by a final promotion
893    /// boundary.
894    #[must_use]
895    pub fn provisional_promotion_receipt(&self) -> Option<&crate::RunCheckpointReceipt> {
896        match &self.kind {
897            BoundSessionCommitKind::ProvisionalPromotion { receipt } => Some(receipt),
898            BoundSessionCommitKind::WholeBlobTyped { .. }
899            | BoundSessionCommitKind::WholeBlobUntyped { .. }
900            | BoundSessionCommitKind::HeadCanonical { .. } => None,
901        }
902    }
903
904    /// Verify that an acknowledgement names this exact prepared successor.
905    ///
906    /// The head-only carrier does not retain the live session. The actor owner
907    /// applies only the prepared row/component acknowledgement after this exact
908    /// store-issued token check succeeds.
909    pub fn acknowledge_head_canonical_commit(
910        &self,
911        committed_head_cas_token: &str,
912    ) -> Result<(), crate::SessionStoreError> {
913        let boundary = self.head_canonical().ok_or_else(|| {
914            crate::SessionStoreError::Internal(
915                "session boundary has no head-canonical mutation to acknowledge".to_string(),
916            )
917        })?;
918        if boundary.mutation().successor_head_token() != committed_head_cas_token {
919            return Err(crate::SessionStoreError::TranscriptRevisionConflict {
920                id: boundary.mutation().session_id().clone(),
921                expected: boundary.mutation().successor_head_token().to_string(),
922                actual: committed_head_cas_token.to_string(),
923            });
924        }
925        Ok(())
926    }
927
928    /// Materialize the whole-blob representation, if the selected backend
929    /// requires one.
930    ///
931    /// A typed carrier serializes its exact `Session` into this single-assignment
932    /// buffer. An untyped carrier returns the bytes supplied to
933    /// [`Self::untyped`]. Calling this on the disjoint head-canonical variant
934    /// is a typed error.
935    pub fn whole_blob_bytes(&self) -> Result<&[u8], SessionBoundaryEncodeError> {
936        let (whole_blob, session) = match &self.kind {
937            BoundSessionCommitKind::WholeBlobTyped {
938                session,
939                whole_blob,
940            } => (whole_blob, Some(session)),
941            BoundSessionCommitKind::WholeBlobUntyped { whole_blob } => (whole_blob, None),
942            BoundSessionCommitKind::HeadCanonical { .. } => {
943                return Err(SessionBoundaryEncodeError {
944                    message: std::sync::Arc::from(
945                        "head-canonical boundary has no whole-blob representation",
946                    ),
947                });
948            }
949            BoundSessionCommitKind::ProvisionalPromotion { .. } => {
950                return Err(SessionBoundaryEncodeError {
951                    message: std::sync::Arc::from(
952                        "provisional promotion boundary has no whole-blob representation",
953                    ),
954                });
955            }
956        };
957        whole_blob
958            .get_or_init(|| {
959                let Some(session) = session else {
960                    return Err(SessionBoundaryEncodeError {
961                        message: std::sync::Arc::from(
962                            "untyped whole-blob carrier lost its compatibility bytes",
963                        ),
964                    });
965                };
966                let snapshot = session
967                    .to_persisted_artifact()
968                    .map_err(SessionBoundaryEncodeError::from_serde)?;
969                #[cfg(test)]
970                self.whole_blob_encode_count
971                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
972                Ok(std::sync::Arc::new(snapshot))
973            })
974            .as_ref()
975            .map(|snapshot| snapshot.bytes())
976            .map_err(Clone::clone)
977    }
978
979    /// The sealed WholeBlob bytes together with their single-pass physical
980    /// row digest.
981    ///
982    /// Runtime/store WholeBlob paths should consume this artifact directly
983    /// and reuse [`crate::SerializedSessionArtifact::row_sha256_token`] rather
984    /// than hashing [`Self::whole_blob_bytes`] again.
985    pub fn whole_blob_artifact(
986        &self,
987    ) -> Result<&crate::SerializedSessionArtifact, SessionBoundaryEncodeError> {
988        let _ = self.whole_blob_bytes()?;
989        let whole_blob = match &self.kind {
990            BoundSessionCommitKind::WholeBlobTyped { whole_blob, .. }
991            | BoundSessionCommitKind::WholeBlobUntyped { whole_blob } => whole_blob,
992            BoundSessionCommitKind::HeadCanonical { .. } => {
993                return Err(SessionBoundaryEncodeError {
994                    message: std::sync::Arc::from(
995                        "head-canonical boundary has no whole-blob representation",
996                    ),
997                });
998            }
999            BoundSessionCommitKind::ProvisionalPromotion { .. } => {
1000                return Err(SessionBoundaryEncodeError {
1001                    message: std::sync::Arc::from(
1002                        "provisional promotion boundary has no whole-blob representation",
1003                    ),
1004                });
1005            }
1006        };
1007        match whole_blob.get() {
1008            Some(Ok(artifact)) => Ok(artifact.as_ref()),
1009            Some(Err(error)) => Err(error.clone()),
1010            None => Err(SessionBoundaryEncodeError {
1011                message: std::sync::Arc::from(
1012                    "whole-blob cell remained empty after successful materialization",
1013                ),
1014            }),
1015        }
1016    }
1017
1018    /// Consume this carrier into a shared whole-blob representation.
1019    ///
1020    /// This is the owned counterpart to [`Self::whole_blob_bytes`]. It avoids
1021    /// copying an already materialized blob; compatibility APIs that still
1022    /// require `Vec<u8>` may need one final bridge copy.
1023    pub fn into_whole_blob_bytes(
1024        self,
1025    ) -> Result<std::sync::Arc<Vec<u8>>, SessionBoundaryEncodeError> {
1026        let _ = self.whole_blob_bytes()?;
1027        let whole_blob = match &self.kind {
1028            BoundSessionCommitKind::WholeBlobTyped { whole_blob, .. }
1029            | BoundSessionCommitKind::WholeBlobUntyped { whole_blob } => whole_blob,
1030            BoundSessionCommitKind::HeadCanonical { .. } => {
1031                return Err(SessionBoundaryEncodeError {
1032                    message: std::sync::Arc::from(
1033                        "head-canonical boundary has no whole-blob representation",
1034                    ),
1035                });
1036            }
1037            BoundSessionCommitKind::ProvisionalPromotion { .. } => {
1038                return Err(SessionBoundaryEncodeError {
1039                    message: std::sync::Arc::from(
1040                        "provisional promotion boundary has no whole-blob representation",
1041                    ),
1042                });
1043            }
1044        };
1045        match whole_blob.get() {
1046            Some(Ok(snapshot)) => Ok(snapshot.bytes_arc()),
1047            Some(Err(error)) => Err(error.clone()),
1048            None => Err(SessionBoundaryEncodeError {
1049                message: std::sync::Arc::from(
1050                    "whole-blob cell remained empty after successful materialization",
1051                ),
1052            }),
1053        }
1054    }
1055
1056    #[cfg(test)]
1057    fn whole_blob_encode_count(&self) -> usize {
1058        self.whole_blob_encode_count
1059            .load(std::sync::atomic::Ordering::Relaxed)
1060    }
1061
1062    /// The typed session, when the producer certified a WholeBlob document;
1063    /// identical by construction to [`Self::whole_blob_bytes`].
1064    #[must_use]
1065    pub fn session(&self) -> Option<&crate::Session> {
1066        match &self.kind {
1067            BoundSessionCommitKind::WholeBlobTyped { session, .. } => Some(session.as_ref()),
1068            BoundSessionCommitKind::WholeBlobUntyped { .. }
1069            | BoundSessionCommitKind::HeadCanonical { .. }
1070            | BoundSessionCommitKind::ProvisionalPromotion { .. } => None,
1071        }
1072    }
1073
1074    /// Borrow the certified session as a shared handle.
1075    #[must_use]
1076    pub fn session_arc(&self) -> Option<&std::sync::Arc<crate::Session>> {
1077        match &self.kind {
1078            BoundSessionCommitKind::WholeBlobTyped { session, .. } => Some(session),
1079            BoundSessionCommitKind::WholeBlobUntyped { .. }
1080            | BoundSessionCommitKind::HeadCanonical { .. }
1081            | BoundSessionCommitKind::ProvisionalPromotion { .. } => None,
1082        }
1083    }
1084
1085    /// Clone the shared handle to the certified Session without consuming this
1086    /// carrier or reparsing its WholeBlob bytes.
1087    #[must_use]
1088    pub fn session_arc_cloned(&self) -> Option<std::sync::Arc<crate::Session>> {
1089        self.session_arc().cloned()
1090    }
1091
1092    /// Consume the pair into the certified session handle, if any.
1093    #[must_use]
1094    pub fn into_session_arc(self) -> Option<std::sync::Arc<crate::Session>> {
1095        match self.kind {
1096            BoundSessionCommitKind::WholeBlobTyped { session, .. } => Some(session),
1097            BoundSessionCommitKind::WholeBlobUntyped { .. }
1098            | BoundSessionCommitKind::HeadCanonical { .. }
1099            | BoundSessionCommitKind::ProvisionalPromotion { .. } => None,
1100        }
1101    }
1102}
1103
1104#[derive(Debug, Clone)]
1105pub struct CoreApplyOutput {
1106    /// Unsequenced receipt proving boundary application. The runtime driver
1107    /// mints the final sequenced [`super::run_receipt::RunBoundaryReceipt`]
1108    /// from the generated machine's per-run boundary counter at commit time
1109    /// (dogma K10 — executors cannot produce the boundary sequence).
1110    pub receipt: RunBoundaryReceiptDraft,
1111    /// The session persistence mutation to commit atomically with the receipt
1112    /// and input-state updates, held as one disjoint sealed value.
1113    ///
1114    /// Private, and readable only through [`Self::committed`] /
1115    /// [`Self::whole_blob_bytes`] / [`Self::session`]: as two assignable `pub`
1116    /// halves the seal was a convention a producer could break by overwriting
1117    /// the bytes after attaching the typed session, or by moving a typed
1118    /// session into a struct literal beside foreign bytes. One private field
1119    /// makes re-pairing unrepresentable — a consumer that validates the typed
1120    /// half and persists the bytes is validating and persisting the same
1121    /// document by construction.
1122    ///
1123    /// Whole-blob variants preserve typed/byte pairing and pay for at most one
1124    /// encode. The head-canonical variant contains no `Session` at all: it
1125    /// carries only the prepared suffix and successor authority, so neither a
1126    /// consumer nor an error fallback can accidentally turn an ordinary
1127    /// append into O(document) work.
1128    committed: Option<BoundSessionCommit>,
1129    /// Terminal payload observation produced by runtime-backed execution.
1130    ///
1131    /// `None` means the primitive committed successfully but did not produce
1132    /// a result payload (for example immediate context appends). Runtime
1133    /// surfaces must route this payload shape through generated machine
1134    /// authority before choosing a public completion result class.
1135    pub terminal: Option<CoreApplyTerminal>,
1136}
1137
1138/// Durable receipt for one exact interaction-terminal publication.
1139#[derive(Debug, Clone, PartialEq, Eq)]
1140pub struct CoreInteractionTerminalPublicationReceipt {
1141    interaction_id: InteractionId,
1142    terminal_seq: u64,
1143    payload_digest: String,
1144}
1145
1146impl CoreInteractionTerminalPublicationReceipt {
1147    pub fn try_new(event: &AgentEvent, terminal_seq: u64) -> Result<Self, CoreExecutorError> {
1148        if terminal_seq == 0 {
1149            return Err(CoreExecutorError::Internal(
1150                "interaction terminal durable sequence must be non-zero".to_string(),
1151            ));
1152        }
1153        let interaction_id = match event {
1154            AgentEvent::InteractionComplete { interaction_id, .. }
1155            | AgentEvent::InteractionCallbackPending { interaction_id, .. }
1156            | AgentEvent::InteractionFailed { interaction_id, .. } => *interaction_id,
1157            _ => {
1158                return Err(CoreExecutorError::Internal(
1159                    "interaction terminal publication receipt requires an Interaction terminal event"
1160                        .to_string(),
1161                ));
1162            }
1163        };
1164        let encoded = serde_json::to_vec(event).map_err(|error| {
1165            CoreExecutorError::Internal(format!(
1166                "failed to encode interaction terminal publication receipt: {error}"
1167            ))
1168        })?;
1169        Ok(Self {
1170            interaction_id,
1171            terminal_seq,
1172            payload_digest: format!("{:x}", Sha256::digest(encoded)),
1173        })
1174    }
1175
1176    pub fn interaction_id(&self) -> InteractionId {
1177        self.interaction_id
1178    }
1179
1180    pub fn terminal_seq(&self) -> u64 {
1181        self.terminal_seq
1182    }
1183
1184    pub fn payload_digest(&self) -> &str {
1185        &self.payload_digest
1186    }
1187}
1188
1189/// Typed failure while preparing or resolving an exact live turn boundary.
1190///
1191/// `Unavailable` and `Stale` invalidate only the transient delivery attempt,
1192/// so a caller may retain an independently accepted durable input for queued
1193/// delivery. `Stale` specifically means the exact actor/run/generation witness
1194/// was invalidated. `Fault` means the preparation mechanism itself failed and
1195/// must not be laundered into ordinary unavailability.
1196#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1197pub enum CoreBoundaryStageError {
1198    #[error("active turn boundary is unavailable: {reason}")]
1199    Unavailable { reason: String },
1200    #[error("active turn boundary authority is stale: {reason}")]
1201    Stale { reason: String },
1202    #[error("active turn boundary preparation failed: {reason}")]
1203    Fault { reason: String },
1204}
1205
1206impl CoreBoundaryStageError {
1207    pub fn unavailable(reason: impl Into<String>) -> Self {
1208        Self::Unavailable {
1209            reason: reason.into(),
1210        }
1211    }
1212
1213    pub fn stale(reason: impl Into<String>) -> Self {
1214        Self::Stale {
1215            reason: reason.into(),
1216        }
1217    }
1218
1219    pub fn fault(reason: impl Into<String>) -> Self {
1220        Self::Fault {
1221            reason: reason.into(),
1222        }
1223    }
1224
1225    #[must_use]
1226    pub fn is_unavailable(&self) -> bool {
1227        matches!(self, Self::Unavailable { .. })
1228    }
1229}
1230
1231pub(crate) trait CoreBoundaryStageCommitAuthority: Send {
1232    fn commit(&mut self) -> Result<(), CoreBoundaryStageError>;
1233    fn abort(&mut self) -> Result<(), CoreBoundaryStageError>;
1234}
1235
1236/// Successful prepare result for one exact parked model boundary.
1237///
1238/// The value is deliberately non-`Clone` and `#[must_use]`: it owns the only
1239/// commit/abort authority for the parked `{actor, run, generation}`. Dropping
1240/// it synchronously aborts the preparation and wakes the runner.
1241///
1242/// `commit` is the publication linearization point, not a claim that the LLM
1243/// consumed the context. A hard cancel that linearizes after publication but
1244/// before the runner's final synchronous consume still cancels that
1245/// active-turn-only context; the runner-owned consumption witness distinguishes
1246/// those outcomes.
1247#[must_use = "a prepared boundary must be committed or aborted; dropping it aborts"]
1248pub struct CoreBoundaryStageOutput {
1249    /// Optional serialized session snapshot to commit atomically with the
1250    /// generated receipt and input-state updates.
1251    session_snapshot: Option<Vec<u8>>,
1252    authority: Option<Box<dyn CoreBoundaryStageCommitAuthority>>,
1253}
1254
1255impl CoreBoundaryStageOutput {
1256    pub(crate) fn prepared(
1257        session_snapshot: Option<Vec<u8>>,
1258        authority: Box<dyn CoreBoundaryStageCommitAuthority>,
1259    ) -> Self {
1260        Self {
1261            session_snapshot,
1262            authority: Some(authority),
1263        }
1264    }
1265
1266    #[must_use]
1267    pub fn session_snapshot(&self) -> Option<&[u8]> {
1268        self.session_snapshot.as_deref()
1269    }
1270
1271    /// Publish the prepared candidate exactly once and unblock its runner.
1272    ///
1273    /// Success means the exact parked actor accepted publication. Delivery to
1274    /// the model remains cancellable until the runner consumes its separate
1275    /// model-boundary witness at the final call seam.
1276    pub fn commit(mut self) -> Result<(), CoreBoundaryStageError> {
1277        let Some(mut authority) = self.authority.take() else {
1278            return Err(CoreBoundaryStageError::stale(
1279                "prepared boundary authority was already resolved",
1280            ));
1281        };
1282        authority.commit()
1283    }
1284
1285    pub fn abort(mut self) -> Result<(), CoreBoundaryStageError> {
1286        let Some(mut authority) = self.authority.take() else {
1287            return Err(CoreBoundaryStageError::stale(
1288                "prepared boundary authority was already resolved",
1289            ));
1290        };
1291        authority.abort()
1292    }
1293}
1294
1295impl std::fmt::Debug for CoreBoundaryStageOutput {
1296    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1297        formatter
1298            .debug_struct("CoreBoundaryStageOutput")
1299            .field(
1300                "session_snapshot_len",
1301                &self.session_snapshot.as_ref().map(Vec::len),
1302            )
1303            .field("authority", &self.authority.as_ref().map(|_| "prepared"))
1304            .finish()
1305    }
1306}
1307
1308impl CoreApplyOutput {
1309    /// An output that commits no session document.
1310    pub fn new(receipt: RunBoundaryReceiptDraft, terminal: Option<CoreApplyTerminal>) -> Self {
1311        Self {
1312            receipt,
1313            committed: None,
1314            terminal,
1315        }
1316    }
1317
1318    /// An output whose committed session document is UNCERTIFIED: the bytes
1319    /// carry no typed half, so a consumer that needs a `Session` deserializes
1320    /// and validates them itself.
1321    ///
1322    /// Producers that hold the typed session use [`Self::with_session`]
1323    /// instead; it seals the pair and is the only way a typed session ever
1324    /// accompanies bytes.
1325    pub fn with_untyped_snapshot(
1326        receipt: RunBoundaryReceiptDraft,
1327        untyped_snapshot: Option<Vec<u8>>,
1328        terminal: Option<CoreApplyTerminal>,
1329    ) -> Self {
1330        Self {
1331            receipt,
1332            committed: untyped_snapshot.map(BoundSessionCommit::untyped),
1333            terminal,
1334        }
1335    }
1336
1337    pub fn with_run_result(
1338        receipt: RunBoundaryReceiptDraft,
1339        untyped_snapshot: Option<Vec<u8>>,
1340        run_result: RunResult,
1341    ) -> Self {
1342        Self::with_untyped_snapshot(
1343            receipt,
1344            untyped_snapshot,
1345            Some(CoreApplyTerminal::RunResult(Box::new(run_result))),
1346        )
1347    }
1348
1349    pub fn with_callback_pending(
1350        receipt: RunBoundaryReceiptDraft,
1351        untyped_snapshot: Option<Vec<u8>>,
1352        tool_use_id: impl Into<String>,
1353        tool_name: impl Into<String>,
1354        args: Value,
1355    ) -> Self {
1356        Self::with_untyped_snapshot(
1357            receipt,
1358            untyped_snapshot,
1359            Some(CoreApplyTerminal::CallbackPending {
1360                tool_use_id: tool_use_id.into(),
1361                tool_name: tool_name.into(),
1362                args,
1363            }),
1364        )
1365    }
1366
1367    pub fn with_callback_batch_pending(
1368        receipt: RunBoundaryReceiptDraft,
1369        untyped_snapshot: Option<Vec<u8>>,
1370        pending_tool_calls: Vec<crate::error::PendingCallbackToolCall>,
1371    ) -> Self {
1372        Self::with_untyped_snapshot(
1373            receipt,
1374            untyped_snapshot,
1375            Some(CoreApplyTerminal::CallbackBatchPending { pending_tool_calls }),
1376        )
1377    }
1378
1379    pub fn without_terminal(
1380        receipt: RunBoundaryReceiptDraft,
1381        untyped_snapshot: Option<Vec<u8>>,
1382    ) -> Self {
1383        Self::with_untyped_snapshot(receipt, untyped_snapshot, None)
1384    }
1385
1386    /// Commit the typed session as one sealed prepared boundary document.
1387    ///
1388    /// Whole-blob serialization is deferred until the selected persistence
1389    /// profile requests it. Any uncertified bytes a constructor installed
1390    /// earlier are replaced wholesale — typed authority and lazy bytes remain
1391    /// one private carrier, so no producer can certify one transcript while a
1392    /// different one is committed.
1393    pub fn with_session(
1394        mut self,
1395        session: std::sync::Arc<crate::Session>,
1396    ) -> Result<Self, serde_json::Error> {
1397        self.committed = Some(BoundSessionCommit::sealed(session)?);
1398        Ok(self)
1399    }
1400
1401    /// Install an already sealed session boundary carrier.
1402    ///
1403    /// This is the profile-aware counterpart to [`Self::with_session`].
1404    /// Producers that prepared a bounded head-canonical mutation must retain
1405    /// that mutation on the exact typed carrier handed to RuntimeStore;
1406    /// reminting from only the `Session` would silently discard its physical
1407    /// predecessor CAS and suffix proof.
1408    #[must_use]
1409    pub fn with_bound_session(mut self, committed: BoundSessionCommit) -> Self {
1410        self.committed = Some(committed);
1411        self
1412    }
1413
1414    /// The sealed session document this boundary commits, if any.
1415    #[must_use]
1416    pub fn committed(&self) -> Option<&BoundSessionCommit> {
1417        self.committed.as_ref()
1418    }
1419
1420    /// Lazily materialize the exact whole-blob bytes this boundary commits.
1421    pub fn whole_blob_bytes(&self) -> Result<Option<&[u8]>, SessionBoundaryEncodeError> {
1422        self.committed
1423            .as_ref()
1424            .map(BoundSessionCommit::whole_blob_bytes)
1425            .transpose()
1426    }
1427
1428    /// The typed WholeBlob session sealed to [`Self::whole_blob_bytes`], when
1429    /// the producer certified one.
1430    #[must_use]
1431    pub fn session(&self) -> Option<&crate::Session> {
1432        self.committed
1433            .as_ref()
1434            .and_then(BoundSessionCommit::session)
1435    }
1436
1437    /// Consume the sealed session document, leaving the receipt and terminal
1438    /// behind.
1439    #[must_use]
1440    pub fn into_committed(self) -> Option<BoundSessionCommit> {
1441        self.committed
1442    }
1443
1444    /// Consume into the receipt, the sealed session document, and the terminal
1445    /// observation. The document stays sealed across the handoff.
1446    #[must_use]
1447    pub fn into_parts(
1448        self,
1449    ) -> (
1450        RunBoundaryReceiptDraft,
1451        Option<BoundSessionCommit>,
1452        Option<CoreApplyTerminal>,
1453    ) {
1454        (self.receipt, self.committed, self.terminal)
1455    }
1456}
1457
1458/// Cloneable live endpoint for cooperative in-flight turn boundaries.
1459///
1460/// ```compile_fail
1461/// use meerkat_core::lifecycle::CoreExecutorBoundaryHandle;
1462///
1463/// async fn boundary_handles_cannot_hard_cancel(handle: &dyn CoreExecutorBoundaryHandle) {
1464///     handle
1465///         .hard_cancel_current_run("wrong authority".to_string())
1466///         .await
1467///         .unwrap();
1468/// }
1469/// ```
1470#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
1471#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
1472pub trait CoreExecutorBoundaryHandle: Send + Sync {
1473    /// Request cooperative cancellation for one exact active run.
1474    async fn cancel_after_boundary(
1475        &self,
1476        expected_run_id: &RunId,
1477        reason: String,
1478    ) -> Result<(), CoreExecutorError>;
1479
1480    /// Prepare request-only runtime context for one exact cooperative LLM
1481    /// boundary and return only after the actor is parked immediately before
1482    /// consumption. The non-clone result owns explicit commit/abort authority.
1483    ///
1484    /// This context is never Session state and therefore carries no durable
1485    /// session snapshot.
1486    async fn prepare_transient_turn_context_at_boundary(
1487        &self,
1488        _expected_run_id: &RunId,
1489        _contexts: Vec<TurnRequestContext>,
1490    ) -> Result<CoreBoundaryStageOutput, CoreBoundaryStageError> {
1491        Err(CoreBoundaryStageError::unavailable(
1492            "live transient turn-context preparation is unsupported by this executor",
1493        ))
1494    }
1495}
1496
1497/// Cloneable live endpoint for hard-cancelling the active run immediately.
1498#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
1499#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
1500pub trait CoreExecutorInterruptHandle: Send + Sync {
1501    async fn hard_cancel_current_run(&self, reason: String) -> Result<(), CoreExecutorError>;
1502}
1503
1504/// Cloneable capability for exact durable interaction-terminal publication.
1505///
1506/// Runtime control paths may need to terminalize queued or staged directed
1507/// inputs while the owning executor is in flight (destroy/unregister) or after
1508/// its loop channels have been detached. Keeping this authority on a separate
1509/// handle prevents those paths from borrowing or duplicating the executor
1510/// while still routing publication through the executor's owning session
1511/// surface.
1512#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
1513#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
1514pub trait CoreExecutorPublicationHandle: Send + Sync {
1515    async fn publish_interaction_terminals(
1516        &self,
1517        events: &[AgentEvent],
1518    ) -> Result<Vec<CoreInteractionTerminalPublicationReceipt>, CoreExecutorError>;
1519}
1520
1521/// Cloneable service/surface cleanup authority retained by the runtime entry.
1522///
1523/// Unlike adapter unregister, this handle owns only the executor's live actor
1524/// and surface-local state. `MeerkatMachine` invokes it inside the exact
1525/// attachment's generated unregister window, and can retry it after a failed
1526/// or externally initiated drain without resurrecting the executor object.
1527/// Implementations must therefore be idempotent, including when a prior
1528/// attempt completed only part of its sidecar cleanup before returning an
1529/// error.
1530#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
1531#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
1532pub trait CoreExecutorPostStopCleanupHandle: Send + Sync {
1533    async fn cleanup_after_runtime_stop_terminalized(&self) -> Result<(), CoreExecutorError>;
1534
1535    /// Cleanup when the runtime loop already owns this session's stable outer
1536    /// turn-finalization boundary. Implementations backed by that boundary must
1537    /// not reacquire it.
1538    async fn cleanup_after_runtime_stop_terminalized_under_turn_finalization_boundary(
1539        &self,
1540    ) -> Result<(), CoreExecutorError> {
1541        self.cleanup_after_runtime_stop_terminalized().await
1542    }
1543}
1544
1545/// Opaque RAII witness that one session actor's turn-finalization interval is
1546/// exclusively owned. The runtime holds this from before queue/effect staging
1547/// through machine commit, compatibility checkpoint, exact terminal receipt
1548/// persistence, and waiter resolution.
1549pub trait CoreExecutorTurnFinalizationGuard: Send {}
1550
1551impl<T: Send> CoreExecutorTurnFinalizationGuard for T {}
1552
1553/// Cloneable endpoint for the stable per-session turn-finalization boundary.
1554#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
1555#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
1556pub trait CoreExecutorTurnFinalizationBoundaryHandle: Send + Sync {
1557    async fn acquire(
1558        &self,
1559    ) -> Result<Box<dyn CoreExecutorTurnFinalizationGuard>, CoreExecutorError>;
1560}
1561
1562/// The interface core exposes for the runtime layer to apply run primitives.
1563///
1564/// The runtime layer creates an implementation that wraps an `Agent` and
1565/// translates `RunPrimitive` into session mutations. This trait is defined
1566/// in core so both layers can depend on it without circular deps.
1567///
1568/// # Object Safety
1569/// This trait is object-safe to allow `Box<dyn CoreExecutor>` usage.
1570#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
1571#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
1572pub trait CoreExecutor: Send + Sync {
1573    /// Optional live cooperative-boundary endpoint.
1574    ///
1575    /// Implementations return this only when the underlying live turn can be
1576    /// signaled while `apply()` is in flight and will also wake any yielding
1577    /// turn so the boundary request can be observed.
1578    fn boundary_handle(&self) -> Option<Arc<dyn CoreExecutorBoundaryHandle>> {
1579        None
1580    }
1581
1582    /// Optional live hard-interrupt endpoint.
1583    ///
1584    /// Hard cancel is intentionally live-handle-only. It is not available on
1585    /// the queued in-loop executor channel because user/session interrupt
1586    /// semantics require prompt delivery during a long in-flight turn.
1587    fn interrupt_handle(&self) -> Option<Arc<dyn CoreExecutorInterruptHandle>> {
1588        None
1589    }
1590
1591    /// Optional cloneable authority for exact durable terminal publication.
1592    fn publication_handle(&self) -> Option<Arc<dyn CoreExecutorPublicationHandle>> {
1593        None
1594    }
1595
1596    /// Whether `MeerkatMachine` should retain and fence this attachment's exact
1597    /// post-stop service cleanup authority.
1598    ///
1599    /// Opted-in executors expose a cloneable attachment-local cleanup handle.
1600    /// The machine fences it by the attachment incarnation it created, so a
1601    /// stale cleanup cannot remove replacement state. Ordinary runtime stop
1602    /// cleans the service incarnation while preserving the registered
1603    /// `Stopped` machine state; explicit unregister owns the later `Draining`
1604    /// transition and registration removal.
1605    fn machine_managed_post_stop_unregister(&self) -> bool {
1606        false
1607    }
1608
1609    /// Cloneable service/surface cleanup authority for machine-managed
1610    /// post-stop unregister.
1611    fn post_stop_cleanup_handle(&self) -> Option<Arc<dyn CoreExecutorPostStopCleanupHandle>> {
1612        None
1613    }
1614
1615    /// Stable boundary shared with direct and non-turn session mutations.
1616    fn turn_finalization_boundary_handle(
1617        &self,
1618    ) -> Option<Arc<dyn CoreExecutorTurnFinalizationBoundaryHandle>> {
1619        None
1620    }
1621
1622    /// Apply a run primitive to the conversation.
1623    ///
1624    /// Returns a receipt proving the application, including a digest of the
1625    /// conversation state after mutation.
1626    async fn apply(
1627        &mut self,
1628        run_id: RunId,
1629        primitive: RunPrimitive,
1630    ) -> Result<CoreApplyOutput, CoreExecutorError>;
1631
1632    /// Persist or project the committed session snapshot after the runtime
1633    /// control plane has durably committed the machine boundary.
1634    ///
1635    /// RuntimeStore remains the authority for runtime-backed turns; this hook
1636    /// is for compatibility projections such as `SessionStore` snapshots that
1637    /// must not be written before the machine commit succeeds. Recovery may
1638    /// invoke this with the authoritative RuntimeStore snapshot after outbox
1639    /// finalization so a stale compatibility snapshot cannot resurrect an
1640    /// already-finalized compaction intent.
1641    async fn checkpoint_committed_session_snapshot(
1642        &mut self,
1643        _session_snapshot: std::sync::Arc<Vec<u8>>,
1644    ) -> Result<(), CoreExecutorError> {
1645        Ok(())
1646    }
1647
1648    /// Acknowledge the exact store-issued authority for a committed session
1649    /// boundary.
1650    ///
1651    /// The runtime validates this authority against the prepared boundary
1652    /// before invoking the executor. Durable implementations publish
1653    /// executor-owned post-commit effects and advance actor-local fencing from
1654    /// this bounded carrier; ordinary finalization must not reload or compare
1655    /// the accumulated document.
1656    ///
1657    /// Generic/ephemeral executors retain a rejecting default because they
1658    /// never receive store authority. In particular, WholeBlob is not a silent
1659    /// no-op: every store-backed executor must explicitly implement this one
1660    /// hook.
1661    async fn acknowledge_committed_session_boundary(
1662        &mut self,
1663        _authority: &CommittedSessionBoundaryAuthority,
1664    ) -> Result<(), CoreExecutorError> {
1665        Err(CoreExecutorError::Internal(
1666            "executor cannot acknowledge a store-owned session boundary".to_string(),
1667        ))
1668    }
1669
1670    /// Reconcile and finalize semantic-memory compaction stages named by the
1671    /// exact RuntimeStore atomic outbox. The empty slice is authoritative: a
1672    /// durable implementation must use it to abort any invisible stage left by
1673    /// a crash before the runtime boundary committed.
1674    async fn reconcile_committed_compaction_projections(
1675        &mut self,
1676        intents: &[crate::memory::CompactionProjectionIntent],
1677    ) -> Result<(), CoreExecutorError> {
1678        if intents.is_empty() {
1679            Ok(())
1680        } else {
1681            Err(CoreExecutorError::Internal(
1682                "executor cannot reconcile committed compaction projections".to_string(),
1683            ))
1684        }
1685    }
1686
1687    /// Roll back and abort any invisible compaction stage after the runtime
1688    /// boundary commit was rejected and the authoritative outbox was observed
1689    /// empty. This is deliberately separate from committed reconciliation so
1690    /// an empty post-error observation can never be mistaken for commit
1691    /// authority.
1692    async fn abort_uncommitted_compaction_projections(&mut self) -> Result<(), CoreExecutorError> {
1693        Ok(())
1694    }
1695
1696    /// Abort every executor-owned projection staged by a run whose atomic
1697    /// runtime boundary was rejected.
1698    ///
1699    /// The default preserves compatibility with executors that can stage only
1700    /// compaction. Runtime-backed session executors override this to also
1701    /// remove any uncommitted live transcript and context-event projections.
1702    /// Implementations must be cancellation-safe and retry-idempotent: once an
1703    /// attempt observes one sub-projection aborted, cancellation before the
1704    /// whole cleanup returns must leave enough mechanical progress to continue
1705    /// without requiring an already-discarded live carrier.
1706    async fn abort_rejected_run_projections(&mut self) -> Result<(), CoreExecutorError> {
1707        self.abort_uncommitted_compaction_projections().await
1708    }
1709
1710    /// Durably publish exact per-input Interaction terminal events after
1711    /// generated runtime completion authority has observed finalization.
1712    /// Implementations must make replay idempotent by interaction ID and
1713    /// reject a mismatching existing payload.
1714    async fn publish_interaction_terminals(
1715        &mut self,
1716        events: &[AgentEvent],
1717    ) -> Result<Vec<CoreInteractionTerminalPublicationReceipt>, CoreExecutorError> {
1718        if events.is_empty() {
1719            return Ok(Vec::new());
1720        }
1721        Err(CoreExecutorError::Internal(
1722            "exact interaction terminal publication is unsupported by this executor".to_string(),
1723        ))
1724    }
1725
1726    /// Request cancellation at the next cooperative boundary.
1727    async fn cancel_after_boundary(&mut self, reason: String) -> Result<(), CoreExecutorError>;
1728
1729    /// Ask this runtime executor to stop accepting work.
1730    async fn stop_runtime_executor(&mut self, reason: String) -> Result<(), CoreExecutorError>;
1731
1732    /// Cleanup of executor-owned external/session material that is safe only
1733    /// after the runtime control plane has durably terminalized the stop.
1734    ///
1735    /// This hook must not unregister the runtime session. The machine-owned
1736    /// runtime-loop cleanup coordinator invokes it; ordinary stop preserves
1737    /// the registered `Stopped` session, while explicit or executor-required
1738    /// unregister separately owns registration removal. Recursive unregister
1739    /// from this hook is rejected fail-closed.
1740    async fn cleanup_after_runtime_stop_terminalized(&mut self) -> Result<(), CoreExecutorError> {
1741        Ok(())
1742    }
1743}
1744
1745#[cfg(test)]
1746#[allow(clippy::panic)]
1747mod tests {
1748    use super::*;
1749
1750    // Verify CoreExecutor is object-safe
1751    fn _assert_object_safe(_: &dyn CoreExecutor) {}
1752
1753    #[test]
1754    fn prepared_session_boundary_serializes_exactly_once_across_clones() {
1755        let Ok(commit) = BoundSessionCommit::sealed(std::sync::Arc::new(crate::Session::new()))
1756        else {
1757            panic!("sealing a typed boundary no longer serializes and cannot fail");
1758        };
1759        let cloned = commit.clone();
1760
1761        assert_eq!(commit.whole_blob_encode_count(), 0);
1762        assert!(commit.whole_blob_bytes().is_ok());
1763        assert!(cloned.whole_blob_bytes().is_ok());
1764        assert_eq!(commit.whole_blob_encode_count(), 1);
1765        assert_eq!(cloned.whole_blob_encode_count(), 1);
1766    }
1767
1768    #[test]
1769    #[allow(clippy::expect_used)]
1770    fn prepared_serialized_artifact_is_retained_without_copy_or_rehash() {
1771        let artifact = std::sync::Arc::new(crate::SerializedSessionArtifact::from_raw_bytes(
1772            br#"{"exact":"artifact"}"#.to_vec(),
1773        ));
1774        let commit = BoundSessionCommit::from_serialized_artifact(std::sync::Arc::clone(&artifact));
1775        let retained = commit
1776            .whole_blob_artifact()
1777            .expect("pre-serialized artifact remains immediately available");
1778
1779        assert!(std::ptr::eq(retained, artifact.as_ref()));
1780        assert_eq!(commit.whole_blob_encode_count(), 0);
1781        assert!(commit.session().is_none());
1782    }
1783
1784    #[test]
1785    fn core_executor_error_display() {
1786        let err = CoreExecutorError::ApplyFailed {
1787            cause: CoreApplyFailureCause::runtime_turn("bad input"),
1788        };
1789        assert_eq!(err.to_string(), "Apply failed: bad input");
1790
1791        let err = CoreExecutorError::ControlFailed {
1792            cause: CoreControlFailureCause::runtime_control("not running"),
1793        };
1794        assert_eq!(err.to_string(), "Control failed: not running");
1795
1796        let err = CoreExecutorError::Stopped;
1797        assert_eq!(err.to_string(), "Executor is stopped");
1798
1799        let err = CoreExecutorError::Cancelled;
1800        assert_eq!(err.to_string(), "Run was cancelled");
1801
1802        let err = CoreExecutorError::Internal("oops".into());
1803        assert_eq!(err.to_string(), "Internal error: oops");
1804    }
1805
1806    #[test]
1807    fn apply_failed_carries_typed_cause() {
1808        let err = CoreExecutorError::ApplyFailed {
1809            cause: CoreApplyFailureCause::runtime_context_apply("context write failed"),
1810        };
1811
1812        match err {
1813            CoreExecutorError::ApplyFailed { cause } => {
1814                assert_eq!(cause.kind, CoreApplyFailureCauseKind::RuntimeContextApply);
1815                assert_eq!(cause.message(), "context write failed");
1816            }
1817            other => panic!("expected typed apply failure, got {other:?}"),
1818        }
1819    }
1820
1821    #[test]
1822    fn cancelled_session_error_remains_typed_at_runtime_executor_boundary() {
1823        let err = CoreExecutorError::apply_failed_from_session_error(SessionError::Agent(
1824            AgentError::Cancelled,
1825        ));
1826
1827        assert!(err.is_cancelled());
1828        assert_eq!(
1829            err.apply_failure_cause().kind,
1830            CoreApplyFailureCauseKind::RuntimeTurn
1831        );
1832    }
1833
1834    #[test]
1835    fn corrupted_live_session_signal_stops_instead_of_retrying_apply() {
1836        let err = CoreExecutorError::apply_failed_from_session_error(
1837            SessionError::runtime_executor_stopped("terminal witness mismatch"),
1838        );
1839
1840        assert!(matches!(err, CoreExecutorError::Stopped));
1841    }
1842
1843    #[test]
1844    fn durable_projection_authority_unknown_requests_canonical_runtime_teardown() {
1845        let err = CoreExecutorError::apply_failed_from_session_error(SessionError::Agent(
1846            AgentError::session_durable_projection_authority_unknown(
1847                "durable transcript projection split",
1848            ),
1849        ));
1850
1851        assert!(err.requires_runtime_teardown());
1852        assert_eq!(
1853            CoreExecutorTeardownReason::DurableProjectionAuthorityUnknown.as_str(),
1854            "DurableProjectionAuthorityUnknown"
1855        );
1856        assert_eq!(
1857            CoreExecutorTeardownReason::from_wire_str("DurableProjectionAuthorityUnknown"),
1858            Some(CoreExecutorTeardownReason::DurableProjectionAuthorityUnknown)
1859        );
1860        assert!(matches!(
1861            err,
1862            CoreExecutorError::TeardownRequired {
1863                reason: CoreExecutorTeardownReason::DurableProjectionAuthorityUnknown,
1864                ..
1865            }
1866        ));
1867    }
1868
1869    #[test]
1870    fn hook_denial_agent_error_maps_to_typed_apply_failure_cause() {
1871        let error = AgentError::HookDenied {
1872            hook_id: crate::hooks::HookId::new("guard"),
1873            point: crate::hooks::HookPoint::PreToolExecution,
1874            reason_code: crate::hooks::HookReasonCode::PolicyViolation,
1875            message: "blocked by hook".to_string(),
1876            payload: None,
1877        };
1878
1879        let cause = CoreApplyFailureCause::from_agent_error(&error);
1880        assert_eq!(cause.kind, CoreApplyFailureCauseKind::HookDenied);
1881        assert!(cause.message().contains("blocked by hook"));
1882    }
1883
1884    #[test]
1885    fn hook_runtime_agent_error_maps_to_typed_apply_failure_cause() {
1886        let error = AgentError::HookExecutionFailed {
1887            hook_id: crate::hooks::HookId::new("guard"),
1888            reason: "missing runtime".to_string(),
1889        };
1890
1891        let cause = CoreApplyFailureCause::from_agent_error(&error);
1892        assert_eq!(cause.kind, CoreApplyFailureCauseKind::HookRuntimeFailure);
1893        assert!(cause.message().contains("missing runtime"));
1894    }
1895}