frame-host 0.2.1

Frame host server and embedding seam — boots an application's frame-core component tree with an embedded liminal bus, announces the host's real application events on the bus, and serves the built frame page
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
//! The single `frame.toml` that describes the whole stack.
//!
//! One config file, two sections:
//!
//! - `[frame]` — the console HTTP server: `bind`, `assets`, `auth_token`
//!   (surfaced to the page verbatim; empty = open server), optional `channel`.
//! - `[bus]` — the embedded messaging-bus component (liminal). This section
//!   deserializes into liminal's own
//!   [`liminal_server::config::ServerConfig`], so the bus validates its own
//!   section (required fields, `deny_unknown_fields`, channel-schema loading).
//!
//! **Defaults policy (BINDING ruling, 2026-07-21):** a required key that has
//! only one value it could possibly hold is FORBIDDEN from being required —
//! it defaults to that value, PROVEN from the deployed demos or the scaffold
//! template, never invented. Genuinely deployment-specific values (bind
//! addresses, file paths, channel names, document ids, auth tokens) stay
//! required with the existing loud errors. Because `ServerConfig` and its
//! nested `WebSocketConfig` are liminal's OWN external types (this crate
//! depends on the published `liminal-server` crate and cannot add
//! `#[serde(default)]` attributes to them), the handful of bus-section
//! defaults this ruling adds (`drain_timeout_ms`, `[bus.websocket].path`,
//! `[bus.websocket].allowed_origins`) are injected into the raw TOML table by
//! [`apply_bus_defaults`] BEFORE the table is handed to liminal's own
//! `ServerConfig` deserializer — a declared value is always left untouched
//! and reaches liminal's own validation exactly as before. See
//! [`apply_bus_defaults`] for the per-key sourcing.
//!
//! **Compatibility window:** the pre-rename section name `[liminal]` (with
//! `[liminal.websocket]`) still parses as an alias of `[bus]`, but every
//! acceptance logs a loud `tracing::warn!` deprecation naming the new section.
//! Declaring both sections is refused. The alias is removed after one window.
//!
//! Because the embedded bus's WebSocket address is derived from THIS one
//! configured section (see [`crate::embedded`]), the page's `busEndpoint`
//! and the server's actual bound listener can never drift — there is exactly
//! one source of truth.

use std::collections::BTreeMap;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};

use liminal_server::config::{ServerConfig, ServiceProfile, apply_env_overrides, validate};
use serde::Deserialize;

use crate::error::HostError;

/// The raw `frame.toml` shape: the console section plus the embedded bus
/// section under its primary name (`[bus]`) or its deprecated alias
/// (`[liminal]`). Exactly one of the two must be present; [`FrameConfig::load`]
/// enforces that and logs the deprecation on the alias.
///
/// The bus sections are captured as raw [`toml::Value`] tables rather than
/// `ServerConfig` directly: [`FrameConfig::load`] injects frame-host's own
/// proven defaults into the table (see [`apply_bus_defaults`]) BEFORE handing
/// it to liminal's own `ServerConfig` deserializer, since `ServerConfig` is
/// an external type this crate cannot annotate.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawFrameConfig {
    /// Console HTTP server configuration.
    frame: FrameSection,
    /// Optional document-service binding (IRIDIUM-A3 R11).
    document: Option<DocumentSection>,
    /// Embedded bus component configuration — the primary section name.
    bus: Option<toml::Value>,
    /// DEPRECATED alias of `[bus]`, kept for one compatibility window.
    liminal: Option<toml::Value>,
}

/// The parsed `frame.toml`: the console section plus the embedded bus
/// section (liminal's own config type).
#[derive(Debug)]
pub struct FrameConfig {
    /// Console HTTP server configuration.
    pub frame: FrameSection,
    /// The optional `[document]` binding: when present, the host boots the
    /// document service (IRIDIUM-A3 R11) against the embedded bus.
    pub document: Option<DocumentSection>,
    /// Embedded bus component configuration — liminal's own config type,
    /// validated by liminal's own validator.
    pub bus: ServerConfig,
}

/// The `[frame]` section: everything the console HTTP server needs.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FrameSection {
    /// Socket address the console HTTP server binds, e.g. `127.0.0.1:4173`.
    pub bind: SocketAddr,
    /// Directory containing the static console bundle (`index.html` at root).
    pub assets: PathBuf,
    /// Bearer token surfaced to the page verbatim as `authToken`. Required key
    /// — the operator states it, the binary never invents it. An explicitly
    /// empty value (`auth_token = ""`) is legal and means an open server,
    /// matching the console's config contract.
    pub auth_token: String,
    /// Feed channel surfaced to the page as `channel`. Absent → the field is
    /// omitted and the page applies the SDK's default channel. When present it
    /// must be non-empty (the console refuses an empty channel).
    #[serde(default)]
    pub channel: Option<String>,
}

/// The `[document]` section (IRIDIUM-A3 R11): the document binding.
///
/// Deployment-identity fields (`id`, `language`, `content_path`,
/// `component_id`, `feed_channel`, `authoring_channel`, `state_dir`) stay
/// fully required — A1 constraint 9's no-assumed-defaults law still governs
/// them, since each one is a genuinely deployment-specific decision with no
/// single "the" value. The five pure tuning/presentation knobs below
/// (`lease_expiry_ms`, `journal_length_bound`, `quiesce_window_ms`,
/// `dark_theme`, `blink_interval_ms`) are amended by the 2026-07-21 BINDING
/// ruling (the same amendment that gave `syntax_theme` its built-in default):
/// every known deployment types the identical value, so requiring the
/// operator to retype it is exactly the "stupid required key" the ruling
/// forbids. `frame_authority::AuthorityConfig::declare` (constraint 9's
/// actual enforcement point) still receives an explicit, concrete,
/// non-invented value either way — the default only decides where that
/// value comes from when the key is absent.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DocumentSection {
    /// The bound document id (`[a-z0-9-]+`).
    pub id: String,
    /// The document language.
    pub language: String,
    /// The initial content source, resolved relative to the config file.
    pub content_path: PathBuf,
    /// The component instance id on every feed envelope (`[a-z0-9-]+`).
    pub component_id: String,
    /// The feed channel: the service publishes; every page subscribes.
    pub feed_channel: String,
    /// The authoring channel: pages publish; the service subscribes.
    pub authoring_channel: String,
    /// The frame-state store directory, resolved relative to the config
    /// file.
    pub state_dir: PathBuf,
    /// Lease expiry (§5.5 policy), milliseconds. Optional — absent defaults
    /// to [`DEFAULT_LEASE_EXPIRY_MS`], the value every known
    /// `[document]`-enabled deployment declares
    /// (`examples/code-view-console/frame.toml`, matching the live deployed
    /// demo `~/.frame-demo/code-view/frame.toml`). A present-but-zero value
    /// still refuses loudly (`FrameConfig::check_document_contract`).
    #[serde(default = "default_lease_expiry_ms")]
    pub lease_expiry_ms: u64,
    /// The journal-length snapshot bound (T3(i) signal 3). Optional —
    /// absent defaults to [`DEFAULT_JOURNAL_LENGTH_BOUND`], sourced the same
    /// way as [`Self::lease_expiry_ms`].
    #[serde(default = "default_journal_length_bound")]
    pub journal_length_bound: u64,
    /// The quiesce debounce window (T3(i) signal 4), milliseconds. Optional
    /// — absent defaults to [`DEFAULT_QUIESCE_WINDOW_MS`], sourced the same
    /// way as [`Self::lease_expiry_ms`].
    #[serde(default = "default_quiesce_window_ms")]
    pub quiesce_window_ms: u64,
    /// The publisher-seat dark-mode presentation input (C5). Optional —
    /// absent defaults to [`DEFAULT_DARK_THEME`], sourced the same way as
    /// [`Self::lease_expiry_ms`].
    #[serde(default = "default_dark_theme")]
    pub dark_theme: bool,
    /// The component's cursor-blink interval (T4), milliseconds — a
    /// component policy value carried by frame.toml per constraint 12 and
    /// advertised to the page. Optional — absent defaults to
    /// [`DEFAULT_BLINK_INTERVAL_MS`], sourced the same way as
    /// [`Self::lease_expiry_ms`].
    #[serde(default = "default_blink_interval_ms")]
    pub blink_interval_ms: u64,
    /// The publisher-seat syntax-theme presentation input (C5, amended
    /// 2026-07-21): capture-name → `#rrggbb` hex colour, TOML table.
    /// Optional — when absent, [`crate::doc_binding::DocBinding::boot`]
    /// falls back to the built-in default palette
    /// ([`crate::syntax_theme::default_syntax_theme`], copied from the
    /// proven `SYNTAX_THEME` in `examples/code-view-console/src/fake-feed.ts`).
    /// When present, this table REPLACES the built-in default WHOLESALE —
    /// there is no per-key merging with the default (merging would invent
    /// behaviour nobody asked for). Every declared value must be `#rrggbb`
    /// hex; [`FrameConfig::load`] fails loudly on a malformed or
    /// non-string entry rather than dropping it silently.
    #[serde(default)]
    pub syntax_theme: Option<BTreeMap<String, String>>,
}

/// Proven `[document].lease_expiry_ms` default (milliseconds), source:
/// `examples/code-view-console/frame.toml` L36, matching the live deployed
/// demo `~/.frame-demo/code-view/frame.toml`.
pub const DEFAULT_LEASE_EXPIRY_MS: u64 = 30_000;
/// Proven `[document].journal_length_bound` default, source:
/// `examples/code-view-console/frame.toml` L37, matching the live deployed
/// demo.
pub const DEFAULT_JOURNAL_LENGTH_BOUND: u64 = 256;
/// Proven `[document].quiesce_window_ms` default (milliseconds), source:
/// `examples/code-view-console/frame.toml` L38, matching the live deployed
/// demo.
pub const DEFAULT_QUIESCE_WINDOW_MS: u64 = 2_000;
/// Proven `[document].dark_theme` default, source:
/// `examples/code-view-console/frame.toml` L39, matching the live deployed
/// demo.
pub const DEFAULT_DARK_THEME: bool = true;
/// Proven `[document].blink_interval_ms` default (milliseconds), source:
/// `examples/code-view-console/frame.toml` L40, matching the live deployed
/// demo.
pub const DEFAULT_BLINK_INTERVAL_MS: u64 = 530;

const fn default_lease_expiry_ms() -> u64 {
    DEFAULT_LEASE_EXPIRY_MS
}
const fn default_journal_length_bound() -> u64 {
    DEFAULT_JOURNAL_LENGTH_BOUND
}
const fn default_quiesce_window_ms() -> u64 {
    DEFAULT_QUIESCE_WINDOW_MS
}
const fn default_dark_theme() -> bool {
    DEFAULT_DARK_THEME
}
const fn default_blink_interval_ms() -> u64 {
    DEFAULT_BLINK_INTERVAL_MS
}

/// The closed hex-colour grammar the wire enforces (`#rrggbb`, matching
/// `frame_editor_wire::snapshot`'s `validate_syntax_theme` and the
/// console's `hexColor` codec check), checked at config load so a doomed
/// theme value fails here, not at first encode.
fn is_hex_color(value: &str) -> bool {
    value.len() == 7
        && value.starts_with('#')
        && value[1..].bytes().all(|byte| byte.is_ascii_hexdigit())
}

/// The closed ASCII id grammar the wire enforces (`[a-z0-9-]+`), checked
/// at config load so a doomed id fails here, not at first encode.
fn is_wire_id(value: &str) -> bool {
    !value.is_empty()
        && value
            .bytes()
            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
}

/// Proven `[bus].drain_timeout_ms` default (milliseconds). Source: every
/// known deployment types the identical value —
/// `examples/code-view-console/frame.toml` L38 (`drain_timeout_ms = 1000`,
/// matching the live deployed demo `~/.frame-demo/code-view/frame.toml`),
/// `crates/frame-cli/templates/frame.toml` L19 (the scaffold every new app
/// starts from), and `docs/guides/BUILDING-APPS.md`'s worked example.
pub const DEFAULT_BUS_DRAIN_TIMEOUT_MS: i64 = 1_000;

/// Proven `[bus.websocket].path` default. Source: every known deployment
/// types the identical value — `examples/code-view-console/frame.toml`
/// (matching the live deployed demo), `crates/frame-cli/templates/frame.toml`
/// L27 (the scaffold every new app starts from), and
/// `docs/guides/BUILDING-APPS.md`'s worked example. `WebSocketConfig::path`
/// (liminal-server's own type) still requires the value to start with `/`;
/// this default satisfies that grammar.
pub const DEFAULT_WEBSOCKET_PATH: &str = "/liminal";

/// Injects frame-host's own proven bus-section defaults into the raw
/// `[bus]` (or `[liminal]` alias) TOML table BEFORE it is handed to
/// liminal's own `ServerConfig` deserializer.
///
/// `ServerConfig` and its nested `WebSocketConfig` are liminal's OWN
/// external types (this crate depends on the published `liminal-server`
/// crate and cannot add `#[serde(default)]` attributes to them), so this is
/// the frame-host-owned seam where the 2026-07-21 BINDING defaults ruling
/// is enforced for the bus section: a key the operator DID declare is left
/// byte-identical and reaches liminal's own validation exactly as before; a
/// key the operator omitted gets the proven default documented at its call
/// site.
///
/// Defaulted:
/// - `drain_timeout_ms` → [`DEFAULT_BUS_DRAIN_TIMEOUT_MS`] (milliseconds).
/// - `websocket.path` → [`DEFAULT_WEBSOCKET_PATH`], but ONLY when a
///   `[bus.websocket]` table is already present — the section itself stays
///   required (embedded frame mode has nowhere for the browser to connect
///   without it; `FrameConfig::check_embedded_mode` still refuses an absent
///   section).
/// - `websocket.allowed_origins` → derived from `frame_bind`'s port as
///   `["http://127.0.0.1:PORT", "http://localhost:PORT"]` (both host
///   spellings, not just one) — the exact fix for a real deployment that
///   died on a 127-vs-localhost Origin mismatch when only one spelling was
///   allow-listed. liminal's own `allowed_origins` default (absent = empty,
///   fail-closed for every browser origin) is correct for a
///   general-purpose bus with an unknown consumer, but wrong for the
///   embedded case: the console is *always* the consumer and its origin is
///   always derivable from `[frame].bind`, so leaving the general-purpose
///   fail-closed default in place here would silently strand the console's
///   own browser page. An explicit list, even an empty one, still overrides
///   wholesale — this only fires when the key is absent entirely.
///
/// # Errors
///
/// Returns [`HostError::ConfigParse`] if `bus` (or a present `websocket`
/// key inside it) is not a TOML table — a genuinely malformed shape that
/// would fail liminal's own deserializer anyway; caught here with a
/// frame-host-flavoured message instead of a generic serde one.
fn apply_bus_defaults(
    mut bus: toml::Value,
    frame_bind: SocketAddr,
    path: &Path,
) -> Result<toml::Value, HostError> {
    let table = bus.as_table_mut().ok_or_else(|| HostError::ConfigParse {
        path: path.to_path_buf(),
        detail: "[bus] (or [liminal]) must be a TOML table".to_owned(),
    })?;

    table
        .entry("drain_timeout_ms")
        .or_insert_with(|| toml::Value::Integer(DEFAULT_BUS_DRAIN_TIMEOUT_MS));

    if let Some(websocket_value) = table.get_mut("websocket") {
        let websocket = websocket_value
            .as_table_mut()
            .ok_or_else(|| HostError::ConfigParse {
                path: path.to_path_buf(),
                detail: "[bus.websocket] (or [liminal.websocket]) must be a TOML table".to_owned(),
            })?;
        websocket
            .entry("path")
            .or_insert_with(|| toml::Value::String(DEFAULT_WEBSOCKET_PATH.to_owned()));
        websocket.entry("allowed_origins").or_insert_with(|| {
            let port = frame_bind.port();
            toml::Value::Array(vec![
                toml::Value::String(format!("http://127.0.0.1:{port}")),
                toml::Value::String(format!("http://localhost:{port}")),
            ])
        });
    }

    Ok(bus)
}

impl FrameConfig {
    /// Loads and fully validates `frame.toml`.
    ///
    /// Accepts the embedded bus section as `[bus]` (primary) or `[liminal]`
    /// (deprecated alias — parsed with a loud `tracing::warn!` naming `[bus]`;
    /// never a silent fallback). Runs liminal's own validation over the bus
    /// section (channel `schema_ref` paths resolve relative to the config
    /// file's directory, exactly as standalone liminal resolves them), then
    /// refuses any bus shape the embedded frame server does not faithfully
    /// orchestrate.
    ///
    /// # Errors
    ///
    /// Returns a typed failure for an unreadable file, malformed TOML, a
    /// missing bus section, both section spellings present at once, a bus
    /// section liminal's validator rejects, or an embedded-mode shape refusal.
    pub fn load(path: &Path) -> Result<Self, HostError> {
        let text = std::fs::read_to_string(path).map_err(|source| HostError::ConfigRead {
            path: path.to_path_buf(),
            source,
        })?;
        let RawFrameConfig {
            frame,
            document,
            bus,
            liminal,
        } = toml::from_str(&text).map_err(|error| HostError::ConfigParse {
            path: path.to_path_buf(),
            detail: error.to_string(),
        })?;
        let bus = match (bus, liminal) {
            (Some(_), Some(_)) => {
                return Err(HostError::ConfigParse {
                    path: path.to_path_buf(),
                    detail: "both [bus] and [liminal] sections are present; [liminal] is the \
                             deprecated alias of [bus] — declare exactly one section (use [bus])"
                        .to_owned(),
                });
            }
            (Some(bus), None) => bus,
            (None, Some(bus)) => {
                tracing::warn!(
                    config = %path.display(),
                    "frame.toml section [liminal] is DEPRECATED: rename [liminal] to [bus] and \
                     [liminal.websocket] to [bus.websocket]; the [liminal] alias is accepted for \
                     one compatibility window only"
                );
                bus
            }
            (None, None) => {
                return Err(HostError::ConfigParse {
                    path: path.to_path_buf(),
                    detail: "missing the [bus] section (the embedded messaging-bus config; \
                             formerly named [liminal])"
                        .to_owned(),
                });
            }
        };
        // Inject frame-host's own proven bus-section defaults into the raw
        // table BEFORE liminal's ServerConfig deserializer sees it — see
        // `apply_bus_defaults` for what is defaulted and why. A key the
        // operator DID declare is left byte-identical.
        let bus = apply_bus_defaults(bus, frame.bind, path)?;
        let bus: ServerConfig =
            bus.try_into()
                .map_err(|error: toml::de::Error| HostError::ConfigParse {
                    path: path.to_path_buf(),
                    detail: error.to_string(),
                })?;
        // Faithful bus load: run liminal's OWN pipeline in liminal's OWN
        // order — environment overrides FIRST, then validation — exactly as
        // standalone liminal's `load_config` does. Omitting the env layer would
        // silently diverge: e.g. `LIMINAL_AUTH_TOKEN` (which liminal uses to
        // inject an `[auth]` section absent from the file) would be ignored,
        // downgrading a token-gated deployment to an open one with no warning.
        // Channel `schema_ref` paths resolve relative to the config file's
        // directory, matching standalone liminal.
        let mut bus =
            apply_env_overrides(bus).map_err(|source| HostError::LiminalConfig { source })?;
        validate(&mut bus, path.parent()).map_err(|source| HostError::LiminalConfig { source })?;
        let document = match document {
            None => None,
            Some(mut section) => {
                // Resolve the declared paths relative to the config file,
                // exactly as the bus resolves its schema refs.
                if let Some(parent) = path.parent() {
                    if section.content_path.is_relative() {
                        section.content_path = parent.join(&section.content_path);
                    }
                    if section.state_dir.is_relative() {
                        section.state_dir = parent.join(&section.state_dir);
                    }
                }
                Some(section)
            }
        };
        let config = Self {
            frame,
            document,
            bus,
        };
        config.check_console_contract()?;
        config.check_embedded_mode()?;
        config.check_document_contract()?;
        Ok(config)
    }

    /// Refuses a `[document]` section the document service would refuse at
    /// boot: bad wire ids, zero policy values, or channels the embedded
    /// bus does not carry — every refusal typed and loud, here rather
    /// than mid-boot.
    fn check_document_contract(&self) -> Result<(), HostError> {
        let Some(document) = &self.document else {
            return Ok(());
        };
        for (field, value) in [
            ("[document].id", document.id.as_str()),
            ("[document].component_id", document.component_id.as_str()),
        ] {
            if !is_wire_id(value) {
                return Err(HostError::ConfigContract {
                    detail: format!("{field} must match [a-z0-9-]+, got {value:?}"),
                });
            }
        }
        if document.feed_channel == document.authoring_channel {
            return Err(HostError::ConfigContract {
                detail: "[document].feed_channel and [document].authoring_channel must be two                          distinct channels (C2: two channels per document)"
                    .to_owned(),
            });
        }
        for (field, channel) in [
            ("[document].feed_channel", &document.feed_channel),
            ("[document].authoring_channel", &document.authoring_channel),
        ] {
            if !self
                .bus
                .channels
                .iter()
                .any(|configured| configured.name == *channel)
            {
                return Err(HostError::ConfigContract {
                    detail: format!(
                        "{field} {channel:?} is not one of the embedded bus's configured \
                         channels: declare it under [bus].channels"
                    ),
                });
            }
        }
        // C8/R11 transport reality, verified at the pinned SDK (0.3.0):
        // the TCP channel-subscription client cannot present an auth token,
        // so the document service cannot subscribe a token-gated bus.
        // Refused loudly here; the upstream ask is recorded in the leg's
        // report.
        if self.bus.auth.is_some() {
            return Err(HostError::ConfigContract {
                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]."
                    .to_owned(),
            });
        }
        for (field, value, default) in [
            (
                "[document].lease_expiry_ms",
                document.lease_expiry_ms,
                DEFAULT_LEASE_EXPIRY_MS,
            ),
            (
                "[document].journal_length_bound",
                document.journal_length_bound,
                DEFAULT_JOURNAL_LENGTH_BOUND,
            ),
            (
                "[document].quiesce_window_ms",
                document.quiesce_window_ms,
                DEFAULT_QUIESCE_WINDOW_MS,
            ),
            (
                "[document].blink_interval_ms",
                document.blink_interval_ms,
                DEFAULT_BLINK_INTERVAL_MS,
            ),
        ] {
            if value == 0 {
                return Err(HostError::ConfigContract {
                    detail: format!(
                        "{field} must be positive when declared explicitly; omit the key \
                         entirely to use the documented default of {default}"
                    ),
                });
            }
        }
        if let Some(theme) = &document.syntax_theme {
            for (capture, color) in theme {
                if capture.is_empty() {
                    return Err(HostError::ConfigContract {
                        detail: "[document].syntax_theme keys must be non-empty".to_owned(),
                    });
                }
                if !is_hex_color(color) {
                    return Err(HostError::ConfigContract {
                        detail: format!(
                            "[document].syntax_theme.{capture} must be #rrggbb hex, got {color:?}"
                        ),
                    });
                }
            }
        }
        Ok(())
    }

    /// Refuses `[frame]` values the console's own config contract would reject,
    /// so a doomed config fails loudly here rather than in the browser.
    fn check_console_contract(&self) -> Result<(), HostError> {
        if let Some(channel) = &self.frame.channel
            && channel.is_empty()
        {
            return Err(HostError::ConfigContract {
                detail: "[frame].channel, when present, must be non-empty: the console refuses an \
                         empty channel; omit the key to use the SDK's default channel"
                    .to_owned(),
            });
        }
        // The served /frame/config.json advertises the FULL configured channel
        // list (the console subscribes every one of them). A bus section
        // with zero channels leaves the console with no feed to subscribe — a
        // doomed deployment refused here, not discovered as a browser-side
        // subscribe rejection.
        if self.bus.channels.is_empty() {
            return Err(HostError::ConfigContract {
                detail: "[bus].channels is empty: the console subscribes every configured \
                         channel and serves the list as `channels` in /frame/config.json, so a \
                         zero-channel bus leaves the console with no feed. Declare at least \
                         one [[bus.channels]] entry."
                    .to_owned(),
            });
        }
        // The console's primary channel must be one the embedded bus
        // actually carries: the page refuses a `channels` roster that omits its
        // `channel`, so serving that pair is a composition error caught here.
        if let Some(channel) = &self.frame.channel
            && !self
                .bus
                .channels
                .iter()
                .any(|configured| configured.name == *channel)
        {
            return Err(HostError::ConfigContract {
                detail: format!(
                    "[frame].channel \"{channel}\" is not one of the embedded bus's \
                     configured channels [{roster}]: the console would subscribe a channel the \
                     embedded server does not carry. Name a configured channel or add it to \
                     [bus].channels.",
                    roster = self
                        .bus
                        .channels
                        .iter()
                        .map(|configured| configured.name.as_str())
                        .collect::<Vec<_>>()
                        .join(", ")
                ),
            });
        }
        // If the bus gates connections behind a token but the page is handed an
        // empty one, the browser would present an empty token to a closed
        // server and be refused — a silent "page can't connect". Refuse loudly.
        if self.bus.auth.is_some() && self.frame.auth_token.is_empty() {
            return Err(HostError::ConfigContract {
                detail: "[bus.auth] gates the embedded server behind a token, but \
                         [frame].auth_token is empty: the page would present an empty token and be \
                         refused. Set [frame].auth_token to the token the page must present."
                    .to_owned(),
            });
        }
        Ok(())
    }

    /// Refuses bus shapes the embedded frame server does not faithfully
    /// orchestrate.
    ///
    /// Embedded frame mode runs liminal's single-node **Full** profile with the
    /// **WebSocket transport required** (the browser is a direct bus
    /// participant per D3, so it needs a WebSocket address). Clustered and
    /// worker-front-door deployments have their own boot orchestration in
    /// standalone liminal that this embedding does not reproduce; rather than
    /// silently mis-boot them, they are refused with a typed error.
    fn check_embedded_mode(&self) -> Result<(), HostError> {
        if self.bus.cluster.is_some() {
            return Err(HostError::EmbeddedModeUnsupported {
                detail: "[bus.cluster] is set, but the embedded frame server runs a \
                         single-node deployment and does not start liminal's distribution \
                         cluster. Remove [bus.cluster] or run liminal standalone."
                    .to_owned(),
            });
        }
        match self
            .bus
            .services
            .profile()
            .map_err(|source| HostError::LiminalConfig { source })?
        {
            ServiceProfile::Full => {}
            ServiceProfile::WorkerFrontDoor => {
                return Err(HostError::EmbeddedModeUnsupported {
                    detail: "[bus.services].profile is \"worker-front-door\", but the embedded \
                             frame server runs liminal's \"full\" profile (channels/conversations \
                             back the console feed). Remove the profile override or run liminal \
                             standalone."
                        .to_owned(),
                });
            }
        }
        if self.bus.websocket.is_none() {
            return Err(HostError::EmbeddedModeUnsupported {
                detail: "[bus.websocket] is absent, but the embedded frame server requires it: \
                         the browser connects to the bus's WebSocket directly (D3), so the console \
                         has nowhere to connect without it. Add a [bus.websocket] section."
                    .to_owned(),
            });
        }
        Ok(())
    }
}