Skip to main content

aion_package/structure/
determinism.rs

1//! Static determinism analysis over a workflow entry-module's Gleam source.
2//!
3//! The determinism boundary (invariant 2) requires workflow code to be a pure
4//! function of its recorded history: time comes from `workflow.now`, entropy
5//! from `workflow.random` / `workflow.random_int`, both recorded and replayed.
6//! A direct wall-clock read or entropy draw — `gleam/erlang.system_time`,
7//! `gleam/float.random`, `erlang:unique_integer`, … — is invisible to the
8//! recorder, so a replay re-runs it and silently diverges. This analysis is the
9//! static gate that catches that class before it ships (P7, C28).
10//!
11//! It reuses the [`super::scan`] tokeniser and the same control-flow primitive
12//! the extractor uses (function-body mapping plus reachability over local helper
13//! calls), so the linter and the graph projection agree on what "reachable from
14//! workflow code" means. Reachability follows a helper both when it is applied
15//! directly (`helper(..)`) and when it is passed as a bare function value into a
16//! higher-order call (`list.map(items, helper)`), so a forbidden call hidden
17//! behind a passed helper is not silently missed. It is deliberately not a Gleam
18//! type-checker: the recognised non-deterministic vocabulary is a fixed, known
19//! set of qualified-call shapes, matched soundly because string literals and
20//! comments are excluded by the tokeniser. A call outside the known set is never
21//! flagged (no false positive on the author's own helpers); a known call
22//! reachable from the entry function is always flagged (no silent miss). The one
23//! reachability edge not followed is a helper aliased through a `let` binding and
24//! then passed (`let f = helper  list.map(items, f)`): name-level token analysis
25//! cannot resolve the alias without a type-checker, and the deterministic SDK
26//! surface gives authors no reason to write it.
27//!
28//! The vocabulary covers the wall-clock and entropy sources reachable from the
29//! BEAM's Gleam surface — Erlang's `os`/`erlang` time and uniqueness builtins,
30//! `gleam/erlang` time wrappers, and the `gleam/int` / `gleam/float` random
31//! draws. The deterministic SDK surface (`workflow.now`, `workflow.random`,
32//! `workflow.random_int`) is explicitly NOT in the set: those are the recorded,
33//! replay-safe substitutes the boundary mandates.
34
35use std::collections::{BTreeMap, BTreeSet};
36
37use super::reader::{find_open_brace, match_brace};
38use super::scan::{Token, tokenise};
39
40/// One recognised non-deterministic call shape: the module qualifier left of the
41/// dot, the member right of it, and a human description of why it is forbidden.
42struct ForbiddenCall {
43    /// The qualifier as written at a recognised call site (a `gleam/erlang`
44    /// import is referenced as `erlang.<member>`; `gleam/float` as
45    /// `float.<member>`; an `@external` Erlang call as `os` / `erlang` /
46    /// `rand` / `crypto`).
47    qualifier: &'static str,
48    /// The member right of the dot.
49    member: &'static str,
50    /// Whether this is a wall-clock read or an entropy draw, for the diagnostic.
51    kind: ViolationKind,
52}
53
54/// Whether a flagged call reads the wall clock or draws entropy.
55#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
56pub enum ViolationKind {
57    /// A wall-clock read: the recorded `workflow.now()` is the only legal time
58    /// source inside workflow code.
59    WallClock,
60    /// An entropy draw: `workflow.random()` / `workflow.random_int(..)` are the
61    /// only legal entropy sources inside workflow code.
62    Entropy,
63}
64
65impl ViolationKind {
66    /// The deterministic SDK substitute an author should reach for instead.
67    #[must_use]
68    pub fn remedy(self) -> &'static str {
69        match self {
70            ViolationKind::WallClock => {
71                "use the recorded `workflow.now()` instead of reading the wall clock"
72            }
73            ViolationKind::Entropy => {
74                "use the seeded `workflow.random()` / `workflow.random_int(..)` \
75                 instead of drawing entropy"
76            }
77        }
78    }
79}
80
81/// The fixed vocabulary of non-deterministic calls the gate flags. Each entry is
82/// a wall-clock or entropy source reachable from Gleam workflow code on the BEAM;
83/// the deterministic `workflow.*` substitutes are deliberately absent.
84const FORBIDDEN_CALLS: &[ForbiddenCall] = &[
85    // Erlang time builtins, reached via an `@external(erlang, "erlang", ..)` or
86    // `@external(erlang, "os", ..)` declaration whose Gleam name keeps the
87    // module qualifier, or via `gleam/erlang/os` / `gleam/erlang` wrappers.
88    ForbiddenCall {
89        qualifier: "erlang",
90        member: "system_time",
91        kind: ViolationKind::WallClock,
92    },
93    ForbiddenCall {
94        qualifier: "erlang",
95        member: "monotonic_time",
96        kind: ViolationKind::WallClock,
97    },
98    ForbiddenCall {
99        qualifier: "erlang",
100        member: "now",
101        kind: ViolationKind::WallClock,
102    },
103    ForbiddenCall {
104        qualifier: "erlang",
105        member: "timestamp",
106        kind: ViolationKind::WallClock,
107    },
108    ForbiddenCall {
109        qualifier: "erlang",
110        member: "unique_integer",
111        kind: ViolationKind::Entropy,
112    },
113    ForbiddenCall {
114        qualifier: "os",
115        member: "system_time",
116        kind: ViolationKind::WallClock,
117    },
118    ForbiddenCall {
119        qualifier: "os",
120        member: "timestamp",
121        kind: ViolationKind::WallClock,
122    },
123    ForbiddenCall {
124        qualifier: "os",
125        member: "perf_counter",
126        kind: ViolationKind::WallClock,
127    },
128    // `gleam/erlang/os` re-exports the wall clock under the same member names,
129    // referenced as `os.<member>` once imported.
130    ForbiddenCall {
131        qualifier: "os",
132        member: "erlang_timestamp",
133        kind: ViolationKind::WallClock,
134    },
135    // Erlang entropy builtins.
136    ForbiddenCall {
137        qualifier: "rand",
138        member: "uniform",
139        kind: ViolationKind::Entropy,
140    },
141    ForbiddenCall {
142        qualifier: "rand",
143        member: "uniform_real",
144        kind: ViolationKind::Entropy,
145    },
146    ForbiddenCall {
147        qualifier: "rand",
148        member: "bytes",
149        kind: ViolationKind::Entropy,
150    },
151    ForbiddenCall {
152        qualifier: "crypto",
153        member: "strong_rand_bytes",
154        kind: ViolationKind::Entropy,
155    },
156    // `gleam/float` and `gleam/int` random draws (the stdlib's non-deterministic
157    // surface), referenced as `float.random` / `int.random`.
158    ForbiddenCall {
159        qualifier: "float",
160        member: "random",
161        kind: ViolationKind::Entropy,
162    },
163    ForbiddenCall {
164        qualifier: "int",
165        member: "random",
166        kind: ViolationKind::Entropy,
167    },
168];
169
170/// A single flagged non-deterministic call site reachable from workflow code.
171#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
172pub struct Violation {
173    /// The function the call was found in (the entry function, or a local helper
174    /// reachable from it).
175    pub function: String,
176    /// The fully-qualified call as written (`erlang.system_time`).
177    pub call: String,
178    /// Whether the call reads the wall clock or draws entropy.
179    pub kind: ViolationKind,
180}
181
182/// Errors raised before analysis can run.
183#[derive(thiserror::Error, Debug, PartialEq, Eq)]
184pub enum DeterminismError {
185    /// The named entry function is not defined in the supplied source, so the
186    /// reachability walk has no root. Reported loudly rather than passing a
187    /// workflow whose entry the analyser never reached.
188    #[error(
189        "entry function `{function}` is not defined in the workflow source; the determinism \
190         analysis requires its body to walk from"
191    )]
192    EntryFunctionNotFound {
193        /// The entry-function name searched for.
194        function: String,
195    },
196}
197
198/// A function body as the half-open token range strictly inside its `{ }`.
199#[derive(Clone, Copy)]
200struct FnBody {
201    start: usize,
202    end: usize,
203}
204
205/// Analyses `source` for wall-clock and entropy calls reachable from
206/// `entry_function`, following local helper calls.
207///
208/// Returns every flagged call in deterministic order (by function name, then
209/// call, then kind), so a clean workflow yields an empty vector and a tainted
210/// one yields a stable, reproducible report.
211///
212/// # Errors
213///
214/// Returns [`DeterminismError::EntryFunctionNotFound`] when `entry_function` is
215/// not defined in `source`.
216pub fn analyze_determinism(
217    source: &str,
218    entry_function: &str,
219) -> Result<Vec<Violation>, DeterminismError> {
220    let tokens = tokenise(source);
221    let functions = map_functions(&tokens);
222    if !functions.contains_key(entry_function) {
223        return Err(DeterminismError::EntryFunctionNotFound {
224            function: entry_function.to_owned(),
225        });
226    }
227
228    let mut violations: BTreeSet<Violation> = BTreeSet::new();
229    let mut visited: BTreeSet<String> = BTreeSet::new();
230    walk(
231        &tokens,
232        &functions,
233        entry_function,
234        &mut visited,
235        &mut violations,
236    );
237    Ok(violations.into_iter().collect())
238}
239
240/// Walks `function`'s body, recording every forbidden call and recursing into
241/// every reachable local helper exactly once (guarded by `visited`, so mutual
242/// recursion terminates).
243fn walk(
244    tokens: &[Token],
245    functions: &BTreeMap<String, FnBody>,
246    function: &str,
247    visited: &mut BTreeSet<String>,
248    violations: &mut BTreeSet<Violation>,
249) {
250    if !visited.insert(function.to_owned()) {
251        return;
252    }
253    let Some(body) = functions.get(function).copied() else {
254        return;
255    };
256    // Collect the helper calls to recurse into after scanning this body, so the
257    // immutable borrow of `tokens` for forbidden-call matching does not overlap
258    // the recursive descent.
259    let mut callees: Vec<String> = Vec::new();
260    let upper = body.end.min(tokens.len());
261    let mut index = body.start;
262    let mut depth: usize = 0;
263    while index < upper {
264        match &tokens[index] {
265            Token::OpenParen => depth += 1,
266            Token::CloseParen => depth = depth.saturating_sub(1),
267            Token::Qualified { left, right } => {
268                if let Some(forbidden) = match_forbidden(left, right) {
269                    violations.insert(Violation {
270                        function: function.to_owned(),
271                        call: format!("{left}.{right}"),
272                        kind: forbidden.kind,
273                    });
274                }
275            }
276            // A local helper is reachable either when it is applied directly
277            // (`name(`) or when it is passed as a bare function value in
278            // argument position (`list.map(items, name)`, depth >= 1), where a
279            // higher-order call will invoke it. Both call-graph edges are
280            // followed so a forbidden call hidden behind a passed helper is not
281            // silently missed by the gate.
282            Token::Ident(name) if functions.contains_key(name) => {
283                let applied = matches!(tokens.get(index + 1), Some(Token::OpenParen));
284                if applied || depth >= 1 {
285                    callees.push(name.clone());
286                }
287            }
288            _ => {}
289        }
290        index += 1;
291    }
292    for callee in callees {
293        walk(tokens, functions, &callee, visited, violations);
294    }
295}
296
297/// Matches a qualified call against the forbidden vocabulary.
298fn match_forbidden(qualifier: &str, member: &str) -> Option<&'static ForbiddenCall> {
299    FORBIDDEN_CALLS
300        .iter()
301        .find(|call| call.qualifier == qualifier && call.member == member)
302}
303
304/// Maps every top-level `fn <name>(...) ... { <body> }` (with optional `pub`) to
305/// its body's token range, mirroring the control-flow extractor's function
306/// mapping so the two analyses agree on function boundaries.
307fn map_functions(tokens: &[Token]) -> BTreeMap<String, FnBody> {
308    let mut functions = BTreeMap::new();
309    let mut index = 0;
310    while index < tokens.len() {
311        if matches!(&tokens[index], Token::Ident(word) if word == "fn")
312            && let Some(Token::Ident(name)) = tokens.get(index + 1)
313            && let Some(open) = find_open_brace(tokens, index + 2, tokens.len())
314            && let Some(close) = match_brace(tokens, open, tokens.len())
315        {
316            functions.insert(
317                name.clone(),
318                FnBody {
319                    start: open + 1,
320                    end: close,
321                },
322            );
323            index = close + 1;
324            continue;
325        }
326        index += 1;
327    }
328    functions
329}
330
331#[cfg(test)]
332mod tests {
333    use super::{DeterminismError, ViolationKind, analyze_determinism};
334
335    #[test]
336    fn clean_workflow_has_no_violations() -> Result<(), Box<dyn std::error::Error>> {
337        let source = "import aion/workflow\n\
338             pub fn run(input) {\n  \
339             let assert Ok(at) = workflow.now()\n  \
340             let assert Ok(seed) = workflow.random()\n  \
341             workflow.run(wrappers.charge_activity(input))\n}\n";
342        assert!(analyze_determinism(source, "run")?.is_empty());
343        Ok(())
344    }
345
346    #[test]
347    fn direct_wall_clock_call_is_flagged() -> Result<(), Box<dyn std::error::Error>> {
348        let source = "pub fn run(input) {\n  \
349             let now = erlang.system_time(1000)\n  \
350             workflow.run(wrappers.charge_activity(input))\n}\n";
351        let violations = analyze_determinism(source, "run")?;
352        assert_eq!(violations.len(), 1);
353        assert_eq!(violations[0].call, "erlang.system_time");
354        assert_eq!(violations[0].kind, ViolationKind::WallClock);
355        assert_eq!(violations[0].function, "run");
356        Ok(())
357    }
358
359    #[test]
360    fn entropy_in_a_reachable_helper_is_flagged() -> Result<(), Box<dyn std::error::Error>> {
361        let source = "pub fn run(input) {\n  \
362             let id = make_id(input)\n  \
363             workflow.run(wrappers.charge_activity(id))\n}\n\
364             fn make_id(input) {\n  float.random()\n}\n";
365        let violations = analyze_determinism(source, "run")?;
366        assert_eq!(violations.len(), 1, "{violations:?}");
367        assert_eq!(violations[0].call, "float.random");
368        assert_eq!(violations[0].kind, ViolationKind::Entropy);
369        assert_eq!(violations[0].function, "make_id");
370        Ok(())
371    }
372
373    #[test]
374    fn entropy_in_a_helper_passed_as_a_value_is_flagged() -> Result<(), Box<dyn std::error::Error>>
375    {
376        // `tainted` is never applied directly — it is passed as a bare function
377        // value to a higher-order call (`list.map`), which invokes it. A gate
378        // that followed only direct `name(` calls would miss the entropy hiding
379        // behind the passed helper; this is the soundness edge the depth-aware
380        // walk closes.
381        let source = "pub fn run(input) {\n  \
382             let _ = list.map(input, tainted)\n  \
383             workflow.run(wrappers.charge_activity(input))\n}\n\
384             fn tainted(item) {\n  float.random()\n}\n";
385        let violations = analyze_determinism(source, "run")?;
386        assert_eq!(violations.len(), 1, "{violations:?}");
387        assert_eq!(violations[0].call, "float.random");
388        assert_eq!(violations[0].kind, ViolationKind::Entropy);
389        assert_eq!(violations[0].function, "tainted");
390        Ok(())
391    }
392
393    #[test]
394    fn unreachable_helper_violation_is_not_flagged() -> Result<(), Box<dyn std::error::Error>> {
395        // `tainted` draws entropy but is never called from `run`: it is dead
396        // relative to the workflow, so it must not be flagged.
397        let source = "pub fn run(input) {\n  \
398             workflow.run(wrappers.charge_activity(input))\n}\n\
399             fn tainted(input) {\n  int.random()\n}\n";
400        assert!(analyze_determinism(source, "run")?.is_empty());
401        Ok(())
402    }
403
404    #[test]
405    fn forbidden_word_inside_a_string_literal_is_not_flagged()
406    -> Result<(), Box<dyn std::error::Error>> {
407        // The tokeniser excludes string contents from matching, so a log message
408        // mentioning the call is never a false positive.
409        let source = "pub fn run(input) {\n  \
410             log(\"erlang.system_time is forbidden here\")\n  \
411             workflow.run(wrappers.charge_activity(input))\n}\n";
412        assert!(analyze_determinism(source, "run")?.is_empty());
413        Ok(())
414    }
415
416    #[test]
417    fn missing_entry_function_is_a_loud_error() {
418        let source = "fn helper() {\n  Nil\n}\n";
419        let result = analyze_determinism(source, "run");
420        assert_eq!(
421            result,
422            Err(DeterminismError::EntryFunctionNotFound {
423                function: "run".to_owned(),
424            })
425        );
426    }
427
428    #[test]
429    fn mutually_recursive_helpers_terminate() -> Result<(), Box<dyn std::error::Error>> {
430        let source = "pub fn run(input) {\n  ping(input)\n}\n\
431             fn ping(input) {\n  pong(input)\n}\n\
432             fn pong(input) {\n  ping(input)\n  os.system_time(1)\n}\n";
433        let violations = analyze_determinism(source, "run")?;
434        assert_eq!(violations.len(), 1);
435        assert_eq!(violations[0].call, "os.system_time");
436        Ok(())
437    }
438
439    #[test]
440    fn multiple_distinct_calls_are_all_reported() -> Result<(), Box<dyn std::error::Error>> {
441        let source = "pub fn run(input) {\n  \
442             let a = os.system_time(1)\n  \
443             let b = crypto.strong_rand_bytes(16)\n  \
444             let c = erlang.unique_integer([])\n}\n";
445        let violations = analyze_determinism(source, "run")?;
446        let calls: Vec<&str> = violations.iter().map(|v| v.call.as_str()).collect();
447        assert_eq!(
448            calls,
449            vec![
450                "crypto.strong_rand_bytes",
451                "erlang.unique_integer",
452                "os.system_time",
453            ]
454        );
455        Ok(())
456    }
457}