Skip to main content

frame_host/
truth.rs

1//! The application-truth snapshot (2026-07-21 boot-visibility fix).
2//!
3//! ## The defect this fixes
4//!
5//! `Application::boot()` queues every boot-time lifecycle transition and
6//! fact announcement in-process; `Application::serve()` used to start the
7//! announcer pump as its FIRST action — before the HTTP listener bound — so
8//! the entire backlog drained onto the bus before `/frame/config.json` (the
9//! only doctrine-sanctioned way a browser discovers the bus address, D3) was
10//! even reachable. A real browser can never win that race: fetch config,
11//! THEN subscribe is the only sequence a real client can perform, and by the
12//! time it completes the boot backlog is already gone (liminal 0.3.1 ships
13//! no replay reachable by a plain, non-participant subscribe — confirmed at
14//! the bytes: `subscribe_response`,
15//! `liminal-server-0.3.1/src/server/connection/apply.rs` ~L436, registers a
16//! fresh subscription going forward only, `durable` or not).
17//!
18//! ## The fix
19//!
20//! frame-host now maintains its own CURRENT TRUTH for this process,
21//! independent of the bus: every lifecycle transition and every announced
22//! fact, retained here and served as plain JSON at
23//! `/frame/app/status.json` (`crate::server::APP_STATUS_ROUTE`) on the SAME
24//! HTTP surface as `/frame/config.json`. A client joining at any moment gets
25//! everything so far from this endpoint, then follows the live announcer
26//! channel for what happens next — the snapshot-then-stream shape
27//! `crate::doc_binding`'s resync path already uses for document content,
28//! applied here to lifecycle + facts instead. `crate::application` also
29//! fixes the other half of the race: the HTTP+WS surface now binds BEFORE
30//! the announcer pump starts draining, so the live channel itself no longer
31//! loses the boot backlog to a startup race either.
32//!
33//! ## Recording is independent of `[frame].channel`
34//!
35//! This recorder runs on its OWN dedicated lifecycle subscription, taken
36//! before component install (same convention as the console logger in
37//! `crate::runtime` and the announcer in `crate::announcer`), so it never
38//! misses a boot transition — regardless of whether `[frame].channel` is
39//! even declared. Facts are recorded by `crate::announcer::Announcer` the
40//! moment it ACCEPTS one (`Announcer::attach_truth` + the hook inside
41//! `announce_fact`), before its pump goes live, so a snapshot taken between
42//! boot and serve already carries the boot fact.
43//!
44//! ## No cap on the retained history
45//!
46//! The 2026-07-21 ruling that ordered this fix is explicit: "the
47//! process-lifetime fact list is what it is" — no invented bound. This
48//! module honours that: [`AppTruth`] retains every transition and fact for
49//! the life of the process. The ONLY bound anywhere in this module is
50//! the private `TRUTH_EVENT_BUFFER` const, which caps the RELAY queue between one publish
51//! and this thread's next drain (the same bounded-with-loud-overflow
52//! posture the console logger and announcer already use) — never the
53//! retained snapshot itself.
54
55use std::collections::{HashMap, HashSet};
56use std::num::NonZeroUsize;
57use std::sync::{Arc, Mutex};
58use std::thread::{self, JoinHandle};
59use std::time::{Duration, SystemTime, UNIX_EPOCH};
60
61use frame_core::component::ComponentId;
62use frame_core::event::{
63    EventReceiveError, LifecycleEventKind, LifecycleState, LifecycleSubscription,
64};
65use frame_core::registry::ComponentRegistry;
66use serde::Serialize;
67use serde_json::Value;
68
69use crate::error::HostError;
70
71/// Bounded relay buffer between the registry's lifecycle stream and this
72/// recorder — the SAME bound and overflow posture as the console logger
73/// (`crate::runtime::LIFECYCLE_EVENT_BUFFER`) and the announcer
74/// (`crate::announcer::ANNOUNCER_EVENT_BUFFER`). This caps only how many
75/// events may be in flight between a publish and this thread's next drain;
76/// it is NOT a cap on the retained snapshot (see the module docs).
77const TRUTH_EVENT_BUFFER: usize = 256;
78
79/// Wait quantum for the recorder's blocking receive — the same posture and
80/// value as the console logger and announcer pump: not a polling interval,
81/// only a bound on how long one idle block lasts before checking for a
82/// closed stream.
83const TRUTH_WAIT_QUANTUM: Duration = Duration::from_secs(3600);
84
85/// One recorded lifecycle transition, timestamped at the moment this
86/// recorder observed it.
87#[derive(Clone, Debug, Serialize)]
88#[serde(rename_all = "camelCase")]
89pub struct RecordedTransition {
90    /// Registry-wide monotonic sequence number, matching the announcer's own
91    /// published `sequence` field.
92    pub sequence: u64,
93    /// The component this transition belongs to.
94    pub component_id: String,
95    /// State before this transition; absent for initial registration.
96    pub from: Option<LifecycleState>,
97    /// State after this transition.
98    pub to: LifecycleState,
99    /// Host wall-clock time this recorder observed the transition,
100    /// milliseconds since the Unix epoch.
101    pub at_epoch_ms: u128,
102}
103
104/// One recorded application fact, timestamped at the moment the announcer
105/// ACCEPTED it (`Announcer::announce_fact`) — before its pump goes live, so
106/// a snapshot taken between boot and serve already carries a boot fact.
107#[derive(Clone, Debug, Serialize)]
108#[serde(rename_all = "camelCase")]
109pub struct RecordedFact {
110    /// Host wall-clock time the fact was accepted, milliseconds since the
111    /// Unix epoch.
112    pub at_epoch_ms: u128,
113    /// The fact body, exactly as announced (unwrapped — the `{"kind":"fact",
114    /// "body": ...}` wire envelope is an announcer-publish concern, not a
115    /// truth-recording one).
116    pub body: Value,
117}
118
119/// The JSON shape served at [`crate::server::APP_STATUS_ROUTE`]: CURRENT
120/// TRUTH at request time.
121#[derive(Clone, Debug, Serialize)]
122#[serde(rename_all = "camelCase")]
123pub struct AppTruthSnapshot {
124    /// Host wall-clock time this snapshot was assembled, milliseconds since
125    /// the Unix epoch.
126    pub generated_at_epoch_ms: u128,
127    /// Every installed component's LATEST observed lifecycle state, keyed by
128    /// component id — a convenience fold over `transitions` below.
129    pub current: HashMap<String, LifecycleState>,
130    /// Every lifecycle transition observed so far, in order.
131    pub transitions: Vec<RecordedTransition>,
132    /// Every application fact announced so far, in order.
133    pub facts: Vec<RecordedFact>,
134}
135
136#[derive(Debug, Default)]
137struct TruthState {
138    transitions: Vec<RecordedTransition>,
139    current: HashMap<ComponentId, LifecycleState>,
140    facts: Vec<RecordedFact>,
141}
142
143/// The process's current-truth recorder: every lifecycle transition and
144/// announced fact since boot, retained for the life of the process (no cap
145/// — see the module docs for the bound this deliberately is NOT).
146#[derive(Debug)]
147pub struct AppTruth {
148    state: Mutex<TruthState>,
149}
150
151impl AppTruth {
152    /// An empty truth with no attached recorder thread. Used internally by
153    /// [`Self::spawn`] and, publicly, by fixtures/tests that construct a
154    /// [`crate::server::ShellConfig`]/[`crate::server::ShellServer`] without
155    /// a live component registry to record from (pure HTTP-serving tests).
156    #[must_use]
157    pub fn empty() -> Arc<Self> {
158        Arc::new(Self {
159            state: Mutex::new(TruthState::default()),
160        })
161    }
162
163    /// Spawns the dedicated recorder thread on its OWN lifecycle
164    /// subscription, taken here — BEFORE component install, the same
165    /// convention the console logger and the announcer both follow — so the
166    /// boot transitions (Registered → Starting → Running) are never missed.
167    /// Recording runs independent of whether `[frame].channel`/the announcer
168    /// exists: this is host-local truth served over plain HTTP, not a bus
169    /// publish.
170    ///
171    /// The recorder thread's exit condition mirrors the console logger's
172    /// exactly: it exits once it has observed the Removed transition of
173    /// every component in `expected` (published by ordered shutdown), or on
174    /// stream closure.
175    ///
176    /// # Errors
177    ///
178    /// Returns a typed failure for a poisoned subscribe or a thread spawn
179    /// refusal.
180    pub fn spawn(
181        registry: &ComponentRegistry,
182        expected: HashSet<ComponentId>,
183    ) -> Result<(Arc<Self>, JoinHandle<()>), HostError> {
184        let capacity =
185            NonZeroUsize::new(TRUTH_EVENT_BUFFER).ok_or(HostError::SynchronizationPoisoned)?;
186        let subscription = registry.subscribe(capacity)?;
187        let truth = Self::empty();
188        let recorder = Arc::clone(&truth);
189        let join = thread::Builder::new()
190            .name("frame-host-app-truth".to_owned())
191            .spawn(move || record_stream(&recorder, &subscription, &expected))
192            .map_err(|source| HostError::TruthRecorderSpawn { source })?;
193        Ok((truth, join))
194    }
195
196    /// Records one accepted application fact. Called by
197    /// [`crate::announcer::Announcer::announce_fact`] the moment a fact is
198    /// accepted — before the pump goes live.
199    pub fn record_fact(&self, body: Value) {
200        let at_epoch_ms = epoch_ms();
201        match self.state.lock() {
202            Ok(mut state) => state.facts.push(RecordedFact { at_epoch_ms, body }),
203            Err(_) => {
204                tracing::error!(
205                    "app-truth synchronization poisoned while recording an announced fact; the \
206                     fact is real (already accepted by the announcer, and still published on the \
207                     bus) but will not appear in the /frame/app/status.json snapshot"
208                );
209            }
210        }
211    }
212
213    /// Assembles the current snapshot: every transition and fact recorded so
214    /// far, in order, plus each component's latest observed state.
215    #[must_use]
216    pub fn snapshot(&self) -> AppTruthSnapshot {
217        let generated_at_epoch_ms = epoch_ms();
218        let Ok(state) = self.state.lock() else {
219            tracing::error!(
220                "app-truth synchronization poisoned while assembling a snapshot; serving an \
221                 empty snapshot rather than a fabricated one"
222            );
223            return AppTruthSnapshot {
224                generated_at_epoch_ms,
225                current: HashMap::new(),
226                transitions: Vec::new(),
227                facts: Vec::new(),
228            };
229        };
230        AppTruthSnapshot {
231            generated_at_epoch_ms,
232            current: state
233                .current
234                .iter()
235                .map(|(id, lifecycle_state)| (id.to_string(), *lifecycle_state))
236                .collect(),
237            transitions: state.transitions.clone(),
238            facts: state.facts.clone(),
239        }
240    }
241
242    fn record_transition(
243        &self,
244        sequence: u64,
245        component_id: ComponentId,
246        from: Option<LifecycleState>,
247        to: LifecycleState,
248    ) {
249        let at_epoch_ms = epoch_ms();
250        match self.state.lock() {
251            Ok(mut state) => {
252                state.transitions.push(RecordedTransition {
253                    sequence,
254                    component_id: component_id.to_string(),
255                    from,
256                    to,
257                    at_epoch_ms,
258                });
259                state.current.insert(component_id, to);
260            }
261            Err(_) => {
262                tracing::error!(
263                    component = %component_id,
264                    ?to,
265                    "app-truth synchronization poisoned while recording a lifecycle transition; \
266                     it is real (already published on the registry's own stream) but will not \
267                     appear in the /frame/app/status.json snapshot"
268                );
269            }
270        }
271    }
272}
273
274/// Drains the lifecycle stream into `truth` until every expected
275/// component's Removed transition (or stream closure) — the exact posture
276/// of `crate::runtime::log_lifecycle_stream`, duplicated here rather than
277/// shared because this recorder writes into `AppTruth` instead of
278/// `tracing`, and because a third subscriber independent of the console
279/// logger and the announcer is what proves this fix (a single shared
280/// subscription would reintroduce exactly the kind of hidden coupling the
281/// underlying defect came from).
282fn record_stream(
283    truth: &AppTruth,
284    subscription: &LifecycleSubscription,
285    expected: &HashSet<ComponentId>,
286) {
287    let mut reported_lag = 0;
288    let mut removed: HashSet<ComponentId> = HashSet::new();
289    loop {
290        let event = match subscription.recv_timeout(TRUTH_WAIT_QUANTUM) {
291            Ok(event) => event,
292            Err(EventReceiveError::Timeout) => continue,
293            Err(EventReceiveError::Closed) => {
294                tracing::info!("lifecycle event stream closed; app-truth recorder exiting");
295                return;
296            }
297            Err(EventReceiveError::Poisoned) => {
298                tracing::error!(
299                    "lifecycle event stream synchronization poisoned; app-truth recorder exiting"
300                );
301                return;
302            }
303        };
304        let lagged = subscription.lagged_events();
305        if lagged > reported_lag {
306            tracing::warn!(
307                dropped = lagged - reported_lag,
308                total_dropped = lagged,
309                "app-truth recorder overflowed its bounded relay buffer; oldest events were \
310                 dropped (the retained snapshot itself has no cap — only this in-flight relay \
311                 does)"
312            );
313            reported_lag = lagged;
314        }
315        if let LifecycleEventKind::Transition { from, to } = event.kind {
316            truth.record_transition(event.sequence, event.component_id, from, to);
317            if to == LifecycleState::Removed {
318                removed.insert(event.component_id);
319                if removed.is_superset(expected) {
320                    tracing::info!(
321                        "every application component removed; app-truth recorder exiting"
322                    );
323                    return;
324                }
325            }
326        }
327        // Capability denials are not part of this snapshot's documented
328        // scope (lifecycle transitions + announced facts, per the
329        // 2026-07-21 ruling); the announcer still publishes them live on
330        // the bus exactly as before. Flagged for the coordinator as a
331        // deliberate scope choice, not an oversight.
332    }
333}
334
335/// Host wall-clock time, milliseconds since the Unix epoch. A clock error
336/// before the epoch (never expected on a real host) yields `0`, logged
337/// loudly rather than fabricated silently — never `.unwrap()`/`.expect()`.
338fn epoch_ms() -> u128 {
339    let Ok(duration) = SystemTime::now().duration_since(UNIX_EPOCH) else {
340        tracing::error!(
341            "host wall clock reports a time before the Unix epoch; recording 0 rather than \
342             fabricating a timestamp"
343        );
344        return 0;
345    };
346    duration.as_millis()
347}
348
349#[cfg(test)]
350mod tests {
351    use super::{AppTruth, epoch_ms};
352    use frame_core::component::ComponentId;
353    use frame_core::event::LifecycleState;
354
355    fn id() -> ComponentId {
356        ComponentId::derive("frame.host", "app-truth-test")
357    }
358
359    #[test]
360    fn epoch_ms_is_monotonic_enough_to_order_two_calls() {
361        let first = epoch_ms();
362        std::thread::sleep(std::time::Duration::from_millis(2));
363        let second = epoch_ms();
364        assert!(
365            second >= first,
366            "wall-clock reads must not run backwards in a tight loop"
367        );
368    }
369
370    #[test]
371    fn empty_snapshot_has_no_transitions_or_facts() {
372        // Constructed directly (not via `spawn`) to unit-test the pure
373        // recording/snapshot logic without a live registry.
374        let truth = AppTruth::empty();
375        let snapshot = truth.snapshot();
376        assert!(snapshot.transitions.is_empty());
377        assert!(snapshot.facts.is_empty());
378        assert!(snapshot.current.is_empty());
379    }
380
381    #[test]
382    fn recorded_transition_updates_current_and_appends_history() {
383        let truth = AppTruth::empty();
384        truth.record_transition(0, id(), None, LifecycleState::Registered);
385        truth.record_transition(
386            1,
387            id(),
388            Some(LifecycleState::Registered),
389            LifecycleState::Starting,
390        );
391        let snapshot = truth.snapshot();
392        assert_eq!(snapshot.transitions.len(), 2);
393        assert_eq!(snapshot.transitions[0].sequence, 0);
394        assert!(snapshot.transitions[0].from.is_none());
395        assert_eq!(snapshot.transitions[1].sequence, 1);
396        assert_eq!(
397            snapshot.current.get(&id().to_string()),
398            Some(&LifecycleState::Starting),
399            "current must fold to the LATEST transition, not the first"
400        );
401    }
402
403    #[test]
404    fn recorded_fact_is_retained_verbatim() {
405        let truth = AppTruth::empty();
406        truth.record_fact(serde_json::json!({ "entity": "e-1" }));
407        let snapshot = truth.snapshot();
408        assert_eq!(snapshot.facts.len(), 1);
409        assert_eq!(snapshot.facts[0].body["entity"], "e-1");
410    }
411}