Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

crates/monetize-admin/src/solo.rs

//! **The solo monetize** (G6): what PID 1 keeps for its two children — the
//! loopback address of each, the words each is started with, the console's
//! settings, the operator's door feed over the installer's, and the judge
//! every feed passes before monetize takes it.

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

use monetize_front::intake;

/// monetize-server's own address: the intake writes it as `grpc_addr`.
pub const SERVER: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::LOCALHOST, 50061);
/// The console's own address.
pub const UI: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::LOCALHOST, 8091);
/// The console's settings, under the data root (0600).
pub const UI_SETTINGS_REL: &str = "monetize-ui/settings.toml";
/// The passkey and session store, under the data root.
pub const UI_STATE_REL: &str = "monetize-ui/state.redb";
/// The marker the operator's door leaves when a passkey reset is asked.
pub const RESET_REL: &str = "monetize-ui/passkeys.reset";
/// The feed the operator PUT on the door after the install (0600, JSON).
pub const DOOR_FEED_REL: &str = "solo/door-feed.json";
/// Where a feed is judged, under the data root (removed after each judging).
const JUDGE_REL: &str = "solo/judging";

/// `monetize-admin server …`'s words.
pub fn server_args(root: &Path) -> Vec<String> {
    vec!["server".into(), "--config".into(), root.join(intake::CONFIG_REL).display().to_string()]
}

/// `monetize-admin ui …`'s words.
pub fn ui_args(root: &Path) -> Vec<String> {
    vec!["ui".into(), "--bind".into(), UI.to_string(), "--server".into(), SERVER.to_string(), "--root".into(), root.display().to_string()]
}

/// **What the console runs with**: the passkeys' relying party and the
/// break-glass bearer for the first passkey.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct UiSettings {
    /// The first fed `web_hosts` entry, else the estate's name.
    pub rp_id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub admin_token: Option<String>,
}

/// Why `token` is too weak to be the break-glass bearer, if it is.
pub fn weak_token(token: &str) -> Option<&'static str> {
    let distinct: std::collections::BTreeSet<char> = token.chars().collect();
    if token.len() < 32 {
        Some("is shorter than 32 characters")
    } else if distinct.len() < 8 {
        Some("repeats too few characters to be random")
    } else {
        None
    }
}

impl UiSettings {
    /// From the feed — pure; the refusal lines beside it.
    pub fn of(feed: &BTreeMap<String, String>, estate: &str) -> (UiSettings, Vec<String>) {
        let mut refused = Vec::new();
        let web = estate_front::web::WebConfig::from(estate, feed);
        let admin_token = feed.get(crate::FEED_ADMIN_TOKEN).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()).and_then(|t| match weak_token(&t) {
            Some(why) => {
                refused.push(format!("REFUSED [console-admin-token-weak] the fed `{}` {why}; the console starts without a break-glass bearer", crate::FEED_ADMIN_TOKEN));
                None
            }
            None => Some(t),
        });
        (UiSettings { rp_id: web.name().to_string(), admin_token }, refused)
    }

    /// Write them (0600), only when they differ: `true` when they changed.
    pub fn write(&self, root: &Path) -> std::io::Result<bool> {
        if UiSettings::read(root).as_ref() == Some(self) {
            return Ok(false);
        }
        let text = toml::to_string(self).map_err(std::io::Error::other)?;
        monetize_front::write_secret(root, UI_SETTINGS_REL, &text)?;
        Ok(true)
    }

    /// What was written, if anything readable was.
    pub fn read(root: &Path) -> Option<UiSettings> {
        toml::from_str(&std::fs::read_to_string(root.join(UI_SETTINGS_REL)).ok()?).ok()
    }
}

/// The feed the operator put on the door (empty when none).
pub fn read_door_feed(root: &Path) -> BTreeMap<String, String> {
    std::fs::read_to_string(root.join(DOOR_FEED_REL)).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()
}

/// Keep `feed` as the door's (0600, atomically).
pub fn write_door_feed(root: &Path, feed: &BTreeMap<String, String>) -> std::io::Result<()> {
    let text = serde_json::to_string(feed).map_err(std::io::Error::other)?;
    monetize_front::write_secret(root, DOOR_FEED_REL, &text)
}

/// **The next release's feed, staged before the update** (M-FOLLOW 7: the product gap made
/// zero): `PUT /feed/next` keeps it here, for the app pin the box's own pointer names. The app
/// that runs never applies it; that next app takes it at its first start ([`feed_now_for`]),
/// and once it runs confirmed it becomes the door feed ([`promote_next_feed`]).
pub const NEXT_FEED_REL: &str = "solo/door-feed.next.json";

/// A staged next feed: the app it is for, the whole feed (secrets: kept 0600, never logged).
#[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
pub struct StagedFeed {
    pub for_app: String,
    pub feed: BTreeMap<String, String>,
}

impl std::fmt::Debug for StagedFeed {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "StagedFeed {{ for_app: {}, {} key(s) }}", self.for_app, self.feed.len())
    }
}

/// Keep `feed` staged for the app `for_app` (0600, atomically; a later stage replaces it).
pub fn stage_next_feed(root: &Path, for_app: &str, feed: &BTreeMap<String, String>) -> std::io::Result<()> {
    let text = serde_json::to_string(&StagedFeed { for_app: for_app.trim().to_string(), feed: feed.clone() }).map_err(std::io::Error::other)?;
    monetize_front::write_secret(root, NEXT_FEED_REL, &text)
}

/// The staged next feed, if any.
pub fn read_next_feed(root: &Path) -> Option<StagedFeed> {
    serde_json::from_str(&std::fs::read_to_string(root.join(NEXT_FEED_REL)).ok()?).ok()
}

/// **The door's feed for the app `app`**: the staged next feed when it is for `app` (that app's
/// first start, its trial), else the door feed.
fn door_feed_for(root: &Path, app: &str) -> BTreeMap<String, String> {
    match read_next_feed(root) {
        Some(s) if !app.trim().is_empty() && s.for_app == app.trim() => s.feed,
        _ => read_door_feed(root),
    }
}

/// **The staged feed becomes the door feed once its app runs CONFIRMED** (never on trial: a
/// korp-start rollback must find the old app's own feed untouched). Answers the line to log.
pub fn promote_next_feed(root: &Path, app: &str, on_trial: bool) -> Option<String> {
    let s = read_next_feed(root)?;
    if on_trial || app.trim().is_empty() || s.for_app != app.trim() {
        return None;
    }
    if let Err(e) = write_door_feed(root, &s.feed) {
        return Some(format!("the staged feed for app {} could not become the door feed: {e}; it stays staged", &s.for_app[..s.for_app.len().min(12)]));
    }
    let _ = std::fs::remove_file(root.join(NEXT_FEED_REL));
    Some(format!("the feed staged for app {} is the door feed now ({} key(s)): this app runs confirmed", &s.for_app[..s.for_app.len().min(12)], s.feed.len()))
}

/// [`feed_now`] for the app pin `app` (the one that runs): its staged next feed over the
/// installer's when one is staged for it ([`door_feed_for`]).
pub fn feed_now_for(root: &Path, fed: &BTreeMap<String, String>, app: &str) -> BTreeMap<String, String> {
    let mut f = fed.clone();
    f.extend(door_feed_for(root, app));
    over_with_app(root, f)
}

/// **The feed in force**: the installer's, with the door's lying over it key by key, and the
/// sites and accounts the operator added or edited in the application over both
/// ([`crate::app_config::overlay`]: the app wins, by name).
pub fn feed_now(root: &Path, fed: &BTreeMap<String, String>) -> BTreeMap<String, String> {
    feed_now_for(root, fed, &crate::self_update::running_app())
}

/// The application's sites and accounts over the feed `f` ([`feed_now`]).
fn over_with_app(root: &Path, f: BTreeMap<String, String>) -> BTreeMap<String, String> {
    let (book, secrets) = (crate::app_config::read_book(root), crate::app_config::read_secrets(root));
    if book.sites.is_empty() && book.vendors.is_empty() && secrets.is_empty() {
        return f;
    }
    match crate::app_config::overlay(&f, &book, &secrets) {
        Ok(with_app) => with_app,
        Err(e) => {
            // Never reached through the door (it refuses a feed the app's sites cannot lie
            // over); said loudly, and the bootstrap's feed serves alone until it holds again.
            tracing::error!("solo: the application's sites are NOT in force: {e}");
            f
        }
    }
}

/// **Judge `feed` before it is taken**: the intake writes it into a scratch
/// root and monetize-server's own loader reads the config back — a feed it
/// would not start on is refused whole, by name. The products it serves, else
/// why not. Nothing is written where monetize reads.
pub fn judge(root: &Path, feed: &BTreeMap<String, String>) -> Result<Vec<String>, String> {
    judge_linked(root, feed, &crate::linked().product_names())
}

/// [`judge`] for a server that links the product plugins `linked`: such a product may be
/// fed with no module (`intake::plugins_linked`).
pub fn judge_linked(root: &Path, feed: &BTreeMap<String, String>, linked: &[&str]) -> Result<Vec<String>, String> {
    judge_in(root, JUDGE_REL, feed, linked)
}

/// [`judge`] in the scratch directory `scratch_rel` (the site book judges in its own: it runs
/// in the server's process, PID 1 in its).
pub fn judge_in(root: &Path, scratch_rel: &str, feed: &BTreeMap<String, String>, linked: &[&str]) -> Result<Vec<String>, String> {
    static ONE: std::sync::Mutex<()> = std::sync::Mutex::new(());
    let _one = ONE.lock().unwrap_or_else(|e| e.into_inner());
    let scratch = root.join(scratch_rel);
    let _ = std::fs::remove_dir_all(&scratch);
    let judged = (|| {
        let refused = intake::apply_pull_linked(&scratch, feed, linked).map_err(|e| format!("{e:#}"))?;
        if !refused.is_empty() {
            return Err(refused.join("\n"));
        }
        let cfg = monetize_server::config::Config::load(&scratch.join(intake::CONFIG_REL)).map_err(|e| format!("REFUSED [solo-config] {e:#}"))?;
        Ok(cfg.products.keys().cloned().collect())
    })();
    let _ = std::fs::remove_dir_all(&scratch);
    judged
}

/// **Where PID 1 says which door feed it took** (R34-5): the sha256 (hex) of
/// [`DOOR_FEED_REL`] as it read it, or `<sha256> NOT taken: <why>` — the door answers a put
/// feed only once this names it (and the server applied the config written for it).
pub const DOOR_TAKEN_REL: &str = "solo/door-feed.taken";
/// How long the door waits for the box to apply a feed before it answers "accepted, applying".
pub const APPLY_WAIT: std::time::Duration = std::time::Duration::from_secs(15);

/// The door feed file's sha256 as it is now (hex; an absent file is the empty text's).
pub fn door_feed_digest(root: &Path) -> String {
    monetize_server::guests::config_digest(&std::fs::read(root.join(DOOR_FEED_REL)).unwrap_or_default())
}

/// PID 1's word on the door feed, written only when it moved.
fn say_taken(root: &Path, line: &str) {
    let p = root.join(DOOR_TAKEN_REL);
    if std::fs::read_to_string(&p).map(|t| t.trim() == line).unwrap_or(false) {
        return;
    }
    if let Err(e) = monetize_front::write_secret(root, DOOR_TAKEN_REL, line) {
        tracing::warn!("solo: {DOOR_TAKEN_REL}: {e}");
    }
}

/// Where a put feed stands ([`applied`]).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Applied {
    /// PID 1 took it and the server applied the config written for it.
    Applied,
    /// PID 1 did NOT take it (monetize serves what it had): why.
    Refused(String),
    /// Not applied yet: what is still awaited.
    Applying(String),
}

/// **Where the door feed whose sha256 is `door` stands now** (R34-5): taken by PID 1
/// ([`DOOR_TAKEN_REL`]) and the config it wrote applied by the server
/// (`monetize_server::guests::applied_path`), refused, or still applying.
pub fn applied(root: &Path, door: &str) -> Applied {
    let taken = std::fs::read_to_string(root.join(DOOR_TAKEN_REL)).unwrap_or_default();
    let taken = taken.trim();
    if let Some(why) = taken.strip_prefix(door).and_then(|r| r.strip_prefix(" NOT taken: ")) {
        return Applied::Refused(why.to_string());
    }
    if taken != door {
        return Applied::Applying("PID 1 has not taken it yet (it looks every two seconds)".into());
    }
    let config = root.join(intake::CONFIG_REL);
    let now = monetize_server::guests::config_digest(&std::fs::read(&config).unwrap_or_default());
    let done = std::fs::read_to_string(monetize_server::guests::applied_path(&config)).unwrap_or_default();
    if done.trim() == now {
        Applied::Applied
    } else {
        Applied::Applying("taken; monetize-server has not applied its config yet (it looks every two seconds)".into())
    }
}

/// [`applied`], waited for up to `within`: the answer once it is applied or refused, else
/// the last "applying". A box whose PID 1 says nothing of the feeds it takes (no
/// [`DOOR_TAKEN_REL`] at all) is not waited for.
pub async fn wait_applied(root: &Path, door: &str, within: std::time::Duration) -> Applied {
    if !root.join(DOOR_TAKEN_REL).exists() {
        return Applied::Applying("this box's PID 1 says nothing yet of the feeds it takes".into());
    }
    let until = std::time::Instant::now() + within;
    loop {
        let now = applied(root, door);
        if !matches!(now, Applied::Applying(_)) || std::time::Instant::now() >= until {
            return now;
        }
        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
    }
}

/// How often PID 1 looks at the feed again.
pub const LOOK_EVERY: std::time::Duration = std::time::Duration::from_secs(2);

/// The keys whose change monetize-server applies only at its next start (a
/// product's prices; the guest blocks, vendors and clouds it applies live).
pub fn needs_restart(before: &BTreeMap<String, String>, after: &BTreeMap<String, String>) -> bool {
    let prices = |f: &BTreeMap<String, String>| f.iter().filter(|(k, _)| k.starts_with(intake::FEED_PLUGIN_PREFIX) && k.ends_with(".prices")).map(|(k, v)| (k.clone(), v.clone())).collect::<BTreeMap<_, _>>();
    prices(before) != prices(after)
}

/// **PID 1's loop for the life of the box**: every [`LOOK_EVERY`], the feed in
/// force (`feed()`, `None` until the installer fed the box) is judged when it
/// moved and taken when monetize would start on it ([`judge`], then
/// `intake::apply_pull`); the console's settings are written from it; the
/// server is started once its config is there, the console once its settings
/// are, each kept running ([`crate::supervise`]). A changed console setting
/// restarts the console; a changed price restarts the server.
pub async fn keep(root: PathBuf, estate: String, feed: std::sync::Arc<dyn Fn() -> Option<BTreeMap<String, String>> + Send + Sync>) {
    use crate::supervise::Supervisor;
    let mut taken: Option<BTreeMap<String, String>> = None;
    let mut said: Vec<String> = Vec::new();
    let mut say = |l: String| {
        if !said.contains(&l) {
            tracing::error!("solo: {l}");
            said.push(l);
        }
    };
    let (mut server, mut ui): (Option<Supervisor>, Option<Supervisor>) = (None, None);
    // The product plugins the server child links (the same binary): fed with no module.
    let linked = crate::linked().product_names();
    loop {
        // M-FOLLOW 7: a feed staged for this app becomes the door feed once it runs confirmed.
        if let Some((app, on_trial)) = crate::self_update::running_trial() {
            if let Some(line) = promote_next_feed(&root, &app, on_trial) {
                tracing::warn!("solo: {line}");
            }
        }
        // R34-5: the door feed as read before the feed in force is: never newer than what is taken.
        let door = door_feed_digest(&root);
        if let Some(f) = feed() {
            if taken.as_ref() == Some(&f) {
                say_taken(&root, &door);
            } else {
                let (r, g, l) = (root.clone(), f.clone(), linked.clone());
                let judged = tokio::task::spawn_blocking(move || judge_linked(&r, &g, &l).and_then(|ps| intake::apply_pull_linked(&r, &g, &l).map_err(|e| format!("{e:#}")).map(|_| ps))).await;
                match judged {
                    Ok(Ok(products)) => {
                        tracing::info!(products = ?products, keys = f.len(), "solo: the feed is taken");
                        if let (Some(s), Some(before)) = (&server, &taken) {
                            if needs_restart(before, &f) {
                                tracing::info!("solo: a price moved — monetize-server starts again to serve it");
                                s.restart();
                            }
                        }
                        taken = Some(f.clone());
                        say_taken(&root, &door);
                    }
                    Ok(Err(e)) => {
                        say_taken(&root, &format!("{door} NOT taken: {}", e.lines().next().unwrap_or_default()));
                        say(format!("the feed is NOT taken (monetize serves what it had): {e}"))
                    }
                    Err(e) => say(format!("the judge did not finish: {e}")),
                }
            }
            let (settings, refused) = UiSettings::of(&f, &estate);
            for l in refused {
                say(l);
            }
            match settings.write(&root) {
                Ok(true) => {
                    if let Some(u) = &ui {
                        tracing::info!(rp_id = %settings.rp_id, "solo: the console's settings moved — it starts again");
                        u.restart();
                    }
                }
                Ok(false) => {}
                Err(e) => say(format!("{UI_SETTINGS_REL}: {e}")),
            }
        }
        if server.is_none() && root.join(intake::CONFIG_REL).exists() {
            // A32-14, stays a task: PID 1's supervisor of its own child; its handle is kept and stopped with PID 1.
            server = Some(Supervisor::spawn("server", me(), server_args(&root)));
        }
        if ui.is_none() && server.is_some() && UiSettings::read(&root).is_some() {
            // A32-14, stays a task: PID 1's supervisor of its own child; its handle is kept and stopped with PID 1.
            ui = Some(Supervisor::spawn("ui", me(), ui_args(&root)));
        }
        tokio::time::sleep(LOOK_EVERY).await;
    }
}

/// This binary, to start the children with.
pub fn me() -> PathBuf {
    exe_for_children(std::env::current_exe().ok())
}

/// Pure: the path the children are started from. A binary run from RAM (a
/// memfd, as korp-installer starts the appliance) has no path on disk;
/// `/proc/self/exe` execs the same image.
pub fn exe_for_children(current: Option<PathBuf>) -> PathBuf {
    current.filter(|p| p.is_file()).unwrap_or_else(|| PathBuf::from("/proc/self/exe"))
}

#[cfg(test)]
mod tests {
    use super::*;

    /// ★ R34-5: a door feed stands applied only when PID 1 named it taken AND the server named
    /// the config written for it applied; PID 1's refusal is said as one; else it is applying.
    #[test]
    fn a_door_feed_is_applied_only_when_taken_and_its_config_applied() {
        let d = tempfile::tempdir().unwrap();
        let root = d.path();
        write_door_feed(root, &BTreeMap::from([("a".to_string(), "b".to_string())])).unwrap();
        let door = door_feed_digest(root);
        assert!(matches!(applied(root, &door), Applied::Applying(_)), "nothing taken yet");
        say_taken(root, &format!("{door} NOT taken: REFUSED [x] no"));
        assert_eq!(applied(root, &door), Applied::Refused("REFUSED [x] no".into()));
        say_taken(root, &door);
        let config = root.join(intake::CONFIG_REL);
        std::fs::create_dir_all(config.parent().unwrap()).unwrap();
        std::fs::write(&config, "# new\n").unwrap();
        assert!(matches!(applied(root, &door), Applied::Applying(w) if w.contains("monetize-server")), "taken, its config not applied yet");
        std::fs::write(monetize_server::guests::applied_path(&config), monetize_server::guests::config_digest(b"# new\n")).unwrap();
        assert_eq!(applied(root, &door), Applied::Applied);
        assert!(matches!(applied(root, &"0".repeat(64)), Applied::Applying(_)), "another feed than the one taken is not applied");
    }

    /// **On the appliance monetize-admin runs from RAM** (korp-installer
    /// `fexecve`s a memfd): `current_exe()` answers `/memfd:monetize-admin
    /// (deleted)`, a path that exists nowhere. MEASURED 2026-10-10 on the
    /// bench: `supervise: cannot start /memfd:monetize-admin (deleted)
    /// child="server" … No such file or directory`, every second, and the box
    /// served nothing. `/proc/self/exe` still execs the same image.
    #[test]
    fn the_children_start_from_proc_self_exe_when_this_binary_is_in_ram() {
        assert_eq!(exe_for_children(Some(PathBuf::from("/memfd:monetize-admin (deleted)"))), PathBuf::from("/proc/self/exe"));
        assert_eq!(exe_for_children(None), PathBuf::from("/proc/self/exe"));
        let here = std::env::current_exe().unwrap();
        assert_eq!(exe_for_children(Some(here.clone())), here, "an executable on disk is started by its path");
    }

    #[test]
    fn a_weak_token_is_named_and_a_strong_one_is_not() {
        assert!(weak_token("short").is_some());
        assert!(weak_token(&"ab".repeat(20)).is_some());
        assert!(weak_token("Zq3v8pL0xN6wT2yR9kB4mH7sJ1cF5dGa").is_none());
    }

    #[test]
    fn only_a_moved_price_starts_the_server_again() {
        let f = |pairs: &[(&str, &str)]| pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect::<BTreeMap<_, _>>();
        let a = f(&[("plugin.gunnar.prices", "[SE]\ndisk_gib_month = 1\n"), ("vendor.stripe.gunnar.toml", "enabled = true\n")]);
        assert!(!needs_restart(&a, &f(&[("plugin.gunnar.prices", "[SE]\ndisk_gib_month = 1\n"), ("vendor.stripe.gunnar.toml", "enabled = false\n")])), "a vendor is applied live");
        assert!(needs_restart(&a, &f(&[("plugin.gunnar.prices", "[SE]\ndisk_gib_month = 2\n")])));
    }

    #[test]
    fn with_no_web_host_the_relying_party_is_the_estates_name() {
        let (s, refused) = UiSettings::of(&BTreeMap::new(), "monetize");
        assert!(refused.is_empty());
        assert_eq!(s, UiSettings { rp_id: "monetize".into(), admin_token: None });
    }

    /// ★ **M-FOLLOW 7: the product gap made zero — the next release's feed is staged BEFORE the
    /// update** (prod 2026-10-26: the new app on the old feed refused both product guests for
    /// 72 s, until the new feed came). Staged for the next app (`PUT /feed/next`), it is never
    /// applied by the app that runs; that next app takes it at its FIRST start, before anything
    /// serves (no gap); the door feed file stays as it was through the trial, so a korp-start
    /// rollback finds the old app's own feed untouched; once that app runs confirmed the staged
    /// feed becomes the door feed. A feed staged for another app is never taken.
    #[test]
    fn the_next_feed_is_staged_before_the_update_and_taken_by_that_app_at_its_first_start() {
        let d = tempfile::tempdir().unwrap();
        let root = d.path();
        let (old, new) = ("0".repeat(64), "1".repeat(64));
        let fed = BTreeMap::from([("web_hosts".to_string(), "box.example".to_string())]);
        let old_feed = BTreeMap::from([("plugin.gunnar.toml".to_string(), "grants = [\"grpc\"]".to_string())]);
        let new_feed = BTreeMap::from([("plugin.gunnar.toml".to_string(), "grants = []".to_string())]);
        write_door_feed(root, &old_feed).unwrap();
        stage_next_feed(root, &new, &new_feed).unwrap();
        assert_eq!(read_next_feed(root), Some(StagedFeed { for_app: new.clone(), feed: new_feed.clone() }));
        // The app that runs keeps serving its own feed.
        assert_eq!(feed_now_for(root, &fed, &old)["plugin.gunnar.toml"], old_feed["plugin.gunnar.toml"]);
        // The next app, at its first start (on trial): the staged feed, over the installer's.
        let at_start = feed_now_for(root, &fed, &new);
        assert_eq!((at_start["plugin.gunnar.toml"].as_str(), at_start["web_hosts"].as_str()), (new_feed["plugin.gunnar.toml"].as_str(), "box.example"));
        // On trial nothing is promoted: a rollback finds the old feed untouched.
        assert_eq!(promote_next_feed(root, &new, true), None);
        assert_eq!(read_door_feed(root), old_feed, "the door feed through the trial is the old app's");
        // korp-start rolled back: the old app runs again and never takes the other app's feed.
        assert_eq!(promote_next_feed(root, &old, false), None);
        assert_eq!(feed_now_for(root, &fed, &old)["plugin.gunnar.toml"], old_feed["plugin.gunnar.toml"]);
        // Confirmed on the next app: the staged feed becomes the door feed, once.
        let line = promote_next_feed(root, &new, false).expect("promoted at the confirm");
        assert!(line.contains(&new[..12]), "{line}");
        assert_eq!(read_door_feed(root), new_feed);
        assert_eq!(read_next_feed(root), None, "taken once");
        assert_eq!(promote_next_feed(root, &new, false), None);
        assert_eq!(feed_now_for(root, &fed, &new)["plugin.gunnar.toml"], new_feed["plugin.gunnar.toml"]);
    }
}