Functional Weave
Code in Rust

monitor.alert-state

Step an alert through inactive, pending, firing and resolved, and say which notification to send.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 25 tests, run in TypeScript, Python and Rust.

What it does

The lifecycle of one alert, one evaluation at a time, and the notification each step should send. The function is pure: store the returned `state` and pass it back as `previous` next time. Start from `{ phase: "inactive", since: null, lastNotifiedAt: null, clearSince: null }`. The condition itself comes from anywhere: `monitor.alert-rule`, a heartbeat, a burn-rate alert.

## Transitions

For example

  • next_alert_state(phase inactive, since —, last notified at —, clear since —, true, 1,000, for seconds 300, clear for seconds 0, renotify seconds —) → state …, notify —, changed true the condition starting on an inactive alert makes it pending
  • next_alert_state(phase inactive, since —, last notified at —, clear since —, true, 1,000, for seconds 0, clear for seconds 0, renotify seconds —) → state …, notify firing, changed true forSeconds 0 fires at once and notifies
  • next_alert_state(phase inactive, since —, last notified at —, clear since —, false, 1,000, for seconds 300, clear for seconds 0, renotify seconds —) → state …, notify —, changed false an inactive alert with the condition false stays as it is

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

pub fn next_alert_state(previous: &AlertState, condition: bool, now: i64, policy: &AlertPolicy) -> AlertTransition
previousAlertStatethe state this function returned last time; start from inactive with every time null
conditionboolwhether the alert condition holds at this evaluation
nowintUnix seconds of this evaluation, not before any time in previous
policyAlertPolicy
returnsAlertTransition

The types it declares, generated into your project

// AlertPhase is a string in Rust, one of: "inactive", "pending", "firing", "resolved".
// Parameters take it as &str and results hold it as String.

/// Everything the next evaluation needs; store it between evaluations.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AlertState {
    pub phase: String,
    /// when this phase began
    pub since: Option<i64>,
    /// when the last notification of any kind was sent
    pub last_notified_at: Option<i64>,
    /// while firing, when the condition was first seen false again
    pub clear_since: Option<i64>,
}

/// How long to wait before firing and before resolving, and how often to repeat.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AlertPolicy {
    /// the condition must hold this long before firing, 0 = at once
    pub for_seconds: i64,
    /// the condition must stay false this long before resolving, 0 = at once
    pub clear_for_seconds: i64,
    /// while firing, repeat the notification this often; null = never
    pub renotify_seconds: Option<i64>,
}

// Notification is a string in Rust, one of: "firing", "repeat", "resolved".
// Parameters take it as &str and results hold it as String.

/// The new state, and what to tell people.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AlertTransition {
    pub state: AlertState,
    /// null = send nothing
    pub notify: Option<String>,
    /// the phase changed
    pub changed: bool,
}

Your code names it in one line, in the file that uses it

fune!(monitor.alert-state@^1);  // then call next_alert_state(…)
impl/rust.rs · 149 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one

fn state(phase: &str, since: Option<i64>, last: Option<i64>, clear_since: Option<i64>) -> AlertState {
    AlertState { phase: phase.to_string(), since, last_notified_at: last, clear_since }
}

fn transition(state: AlertState, notify: Option<&str>, changed: bool) -> AlertTransition {
    AlertTransition { state, notify: notify.map(|n| n.to_string()), changed }
}

/// One step of an alert's lifecycle. Pure: the caller stores the returned
/// state and passes it back at the next evaluation.
///
/// Firing needs the condition to hold for_seconds (Prometheus `for`);
/// resolving needs it to stay false clear_for_seconds, so a flapping check
/// does not send resolved-firing-resolved storms. last_notified_at records
/// the last notification of any kind; it drives the repeat interval while
/// firing.
///
/// # Panics
/// Panics on a negative duration, an unknown phase, a pending or firing state
/// without since, or a now earlier than any time in the previous state.
pub fn next_alert_state(previous: &AlertState, condition: bool, now: i64, policy: &AlertPolicy) -> AlertTransition {
    let for_seconds = policy.for_seconds;
    let clear_for = policy.clear_for_seconds;
    if for_seconds < 0 {
        panic!("forSeconds must not be negative, received {}", for_seconds);
    }
    if clear_for < 0 {
        panic!("clearForSeconds must not be negative, received {}", clear_for);
    }
    if let Some(r) = policy.renotify_seconds {
        if r < 1 {
            panic!("renotifySeconds must be null or at least 1, received {}", r);
        }
    }
    let phase = previous.phase.as_str();
    if !matches!(phase, "inactive" | "pending" | "firing" | "resolved") {
        panic!("unknown alert phase: {}", phase);
    }
    let (since, last, clear_since) = (previous.since, previous.last_notified_at, previous.clear_since);
    if (phase == "pending" || phase == "firing") && since.is_none() {
        panic!("since must be set in phase {}", phase);
    }
    for (name, t) in [("since", since), ("lastNotifiedAt", last), ("clearSince", clear_since)] {
        if let Some(t) = t {
            if now < t {
                panic!("now {} is earlier than {} {}", now, name, t);
            }
        }
    }

    match phase {
        "inactive" | "resolved" => {
            if !condition {
                transition(previous.clone(), None, false)
            } else if for_seconds == 0 {
                transition(state("firing", Some(now), Some(now), None), Some("firing"), true)
            } else {
                transition(state("pending", Some(now), last, None), None, true)
            }
        }
        "pending" => {
            if !condition {
                transition(state("inactive", Some(now), last, None), None, true)
            } else if now - since.unwrap() >= for_seconds {
                transition(state("firing", Some(now), Some(now), None), Some("firing"), true)
            } else {
                transition(previous.clone(), None, false)
            }
        }
        _ => {
            if condition {
                let due = match (policy.renotify_seconds, last) {
                    (Some(_), None) => true,
                    (Some(r), Some(l)) => now - l >= r,
                    (None, _) => false,
                };
                if due {
                    transition(state("firing", since, Some(now), None), Some("repeat"), false)
                } else {
                    transition(state("firing", since, last, None), None, false)
                }
            } else {
                let clearing = clear_since.unwrap_or(now);
                if now - clearing >= clear_for {
                    transition(state("resolved", Some(now), Some(now), None), Some("resolved"), true)
                } else {
                    transition(state("firing", since, last, Some(clearing)), None, false)
                }
            }
        }
    }
}

fn opt_i64(v: &Value) -> Option<i64> {
    if v.is_null() { None } else { Some(v.as_i64()) }
}

fn opt_to_value(v: Option<i64>) -> Value {
    match v {
        Some(i) => Value::Int(i),
        None => Value::Null,
    }
}

pub fn alert_state_from_value(v: &Value) -> AlertState {
    AlertState {
        phase: v.get("phase").as_str().to_string(),
        since: opt_i64(v.get("since")),
        last_notified_at: opt_i64(v.get("lastNotifiedAt")),
        clear_since: opt_i64(v.get("clearSince")),
    }
}

pub fn alert_state_to_value(s: &AlertState) -> Value {
    Value::obj(vec![
        ("phase", Value::str(&s.phase)),
        ("since", opt_to_value(s.since)),
        ("lastNotifiedAt", opt_to_value(s.last_notified_at)),
        ("clearSince", opt_to_value(s.clear_since)),
    ])
}

pub fn alert_policy_from_value(v: &Value) -> AlertPolicy {
    AlertPolicy {
        for_seconds: v.get("forSeconds").as_i64(),
        clear_for_seconds: v.get("clearForSeconds").as_i64(),
        renotify_seconds: opt_i64(v.get("renotifySeconds")),
    }
}

pub fn alert_transition_to_value(t: &AlertTransition) -> Value {
    Value::obj(vec![
        ("state", alert_state_to_value(&t.state)),
        ("notify", match &t.notify { Some(n) => Value::str(n), None => Value::Null }),
        ("changed", Value::Bool(t.changed)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let previous = alert_state_from_value(&args[0]);
    let now = match &args[2] {
        Value::Int(i) => *i,
        _ => panic!("now must be a whole number of seconds"),
    };
    let policy = alert_policy_from_value(&args[3]);
    alert_transition_to_value(&next_alert_state(&previous, args[1].as_bool(), now, &policy))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add monitor.alert-state
Download for Rust monitor.alert-state-1.0.0-rust.fune · 22,525 bytes sha256 f8479d38d510efb8ebc0cce9ff3faacf587cb8dda6be203d3342c1068791ba72

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./monitor.alert-state-1.0.0-rust.fune, or fetch it from a terminal with fune pull monitor.alert-state@1.0.0:rust.

The whole function, every language, is one file too: monitor.alert-state-1.0.0.fune, 30,077 bytes, sha256 9f53e4ae117c8e8b7e9613f83b621d77b32fb2d1ac4450004a95b8d77e1eaa2b. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before monitor.alert-state

after — your function gets the result and the arguments, and returns the final result.

// fune: after monitor.alert-state

replace — it requires no other capability, so there is no dependency to replace.

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show monitor.alert-state --steps.

// fune: step monitor.alert-state after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
the condition starting on an inactive alert makes it pending phase inactive, since —, last notified at —, clear since —, true, 1,000, for seconds 300, clear for seconds 0, renotify seconds — → state …, notify —, changed true
forSeconds 0 fires at once and notifies phase inactive, since —, last notified at —, clear since —, true, 1,000, for seconds 0, clear for seconds 0, renotify seconds — → state …, notify firing, changed true
an inactive alert with the condition false stays as it is phase inactive, since —, last notified at —, clear since —, false, 1,000, for seconds 300, clear for seconds 0, renotify seconds — → state …, notify —, changed false
pending one second short of forSeconds stays pending phase pending, since 1,000, last notified at —, clear since —, true, 1,299, for seconds 300, clear for seconds 0, renotify seconds — → state …, notify —, changed false
pending for exactly forSeconds fires and notifies phase pending, since 1,000, last notified at —, clear since —, true, 1,300, for seconds 300, clear for seconds 0, renotify seconds — → state …, notify firing, changed true
the condition ending while pending goes back to inactive without a notification phase pending, since 1,000, last notified at —, clear since —, false, 1,100, for seconds 300, clear for seconds 0, renotify seconds — → state …, notify —, changed true
firing and still true, with no repeat interval, sends nothing phase firing, since 1,300, last notified at 1,300, clear since —, true, 1,500, for seconds 300, clear for seconds 0, renotify seconds — → state …, notify —, changed false
firing and still true for exactly the repeat interval sends a repeat phase firing, since 1,300, last notified at 1,300, clear since —, true, 4,900, for seconds 300, clear for seconds 0, renotify seconds 3,600 → state …, notify repeat, changed false
one second short of the repeat interval sends nothing phase firing, since 1,300, last notified at 1,300, clear since —, true, 4,899, for seconds 300, clear for seconds 0, renotify seconds 3,600 → state …, notify —, changed false
firing then false with clearForSeconds 0 resolves at once phase firing, since 1,300, last notified at 1,300, clear since —, false, 2,000, for seconds 300, clear for seconds 0, renotify seconds — → state …, notify resolved, changed true
Show the other 15 tests
CaseArgumentsExpected
firing then false with a clear delay starts the clear clock and keeps firing phase firing, since 1,300, last notified at 1,300, clear since —, false, 2,000, for seconds 300, clear for seconds 600, renotify seconds — → state …, notify —, changed false
false for the whole clear delay resolves phase firing, since 1,300, last notified at 1,300, clear since 2,000, false, 2,600, for seconds 300, clear for seconds 600, renotify seconds — → state …, notify resolved, changed true
false one second short of the clear delay keeps firing phase firing, since 1,300, last notified at 1,300, clear since 2,000, false, 2,599, for seconds 300, clear for seconds 600, renotify seconds — → state …, notify —, changed false
a flap back to true during the clear delay cancels it, no resolved sent phase firing, since 1,300, last notified at 1,300, clear since 2,000, true, 2,300, for seconds 300, clear for seconds 600, renotify seconds — → state …, notify —, changed false
a resolved alert with the condition false stays resolved phase resolved, since 2,000, last notified at 2,000, clear since —, false, 3,000, for seconds 300, clear for seconds 0, renotify seconds — → state …, notify —, changed false
a resolved alert whose condition returns goes pending, keeping the last notification time phase resolved, since 2,000, last notified at 2,000, clear since —, true, 3,000, for seconds 300, clear for seconds 0, renotify seconds — → state …, notify —, changed true
now before the state's since is an error phase pending, since 1,000, last notified at —, clear since —, true, 900, for seconds 300, clear for seconds 0, renotify seconds — → error: now 900 is earlier than since 1000
now before the last notification is an error phase firing, since 1,300, last notified at 1,500, clear since —, true, 1,400, for seconds 300, clear for seconds 0, renotify seconds — → error: now 1400 is earlier than lastNotifiedAt 1500
now before clearSince is an error phase firing, since 1,300, last notified at 1,300, clear since 2,000, false, 1,900, for seconds 300, clear for seconds 0, renotify seconds — → error: now 1900 is earlier than clearSince 2000
an unknown phase is an error phase acknowledged, since 1,000, last notified at —, clear since —, true, 1,000, for seconds 300, clear for seconds 0, renotify seconds — → error: unknown alert phase: acknowledged
a pending state without since is an error phase pending, since —, last notified at —, clear since —, true, 1,000, for seconds 300, clear for seconds 0, renotify seconds — → error: since must be set in phase pending
a negative forSeconds is an error phase inactive, since —, last notified at —, clear since —, true, 1,000, for seconds -1, clear for seconds 0, renotify seconds — → error: forSeconds must not be negative
a negative clearForSeconds is an error phase inactive, since —, last notified at —, clear since —, true, 1,000, for seconds 300, clear for seconds -1, renotify seconds — → error: clearForSeconds must not be negative
a zero repeat interval is an error phase inactive, since —, last notified at —, clear since —, true, 1,000, for seconds 300, clear for seconds 0, renotify seconds 0 → error: renotifySeconds must be null or at least 1
a fractional now is an error phase inactive, since —, last notified at —, clear since —, true, 1,000.5, for seconds 300, clear for seconds 0, renotify seconds — → error: now must be a whole number of seconds

More from the author

| previous | condition | result | | --- | --- | --- | | inactive or resolved | false | unchanged | | inactive or resolved | true | `pending` since now; or, when `forSeconds` is 0, `firing` since now and notify `firing` | | pending | true | `firing` since now and notify `firing` once `now - since >= forSeconds`, else unchanged | | pending | false | `inactive` since now, no notification (it never fired, so there is nothing to resolve) | | firing | true | stays firing, `clearSince` reset to null; notify `repeat` when `renotifySeconds` is set and `now - lastNotifiedAt >= renotifySeconds` | | firing | false | `clearSince` is set to now if it was null; once `now - clearSince >= clearForSeconds` it becomes `resolved` since now and notifies `resolved` |

- `lastNotifiedAt` is the time of the last notification of any kind, and is carried through the other phases; entering `firing` sets it, so the repeat interval counts from the first notification. - `changed` says whether the **phase** changed, so a caller knows when to write an alert history row. Store the returned state every time anyway: `clearSince` and `lastNotifiedAt` can change without the phase changing. - `since` of a firing alert is when it started firing, not when it became pending.

## Why this shape

`forSeconds` is Prometheus's `for`: "wait for a certain duration between first encountering a new expression output vector element and counting an alert as firing"; until then the alert is pending, and a pending alert whose condition goes away is simply dropped. `clearForSeconds` is the other direction, what Prometheus calls `keep_firing_for` ("keep this alert firing for the specified duration after the firing condition was last met"): a check that flaps true/false/true sends one firing and one resolved, not a storm. Here the condition must stay false for the whole delay; one true evaluation during it cancels the resolve. `renotifySeconds` matches Alertmanager's `repeat_interval`: a reminder while an alert keeps firing.

## Errors

- `now 900 is earlier than since 1000` (also for `lastNotifiedAt` and `clearSince`): time never goes backwards between evaluations. - `unknown alert phase: X` - `since must be set in phase pending` (or `firing`) - `forSeconds must not be negative`, `clearForSeconds must not be negative` - `renotifySeconds must be null or at least 1` - `now must be a whole number of seconds`

## Sources

- Prometheus, "Alerting rules", https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/ (`for`, pending and firing, `keep_firing_for`). - Prometheus Alertmanager, "Configuration", https://prometheus.io/docs/alerting/latest/configuration/ (`repeat_interval`).

Files

PathBytes
README.md3,115
impl/python.py3,748
impl/rust.rs5,658
impl/typescript.ts3,541
vectors.json8,666