Skip to main content

meerkat_runtime/
member_observation.rs

1//! Member-host observation seam (multi-host mobs §7.4, DEC-P6E-2).
2//!
3//! The member runtime's comms drain serves three supervisor bridge commands
4//! (`ReadMemberHistory`, `PollMemberEvents`, and the directed-turn half of
5//! `DeliverMemberInput`) but cannot read what those arms need: session
6//! history lives behind the daemon's session service, durable event reads
7//! live behind the facade service's event log, and generation + turn-outcome
8//! journal facts are `MobHostBindingAuthority` facts on the host daemon.
9//! This module defines the injected host trait the drain resolves — the
10//! `SessionLlmReconfigureHost` precedent — plus the carrier types and the
11//! per-session tracked-turn journal seam (`TrackedTurnJournal`,
12//! DEC-P6F-9's registration contract; the meerkat-mob host lane implements
13//! both and the daemon installs them).
14//!
15//! Sequence-domain law (plan gotcha 8): every `seq` in this module is the
16//! owning host's durable `StoredEvent.seq` (or, for ephemeral hosts, the
17//! host-owned generation-scoped ring seq that substitutes for it). The
18//! session-task and per-stream counters never leak into these carriers.
19
20use std::sync::Arc;
21
22use meerkat_contracts::wire::supervisor_bridge::{
23    BridgeDeliveryRejectionCause, BridgeHostRuntimeIncarnation, BridgeMemberIncarnation,
24    BridgeTrackedInputCancelOutcome, BridgeTurnOutcomeAck, BridgeTurnOutcomeRecord,
25    WireFlowTurnOutcome,
26};
27use meerkat_core::event::{AgentEvent, EventEnvelope};
28use meerkat_core::service::SessionHistoryPage;
29use meerkat_core::time_compat::Duration;
30use meerkat_core::types::SessionId;
31
32use crate::completion::CompletionHandle;
33
34/// Maximum standalone JSON size of one durable directed-turn terminal row.
35/// This keeps one provider/error detail from permanently bricking the
36/// journal or an otherwise bounded bridge page.
37pub const MAX_TURN_OUTCOME_RECORD_BYTES: usize = 64 * 1024;
38
39/// Typed failure vocabulary for member observation serving.
40#[derive(Debug, thiserror::Error)]
41pub enum MemberObservationError {
42    /// The addressed host-member residency changed while the observation was
43    /// being served. Maps to the wire `StaleFence` authority rejection.
44    #[error("stale member observation residency: {reason}")]
45    StaleIncarnation { reason: String },
46    /// The requested cursor position was pruned from the retained window
47    /// (structurally ring-only in v1 — a durable log never overruns). Maps
48    /// to the wire `BridgeRejectionCause::StaleCursor`.
49    #[error("cursor overran the retained window (watermark {watermark}, generation {generation})")]
50    StaleCursor { watermark: u64, generation: u64 },
51    /// The cursor names a generation this host never issued (`g > current`;
52    /// restore-from-backup / split-brain shape). Fail closed — never serve
53    /// under a future-generation cursor (FLAG-P6E-12). Maps to the wire
54    /// `BridgeRejectionCause::Internal` with this diagnostic.
55    #[error("cursor generation {requested} is ahead of current generation {current}")]
56    FutureGenerationCursor { requested: u64, current: u64 },
57    /// The observation substrate cannot serve this session right now
58    /// (unknown session, missing durable log, halted projection). Maps to
59    /// the wire `BridgeRejectionCause::Unavailable` (ADJ-P4-7 vocabulary).
60    #[error("member observation unavailable: {reason}")]
61    Unavailable { reason: String },
62    /// A single outcome row cannot fit the independently bounded outcome
63    /// protocol. Rejected before it reaches durable storage.
64    #[error("turn outcome record is {encoded_bytes} bytes (maximum {max_bytes})")]
65    OutcomeRecordTooLarge {
66        encoded_bytes: usize,
67        max_bytes: usize,
68    },
69    /// An invariant was violated while serving. Maps to `Internal`.
70    #[error("member observation internal fault: {reason}")]
71    Internal { reason: String },
72}
73
74/// Domain twin of the wire `BridgeEventCursor`.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum MemberObservationCursor {
77    /// Start at the live tail (skip history).
78    Tail,
79    /// Resume from a recorded `(generation, seq)` position.
80    At { generation: u64, seq: u64 },
81}
82
83/// One served page of a member's durable event stream (DEC-P6E-4/5/18).
84#[derive(Debug)]
85pub struct MemberEventsWindow {
86    /// Exact boot incarnation of the host actor that served this page. The
87    /// controlling mob fences reachability on this token before it may fold
88    /// or advertise the page as progress.
89    pub runtime_incarnation: BridgeHostRuntimeIncarnation,
90    /// Generation the page was served at — the self-describing seq-domain
91    /// reset signal (§14.6).
92    pub generation: u64,
93    /// Materialization fence paired with `generation`. A same-generation
94    /// fence rotation is a distinct observation incarnation.
95    pub fence_token: u64,
96    /// `(durable seq, envelope)` rows in seq order.
97    pub rows: Vec<(u64, EventEnvelope<AgentEvent>)>,
98    /// Exact read floor used for this page. Response-size trimming may drop
99    /// every event row and must then resume from this value, never skip.
100    pub from_seq: u64,
101    /// Cursor seq to resume from (durable seq domain).
102    pub next_seq: u64,
103    /// Highest durable seq the owning host has recorded.
104    pub watermark: u64,
105    /// Bounded page of retained, unacknowledged turn-outcome journal rows for
106    /// the member's current generation.
107    pub turn_outcomes: Vec<BridgeTurnOutcomeRecord>,
108    /// Whether every currently retained unacknowledged row fit this outcome
109    /// page. This does not claim that no delayed commit can appear later.
110    pub outcomes_complete: bool,
111}
112
113/// One served transcript page (DEC-P6E-6).
114#[derive(Debug)]
115pub struct MemberHistoryWindow {
116    /// Generation the page was read at.
117    pub generation: u64,
118    /// Domain transcript page; the drain arm projects it through the ONE
119    /// wire projection (`WireMemberHistoryPageBody::try_from_history_page`).
120    pub page: SessionHistoryPage,
121}
122
123/// Authority witness and bounded-read controls for one member event poll.
124///
125/// Keeping these facts together prevents callers from accidentally pairing
126/// acknowledgement rows or cursor controls with a different member
127/// incarnation while leaving the addressed session explicit on the host
128/// seam.
129#[derive(Debug, Clone, Copy)]
130pub struct MemberEventsPollRequest<'a> {
131    /// Exact resident member incarnation authorized to serve the page.
132    pub expected_member: &'a BridgeMemberIncarnation,
133    /// Durable event cursor in the owning host's sequence domain.
134    pub cursor: MemberObservationCursor,
135    /// Maximum event rows requested by the caller.
136    pub max: u32,
137    /// Bounded long-poll duration.
138    pub wait: Duration,
139    /// Exact terminal outcome acknowledgements to apply before serving.
140    pub outcome_acks: &'a [BridgeTurnOutcomeAck],
141    /// Maximum retained terminal outcomes requested by the caller.
142    pub max_outcomes: u32,
143}
144
145/// Pre-accept event window for a directed turn (DEC-P6E-16's atomicity
146/// letter: the subscription exists BEFORE `accept_input_with_completion`
147/// runs, so no terminal can slip between accept and watch).
148pub struct DirectedTurnWindow {
149    /// Full host residency captured with the durable Pending reservation.
150    /// Every later cancel/admit/record uses this tuple rather than re-deriving
151    /// authority from a possibly replaced session projection.
152    pub expected_member: meerkat_contracts::wire::supervisor_bridge::BridgeMemberIncarnation,
153    /// Live session-events subscription opened before acceptance.
154    pub subscription: meerkat_core::comms::EventStream,
155    /// Durable watermark at open (+1 = first seq that can belong to this
156    /// window). The `terminal_seq` binder scans forward from here.
157    pub window_start: u64,
158    /// Member generation at open.
159    pub generation: u64,
160    /// Materialization fence paired with `generation`.
161    pub fence_token: u64,
162    /// Exact directed-turn input id reserved before runtime acceptance.
163    pub input_id: String,
164    /// Whether the host persisted/replayed Pending or found an already
165    /// terminal exact key. Terminal replay needs no watcher.
166    pub tracking: DirectedTurnTracking,
167}
168
169/// Subscription-free facts needed to serialize and revalidate final runtime
170/// admission. This owned projection is safe to retain across the async trait
171/// boundary without requiring [`DirectedTurnWindow`]'s event stream to be
172/// `Sync`.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct DirectedTurnAdmissionRequest {
175    /// Full host residency captured with the durable Pending reservation.
176    pub expected_member: BridgeMemberIncarnation,
177    /// First durable sequence that can belong to this directed-turn window.
178    pub window_start: u64,
179    /// Member generation captured when the window opened.
180    pub generation: u64,
181    /// Materialization fence paired with `generation`.
182    pub fence_token: u64,
183    /// Exact directed-turn input id reserved before runtime acceptance.
184    pub input_id: String,
185}
186
187impl DirectedTurnWindow {
188    /// Snapshot the exact durable reservation facts used by the final
189    /// admission revalidation. The live subscription remains owned by this
190    /// window for the terminal watcher.
191    #[must_use]
192    pub fn admission_request(&self) -> DirectedTurnAdmissionRequest {
193        DirectedTurnAdmissionRequest {
194            expected_member: self.expected_member.clone(),
195            window_start: self.window_start,
196            generation: self.generation,
197            fence_token: self.fence_token,
198            input_id: self.input_id.clone(),
199        }
200    }
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum DirectedTurnTracking {
205    /// This request created the durable Pending row and may cancel it after
206    /// proving its own runtime admission had no effect.
207    PendingFresh,
208    /// A previous request already owned durable Pending custody. Failure of
209    /// this retry cannot prove the original request had no effect, so it must
210    /// never cancel the row or return a definite no-effect rejection.
211    PendingReplay,
212    TerminalReplay,
213}
214
215/// Opaque ownership of the host's exact-key delivery/cancellation admission
216/// interval. The comms drain retains this value across runtime acceptance;
217/// its drop releases the host-side mutex. The payload is deliberately opaque
218/// so runtime code cannot inspect or forge host synchronization state.
219pub struct DirectedTurnAdmissionPermit {
220    _guard: Box<dyn Send>,
221}
222
223impl DirectedTurnAdmissionPermit {
224    #[doc(hidden)]
225    pub fn new(guard: impl Send + 'static) -> Self {
226        Self {
227            _guard: Box::new(guard),
228        }
229    }
230}
231
232/// Result of atomically locking and revalidating a previously opened Pending
233/// window. `TerminalReplay` means cancellation/terminal custody won the race
234/// and runtime admission must not run.
235pub enum DirectedTurnAdmissionDecision {
236    Admit(DirectedTurnAdmissionPermit),
237    TerminalReplay,
238}
239
240impl std::fmt::Debug for DirectedTurnWindow {
241    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242        f.debug_struct("DirectedTurnWindow")
243            .field("expected_member", &self.expected_member)
244            .field("window_start", &self.window_start)
245            .field("generation", &self.generation)
246            .field("fence_token", &self.fence_token)
247            .field("input_id", &self.input_id)
248            .field("tracking", &self.tracking)
249            .finish_non_exhaustive()
250    }
251}
252
253/// Admission handoff for one directed (tracked) turn: either a flow directive
254/// or a plain interaction with explicit outcome custody. Carries everything the
255/// observation host's watcher needs to classify the terminal, bind
256/// `terminal_seq`, and journal the outcome (DEC-P6E-16/17).
257pub struct DirectedTurnAdmission {
258    /// Canonical accepted input id — the journal / dedup / obligation key
259    /// (`AcceptOutcome::{Accepted,Deduplicated}` canonical id, which equals
260    /// the delivery `input_id` by idempotency-key construction).
261    pub input_id: String,
262    /// Pre-accept event window (subscription + admission watermark).
263    pub window: DirectedTurnWindow,
264    /// The completion handle the drain used to discard (`comms_drain.rs`
265    /// `_completion_handle`); generated-authority terminal truth.
266    /// `None` means runtime authority proved the accepted/deduplicated input
267    /// was already terminal during admission. The host still reconstructs the
268    /// terminal exhaustively from the original durable window.
269    pub completion_handle: Option<CompletionHandle>,
270    /// Per-session journal seam registered at residency establishment.
271    pub journal: Arc<dyn TrackedTurnJournal>,
272}
273
274/// Typed reject for a tracked-turn admission that cannot be tracked.
275/// Structural absence maps to the legacy-named `TurnDirectiveUnsupported`; a
276/// saturated durable completion journal maps to `OutcomeJournalFull` before
277/// runtime acceptance.
278#[derive(Debug, thiserror::Error)]
279#[error("tracked turn unsupported: {detail}")]
280pub struct DirectedTurnReject {
281    pub cause: BridgeDeliveryRejectionCause,
282    pub detail: String,
283    /// Whether this rejection proves no request with the same durable key was
284    /// previously accepted. `false` must surface as an outer ambiguous bridge
285    /// failure, never `BridgeDeliveryOutcome::Rejected`.
286    pub definite_no_effect: bool,
287}
288
289impl DirectedTurnReject {
290    #[must_use]
291    pub fn unsupported(detail: impl Into<String>) -> Self {
292        let detail = detail.into();
293        Self {
294            cause: BridgeDeliveryRejectionCause::TurnDirectiveUnsupported {
295                detail: detail.clone(),
296            },
297            detail,
298            definite_no_effect: true,
299        }
300    }
301
302    #[must_use]
303    pub fn ambiguous(detail: impl Into<String>) -> Self {
304        let detail = detail.into();
305        Self {
306            cause: BridgeDeliveryRejectionCause::TurnDirectiveUnsupported {
307                detail: detail.clone(),
308            },
309            detail,
310            definite_no_effect: false,
311        }
312    }
313
314    #[must_use]
315    pub fn outcome_journal_full(retained: usize, limit: usize) -> Self {
316        let retained = u32::try_from(retained).unwrap_or(u32::MAX);
317        let limit = u32::try_from(limit).unwrap_or(u32::MAX);
318        Self {
319            cause: BridgeDeliveryRejectionCause::OutcomeJournalFull { retained, limit },
320            detail: format!(
321                "directed-turn outcome journal is full ({retained}/{limit}); consume and acknowledge outcomes before submitting more work"
322            ),
323            definite_no_effect: true,
324        }
325    }
326}
327
328/// Machine-wide injected observation host (DEC-P6E-2). Implemented by the
329/// mob host daemon (`HostMemberObservation` in meerkat-mob) over the facade
330/// service's durable event log, the daemon session service, and the host
331/// binding authority's generation + journal facts. Absent host ⇒ the
332/// observation arms reply typed `Unavailable` (never `Unsupported`: the
333/// command IS served on this engine; the composition lacks the substrate).
334#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
335#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
336pub trait MemberObservationHost: Send + Sync {
337    /// Current member generation for a resident session
338    /// (`MobHostBindingAuthority` fact).
339    async fn member_generation(&self, session: &SessionId) -> Result<u64, MemberObservationError>;
340
341    /// Transcript page. `from_index: None` = tail-addressed when `limit`
342    /// is present, full-from-zero otherwise (DEC-P6E-6).
343    async fn read_history(
344        &self,
345        session: &SessionId,
346        from_index: Option<u64>,
347        limit: Option<u32>,
348    ) -> Result<MemberHistoryWindow, MemberObservationError>;
349
350    /// Bounded long-poll over the member's event log (DEC-P6E-4/5).
351    async fn poll_events(
352        &self,
353        session: &SessionId,
354        request: MemberEventsPollRequest<'_>,
355    ) -> Result<MemberEventsWindow, MemberObservationError>;
356
357    /// Open the pre-accept event window for a directed turn (DEC-P6E-16
358    /// step 2 — MUST run before `accept_input_with_completion`).
359    async fn open_directed_turn_window(
360        &self,
361        session: &SessionId,
362        expected_member: &BridgeMemberIncarnation,
363        input_id: &str,
364    ) -> Result<DirectedTurnWindow, DirectedTurnReject>;
365
366    /// Cancel Pending only when the caller has proven the runtime did not
367    /// accept the effect. Ambiguous admission errors must retain the row for
368    /// replay/recovery.
369    async fn cancel_directed_turn_window(
370        &self,
371        session: &SessionId,
372        window: DirectedTurnWindow,
373    ) -> Result<(), DirectedTurnReject>;
374
375    /// Serialize runtime acceptance against exact-key cancellation, then
376    /// re-probe durable host authority while the lock is held. A caller may
377    /// enter runtime acceptance only with the returned permit alive.
378    async fn lock_and_revalidate_directed_turn_admission(
379        &self,
380        session: &SessionId,
381        request: DirectedTurnAdmissionRequest,
382    ) -> Result<DirectedTurnAdmissionDecision, DirectedTurnReject> {
383        let _ = (session, request);
384        Err(DirectedTurnReject::ambiguous(
385            "member observation host has no exact-key admission lock".to_string(),
386        ))
387    }
388
389    /// Level-triggered exact-key cancellation. Implementations durably block
390    /// delayed delivery before certifying no-effect/cancellation and return a
391    /// terminal only after runtime quiescence.
392    async fn cancel_tracked_member_input(
393        &self,
394        session: &SessionId,
395        expected_member: &BridgeMemberIncarnation,
396        input_id: &str,
397    ) -> Result<BridgeTrackedInputCancelOutcome, MemberObservationError> {
398        let _ = (session, expected_member, input_id);
399        Err(MemberObservationError::Unavailable {
400            reason: "member observation host has no tracked-input cancellation authority"
401                .to_string(),
402        })
403    }
404
405    /// O2: adopt an accepted directive-bearing or explicitly interaction-
406    /// tracked delivery as a tracked turn.
407    /// Spawns the terminal watcher (classify → bind `terminal_seq` →
408    /// `RecordTurnOutcome` through the journal seam).
409    async fn admit_directed_turn(
410        &self,
411        session: &SessionId,
412        admission: DirectedTurnAdmission,
413    ) -> Result<(), DirectedTurnReject>;
414}
415
416/// Small read adapter over the facade service's durable event log
417/// (`PersistentSessionService::{event_log_read_from, event_log_latest_seq}`)
418/// so the daemon can hand the observation host durable reads without
419/// widening `MobSessionService` (DEC-P6E-2).
420#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
421#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
422pub trait DurableEventLogRead: Send + Sync {
423    /// Rows from `from_seq` onward as `(durable seq, envelope)`, or `None`
424    /// when the composition has no durable event projection (Memory-backend
425    /// realms — the ring substitutes, DEC-P6E-5).
426    async fn read_from(
427        &self,
428        session: &SessionId,
429        from_seq: u64,
430        max_rows: usize,
431    ) -> Result<Option<Vec<(u64, EventEnvelope<AgentEvent>)>>, MemberObservationError>;
432
433    /// Highest durable seq recorded for the session (`None` = no durable
434    /// projection composed).
435    async fn latest_seq(&self, session: &SessionId) -> Result<Option<u64>, MemberObservationError>;
436}
437
438/// One recorded tracked-turn terminal (the journal write carrier). The
439/// coarse machine kind is derived from `outcome` by the journal impl; the
440/// full wire outcome is retained verbatim as sidecar presentation material
441/// recorded once, at classification time, by the one shared classifier.
442#[derive(Debug, Clone)]
443pub struct TrackedTurnOutcomeRecord {
444    pub input_id: String,
445    pub generation: u64,
446    pub fence_token: u64,
447    /// Durable `StoredEvent.seq` of the turn's terminal event (gotcha 8 —
448    /// never a watermark approximation, never a `CompletionFeed` seq).
449    pub terminal_seq: u64,
450    pub outcome: WireFlowTurnOutcome,
451}
452
453/// Per-session tracked-turn journal seam (DEC-P6F-9's registration
454/// contract, cross-lane name). Implemented in meerkat-mob's host lane,
455/// wrapping the generated `MobHostBindingAuthority` record path plus the
456/// member's current generation + fence facts; registered by the host daemon at
457/// residency establishment (`register_tracked_turn_journal`). No
458/// registration ⇒ tracked deliveries reject
459/// `TurnDirectiveUnsupported` — the capability gate is structural.
460#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
461#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
462pub trait TrackedTurnJournal: Send + Sync {
463    /// Complete residency tuple validated before durable Pending/admission.
464    fn member_incarnation(
465        &self,
466    ) -> &meerkat_contracts::wire::supervisor_bridge::BridgeMemberIncarnation;
467
468    /// The member generation this residency was established at.
469    fn member_generation(&self) -> u64;
470
471    /// The materialization fence paired with [`Self::member_generation`].
472    fn member_fence_token(&self) -> u64;
473
474    /// Durably record one tracked turn's terminal outcome through the
475    /// generated `MobHostBindingAuthority` wrapper (witness discipline —
476    /// the shell never mutates journal maps directly). Idempotent:
477    /// redelivery converges on the machine's `RecordTurnOutcomeReplay`.
478    async fn record_turn_outcome(
479        &self,
480        record: TrackedTurnOutcomeRecord,
481    ) -> Result<(), MemberObservationError>;
482}