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}