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            if let Some(Token::Ident(name)) = tokens.get(index + 1) {
313                if let Some(open) = find_open_brace(tokens, index + 2, tokens.len()) {
314                    if let Some(close) = match_brace(tokens, open, tokens.len()) {
315                        functions.insert(
316                            name.clone(),
317                            FnBody {
318                                start: open + 1,
319                                end: close,
320                            },
321                        );
322                        index = close + 1;
323                        continue;
324                    }
325                }
326            }
327        }
328        index += 1;
329    }
330    functions
331}
332
333#[cfg(test)]
334mod tests {
335    use super::{DeterminismError, ViolationKind, analyze_determinism};
336
337    #[test]
338    fn clean_workflow_has_no_violations() -> Result<(), Box<dyn std::error::Error>> {
339        let source = "import aion/workflow\n\
340             pub fn run(input) {\n  \
341             let assert Ok(at) = workflow.now()\n  \
342             let assert Ok(seed) = workflow.random()\n  \
343             workflow.run(wrappers.charge_activity(input))\n}\n";
344        assert!(analyze_determinism(source, "run")?.is_empty());
345        Ok(())
346    }
347
348    #[test]
349    fn direct_wall_clock_call_is_flagged() -> Result<(), Box<dyn std::error::Error>> {
350        let source = "pub fn run(input) {\n  \
351             let now = erlang.system_time(1000)\n  \
352             workflow.run(wrappers.charge_activity(input))\n}\n";
353        let violations = analyze_determinism(source, "run")?;
354        assert_eq!(violations.len(), 1);
355        assert_eq!(violations[0].call, "erlang.system_time");
356        assert_eq!(violations[0].kind, ViolationKind::WallClock);
357        assert_eq!(violations[0].function, "run");
358        Ok(())
359    }
360
361    #[test]
362    fn entropy_in_a_reachable_helper_is_flagged() -> Result<(), Box<dyn std::error::Error>> {
363        let source = "pub fn run(input) {\n  \
364             let id = make_id(input)\n  \
365             workflow.run(wrappers.charge_activity(id))\n}\n\
366             fn make_id(input) {\n  float.random()\n}\n";
367        let violations = analyze_determinism(source, "run")?;
368        assert_eq!(violations.len(), 1, "{violations:?}");
369        assert_eq!(violations[0].call, "float.random");
370        assert_eq!(violations[0].kind, ViolationKind::Entropy);
371        assert_eq!(violations[0].function, "make_id");
372        Ok(())
373    }
374
375    #[test]
376    fn entropy_in_a_helper_passed_as_a_value_is_flagged() -> Result<(), Box<dyn std::error::Error>>
377    {
378        // `tainted` is never applied directly — it is passed as a bare function
379        // value to a higher-order call (`list.map`), which invokes it. A gate
380        // that followed only direct `name(` calls would miss the entropy hiding
381        // behind the passed helper; this is the soundness edge the depth-aware
382        // walk closes.
383        let source = "pub fn run(input) {\n  \
384             let _ = list.map(input, tainted)\n  \
385             workflow.run(wrappers.charge_activity(input))\n}\n\
386             fn tainted(item) {\n  float.random()\n}\n";
387        let violations = analyze_determinism(source, "run")?;
388        assert_eq!(violations.len(), 1, "{violations:?}");
389        assert_eq!(violations[0].call, "float.random");
390        assert_eq!(violations[0].kind, ViolationKind::Entropy);
391        assert_eq!(violations[0].function, "tainted");
392        Ok(())
393    }
394
395    #[test]
396    fn unreachable_helper_violation_is_not_flagged() -> Result<(), Box<dyn std::error::Error>> {
397        // `tainted` draws entropy but is never called from `run`: it is dead
398        // relative to the workflow, so it must not be flagged.
399        let source = "pub fn run(input) {\n  \
400             workflow.run(wrappers.charge_activity(input))\n}\n\
401             fn tainted(input) {\n  int.random()\n}\n";
402        assert!(analyze_determinism(source, "run")?.is_empty());
403        Ok(())
404    }
405
406    #[test]
407    fn forbidden_word_inside_a_string_literal_is_not_flagged()
408    -> Result<(), Box<dyn std::error::Error>> {
409        // The tokeniser excludes string contents from matching, so a log message
410        // mentioning the call is never a false positive.
411        let source = "pub fn run(input) {\n  \
412             log(\"erlang.system_time is forbidden here\")\n  \
413             workflow.run(wrappers.charge_activity(input))\n}\n";
414        assert!(analyze_determinism(source, "run")?.is_empty());
415        Ok(())
416    }
417
418    #[test]
419    fn missing_entry_function_is_a_loud_error() {
420        let source = "fn helper() {\n  Nil\n}\n";
421        let result = analyze_determinism(source, "run");
422        assert_eq!(
423            result,
424            Err(DeterminismError::EntryFunctionNotFound {
425                function: "run".to_owned(),
426            })
427        );
428    }
429
430    #[test]
431    fn mutually_recursive_helpers_terminate() -> Result<(), Box<dyn std::error::Error>> {
432        let source = "pub fn run(input) {\n  ping(input)\n}\n\
433             fn ping(input) {\n  pong(input)\n}\n\
434             fn pong(input) {\n  ping(input)\n  os.system_time(1)\n}\n";
435        let violations = analyze_determinism(source, "run")?;
436        assert_eq!(violations.len(), 1);
437        assert_eq!(violations[0].call, "os.system_time");
438        Ok(())
439    }
440
441    #[test]
442    fn multiple_distinct_calls_are_all_reported() -> Result<(), Box<dyn std::error::Error>> {
443        let source = "pub fn run(input) {\n  \
444             let a = os.system_time(1)\n  \
445             let b = crypto.strong_rand_bytes(16)\n  \
446             let c = erlang.unique_integer([])\n}\n";
447        let violations = analyze_determinism(source, "run")?;
448        let calls: Vec<&str> = violations.iter().map(|v| v.call.as_str()).collect();
449        assert_eq!(
450            calls,
451            vec![
452                "crypto.strong_rand_bytes",
453                "erlang.unique_integer",
454                "os.system_time",
455            ]
456        );
457        Ok(())
458    }
459}