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