Skip to main content

meerkat_core/
handles.rs

1//! Cross-crate DSL handle traits.
2//!
3//! Downstream crates (`meerkat-mcp`, `meerkat-comms`, `meerkat-session`) drive
4//! DSL transitions through these trait objects without importing
5//! `meerkat-runtime`. Concrete impls live in `meerkat-runtime`, where the DSL
6//! authority lives.
7//!
8//! The mob side (`meerkat-mob`) already depends on `meerkat-runtime` and owns
9//! its MobMachine DSL authority in-crate, so it drives DSL transitions via
10//! direct `dsl_authority.apply(...)` calls — no cross-crate trait required.
11//!
12//! Trait methods are named per-DSL input, not per-authority input.
13//! DSL-owned discriminants (turn phase, drain mode, surface phase, surface
14//! pending/staged op, auth lease phase) flow as typed enums defined here in
15//! `meerkat-core` — each maps 1-to-1 with the typed DSL state that
16//! [`meerkat-runtime::meerkat_machine`] owns. Free-form `String` values are
17//! reserved for opaque identifiers (surface ids, binding keys, error
18//! messages).
19//!
20//! Return type is `Result<(), DslTransitionError>`. The DSL decides legality;
21//! phase/field reads happen elsewhere (direct DSL state accessors, not via
22//! these traits).
23
24#[cfg(all(meerkat_internal_generated_authority_bridge, not(test)))]
25use std::any::Any;
26use std::collections::BTreeSet;
27use std::sync::Arc;
28
29use crate::LoopState;
30use crate::auth::{RefreshFailureDisposition, RefreshFailureObservation};
31use crate::comms::InputSource;
32use crate::interaction::{
33    PeerIngressAdmission, PeerIngressDequeueAuthority, PeerIngressDequeueFacts,
34    PeerIngressEnvelopeFacts, PeerIngressPlainEventFacts, PeerIngressReceiveAuthority,
35    PeerIngressReceiveFacts,
36};
37use crate::lifecycle::run_primitive::ModelId;
38use crate::lifecycle::{InputId, RunId};
39use crate::ops::{AsyncOpRef, OperationId};
40use crate::peer_correlation::{
41    InboundPeerRequestState, InteractionStreamAbandonReason, InteractionStreamState,
42    OutboundPeerRequestState, PeerCorrelationId,
43};
44use crate::retry::LlmRetrySchedule;
45use crate::tool_scope::{
46    ExternalToolSurfaceBaseState, ExternalToolSurfaceDeltaOperation, ExternalToolSurfaceDeltaPhase,
47    ExternalToolSurfaceFailureCause, ExternalToolSurfaceGlobalPhase, ExternalToolSurfacePendingOp,
48    ExternalToolSurfaceStagedOp,
49};
50use crate::turn_execution_authority::{
51    ContentShape, TurnExecutionEffect, TurnExecutionInput, TurnFailureReason, TurnFailureSource,
52    TurnPhase, TurnPrimitiveKind, TurnTerminalCauseKind, TurnTerminalOutcome,
53};
54use crate::types::{HandlingMode, SessionId};
55
56// ---------------------------------------------------------------------------
57// Typed cross-crate enums for DSL-owned discriminants.
58//
59// Each maps 1-to-1 with the typed DSL state that meerkat-runtime's
60// MeerkatMachine / AuthMachine own. The runtime handle impls do a single
61// exhaustive `match` from DSL-typed to handle-typed — no string parsing,
62// no `_ => default` arms, no parallel adapters.
63// ---------------------------------------------------------------------------
64
65/// Mode for a comms drain task.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum DrainMode {
68    /// Legacy timed drain with idle timeout.
69    Timed,
70    /// Live session ingress while a runtime-backed session is attached.
71    AttachedSession,
72    /// Long-lived host drain (no idle timeout, respawnable on failure).
73    PersistentHost,
74}
75
76/// Reason a drain task exited.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum DrainExitReason {
79    IdleTimeout,
80    Dismissed,
81    Failed,
82    Aborted,
83    SessionShutdown,
84}
85
86/// Session model-routing baseline handle.
87///
88/// Runtime-backed surfaces create sessions before the factory has resolved the
89/// final LLM identity. The factory uses this handle after resolution so the DSL
90/// owns the canonical baseline model and capability surface before tools or
91/// visibility projections observe those facts.
92pub trait ModelRoutingHandle: Send + Sync {
93    /// Set the session's canonical model-routing baseline.
94    fn set_baseline(
95        &self,
96        baseline_model: ModelId,
97        realtime_capable: bool,
98    ) -> Result<(), DslTransitionError>;
99
100    /// Hydrate the session's canonical LLM capability surface.
101    ///
102    /// `profile` is a typed catalog observation. The generated machine decides
103    /// whether the paired capability-base filter is legal for that surface.
104    fn hydrate_llm_capability_surface(
105        &self,
106        identity: &crate::SessionLlmIdentity,
107        profile: Option<&crate::model_profile::ModelProfile>,
108        capability_base_filter: &crate::ToolFilter,
109    ) -> Result<(), DslTransitionError>;
110
111    /// Stage a machine-authorized sticky model fallback.
112    ///
113    /// The generated machine revalidates the accepted recovery attempt and
114    /// previous session identity, then atomically updates current identity,
115    /// capability truth, and the model-routing baseline. Runtime-backed agent
116    /// loops use this as the canonical commit inside their compensated
117    /// client/auth/machine transaction. `activation` is a one-shot, opaque
118    /// capability minted inside core only after generated recovery acceptance
119    /// and exact effective-registry validation. Public handle holders cannot
120    /// fabricate one from a raw or foreign registry witness.
121    fn stage_sticky_model_fallback(
122        &self,
123        activation: crate::StickyModelFallbackActivationProof,
124        visibility_plan: &StickyModelFallbackVisibilityPlan,
125    ) -> Result<Box<dyn StickyModelFallbackMachineCommit>, DslTransitionError>;
126}
127
128/// One-shot generated-authority commit staged for an exact sticky fallback.
129///
130/// Staging previews the generated transition against an exact authority
131/// snapshot without publishing it. Consuming this token commits only if that
132/// snapshot is still current, so a durable coordinator can place its session
133/// compare-and-swap between generated preauthorization and synchronous machine
134/// publication without allowing a caller to replay the transition.
135pub trait StickyModelFallbackMachineCommit: Send + Sync {
136    fn commit(self: Box<Self>) -> Result<(), DslTransitionError>;
137}
138
139/// Fully-derived visibility witness carried by a sticky model fallback.
140///
141/// This is the same contract used by live LLM reconfiguration: generated
142/// authority validates the previous state, target capability filter, visible
143/// `view_image` delta, and monotonic revision before it commits either the LLM
144/// identity or the canonical visibility state.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct StickyModelFallbackVisibilityPlan {
147    pub previous_state: crate::SessionToolVisibilityState,
148    pub next_state: crate::SessionToolVisibilityState,
149    pub view_image_tool_available: bool,
150    pub previous_view_image_visible: bool,
151    pub next_view_image_visible: bool,
152    pub committed_visible_set_changed: bool,
153    pub revision_bumped: bool,
154}
155
156/// Opaque durable-session control delta for one sticky fallback.
157///
158/// Only the core agent loop can construct this value. The runtime may validate
159/// and apply it to an exact persisted [`crate::Session`] snapshot, but cannot
160/// fabricate a different identity or visibility transition. Applying the delta
161/// changes only canonical control metadata; transcript messages are untouched.
162#[derive(Debug, Clone)]
163pub struct StickyModelFallbackControlDelta {
164    previous_identity: crate::SessionLlmIdentity,
165    target_identity: crate::SessionLlmIdentity,
166    persisted_visibility_parent: crate::SessionToolVisibilityState,
167    target_visibility_state: crate::SessionToolVisibilityState,
168}
169
170impl StickyModelFallbackControlDelta {
171    pub(crate) fn new(
172        previous_identity: crate::SessionLlmIdentity,
173        target_identity: crate::SessionLlmIdentity,
174        visibility_plan: &StickyModelFallbackVisibilityPlan,
175        persisted_visibility_parent: crate::SessionToolVisibilityState,
176    ) -> Self {
177        Self {
178            previous_identity,
179            target_identity,
180            persisted_visibility_parent,
181            target_visibility_state: visibility_plan.next_state.clone(),
182        }
183    }
184
185    pub fn previous_identity(&self) -> &crate::SessionLlmIdentity {
186        &self.previous_identity
187    }
188
189    pub fn target_identity(&self) -> &crate::SessionLlmIdentity {
190        &self.target_identity
191    }
192
193    pub fn previous_visibility_state(&self) -> &crate::SessionToolVisibilityState {
194        &self.persisted_visibility_parent
195    }
196
197    pub fn target_visibility_state(&self) -> &crate::SessionToolVisibilityState {
198        &self.target_visibility_state
199    }
200
201    /// Validate the exact persisted control parent and apply its target.
202    pub fn validate_and_apply(
203        &self,
204        session: &mut crate::Session,
205    ) -> Result<(), StickyModelFallbackControlDeltaError> {
206        let mut metadata = session
207            .try_session_metadata()
208            .map_err(|error| {
209                StickyModelFallbackControlDeltaError::InvalidSessionMetadata(error.to_string())
210            })?
211            .ok_or(StickyModelFallbackControlDeltaError::MissingSessionMetadata)?;
212        let current_identity = metadata.llm_identity();
213        if current_identity != self.previous_identity {
214            return Err(
215                StickyModelFallbackControlDeltaError::IdentityParentMismatch {
216                    expected: Box::new(self.previous_identity.clone()),
217                    actual: Box::new(current_identity),
218                },
219            );
220        }
221        let current_visibility = session
222            .try_tool_visibility_state()
223            .map_err(|error| {
224                StickyModelFallbackControlDeltaError::InvalidVisibilityMetadata(error.to_string())
225            })?
226            .ok_or(StickyModelFallbackControlDeltaError::MissingVisibilityMetadata)?;
227        if current_visibility != self.persisted_visibility_parent {
228            return Err(StickyModelFallbackControlDeltaError::VisibilityParentMismatch);
229        }
230
231        metadata.apply_llm_identity(&self.target_identity);
232        session.set_session_metadata(metadata).map_err(|error| {
233            StickyModelFallbackControlDeltaError::InvalidSessionMetadata(error.to_string())
234        })?;
235        session
236            .set_tool_visibility_state(
237                crate::AuthorizedSessionToolVisibilityState::from_generated_authority(
238                    self.target_visibility_state.clone(),
239                ),
240            )
241            .map_err(|error| {
242                StickyModelFallbackControlDeltaError::InvalidVisibilityMetadata(error.to_string())
243            })?;
244        Ok(())
245    }
246}
247
248#[derive(Debug, Clone, thiserror::Error)]
249pub enum StickyModelFallbackControlDeltaError {
250    #[error("persisted session has no canonical LLM identity metadata")]
251    MissingSessionMetadata,
252    #[error("persisted session has no canonical tool visibility metadata")]
253    MissingVisibilityMetadata,
254    #[error("persisted session LLM identity parent does not match the staged fallback")]
255    IdentityParentMismatch {
256        expected: Box<crate::SessionLlmIdentity>,
257        actual: Box<crate::SessionLlmIdentity>,
258    },
259    #[error("persisted session tool visibility parent does not match the staged fallback")]
260    VisibilityParentMismatch,
261    #[error("persisted session LLM identity metadata is invalid: {0}")]
262    InvalidSessionMetadata(String),
263    #[error("persisted session tool visibility metadata is invalid: {0}")]
264    InvalidVisibilityMetadata(String),
265}
266
267/// Resultful, cancellation-safe durable sticky-fallback handoff.
268pub trait StickyModelFallbackCommitCoordinator: Send + Sync {
269    fn begin(
270        &self,
271        machine_commit: Box<dyn StickyModelFallbackMachineCommit>,
272        control_delta: StickyModelFallbackControlDelta,
273    ) -> Result<Arc<dyn StickyModelFallbackCommitOperation>, StickyModelFallbackCommitError>;
274}
275
276/// Join handle for one supervised sticky-fallback transaction.
277///
278/// Dropping a `wait` future does not cancel the transaction. Callers may wait
279/// again through the retained operation and observe the same terminal result.
280#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
281#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
282pub trait StickyModelFallbackCommitOperation: Send + Sync {
283    async fn wait(
284        &self,
285    ) -> Result<Option<crate::SessionControlCommitReceipt>, StickyModelFallbackCommitError>;
286}
287
288#[derive(Debug, Clone, thiserror::Error)]
289pub enum StickyModelFallbackCommitError {
290    #[error("durable sticky fallback is unavailable without a RuntimeStore")]
291    StoreUnavailable,
292    #[error("durable sticky fallback session snapshot is missing for {session_id}")]
293    SnapshotMissing { session_id: crate::SessionId },
294    #[error("durable sticky fallback session snapshot is invalid: {0}")]
295    SnapshotInvalid(String),
296    #[error("durable sticky fallback snapshot belongs to {actual}, expected {expected}")]
297    SessionMismatch {
298        expected: crate::SessionId,
299        actual: crate::SessionId,
300    },
301    #[error(transparent)]
302    InvalidControlDelta(StickyModelFallbackControlDeltaError),
303    #[error("durable sticky fallback store operation failed before commit: {0}")]
304    Store(String),
305    #[error("durable sticky fallback compare-and-swap observed a competing snapshot")]
306    SnapshotConflict,
307    #[error("durable sticky fallback compare-and-swap outcome is unknown: {0}")]
308    SnapshotOutcomeUnknown(String),
309    #[error("generated authority rejected the staged sticky fallback: {0}")]
310    MachineRejected(DslTransitionError),
311    #[error(
312        "generated authority rejected the staged sticky fallback and durable compensation failed: {0}"
313    )]
314    CompensationFailed(String),
315    #[error("durable sticky fallback supervisor ended without a retained result")]
316    SupervisorLost,
317}
318
319impl StickyModelFallbackCommitError {
320    /// Whether the coordinator could not prove a single authoritative parent
321    /// or compensation result. These outcomes require canonical executor
322    /// teardown instead of ordinary failed-batch retry.
323    pub fn requires_teardown(&self) -> bool {
324        matches!(
325            self,
326            Self::SnapshotConflict
327                | Self::SnapshotOutcomeUnknown(_)
328                | Self::CompensationFailed(_)
329                | Self::SupervisorLost
330        )
331    }
332}
333
334#[cfg(test)]
335#[allow(clippy::unwrap_used)]
336mod sticky_model_fallback_control_delta_tests {
337    use super::*;
338    use crate::{
339        Message, Provider, SESSION_METADATA_SCHEMA_VERSION, Session, SessionMetadata,
340        SessionTooling, ToolFilter, UserMessage,
341    };
342
343    fn identity(model: &str) -> crate::SessionLlmIdentity {
344        crate::SessionLlmIdentity {
345            model: model.to_string(),
346            provider: Provider::OpenAI,
347            self_hosted_server_id: None,
348            provider_params: None,
349            auth_binding: None,
350        }
351    }
352
353    fn session_with_control_state(
354        identity: &crate::SessionLlmIdentity,
355        visibility: &crate::SessionToolVisibilityState,
356    ) -> Session {
357        let mut session = Session::new();
358        session
359            .set_session_metadata(SessionMetadata {
360                schema_version: SESSION_METADATA_SCHEMA_VERSION,
361                model: identity.model.clone(),
362                max_tokens: 4096,
363                structured_output_retries: 2,
364                provider: identity.provider,
365                self_hosted_server_id: identity.self_hosted_server_id.clone(),
366                provider_params: identity.provider_params.clone(),
367                tooling: SessionTooling::default(),
368                keep_alive: true,
369                comms_name: None,
370                peer_meta: None,
371                realm_id: None,
372                instance_id: None,
373                backend: None,
374                config_generation: Some(7),
375                auth_binding: identity.auth_binding.clone(),
376                mob_member_binding: None,
377            })
378            .unwrap();
379        session
380            .set_tool_visibility_state(
381                crate::AuthorizedSessionToolVisibilityState::from_generated_authority(
382                    visibility.clone(),
383                ),
384            )
385            .unwrap();
386        session.push(Message::User(UserMessage::text(
387            "uncommitted turn must not appear",
388        )));
389        session
390    }
391
392    #[test]
393    fn control_delta_changes_only_identity_and_typed_visibility() {
394        let previous = identity("primary");
395        let target = identity("backup");
396        let previous_visibility = crate::SessionToolVisibilityState::default();
397        let mut target_visibility = previous_visibility.clone();
398        target_visibility.capability_base_filter = ToolFilter::Deny(
399            [crate::VIEW_IMAGE_TOOL_NAME.to_string()]
400                .into_iter()
401                .collect(),
402        );
403        target_visibility.active_revision = 1;
404        target_visibility.staged_revision = 1;
405        let plan = StickyModelFallbackVisibilityPlan {
406            previous_state: previous_visibility.clone(),
407            next_state: target_visibility.clone(),
408            view_image_tool_available: true,
409            previous_view_image_visible: true,
410            next_view_image_visible: false,
411            committed_visible_set_changed: true,
412            revision_bumped: true,
413        };
414        let delta = StickyModelFallbackControlDelta::new(
415            previous,
416            target.clone(),
417            &plan,
418            previous_visibility,
419        );
420        let mut session = session_with_control_state(
421            delta.previous_identity(),
422            delta.previous_visibility_state(),
423        );
424        let messages_before = session.messages().to_vec();
425        let total_tokens_before = session.total_tokens();
426        let unrelated_generation = session
427            .session_metadata()
428            .and_then(|metadata| metadata.config_generation);
429
430        delta.validate_and_apply(&mut session).unwrap();
431
432        assert_eq!(session.messages(), messages_before);
433        assert_eq!(session.total_tokens(), total_tokens_before);
434        let metadata = session.session_metadata().unwrap();
435        assert_eq!(metadata.llm_identity(), target);
436        assert_eq!(metadata.config_generation, unrelated_generation);
437        assert_eq!(
438            session.tool_visibility_state().unwrap(),
439            Some(target_visibility)
440        );
441    }
442
443    #[test]
444    fn control_delta_rejects_a_non_parent_without_mutation() {
445        let previous = identity("primary");
446        let target = identity("backup");
447        let visibility = crate::SessionToolVisibilityState::default();
448        let plan = StickyModelFallbackVisibilityPlan {
449            previous_state: visibility.clone(),
450            next_state: visibility.clone(),
451            view_image_tool_available: false,
452            previous_view_image_visible: false,
453            next_view_image_visible: false,
454            committed_visible_set_changed: false,
455            revision_bumped: false,
456        };
457        let delta =
458            StickyModelFallbackControlDelta::new(previous, target, &plan, visibility.clone());
459        let mut session = session_with_control_state(&identity("different"), &visibility);
460        let bytes_before = serde_json::to_vec(&session).unwrap();
461
462        assert!(matches!(
463            delta.validate_and_apply(&mut session),
464            Err(StickyModelFallbackControlDeltaError::IdentityParentMismatch { .. })
465        ));
466        assert_eq!(serde_json::to_vec(&session).unwrap(), bytes_before);
467    }
468
469    #[test]
470    fn control_delta_accepts_exact_persisted_pre_boundary_visibility_parent() {
471        let previous = identity("primary");
472        let target = identity("backup");
473        let mut persisted_visibility = crate::SessionToolVisibilityState {
474            staged_filter: ToolFilter::Deny(["shell".to_string()].into_iter().collect()),
475            staged_revision: 1,
476            ..Default::default()
477        };
478        persisted_visibility.staged_requested_deferred_names =
479            [crate::ToolName::from("deferred")].into_iter().collect();
480        let promoted_visibility = persisted_visibility.projected_boundary_applied();
481        let mut target_visibility = promoted_visibility.clone();
482        target_visibility.capability_base_filter = ToolFilter::Deny(
483            [crate::VIEW_IMAGE_TOOL_NAME.to_string()]
484                .into_iter()
485                .collect(),
486        );
487        target_visibility.active_revision = 2;
488        target_visibility.staged_revision = 2;
489        let plan = StickyModelFallbackVisibilityPlan {
490            previous_state: promoted_visibility,
491            next_state: target_visibility.clone(),
492            view_image_tool_available: true,
493            previous_view_image_visible: true,
494            next_view_image_visible: false,
495            committed_visible_set_changed: true,
496            revision_bumped: true,
497        };
498        let delta = StickyModelFallbackControlDelta::new(
499            previous,
500            target.clone(),
501            &plan,
502            persisted_visibility.clone(),
503        );
504        let mut session =
505            session_with_control_state(delta.previous_identity(), &persisted_visibility);
506
507        delta.validate_and_apply(&mut session).unwrap();
508
509        assert_eq!(session.session_metadata().unwrap().llm_identity(), target);
510        assert_eq!(
511            session.tool_visibility_state().unwrap(),
512            Some(target_visibility)
513        );
514    }
515}
516
517impl DrainExitReason {
518    /// Stable discriminant for wire logging (drain exit reason is not yet a
519    /// typed DSL field; the handle passes the discriminant through).
520    pub const fn as_str(self) -> &'static str {
521        match self {
522            Self::IdleTimeout => "IdleTimeout",
523            Self::Dismissed => "Dismissed",
524            Self::Failed => "Failed",
525            Self::Aborted => "Aborted",
526            Self::SessionShutdown => "SessionShutdown",
527        }
528    }
529}
530
531/// Auth lease lifecycle phase, projected from the per-binding AuthMachine.
532#[derive(Debug, Clone, Copy, PartialEq, Eq)]
533pub enum AuthLeasePhase {
534    Valid,
535    Expiring,
536    Expired,
537    Refreshing,
538    ReauthRequired,
539    Released,
540}
541
542/// Typed credential-use intent fed by the resolver shell to the AuthMachine's
543/// credential-use admission classifier.
544///
545/// Identifies WHICH credential gate is asking — never a policy decision. The
546/// per-binding AuthMachine owns the `(lifecycle_phase, credential_present,
547/// intent)` -> [`CredentialUseDisposition`] verdict; the resolver shell extracts
548/// only this typed intent and mirrors the emitted verdict.
549#[derive(Debug, Clone, Copy, PartialEq, Eq)]
550pub enum CredentialUseIntent {
551    /// Resolver "use the credential now" read.
552    UseCredential,
553    /// Resolver post-publish lifecycle-authority gate.
554    HoldAuthority,
555    /// Resolver OAuth-refresh begin gate.
556    BeginRefresh,
557}
558
559/// Machine-owned credential-use disposition the resolver shell mirrors.
560///
561/// Decided by the per-binding AuthMachine's credential-use admission classifier
562/// from its own `(lifecycle_phase, credential_present)` plus the shell-supplied
563/// [`CredentialUseIntent`]. The resolver shell maps each variant to its existing
564/// behavior/error and never decides the disposition itself.
565#[derive(Debug, Clone, Copy, PartialEq, Eq)]
566pub enum CredentialUseDisposition {
567    /// Credential may be used / lifecycle authority is held.
568    Authorized,
569    /// Caller must refresh first.
570    RefreshRequired,
571    /// A refresh is required to proceed but the binding's config does not permit
572    /// silent refresh (`allow_refresh == false`); the caller surfaces a
573    /// refresh-required error instead of beginning a refresh. Only emitted by the
574    /// OAuth-login cached-vs-refresh disposition.
575    RefreshDisallowed,
576    /// Interactive user reauthorization is required.
577    ReauthRequired,
578    /// No usable lease is present.
579    LeaseAbsent,
580    /// A refresh is already in flight; the begin-refresh caller no-ops.
581    AlreadyRefreshing,
582}
583
584/// Pure provider-runtime observations fed to the AuthMachine's OAuth-login
585/// cached-vs-refresh disposition classifier.
586///
587/// The provider runtime shell holds these three facts and never composes them
588/// into a disposition itself: the per-binding AuthMachine owns the
589/// `(lifecycle_phase, self.credential_present, credential_present, force_refresh,
590/// refresh_allowed)` -> [`CredentialUseDisposition`] policy and the shell mirrors
591/// the emitted verdict.
592#[derive(Debug, Clone, Copy, PartialEq, Eq)]
593pub struct OAuthLoginCredentialFacts {
594    /// Whether a persisted credential secret is present
595    /// (`persisted.primary_secret.is_some()`).
596    pub credential_present: bool,
597    /// Whether the caller forced a refresh (`env.force_refresh`).
598    pub force_refresh: bool,
599    /// Whether the binding config permits silent refresh
600    /// (`refresh_allowed(binding)` / `allow_refresh`).
601    pub refresh_allowed: bool,
602}
603
604/// Typed classification of why a DSL transition was rejected.
605///
606/// Emitted by the generated kernel's `apply` / `apply_signal` methods and
607/// bridged into [`DslTransitionError::kind`]. Callers that fire
608/// idempotently (realtime dispatchers, monotonic watermark advances,
609/// etc.) inspect this to distinguish "input was out of scope for this
610/// phase" (a real error) from "input was recognised but the guard dropped
611/// it" (a successful no-op).
612#[derive(Debug, Clone, Copy, PartialEq, Eq)]
613pub enum DslRejectionKind {
614    /// No transition is declared for this `(phase, trigger)` pair — the
615    /// shell fired an input that is semantically out of scope for the
616    /// current phase. This is a programming mistake on the shell side.
617    NoMatchingTransition,
618    /// A transition is declared for this `(phase, trigger)` pair but
619    /// every candidate transition's guard evaluated false. Callers
620    /// firing idempotently treat this as a no-op; callers firing
621    /// unconditionally treat it as a user-visible error.
622    GuardRejected,
623    /// Generated authority rejected recovered state before any transition
624    /// was attempted. This is not an idempotent transition guard no-op.
625    RecoveredStateInvariantRejected,
626}
627
628/// Error surfaced when a DSL transition is rejected.
629///
630/// Wraps the generated kernel's typed rejection. Trait impls populate
631/// `context` from the trait method name so callers can tell which handle
632/// rejected; `kind` lets callers distinguish guard rejection from
633/// out-of-scope input without substring-matching the rendered message.
634#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
635#[error("DSL transition rejected in {context}: {reason}")]
636pub struct DslTransitionError {
637    /// Name of the trait method / DSL variant whose transition was rejected.
638    pub context: &'static str,
639    /// Typed classification of the rejection — see [`DslRejectionKind`].
640    pub kind: DslRejectionKind,
641    /// Underlying rejection reason (typically the generated
642    /// `NoMatchingTransition`/`GuardRejected` formatted).
643    pub reason: String,
644}
645
646impl DslTransitionError {
647    /// Construct an error with `kind = NoMatchingTransition`.
648    pub fn no_matching(context: &'static str, reason: impl Into<String>) -> Self {
649        Self {
650            context,
651            kind: DslRejectionKind::NoMatchingTransition,
652            reason: reason.into(),
653        }
654    }
655
656    /// Construct an error with `kind = GuardRejected`.
657    pub fn guard_rejected(context: &'static str, reason: impl Into<String>) -> Self {
658        Self {
659            context,
660            kind: DslRejectionKind::GuardRejected,
661            reason: reason.into(),
662        }
663    }
664
665    /// Construct an error with `kind = RecoveredStateInvariantRejected`.
666    pub fn recovered_state_invariant_rejected(
667        context: &'static str,
668        reason: impl Into<String>,
669    ) -> Self {
670        Self {
671            context,
672            kind: DslRejectionKind::RecoveredStateInvariantRejected,
673            reason: reason.into(),
674        }
675    }
676
677    /// True iff this rejection came from a guard evaluating false.
678    pub fn is_guard_rejected(&self) -> bool {
679        self.kind == DslRejectionKind::GuardRejected
680    }
681}
682
683// ---------------------------------------------------------------------------
684// Cross-crate peer prompt/context projection seam
685// ---------------------------------------------------------------------------
686
687#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
688#[serde(rename_all = "snake_case")]
689pub enum PeerResponseProgressProjectionPhase {
690    Accepted,
691    InProgress,
692    PartialResult,
693}
694
695impl PeerResponseProgressProjectionPhase {
696    fn label(self) -> &'static str {
697        match self {
698            Self::Accepted => "accepted",
699            Self::InProgress => "in_progress",
700            Self::PartialResult => "partial_result",
701        }
702    }
703}
704
705#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
706#[serde(rename_all = "snake_case")]
707pub enum PeerResponseTerminalProjectionStatus {
708    Completed,
709    Failed,
710    Cancelled,
711}
712
713impl PeerResponseTerminalProjectionStatus {
714    pub fn label(self) -> &'static str {
715        match self {
716            Self::Completed => "completed",
717            Self::Failed => "failed",
718            Self::Cancelled => "cancelled",
719        }
720    }
721}
722
723#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
724pub enum PeerResponseTerminalFactError {
725    #[error("transport identity cannot be empty")]
726    EmptyTransportIdentity,
727    #[error("route identity cannot be empty")]
728    EmptyRouteIdentity,
729    #[error("route identity must be a canonical peer UUID")]
730    InvalidRouteIdentity,
731    #[error("display identity is required")]
732    MissingDisplayIdentity,
733    #[error("display identity cannot be empty")]
734    EmptyDisplayIdentity,
735    #[error("display identity cannot contain control characters")]
736    InvalidDisplayIdentity,
737    #[error("correlation id cannot be empty")]
738    EmptyCorrelationId,
739    #[error("correlation id must be a UUID: {input}")]
740    InvalidCorrelationId { input: String },
741}
742
743#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
744#[serde(transparent)]
745pub struct PeerResponseTerminalTransportIdentity(String);
746
747impl PeerResponseTerminalTransportIdentity {
748    pub fn parse(raw: impl Into<String>) -> Result<Self, PeerResponseTerminalFactError> {
749        let raw = raw.into();
750        if raw.trim().is_empty() {
751            return Err(PeerResponseTerminalFactError::EmptyTransportIdentity);
752        }
753        Ok(Self(raw))
754    }
755
756    pub fn as_str(&self) -> &str {
757        &self.0
758    }
759}
760
761impl std::fmt::Display for PeerResponseTerminalTransportIdentity {
762    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
763        self.0.fmt(f)
764    }
765}
766
767#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
768#[serde(transparent)]
769pub struct PeerResponseTerminalRouteIdentity(crate::comms::PeerId);
770
771impl PeerResponseTerminalRouteIdentity {
772    pub const fn from_peer_id(peer_id: crate::comms::PeerId) -> Self {
773        Self(peer_id)
774    }
775
776    pub fn parse(raw: impl Into<String>) -> Result<Self, PeerResponseTerminalFactError> {
777        let raw = raw.into();
778        if raw.trim().is_empty() {
779            return Err(PeerResponseTerminalFactError::EmptyRouteIdentity);
780        }
781        if raw.chars().any(char::is_control) {
782            return Err(PeerResponseTerminalFactError::InvalidRouteIdentity);
783        }
784        let peer_id = crate::comms::PeerId::parse(raw.trim())
785            .map_err(|_| PeerResponseTerminalFactError::InvalidRouteIdentity)?;
786        Ok(Self(peer_id))
787    }
788
789    /// The canonical typed routing identity.
790    pub fn peer_id(&self) -> crate::comms::PeerId {
791        self.0
792    }
793
794    pub fn as_str(&self) -> String {
795        self.0.as_str()
796    }
797}
798
799impl std::fmt::Display for PeerResponseTerminalRouteIdentity {
800    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
801        self.0.fmt(f)
802    }
803}
804
805#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
806#[serde(transparent)]
807pub struct PeerResponseTerminalDisplayIdentity(String);
808
809impl PeerResponseTerminalDisplayIdentity {
810    pub fn parse(raw: impl Into<String>) -> Result<Self, PeerResponseTerminalFactError> {
811        let raw = raw.into();
812        if raw.trim().is_empty() {
813            return Err(PeerResponseTerminalFactError::EmptyDisplayIdentity);
814        }
815        if raw.chars().any(char::is_control) {
816            return Err(PeerResponseTerminalFactError::InvalidDisplayIdentity);
817        }
818        Ok(Self(raw))
819    }
820
821    pub fn as_str(&self) -> &str {
822        &self.0
823    }
824}
825
826impl std::fmt::Display for PeerResponseTerminalDisplayIdentity {
827    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
828        self.0.fmt(f)
829    }
830}
831
832#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
833#[serde(transparent)]
834pub struct PeerResponseTerminalCorrelationId(PeerCorrelationId);
835
836impl PeerResponseTerminalCorrelationId {
837    pub fn parse(raw: impl AsRef<str>) -> Result<Self, PeerResponseTerminalFactError> {
838        let raw = raw.as_ref();
839        if raw.trim().is_empty() {
840            return Err(PeerResponseTerminalFactError::EmptyCorrelationId);
841        }
842        uuid::Uuid::parse_str(raw)
843            .map(|uuid| Self(PeerCorrelationId::from_uuid(uuid)))
844            .map_err(|_| PeerResponseTerminalFactError::InvalidCorrelationId {
845                input: raw.to_string(),
846            })
847    }
848
849    pub const fn from_peer_correlation_id(correlation_id: PeerCorrelationId) -> Self {
850        Self(correlation_id)
851    }
852
853    pub const fn as_peer_correlation_id(self) -> PeerCorrelationId {
854        self.0
855    }
856}
857
858impl std::fmt::Display for PeerResponseTerminalCorrelationId {
859    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
860        self.0.fmt(f)
861    }
862}
863
864#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
865#[serde(transparent)]
866pub struct PeerResponseTerminalRenderPayload(Option<serde_json::Value>);
867
868impl PeerResponseTerminalRenderPayload {
869    pub fn new(payload: Option<serde_json::Value>) -> Self {
870        Self(payload)
871    }
872
873    pub fn as_ref(&self) -> Option<&serde_json::Value> {
874        self.0.as_ref()
875    }
876}
877
878impl From<Option<serde_json::Value>> for PeerResponseTerminalRenderPayload {
879    fn from(payload: Option<serde_json::Value>) -> Self {
880        Self::new(payload)
881    }
882}
883
884#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
885pub struct PeerResponseTerminalSource {
886    #[serde(default, skip_serializing_if = "Option::is_none")]
887    pub transport_identity: Option<PeerResponseTerminalTransportIdentity>,
888    pub route_identity: PeerResponseTerminalRouteIdentity,
889    pub display_identity: PeerResponseTerminalDisplayIdentity,
890}
891
892impl PeerResponseTerminalSource {
893    pub fn new(
894        transport_identity: Option<PeerResponseTerminalTransportIdentity>,
895        route_identity: PeerResponseTerminalRouteIdentity,
896        display_identity: PeerResponseTerminalDisplayIdentity,
897    ) -> Self {
898        Self {
899            transport_identity,
900            route_identity,
901            display_identity,
902        }
903    }
904
905    pub fn parse(
906        transport_identity: Option<impl Into<String>>,
907        route_identity: impl Into<String>,
908        display_identity: impl Into<String>,
909    ) -> Result<Self, PeerResponseTerminalFactError> {
910        Ok(Self::new(
911            transport_identity
912                .map(PeerResponseTerminalTransportIdentity::parse)
913                .transpose()?,
914            PeerResponseTerminalRouteIdentity::parse(route_identity)?,
915            PeerResponseTerminalDisplayIdentity::parse(display_identity)?,
916        ))
917    }
918}
919
920#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
921pub struct PeerResponseTerminalFact {
922    pub source: PeerResponseTerminalSource,
923    pub correlation_id: PeerResponseTerminalCorrelationId,
924    pub status: PeerResponseTerminalProjectionStatus,
925    pub render_payload: PeerResponseTerminalRenderPayload,
926}
927
928impl PeerResponseTerminalFact {
929    pub fn new(
930        source: PeerResponseTerminalSource,
931        correlation_id: PeerResponseTerminalCorrelationId,
932        status: PeerResponseTerminalProjectionStatus,
933        render_payload: PeerResponseTerminalRenderPayload,
934    ) -> Self {
935        Self {
936            source,
937            correlation_id,
938            status,
939            render_payload,
940        }
941    }
942
943    pub fn prompt_text(&self) -> String {
944        format!(
945            "Peer terminal response from {}. Request ID: {}. Status: {}. Result: {}.",
946            self.source.display_identity,
947            self.correlation_id,
948            self.status.label(),
949            format_peer_projection_payload(self.render_payload.as_ref())
950        )
951    }
952
953    pub fn context_key(&self) -> String {
954        Self::context_key_for(&self.source.route_identity, self.correlation_id)
955    }
956
957    /// Derive the canonical terminal context key from the typed identity facts
958    /// before the remaining render/status facts are available.
959    pub fn context_key_for(
960        route_identity: &PeerResponseTerminalRouteIdentity,
961        correlation_id: PeerResponseTerminalCorrelationId,
962    ) -> String {
963        peer_response_terminal_context_key(route_identity, correlation_id)
964    }
965
966    /// Typed render payload accessor for surfaces that summarize the terminal
967    /// fact directly instead of re-parsing the flattened prompt text.
968    pub fn render_payload_value(&self) -> Option<&serde_json::Value> {
969        self.render_payload.as_ref()
970    }
971}
972
973#[derive(Debug, Clone, PartialEq)]
974pub enum PeerConversationProjection {
975    Message {
976        peer_id: String,
977    },
978    Request {
979        peer_id: crate::comms::PeerId,
980        display_name: Option<String>,
981        request_id: String,
982        intent: String,
983        payload: Option<serde_json::Value>,
984    },
985    ResponseProgress {
986        peer_id: String,
987        request_id: String,
988        phase: PeerResponseProgressProjectionPhase,
989        payload: Option<serde_json::Value>,
990    },
991    ResponseTerminal {
992        fact: PeerResponseTerminalFact,
993    },
994}
995
996impl PeerConversationProjection {
997    pub fn response_terminal(fact: PeerResponseTerminalFact) -> Self {
998        Self::ResponseTerminal { fact }
999    }
1000
1001    pub fn block_prefix_text(&self) -> Option<String> {
1002        match self {
1003            Self::Message { peer_id } => Some(format!("Peer message from {peer_id}")),
1004            Self::Request { .. }
1005            | Self::ResponseProgress { .. }
1006            | Self::ResponseTerminal { .. } => None,
1007        }
1008    }
1009
1010    pub fn prompt_text(&self) -> String {
1011        match self {
1012            Self::Message { .. } => String::new(),
1013            Self::Request {
1014                peer_id,
1015                display_name,
1016                request_id,
1017                intent,
1018                payload,
1019            } => {
1020                let display_suffix = display_name
1021                    .as_deref()
1022                    .map(str::trim)
1023                    .filter(|name| !name.is_empty())
1024                    .map(|name| format!(" (display_name: {name})"))
1025                    .unwrap_or_default();
1026                let response_call = crate::interaction::SendResponseCallProjection::new(
1027                    *peer_id,
1028                    display_name.as_deref(),
1029                    request_id.clone(),
1030                );
1031                format!(
1032                    "Peer request from peer_id {peer_id}{display_suffix}. Intent: {intent}. Request ID: {request_id}. Params: {}. This is not a normal user request and not a prompt for direct user-facing output. {} Do not use send_message for this reply.",
1033                    format_peer_projection_payload(payload.as_ref()),
1034                    response_call.instruction_text()
1035                )
1036            }
1037            Self::ResponseProgress {
1038                peer_id,
1039                request_id,
1040                phase,
1041                payload,
1042            } => format!(
1043                "Peer response progress from {peer_id}. Request ID: {request_id}. Phase: {}. Payload: {}.",
1044                phase.label(),
1045                format_peer_projection_payload(payload.as_ref())
1046            ),
1047            Self::ResponseTerminal { fact } => fact.prompt_text(),
1048        }
1049    }
1050
1051    pub fn context_key(&self) -> Option<String> {
1052        match self {
1053            Self::ResponseTerminal { fact } => Some(fact.context_key()),
1054            Self::Message { .. } | Self::Request { .. } | Self::ResponseProgress { .. } => None,
1055        }
1056    }
1057}
1058
1059pub fn peer_response_terminal_context_key(
1060    route_identity: &PeerResponseTerminalRouteIdentity,
1061    correlation_id: PeerResponseTerminalCorrelationId,
1062) -> String {
1063    format!("peer_response_terminal:{route_identity}:{correlation_id}")
1064}
1065
1066fn format_peer_projection_payload(payload: Option<&serde_json::Value>) -> String {
1067    serde_json::to_string_pretty(payload.unwrap_or(&serde_json::Value::Null))
1068        .unwrap_or_else(|_| "null".to_string())
1069}
1070
1071// ---------------------------------------------------------------------------
1072// TurnStateHandle
1073// ---------------------------------------------------------------------------
1074
1075#[derive(Debug, Clone, PartialEq, Eq)]
1076pub struct TurnStateSnapshot {
1077    pub active_run_id: Option<RunId>,
1078    /// Exact run whose terminal outcome/cause is projected below. Unlike
1079    /// `active_run_id`, this remains populated after the terminal transition
1080    /// clears the active binding.
1081    pub terminal_run_id: Option<RunId>,
1082    /// Observable loop-state projection supplied by the turn-state owner.
1083    ///
1084    /// Consumers should not reclassify [`TurnPhase`] locally. Runtime-backed
1085    /// handles derive this from the same DSL snapshot as `turn_phase`; test
1086    /// handles do the same from their in-core test state.
1087    pub loop_state: LoopState,
1088    pub turn_phase: TurnPhase,
1089    /// Machine-owned turn-terminality verdict.
1090    ///
1091    /// Consumers must not reclassify [`TurnPhase`] locally. This bool is the
1092    /// `TurnTerminalityClassified.terminal` verdict emitted by the canonical
1093    /// MeerkatMachine `ClassifyTurnTerminality` input over the same DSL snapshot
1094    /// as `turn_phase`; the turn-state owner mirrors it, failing closed.
1095    pub turn_terminal: bool,
1096    /// Typed primitive kind recorded by the DSL (dogma #5, #19 — no stringly
1097    /// discriminants). `None` means no primitive is currently in flight.
1098    pub primitive_kind: Option<TurnPrimitiveKind>,
1099    pub admitted_content_shape: Option<ContentShape>,
1100    pub vision_enabled: bool,
1101    pub image_tool_results_enabled: bool,
1102    pub tool_calls_pending: u64,
1103    pub pending_op_refs: BTreeSet<AsyncOpRef>,
1104    pub barrier_operation_ids: BTreeSet<OperationId>,
1105    pub has_barrier_ops: bool,
1106    pub barrier_satisfied: bool,
1107    pub boundary_count: u64,
1108    pub cancel_after_boundary: bool,
1109    /// Typed terminal outcome recorded by the DSL (dogma #5, #19 — no stringly
1110    /// discriminants). `None` means the turn has not reached a terminal phase.
1111    pub terminal_outcome: Option<TurnTerminalOutcome>,
1112    /// Typed terminal cause recorded by the DSL. `None` means no failure cause
1113    /// has been selected for the current turn.
1114    pub terminal_cause_kind: Option<TurnTerminalCauseKind>,
1115    pub extraction_attempts: u64,
1116    pub max_extraction_retries: u64,
1117    /// Machine-owned total answer to "is this turn inside the
1118    /// structured-output extraction sub-flow" (dogma K9). Set by
1119    /// `EnterExtraction`, cleared on every turn-terminal transition and on
1120    /// run start. Consumers must read this — never derive in-extraction from
1121    /// loop-local scratch like `extraction_state.primary_output`.
1122    pub extraction_active: bool,
1123    pub llm_retry_attempt: u32,
1124    pub llm_retry_max_retries: u32,
1125    pub llm_retry_selected_delay_ms: u64,
1126}
1127
1128/// Turn-execution DSL handle.
1129pub trait TurnStateHandle: Send + Sync {
1130    /// Apply one typed turn-execution input and return the generated
1131    /// turn-authority effects emitted by that transition.
1132    fn apply_turn_input(
1133        &self,
1134        input: TurnExecutionInput,
1135    ) -> Result<Vec<TurnExecutionEffect>, DslTransitionError>;
1136
1137    fn start_conversation_run(
1138        &self,
1139        run_id: RunId,
1140        primitive_kind: TurnPrimitiveKind,
1141        admitted_content_shape: ContentShape,
1142        vision_enabled: bool,
1143        image_tool_results_enabled: bool,
1144        max_extraction_retries: u64,
1145    ) -> Result<(), DslTransitionError>;
1146
1147    fn start_immediate_append(&self, run_id: RunId) -> Result<(), DslTransitionError>;
1148
1149    fn primitive_applied(&self, run_id: RunId) -> Result<(), DslTransitionError>;
1150
1151    fn llm_returned_tool_calls(
1152        &self,
1153        run_id: RunId,
1154        tool_count: u64,
1155    ) -> Result<(), DslTransitionError>;
1156
1157    fn llm_returned_terminal(&self, run_id: RunId) -> Result<(), DslTransitionError>;
1158
1159    fn register_pending_ops(
1160        &self,
1161        run_id: RunId,
1162        op_refs: BTreeSet<AsyncOpRef>,
1163        barrier_operation_ids: BTreeSet<OperationId>,
1164    ) -> Result<(), DslTransitionError>;
1165
1166    fn tool_calls_resolved(&self, run_id: RunId) -> Result<(), DslTransitionError>;
1167
1168    fn ops_barrier_satisfied(
1169        &self,
1170        run_id: RunId,
1171        operation_ids: BTreeSet<OperationId>,
1172    ) -> Result<(), DslTransitionError>;
1173
1174    fn boundary_continue(&self, run_id: RunId) -> Result<(), DslTransitionError>;
1175
1176    fn boundary_complete(&self, run_id: RunId) -> Result<(), DslTransitionError>;
1177
1178    fn enter_extraction(&self, run_id: RunId, max_retries: u32) -> Result<(), DslTransitionError>;
1179
1180    fn extraction_start(&self, run_id: RunId) -> Result<(), DslTransitionError>;
1181
1182    fn extraction_validation_passed(&self, run_id: RunId) -> Result<(), DslTransitionError>;
1183
1184    fn extraction_validation_failed(
1185        &self,
1186        run_id: RunId,
1187        error: String,
1188    ) -> Result<(), DslTransitionError>;
1189
1190    fn extraction_failed(&self, run_id: RunId, error: String) -> Result<(), DslTransitionError>;
1191
1192    fn recoverable_failure(
1193        &self,
1194        run_id: RunId,
1195        retry: LlmRetrySchedule,
1196    ) -> Result<(), DslTransitionError>;
1197
1198    fn fatal_failure(
1199        &self,
1200        run_id: RunId,
1201        failure: TurnFailureSource,
1202    ) -> Result<(), DslTransitionError>;
1203
1204    fn retry_requested(&self, run_id: RunId, retry_attempt: u32) -> Result<(), DslTransitionError>;
1205
1206    fn cancel_now(&self, run_id: RunId) -> Result<(), DslTransitionError>;
1207
1208    fn request_cancel_after_boundary(&self, run_id: RunId) -> Result<(), DslTransitionError>;
1209
1210    fn cancellation_observed(&self, run_id: RunId) -> Result<(), DslTransitionError>;
1211
1212    fn acknowledge_terminal(&self, run_id: RunId) -> Result<(), DslTransitionError>;
1213
1214    fn turn_limit_reached(
1215        &self,
1216        run_id: RunId,
1217        turn_count: u64,
1218        max_turns: u64,
1219    ) -> Result<(), DslTransitionError>;
1220
1221    fn budget_exhausted(&self, run_id: RunId) -> Result<(), DslTransitionError>;
1222
1223    fn time_budget_exceeded(&self, run_id: RunId) -> Result<(), DslTransitionError>;
1224
1225    fn force_cancel_no_run(&self) -> Result<(), DslTransitionError>;
1226
1227    fn run_completed(&self, run_id: RunId) -> Result<(), DslTransitionError>;
1228
1229    fn run_failed(
1230        &self,
1231        run_id: RunId,
1232        reason: TurnFailureReason,
1233    ) -> Result<(), DslTransitionError>;
1234
1235    fn run_cancelled(&self, run_id: RunId) -> Result<(), DslTransitionError>;
1236
1237    fn snapshot(&self) -> TurnStateSnapshot;
1238}
1239
1240// ---------------------------------------------------------------------------
1241// CommsDrainHandle
1242// ---------------------------------------------------------------------------
1243
1244/// Comms drain lifecycle DSL handle.
1245///
1246/// Covers the `drain_phase`/`drain_mode` DSL substate: ensure/spawn/stop the
1247/// comms drain task and report typed exit reasons. The machine classifies the
1248/// resulting stopped vs respawnable state.
1249pub trait CommsDrainHandle: Send + Sync {
1250    /// Fire the `EnsureDrainRunning` signal — lazy spawn path.
1251    fn ensure_drain_running(&self) -> Result<(), DslTransitionError>;
1252
1253    /// Fire the `SpawnDrain { mode }` input — explicit spawn with typed mode.
1254    fn spawn_drain(&self, mode: DrainMode) -> Result<(), DslTransitionError>;
1255
1256    /// Fire the `StopDrain` input.
1257    fn stop_drain(&self) -> Result<(), DslTransitionError>;
1258
1259    /// Fire the `NotifyDrainExited { reason }` input with a typed reason.
1260    fn notify_drain_exited(&self, reason: DrainExitReason) -> Result<(), DslTransitionError>;
1261}
1262
1263// ---------------------------------------------------------------------------
1264// ExternalToolSurfaceHandle
1265// ---------------------------------------------------------------------------
1266
1267#[derive(Debug, Clone, PartialEq, Eq)]
1268pub struct SurfaceSnapshot {
1269    pub surface_id: String,
1270    /// Typed base lifecycle state (dogma #5, #17 — no stringly discriminants
1271    /// across the cross-crate handle boundary).
1272    pub base_state: Option<ExternalToolSurfaceBaseState>,
1273    pub pending_op: ExternalToolSurfacePendingOp,
1274    pub staged_op: ExternalToolSurfaceStagedOp,
1275    pub staged_intent_sequence: Option<u64>,
1276    pub pending_task_sequence: Option<u64>,
1277    pub pending_lineage_sequence: Option<u64>,
1278    pub inflight_calls: u64,
1279    /// Typed last-emitted delta operation (dogma #5, #17).
1280    pub last_delta_operation: Option<ExternalToolSurfaceDeltaOperation>,
1281    /// Typed last-emitted delta phase (dogma #5, #17).
1282    pub last_delta_phase: Option<ExternalToolSurfaceDeltaPhase>,
1283    pub removal_draining_since_ms: Option<u64>,
1284    pub removal_timeout_at_ms: Option<u64>,
1285    pub removal_applied_at_turn: Option<u64>,
1286}
1287
1288#[derive(Debug, Clone, PartialEq, Eq)]
1289pub struct SurfaceDiagnosticSnapshot {
1290    pub surface_phase: ExternalToolSurfaceGlobalPhase,
1291    pub known_surfaces: BTreeSet<String>,
1292    pub visible_surfaces: BTreeSet<String>,
1293    pub snapshot_epoch: u64,
1294    pub snapshot_aligned_epoch: u64,
1295    pub has_pending_or_staged: bool,
1296    pub entries: Vec<SurfaceSnapshot>,
1297}
1298
1299#[derive(Debug, Clone, PartialEq, Eq)]
1300pub enum ExternalToolSurfaceInput {
1301    SetRemovalTimeout {
1302        timeout_ms: u64,
1303    },
1304    StageAdd {
1305        surface_id: String,
1306        now_ms: u64,
1307    },
1308    StageRemove {
1309        surface_id: String,
1310        now_ms: u64,
1311    },
1312    StageReload {
1313        surface_id: String,
1314        now_ms: u64,
1315    },
1316    ApplyBoundary {
1317        surface_id: String,
1318        now_ms: u64,
1319        staged_intent_sequence: u64,
1320        applied_at_turn: u64,
1321    },
1322    MarkPendingSucceeded {
1323        surface_id: String,
1324        pending_task_sequence: u64,
1325        staged_intent_sequence: u64,
1326    },
1327    MarkPendingFailed {
1328        surface_id: String,
1329        pending_task_sequence: u64,
1330        staged_intent_sequence: u64,
1331        cause: ExternalToolSurfaceFailureCause,
1332    },
1333    CallStarted {
1334        surface_id: String,
1335    },
1336    CallFinished {
1337        surface_id: String,
1338    },
1339    FinalizeRemovalClean {
1340        surface_id: String,
1341    },
1342    FinalizeRemovalForced {
1343        surface_id: String,
1344    },
1345    SnapshotAligned {
1346        epoch: u64,
1347    },
1348    Shutdown,
1349}
1350
1351#[derive(Debug, Clone, PartialEq, Eq)]
1352pub enum ExternalToolSurfaceEffect {
1353    ScheduleSurfaceCompletion {
1354        surface_id: String,
1355        operation: ExternalToolSurfaceDeltaOperation,
1356        pending_task_sequence: u64,
1357        staged_intent_sequence: u64,
1358        applied_at_turn: u64,
1359    },
1360    RefreshVisibleSurfaceSet {
1361        snapshot_epoch: u64,
1362    },
1363    EmitExternalToolDelta {
1364        surface_id: String,
1365        operation: ExternalToolSurfaceDeltaOperation,
1366        phase: ExternalToolSurfaceDeltaPhase,
1367        cause: Option<ExternalToolSurfaceFailureCause>,
1368    },
1369    CloseSurfaceConnection {
1370        surface_id: String,
1371    },
1372    RejectSurfaceCall {
1373        surface_id: String,
1374        cause: ExternalToolSurfaceFailureCause,
1375    },
1376}
1377
1378#[derive(Debug, Clone, PartialEq, Eq)]
1379pub struct ExternalToolSurfaceTransition {
1380    pub phase: ExternalToolSurfaceGlobalPhase,
1381    pub effects: Vec<ExternalToolSurfaceEffect>,
1382}
1383
1384/// External tool surface lifecycle DSL handle.
1385pub trait ExternalToolSurfaceHandle: Send + Sync {
1386    fn apply_surface_input(
1387        &self,
1388        input: ExternalToolSurfaceInput,
1389    ) -> Result<ExternalToolSurfaceTransition, DslTransitionError>;
1390
1391    fn register(&self, surface_id: String) -> Result<(), DslTransitionError>;
1392
1393    fn stage_add(&self, surface_id: String, now_ms: u64) -> Result<(), DslTransitionError>;
1394
1395    fn stage_remove(&self, surface_id: String, now_ms: u64) -> Result<(), DslTransitionError>;
1396
1397    fn stage_reload(&self, surface_id: String, now_ms: u64) -> Result<(), DslTransitionError>;
1398
1399    fn apply_boundary(
1400        &self,
1401        surface_id: String,
1402        now_ms: u64,
1403        staged_intent_sequence: u64,
1404        applied_at_turn: u64,
1405    ) -> Result<(), DslTransitionError>;
1406
1407    fn mark_pending_succeeded(
1408        &self,
1409        surface_id: String,
1410        pending_task_sequence: u64,
1411        staged_intent_sequence: u64,
1412    ) -> Result<(), DslTransitionError>;
1413
1414    fn mark_pending_failed(
1415        &self,
1416        surface_id: String,
1417        pending_task_sequence: u64,
1418        staged_intent_sequence: u64,
1419        cause: ExternalToolSurfaceFailureCause,
1420    ) -> Result<(), DslTransitionError>;
1421
1422    fn call_started(&self, surface_id: String) -> Result<(), DslTransitionError>;
1423
1424    fn call_finished(&self, surface_id: String) -> Result<(), DslTransitionError>;
1425
1426    fn finalize_removal_clean(&self, surface_id: String) -> Result<(), DslTransitionError>;
1427
1428    fn finalize_removal_forced(&self, surface_id: String) -> Result<(), DslTransitionError>;
1429
1430    fn snapshot_aligned(&self, epoch: u64) -> Result<(), DslTransitionError>;
1431
1432    fn shutdown_surface(&self) -> Result<(), DslTransitionError>;
1433
1434    fn surface_snapshot(&self, surface_id: &str) -> Option<SurfaceSnapshot>;
1435
1436    fn diagnostic_snapshot(&self) -> SurfaceDiagnosticSnapshot;
1437
1438    fn visible_surfaces(&self) -> BTreeSet<String>;
1439
1440    fn removing_surfaces(&self) -> BTreeSet<String>;
1441
1442    fn pending_surfaces(&self) -> BTreeSet<String>;
1443
1444    fn has_pending_or_staged(&self) -> bool;
1445
1446    fn snapshot_epoch(&self) -> u64;
1447
1448    fn snapshot_aligned_epoch(&self) -> u64;
1449}
1450
1451// ---------------------------------------------------------------------------
1452// PeerCommsHandle
1453// ---------------------------------------------------------------------------
1454
1455/// Peer comms ingress classification DSL handle.
1456///
1457/// Covers the peer-envelope classification and receive/dequeue authority
1458/// signals on the MeerkatMachine DSL. Runtime-backed comms ingress hands
1459/// parsed transport facts and queue observations to this handle and receives
1460/// the complete typed classification/admission/phase facts back. A rejection
1461/// is authoritative and callers fail closed. Classified comms ingress without
1462/// this session DSL handle fails closed rather than deriving machine facts in
1463/// the transport shell.
1464pub trait PeerCommsHandle: Send + Sync {
1465    /// Fire the `ClassifyExternalEnvelope` signal and return machine-owned
1466    /// admission facts for the parsed envelope.
1467    fn classify_external_envelope(
1468        &self,
1469        facts: PeerIngressEnvelopeFacts,
1470    ) -> Result<PeerIngressAdmission, DslTransitionError>;
1471
1472    /// Fire the `ClassifyPlainEvent` signal and return machine-owned
1473    /// admission facts for the parsed plain event.
1474    fn classify_plain_event(
1475        &self,
1476        facts: PeerIngressPlainEventFacts,
1477    ) -> Result<PeerIngressAdmission, DslTransitionError>;
1478
1479    /// Fire `ResolvePeerIngressReceive` and return the machine-owned
1480    /// admission outcome plus authority phase for a classified peer envelope.
1481    fn resolve_peer_ingress_receive(
1482        &self,
1483        facts: PeerIngressReceiveFacts,
1484    ) -> Result<PeerIngressReceiveAuthority, DslTransitionError>;
1485
1486    /// Fire `ResolvePeerIngressDequeue` and return the machine-owned
1487    /// authority phase for a classified queue dequeue observation.
1488    fn resolve_peer_ingress_dequeue(
1489        &self,
1490        facts: PeerIngressDequeueFacts,
1491    ) -> Result<PeerIngressDequeueAuthority, DslTransitionError>;
1492
1493    /// Fire the `SetPeerIngressContext { keep_alive }` input.
1494    fn set_peer_ingress_context(&self, keep_alive: bool) -> Result<(), DslTransitionError>;
1495
1496    /// Route a local-runtime endpoint observation through generated machine
1497    /// authority and install the accepted generated authority package on the
1498    /// target.
1499    fn install_generated_peer_comms_on_target(
1500        &self,
1501        _expected_owner: &crate::comms::GeneratedPeerCommsOwnerToken,
1502        _target: &(dyn PeerCommsInstallTarget + '_),
1503    ) -> Result<(), String> {
1504        Err("peer-comms handle does not expose generated install target authority".to_string())
1505    }
1506}
1507
1508#[derive(Clone)]
1509pub struct GeneratedPeerCommsInstallFactory {
1510    handle: std::sync::Arc<dyn PeerCommsHandle>,
1511    owner_token: crate::comms::GeneratedPeerCommsOwnerToken,
1512}
1513
1514impl std::fmt::Debug for GeneratedPeerCommsInstallFactory {
1515    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1516        f.debug_struct("GeneratedPeerCommsInstallFactory")
1517            .field("handle", &"<dyn PeerCommsHandle>")
1518            .field("owner_token", &self.owner_token)
1519            .finish()
1520    }
1521}
1522
1523impl GeneratedPeerCommsInstallFactory {
1524    #[cfg(all(meerkat_internal_generated_authority_bridge, not(test)))]
1525    #[doc(hidden)]
1526    pub fn __from_runtime_generated_authority(
1527        token: &'static (dyn Any + Send + Sync),
1528        handle: std::sync::Arc<dyn PeerCommsHandle>,
1529        owner_token: std::sync::Arc<dyn Any + Send + Sync>,
1530    ) -> Result<Self, String> {
1531        validate_peer_comms_install_bridge_token(token)?;
1532        Ok(Self {
1533            handle,
1534            owner_token: crate::comms::GeneratedPeerCommsOwnerToken::from_generated_owner_token(
1535                owner_token,
1536            ),
1537        })
1538    }
1539
1540    pub fn peer_comms_handle(&self) -> &std::sync::Arc<dyn PeerCommsHandle> {
1541        &self.handle
1542    }
1543
1544    pub fn install_on_target(
1545        &self,
1546        target: &(dyn PeerCommsInstallTarget + '_),
1547    ) -> Result<(), String> {
1548        self.handle
1549            .install_generated_peer_comms_on_target(&self.owner_token, target)
1550    }
1551}
1552
1553#[derive(Clone)]
1554pub struct GeneratedPeerCommsInstall {
1555    handle: std::sync::Arc<dyn PeerCommsHandle>,
1556    owner_token: crate::comms::GeneratedPeerCommsOwnerToken,
1557    target_peer_id: crate::comms::PeerId,
1558}
1559
1560impl std::fmt::Debug for GeneratedPeerCommsInstall {
1561    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1562        f.debug_struct("GeneratedPeerCommsInstall")
1563            .field("handle", &"<dyn PeerCommsHandle>")
1564            .field("owner_token", &self.owner_token)
1565            .field("target_peer_id", &self.target_peer_id)
1566            .finish()
1567    }
1568}
1569
1570impl GeneratedPeerCommsInstall {
1571    #[cfg(all(meerkat_internal_generated_authority_bridge, not(test)))]
1572    #[doc(hidden)]
1573    pub fn __from_runtime_generated_authority(
1574        token: &'static (dyn Any + Send + Sync),
1575        handle: std::sync::Arc<dyn PeerCommsHandle>,
1576        owner_token: std::sync::Arc<dyn Any + Send + Sync>,
1577        target_peer_id: crate::comms::PeerId,
1578    ) -> Result<Self, String> {
1579        validate_peer_comms_install_bridge_token(token)?;
1580        Ok(Self {
1581            handle,
1582            owner_token: crate::comms::GeneratedPeerCommsOwnerToken::from_generated_owner_token(
1583                owner_token,
1584            ),
1585            target_peer_id,
1586        })
1587    }
1588
1589    pub fn peer_comms_handle(&self) -> &std::sync::Arc<dyn PeerCommsHandle> {
1590        &self.handle
1591    }
1592
1593    pub fn owner_token(&self) -> crate::comms::GeneratedPeerCommsOwnerToken {
1594        self.owner_token.clone()
1595    }
1596
1597    pub fn target_peer_id(&self) -> crate::comms::PeerId {
1598        self.target_peer_id
1599    }
1600}
1601
1602#[cfg(all(meerkat_internal_generated_authority_bridge, not(test)))]
1603#[allow(improper_ctypes_definitions, unsafe_code)]
1604unsafe extern "Rust" {
1605    #[link_name = concat!(
1606        "__meerkat_runtime_generated_authority_bridge_token_is_valid_v1_comms_trust_reconcile_",
1607        env!("MEERKAT_GENERATED_AUTHORITY_BRIDGE_SYMBOL_SUFFIX")
1608    )]
1609    fn runtime_peer_comms_install_generated_authority_bridge_token_is_valid(
1610        token: &(dyn Any + Send + Sync),
1611    ) -> bool;
1612}
1613
1614#[cfg(all(meerkat_internal_generated_authority_bridge, not(test)))]
1615fn validate_peer_comms_install_bridge_token(token: &(dyn Any + Send + Sync)) -> Result<(), String> {
1616    #[allow(unsafe_code)]
1617    let valid =
1618        unsafe { runtime_peer_comms_install_generated_authority_bridge_token_is_valid(token) };
1619    if valid {
1620        Ok(())
1621    } else {
1622        Err("generated peer-comms install requires the matching generated runtime protocol bridge token".into())
1623    }
1624}
1625
1626/// Target that can bind a peer-comms handle to its generated trust owner.
1627///
1628/// Generic [`PeerCommsHandle`] implementations classify ingress only. This
1629/// path accepts an opaque generated install package rather than a raw owner
1630/// token, so handwritten code cannot bind machine trust facts by copying a
1631/// token into a fake handle.
1632pub trait PeerCommsInstallTarget: crate::agent::CommsRuntime {
1633    fn generated_peer_comms_target_endpoint(
1634        &self,
1635    ) -> Result<crate::comms::TrustedPeerDescriptor, String> {
1636        let peer_id = self
1637            .peer_id()
1638            .ok_or_else(|| "runtime peer_id unavailable".to_string())?;
1639        let name = self
1640            .comms_name()
1641            .ok_or_else(|| "runtime comms_name unavailable".to_string())?;
1642        let address = self
1643            .advertised_address()
1644            .ok_or_else(|| "runtime advertised_address unavailable".to_string())?;
1645        let pubkey = self
1646            .public_key_bytes()
1647            .ok_or_else(|| "runtime public_key_bytes unavailable".to_string())?;
1648        crate::comms::TrustedPeerDescriptor::unsigned_with_pubkey(
1649            name,
1650            peer_id.to_string(),
1651            pubkey,
1652            address,
1653        )
1654        .map_err(|error| format!("runtime peer-comms install target endpoint invalid: {error}"))
1655    }
1656
1657    fn install_generated_peer_comms_handle(
1658        &self,
1659        install: GeneratedPeerCommsInstall,
1660    ) -> Result<(), String>;
1661}
1662
1663// ---------------------------------------------------------------------------
1664// SessionAdmissionHandle
1665// ---------------------------------------------------------------------------
1666
1667/// Session turn admission DSL handle.
1668///
1669/// Covers the admission-adjacent inputs on the MeerkatMachine DSL: ingest an
1670/// input into the session, accept it (with or without wake), and prepare a
1671/// run. Commit terminalization is owned by the runtime loop's
1672/// `commit_runtime_loop_run` durable receipt path; failed run return is owned
1673/// by the runtime turn-state path after a typed terminal cause is recorded.
1674/// These inputs manage the input-lifecycle
1675/// substate maps (`input_phases`, `input_run_associations`, etc.) and the
1676/// top-level `current_run_id` / `pre_run_phase` fields.
1677pub trait SessionAdmissionHandle: Send + Sync {
1678    /// Fire the `Ingest { runtime_id, work_id, origin }` input.
1679    ///
1680    /// `runtime_id` is the stringified logical runtime id; `work_id` the
1681    /// stringified work identifier (typically the same domain as `InputId`).
1682    /// `origin` is the typed transport source that admitted the input
1683    /// (dogma #5, #17 — no stringly discriminants across the handle boundary).
1684    fn ingest(
1685        &self,
1686        runtime_id: &str,
1687        work_id: &str,
1688        origin: InputSource,
1689    ) -> Result<(), DslTransitionError>;
1690
1691    /// Fire the `AcceptWithCompletion { input_id, request_immediate_processing,
1692    /// interrupt_yielding, wake_if_idle, run_id }` input.
1693    ///
1694    /// `wake_if_idle` carries the policy-level "this input must wake the
1695    /// runtime loop once the session reaches idle" intent (e.g.
1696    /// `peer_response_terminal` staged while running): the DSL's
1697    /// Running+Queued transition splits on it and emits a
1698    /// `PostAdmissionSignal::WakeLoop` so the pending wake lands on the
1699    /// next idle reach. Idle/Attached queued arms already wake
1700    /// unconditionally, so the flag is ignored in those guards.
1701    fn accept_with_completion(
1702        &self,
1703        input_id: &InputId,
1704        request_immediate_processing: bool,
1705        interrupt_yielding: bool,
1706        wake_if_idle: bool,
1707    ) -> Result<(), DslTransitionError>;
1708
1709    /// Fire the `AcceptWithoutWake { input_id }` input.
1710    fn accept_without_wake(&self, input_id: &InputId) -> Result<(), DslTransitionError>;
1711
1712    /// Fire the `Prepare { session_id, run_id }` input — bound for the session this handle was prepared for.
1713    fn prepare(&self, run_id: &RunId) -> Result<(), DslTransitionError>;
1714}
1715
1716// ---------------------------------------------------------------------------
1717// AuthLeaseHandle (Phase 1.5-rev)
1718// ---------------------------------------------------------------------------
1719
1720/// Typed key for one auth lease machine.
1721#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
1722pub struct LeaseKey {
1723    pub realm: crate::connection::RealmId,
1724    pub binding: crate::connection::BindingId,
1725    pub profile: Option<crate::connection::ProfileId>,
1726}
1727
1728impl LeaseKey {
1729    pub fn new(
1730        realm: crate::connection::RealmId,
1731        binding: crate::connection::BindingId,
1732        profile: Option<crate::connection::ProfileId>,
1733    ) -> Self {
1734        Self {
1735            realm,
1736            binding,
1737            profile,
1738        }
1739    }
1740
1741    pub fn from_auth_binding(auth_binding: &crate::connection::AuthBindingRef) -> Self {
1742        Self {
1743            realm: auth_binding.realm.clone(),
1744            binding: auth_binding.binding.clone(),
1745            profile: auth_binding.profile.clone(),
1746        }
1747    }
1748}
1749
1750impl std::fmt::Display for LeaseKey {
1751    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1752        match &self.profile {
1753            Some(profile) => write!(f, "{}:{}:{}", self.realm, self.binding, profile),
1754            None => write!(f, "{}:{}", self.realm, self.binding),
1755        }
1756    }
1757}
1758
1759/// Observable snapshot of an auth lease's DSL state for a given [`LeaseKey`].
1760///
1761/// Returned by [`AuthLeaseHandle::snapshot`]. If the binding is not tracked
1762/// at all, `phase` is `None` and `expires_at` is `None`. `generation`
1763/// advances when credential material is published. Non-publishing lifecycle
1764/// transitions, such as marking a lease expiring or a transient refresh failure,
1765/// do not advance this credential marker generation. This lets consumers
1766/// distinguish a stale projection from a freshly reacquired lease even when the
1767/// expiry timestamp is unchanged, without invalidating retryable stored
1768/// credentials after state-only transitions. OAuth login-flow membership
1769/// transitions also do not advance this credential marker generation.
1770/// `credential_present` distinguishes credential lifecycle authority from
1771/// OAuth login-flow membership that may keep an AuthMachine instance alive
1772/// after credential rollback.
1773/// `credential_published_at_millis` advances only when credential material is
1774/// acquired/refreshed.
1775#[derive(Debug, Clone, PartialEq, Eq)]
1776pub struct AuthLeaseSnapshot {
1777    pub phase: Option<AuthLeasePhase>,
1778    pub expires_at: Option<u64>,
1779    pub credential_present: bool,
1780    pub generation: u64,
1781    pub credential_published_at_millis: Option<u64>,
1782}
1783
1784/// Opaque token for restoring a previously captured auth lease snapshot.
1785///
1786/// This is intentionally not constructible from public snapshot fields. A
1787/// rollback caller must first capture it from an [`AuthLeaseHandle`], then hand
1788/// that exact token back to the same authority boundary if a later durable
1789/// write fails.
1790#[derive(Debug, Clone, PartialEq, Eq)]
1791pub struct AuthLeaseRestoreSnapshot {
1792    lease_key: LeaseKey,
1793    snapshot: AuthLeaseSnapshot,
1794    captured_by: std::any::TypeId,
1795    captured_by_instance: usize,
1796}
1797
1798impl AuthLeaseRestoreSnapshot {
1799    fn capture(
1800        lease_key: LeaseKey,
1801        snapshot: AuthLeaseSnapshot,
1802        captured_by: std::any::TypeId,
1803        captured_by_instance: usize,
1804    ) -> Self {
1805        Self {
1806            lease_key,
1807            snapshot,
1808            captured_by,
1809            captured_by_instance,
1810        }
1811    }
1812
1813    pub fn lease_key(&self) -> &LeaseKey {
1814        &self.lease_key
1815    }
1816
1817    pub fn snapshot(&self) -> &AuthLeaseSnapshot {
1818        &self.snapshot
1819    }
1820
1821    #[doc(hidden)]
1822    pub fn captured_by_type_id(&self) -> std::any::TypeId {
1823        self.captured_by
1824    }
1825
1826    #[doc(hidden)]
1827    pub fn captured_by_instance_id(&self) -> usize {
1828        self.captured_by_instance
1829    }
1830}
1831
1832/// Result of an accepted auth lease lifecycle transition.
1833///
1834/// `generation` is the projection version assigned while the transition is
1835/// accepted, so consumers can bind derived material to the exact lease state
1836/// that published it without taking a later snapshot.
1837/// `credential_published_at_millis` is the durable credential publication
1838/// timestamp attached to acquired/refreshed credential material.
1839#[derive(Debug, Clone, PartialEq, Eq)]
1840pub struct AuthLeaseTransition {
1841    lease_key: LeaseKey,
1842    phase: AuthLeasePhase,
1843    expires_at: u64,
1844    generation: u64,
1845    credential_published_at_millis: Option<u64>,
1846}
1847
1848impl AuthLeaseTransition {
1849    pub fn lease_key(&self) -> &LeaseKey {
1850        &self.lease_key
1851    }
1852
1853    pub fn phase(&self) -> AuthLeasePhase {
1854        self.phase
1855    }
1856
1857    pub fn expires_at(&self) -> u64 {
1858        self.expires_at
1859    }
1860
1861    pub fn generation(&self) -> u64 {
1862        self.generation
1863    }
1864
1865    pub fn credential_published_at_millis(&self) -> Option<u64> {
1866        self.credential_published_at_millis
1867    }
1868
1869    #[cfg_attr(
1870        any(not(meerkat_internal_generated_authority_bridge), test),
1871        allow(dead_code)
1872    )]
1873    fn from_generated_auth_lease_publication_parts(
1874        lease_key: LeaseKey,
1875        phase: AuthLeasePhase,
1876        expires_at: u64,
1877        generation: u64,
1878        credential_published_at_millis: Option<u64>,
1879    ) -> Self {
1880        Self {
1881            lease_key,
1882            phase,
1883            expires_at,
1884            generation,
1885            credential_published_at_millis,
1886        }
1887    }
1888}
1889
1890/// Auth lease lifecycle handle certified by the generated AuthMachine
1891/// publication authority.
1892///
1893/// The wrapped trait object remains the mechanical dispatch surface, but
1894/// production resolver/factory seams accept this type so callers cannot install
1895/// an arbitrary handwritten reducer as lifecycle authority.
1896#[derive(Clone)]
1897pub struct GeneratedAuthLeaseHandle {
1898    inner: Arc<dyn AuthLeaseHandle>,
1899}
1900
1901impl GeneratedAuthLeaseHandle {
1902    pub fn as_handle(&self) -> &dyn AuthLeaseHandle {
1903        self.inner.as_ref()
1904    }
1905
1906    pub fn clone_handle(&self) -> Arc<dyn AuthLeaseHandle> {
1907        Arc::clone(&self.inner)
1908    }
1909
1910    #[cfg_attr(
1911        any(not(meerkat_internal_generated_authority_bridge), test),
1912        allow(dead_code)
1913    )]
1914    fn from_generated_authority(inner: Arc<dyn AuthLeaseHandle>) -> Self {
1915        Self { inner }
1916    }
1917}
1918
1919impl std::fmt::Debug for GeneratedAuthLeaseHandle {
1920    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1921        f.debug_struct("GeneratedAuthLeaseHandle")
1922            .finish_non_exhaustive()
1923    }
1924}
1925
1926impl std::ops::Deref for GeneratedAuthLeaseHandle {
1927    type Target = dyn AuthLeaseHandle;
1928
1929    fn deref(&self) -> &Self::Target {
1930        self.inner.as_ref()
1931    }
1932}
1933
1934impl AsRef<dyn AuthLeaseHandle> for GeneratedAuthLeaseHandle {
1935    fn as_ref(&self) -> &dyn AuthLeaseHandle {
1936        self.inner.as_ref()
1937    }
1938}
1939
1940#[cfg(all(meerkat_internal_generated_authority_bridge, not(test)))]
1941#[allow(improper_ctypes_definitions, unsafe_code)]
1942unsafe extern "Rust" {
1943    #[link_name = concat!(
1944        "__meerkat_runtime_generated_authority_bridge_token_is_valid_v1_auth_lease_lifecycle_publication_",
1945        env!("MEERKAT_GENERATED_AUTHORITY_BRIDGE_SYMBOL_SUFFIX")
1946    )]
1947    fn runtime_auth_lease_lifecycle_publication_generated_authority_bridge_token_is_valid(
1948        token: &(dyn std::any::Any + Send + Sync),
1949    ) -> bool;
1950}
1951
1952#[cfg(all(meerkat_internal_generated_authority_bridge, not(test)))]
1953#[doc(hidden)]
1954#[allow(improper_ctypes_definitions, unsafe_code)]
1955#[unsafe(export_name = concat!(
1956    "__meerkat_core_runtime_generated_auth_lease_transition_build_v1_",
1957    env!("MEERKAT_GENERATED_AUTHORITY_BRIDGE_SYMBOL_SUFFIX")
1958))]
1959pub(crate) extern "Rust" fn runtime_generated_auth_lease_transition_build(
1960    token: &'static (dyn std::any::Any + Send + Sync),
1961    lease_key: LeaseKey,
1962    phase: AuthLeasePhase,
1963    expires_at: u64,
1964    generation: u64,
1965    credential_published_at_millis: Option<u64>,
1966) -> Result<AuthLeaseTransition, String> {
1967    validate_runtime_generated_authority_bridge_token(token)?;
1968    Ok(
1969        AuthLeaseTransition::from_generated_auth_lease_publication_parts(
1970            lease_key,
1971            phase,
1972            expires_at,
1973            generation,
1974            credential_published_at_millis,
1975        ),
1976    )
1977}
1978
1979#[cfg(all(meerkat_internal_generated_authority_bridge, not(test)))]
1980#[doc(hidden)]
1981#[allow(improper_ctypes_definitions, unsafe_code)]
1982#[unsafe(export_name = concat!(
1983    "__meerkat_core_runtime_generated_auth_lease_handle_build_v1_",
1984    env!("MEERKAT_GENERATED_AUTHORITY_BRIDGE_SYMBOL_SUFFIX")
1985))]
1986pub(crate) extern "Rust" fn runtime_generated_auth_lease_handle_build(
1987    token: &'static (dyn std::any::Any + Send + Sync),
1988    handle: Arc<dyn AuthLeaseHandle>,
1989) -> Result<GeneratedAuthLeaseHandle, String> {
1990    validate_runtime_generated_authority_bridge_token(token)?;
1991    Ok(GeneratedAuthLeaseHandle::from_generated_authority(handle))
1992}
1993
1994#[cfg(all(meerkat_internal_generated_authority_bridge, not(test)))]
1995fn validate_runtime_generated_authority_bridge_token(
1996    token: &(dyn std::any::Any + Send + Sync),
1997) -> Result<(), String> {
1998    #[allow(unsafe_code)]
1999    let valid = unsafe {
2000        runtime_auth_lease_lifecycle_publication_generated_authority_bridge_token_is_valid(token)
2001    };
2002    if valid {
2003        Ok(())
2004    } else {
2005        Err(
2006            "generated auth lease transition requires the generated AuthMachine protocol bridge token"
2007                .into(),
2008        )
2009    }
2010}
2011
2012/// Window (in seconds) before `expires_at` at which a `valid` lease is
2013/// eligible to transition into `expiring` at the next CallingLlm
2014/// boundary. Owned here — on the handle trait module — rather than in
2015/// shell code, per dogma §9 ("policy composes at the facade/factory
2016/// seam, not in random helpers") and §20 ("every important behavior
2017/// reduces to one clear owner").
2018///
2019/// The actual state transition is gated by the AuthMachine DSL's
2020/// `MarkAuthExpiring` input (which enforces the `valid → expiring`
2021/// legality); this constant only controls *when* the runner fires
2022/// that input, not whether the transition is legal.
2023pub const AUTH_LEASE_TTL_REFRESH_WINDOW_SECS: u64 = 60;
2024
2025/// Auth lease lifecycle DSL handle.
2026pub trait AuthLeaseHandle: Send + Sync + std::any::Any {
2027    /// Fire `AcquireAuthLease { lease_key, expires_at }` — unconditional.
2028    ///
2029    /// Moves the binding into `auth_valid_leases` and records its expiry.
2030    /// Returns the generation assigned by the accepted transition.
2031    fn acquire_lease(
2032        &self,
2033        lease_key: &LeaseKey,
2034        expires_at: u64,
2035    ) -> Result<AuthLeaseTransition, DslTransitionError>;
2036
2037    /// Fire `MarkAuthExpiring { lease_key }` — only legal from `valid`.
2038    fn mark_expiring(&self, lease_key: &LeaseKey) -> Result<(), DslTransitionError>;
2039
2040    /// Fire `ObserveCredentialFreshness { lease_key, now, refresh_window }`.
2041    ///
2042    /// AuthMachine owns whether the credential remains valid, becomes
2043    /// expiring, or becomes expired from its observed expiry facts.
2044    fn observe_credential_freshness(
2045        &self,
2046        lease_key: &LeaseKey,
2047        now: u64,
2048        refresh_window_secs: u64,
2049    ) -> Result<(), DslTransitionError>;
2050
2051    /// Fire `BeginAuthRefresh { lease_key }` — legal from `valid` or
2052    /// `expiring` or `expired`.
2053    ///
2054    /// Provides the DSL-level refresh dedup: once the binding is in
2055    /// `auth_refreshing_leases`, no concurrent `BeginAuthRefresh` is
2056    /// permitted until `CompleteAuthRefresh` or `AuthRefreshFailed` moves
2057    /// it back out.
2058    fn begin_refresh(&self, lease_key: &LeaseKey) -> Result<(), DslTransitionError>;
2059
2060    /// Fire `CompleteAuthRefresh { lease_key, new_expires_at, now }` — only
2061    /// legal from `refreshing`. Returns the generation assigned by the accepted
2062    /// transition.
2063    fn complete_refresh(
2064        &self,
2065        lease_key: &LeaseKey,
2066        new_expires_at: u64,
2067        now: u64,
2068    ) -> Result<AuthLeaseTransition, DslTransitionError>;
2069
2070    /// Classify a typed refresh-failure observation through AuthMachine's
2071    /// read-only generated resolver — only legal from `refreshing`.
2072    ///
2073    /// The machine owns permanent-vs-transient policy. Callers may mirror the
2074    /// returned verdict to order durable cleanup, but must not infer it from
2075    /// [`RefreshFailureObservation`] fields.
2076    fn resolve_refresh_failure_disposition(
2077        &self,
2078        lease_key: &LeaseKey,
2079        observation: RefreshFailureObservation,
2080    ) -> Result<RefreshFailureDisposition, DslTransitionError> {
2081        let _ = (lease_key, observation);
2082        Err(DslTransitionError::no_matching(
2083            "AuthLeaseHandle::resolve_refresh_failure_disposition",
2084            "classifying refresh failure requires generated AuthMachine authority",
2085        ))
2086    }
2087
2088    /// Resolve a typed refresh-failure observation through AuthMachine, then
2089    /// commit the machine-issued disposition — only legal from `refreshing`.
2090    fn refresh_failed(
2091        &self,
2092        lease_key: &LeaseKey,
2093        observation: RefreshFailureObservation,
2094    ) -> Result<(), DslTransitionError>;
2095
2096    /// Fire `MarkReauthRequired { lease_key }` — any known state → reauth.
2097    fn mark_reauth_required(&self, lease_key: &LeaseKey) -> Result<(), DslTransitionError>;
2098
2099    /// Fire `ReleaseAuthLease { lease_key }` — removes the binding from all
2100    /// sets and the expiry map.
2101    fn release_lease(&self, lease_key: &LeaseKey) -> Result<(), DslTransitionError>;
2102
2103    /// Clear credential lifecycle authority without treating persisted token
2104    /// bytes as a new lease source.
2105    ///
2106    /// Handles that co-locate short-lived OAuth flow membership with credential
2107    /// lifecycle state should preserve those flow memberships when clearing
2108    /// only the credential side after a failed login commit.
2109    fn release_credential_lifecycle(&self, lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
2110        self.release_lease(lease_key)
2111    }
2112
2113    /// Capture the current lifecycle snapshot for possible rollback.
2114    ///
2115    /// The returned token is an opaque handoff for `restore_auth_lifecycle_snapshot`;
2116    /// callers may inspect the read-only snapshot but cannot fabricate a restore
2117    /// request from token-store or projection metadata.
2118    fn capture_auth_lifecycle_restore_snapshot(
2119        &self,
2120        lease_key: &LeaseKey,
2121    ) -> AuthLeaseRestoreSnapshot {
2122        AuthLeaseRestoreSnapshot::capture(
2123            lease_key.clone(),
2124            self.snapshot(lease_key),
2125            self.type_id(),
2126            self.auth_lifecycle_restore_instance_id(),
2127        )
2128    }
2129
2130    #[doc(hidden)]
2131    fn auth_lifecycle_restore_instance_id(&self) -> usize {
2132        std::ptr::from_ref(self).cast::<()>() as usize
2133    }
2134
2135    /// Restore a captured lifecycle snapshot after a later durable write failed.
2136    ///
2137    /// Production handles must implement this through generated machine
2138    /// authority. The default fails closed so a handwritten handle cannot become
2139    /// a lifecycle reducer by replaying public snapshot fields.
2140    fn restore_auth_lifecycle_snapshot(
2141        &self,
2142        snapshot: &AuthLeaseRestoreSnapshot,
2143    ) -> Result<Option<AuthLeaseTransition>, DslTransitionError> {
2144        let _ = snapshot;
2145        Err(DslTransitionError::no_matching(
2146            "AuthLeaseHandle::restore_auth_lifecycle_snapshot",
2147            "restoring auth lifecycle snapshots requires generated AuthMachine authority",
2148        ))
2149    }
2150
2151    /// Restore a durable credential lifecycle publication through generated
2152    /// AuthMachine authority.
2153    ///
2154    /// The default fails closed so token stores cannot become handwritten
2155    /// lifecycle reducers. Production handles must route this through the
2156    /// generated `RestoreAuthoritySnapshot` input before exposing a restored
2157    /// lease transition.
2158    fn restore_published_credential_lifecycle(
2159        &self,
2160        lease_key: &LeaseKey,
2161        publication: &crate::generated::auth_lease_durable_lifecycle_marker::AuthLeaseDurableRestorePublication,
2162    ) -> Result<AuthLeaseTransition, DslTransitionError> {
2163        let _ = (lease_key, publication);
2164        Err(DslTransitionError::no_matching(
2165            "AuthLeaseHandle::restore_published_credential_lifecycle",
2166            "restoring durable auth lifecycle publications requires generated AuthMachine authority",
2167        ))
2168    }
2169
2170    /// Classify the credential-use disposition for a binding under `intent`.
2171    ///
2172    /// Drives the per-binding AuthMachine's `ResolveCredentialUseAdmission`
2173    /// read-only classifier over the live machine and mirrors the emitted
2174    /// `CredentialUseAdmissionResolved` disposition. The AuthMachine owns the
2175    /// complete `(lifecycle_phase, credential_present, intent)` -> disposition
2176    /// POLICY; the caller (the auth-core resolver) extracts only the typed
2177    /// `intent` and mirrors the verdict.
2178    ///
2179    /// Production handles must implement this through generated AuthMachine
2180    /// authority. The default fails closed so a handwritten handle cannot become
2181    /// a credential-use reducer.
2182    fn resolve_credential_use_admission(
2183        &self,
2184        lease_key: &LeaseKey,
2185        intent: CredentialUseIntent,
2186    ) -> Result<CredentialUseDisposition, DslTransitionError> {
2187        let _ = (lease_key, intent);
2188        Err(DslTransitionError::no_matching(
2189            "AuthLeaseHandle::resolve_credential_use_admission",
2190            "classifying credential-use admission requires generated AuthMachine authority",
2191        ))
2192    }
2193
2194    /// Classify the OAuth-login cached-vs-refresh disposition for a binding.
2195    ///
2196    /// Drives the per-binding AuthMachine's
2197    /// `ResolveOAuthLoginCredentialDisposition` read-only classifier over the
2198    /// live machine and mirrors the emitted `CredentialUseAdmissionResolved`
2199    /// disposition. The AuthMachine owns the complete `(lifecycle_phase,
2200    /// self.credential_present, credential_present, force_refresh,
2201    /// refresh_allowed)` -> disposition POLICY; the provider runtime shell
2202    /// extracts only the pure [`OAuthLoginCredentialFacts`] observations and
2203    /// mirrors the verdict (`Authorized` -> use cached, `RefreshRequired` ->
2204    /// begin refresh, `RefreshDisallowed` -> refresh-required error, etc.).
2205    ///
2206    /// Production handles must implement this through generated AuthMachine
2207    /// authority. The default fails closed so a handwritten handle cannot become
2208    /// a cached-vs-refresh reducer.
2209    fn resolve_oauth_login_credential_disposition(
2210        &self,
2211        lease_key: &LeaseKey,
2212        facts: OAuthLoginCredentialFacts,
2213    ) -> Result<CredentialUseDisposition, DslTransitionError> {
2214        let _ = (lease_key, facts);
2215        Err(DslTransitionError::no_matching(
2216            "AuthLeaseHandle::resolve_oauth_login_credential_disposition",
2217            "classifying OAuth-login credential disposition requires generated AuthMachine authority",
2218        ))
2219    }
2220
2221    /// Observe the current DSL-level state of a binding.
2222    fn snapshot(&self, lease_key: &LeaseKey) -> AuthLeaseSnapshot;
2223}
2224
2225// ---------------------------------------------------------------------------
2226// McpServerLifecycleHandle (Phase 5G / T5g)
2227// ---------------------------------------------------------------------------
2228
2229/// MCP client handshake lifecycle DSL handle (session-scoped).
2230///
2231/// Routes each per-server MCP handshake event into the MeerkatMachine DSL's
2232/// `mcp_server_states` substate. Distinct from the external-tool surface
2233/// lifecycle (which tracks staged/pending *tool surface* intents): this handle
2234/// tracks per-server *connection* lifecycle (PendingConnect → Connected |
2235/// Failed | Disconnected), keyed by the configured MCP server name.
2236///
2237/// Read side (`pending_server_ids`) is the authoritative source for the
2238/// `[MCP_PENDING]` system-notice toggle — any server in `PendingConnect` means
2239/// the notice is emitted; otherwise the notice is suppressed.
2240///
2241/// Concrete impls live in `meerkat-runtime`; standalone callers (tests,
2242/// fixtures) pass `None` for the handle and the router's shell-level behavior
2243/// remains identical (DSL record-keeping is skipped, which is fine because
2244/// there is no session DSL to mirror into).
2245pub trait McpServerLifecycleHandle: Send + Sync {
2246    /// Fire `McpServerConnectPending { server_id }` — server staged for
2247    /// background connect.
2248    fn apply_connect_pending(&self, server_id: &str) -> Result<(), DslTransitionError>;
2249
2250    /// Fire `McpServerConnected { server_id }` — handshake succeeded.
2251    fn apply_connected(&self, server_id: &str) -> Result<(), DslTransitionError>;
2252
2253    /// Fire `McpServerFailed { server_id, error }` — handshake failed.
2254    fn apply_failed(&self, server_id: &str, error: &str) -> Result<(), DslTransitionError>;
2255
2256    /// Fire `McpServerDisconnected { server_id }` — connection closed.
2257    fn apply_disconnected(&self, server_id: &str) -> Result<(), DslTransitionError>;
2258
2259    /// Fire `McpServerReload { server_id }` — reload requested; server returns
2260    /// to `PendingConnect` while the shell tears down and redials.
2261    fn apply_reload(&self, server_id: &str) -> Result<(), DslTransitionError>;
2262
2263    /// Observe the set of server ids currently in `PendingConnect`.
2264    ///
2265    /// Used by the agent loop to drive the `[MCP_PENDING]` system-notice
2266    /// lifecycle: non-empty → emit notice; empty → strip notice.
2267    fn pending_server_ids(&self) -> BTreeSet<String>;
2268}
2269
2270// ---------------------------------------------------------------------------
2271// PeerInteractionHandle (W1-A / issue #264)
2272// ---------------------------------------------------------------------------
2273
2274/// Terminal disposition companion for [`PeerInteractionHandle::response_terminal`].
2275///
2276/// Carried as a typed wire value so the DSL can route `Completed` / `Failed`
2277/// terminal transitions without the shell re-interpreting `ResponseStatus`.
2278#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2279#[non_exhaustive]
2280pub enum PeerTerminalDisposition {
2281    /// Terminal response with `Completed` status.
2282    Completed,
2283    /// Terminal response with `Failed` status.
2284    Failed,
2285}
2286
2287/// Peer request / response lifecycle DSL handle (W1-A).
2288///
2289/// Routes the full peer-interaction lifecycle — outbound `Sent`,
2290/// progress / terminal response arrival, timeouts, and inbound
2291/// `Received` / `Replied` — into the MeerkatMachine DSL's
2292/// `pending_peer_requests` / `inbound_peer_requests` substate maps.
2293///
2294/// Terminal transitions emit a DSL-owned cleanup effect that the shell
2295/// observes to drop any subscriber / stream channel associated with the
2296/// correlation id. The channels themselves live in shell-owned maps (they
2297/// hold `mpsc::Sender` values that cannot live in DSL state); those maps
2298/// are strict projections of DSL state, with the invariant "channel live
2299/// iff `corr_id ∈ pending ∧ state ≠ terminal`" enforced by the effect.
2300pub trait PeerInteractionHandle: Send + Sync {
2301    /// Fire `PeerRequestSent { corr_id }`.
2302    ///
2303    /// Guard: `corr_id` is not already in `pending_peer_requests`.
2304    fn request_sent(&self, corr_id: PeerCorrelationId) -> Result<(), DslTransitionError>;
2305
2306    /// Fire `PeerResponseProgressArrived { corr_id }`.
2307    ///
2308    /// Guard: `corr_id` is in `pending_peer_requests`. Progress after
2309    /// progress is admitted as a self-loop (the DSL overwrites the state
2310    /// slot). Rejects on unknown corr_id.
2311    fn response_progress(&self, corr_id: PeerCorrelationId) -> Result<(), DslTransitionError>;
2312
2313    /// Fire `PeerResponseTerminalArrived { corr_id, disposition }`.
2314    ///
2315    /// Guard: `corr_id` is in `pending_peer_requests`. Terminal transitions
2316    /// remove the map entry and emit the `PeerInteractionCleanup` effect,
2317    /// so any second terminal on the same corr_id is rejected at the
2318    /// `pending_exists` guard by construction.
2319    fn response_terminal(
2320        &self,
2321        corr_id: PeerCorrelationId,
2322        disposition: PeerTerminalDisposition,
2323    ) -> Result<(), DslTransitionError>;
2324
2325    /// Fire `PeerResponseRejected { corr_id }`.
2326    ///
2327    /// Guard: `corr_id` is in `pending_peer_requests`. This is used when
2328    /// peer ingress produced a response observation that cannot be admitted
2329    /// as progress or terminal because the generated terminality feedback is
2330    /// missing or inconsistent. The DSL owns the terminal cleanup; callers do
2331    /// not substitute a response disposition.
2332    fn response_rejected(&self, corr_id: PeerCorrelationId) -> Result<(), DslTransitionError>;
2333
2334    /// Fire `PeerRequestTimedOut { corr_id }`.
2335    ///
2336    /// Guard: `corr_id` is in `pending_peer_requests`. Like `response_terminal`,
2337    /// the map entry is removed on success and the `PeerInteractionCleanup`
2338    /// effect is emitted; subsequent fires fail the guard.
2339    fn request_timed_out(&self, corr_id: PeerCorrelationId) -> Result<(), DslTransitionError>;
2340
2341    /// Fire `PeerRequestSendFailed { corr_id }` (#291).
2342    ///
2343    /// Distinct from `request_timed_out`: the outbound request never reached the
2344    /// peer (transport/send failure), versus a genuine elapsed-deadline timeout.
2345    /// Emits `OutboundPeerRequestState::Failed` and removes the pending entry,
2346    /// mirroring the `PeerResponseRejected` failure disposition.
2347    fn request_send_failed(&self, corr_id: PeerCorrelationId) -> Result<(), DslTransitionError>;
2348
2349    /// Fire `PeerRequestReceived { corr_id, handling_mode }` (inbound).
2350    ///
2351    /// Guard: `corr_id` is not already in `inbound_peer_requests`.
2352    fn request_received(
2353        &self,
2354        corr_id: PeerCorrelationId,
2355        handling_mode: HandlingMode,
2356    ) -> Result<(), DslTransitionError>;
2357
2358    /// Ask the generated machine authority to classify a typed outbound reply
2359    /// status before shell transport send/cleanup code consumes terminality.
2360    fn classify_response_reply(
2361        &self,
2362        status: crate::ResponseStatus,
2363    ) -> Result<crate::TerminalityClass, DslTransitionError>;
2364
2365    /// Fire `PeerResponseReplied { corr_id }` (inbound reply sent).
2366    ///
2367    /// Guard: `corr_id` is in `inbound_peer_requests` with state `Received`.
2368    fn response_replied(&self, corr_id: PeerCorrelationId) -> Result<(), DslTransitionError>;
2369
2370    /// Observe the DSL-owned state of an outbound peer request.
2371    ///
2372    /// Returns `None` if the correlation id is not in `pending_peer_requests`.
2373    fn outbound_state(&self, corr_id: PeerCorrelationId) -> Option<OutboundPeerRequestState>;
2374
2375    /// Observe the DSL-owned state of an inbound peer request.
2376    fn inbound_state(&self, corr_id: PeerCorrelationId) -> Option<InboundPeerRequestState>;
2377
2378    /// Observe the DSL-owned handling-mode default for an inbound peer request.
2379    fn inbound_handling_mode(&self, corr_id: PeerCorrelationId) -> Option<HandlingMode>;
2380
2381    /// Install a projection-cleanup observer for the peer-interaction
2382    /// lifecycle. The runtime handle invokes the observer whenever a DSL
2383    /// transition emits `PeerInteractionCleanup`, closing the loop
2384    /// "terminal transition → effect → shell projection cleanup".
2385    ///
2386    /// Implementations with no observer simply drop any emitted cleanup
2387    /// notifications on the floor. Standalone / WASM paths leave this
2388    /// unset.
2389    fn install_cleanup_observer(&self, observer: Arc<dyn PeerInteractionCleanupObserver>);
2390}
2391
2392/// Observer invoked by [`PeerInteractionHandle`] when a DSL
2393/// `PeerInteractionCleanup` effect is emitted.
2394///
2395/// Shell-owned projection consumers (the comms runtime's subscriber /
2396/// stream registries) implement this to drop channel entries keyed on the
2397/// terminated correlation id. The observer is invoked under the same
2398/// authority lock as the transition that emitted the effect, so the
2399/// "terminal transition → effect → cleanup" chain is causal, not lexically
2400/// adjacent.
2401pub trait PeerInteractionCleanupObserver: Send + Sync {
2402    /// Called once per emitted `PeerInteractionCleanup { corr_id }` effect.
2403    ///
2404    /// Idempotent: a well-formed DSL run emits exactly one cleanup per
2405    /// correlation id because terminal transitions remove the map entry
2406    /// (subsequent attempts are rejected at the `pending_exists` guard),
2407    /// but observers should tolerate a redundant call defensively.
2408    fn on_peer_interaction_cleanup(&self, corr_id: PeerCorrelationId);
2409}
2410
2411/// Session-context advancement DSL handle (W2-E / issue #264).
2412///
2413/// Shell callers fire `context_advanced(updated_at_ms)` at every site that
2414/// mutates canonical session truth (prompt append, external content
2415/// injection, tool-result append, external assistant output,
2416/// ordinary durable System-message append, any `summary_tx.send_replace`). The
2417/// transition is monotonic: the DSL guard drops ticks whose `updated_at_ms`
2418/// isn't strictly greater than the last recorded watermark, so callers can
2419/// fire unconditionally post-mutation.
2420///
2421/// Every successful transition emits `SessionContextAdvanced` which is
2422/// dispatched to the installed [`SessionContextAdvancedObserver`] — the
2423/// realtime projection consumer uses the observer to drive a typed
2424/// `ProjectionFreshness` state instead of polling a watch channel.
2425pub trait SessionContextHandle: Send + Sync {
2426    /// Fire `AdvanceSessionContext { updated_at_ms }`.
2427    ///
2428    /// Guard: `updated_at_ms` is strictly greater than the last recorded
2429    /// watermark. Returns `Ok(false)` when the guard rejects the tick as
2430    /// non-advancing (duplicate or out-of-order); returns `Ok(true)` when
2431    /// the transition lands and the effect is emitted. Transition errors
2432    /// (lock poisoning, unexpected DSL state) surface as `Err`.
2433    fn context_advanced(&self, updated_at_ms: u64) -> Result<bool, DslTransitionError>;
2434
2435    /// The monotonic watermark in milliseconds of the last successful
2436    /// `AdvanceSessionContext` transition recorded on this handle.
2437    ///
2438    /// Returns `0` before any advance has been recorded. The realtime
2439    /// projection consumer reads this once at install time to seed its
2440    /// `ProjectionFreshness` baseline, so the consumer and the DSL agree
2441    /// on the initial frontier by construction (no two-read race).
2442    fn current_watermark_ms(&self) -> u64;
2443
2444    /// Install a typed observer for `SessionContextAdvanced` effect
2445    /// emission. Implementations without an installed observer drop the
2446    /// effect on the floor (standalone / WASM paths).
2447    fn install_observer(&self, observer: Arc<dyn SessionContextAdvancedObserver>);
2448
2449    /// Atomically install a typed observer and return the current watermark
2450    /// as a single critical section. Implementations MUST hold the same
2451    /// authority lock that `context_advanced` uses for both the watermark
2452    /// read and the observer installation, so no `SessionContextAdvanced`
2453    /// effect can slip between "sampled baseline" and "observer visible".
2454    ///
2455    /// Callers use the returned `u64` as their `ProjectionFreshness`
2456    /// baseline; any subsequent `context_advanced` tick is guaranteed to
2457    /// either (a) have already been included in the returned watermark, or
2458    /// (b) be visible to the observer. The `current_watermark_ms` +
2459    /// `install_observer` pair is NOT a substitute: a transition can land
2460    /// between those two non-atomic steps and be lost to both the baseline
2461    /// and the observer.
2462    fn install_observer_with_baseline(
2463        &self,
2464        observer: Arc<dyn SessionContextAdvancedObserver>,
2465    ) -> u64;
2466}
2467
2468/// Observer invoked by [`SessionContextHandle`] when a DSL
2469/// `SessionContextAdvanced` effect is emitted (W2-E / issue #264).
2470///
2471/// The realtime projection consumer implements this to advance its typed
2472/// `ProjectionFreshness` state. Runtime handles sample the installed
2473/// observer under the same authority lock as the transition that emitted
2474/// the effect, then dispatch the callback immediately after releasing the
2475/// lock so re-entrant observer implementations can safely route back
2476/// through the same DSL authority.
2477pub trait SessionContextAdvancedObserver: Send + Sync {
2478    /// Called once per emitted `SessionContextAdvanced { updated_at_ms }`
2479    /// effect. `updated_at_ms` is the monotonic millisecond watermark of
2480    /// the canonical session-context mutation that produced this tick.
2481    fn on_session_context_advanced(&self, updated_at_ms: u64);
2482}
2483
2484// ---------------------------------------------------------------------------
2485// SessionClaimHandle (dogma #2 — canonical session-identity owner)
2486// ---------------------------------------------------------------------------
2487
2488/// Error surfaced by [`SessionClaimHandle::try_acquire`].
2489#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2490pub enum SessionClaimError {
2491    /// Another live claim already exists for this session id.
2492    #[error("session identity already claimed: {0}")]
2493    SessionIdentityInUse(SessionId),
2494}
2495
2496/// RAII token returned by [`SessionClaimHandle::try_acquire`].
2497///
2498/// While alive, the underlying registry guarantees no other caller can
2499/// acquire a claim for the same `session_id`. Drop releases the claim back
2500/// through the owning handle.
2501pub struct SessionClaim {
2502    session_id: SessionId,
2503    handle: Arc<dyn SessionClaimHandle>,
2504}
2505
2506impl SessionClaim {
2507    /// Construct a new claim — only [`SessionClaimHandle`] impls should call
2508    /// this, immediately after they have inserted `session_id` into their
2509    /// canonical registry under a single critical section.
2510    pub fn new(session_id: SessionId, handle: Arc<dyn SessionClaimHandle>) -> Self {
2511        Self { session_id, handle }
2512    }
2513
2514    /// The session id this claim covers.
2515    pub fn session_id(&self) -> &SessionId {
2516        &self.session_id
2517    }
2518}
2519
2520impl Drop for SessionClaim {
2521    fn drop(&mut self) {
2522        self.handle.release(&self.session_id);
2523    }
2524}
2525
2526impl std::fmt::Debug for SessionClaim {
2527    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2528        f.debug_struct("SessionClaim")
2529            .field("session_id", &self.session_id)
2530            .finish_non_exhaustive()
2531    }
2532}
2533
2534/// Process-scope canonical owner of "this session id is currently active."
2535///
2536/// One canonical owner per process: `MeerkatMachine` exposes its registry
2537/// when a runtime is wired (so every live runtime-registered session also
2538/// owns its identity claim), and a default in-process registry covers bare
2539/// `AgentFactory` callers without a runtime. Either way, "this session id
2540/// is in use" lives in a typed owner — never in process-global shell
2541/// bookkeeping.
2542pub trait SessionClaimHandle: Send + Sync {
2543    /// Atomically reserve `session_id`. Returns a [`SessionClaim`] whose
2544    /// `Drop` releases the slot. Returns
2545    /// [`SessionClaimError::SessionIdentityInUse`] if another live claim
2546    /// already covers this session.
2547    ///
2548    /// Implementations MUST insert under a single critical section so two
2549    /// concurrent callers cannot both succeed.
2550    fn try_acquire(
2551        self: Arc<Self>,
2552        session_id: &SessionId,
2553    ) -> Result<SessionClaim, SessionClaimError>;
2554
2555    /// Release a claim previously created by [`Self::try_acquire`].
2556    ///
2557    /// Called from [`SessionClaim`]'s `Drop`. Idempotent: releasing an
2558    /// unknown id is a no-op (the registry was already cleared, e.g. via
2559    /// runtime teardown).
2560    fn release(&self, session_id: &SessionId);
2561}
2562
2563/// In-process default [`SessionClaimHandle`] for bare-usage paths that have
2564/// no `MeerkatMachine` available (standalone `AgentFactory` callers, doc
2565/// examples, simple SDK consumers). One process-global instance keeps the
2566/// "one active claim per session id" invariant intact even when no runtime
2567/// is wired.
2568pub struct DefaultSessionClaimRegistry {
2569    claims: std::sync::Mutex<std::collections::HashSet<SessionId>>,
2570}
2571
2572impl DefaultSessionClaimRegistry {
2573    /// Construct an empty registry.
2574    pub fn new() -> Self {
2575        Self {
2576            claims: std::sync::Mutex::new(std::collections::HashSet::new()),
2577        }
2578    }
2579
2580    /// Process-global instance — used by bare-usage facade builders.
2581    pub fn global() -> Arc<Self> {
2582        use std::sync::OnceLock;
2583        static GLOBAL: OnceLock<Arc<DefaultSessionClaimRegistry>> = OnceLock::new();
2584        Arc::clone(GLOBAL.get_or_init(|| Arc::new(DefaultSessionClaimRegistry::new())))
2585    }
2586}
2587
2588impl Default for DefaultSessionClaimRegistry {
2589    fn default() -> Self {
2590        Self::new()
2591    }
2592}
2593
2594impl SessionClaimHandle for DefaultSessionClaimRegistry {
2595    fn try_acquire(
2596        self: Arc<Self>,
2597        session_id: &SessionId,
2598    ) -> Result<SessionClaim, SessionClaimError> {
2599        let mut claims = self
2600            .claims
2601            .lock()
2602            .unwrap_or_else(std::sync::PoisonError::into_inner);
2603        if !claims.insert(session_id.clone()) {
2604            return Err(SessionClaimError::SessionIdentityInUse(session_id.clone()));
2605        }
2606        drop(claims);
2607        Ok(SessionClaim::new(
2608            session_id.clone(),
2609            self as Arc<dyn SessionClaimHandle>,
2610        ))
2611    }
2612
2613    fn release(&self, session_id: &SessionId) {
2614        let mut claims = self
2615            .claims
2616            .lock()
2617            .unwrap_or_else(std::sync::PoisonError::into_inner);
2618        claims.remove(session_id);
2619    }
2620}
2621
2622// ---------------------------------------------------------------------------
2623// InteractionStreamHandle (U6 / dogma #5)
2624// ---------------------------------------------------------------------------
2625
2626/// Interaction stream lifecycle DSL handle.
2627///
2628/// Routes the reservation/attach/completion/expire/close-early/abandon lifecycle of
2629/// a streamed interaction into the MeerkatMachine DSL's `interaction_streams`
2630/// substate map. The shell-side `interaction_stream_registry` projects
2631/// sender/receiver channels off this map; terminal transitions emit
2632/// [`InteractionStreamCleanupObserver::on_interaction_stream_cleanup`], which
2633/// the comms runtime uses to drop the channel projection.
2634///
2635/// Reservation TTL is shell-owned mechanics: the runtime holds the timestamp
2636/// and decides when to fire `expired`. Every state-meaning decision (is the
2637/// reservation still claimable? has the consumer attached? did a terminal
2638/// event win the race?) lives in the DSL.
2639pub trait InteractionStreamHandle: Send + Sync {
2640    /// Fire `InteractionStreamReserved { corr_id }`.
2641    ///
2642    /// Guard: `corr_id` is not already in `interaction_streams`. Rejected
2643    /// duplicates surface as [`DslTransitionError`] so the shell can refuse
2644    /// to register two channels under the same key.
2645    fn reserved(&self, corr_id: PeerCorrelationId) -> Result<(), DslTransitionError>;
2646
2647    /// Fire `InteractionStreamAttached { corr_id }`.
2648    ///
2649    /// Guard: state is `Reserved`. Rejected if the reservation already
2650    /// expired, the consumer already attached, or the entry never existed.
2651    fn attached(&self, corr_id: PeerCorrelationId) -> Result<(), DslTransitionError>;
2652
2653    /// Fire `InteractionStreamCompleted { corr_id }`.
2654    ///
2655    /// Guard: state is `Attached`. Terminal — emits the cleanup effect.
2656    fn completed(&self, corr_id: PeerCorrelationId) -> Result<(), DslTransitionError>;
2657
2658    /// Fire `InteractionStreamExpired { corr_id }`.
2659    ///
2660    /// Guard: state is `Reserved`. Terminal — emits the cleanup effect.
2661    fn expired(&self, corr_id: PeerCorrelationId) -> Result<(), DslTransitionError>;
2662
2663    /// Fire `InteractionStreamClosedEarly { corr_id }`.
2664    ///
2665    /// Guard: state is `Attached`. Terminal — emits the cleanup effect.
2666    fn closed_early(&self, corr_id: PeerCorrelationId) -> Result<(), DslTransitionError>;
2667
2668    /// Fire `InteractionStreamAbandoned { corr_id, reason }`.
2669    ///
2670    /// Guard: state is `Reserved` or `Attached`. Terminal — emits the cleanup
2671    /// effect. This is the explicit failure path for send/admission/response
2672    /// delivery failures; callers must not substitute `expired` or a peer
2673    /// request timeout for these observations.
2674    fn abandoned(
2675        &self,
2676        corr_id: PeerCorrelationId,
2677        reason: InteractionStreamAbandonReason,
2678    ) -> Result<(), DslTransitionError>;
2679
2680    /// Read the DSL-owned state for a given correlation id, if any.
2681    ///
2682    /// Returns `None` when the entry has already been removed (terminal or
2683    /// never reserved). Active states (`Reserved`, `Attached`) surface as
2684    /// `Some(..)`; terminal variants surface only via the
2685    /// `InteractionStreamStateChanged` effect, never on the active map.
2686    fn state(&self, corr_id: PeerCorrelationId) -> Option<InteractionStreamState>;
2687
2688    /// Install a projection-cleanup observer for the interaction stream
2689    /// lifecycle. The runtime handle invokes the observer whenever a DSL
2690    /// transition emits `InteractionStreamCleanup`, closing the loop
2691    /// "terminal transition → effect → shell projection cleanup".
2692    fn install_cleanup_observer(&self, observer: Arc<dyn InteractionStreamCleanupObserver>);
2693}
2694
2695/// Observer invoked by [`InteractionStreamHandle`] when a DSL
2696/// `InteractionStreamCleanup` effect is emitted.
2697///
2698/// Shell-owned projection consumers (the comms runtime's
2699/// `interaction_stream_registry`) implement this to drop channel entries
2700/// keyed on the terminated correlation id. Runtime handles sample the
2701/// observer under the same authority lock as the transition that emitted
2702/// the effect, then dispatch after releasing the lock.
2703pub trait InteractionStreamCleanupObserver: Send + Sync {
2704    /// Called once per emitted `InteractionStreamCleanup` effect. The optional
2705    /// reason is present only for the generated `Abandoned` terminal and lets
2706    /// the shell project that typed fault without reclassifying it.
2707    ///
2708    /// Idempotent in the well-formed case (terminal transitions remove the
2709    /// map entry so subsequent fires fail the guard), but observers should
2710    /// tolerate redundant calls defensively.
2711    fn on_interaction_stream_cleanup(
2712        &self,
2713        corr_id: PeerCorrelationId,
2714        abandon_reason: Option<InteractionStreamAbandonReason>,
2715    );
2716}
2717
2718#[cfg(test)]
2719#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
2720mod tests {
2721    use super::{
2722        DslRejectionKind, DslTransitionError, ExternalToolSurfaceEffect,
2723        ExternalToolSurfaceFailureCause, ExternalToolSurfaceInput, PeerConversationProjection,
2724        PeerResponseProgressProjectionPhase, PeerResponseTerminalCorrelationId,
2725        PeerResponseTerminalDisplayIdentity, PeerResponseTerminalFact,
2726        PeerResponseTerminalFactError, PeerResponseTerminalProjectionStatus,
2727        PeerResponseTerminalRenderPayload, PeerResponseTerminalRouteIdentity,
2728        PeerResponseTerminalSource, PeerResponseTerminalTransportIdentity,
2729    };
2730    use crate::tool_scope::{ExternalToolSurfaceDeltaOperation, ExternalToolSurfaceDeltaPhase};
2731
2732    #[test]
2733    fn recovered_state_rejection_is_not_guard_noop() {
2734        let err =
2735            DslTransitionError::recovered_state_invariant_rejected("recover", "bad invariant");
2736        assert_eq!(err.kind, DslRejectionKind::RecoveredStateInvariantRejected);
2737        assert!(!err.is_guard_rejected());
2738    }
2739
2740    #[test]
2741    fn external_tool_surface_pending_failure_cause_projects_external_code() {
2742        let input = ExternalToolSurfaceInput::MarkPendingFailed {
2743            surface_id: "alpha".to_owned(),
2744            pending_task_sequence: 7,
2745            staged_intent_sequence: 11,
2746            cause: ExternalToolSurfaceFailureCause::PendingFailed,
2747        };
2748
2749        let ExternalToolSurfaceInput::MarkPendingFailed { cause, .. } = input else {
2750            panic!("constructed MarkPendingFailed input");
2751        };
2752        assert_eq!(cause, ExternalToolSurfaceFailureCause::PendingFailed);
2753        assert_eq!(cause.as_str(), "pending_failed");
2754        assert_eq!(
2755            serde_json::to_value(cause).expect("serialize failure cause"),
2756            serde_json::json!("pending_failed")
2757        );
2758
2759        let effect = ExternalToolSurfaceEffect::EmitExternalToolDelta {
2760            surface_id: "alpha".to_owned(),
2761            operation: ExternalToolSurfaceDeltaOperation::Add,
2762            phase: ExternalToolSurfaceDeltaPhase::Failed,
2763            cause: Some(cause),
2764        };
2765        assert!(matches!(
2766            effect,
2767            ExternalToolSurfaceEffect::EmitExternalToolDelta {
2768                cause: Some(ExternalToolSurfaceFailureCause::PendingFailed),
2769                ..
2770            }
2771        ));
2772    }
2773
2774    #[test]
2775    fn peer_terminal_projection_owns_prompt_and_context_key() {
2776        let route_id = "550e8400-e29b-41d4-a716-446655440000";
2777        let route_identity =
2778            PeerResponseTerminalRouteIdentity::parse(route_id).expect("route identity");
2779        let correlation_id =
2780            PeerResponseTerminalCorrelationId::parse("018f6f79-7a82-7c4e-a552-a3b86f9630f1")
2781                .expect("correlation id");
2782        let projection = PeerConversationProjection::ResponseTerminal {
2783            fact: PeerResponseTerminalFact::new(
2784                PeerResponseTerminalSource::new(
2785                    Some(
2786                        PeerResponseTerminalTransportIdentity::parse("transport-runtime-1")
2787                            .expect("transport identity"),
2788                    ),
2789                    route_identity,
2790                    PeerResponseTerminalDisplayIdentity::parse("Analyst")
2791                        .expect("display identity"),
2792                ),
2793                correlation_id,
2794                PeerResponseTerminalProjectionStatus::Completed,
2795                PeerResponseTerminalRenderPayload::new(Some(serde_json::json!({
2796                    "request_intent": "checksum_token",
2797                    "request_subject": "alpha beta gamma",
2798                    "token": "birch seventeen"
2799                }))),
2800            ),
2801        };
2802
2803        assert_eq!(
2804            projection.context_key().as_deref(),
2805            Some(
2806                "peer_response_terminal:550e8400-e29b-41d4-a716-446655440000:018f6f79-7a82-7c4e-a552-a3b86f9630f1"
2807            )
2808        );
2809        assert_eq!(
2810            projection.prompt_text(),
2811            "Peer terminal response from Analyst. Request ID: 018f6f79-7a82-7c4e-a552-a3b86f9630f1. Status: completed. Result: {\n  \"request_intent\": \"checksum_token\",\n  \"request_subject\": \"alpha beta gamma\",\n  \"token\": \"birch seventeen\"\n}."
2812        );
2813    }
2814
2815    #[test]
2816    fn peer_terminal_fact_is_structural_projection_only() {
2817        let route_id = "550e8400-e29b-41d4-a716-446655440000";
2818        let route_identity =
2819            PeerResponseTerminalRouteIdentity::parse(route_id).expect("route identity");
2820        let correlation_id =
2821            PeerResponseTerminalCorrelationId::parse("018f6f79-7a82-7c4e-a552-a3b86f9630f1")
2822                .expect("correlation id");
2823
2824        let fact = PeerResponseTerminalFact::new(
2825            PeerResponseTerminalSource::new(
2826                None,
2827                route_identity,
2828                PeerResponseTerminalDisplayIdentity::parse("Analyst").expect("display identity"),
2829            ),
2830            correlation_id,
2831            PeerResponseTerminalProjectionStatus::Cancelled,
2832            PeerResponseTerminalRenderPayload::new(None),
2833        );
2834
2835        assert_eq!(
2836            fact.status,
2837            PeerResponseTerminalProjectionStatus::Cancelled,
2838            "status support is decided by generated admission authority, not fact construction"
2839        );
2840    }
2841
2842    #[test]
2843    fn peer_progress_projection_formats_phase_from_shared_seam() {
2844        let projection = PeerConversationProjection::ResponseProgress {
2845            peer_id: "operator-rt".into(),
2846            request_id: "req-789".into(),
2847            phase: PeerResponseProgressProjectionPhase::PartialResult,
2848            payload: Some(serde_json::json!({ "chunk": "alpha" })),
2849        };
2850
2851        assert_eq!(projection.context_key(), None);
2852        assert_eq!(
2853            projection.prompt_text(),
2854            "Peer response progress from operator-rt. Request ID: req-789. Phase: partial_result. Payload: {\n  \"chunk\": \"alpha\"\n}."
2855        );
2856    }
2857
2858    #[test]
2859    fn peer_terminal_context_key_helper_stays_canonical() {
2860        let route_id = "550e8400-e29b-41d4-a716-446655440000";
2861        let route_identity =
2862            PeerResponseTerminalRouteIdentity::parse(route_id).expect("route identity");
2863        let correlation_id =
2864            PeerResponseTerminalCorrelationId::parse("018f6f79-7a82-7c4e-a552-a3b86f9630f1")
2865                .expect("correlation id");
2866        assert_eq!(
2867            PeerResponseTerminalFact::context_key_for(&route_identity, correlation_id),
2868            "peer_response_terminal:550e8400-e29b-41d4-a716-446655440000:018f6f79-7a82-7c4e-a552-a3b86f9630f1"
2869        );
2870    }
2871
2872    #[test]
2873    fn peer_terminal_route_identity_rejects_display_name_alias() {
2874        assert!(matches!(
2875            PeerResponseTerminalRouteIdentity::parse("analyst-rt"),
2876            Err(PeerResponseTerminalFactError::InvalidRouteIdentity)
2877        ));
2878    }
2879
2880    #[test]
2881    fn peer_terminal_fact_round_trips_through_serde() {
2882        // The typed fact is persisted with its runtime input and realized as
2883        // one ordinary SystemNotice, so it must survive a durable serde
2884        // round-trip without re-parsing flattened prompt text.
2885        let fact = PeerResponseTerminalFact::new(
2886            PeerResponseTerminalSource::parse(
2887                Some("inproc://analyst"),
2888                "550e8400-e29b-41d4-a716-446655440000",
2889                "analyst-rt",
2890            )
2891            .expect("source"),
2892            PeerResponseTerminalCorrelationId::parse("018f6f79-7a82-7c4e-a552-a3b86f9630f1")
2893                .expect("correlation id"),
2894            PeerResponseTerminalProjectionStatus::Completed,
2895            PeerResponseTerminalRenderPayload::new(Some(serde_json::json!({
2896                "request_intent": "checksum_token",
2897                "token": "birch seventeen",
2898            }))),
2899        );
2900
2901        let json = serde_json::to_string(&fact).expect("serialize fact");
2902        let decoded: PeerResponseTerminalFact =
2903            serde_json::from_str(&json).expect("deserialize fact");
2904        assert_eq!(decoded, fact);
2905        assert_eq!(
2906            decoded.context_key(),
2907            "peer_response_terminal:550e8400-e29b-41d4-a716-446655440000:018f6f79-7a82-7c4e-a552-a3b86f9630f1"
2908        );
2909        assert_eq!(
2910            decoded
2911                .render_payload_value()
2912                .and_then(|payload| payload.get("token"))
2913                .and_then(|token| token.as_str()),
2914            Some("birch seventeen")
2915        );
2916    }
2917}