Repository F# setup
open System
open System.IO
open System.Threading
open System.Threading.Tasks
open Axial
open Axial.Layers
open Axial.Console
open Axial.FileSystem
open Axial.Hosting
open Axial.Hosting.Browser
open Axial.Hosting.Node
open Axial.PlatformService
open Axial.State
open Axial.Telemetry
open Axial.Telemetry.JavaScript

Guardrail diagnostics

Each AXG diagnostic has a narrow contract: the effect or convention it detects, the service or form that replaces it, and the suppression directive that allows an intentional boundary. The adoption guide covers install, severity, and suppression choices; this page is the per-diagnostic reference.

AXG001: Use explicit effect services

AXG001 detects direct calls to ambient .NET effects.

Category Detects Use instead
random Construction of System.Random IRandom, Random.service, or Random.nextDouble
guid Guid.NewGuid() IGuid, Guid.service, or Guid.newGuid
clock Ambient date and time properties, Stopwatch, and Task.Delay IClock, Clock.service, Clock.utcNow, Clock.timed, Flow.sleep, or Schedule
environment Ambient environment variables, machine or process properties, and TimeZoneInfo.Local IEnvironment or a value passed through 'env; an explicit TimeZoneInfo for conversions; Parallelism.ofProcessors for ProcessorCount
console Any System.Console member Axial.Console.IConsole
filesystem Any System.IO.File or System.IO.Directory member Axial.FileSystem.IFileSystem
process System.Diagnostics.Process.Start Axial.Process.IProcess
sleep Thread.Sleep Flow.sleep or Schedule
http Construction of HttpClient Axial.HttpClient.IHttp, with Http.live over one shared client
blocking Async.RunSynchronously, Task.Wait()/Wait(timeout), task/value-task .Result, and .GetAwaiter().GetResult() outside flows; task .Result and .GetAwaiter().GetResult() inside flows Await asynchronously outside flows; let! on the task inside flows
scope A direct Cache.make*, Hub.make/bounded, or Queue.bounded/unbounded returned by a run Use the service before the scope closes

Reads of .Result or .GetAwaiter().GetResult() on a task or value task are exempt when the same value's completion state is tested in the enclosing if branch or when guard, or when the value is the parameter of a ContinueWith callback. Other waits remain findings. Blocking waits outside flows can deadlock UI synchronization contexts. At a genuine synchronous entry point or test boundary, use // axial-allow-effect: blocking.

TimeZoneInfo.ConvertTimeFromUtc(utc, zone) is allowed when zone is supplied explicitly; reading TimeZoneInfo.Local to supply that argument is not. Pass the desired zone through configuration or 'env.

The analyzer matches resolved System.* symbols, not source text. It does not flag an application type named Random or a local value named now.

Allow an intentional effect boundary

Live service implementations, process entry points, and the scheduler implement explicit effect boundaries.

To allow one call, add a category-specific directive to the flagged line or the line immediately above it:

Verified
let live : IClock =
    let stopwatch = System.Diagnostics.Stopwatch.StartNew() // axial-allow-effect: clock

    { new IClock with
        member _.UtcNow() = DateTimeOffset.UtcNow // axial-allow-effect: clock
        member _.Elapsed() = stopwatch.Elapsed
        member _.Sleep(delay, token) = Task.Delay(delay, token) }
Requires & types
Requires: open Axial; open System; open System.Threading.Tasks (packages: Axial)
val live: IClock

To allow a category throughout a boundary implementation file, place a file directive in the leading comment block immediately before the namespace declaration:

Verified
// This file provides the live IConsole implementation.
// axial-allow-effect-file: console

A file directive allows only the named categories. For example, a console directive does not allow System.Random().

Each directive must name a category. There is no directive that allows every effect.

For the exact matching and suppression rules, see EffectCatalog.fs and Suppressions.fs in src/Axial.Guardrails.

AXG002: Remove invalid suppressions

AXG002 validates every axial-allow-effect and axial-allow-effect-file directive.

It reports a directive when the category is unknown or when the directive does not suppress an AXG001 finding. This check catches spelling errors and directives left behind after a refactoring. A directive whose guardrail is turned off suppresses nothing, so it is reported too.

Remove an unused directive. Correct a category only when the associated call is an intentional effect boundary.

AXG003: Return typed failures from workflows

A Flow<'env, 'error, 'value> represents expected failures through its 'error channel. Raising an exception inside flow { } creates an unhandled defect instead, so callers cannot handle it as an expected error.

AXG003 detects direct calls to raise, failwith, failwithf, invalidOp, invalidArg, and reraise inside flow { }. It also checks nested conditionals, matches, bindings, and lambdas.

Use return! Flow.fail error for an expected failure. Use Flow.die when the failure is an unrecoverable defect.

If a nearby try/with catches and translates the exception, add axial-allow-raise to the flagged line or the line immediately above it:

Verified
let start (proc: System.Diagnostics.Process) =
    if not (proc.Start()) then raise (Exception "Process did not start") // axial-allow-raise
Requires & types
val start: Diagnostics.Process -> unit

Use this directive only when the same local boundary catches and translates the exception.

AXG004: Create fresh test fixtures

A module-level let value is initialized once and shared by every test in the module. Mutable or stateful values can therefore make parallel xUnit tests interfere with each other.

AXG004 checks modules that contain an xUnit [<Fact>] or [<Theory>], or an FsCheck [<Property>]. It reports module-level values but not functions such as let fixture () = ....

The analyzer excludes constants and point-free function definitions because they do not create the shared-state risk.

Create fixtures inside each test or expose a function that returns a fresh fixture. For a safe immutable value, add axial-allow-fixture to the flagged line or the line immediately above it:

Verified
let private syntheticTime = DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero) // axial-allow-fixture
Requires & types
Requires: open System
val syntheticTime: DateTimeOffset

AXG005: Preserve cancellation tokens

Task adapters receive Flow's cancellation token. Discarding that token prevents cancellation from stopping the underlying operation.

AXG005 checks ColdTask, ColdTask.create, Flow.fromTask, and Flow.fromTaskResult. It reports a single-argument lambda when the argument is a bare discard, such as fun _ -> ....

Use the cancellation-aware overload of the wrapped operation and pass the token to it.

If a legacy API has no cancellation-aware overload, add axial-allow-discarded-cancellation to the flagged line or the line immediately above it:

Verified
/// An API with no cancellation support.
let legacyCall () : Task<int> = Task.FromResult 42

let legacy = ColdTask(fun _ -> legacyCall ()) // axial-allow-discarded-cancellation
Requires & types
Requires: open Axial; open System.Threading.Tasks (packages: Axial)
val legacyCall: unit -> Task<int>
val legacy: ColdTask<int>

This check does not run in test projects. Test adapters often wrap completed tasks that have no work to cancel.

AXG006: Format without reflection

F# unions and records get a compiler-generated ToString() that calls sprintf "%+A". %A walks the value with FSharp.Reflection, and FSharp.Core's option, voption, list, Result, Choice, Map, and Set print their contents the same way. NativeAOT and full trimming remove the metadata this needs. The call then throws or prints the wrong text, and the build gives no specific warning because the reflection happens inside FSharp.Core.

AXG006 reports these formatting sites in typed code:

  • x.ToString(), string x, and interpolation holes such as $"{x}" when x is an F# union, record, anonymous record, exception, or one of the FSharp.Core types above, and the type has no hand-written ToString override
  • the same forms on a type parameter, whose substituted type may be a union or record
  • %A on any value other than a primitive, even when its type overrides ToString
  • a boxed value of those types passed to String.Format, String.Concat, StringBuilder.Append, TextWriter.Write, Console.Write, Trace.WriteLine, or Debug.WriteLine

Render the value explicitly instead. Match on the cases, call a describe function, or give the type a hand-written override:

Verified
type OrderError =
    | OutOfStock of sku: string
    | InvalidQuantity of int

    override this.ToString() =
        match this with
        | OutOfStock sku -> $"Out of stock: {sku}"
        | InvalidQuantity quantity -> $"Invalid quantity: {quantity}"

With that override, $"{error}", string error, and %O are safe. %A is not.

Axial's own public unions render themselves this way, including Exit, Cause, FiberStatus, FiberId, and the Process, HttpClient, FileSystem, and PlatformService error types. A payload inside Exit or Cause is still rendered with its own ToString, so give your error types an override too. Cause.prettyPrint also accepts an explicit error renderer.

If code never runs trimmed, such as a script or a test helper, add axial-allow-reflection-format to the flagged line or the line immediately above it:

Verified
let printDiagnostics (diagnostics: Map<string, int list>) =
    printfn "%A" diagnostics // axial-allow-reflection-format
Requires & types
val printDiagnostics: Map<string,int list> -> unit

scripts/run-aot-probe.sh publishes a NativeAOT probe that renders these types and dumps a fiber registry, so the release checks catch a regression that the analyzer cannot see.

AXG007: Keep resources acquired by a fiber

A forked fiber owns the scoped resources it acquires and releases them when it settles. Fiber.join returns the fiber's value after that, so a resource the fiber returns is already released:

Verified
let connection : Resource<ClockEnvironment, Never, MemoryStream> =
    Resource.create
        (Flow.ok (new MemoryStream()))
        (fun stream -> Flow.delay (fun () -> stream.Dispose(); Flow.ok ()))

/// Reported: the connection is released before join returns it.
let releasedBeforeUse =
    flow {
        let! fiber = Flow.scopeResource connection |> Flow.fork
        return! Fiber.join fiber
    }
Requires & types
Requires: open Axial; open System.IO (packages: Axial)
val connection: Resource<ClockEnvironment,Never,MemoryStream>
val releasedBeforeUse: Flow<ClockEnvironment,Never,MemoryStream>

The code compiles and reads like ZIO, where a forked fiber shares its parent's Scope. In Axial the forked fiber's own scope closes when it settles, so the release runs before Fiber.join returns: the stream is already disposed, and the first use after the join throws ObjectDisposedException.

AXG007 reports a Flow.fork, forkNamed, forkGraceful, forkDetached, forkReplacing, or forkReplacingKey of a flow that is a Flow.scopeResource or Flow.scopeAcquireRelease call, or a flow { } that returns what it acquired with one of them. Acquire the resource with Flow.forkResource to keep it in the caller's scope, or with Flow.zipPar or Flow.sequencePar to acquire several in parallel:

Verified
let keptForUse =
    flow {
        let! connecting = Flow.forkResource connection
        return! Fiber.join connecting
    }
Requires & types
Requires: open Axial (packages: Axial)
val keptForUse: Flow<ClockEnvironment,Never,MemoryStream>

Concurrency and scoped resources explains which scope owns a resource in each concurrent combinator.

If the fiber's value is never used after the fiber settles, add axial-allow-resource-from-fiber to the flagged line or the line immediately above it.

AXG008: Keep scope-owned services inside their run

Flow.run, Flow.runSync, Flow.RunSynchronously, and StartAsTask close their scope after completion. Returning Queue.makeScoped, Hub.makeScoped, or Hub.subscribe directly leaves a shut-down service. Caches made by Cache.make, makeBounded, makeBoundedWithExpiry, or makeRec capture the creating runtime; closing its scope interrupts lookups. Use these services inside the flow or keep their owning scope open. Queue.make (and its bounded, unbounded, dropping, and sliding shorthands) and Hub.make do not register with the scope and may be returned. The syntax check sees direct calls and pipelines ending in a scoped constructor; the result-type check catches composed cache flows but does not flag Queue or Hub by type because it cannot distinguish scoped from unscoped construction. At an intentional boundary, add // axial-allow-effect: scope.