Skip to main content

frame_host/
config.rs

1//! The single `frame.toml` that describes the whole stack.
2//!
3//! One config file, two sections:
4//!
5//! - `[frame]` — the console HTTP server: `bind`, `assets`, `auth_token`
6//!   (surfaced to the page verbatim; empty = open server), optional `channel`.
7//! - `[bus]` — the embedded messaging-bus component (liminal). This section
8//!   deserializes into liminal's own
9//!   [`liminal_server::config::ServerConfig`], so the bus validates its own
10//!   section (required fields, `deny_unknown_fields`, channel-schema loading).
11//!
12//! **Defaults policy (BINDING ruling, 2026-07-21):** a required key that has
13//! only one value it could possibly hold is FORBIDDEN from being required —
14//! it defaults to that value, PROVEN from the deployed demos or the scaffold
15//! template, never invented. Genuinely deployment-specific values (bind
16//! addresses, file paths, channel names, document ids, auth tokens) stay
17//! required with the existing loud errors. Because `ServerConfig` and its
18//! nested `WebSocketConfig` are liminal's OWN external types (this crate
19//! depends on the published `liminal-server` crate and cannot add
20//! `#[serde(default)]` attributes to them), the handful of bus-section
21//! defaults this ruling adds (`drain_timeout_ms`, `[bus.websocket].path`,
22//! `[bus.websocket].allowed_origins`) are injected into the raw TOML table by
23//! this module's private `apply_bus_defaults` helper BEFORE the table is
24//! handed to liminal's own `ServerConfig` deserializer — a declared value is
25//! always left untouched and reaches liminal's own validation exactly as
26//! before. See `apply_bus_defaults` for the per-key sourcing.
27//!
28//! **Compatibility window:** the pre-rename section name `[liminal]` (with
29//! `[liminal.websocket]`) still parses as an alias of `[bus]`, but every
30//! acceptance logs a loud `tracing::warn!` deprecation naming the new section.
31//! Declaring both sections is refused. The alias is removed after one window.
32//!
33//! Because the embedded bus's WebSocket address is derived from THIS one
34//! configured section (see [`crate::embedded`]), the page's `busEndpoint`
35//! and the server's actual bound listener can never drift — there is exactly
36//! one source of truth.
37
38use std::collections::BTreeMap;
39use std::net::SocketAddr;
40use std::path::{Path, PathBuf};
41
42use liminal_server::config::{ServerConfig, ServiceProfile, apply_env_overrides, validate};
43use serde::Deserialize;
44
45use crate::error::HostError;
46
47/// The raw `frame.toml` shape: the console section plus the embedded bus
48/// section under its primary name (`[bus]`) or its deprecated alias
49/// (`[liminal]`). Exactly one of the two must be present; [`FrameConfig::load`]
50/// enforces that and logs the deprecation on the alias.
51///
52/// The bus sections are captured as raw [`toml::Value`] tables rather than
53/// `ServerConfig` directly: [`FrameConfig::load`] injects frame-host's own
54/// proven defaults into the table (see [`apply_bus_defaults`]) BEFORE handing
55/// it to liminal's own `ServerConfig` deserializer, since `ServerConfig` is
56/// an external type this crate cannot annotate.
57#[derive(Debug, Deserialize)]
58#[serde(deny_unknown_fields)]
59struct RawFrameConfig {
60    /// Console HTTP server configuration.
61    frame: FrameSection,
62    /// Optional document-service binding (IRIDIUM-A3 R11).
63    document: Option<DocumentSection>,
64    /// Embedded bus component configuration — the primary section name.
65    bus: Option<toml::Value>,
66    /// DEPRECATED alias of `[bus]`, kept for one compatibility window.
67    liminal: Option<toml::Value>,
68}
69
70/// The parsed `frame.toml`: the console section plus the embedded bus
71/// section (liminal's own config type).
72#[derive(Debug)]
73pub struct FrameConfig {
74    /// Console HTTP server configuration.
75    pub frame: FrameSection,
76    /// The optional `[document]` binding: when present, the host boots the
77    /// document service (IRIDIUM-A3 R11) against the embedded bus.
78    pub document: Option<DocumentSection>,
79    /// Embedded bus component configuration — liminal's own config type,
80    /// validated by liminal's own validator.
81    pub bus: ServerConfig,
82    /// Whether the embedded bus's `[bus.websocket].allowed_origins` list is
83    /// DERIVED (the operator did not state it) rather than stated verbatim.
84    ///
85    /// When `true`, [`Self::finalize_page_origins`] fills the list from the
86    /// page server's REAL bound address once it is known
87    /// ([`crate::page::PageServer::resolve`]) — both the `127.0.0.1` and the
88    /// `localhost` spelling — so the served page's own origin is always
89    /// allow-listed. When `false` the operator's explicit list is LAW and is
90    /// never touched (2026-07-22 portless ruling: stated config remains
91    /// authoritative, no silent fallback).
92    pub page_origins_derived: bool,
93    /// Whether the whole port topology is stated verbatim by the operator:
94    /// `[frame].bind` is present AND `[bus.websocket].allowed_origins` is
95    /// stated. Only a fully-stated topology has any port coherence to check
96    /// (`frame check`); a derived/portless one is coherent by construction.
97    pub ports_explicit: bool,
98}
99
100/// The `[frame]` section: everything the console HTTP server needs.
101#[derive(Debug, Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct FrameSection {
104    /// Socket address the console HTTP server binds, e.g. `127.0.0.1:4173`.
105    ///
106    /// Optional (2026-07-22 portless ruling). When present it is LAW: the page
107    /// server binds exactly this address and a taken port is a loud failure
108    /// (never a silent move). When absent the host prefers `127.0.0.1:6010` and
109    /// walks forward to the next free port, printing the chosen URL loudly on
110    /// boot — see [`crate::page::PageServer::resolve`]. Either way the page's
111    /// REAL bound address is what flows into the derived
112    /// `[bus.websocket].allowed_origins` and the boot log line, so the two can
113    /// never drift.
114    #[serde(default)]
115    pub bind: Option<SocketAddr>,
116    /// Directory containing the static console bundle (`index.html` at root).
117    pub assets: PathBuf,
118    /// Bearer token surfaced to the page verbatim as `authToken`. Required key
119    /// — the operator states it, the binary never invents it. An explicitly
120    /// empty value (`auth_token = ""`) is legal and means an open server,
121    /// matching the console's config contract.
122    pub auth_token: String,
123    /// Feed channel surfaced to the page as `channel`. Absent → the field is
124    /// omitted and the page applies the SDK's default channel. When present it
125    /// must be non-empty (the console refuses an empty channel).
126    #[serde(default)]
127    pub channel: Option<String>,
128}
129
130/// The `[document]` section (IRIDIUM-A3 R11): the document binding.
131///
132/// Deployment-identity fields (`id`, `language`, `content_path`,
133/// `component_id`, `feed_channel`, `authoring_channel`, `state_dir`) stay
134/// fully required — A1 constraint 9's no-assumed-defaults law still governs
135/// them, since each one is a genuinely deployment-specific decision with no
136/// single "the" value. The five pure tuning/presentation knobs below
137/// (`lease_expiry_ms`, `journal_length_bound`, `quiesce_window_ms`,
138/// `dark_theme`, `blink_interval_ms`) are amended by the 2026-07-21 BINDING
139/// ruling (the same amendment that gave `syntax_theme` its built-in default):
140/// every known deployment types the identical value, so requiring the
141/// operator to retype it is exactly the "stupid required key" the ruling
142/// forbids. `frame_authority::AuthorityConfig::declare` (constraint 9's
143/// actual enforcement point) still receives an explicit, concrete,
144/// non-invented value either way — the default only decides where that
145/// value comes from when the key is absent.
146#[derive(Debug, Deserialize)]
147#[serde(deny_unknown_fields)]
148pub struct DocumentSection {
149    /// The bound document id (`[a-z0-9-]+`).
150    pub id: String,
151    /// The document language.
152    pub language: String,
153    /// The initial content source, resolved relative to the config file.
154    pub content_path: PathBuf,
155    /// The component instance id on every feed envelope (`[a-z0-9-]+`).
156    pub component_id: String,
157    /// The feed channel: the service publishes; every page subscribes.
158    pub feed_channel: String,
159    /// The authoring channel: pages publish; the service subscribes.
160    pub authoring_channel: String,
161    /// The frame-state store directory, resolved relative to the config
162    /// file.
163    pub state_dir: PathBuf,
164    /// Lease expiry (§5.5 policy), milliseconds. Optional — absent defaults
165    /// to [`DEFAULT_LEASE_EXPIRY_MS`], the value every known
166    /// `[document]`-enabled deployment declares
167    /// (`examples/code-view-console/frame.toml`, matching the live deployed
168    /// demo `~/.frame-demo/code-view/frame.toml`). A present-but-zero value
169    /// still refuses loudly (`FrameConfig::check_document_contract`).
170    #[serde(default = "default_lease_expiry_ms")]
171    pub lease_expiry_ms: u64,
172    /// The journal-length snapshot bound (T3(i) signal 3). Optional —
173    /// absent defaults to [`DEFAULT_JOURNAL_LENGTH_BOUND`], sourced the same
174    /// way as [`Self::lease_expiry_ms`].
175    #[serde(default = "default_journal_length_bound")]
176    pub journal_length_bound: u64,
177    /// The quiesce debounce window (T3(i) signal 4), milliseconds. Optional
178    /// — absent defaults to [`DEFAULT_QUIESCE_WINDOW_MS`], sourced the same
179    /// way as [`Self::lease_expiry_ms`].
180    #[serde(default = "default_quiesce_window_ms")]
181    pub quiesce_window_ms: u64,
182    /// The publisher-seat dark-mode presentation input (C5). Optional —
183    /// absent defaults to [`DEFAULT_DARK_THEME`], sourced the same way as
184    /// [`Self::lease_expiry_ms`].
185    #[serde(default = "default_dark_theme")]
186    pub dark_theme: bool,
187    /// The component's cursor-blink interval (T4), milliseconds — a
188    /// component policy value carried by frame.toml per constraint 12 and
189    /// advertised to the page. Optional — absent defaults to
190    /// [`DEFAULT_BLINK_INTERVAL_MS`], sourced the same way as
191    /// [`Self::lease_expiry_ms`].
192    #[serde(default = "default_blink_interval_ms")]
193    pub blink_interval_ms: u64,
194    /// The publisher-seat syntax-theme presentation input (C5, amended
195    /// 2026-07-21): capture-name → `#rrggbb` hex colour, TOML table.
196    /// Optional — when absent, [`crate::doc_binding::DocBinding::boot`]
197    /// falls back to the built-in default palette
198    /// ([`crate::syntax_theme::default_syntax_theme`], copied from the
199    /// proven `SYNTAX_THEME` in `examples/code-view-console/src/fake-feed.ts`).
200    /// When present, this table REPLACES the built-in default WHOLESALE —
201    /// there is no per-key merging with the default (merging would invent
202    /// behaviour nobody asked for). Every declared value must be `#rrggbb`
203    /// hex; [`FrameConfig::load`] fails loudly on a malformed or
204    /// non-string entry rather than dropping it silently.
205    #[serde(default)]
206    pub syntax_theme: Option<BTreeMap<String, String>>,
207}
208
209/// Proven `[document].lease_expiry_ms` default (milliseconds), source:
210/// `examples/code-view-console/frame.toml` L36, matching the live deployed
211/// demo `~/.frame-demo/code-view/frame.toml`.
212pub const DEFAULT_LEASE_EXPIRY_MS: u64 = 30_000;
213/// Proven `[document].journal_length_bound` default, source:
214/// `examples/code-view-console/frame.toml` L37, matching the live deployed
215/// demo.
216pub const DEFAULT_JOURNAL_LENGTH_BOUND: u64 = 256;
217/// Proven `[document].quiesce_window_ms` default (milliseconds), source:
218/// `examples/code-view-console/frame.toml` L38, matching the live deployed
219/// demo.
220pub const DEFAULT_QUIESCE_WINDOW_MS: u64 = 2_000;
221/// Proven `[document].dark_theme` default, source:
222/// `examples/code-view-console/frame.toml` L39, matching the live deployed
223/// demo.
224pub const DEFAULT_DARK_THEME: bool = true;
225/// Proven `[document].blink_interval_ms` default (milliseconds), source:
226/// `examples/code-view-console/frame.toml` L40, matching the live deployed
227/// demo.
228pub const DEFAULT_BLINK_INTERVAL_MS: u64 = 530;
229
230const fn default_lease_expiry_ms() -> u64 {
231    DEFAULT_LEASE_EXPIRY_MS
232}
233const fn default_journal_length_bound() -> u64 {
234    DEFAULT_JOURNAL_LENGTH_BOUND
235}
236const fn default_quiesce_window_ms() -> u64 {
237    DEFAULT_QUIESCE_WINDOW_MS
238}
239const fn default_dark_theme() -> bool {
240    DEFAULT_DARK_THEME
241}
242const fn default_blink_interval_ms() -> u64 {
243    DEFAULT_BLINK_INTERVAL_MS
244}
245
246/// The closed hex-colour grammar the wire enforces (`#rrggbb`, matching
247/// `frame_editor_wire::snapshot`'s `validate_syntax_theme` and the
248/// console's `hexColor` codec check), checked at config load so a doomed
249/// theme value fails here, not at first encode.
250fn is_hex_color(value: &str) -> bool {
251    value.len() == 7
252        && value.starts_with('#')
253        && value[1..].bytes().all(|byte| byte.is_ascii_hexdigit())
254}
255
256/// The closed ASCII id grammar the wire enforces (`[a-z0-9-]+`), checked
257/// at config load so a doomed id fails here, not at first encode.
258fn is_wire_id(value: &str) -> bool {
259    !value.is_empty()
260        && value
261            .bytes()
262            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
263}
264
265/// Proven `[bus].drain_timeout_ms` default (milliseconds). Source: every
266/// known deployment types the identical value —
267/// `examples/code-view-console/frame.toml` L38 (`drain_timeout_ms = 1000`,
268/// matching the live deployed demo `~/.frame-demo/code-view/frame.toml`),
269/// `crates/frame-cli/templates/frame.toml` L19 (the scaffold every new app
270/// starts from), and `docs/guides/BUILDING-APPS.md`'s worked example.
271pub const DEFAULT_BUS_DRAIN_TIMEOUT_MS: i64 = 1_000;
272
273/// Proven `[bus.websocket].path` default. Source: every known deployment
274/// types the identical value — `examples/code-view-console/frame.toml`
275/// (matching the live deployed demo), `crates/frame-cli/templates/frame.toml`
276/// L27 (the scaffold every new app starts from), and
277/// `docs/guides/BUILDING-APPS.md`'s worked example. `WebSocketConfig::path`
278/// (liminal-server's own type) still requires the value to start with `/`;
279/// this default satisfies that grammar.
280pub const DEFAULT_WEBSOCKET_PATH: &str = "/liminal";
281
282/// The OS-assigned bind sentinel for an internal endpoint that carries no
283/// stated address (2026-07-22 portless ruling): binding `127.0.0.1:0` lets
284/// the kernel pick a free port, and [`crate::embedded::EmbeddedLiminal`]
285/// captures the REAL bound address via each listener's `local_addr`, which is
286/// what `/frame/config.json` advertises to the page. A synthesized portless
287/// `[bus]` uses this for its wire, health, and WebSocket listeners.
288const OS_ASSIGNED: &str = "127.0.0.1:0";
289
290/// Injects frame-host's own proven bus-section defaults into the raw
291/// `[bus]` (or `[liminal]` alias) TOML table BEFORE it is handed to
292/// liminal's own `ServerConfig` deserializer.
293///
294/// `ServerConfig` and its nested `WebSocketConfig` are liminal's OWN
295/// external types (this crate depends on the published `liminal-server`
296/// crate and cannot add `#[serde(default)]` attributes to them), so this is
297/// the frame-host-owned seam where the 2026-07-21 BINDING defaults ruling
298/// is enforced for the bus section: a key the operator DID declare is left
299/// byte-identical and reaches liminal's own validation exactly as before; a
300/// key the operator omitted gets the proven default documented at its call
301/// site.
302///
303/// Defaulted:
304/// - `drain_timeout_ms` → [`DEFAULT_BUS_DRAIN_TIMEOUT_MS`] (milliseconds).
305/// - `websocket.path` → [`DEFAULT_WEBSOCKET_PATH`], but ONLY when a
306///   `[bus.websocket]` table is already present — the section itself stays
307///   required (embedded frame mode has nowhere for the browser to connect
308///   without it; `FrameConfig::check_embedded_mode` still refuses an absent
309///   section).
310///
311/// `allowed_origins` is deliberately NOT defaulted here (2026-07-22 portless
312/// ruling): the served page's origin is the page server's REAL bound address,
313/// which is not known until [`crate::page::PageServer::resolve`] has walked to
314/// a free port. When the operator did not state the list,
315/// [`FrameConfig::load`] records [`FrameConfig::page_origins_derived`] and
316/// [`FrameConfig::finalize_page_origins`] fills it from that real address at
317/// boot — both the `127.0.0.1` and `localhost` spellings — before the bus
318/// binds. A stated list is LAW and never touched.
319///
320/// # Errors
321///
322/// Returns [`HostError::ConfigParse`] if `bus` (or a present `websocket`
323/// key inside it) is not a TOML table — a genuinely malformed shape that
324/// would fail liminal's own deserializer anyway; caught here with a
325/// frame-host-flavoured message instead of a generic serde one.
326fn apply_bus_defaults(mut bus: toml::Value, path: &Path) -> Result<toml::Value, HostError> {
327    let table = bus.as_table_mut().ok_or_else(|| HostError::ConfigParse {
328        path: path.to_path_buf(),
329        detail: "[bus] (or [liminal]) must be a TOML table".to_owned(),
330    })?;
331
332    table
333        .entry("drain_timeout_ms")
334        .or_insert_with(|| toml::Value::Integer(DEFAULT_BUS_DRAIN_TIMEOUT_MS));
335
336    if let Some(websocket_value) = table.get_mut("websocket") {
337        let websocket = websocket_value
338            .as_table_mut()
339            .ok_or_else(|| HostError::ConfigParse {
340                path: path.to_path_buf(),
341                detail: "[bus.websocket] (or [liminal.websocket]) must be a TOML table".to_owned(),
342            })?;
343        websocket
344            .entry("path")
345            .or_insert_with(|| toml::Value::String(DEFAULT_WEBSOCKET_PATH.to_owned()));
346    }
347
348    // The participant (conversation) transport, enabled by default
349    // (F-7b): the embedded bus carried only channel flows until the dev
350    // management conversation became its first in-process conversation
351    // consumer — without a [bus.participant] section liminal never
352    // negotiates the participant-v1 capability and every enrollment is
353    // refused (`ParticipantCapabilityRequired`). The defaults below are
354    // liminal's OWN production-test participant configuration (published
355    // `test_participant_config`, the values the F-0c spike recorded and
356    // every frame conversation suite runs against) — a section the
357    // operator declares is LAW and left untouched.
358    table
359        .entry("participant")
360        .or_insert_with(default_participant_table);
361
362    Ok(bus)
363}
364
365/// Liminal's own proven participant-transport configuration, as the raw
366/// TOML table [`apply_bus_defaults`] injects. Every field is stated —
367/// liminal's `ParticipantConfig` carries no serde defaults (its own
368/// no-assumed-defaults rule), so an injected section must be complete.
369fn default_participant_table() -> toml::Value {
370    let mut participant = toml::value::Table::new();
371    for (key, value) in [
372        ("wire_frame_limit", 65_536),
373        ("attach_receipt_ttl_ms", 60_000),
374        ("receipt_provenance_ttl_ms", 600_000),
375        ("max_live_attach_receipts_server", 1_024),
376        ("max_live_attach_receipts_per_participant", 8),
377        ("max_receipt_provenance_server", 4_096),
378        ("max_receipt_provenance_per_conversation", 256),
379        ("max_receipt_provenance_per_participant", 64),
380        ("max_retired_identity_slots_server", 1_024),
381        ("identity_slots", 4),
382        ("observer_recovery_max_entries", 64),
383        ("max_semantic_conversations_per_connection", 32),
384        ("max_ordinary_record_entries", 1),
385        ("max_ordinary_record_bytes", 131_072),
386        ("max_generated_marker_entries", 1),
387        ("max_generated_marker_bytes", 4_096),
388        ("mandatory_transaction_bound_entries", 4),
389        ("mandatory_transaction_bound_bytes", 16_384),
390        ("full_recovery_claim_entries", 4),
391        ("full_recovery_claim_bytes", 16_384),
392        ("retained_capacity_entries", 2_048),
393        ("retained_capacity_bytes", 16_777_216),
394        ("max_retained_record_rows", 1_024),
395        ("closure_episode_churn_limit", 1_024),
396    ] {
397        participant.insert(key.to_owned(), toml::Value::Integer(value));
398    }
399    toml::Value::Table(participant)
400}
401
402/// Synthesizes the raw `[bus]` TOML table for a PORTLESS scaffold — a
403/// `frame.toml` that states no `[bus]` section at all (2026-07-22 portless
404/// ruling).
405///
406/// The host owns the whole shape: the wire, health, and WebSocket listeners
407/// bind OS-assigned free ports ([`OS_ASSIGNED`]); the single channel is the
408/// application's own `[frame].channel` (the one place a portless scaffold
409/// names its channel); routing is empty; the WebSocket upgrade path is the
410/// proven [`DEFAULT_WEBSOCKET_PATH`]. `allowed_origins` is intentionally
411/// omitted here and derived from the real page address at boot (see
412/// [`apply_bus_defaults`]). Every field frame-host authors is correct by
413/// construction, so [`FrameConfig::load`] skips liminal's own port-non-zero
414/// validation for this synthesized shape — the `:0` sentinels are intentional
415/// and resolved by the OS at bind, exactly the state liminal's validator was
416/// written to forbid for an operator-authored file.
417///
418/// The channel name is the single deployment-specific value; it comes from
419/// the required `[frame].channel`. A portless scaffold with no `[frame].channel`
420/// has nothing to name its bus channel and is refused loudly by the caller.
421fn synthesize_portless_bus(channel: &str) -> toml::Value {
422    let mut channel_entry = toml::value::Table::new();
423    channel_entry.insert("name".to_owned(), toml::Value::String(channel.to_owned()));
424    channel_entry.insert("durable".to_owned(), toml::Value::Boolean(false));
425
426    let mut websocket = toml::value::Table::new();
427    websocket.insert(
428        "listen_address".to_owned(),
429        toml::Value::String(OS_ASSIGNED.to_owned()),
430    );
431
432    let mut table = toml::value::Table::new();
433    table.insert(
434        "listen_address".to_owned(),
435        toml::Value::String(OS_ASSIGNED.to_owned()),
436    );
437    table.insert(
438        "health_listen_address".to_owned(),
439        toml::Value::String(OS_ASSIGNED.to_owned()),
440    );
441    table.insert(
442        "channels".to_owned(),
443        toml::Value::Array(vec![toml::Value::Table(channel_entry)]),
444    );
445    table.insert("routing_rules".to_owned(), toml::Value::Array(Vec::new()));
446    table.insert("websocket".to_owned(), toml::Value::Table(websocket));
447    toml::Value::Table(table)
448}
449
450/// Whether the raw `[bus]` (or `[liminal]`) table states
451/// `[bus.websocket].allowed_origins` verbatim. A stated list is LAW; an
452/// absent one is derived from the real page address at boot.
453fn bus_states_allowed_origins(bus: &toml::Value) -> bool {
454    bus.as_table()
455        .and_then(|table| table.get("websocket"))
456        .and_then(toml::Value::as_table)
457        .is_some_and(|websocket| websocket.contains_key("allowed_origins"))
458}
459
460impl FrameConfig {
461    /// Loads and fully validates `frame.toml`.
462    ///
463    /// Accepts the embedded bus section as `[bus]` (primary), `[liminal]`
464    /// (deprecated alias — parsed with a loud `tracing::warn!` naming `[bus]`;
465    /// never a silent fallback), or ENTIRELY ABSENT (the portless scaffold,
466    /// 2026-07-22 ruling): a missing `[bus]` is synthesized wholesale by
467    /// frame-host (`synthesize_portless_bus`) with OS-assigned internal
468    /// ports and the single channel taken from `[frame].channel`. An
469    /// operator-authored bus runs liminal's own validation (channel
470    /// `schema_ref` paths resolve relative to the config file's directory,
471    /// exactly as standalone liminal resolves them); a synthesized one is
472    /// correct by construction and skips only liminal's port-non-zero check
473    /// (the `:0` sentinels are intentional). Either way frame-host then
474    /// refuses any bus shape the embedded frame server does not faithfully
475    /// orchestrate.
476    ///
477    /// # Errors
478    ///
479    /// Returns a typed failure for an unreadable file, malformed TOML, both
480    /// section spellings present at once, a portless config with no
481    /// `[frame].channel` to name its bus channel, an operator bus section
482    /// liminal's validator rejects, or an embedded-mode shape refusal.
483    pub fn load(path: &Path) -> Result<Self, HostError> {
484        let text = std::fs::read_to_string(path).map_err(|source| HostError::ConfigRead {
485            path: path.to_path_buf(),
486            source,
487        })?;
488        let RawFrameConfig {
489            frame,
490            document,
491            bus,
492            liminal,
493        } = toml::from_str(&text).map_err(|error| HostError::ConfigParse {
494            path: path.to_path_buf(),
495            detail: error.to_string(),
496        })?;
497        // `synthesized` is true when the operator stated no `[bus]`/`[liminal]`
498        // at all: frame-host authors the whole bus shape (OS-assigned ports,
499        // the `[frame].channel` channel) and, being correct by construction,
500        // skips liminal's operator-facing port-non-zero validation below.
501        let (bus, synthesized) = match (bus, liminal) {
502            (Some(_), Some(_)) => {
503                return Err(HostError::ConfigParse {
504                    path: path.to_path_buf(),
505                    detail: "both [bus] and [liminal] sections are present; [liminal] is the \
506                             deprecated alias of [bus] — declare exactly one section (use [bus])"
507                        .to_owned(),
508                });
509            }
510            (Some(bus), None) => (bus, false),
511            (None, Some(bus)) => {
512                tracing::warn!(
513                    config = %path.display(),
514                    "frame.toml section [liminal] is DEPRECATED: rename [liminal] to [bus] and \
515                     [liminal.websocket] to [bus.websocket]; the [liminal] alias is accepted for \
516                     one compatibility window only"
517                );
518                (bus, false)
519            }
520            (None, None) => {
521                // Portless scaffold: the single deployment-specific value the
522                // synthesized bus needs is the channel, and the one place a
523                // portless file names it is `[frame].channel`. With neither a
524                // `[bus]` nor a `[frame].channel`, the host has nothing to name
525                // its bus channel — a loud refusal naming the fix, never a
526                // silent invented channel.
527                let Some(channel) = frame.channel.as_deref() else {
528                    return Err(HostError::ConfigParse {
529                        path: path.to_path_buf(),
530                        detail: "no [bus] section and no [frame].channel: a portless frame.toml \
531                                 derives its embedded bus channel from [frame].channel, so state \
532                                 [frame].channel = \"<name>.events\" (or declare a full [bus] \
533                                 section to pin ports explicitly)"
534                            .to_owned(),
535                    });
536                };
537                (synthesize_portless_bus(channel), true)
538            }
539        };
540        // Whether the operator stated `[bus.websocket].allowed_origins`
541        // verbatim: a stated list is LAW; an absent one is derived from the
542        // real page address at boot (`finalize_page_origins`). A synthesized
543        // portless bus never states it.
544        let origins_stated = bus_states_allowed_origins(&bus);
545        // Inject frame-host's own proven bus-section defaults into the raw
546        // table BEFORE liminal's ServerConfig deserializer sees it — see
547        // `apply_bus_defaults` for what is defaulted and why. A key the
548        // operator DID declare is left byte-identical.
549        let bus = apply_bus_defaults(bus, path)?;
550        let bus: ServerConfig =
551            bus.try_into()
552                .map_err(|error: toml::de::Error| HostError::ConfigParse {
553                    path: path.to_path_buf(),
554                    detail: error.to_string(),
555                })?;
556        // Faithful bus load: run liminal's OWN pipeline in liminal's OWN
557        // order — environment overrides FIRST, then validation — exactly as
558        // standalone liminal's `load_config` does. Omitting the env layer would
559        // silently diverge: e.g. `LIMINAL_AUTH_TOKEN` (which liminal uses to
560        // inject an `[auth]` section absent from the file) would be ignored,
561        // downgrading a token-gated deployment to an open one with no warning.
562        // Channel `schema_ref` paths resolve relative to the config file's
563        // directory, matching standalone liminal.
564        let mut bus =
565            apply_env_overrides(bus).map_err(|source| HostError::LiminalConfig { source })?;
566        // A synthesized portless bus intentionally binds OS-assigned `:0`
567        // ports, exactly the state liminal's operator-facing validator rejects
568        // (`listen_address: port must be non-zero`). Every other field is
569        // frame-host-authored and correct by construction (one schema-less
570        // channel, empty routing, the proven WebSocket path), so validating it
571        // would add nothing but a false rejection of the intended `:0`. An
572        // operator-authored bus still runs the full validation unchanged.
573        if !synthesized {
574            validate(&mut bus, path.parent())
575                .map_err(|source| HostError::LiminalConfig { source })?;
576        }
577        let document = match document {
578            None => None,
579            Some(mut section) => {
580                // Resolve the declared paths relative to the config file,
581                // exactly as the bus resolves its schema refs.
582                if let Some(parent) = path.parent() {
583                    if section.content_path.is_relative() {
584                        section.content_path = parent.join(&section.content_path);
585                    }
586                    if section.state_dir.is_relative() {
587                        section.state_dir = parent.join(&section.state_dir);
588                    }
589                }
590                Some(section)
591            }
592        };
593        let ports_explicit = frame.bind.is_some() && origins_stated;
594        let config = Self {
595            frame,
596            document,
597            bus,
598            page_origins_derived: !origins_stated,
599            ports_explicit,
600        };
601        config.check_console_contract()?;
602        config.check_embedded_mode()?;
603        config.check_document_contract()?;
604        Ok(config)
605    }
606
607    /// Fills the embedded bus's `[bus.websocket].allowed_origins` from the page
608    /// server's REAL bound address once it is known, but ONLY when the operator
609    /// did not state the list ([`Self::page_origins_derived`]).
610    ///
611    /// The served page's origin is `http://<page-addr>`; the browser presents
612    /// it on every WebSocket upgrade and liminal's acceptor checks it against
613    /// this allow-list (fail-closed when empty). Both the `127.0.0.1` and the
614    /// `localhost` spelling of the bound port are added — the exact fix for a
615    /// real deployment that died on a 127-vs-localhost Origin mismatch when
616    /// only one spelling was allow-listed. Call this AFTER
617    /// [`crate::page::PageServer::resolve`] and BEFORE the bus binds
618    /// ([`crate::application::Application::boot`]); a stated list is LAW and is
619    /// left untouched.
620    ///
621    /// Idempotent and side-effect-free when the list was stated: the operator's
622    /// bytes reach liminal's acceptor exactly as written.
623    pub fn finalize_page_origins(&mut self, page_addr: SocketAddr) {
624        if !self.page_origins_derived {
625            return;
626        }
627        let Some(websocket) = self.bus.websocket.as_mut() else {
628            return;
629        };
630        let port = page_addr.port();
631        websocket.allowed_origins = vec![
632            format!("http://127.0.0.1:{port}"),
633            format!("http://localhost:{port}"),
634        ];
635    }
636
637    /// Refuses a `[document]` section the document service would refuse at
638    /// boot: bad wire ids, zero policy values, or channels the embedded
639    /// bus does not carry — every refusal typed and loud, here rather
640    /// than mid-boot.
641    fn check_document_contract(&self) -> Result<(), HostError> {
642        let Some(document) = &self.document else {
643            return Ok(());
644        };
645        for (field, value) in [
646            ("[document].id", document.id.as_str()),
647            ("[document].component_id", document.component_id.as_str()),
648        ] {
649            if !is_wire_id(value) {
650                return Err(HostError::ConfigContract {
651                    detail: format!("{field} must match [a-z0-9-]+, got {value:?}"),
652                });
653            }
654        }
655        if document.feed_channel == document.authoring_channel {
656            return Err(HostError::ConfigContract {
657                detail: "[document].feed_channel and [document].authoring_channel must be two                          distinct channels (C2: two channels per document)"
658                    .to_owned(),
659            });
660        }
661        for (field, channel) in [
662            ("[document].feed_channel", &document.feed_channel),
663            ("[document].authoring_channel", &document.authoring_channel),
664        ] {
665            if !self
666                .bus
667                .channels
668                .iter()
669                .any(|configured| configured.name == *channel)
670            {
671                return Err(HostError::ConfigContract {
672                    detail: format!(
673                        "{field} {channel:?} is not one of the embedded bus's configured \
674                         channels: declare it under [bus].channels"
675                    ),
676                });
677            }
678        }
679        // C8/R11 transport reality, verified at the pinned SDK (0.3.0):
680        // the TCP channel-subscription client cannot present an auth token,
681        // so the document service cannot subscribe a token-gated bus.
682        // Refused loudly here; the upstream ask is recorded in the leg's
683        // report.
684        if self.bus.auth.is_some() {
685            return Err(HostError::ConfigContract {
686                detail: "[document] cannot run against a token-gated [bus.auth]: the pinned bus                          SDK's channel subscription presents no auth token (upstream ask                          recorded). Run the embedded bus open, or drop [document]."
687                    .to_owned(),
688            });
689        }
690        for (field, value, default) in [
691            (
692                "[document].lease_expiry_ms",
693                document.lease_expiry_ms,
694                DEFAULT_LEASE_EXPIRY_MS,
695            ),
696            (
697                "[document].journal_length_bound",
698                document.journal_length_bound,
699                DEFAULT_JOURNAL_LENGTH_BOUND,
700            ),
701            (
702                "[document].quiesce_window_ms",
703                document.quiesce_window_ms,
704                DEFAULT_QUIESCE_WINDOW_MS,
705            ),
706            (
707                "[document].blink_interval_ms",
708                document.blink_interval_ms,
709                DEFAULT_BLINK_INTERVAL_MS,
710            ),
711        ] {
712            if value == 0 {
713                return Err(HostError::ConfigContract {
714                    detail: format!(
715                        "{field} must be positive when declared explicitly; omit the key \
716                         entirely to use the documented default of {default}"
717                    ),
718                });
719            }
720        }
721        if let Some(theme) = &document.syntax_theme {
722            for (capture, color) in theme {
723                if capture.is_empty() {
724                    return Err(HostError::ConfigContract {
725                        detail: "[document].syntax_theme keys must be non-empty".to_owned(),
726                    });
727                }
728                if !is_hex_color(color) {
729                    return Err(HostError::ConfigContract {
730                        detail: format!(
731                            "[document].syntax_theme.{capture} must be #rrggbb hex, got {color:?}"
732                        ),
733                    });
734                }
735            }
736        }
737        Ok(())
738    }
739
740    /// Refuses `[frame]` values the console's own config contract would reject,
741    /// so a doomed config fails loudly here rather than in the browser.
742    fn check_console_contract(&self) -> Result<(), HostError> {
743        if let Some(channel) = &self.frame.channel
744            && channel.is_empty()
745        {
746            return Err(HostError::ConfigContract {
747                detail: "[frame].channel, when present, must be non-empty: the console refuses an \
748                         empty channel; omit the key to use the SDK's default channel"
749                    .to_owned(),
750            });
751        }
752        // The served /frame/config.json advertises the FULL configured channel
753        // list (the console subscribes every one of them). A bus section
754        // with zero channels leaves the console with no feed to subscribe — a
755        // doomed deployment refused here, not discovered as a browser-side
756        // subscribe rejection.
757        if self.bus.channels.is_empty() {
758            return Err(HostError::ConfigContract {
759                detail: "[bus].channels is empty: the console subscribes every configured \
760                         channel and serves the list as `channels` in /frame/config.json, so a \
761                         zero-channel bus leaves the console with no feed. Declare at least \
762                         one [[bus.channels]] entry."
763                    .to_owned(),
764            });
765        }
766        // The console's primary channel must be one the embedded bus
767        // actually carries: the page refuses a `channels` roster that omits its
768        // `channel`, so serving that pair is a composition error caught here.
769        if let Some(channel) = &self.frame.channel
770            && !self
771                .bus
772                .channels
773                .iter()
774                .any(|configured| configured.name == *channel)
775        {
776            return Err(HostError::ConfigContract {
777                detail: format!(
778                    "[frame].channel \"{channel}\" is not one of the embedded bus's \
779                     configured channels [{roster}]: the console would subscribe a channel the \
780                     embedded server does not carry. Name a configured channel or add it to \
781                     [bus].channels.",
782                    roster = self
783                        .bus
784                        .channels
785                        .iter()
786                        .map(|configured| configured.name.as_str())
787                        .collect::<Vec<_>>()
788                        .join(", ")
789                ),
790            });
791        }
792        // If the bus gates connections behind a token but the page is handed an
793        // empty one, the browser would present an empty token to a closed
794        // server and be refused — a silent "page can't connect". Refuse loudly.
795        if let Some(auth) = &self.bus.auth {
796            if self.frame.auth_token.is_empty() {
797                return Err(HostError::ConfigContract {
798                    detail: "[bus.auth] gates the embedded server behind a token, but \
799                             [frame].auth_token is empty: the page would present an empty token \
800                             and be refused. Set [frame].auth_token to the token the page must \
801                             present."
802                        .to_owned(),
803                });
804            }
805            // The same doomed deployment with a non-empty wrong token: the
806            // served page presents [frame].auth_token on its WebSocket
807            // handshake and the embedded server validates it against
808            // [bus.auth].token, so a mismatch boots a healthy-looking host
809            // whose every browser is silently auth-refused (the announcer and
810            // document publisher connect with the bus token directly, so
811            // nothing fails host-side). The two fields have no legitimate
812            // reason to differ in embedded mode. Refuse loudly.
813            if self.frame.auth_token != auth.token {
814                return Err(HostError::ConfigContract {
815                    detail: "[frame].auth_token does not match [bus.auth].token: the page \
816                             presents [frame].auth_token to the embedded server, which validates \
817                             against [bus.auth].token, so a mismatch means every browser is \
818                             refused while the host boots healthy. Set both to the same token."
819                        .to_owned(),
820                });
821            }
822        }
823        Ok(())
824    }
825
826    /// Refuses bus shapes the embedded frame server does not faithfully
827    /// orchestrate.
828    ///
829    /// Embedded frame mode runs liminal's single-node **Full** profile with the
830    /// **WebSocket transport required** (the browser is a direct bus
831    /// participant per D3, so it needs a WebSocket address). Clustered and
832    /// worker-front-door deployments have their own boot orchestration in
833    /// standalone liminal that this embedding does not reproduce; rather than
834    /// silently mis-boot them, they are refused with a typed error.
835    fn check_embedded_mode(&self) -> Result<(), HostError> {
836        if self.bus.cluster.is_some() {
837            return Err(HostError::EmbeddedModeUnsupported {
838                detail: "[bus.cluster] is set, but the embedded frame server runs a \
839                         single-node deployment and does not start liminal's distribution \
840                         cluster. Remove [bus.cluster] or run liminal standalone."
841                    .to_owned(),
842            });
843        }
844        match self
845            .bus
846            .services
847            .profile()
848            .map_err(|source| HostError::LiminalConfig { source })?
849        {
850            ServiceProfile::Full => {}
851            ServiceProfile::WorkerFrontDoor => {
852                return Err(HostError::EmbeddedModeUnsupported {
853                    detail: "[bus.services].profile is \"worker-front-door\", but the embedded \
854                             frame server runs liminal's \"full\" profile (channels/conversations \
855                             back the console feed). Remove the profile override or run liminal \
856                             standalone."
857                        .to_owned(),
858                });
859            }
860        }
861        if self.bus.websocket.is_none() {
862            return Err(HostError::EmbeddedModeUnsupported {
863                detail: "[bus.websocket] is absent, but the embedded frame server requires it: \
864                         the browser connects to the bus's WebSocket directly (D3), so the console \
865                         has nowhere to connect without it. Add a [bus.websocket] section."
866                    .to_owned(),
867            });
868        }
869        Ok(())
870    }
871}