nordisk-pairing 0.5.29

The boxes of an estate find each other: a seed each box is installed with, a one-time join against the front, a tunnel (WireGuard by default) they meet inside, roles decided once, a pairing loop each box runs itself, and the signal it reports. Shared by gunnar and holger; the laptop's xtask only lays machines, writes seeds and reads the signal. Design: .nornir/pairing-design.md (2026-09-24).
Documentation
//! **The weakest box becomes the twin — decided ONCE, on the front, from two
//! capacity reports.** The owner's ruling (2026-09-21) that the role never
//! changes later is kept: the front records the verdict with the numbers and
//! never re-decides; a box that joins again (a re-image) is handed the same
//! role. This module is the RULE; the front's ledger is where the verdict is
//! kept.

pub mod disktest;

use serde::{Deserialize, Serialize};
use std::net::Ipv4Addr;

/// What a box measured about itself at first boot.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Capacity {
    pub cores: u32,
    pub ram_bytes: u64,
    /// The data set's room.
    pub data_bytes: u64,
    /// The start-up disk test's throughput.
    pub disk_mib_s: u32,
}

impl Capacity {
    /// The order the comparison walks: fewer cores loses, then less RAM, then
    /// less data room, then a slower disk. A weaker box on ANY earlier axis is
    /// the weaker box; the later axes only break ties.
    fn key(&self) -> (u32, u64, u64, u32) {
        (self.cores, self.ram_bytes, self.data_bytes, self.disk_mib_s)
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Verdict {
    pub twin: String,
    pub primary: String,
    /// The sentence the front prints and keeps beside the numbers.
    pub why: String,
}

/// One report: name, overlay address, capacity.
pub type Report<'a> = (&'a str, Ipv4Addr, &'a Capacity);

/// **The election.** Weakest is twin; a perfect tie goes to the LOWER overlay
/// address, so the answer is a pure function of the two reports and both
/// boxes could compute it themselves and agree.
pub fn weakest(a: Report<'_>, b: Report<'_>) -> Verdict {
    let (an, ao, ac) = a;
    let (bn, bo, bc) = b;
    let (twin, primary, why) = match ac.key().cmp(&bc.key()) {
        std::cmp::Ordering::Less => (an, bn, format!("`{an}` is the weaker box ({}) against `{bn}` ({})", say(ac), say(bc))),
        std::cmp::Ordering::Greater => (bn, an, format!("`{bn}` is the weaker box ({}) against `{an}` ({})", say(bc), say(ac))),
        std::cmp::Ordering::Equal => {
            if ao <= bo {
                (an, bn, format!("`{an}` and `{bn}` measure the same ({}); the lower address {ao} is the twin", say(ac)))
            } else {
                (bn, an, format!("`{an}` and `{bn}` measure the same ({}); the lower address {bo} is the twin", say(bc)))
            }
        }
    };
    Verdict { twin: twin.to_string(), primary: primary.to_string(), why }
}

fn say(c: &Capacity) -> String {
    format!("{} cores, {} GiB RAM, {} GiB data, {} MiB/s disk", c.cores, c.ram_bytes >> 30, c.data_bytes >> 30, c.disk_mib_s)
}

/// Pure: **the role a box pulls and reports as** (A7 row 1; Gunnar b009cbcc, gunnar-server
/// `repair::pull_role`; moved here from monetize-embed `fleet::pull_role` so a community build
/// reads it without linking any monetize crate), asked again on every round, never fixed at
/// the start:
///
/// * `flag` — the medium's or the operator's own role (`--fleet-role`, an enrolment's
///   `role`), when it names one;
/// * `twin_root` — this box's own record says it is a twin (it became one after the start);
/// * `door` — the role the installer's door ELECTED (`joined.toml`): `None` on a box no door
///   paired, `Some(None)` while the door has not elected one yet, `Some(Some("twin"))`, or
///   `Some(Some(_))` for the primary.
///
/// `None`: not known yet. The box WAITS and reports nothing: a twin that reported as `main`
/// before its election would pin its key in main's window and close it.
pub fn pull_role(flag: &str, twin_root: bool, door: Option<Option<&str>>) -> Option<String> {
    if !flag.trim().is_empty() {
        return Some(flag.trim().to_owned());
    }
    if twin_root {
        return Some("twin".to_owned());
    }
    match door {
        // No door paired this box: the medium's own, a main.
        None => Some("main".to_owned()),
        // Fed, and the door has not said what this box is yet.
        Some(None) => None,
        Some(Some("twin")) => Some("twin".to_owned()),
        Some(Some(_)) => Some("main".to_owned()),
    }
}

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

    const BIG: Capacity = Capacity { cores: 4, ram_bytes: 8 << 30, data_bytes: 40 << 30, disk_mib_s: 900 };
    const SMALL: Capacity = Capacity { cores: 2, ram_bytes: 8 << 30, data_bytes: 40 << 30, disk_mib_s: 900 };

    #[test]
    fn the_weaker_box_is_the_twin_whichever_side_it_is_on() {
        let v = weakest(("main", crate::OVERLAY_BOX_A, &BIG), ("twin", crate::OVERLAY_BOX_B, &SMALL));
        assert_eq!((v.twin.as_str(), v.primary.as_str()), ("twin", "main"));
        let v = weakest(("main", crate::OVERLAY_BOX_A, &SMALL), ("twin", crate::OVERLAY_BOX_B, &BIG));
        assert_eq!((v.twin.as_str(), v.primary.as_str()), ("main", "twin"));
        assert!(v.why.contains("weaker box"), "{}", v.why);
    }

    #[test]
    fn a_tie_goes_to_the_lower_address_and_a_slower_disk_only_breaks_ties() {
        let v = weakest(("b", crate::OVERLAY_BOX_B, &BIG), ("a", crate::OVERLAY_BOX_A, &BIG));
        assert_eq!(v.twin, "a", "{}", v.why);
        let slow = Capacity { disk_mib_s: 100, ..BIG };
        let v = weakest(("a", crate::OVERLAY_BOX_A, &slow), ("b", crate::OVERLAY_BOX_B, &BIG));
        assert_eq!(v.twin, "a");
        // fewer cores beats a faster disk
        let few_fast = Capacity { cores: 2, disk_mib_s: 5000, ..BIG };
        let v = weakest(("a", crate::OVERLAY_BOX_A, &few_fast), ("b", crate::OVERLAY_BOX_B, &BIG));
        assert_eq!(v.twin, "a");
    }

    /// ★ A7 row 1 (moved from monetize-embed `fleet::pull_role`, Gunnar b009cbcc): a box
    /// elected twin AFTER its start pulls as the twin; one the door has not elected yet
    /// waits; the operator's flag and the box's own twin record win.
    #[test]
    fn the_role_is_the_flag_then_the_twin_record_then_the_doors_election() {
        assert_eq!(pull_role(" twin ", false, Some(Some("primary"))).as_deref(), Some("twin"));
        assert_eq!(pull_role("", true, Some(Some("primary"))).as_deref(), Some("twin"));
        assert_eq!(pull_role("", false, Some(None)), None, "fed and not elected: wait");
        assert_eq!(pull_role("", false, Some(Some("twin"))).as_deref(), Some("twin"));
        assert_eq!(pull_role("", false, Some(Some("primary"))).as_deref(), Some("main"));
        assert_eq!(pull_role("", false, None).as_deref(), Some("main"), "no door: the medium's main");
        assert_eq!(pull_role("  ", false, None).as_deref(), Some("main"), "a blank flag names no role");
    }
}