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}