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

nordisk/Skidbladnir

src/promote/registry.rs

//! The **slot registry** — the runtime view of a [`Ladder`]: resolve an
//! identifier to a slot, keep the production fence un-lowerable, and hand a
//! viewer the whole graph as data.
//!
//! The *format* lives in [`schema`](super::schema); this module is what the guard
//! and the promoter ask questions of.
//!
//! # The demotion hole, and why it cannot exist here
//!
//! A guard written as *"if this target is not marked production, allow it"* is
//! not lowered by a config file — it is **deleted** by one. Drop the production
//! entry, or flip its `guarded` to `false`, and every destructive verb silently
//! becomes legal against production. Nothing fails; nothing warns; the fence is
//! simply not there any more.
//!
//! [`SlotRegistry::load_over`] closes that by construction. Config is layered
//! **over** a baseline the caller declared in code, and the merge is
//! one-directional:
//!
//! * config may **raise** a slot to guarded — always allowed;
//! * config may **not lower** a guarded baseline slot, and may not make one
//!   disappear by omission. Either the load is refused
//!   ([`DemotionPolicy::Refuse`], the default) or it succeeds with the slot
//!   **still fenced** and the attempt recorded in [`SlotRegistry::demotions`].
//!
//! There is deliberately **no** third policy. "Let the config win" is the hole;
//! offering it as an option is offering the hole.
//!
//! # One slot has many names
//!
//! A slot name, a namespace, each declared host, and any generation namespace
//! derived from name or namespace are all the *same* slot.
//! [`SlotRegistry::resolve`] matches across every one of them, which is what stops
//! an identifier from slipping past a fence that only knew about one of a slot's
//! names.

use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};

use super::schema::{Hop, Ladder, Slot, eq};
use super::topology::{Observations, Topology};

/// What the caller is pointing the tool at.
///
/// There is no `&str` overload anywhere in this module, and that is the point:
/// the tool this generalises accepted a bare `--slot` string into a field the
/// fence read as a *namespace*, so `--slot prod` cleared a fence keyed on
/// namespaces (`njord`) simply because no namespace is literally called `prod`.
/// Naming the **kind** of identifier at the call site makes that mistake
/// unspellable, and [`SlotRegistry::resolve`] then matches across every identity a
/// slot has regardless of which kind you handed it — so getting the kind wrong is
/// safe too.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetRef<'a> {
    /// A slot **name**: `prod`, `lab`.
    Slot(&'a str),
    /// A Kubernetes **namespace**: `njord`, `test1-20260721-1432`.
    Namespace(&'a str),
    /// A bare **host**: `g11.se`, `lab.g11.se`.
    Host(&'a str),
    /// A **URL or connection string**, possibly carrying credentials:
    /// `postgres://user:pw@db.g11.se:5432/app`. Only the host is looked at, and
    /// [`redact`] exists so the rest never reaches a log line.
    Url(&'a str),
}

impl TargetRef<'_> {
    /// The identifier to resolve — for [`TargetRef::Url`], its host.
    pub fn needle(&self) -> String {
        match self {
            TargetRef::Slot(s) | TargetRef::Namespace(s) | TargetRef::Host(s) => {
                s.trim().to_ascii_lowercase()
            }
            TargetRef::Url(u) => host_of(u),
        }
    }

    /// The kind, for refusal messages.
    pub fn kind(&self) -> &'static str {
        match self {
            TargetRef::Slot(_) => "slot",
            TargetRef::Namespace(_) => "namespace",
            TargetRef::Host(_) => "host",
            TargetRef::Url(_) => "url",
        }
    }

    /// The target as it is safe to print (a URL loses its credentials).
    pub fn shown(&self) -> String {
        match self {
            TargetRef::Url(u) => redact(u),
            other => other.needle(),
        }
    }
}

/// The lowercased host out of a URL, a `user:pass@host:port/…`, or a bare host.
pub fn host_of(s: &str) -> String {
    let after_scheme = s.trim().rsplit("://").next().unwrap_or(s);
    let after_userinfo = after_scheme.rsplit('@').next().unwrap_or(after_scheme);
    after_userinfo
        .chars()
        .take_while(|c| !matches!(c, '/' | ':' | '?' | '#'))
        .collect::<String>()
        .to_ascii_lowercase()
}

/// Strip `user:pass@` out of a URL so a refusal can name the target without
/// leaking the credential that reached it.
pub fn redact(url: &str) -> String {
    match (url.find("://"), url.rfind('@')) {
        (Some(s), Some(at)) if at > s + 3 => format!("{}***@{}", &url[..s + 3], &url[at + 1..]),
        _ => url.to_string(),
    }
}

/// What [`SlotRegistry::resolve`] found.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Resolution<'a> {
    /// The identifier names this slot (by name, namespace, host or generation).
    Slot(&'a Slot),
    /// Nothing in the registry claims this identifier. **Not** a licence to
    /// proceed — see [`UnknownPolicy`](super::UnknownPolicy).
    Unknown,
}

impl Resolution<'_> {
    pub fn slot(&self) -> Option<&Slot> {
        match self {
            Resolution::Slot(s) => Some(s),
            Resolution::Unknown => None,
        }
    }
    /// Is the resolved slot behind the fence? An unresolved target is not
    /// "not guarded" — see the callers, which fence the unknown separately.
    pub fn is_guarded(&self) -> bool {
        self.slot().is_some_and(Slot::is_guarded)
    }
}

/// A guarded slot the configuration tried to lower or drop, and what the registry
/// did about it (which is: kept the fence up).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Demotion {
    /// The baseline slot's name.
    pub slot: String,
    /// Its namespace, so a message can be precise about what stayed fenced.
    pub namespace: String,
    /// What the config did: `lowered` (present, `guarded` flipped) or `dropped`
    /// (absent entirely).
    pub how: String,
    /// The name the config used for the colliding entry, when there was one.
    pub config_named: Option<String>,
}

impl Demotion {
    /// A single line fit for an operator's terminal.
    pub fn line(&self) -> String {
        format!(
            "⚠ config {} the guarded slot '{}' (namespace '{}') — IGNORED, it stays fenced{}",
            self.how,
            self.slot,
            self.namespace,
            match &self.config_named {
                Some(n) if !eq(n, &self.slot) => format!(" (config entry '{n}')"),
                _ => String::new(),
            }
        )
    }
}

/// What to do when configuration tries to lower or drop a guarded slot.
///
/// Note what is **absent**: there is no variant that lets the demotion take
/// effect. Both variants keep the slot fenced; they differ only in whether the
/// load fails or merely shouts.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DemotionPolicy {
    /// Refuse the load. The default, and the right answer for a deploy tool: a
    /// config that disagrees with the baseline about what production is has a bug
    /// in it, and running with it is running blind.
    #[default]
    Refuse,
    /// Load anyway, keep every baseline guarded slot fenced, and record the
    /// attempt in [`SlotRegistry::demotions`] — which every refusal touching that
    /// slot then prints.
    ReportAndKeepFenced,
}

/// Every slot the tool knows about, plus what configuration tried and failed to
/// do to the fence.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SlotRegistry {
    ladder: Ladder,
    demotions: Vec<Demotion>,
}

impl SlotRegistry {
    /// A registry over an already-validated ladder — the **baseline** a caller
    /// declares in code and layers configuration over.
    pub fn new(ladder: Ladder) -> Self {
        SlotRegistry {
            ladder,
            demotions: Vec::new(),
        }
    }

    /// Build from slots declared in code (validated as a ladder).
    pub fn from_slots(slots: Vec<Slot>) -> Result<Self> {
        Ok(SlotRegistry::new(Ladder::from_slots(slots)?))
    }

    /// **The load a fence is built on.** Layer `config` over `baseline`, allowing
    /// the config to add slots and to *raise* a slot to guarded, but never to
    /// lower or drop one the baseline declared guarded.
    ///
    /// A baseline guarded slot is matched against the config by any identity
    /// ([`Slot::matches`]), so renaming the entry does not evade the check either.
    /// If the config's colliding entry is unguarded, or if no config entry claims
    /// the slot at all, that is a [`Demotion`]: under [`DemotionPolicy::Refuse`]
    /// the load fails; under [`DemotionPolicy::ReportAndKeepFenced`] the slot is
    /// kept — fenced — and the attempt is recorded.
    ///
    /// The merged ladder is re-validated, so a config that raises a slot into an
    /// ambiguous state (`role: production`, `guarded: false`) still cannot load.
    pub fn load_over(
        baseline: &SlotRegistry,
        config: Ladder,
        policy: DemotionPolicy,
    ) -> Result<Self> {
        let mut slots = config.slots;
        let meta = config.meta;
        let mut demotions = Vec::new();

        for base in baseline.ladder.slots.iter().filter(|s| s.is_guarded()) {
            let idx = slots.iter().position(|c| {
                c.matches(&base.name)
                    || c.matches(&base.namespace)
                    || base.hosts.iter().any(|h| c.matches(h))
                    || base.matches(&c.name)
                    || base.matches(&c.namespace)
            });
            match idx {
                Some(i) if slots[i].is_guarded() => {} // config agrees — nothing to do
                Some(i) => {
                    demotions.push(Demotion {
                        slot: base.name.clone(),
                        namespace: base.namespace.clone(),
                        how: "lowered".into(),
                        config_named: Some(slots[i].name.clone()),
                    });
                    // The fence goes back up on the config's OWN entry, so its
                    // namespace/hosts/edges (which may be the newer truth) survive.
                    slots[i].guarded = true;
                }
                None => {
                    demotions.push(Demotion {
                        slot: base.name.clone(),
                        namespace: base.namespace.clone(),
                        how: "dropped".into(),
                        config_named: None,
                    });
                    // Re-inserted without its edges: the config owns the ladder's
                    // shape, and inventing edges into slots it never declared would
                    // be the registry making up a topology. It is restored as a
                    // fenced, unlinked rung — visible, guarded, and obviously wrong
                    // in the topology view, which is exactly what it is.
                    let mut restored = base.clone();
                    restored.promotes_to.clear();
                    slots.push(restored);
                }
            }
        }

        if !demotions.is_empty() && policy == DemotionPolicy::Refuse {
            let lines: Vec<String> = demotions.iter().map(Demotion::line).collect();
            bail!(
                "refusing this slot configuration: it tries to lower the production fence.\n{}\n\
                 A configuration file may RAISE a slot to guarded, never lower one.\n\
                 Fix the config, or load it with DemotionPolicy::ReportAndKeepFenced — which \
                 keeps every one of these slots fenced anyway and prints the warning above.",
                lines.join("\n")
            );
        }

        let ladder = Ladder {
            api_version: config.api_version,
            meta,
            slots,
        };
        ladder.validate("<merged ladder>")?;
        Ok(SlotRegistry { ladder, demotions })
    }

    /// The validated ladder behind this registry.
    pub fn ladder(&self) -> &Ladder {
        &self.ladder
    }

    /// Every slot, in declaration order.
    pub fn slots(&self) -> &[Slot] {
        &self.ladder.slots
    }

    /// Guarded slots the configuration tried to lower or drop. Empty in the
    /// healthy case. Never empty *and* honoured: a recorded demotion is one that
    /// did **not** take effect.
    pub fn demotions(&self) -> &[Demotion] {
        &self.demotions
    }

    /// The guarded slots, as the fence sees them.
    pub fn guarded(&self) -> Vec<&Slot> {
        self.ladder.guarded()
    }

    /// Resolve an identifier across **every** identity every slot has.
    ///
    /// Two passes, and the order is the whole safety argument:
    ///
    /// 1. **Exact identities** — name, namespace, generation namespace, declared
    ///    host. Guarded slots are consulted first, so an ambiguous identifier reads
    ///    as fenced.
    /// 2. **Declared zones** ([`Slot::matches_zone`]) — only once every exact
    ///    identity has missed, again guarded-first.
    ///
    /// Pass 1 outranking pass 2 is what lets a production apex own `*.example.test`
    /// without swallowing a sibling that named `lab.example.test` for itself: an
    /// explicit claim always beats someone else's wildcard. And pass 2 existing at
    /// all is what stops prod's unlisted data plane (`db.example.test`) from
    /// resolving to [`Resolution::Unknown`] — which
    /// [`Guard::allow_unknown`](super::guard::Guard::allow_unknown) would wave
    /// through.
    pub fn resolve(&self, target: &TargetRef<'_>) -> Resolution<'_> {
        let needle = target.needle();
        if needle.is_empty() {
            return Resolution::Unknown;
        }
        // Guarded first: if an identifier is ambiguous between a fenced slot and a
        // free one, the fenced reading wins. Ambiguity must fail safe.
        if let Some(s) = self
            .ladder
            .slots
            .iter()
            .find(|s| s.is_guarded() && s.matches(&needle))
        {
            return Resolution::Slot(s);
        }
        if let Some(s) = self.ladder.slots.iter().find(|s| s.matches(&needle)) {
            return Resolution::Slot(s);
        }
        // Nobody claimed it by name. Now — and only now — honour declared zone
        // ownership, still fenced-first.
        if let Some(s) = self
            .ladder
            .slots
            .iter()
            .find(|s| s.is_guarded() && s.matches_zone(&needle))
        {
            return Resolution::Slot(s);
        }
        match self.ladder.slots.iter().find(|s| s.matches_zone(&needle)) {
            Some(s) => Resolution::Slot(s),
            None => Resolution::Unknown,
        }
    }

    /// Every identifier an operator could legitimately have meant — used to turn a
    /// refusal into a next step rather than a dead end.
    pub fn known_identifiers(&self) -> Vec<String> {
        let mut v: Vec<String> = Vec::new();
        for s in &self.ladder.slots {
            v.push(s.name.clone());
            if !eq(&s.name, &s.namespace) {
                v.push(s.namespace.clone());
            }
            // Exact hosts only — a `*.zone` entry is ownership, not something an
            // operator could have typed, so suggesting it back is noise.
            v.extend(s.exact_hosts().map(|h| h.to_string()));
        }
        v.sort();
        v.dedup();
        v
    }

    /// Is promoting **code** from `from` to `to` a legal hop? Delegates to the
    /// ladder; see [`Hop`].
    pub fn check_promotion(&self, from: &str, to: &str) -> Hop {
        self.ladder.check_promotion(from, to)
    }

    /// Is copying **data** from `from` to `to` legal? Data flows down.
    pub fn check_data_flow(&self, from: &str, to: &str) -> Hop {
        self.ladder.check_data_flow(from, to)
    }

    /// **The whole ladder as data**, for a viewer, an RPC or a test.
    ///
    /// Nodes, edges, computed ranks, each slot's declared role/guard/meta, and a
    /// per-rung status taken from `observations` — never inferred. A rung nobody
    /// observed reports [`RungState::NeverRun`](super::topology::RungState::NeverRun),
    /// not green. Any configuration that tried to lower the fence rides along in
    /// [`Topology::notes`].
    pub fn topology(&self, observations: &Observations) -> Topology {
        Topology::build(&self.ladder, observations, &self.demotions)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    #[cfg(feature = "promote")]
    use crate::promote::schema::EXAMPLE_LADDER;
    use crate::promote::schema::SlotRole;

    fn baseline() -> SlotRegistry {
        SlotRegistry::from_slots(vec![
            Slot::new("prod", "njord", SlotRole::Production, true)
                .host("g11.se")
                .host("www.g11.se"),
            Slot::new("preprod", "njord-lab", SlotRole::PreProduction, false)
                .host("lab.g11.se")
                .promotes_to("prod"),
            Slot::new("test1", "test1", SlotRole::Test, false)
                .host("test1.g11.se")
                .promotes_to("preprod"),
        ])
        .unwrap()
    }

    /// Every overlay fixture in this module is a YAML parse, which is the ONE thing
    /// the `promote` feature actually buys (`schema.rs`'s `from_yaml`). The tests
    /// that reach it are dependency-bound and stay gated; the resolution tests below
    /// build their registry from `from_slots` and are not.
    #[cfg(feature = "promote")]
    fn cfg(yaml: &str) -> Ladder {
        Ladder::from_yaml("test-config.yaml", yaml).unwrap()
    }

    #[test]
    fn a_slot_answers_to_every_one_of_its_names() {
        let r = baseline();
        for n in [
            "prod",
            "njord",
            "g11.se",
            "www.g11.se",
            "njord-20260722-1730",
        ] {
            assert!(
                r.resolve(&TargetRef::Slot(n)).is_guarded(),
                "'{n}' must resolve to prod"
            );
        }
    }

    /// The exact hole this registry exists to close: a fence keyed only on the
    /// namespace waves `--slot prod` through, because no namespace is called
    /// `prod`.
    #[test]
    fn the_slot_name_of_production_is_not_a_bypass() {
        let r = baseline();
        assert!(r.resolve(&TargetRef::Slot("prod")).is_guarded());
        assert!(
            r.resolve(&TargetRef::Namespace("prod")).is_guarded(),
            "kind must not matter"
        );
    }

    #[test]
    fn exact_tokens_only_no_prefix_bleed() {
        let r = SlotRegistry::from_slots(vec![
            Slot::new("test1", "test1", SlotRole::Production, true).host("test1.g11.se"),
            Slot::new("test10", "test10", SlotRole::Test, false).host("test10.g11.se"),
        ])
        .unwrap();
        assert!(!r.resolve(&TargetRef::Namespace("test10")).is_guarded());
        assert!(r.resolve(&TargetRef::Namespace("test1")).is_guarded());
    }

    #[test]
    fn subdomains_are_their_own_slots_but_www_folds_toward_the_fence() {
        let r = baseline();
        assert!(r.resolve(&TargetRef::Host("www.g11.se")).is_guarded());
        assert!(
            r.resolve(&TargetRef::Host("WWW.G11.SE")).is_guarded(),
            "case-insensitive"
        );
        assert!(!r.resolve(&TargetRef::Host("lab.g11.se")).is_guarded());
        assert!(!r.resolve(&TargetRef::Host("test1.g11.se")).is_guarded());
    }

    #[test]
    fn urls_resolve_by_host_and_print_redacted() {
        let r = baseline();
        assert!(
            r.resolve(&TargetRef::Url("postgres://u:pw@g11.se:5432/app"))
                .is_guarded()
        );
        assert!(
            !r.resolve(&TargetRef::Url("postgres://u:pw@test1-postgresql:5432/app"))
                .is_guarded()
        );
        assert_eq!(
            TargetRef::Url("postgres://u:secret@db.g11.se/app").shown(),
            "postgres://***@db.g11.se/app"
        );
    }

    #[test]
    fn unrelated_identifiers_resolve_to_unknown() {
        assert_eq!(
            baseline().resolve(&TargetRef::Namespace("something-else")),
            Resolution::Unknown
        );
    }

    // ── the demotion hole ────────────────────────────────────────────────────

    #[cfg(feature = "promote")]
    const LYING: &str = "apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n\
        - {name: prod, namespace: njord, hosts: [g11.se], role: test, guarded: false}\n\
        - {name: preprod, namespace: njord-lab, hosts: [lab.g11.se], role: pre-production, guarded: false, promotes_to: [prod]}\n";

    #[test]
    #[cfg(feature = "promote")]
    fn a_config_that_lowers_the_fence_is_refused() {
        let err = SlotRegistry::load_over(&baseline(), cfg(LYING), DemotionPolicy::Refuse)
            .expect_err("a config that lowers the fence must not load");
        let msg = format!("{err:#}");
        assert!(
            msg.contains("lowered"),
            "the refusal must name what happened: {msg}"
        );
        assert!(msg.contains("prod"), "…and which slot: {msg}");
    }

    #[test]
    #[cfg(feature = "promote")]
    fn a_config_that_lowers_the_fence_cannot_disarm_it_even_when_tolerated() {
        let r =
            SlotRegistry::load_over(&baseline(), cfg(LYING), DemotionPolicy::ReportAndKeepFenced)
                .expect("tolerated");
        assert!(
            r.resolve(&TargetRef::Namespace("njord")).is_guarded(),
            "the config said unguarded; the fence must stay up regardless"
        );
        assert_eq!(r.demotions().len(), 1);
        assert!(r.demotions()[0].line().contains("IGNORED"));
    }

    /// Deleting the entry is the same attack as lowering it — and against a guard
    /// that returns early for "not marked production" it is the *more* effective
    /// one, because there is nothing left to disagree with.
    #[test]
    #[cfg(feature = "promote")]
    fn a_config_that_drops_the_guarded_slot_cannot_disarm_the_fence() {
        let dropped = "apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n\
            - {name: preprod, namespace: njord-lab, hosts: [lab.g11.se], role: pre-production, guarded: false}\n";
        let err = SlotRegistry::load_over(&baseline(), cfg(dropped), DemotionPolicy::Refuse)
            .expect_err("dropping the guarded slot must not load silently");
        assert!(format!("{err:#}").contains("dropped"), "{err:#}");

        let r = SlotRegistry::load_over(
            &baseline(),
            cfg(dropped),
            DemotionPolicy::ReportAndKeepFenced,
        )
        .unwrap();
        assert!(
            r.resolve(&TargetRef::Namespace("njord")).is_guarded(),
            "restored, fenced"
        );
    }

    /// Renaming the entry must not evade the collision check either.
    #[test]
    #[cfg(feature = "promote")]
    fn a_renamed_entry_is_still_the_same_guarded_slot() {
        let renamed = "apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n\
            - {name: produktion, namespace: njord, hosts: [g11.se], role: test, guarded: false}\n";
        let r = SlotRegistry::load_over(
            &baseline(),
            cfg(renamed),
            DemotionPolicy::ReportAndKeepFenced,
        )
        .unwrap();
        assert!(r.resolve(&TargetRef::Namespace("njord")).is_guarded());
        assert_eq!(r.demotions()[0].config_named.as_deref(), Some("produktion"));
    }

    #[test]
    #[cfg(feature = "promote")]
    fn a_config_may_raise_a_slot_to_guarded() {
        let raising = "apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n\
            - {name: prod, namespace: njord, hosts: [g11.se], role: production, guarded: true}\n\
            - {name: preprod, namespace: njord-lab, hosts: [lab.g11.se], role: pre-production, guarded: true, promotes_to: [prod]}\n";
        let r = SlotRegistry::load_over(&baseline(), cfg(raising), DemotionPolicy::Refuse).unwrap();
        assert!(r.resolve(&TargetRef::Slot("preprod")).is_guarded());
        assert!(r.demotions().is_empty());
    }

    /// The merge re-validates, so a config cannot smuggle in an ambiguous slot.
    #[test]
    #[cfg(feature = "promote")]
    fn a_merged_ladder_is_revalidated() {
        // Two config slots claiming one namespace: invalid on its own, and the
        // merge must not launder it.
        let bad = Ladder::from_yaml(
            "x",
            "apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n\
             - {name: prod, namespace: njord, hosts: [g11.se], role: production, guarded: true}\n",
        )
        .unwrap();
        // Baseline has a guarded slot on namespace `njord`; a config declaring a
        // DIFFERENT guarded slot that also claims `njord` would be caught here.
        let r = SlotRegistry::load_over(&baseline(), bad, DemotionPolicy::Refuse).unwrap();
        assert_eq!(
            r.slots().len(),
            1,
            "config owns the shape when it agrees about the fence"
        );
    }

    #[test]
    fn ambiguity_resolves_to_the_fenced_reading() {
        let r = SlotRegistry::from_slots(vec![
            Slot::new("free", "shared", SlotRole::Test, false).host("free.example"),
            Slot::new("prod", "apex", SlotRole::Production, true).host("shared"),
        ])
        .unwrap();
        assert!(r.resolve(&TargetRef::Namespace("shared")).is_guarded());
    }

    #[test]
    #[cfg(feature = "promote")]
    fn a_registry_answers_hop_questions_from_the_ladder() {
        let r = SlotRegistry::new(Ladder::from_yaml("x", EXAMPLE_LADDER).unwrap());
        assert!(r.check_promotion("staging", "live").is_legal());
        assert!(r.check_data_flow("live", "dev").is_legal());
        assert!(!r.check_data_flow("dev", "live").is_legal());
    }
}