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

nordisk/edda

crates/nornir-saga/src/event.rs

//! [`SagaEvent`] — one row of the user's journey, and the [`EventKind`] that
//! tags it. Toolkit-neutral: an egui app and a web (Leptos + Axum) app emit the
//! *same* event shape; only the (deferred) capture adapter differs.
//!
//! The builders ([`SagaEvent::nav`], [`SagaEvent::click`], [`SagaEvent::log`],
//! [`SagaEvent::error`], [`SagaEvent::state`]) set the `kind` + the fields that
//! matter for that kind and leave the session/app identity (`session_id`,
//! `user`, `app`, `app_version`, `git_sha`) blank — a capture adapter fills those
//! once per session via [`SagaEvent::in_session`]. `seq`/`ts_micros` are stamped
//! by the recorder at [`crate::record`] time.

use serde::{Deserialize, Serialize};

/// What a [`SagaEvent`] represents. **Navigation is the primary event**; the
/// others ride the same pipe so a session is one ordered stream.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EventKind {
    /// A route / view change — the backbone of replay.
    Nav,
    /// A click / activation on a component.
    Click,
    /// A log line (carries `level` + `detail`).
    Log,
    /// An error (a `Log` whose level is error, surfaced distinctly for clustering).
    Error,
    /// A standalone component-state snapshot (no navigation, just `state_json`).
    State,
}

impl EventKind {
    /// Stable lowercase label (matches the warehouse `kind` column + serde repr).
    pub fn as_str(self) -> &'static str {
        match self {
            EventKind::Nav => "nav",
            EventKind::Click => "click",
            EventKind::Log => "log",
            EventKind::Error => "error",
            EventKind::State => "state",
        }
    }

    /// Parse a label back to a kind (case-insensitive). `None` if unknown.
    pub fn parse(s: &str) -> Option<EventKind> {
        match s.trim().to_ascii_lowercase().as_str() {
            "nav" => Some(EventKind::Nav),
            "click" => Some(EventKind::Click),
            "log" => Some(EventKind::Log),
            "error" => Some(EventKind::Error),
            "state" => Some(EventKind::State),
            _ => None,
        }
    }

    /// Is this a navigation step the matrix replays into a robot action?
    /// (`Nav` + `Click` are actionable; logs/errors/state are context.)
    pub fn is_actionable(self) -> bool {
        matches!(self, EventKind::Nav | EventKind::Click)
    }
}

/// One captured moment in a user's journey. Mirrors the nornir-warehouse
/// `saga_events` table one-to-one.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SagaEvent {
    /// Monotonic capture order within the process (stamped by the recorder).
    /// Replay sorts by this, so ordering is independent of wall-clock skew.
    pub seq: u64,
    /// Wall-clock microseconds since the Unix epoch (stamped by the recorder; `0`
    /// for an un-recorded builder result).
    pub ts_micros: u64,
    /// Session this event belongs to (filled by the capture adapter).
    pub session_id: String,
    /// The acting user (filled by the capture adapter; may be anonymous).
    pub user: String,
    /// What kind of event this is.
    pub kind: EventKind,
    /// The route / view at the time (e.g. `/offert/123` or `GraphView`).
    pub route: String,
    /// The component reporting (e.g. `OffertForm`, `facett-map`).
    pub component: String,
    /// The action taken (e.g. `submit`, `open`, `select_row`).
    pub action: String,
    /// A short human note / the log message.
    pub detail: String,
    /// The component's/route's readable state at this moment (JSON string).
    pub state_json: String,
    /// Log level for `Log`/`Error` events (`info`/`warn`/`error`); empty otherwise.
    pub level: String,
    /// Span id for causal grouping (filled by the capture adapter; optional).
    pub span_id: String,
    /// Parent span/event id (optional).
    pub parent: String,
    /// The application name (filled by the capture adapter, e.g. `njord`).
    pub app: String,
    /// The application version (filled by the capture adapter).
    pub app_version: String,
    /// The git sha the app was built from (filled by the capture adapter).
    pub git_sha: String,
}

impl SagaEvent {
    /// An empty event with the given kind — internal seed for the builders.
    fn of(kind: EventKind) -> SagaEvent {
        SagaEvent {
            seq: 0,
            ts_micros: 0,
            session_id: String::new(),
            user: String::new(),
            kind,
            route: String::new(),
            component: String::new(),
            action: String::new(),
            detail: String::new(),
            state_json: String::new(),
            level: String::new(),
            span_id: String::new(),
            parent: String::new(),
            app: String::new(),
            app_version: String::new(),
            git_sha: String::new(),
        }
    }

    /// A **navigation** event — the primary, replayable step.
    pub fn nav(route: &str, component: &str, action: &str, state_json: &str) -> SagaEvent {
        SagaEvent {
            route: route.to_string(),
            component: component.to_string(),
            action: action.to_string(),
            state_json: state_json.to_string(),
            ..SagaEvent::of(EventKind::Nav)
        }
    }

    /// A **click** / activation event — also replayable.
    pub fn click(route: &str, component: &str, action: &str, state_json: &str) -> SagaEvent {
        SagaEvent {
            route: route.to_string(),
            component: component.to_string(),
            action: action.to_string(),
            state_json: state_json.to_string(),
            ..SagaEvent::of(EventKind::Click)
        }
    }

    /// A **log** line.
    pub fn log(level: &str, detail: &str) -> SagaEvent {
        SagaEvent {
            level: level.to_string(),
            detail: detail.to_string(),
            ..SagaEvent::of(EventKind::Log)
        }
    }

    /// An **error** event (a log whose level is `error`, surfaced distinctly).
    pub fn error(component: &str, detail: &str) -> SagaEvent {
        SagaEvent {
            component: component.to_string(),
            detail: detail.to_string(),
            level: "error".to_string(),
            ..SagaEvent::of(EventKind::Error)
        }
    }

    /// A standalone component-**state** snapshot.
    pub fn state(component: &str, state_json: &str) -> SagaEvent {
        SagaEvent {
            component: component.to_string(),
            state_json: state_json.to_string(),
            ..SagaEvent::of(EventKind::State)
        }
    }

    /// Stamp the session/app identity onto this event. A capture adapter calls
    /// this once per event (cheaply, from a held [`SessionCtx`]) before
    /// [`crate::record`].
    pub fn in_session(mut self, ctx: &SessionCtx) -> SagaEvent {
        self.session_id = ctx.session_id.clone();
        self.user = ctx.user.clone();
        self.app = ctx.app.clone();
        self.app_version = ctx.app_version.clone();
        self.git_sha = ctx.git_sha.clone();
        self
    }
}

// ─── PII redaction (mask-by-default) ─────────────────────────────────────────

/// Mask-by-default **PII redaction** for [`SagaEvent`] free-text: emails, bearer/
/// token strings, and id-shaped substrings are replaced with a fixed marker before
/// an event is recorded or displayed, so a stored journey never carries a raw user
/// email or secret. Length-preserving-ish markers keep the shape readable.
///
/// Applied in the record path ([`crate::record`]) so events are masked by DEFAULT;
/// a field can be opted OUT via an allowlist (a field the app has judged safe, e.g.
/// a public route). Idempotent: masking an already-masked value is a no-op.
pub mod redact {
    /// The marker a redacted email/token/id is replaced with.
    pub const MASK: &str = "«redacted»";

    /// Mask every email, bearer/token, Swedish personnummer, and long id-shaped
    /// run in `value`. Returns
    /// the value unchanged when it contains no PII-shaped substring. Idempotent —
    /// [`MASK`] itself contains none of the shapes it looks for.
    ///
    /// A secret **label** (`Bearer`/`token`/`password`/…) also masks the value
    /// that immediately follows it, so the credential itself is scrubbed even
    /// when it is short and not id-shaped: `Bearer abc.def` and `password=hunter2`
    /// both mask the secret, not just the label word.
    pub fn mask(value: &str) -> String {
        let mut out = String::with_capacity(value.len());
        // When a secret label was just masked, the credential is the NEXT value
        // run — mask it too (separators in between are emitted verbatim and do
        // not consume the pending mask).
        let mut mask_next_value = false;
        for token in split_keep(value) {
            let is_value = token.chars().next().map(is_value_char).unwrap_or(false);
            if !is_value {
                out.push_str(token);
                continue;
            }
            if mask_next_value {
                out.push_str(MASK);
                mask_next_value = false;
                continue;
            }
            if is_pii(token) {
                out.push_str(MASK);
                // A secret LABEL carries its credential in the following value run.
                if is_secret_word(core_of(token)) {
                    mask_next_value = true;
                }
            } else {
                out.push_str(token);
            }
        }
        out
    }

    /// True when `s` looks like a bearer/`token=…`/`sk-…` secret carrier — masked
    /// as a whole run (so `Authorization: Bearer abc.def` masks the credential).
    fn is_secret_word(s: &str) -> bool {
        let low = s.to_ascii_lowercase();
        low.starts_with("bearer")
            || low.starts_with("token")
            || low.starts_with("sk-")
            || low.starts_with("ghp_")
            || low.starts_with("apikey")
            || low.starts_with("api_key")
            || low.starts_with("password")
            || low.starts_with("secret")
    }

    /// Strip surrounding punctuation from a token for the shape checks (the raw
    /// token is kept for masking so the whole run is replaced).
    fn core_of(token: &str) -> &str {
        token.trim_matches(|c: char| {
            !c.is_ascii_alphanumeric() && c != '@' && c != '.' && c != '-' && c != '_' && c != '+'
        })
    }

    /// Does this whitespace-delimited token carry PII we mask?
    fn is_pii(token: &str) -> bool {
        let core = core_of(token);
        if core.is_empty() {
            return false;
        }
        if is_email(core) {
            return true;
        }
        if is_secret_word(core) {
            return true;
        }
        if is_personnummer(core) {
            return true;
        }
        if is_organisationsnummer(core) {
            return true;
        }
        // Long opaque id-shaped run: >= 16 chars of hex/base64-ish with no spaces
        // and at least one digit (so ordinary long words aren't masked).
        if core.len() >= 16
            && core.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
            && core.chars().any(|c| c.is_ascii_digit())
            && core.chars().any(|c| c.is_ascii_alphabetic())
        {
            return true;
        }
        false
    }

    /// True when `s` is a Swedish personnummer or samordningsnummer.
    ///
    /// These need their own check because the id-shaped run above can never
    /// catch one: they are 10–12 characters and carry no letter, so they fail
    /// both the `len() >= 16` gate and the "has an alphabetic char" condition.
    /// They are also the most sensitive identifier the product handles, which
    /// is why they are worth a dedicated shape rather than a looser id rule.
    ///
    /// Accepts `YYMMDD-XXXX`, `YYMMDD+XXXX` (the bearer has turned 100),
    /// `YYYYMMDD-XXXX`, and the separator-less 10- and 12-digit forms.
    ///
    /// The Luhn checksum is what keeps this from masking every 10-digit number
    /// that flows past: an arbitrary digit run survives it only 1 time in 10,
    /// and it must carry a plausible month and day on top of that.
    fn is_personnummer(s: &str) -> bool {
        // `.` is a value char, so a personnummer ending a sentence arrives with
        // the period still attached.
        let s = s.trim_matches('.');
        let mut digits = [0u8; 12];
        let mut n = 0;
        let mut separators = 0;
        for (i, c) in s.char_indices() {
            match c {
                '0'..='9' => {
                    if n == 12 {
                        return false;
                    }
                    digits[n] = c as u8 - b'0';
                    n += 1;
                }
                // At most one separator, and only immediately before the
                // four-digit suffix — otherwise `2026-07-21` would parse.
                '-' | '+' if i > 0 && separators == 0 && s.len() - i == 5 => separators += 1,
                _ => return false,
            }
        }
        // The 12-digit form carries the century. Luhn and the date are defined
        // on the 10-digit core, so drop it.
        let core: &[u8] = match n {
            10 => &digits[..10],
            12 => &digits[2..],
            _ => return false,
        };
        let month = core[2] * 10 + core[3];
        let day = core[4] * 10 + core[5];
        if !(1..=12).contains(&month) {
            return false;
        }
        // A samordningsnummer is the birth day plus 60.
        if !(1..=31).contains(&day) && !(61..=91).contains(&day) {
            return false;
        }
        luhn_ok(core)
    }

    /// True when `s` is a Swedish **organisationsnummer** — the company-number
    /// counterpart to [`is_personnummer`], which structurally cannot catch one.
    ///
    /// The two share a format (`NNNNNN-NNNN`, Luhn over the first nine digits) and
    /// are told apart by the third and fourth digits: on a personnummer they are a
    /// birth MONTH (`01`–`12`), on an organisationsnummer they are a group number
    /// that is always **≥ 20**. So `is_personnummer`'s month check — the thing that
    /// stops it masking every 10-digit run — rejects every orgnr by construction.
    /// Without this function they flowed through unmasked.
    ///
    /// Worth masking even though a limited company's number is not itself personal
    /// data: for an **enskild firma** the organisationsnummer IS the owner's
    /// personnummer, so the two sets overlap on exactly the most sensitive case. (A
    /// sole trader's number has a real month and is already caught above; this
    /// covers the rest, where the number still identifies a counterparty in a
    /// recorded journey.)
    ///
    /// Accepts the `NNNNNN-NNNN` and bare 10-digit forms, plus the 12-digit form
    /// prefixed `16` (the only century prefix an orgnr takes).
    fn is_organisationsnummer(s: &str) -> bool {
        let s = s.trim_matches('.');
        let mut digits = [0u8; 12];
        let mut n = 0;
        let mut separators = 0;
        for (i, c) in s.char_indices() {
            match c {
                '0'..='9' => {
                    if n == 12 {
                        return false;
                    }
                    digits[n] = c as u8 - b'0';
                    n += 1;
                }
                // Same placement rule as a personnummer: one separator, and only
                // immediately before the four-digit suffix. `+` is personnummer-only
                // (it marks a bearer over 100), so an orgnr never carries it.
                '-' if i > 0 && separators == 0 && s.len() - i == 5 => separators += 1,
                _ => return false,
            }
        }
        let core: &[u8] = match n {
            10 => &digits[..10],
            // Only `16` prefixes a 12-digit organisationsnummer.
            12 if digits[0] == 1 && digits[1] == 6 => &digits[2..],
            _ => return false,
        };
        // The group-number digits — what separates an orgnr from a personnummer.
        if core[2] * 10 + core[3] < 20 {
            return false;
        }
        luhn_ok(core)
    }

    /// Luhn (mod-10) over the first nine digits, checked against the tenth — the
    /// control digit both Swedish number formats carry. Shared so the two shape
    /// checks cannot drift apart on the one part of the rule they agree on.
    fn luhn_ok(core: &[u8]) -> bool {
        let sum: u32 = core[..9]
            .iter()
            .enumerate()
            .map(|(i, &d)| {
                // Every other digit doubled, starting with the first.
                let p = u32::from(d) * if i % 2 == 0 { 2 } else { 1 };
                if p > 9 { p - 9 } else { p }
            })
            .sum();
        (10 - (sum % 10)) % 10 == u32::from(core[9])
    }

    /// A minimal `local@domain.tld` shape check (no full RFC 5322).
    fn is_email(s: &str) -> bool {
        let mut parts = s.splitn(2, '@');
        let (Some(local), Some(domain)) = (parts.next(), parts.next()) else {
            return false;
        };
        let domain = domain.trim_end_matches('.');
        !local.is_empty()
            && domain.contains('.')
            && domain.split('.').all(|seg| !seg.is_empty())
            && !domain.starts_with('.')
    }

    /// A char that can be part of a maskable value run (email/token/id): letters,
    /// digits, and the punctuation those shapes use internally. Everything else
    /// (whitespace, quotes, braces, colons, commas, slashes …) is a separator, so a
    /// value embedded in JSON — `"Bearer abc123…"` — still tokenizes out.
    fn is_value_char(c: char) -> bool {
        c.is_ascii_alphanumeric() || matches!(c, '@' | '.' | '-' | '_' | '+')
    }

    /// Split into alternating value-runs and separator-runs, preserving the
    /// separators verbatim so re-joining reproduces the original layout (only the
    /// value runs that are PII are swapped for [`MASK`]).
    fn split_keep(value: &str) -> Vec<&str> {
        let mut out = Vec::new();
        let mut start = 0;
        let mut cur = value.chars().next().map(is_value_char).unwrap_or(false);
        for (i, c) in value.char_indices() {
            if is_value_char(c) != cur {
                out.push(&value[start..i]);
                start = i;
                cur = is_value_char(c);
            }
        }
        if start < value.len() {
            out.push(&value[start..]);
        }
        out
    }
}

impl SagaEvent {
    /// Return a **PII-redacted copy** of this event: `detail`, `state_json`, and
    /// `user` are mask-by-default scrubbed via [`redact::mask`], EXCEPT any field
    /// name present in `allow` (an explicit opt-in allowlist over `"detail"`,
    /// `"state_json"`, `"user"`). Structural fields (`route`, `component`,
    /// `action`, `kind`, ids) are left intact — they are the journey's shape, not
    /// its payload. Idempotent.
    pub fn redacted_with_allowlist(&self, allow: &[&str]) -> SagaEvent {
        let allowed = |f: &str| allow.contains(&f);
        let mut e = self.clone();
        if !allowed("detail") {
            e.detail = redact::mask(&e.detail);
        }
        if !allowed("state_json") {
            e.state_json = redact::mask(&e.state_json);
        }
        if !allowed("user") {
            e.user = redact::mask(&e.user);
        }
        e
    }

    /// Mask-by-default: [`redacted_with_allowlist`](Self::redacted_with_allowlist)
    /// with an empty allowlist — every free-text field is scrubbed. This is the
    /// default the record path applies.
    pub fn redacted(&self) -> SagaEvent {
        self.redacted_with_allowlist(&[])
    }
}

/// The per-session identity a capture adapter holds and stamps onto every event
/// via [`SagaEvent::in_session`]. Kept here (not in the recorder) so the core
/// stays free of global session state — the adapter owns the session.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionCtx {
    pub session_id: String,
    pub user: String,
    pub app: String,
    pub app_version: String,
    pub git_sha: String,
}

impl SessionCtx {
    /// A new session context for `app` and `user`.
    pub fn new(session_id: &str, user: &str, app: &str, app_version: &str, git_sha: &str) -> Self {
        SessionCtx {
            session_id: session_id.to_string(),
            user: user.to_string(),
            app: app.to_string(),
            app_version: app_version.to_string(),
            git_sha: git_sha.to_string(),
        }
    }
}

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

    #[test]
    fn kind_label_roundtrips() {
        for k in [
            EventKind::Nav,
            EventKind::Click,
            EventKind::Log,
            EventKind::Error,
            EventKind::State,
        ] {
            assert_eq!(EventKind::parse(k.as_str()), Some(k), "{k:?} roundtrips");
        }
        assert_eq!(EventKind::parse("NAV"), Some(EventKind::Nav), "case-insensitive");
        assert_eq!(EventKind::parse("nope"), None, "unknown → None");
    }

    #[test]
    fn only_nav_and_click_are_actionable() {
        assert!(EventKind::Nav.is_actionable());
        assert!(EventKind::Click.is_actionable());
        assert!(!EventKind::Log.is_actionable());
        assert!(!EventKind::Error.is_actionable());
        assert!(!EventKind::State.is_actionable());
    }

    #[test]
    fn nav_builder_sets_kind_and_fields() {
        let e = SagaEvent::nav("/offert/7", "OffertForm", "open", r#"{"id":7}"#);
        assert_eq!(e.kind, EventKind::Nav);
        assert_eq!(e.route, "/offert/7");
        assert_eq!(e.component, "OffertForm");
        assert_eq!(e.action, "open");
        assert_eq!(e.state_json, r#"{"id":7}"#);
        // identity blank until an adapter stamps it.
        assert_eq!(e.session_id, "");
        assert_eq!(e.app, "");
        assert_eq!(e.seq, 0);
    }

    #[test]
    fn error_builder_is_error_kind_at_error_level() {
        let e = SagaEvent::error("OffertForm", "validation failed: missing org_nr");
        assert_eq!(e.kind, EventKind::Error);
        assert_eq!(e.level, "error");
        assert_eq!(e.component, "OffertForm");
        assert_eq!(e.detail, "validation failed: missing org_nr");
    }

    #[test]
    fn in_session_stamps_identity_onto_event() {
        let ctx = SessionCtx::new("sess-1", "alice", "njord", "0.9.0", "abc123");
        let e = SagaEvent::nav("/home", "Dashboard", "open", "{}").in_session(&ctx);
        assert_eq!(e.session_id, "sess-1");
        assert_eq!(e.user, "alice");
        assert_eq!(e.app, "njord");
        assert_eq!(e.app_version, "0.9.0");
        assert_eq!(e.git_sha, "abc123");
        // navigation payload untouched by the stamp.
        assert_eq!(e.route, "/home");
        assert_eq!(e.kind, EventKind::Nav);
    }

    #[test]
    fn event_json_roundtrips() {
        let ctx = SessionCtx::new("s", "u", "njord", "1.0", "sha");
        let e = SagaEvent::click("/x", "Btn", "submit", r#"{"v":1}"#).in_session(&ctx);
        let json = serde_json::to_string(&e).expect("serialize");
        let back: SagaEvent = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(back, e, "SagaEvent survives a warehouse JSON roundtrip");
        // kind serializes as the stable lowercase label.
        assert!(json.contains(r#""kind":"click""#), "kind label in json: {json}");
    }

    // ── PII redaction (mask-by-default) ──────────────────────────────────────

    #[test]
    fn redacted_masks_email_and_token_in_free_text() {
        let ctx = SessionCtx::new("s", "alice@example.com", "njord", "1.0", "sha");
        let mut e = SagaEvent::error("LoginForm", "login failed for alice@example.com")
            .in_session(&ctx);
        e.state_json = r#"{"authorization":"Bearer abc123deadbeefcafe","ok":false}"#.into();

        let r = e.redacted();
        assert!(!r.detail.contains("alice@example.com"), "email masked in detail: {}", r.detail);
        assert!(r.detail.contains(redact::MASK));
        assert!(!r.state_json.contains("Bearer abc123deadbeefcafe"), "token masked: {}", r.state_json);
        assert!(!r.user.contains("alice@example.com"), "user email masked: {}", r.user);
        // Structural fields are the journey's shape — untouched.
        assert_eq!(r.component, "LoginForm");
        assert_eq!(r.kind, EventKind::Error);
    }

    #[test]
    fn redacted_masks_swedish_personnummer() {
        // Every accepted form. The id-shaped rule cannot reach these: 10–12
        // characters, no letter.
        for pnr in [
            "811218-9876",
            "8112189876",
            "19811218-9876",
            "198112189876",
            "640823-3234",
            "990161-0007", // samordningsnummer — birth day + 60
            "811218+9876", // the bearer has turned 100
        ] {
            let e = SagaEvent::log("info", &format!("kund {pnr} bokade besiktning"));
            let r = e.redacted();
            assert!(!r.detail.contains(pnr), "personnummer {pnr} masked: {}", r.detail);
            assert!(r.detail.contains(redact::MASK), "mask marker present for {pnr}");
            // The surrounding sentence survives — only the number goes.
            assert!(r.detail.contains("bokade besiktning"), "context kept for {pnr}");
        }
    }

    /// **RED-when-broken: organisationsnummer are masked too.**
    ///
    /// `is_personnummer` can never catch these — it requires digits 3–4 to be a
    /// birth month (`01`–`12`), and an orgnr's group number is always ≥ 20 — so
    /// before `is_organisationsnummer` existed every one of these flowed into a
    /// recorded journey in the clear. Vectors are real, published company numbers
    /// (their check digits are what the Luhn here computes, which is the point).
    #[test]
    fn redacted_masks_swedish_organisationsnummer() {
        for orgnr in [
            "556016-0680",   // Ericsson
            "5560125790",    // Volvo, bare 10-digit form
            "232100-0156",   // a region (group 21)
            "802477-3114",   // an ideell förening (group 24)
            "262000-1129",   // a state authority (group 20)
            "165560160680",  // the 12-digit `16`-prefixed form
        ] {
            let e = SagaEvent::log("info", &format!("faktura till {orgnr} skickad"));
            let r = e.redacted();
            assert!(!r.detail.contains(orgnr), "orgnr {orgnr} masked: {}", r.detail);
            assert!(r.detail.contains(redact::MASK), "mask marker present for {orgnr}");
            assert!(r.detail.contains("skickad"), "context kept for {orgnr}");
        }
    }

    /// The orgnr shape must not become a back door that masks any 10-digit run —
    /// over-masking guts the journey these events exist to record.
    #[test]
    fn organisationsnummer_check_does_not_swallow_ordinary_numbers() {
        for benign in [
            "5560160681",   // right shape + group, WRONG check digit
            "1160160680",   // valid Luhn shape but group number < 20 and no valid month
            "175560160680", // 12 digits with a `17` prefix — only `16` is an orgnr
            "2026-07-21",   // an ISO date must never read as a company
        ] {
            let e = SagaEvent::log("info", benign);
            let r = e.redacted();
            assert_eq!(r.detail, benign, "not orgnr-shaped, left intact: {benign}");
        }
    }

    #[test]
    fn personnummer_check_does_not_swallow_ordinary_numbers() {
        // A failed Luhn digit, an ISO date and a plain quantity must all survive
        // — over-masking would gut the journey these events exist to record.
        for benign in ["8506151234", "2026-07-21", "1234", "order 42 st"] {
            let e = SagaEvent::log("info", benign);
            let r = e.redacted();
            assert_eq!(r.detail, benign, "not PII-shaped, left intact: {benign}");
        }
    }

    #[test]
    fn redaction_is_idempotent() {
        let e = SagaEvent::log("error", "contact admin@example.com now");
        let once = e.redacted();
        let twice = once.redacted();
        assert_eq!(once, twice, "masking an already-masked event is a no-op");
        assert!(!once.detail.contains("admin@example.com"));
    }

    #[test]
    fn allowlisted_field_survives_redaction() {
        let e = SagaEvent::log("info", "user u@d.com opened offert");
        // Opt `detail` in → it is NOT masked; other fields still default-masked.
        let r = e.redacted_with_allowlist(&["detail"]);
        assert!(r.detail.contains("u@d.com"), "allowlisted detail survives: {}", r.detail);
    }

    #[test]
    fn secret_label_masks_the_short_credential_that_follows() {
        // Regression: a short, non-id-shaped credential after a secret label used
        // to leak (only the label word was masked). The label now scrubs the
        // value that follows it, whether space- or `=`-separated.
        assert_eq!(redact::mask("Authorization: Bearer abc.def"),
            format!("Authorization: {} {}", redact::MASK, redact::MASK));
        assert_eq!(redact::mask("password=hunter2"),
            format!("{}={}", redact::MASK, redact::MASK));
        assert_eq!(redact::mask("token deadbeef"),
            format!("{} {}", redact::MASK, redact::MASK));
        // Re-masking is still a no-op (the label chaining does not fire on MASK).
        let once = redact::mask("Bearer abc.def");
        assert_eq!(redact::mask(&once), once, "credential-follow masking is idempotent");
    }

    #[test]
    fn ordinary_text_is_left_intact() {
        let e = SagaEvent::nav("/offert/123", "OffertForm", "open", r#"{"count":3}"#);
        let r = e.redacted();
        // No PII shapes → identical.
        assert_eq!(r.detail, e.detail);
        assert_eq!(r.state_json, e.state_json, "plain small json unchanged");
        assert_eq!(r.route, "/offert/123", "route is structural, never masked");
    }

    #[test]
    fn mask_helper_preserves_layout_of_non_pii_words() {
        let masked = redact::mask("hello world foo");
        assert_eq!(masked, "hello world foo", "no PII → verbatim, layout preserved");
        let masked = redact::mask("email a@b.com end");
        assert_eq!(masked, format!("email {} end", redact::MASK));
    }
}