towerops/docs/plans/2026-03-21-gleam4-batch-conversion.md
Graham McIntire 6ef6b3d61d fix: comprehensive security audit fixes (#108)
This commit addresses multiple CRITICAL, HIGH, and MEDIUM severity security vulnerabilities identified in the security audit:

CRITICAL FIXES:
- Fix weak RNG for recovery codes - replaced Enum.random() with :crypto.strong_rand_bytes/1 for cryptographically secure token generation
- Fix subscription limit race conditions - moved free org and device quota checks inside transactions with FOR UPDATE locks to prevent concurrent bypass
- Fix default organization race condition - moved is_default check inside transaction to prevent multiple defaults per user

HIGH SEVERITY FIXES:
- Fix agent token deletion race condition - moved PubSub broadcast inside transaction to ensure agents only receive notification after successful deletion

MEDIUM SEVERITY FIXES:
- Fix LIKE wildcard injection in search - applied sanitize_like() to all user-facing search queries in devices.ex, sites.ex, and gaiia.ex to prevent enumeration attacks
- Fix Jason.decode! DoS - replaced with safe Jason.decode/1 with error handling in device_live/index.ex
- Fix SSRF vulnerability - added URL validation in HTTP executor to block requests to private/internal IP ranges (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16)
- Fix error information leakage - replaced inspect() in API responses with generic error messages, logging details server-side only
- Fix atom table pollution - HTTP method normalization now uses whitelist mapping instead of String.to_atom()

SECURITY IMPROVEMENTS:
- All quota checks now use pessimistic locking (SELECT FOR UPDATE) to prevent TOCTOU race conditions
- Private IP validation prevents cloud metadata service access (169.254.169.254)
- DNS resolution performed before HTTP requests to detect IP spoofing
- Error details logged server-side but not exposed to clients

Files changed:
- lib/towerops/accounts/user_recovery_code.ex
- lib/towerops/organizations.ex
- lib/towerops/devices.ex
- lib/towerops/sites.ex
- lib/towerops/gaiia.ex
- lib/towerops/agents.ex
- lib/towerops/monitoring/executors/http_executor.ex
- lib/towerops_web/live/device_live/index.ex
- lib/towerops_web/controllers/api/v1/mib_controller.ex
- lib/towerops_web/controllers/api/v1/agent_release_webhook_controller.ex
- lib/towerops_web/controllers/api/v1/geoip_controller.ex

Reviewed-on: graham/towerops-web#108
2026-03-22 10:10:27 -05:00

40 KiB

Gleam Batch 4: Convert Pure-Function Modules to Gleam

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Convert 9 pure-function Elixir modules to Gleam, keeping thin Elixir wrappers for Ecto/OTP integration.

Architecture: Each module follows the established pattern: pure logic moves to .gleam in src/, FFI .erl files bridge Erlang stdlib, Elixir callers use :towerops@module@name.function() syntax. Ecto Type callbacks (cast/load/dump) and Repo/PubSub calls stay in Elixir.

Tech Stack: Gleam (BEAM target), Erlang FFI, Elixir wrappers, ExUnit tests unchanged.


Conversion Pattern Reference

Gleam modules compile to BEAM modules named with @ separators:

  • src/towerops/foo.gleam:towerops@foo
  • Called from Elixir: :towerops@foo.my_function(arg1, arg2)

FFI files go in src/ as .erl modules referenced via @external(erlang, "module_name", "function_name").

Existing tests stay in ExUnit — they call the Elixir wrapper which delegates to Gleam.

Important Gleam conventions:

  • No nil — use Option(a) with Some(value) / None
  • No exceptions — use Result(a, e) with Ok(value) / Error(reason)
  • Snake_case for functions, PascalCase for types
  • Pattern matching via case expressions
  • Strings are UTF-8 binaries (same as Elixir)
  • Lists are linked lists (same as Elixir)
  • Use gleam/string, gleam/int, gleam/float, gleam/list from stdlib
  • Use gleam/regexp for regex (already a dependency)
  • No mutable state, no processes
  • Gleam Float and Int are separate types — explicit conversion required

Task 1: Create branch and set up

Files:

  • Branch: gleam4

Step 1: Create branch

cd /Users/graham/dev/towerops/towerops-web
git checkout -b gleam4

Step 2: Verify Gleam builds

cd /Users/graham/dev/towerops/towerops-web
gleam build

Step 3: Verify tests pass

mix test

Expected: All tests pass, clean starting point.


Task 2: Convert Towerops.Organizations.Policy

Pure authorization logic: (role, action, resource) → bool.

Files:

  • Create: src/towerops/organizations/policy.gleam
  • Modify: lib/towerops/organizations/policy.ex

Step 1: Create the Gleam module

Create src/towerops/organizations/policy.gleam with these types and functions:

/// Role-based authorization policy for organization resources.
/// Compiles to `:towerops@organizations@policy`.

/// Organization member roles
pub type Role {
  Owner
  Admin
  Executive
  Technician
  Member
  Viewer
}

/// Actions that can be performed
pub type Action {
  View
  List
  Create
  Edit
  Delete
  Acknowledge
  Manage
  OtherAction
}

/// Resources within an organization
pub type Resource {
  Organization
  Site
  Device
  Alert
  Membership
  Invitation
  Integration
  Financials
  OtherResource
}

/// Check if a role can perform an action on a resource.
pub fn check_permission(role: Role, action: Action, resource: Resource) -> Bool {
  case role {
    Owner -> True
    Admin -> check_admin(action, resource)
    Executive -> check_executive(action, resource)
    Technician -> check_technician(action, resource)
    Member -> check_technician(action, resource)
    Viewer -> check_viewer(action, resource)
  }
}

/// Returns true if the role can view financial data.
pub fn financials_visible(role: Role) -> Bool {
  case role {
    Owner | Admin | Executive -> True
    _ -> False
  }
}

fn check_admin(action: Action, resource: Resource) -> Bool {
  case action, resource {
    Delete, Organization -> False
    _, _ -> True
  }
}

fn check_executive(action: Action, resource: Resource) -> Bool {
  case action, resource {
    View, _ -> True
    List, _ -> True
    Acknowledge, Alert -> True
    _, _ -> False
  }
}

fn check_technician(action: Action, resource: Resource) -> Bool {
  case action, resource {
    View, Financials -> False
    View, Organization -> True
    List, Organization -> True
    Delete, _ -> False
    Create, Membership -> False
    Edit, Membership -> False
    Create, Invitation -> False
    Delete, Invitation -> False
    Create, Integration -> False
    Edit, Integration -> False
    Delete, Integration -> False
    View, Site -> True
    List, Site -> True
    Create, Site -> True
    Edit, Site -> True
    View, Device -> True
    List, Device -> True
    Create, Device -> True
    Edit, Device -> True
    View, Alert -> True
    List, Alert -> True
    Create, Alert -> True
    Edit, Alert -> True
    Acknowledge, Alert -> True
    _, _ -> False
  }
}

fn check_viewer(action: Action, _resource: Resource) -> Bool {
  case action {
    View -> True
    List -> True
    Acknowledge -> True
    _ -> False
  }
}

Step 2: Create FFI for atom-to-type conversion

Create src/towerops_policy_ffi.erl:

-module(towerops_policy_ffi).
-export([atom_to_role/1, atom_to_action/1, atom_to_resource/1]).

atom_to_role(owner) -> {ok, owner};
atom_to_role(admin) -> {ok, admin};
atom_to_role(executive) -> {ok, executive};
atom_to_role(technician) -> {ok, technician};
atom_to_role(member) -> {ok, member};
atom_to_role(viewer) -> {ok, viewer};
atom_to_role(_) -> {error, nil}.

atom_to_action(view) -> {ok, view};
atom_to_action(list) -> {ok, list};
atom_to_action(create) -> {ok, create};
atom_to_action(edit) -> {ok, edit};
atom_to_action(delete) -> {ok, delete};
atom_to_action(acknowledge) -> {ok, acknowledge};
atom_to_action(manage) -> {ok, manage};
atom_to_action(_) -> {ok, other_action}.

atom_to_resource(organization) -> {ok, organization};
atom_to_resource(site) -> {ok, site};
atom_to_resource(device) -> {ok, device};
atom_to_resource(alert) -> {ok, alert};
atom_to_resource(membership) -> {ok, membership};
atom_to_resource(invitation) -> {ok, invitation};
atom_to_resource(integration) -> {ok, integration};
atom_to_resource(financials) -> {ok, financials};
atom_to_resource(_) -> {ok, other_resource}.

Wait — Gleam custom types compile to Erlang atoms/tuples. The Gleam type Owner becomes atom owner, Admin becomes admin, etc. So the Elixir wrapper can just pass atoms directly as the Gleam constructors are lowercase atoms on the BEAM. We don't need FFI at all — Elixir atoms map directly to Gleam constructors.

Actually, let me reconsider. Gleam custom type variants compile to atoms only if they have no fields. Owner (no fields) compiles to owner atom. So :towerops@organizations@policy.check_permission(:owner, :view, :site) should work directly.

But OtherAction and OtherResource compile to other_action and other_resource atoms. The Elixir atom :manage won't match manage Gleam variant because Gleam doesn't know about it... wait, we defined Manage as a variant, so it compiles to atom manage. Good.

The only issue is unknown atoms. If someone passes :foo_bar as an action, Gleam will crash with a function clause error. The Elixir wrapper should handle this with a catch-all.

Step 2 (revised): Update the Elixir wrapper

Modify lib/towerops/organizations/policy.ex to delegate to Gleam:

defmodule Towerops.Organizations.Policy do
  @moduledoc """
  Authorization policy for organization resources.
  Pure logic delegated to Gleam module `:towerops@organizations@policy`.
  """

  alias Towerops.Organizations.Membership

  @gleam_module :towerops@organizations@policy

  def can?(%Membership{role: role}, action, resource) do
    @gleam_module.check_permission(role, action, resource)
  end

  def can?(nil, _action, _resource), do: false

  def can_view_financials?(%Membership{role: role}), do: @gleam_module.financials_visible(role)
  def can_view_financials?(nil), do: false
end

Hmm, but this won't work for unknown action/resource atoms. The Gleam function expects specific variant atoms. If :manage is passed as action, it matches manage in Gleam (we defined Manage). If :anything is passed, it'll crash. Looking at the tests: Policy.can?(membership, :manage, :members):members is used as a resource. We don't have a Members variant.

Looking at the original code more carefully: the original Policy module uses catch-all patterns extensively. The owner returns true for ANY action/resource. The admin returns true for almost anything. The viewer returns true for :view and :list on ANY resource.

So we need either:

  1. Map all unknown atoms to OtherAction/OtherResource in the Elixir wrapper
  2. Or use the FFI approach

Let's go with option 1 — a simple mapping in the Elixir wrapper:

defp map_action(action) when action in [:view, :list, :create, :edit, :delete, :acknowledge, :manage], do: action
defp map_action(_), do: :other_action

defp map_resource(resource) when resource in [:organization, :site, :device, :alert, :membership, :invitation, :integration, :financials], do: resource
defp map_resource(_), do: :other_resource

Step 3: Run tests

mix test test/towerops/organizations/policy_test.exs

Expected: All tests pass.

Step 4: Run full test suite

mix test

Expected: All tests pass.

Step 5: Verify Gleam builds clean

gleam build

Step 6: Commit

git add src/towerops/organizations/policy.gleam lib/towerops/organizations/policy.ex
git commit -m "Convert Organizations.Policy authorization logic to Gleam"

Task 3: Convert Towerops.Result

Elixir Result helpers map naturally to Gleam's native Result type.

Files:

  • Create: src/towerops/result.gleam
  • Create: src/towerops_result_ffi.erl
  • Modify: lib/towerops/result.ex

Step 1: Create the Gleam module

Since Gleam's Result type uses Ok/Error which are the same as Elixir's {:ok, v}/{:error, e} on the BEAM, we can implement the helpers directly:

/// Result type utilities.
/// Compiles to `:towerops@result`.
import gleam/dynamic.{type Dynamic}

/// Map the success value, passing errors through.
pub fn map(result: Result(a, e), fun: fn(a) -> b) -> Result(b, e) {
  case result {
    Ok(value) -> Ok(fun(value))
    Error(e) -> Error(e)
  }
}

/// Map the error value, passing successes through.
pub fn map_error(result: Result(a, e1), fun: fn(e1) -> e2) -> Result(a, e2) {
  case result {
    Ok(value) -> Ok(value)
    Error(e) -> Error(fun(e))
  }
}

/// Chain operations that return results.
pub fn and_then(result: Result(a, e), fun: fn(a) -> Result(b, e)) -> Result(b, e) {
  case result {
    Ok(value) -> fun(value)
    Error(e) -> Error(e)
  }
}

/// Unwrap a result, returning the success value or a default.
pub fn unwrap_or(result: Result(a, e), default: a) -> a {
  case result {
    Ok(value) -> value
    Error(_) -> default
  }
}

/// Check if the result is Ok.
pub fn is_ok(result: Result(a, e)) -> Bool {
  case result {
    Ok(_) -> True
    Error(_) -> False
  }
}

/// Check if the result is Error.
pub fn is_error(result: Result(a, e)) -> Bool {
  case result {
    Ok(_) -> False
    Error(_) -> True
  }
}

Wait — there's a problem. Elixir calls these functions with anonymous functions as arguments:

Result.map({:ok, 5}, fn x -> x * 2 end)

When calling from Elixir to Gleam, anonymous functions pass through fine since they're both BEAM funs. So :towerops@result.map({:ok, 5}, fn x -> x * 2 end) should work.

But the Gleam type system requires typed function arguments. Since Elixir will pass Dynamic values, we need to use Dynamic types. Actually, on the BEAM level, types are erased — Gleam's type checking is compile-time only. At runtime, any value can be passed. So the Gleam module will work with whatever Elixir passes.

Step 2: Update the Elixir wrapper

defmodule Towerops.Result do
  @moduledoc """
  Generic result type. Pure logic delegated to Gleam `:towerops@result`.
  """

  @type t(ok_value, error_value) :: {:ok, ok_value} | {:error, error_value}
  @type t(ok_value) :: t(ok_value, term())

  defdelegate map(result, fun), to: :towerops@result
  defdelegate map_error(result, fun), to: :towerops@result
  defdelegate and_then(result, fun), to: :towerops@result
  defdelegate unwrap_or(result, default), to: :towerops@result

  def ok?(result), do: :towerops@result.is_ok(result)
  def error?(result), do: :towerops@result.is_error(result)
end

Step 3: Run tests

mix test test/towerops/result_test.exs

Expected: All tests pass.

Step 4: Commit

git add src/towerops/result.gleam lib/towerops/result.ex
git commit -m "Convert Result module to Gleam"

Task 4: Convert Towerops.EctoTypes.MacAddress (parsing core)

Extract the pure parsing/formatting logic to Gleam. Ecto Type callbacks stay in Elixir.

Files:

  • Create: src/towerops/ecto_types/mac_address.gleam
  • Create: src/towerops_mac_address_ffi.erl
  • Modify: lib/towerops/ecto_types/mac_address.ex

Step 1: Create the Gleam module

The Gleam module handles: parsing strings in 4 formats, formatting binary to 4 output formats, hex octet conversion.

/// MAC address parsing and formatting.
/// Compiles to `:towerops@ecto_types@mac_address`.
import gleam/int
import gleam/list
import gleam/regexp
import gleam/result
import gleam/string

/// Parse a MAC address string into a list of 6 byte values.
/// Accepts colon, hyphen, dot (Cisco), and compact formats.
/// Returns Ok(bytes) or Error(Nil).
pub fn parse_string(value: String) -> Result(List(Int), Nil) {
  let trimmed = string.trim(value)
  case trimmed {
    "" -> Error(Nil)
    _ -> try_parse_formats(trimmed)
  }
}

/// Format 6 bytes as colon-separated lowercase hex.
pub fn format_colon(bytes: List(Int)) -> String {
  bytes |> list.map(format_octet) |> string.join(":")
}

/// Format 6 bytes as hyphen-separated lowercase hex.
pub fn format_hyphen(bytes: List(Int)) -> String {
  bytes |> list.map(format_octet) |> string.join("-")
}

/// Format 6 bytes as dot-separated groups of 4 hex chars (Cisco).
pub fn format_dot(bytes: List(Int)) -> String {
  case bytes {
    [a, b, c, d, e, f] ->
      format_octet(a) <> format_octet(b)
      <> "."
      <> format_octet(c) <> format_octet(d)
      <> "."
      <> format_octet(e) <> format_octet(f)
    _ -> ""
  }
}

/// Format 6 bytes as compact (no separators).
pub fn format_compact(bytes: List(Int)) -> String {
  bytes |> list.map(format_octet) |> string.join("")
}

fn format_octet(byte: Int) -> String {
  byte
  |> int.to_base16()
  |> string.lowercase()
  |> string.pad_start(to: 2, with: "0")
}

fn try_parse_formats(value: String) -> Result(List(Int), Nil) {
  let assert Ok(colon_re) = regexp.from_string("^([0-9a-fA-F]{2}):([0-9a-fA-F]{2}):([0-9a-fA-F]{2}):([0-9a-fA-F]{2}):([0-9a-fA-F]{2}):([0-9a-fA-F]{2})$")
  let assert Ok(hyphen_re) = regexp.from_string("^([0-9a-fA-F]{2})-([0-9a-fA-F]{2})-([0-9a-fA-F]{2})-([0-9a-fA-F]{2})-([0-9a-fA-F]{2})-([0-9a-fA-F]{2})$")
  let assert Ok(dot_re) = regexp.from_string("^([0-9a-fA-F]{4})\\.([0-9a-fA-F]{4})\\.([0-9a-fA-F]{4})$")
  let assert Ok(compact_re) = regexp.from_string("^([0-9a-fA-F]{12})$")

  case regexp.scan(colon_re, value) {
    [match] -> parse_separated_match(match)
    _ ->
      case regexp.scan(hyphen_re, value) {
        [match] -> parse_separated_match(match)
        _ ->
          case regexp.scan(dot_re, value) {
            [match] -> parse_dot_match(match)
            _ ->
              case regexp.scan(compact_re, value) {
                [match] -> parse_compact_match(match)
                _ -> Error(Nil)
              }
          }
      }
  }
}

fn parse_separated_match(match: regexp.Match) -> Result(List(Int), Nil) {
  match.submatches
  |> list.filter_map(fn(submatch) {
    case submatch {
      gleam.option.Some(s) -> parse_hex(s)
      gleam.option.None -> Error(Nil)
    }
  })
  |> fn(bytes) {
    case list.length(bytes) == 6 {
      True -> Ok(bytes)
      False -> Error(Nil)
    }
  }
}

Hmm, this is getting complex with the regexp submatches. Let me simplify — since we know the format matched, we can just split by separator and parse each part:

fn try_parse_formats(value: String) -> Result(List(Int), Nil) {
  // Try colon format: "00:11:22:33:44:55"
  case try_separated(value, ":") {
    Ok(bytes) -> Ok(bytes)
    Error(_) ->
      // Try hyphen format: "00-11-22-33-44-55"
      case try_separated(value, "-") {
        Ok(bytes) -> Ok(bytes)
        Error(_) ->
          // Try dot format: "0011.2233.4455"
          case try_dot(value) {
            Ok(bytes) -> Ok(bytes)
            Error(_) ->
              // Try compact format: "001122334455"
              try_compact(value)
          }
      }
  }
}

fn try_separated(value: String, sep: String) -> Result(List(Int), Nil) {
  let parts = string.split(value, sep)
  case list.length(parts) {
    6 -> parse_hex_list(parts)
    _ -> Error(Nil)
  }
}

fn try_dot(value: String) -> Result(List(Int), Nil) {
  let groups = string.split(value, ".")
  case groups {
    [g1, g2, g3] if string.length(g1) == 4 && string.length(g2) == 4 && string.length(g3) == 4 ->
      // Split each 4-char group into two 2-char octets
      let octets = split_groups([g1, g2, g3])
      parse_hex_list(octets)
    _ -> Error(Nil)
  }
}

Actually, I'm over-specifying the Gleam code in this plan. The implementor should follow the pattern from existing conversions. Let me keep the plan higher-level for the implementation details and focus on the structure.

The key decisions for this task:

  1. Gleam module at src/towerops/ecto_types/mac_address.gleam exports:

    • parse_string(value: String) -> Result(List(Int), Nil) — parse any format to 6 bytes
    • format_colon(bytes: List(Int)) -> String
    • format_hyphen(bytes: List(Int)) -> String
    • format_dot(bytes: List(Int)) -> String
    • format_compact(bytes: List(Int)) -> String
  2. FFI at src/towerops_mac_address_ffi.erl exports:

    • parse_hex(String) -> {ok, Int} | {error, nil} — wraps Integer.parse(s, 16)
    • list_to_binary(List) -> Binary — wraps erlang:list_to_binary/1
    • binary_to_list(Binary) -> List — wraps erlang:binary_to_list/1
    • is_printable(Binary) -> Bool — wraps String.printable?/1
    • byte_size(Binary) -> Int
  3. Elixir wrapper at lib/towerops/ecto_types/mac_address.ex keeps:

    • All use Ecto.Type callbacks (type/cast/load/dump)
    • The struct definition and @derive Jason.Encoder
    • new/1, new!/1, to_string/1, to_binary/1, format/2
    • Delegates parsing to Gleam: case :towerops@ecto_types@mac_address.parse_string(value)
    • Delegates formatting to Gleam for format/2

Step 2: Run tests

mix test test/towerops/ecto_types/mac_address_test.exs

Expected: All 27 tests pass.

Step 3: Commit

git add src/towerops/ecto_types/mac_address.gleam src/towerops_mac_address_ffi.erl lib/towerops/ecto_types/mac_address.ex
git commit -m "Extract MacAddress parsing/formatting to Gleam"

Task 5: Convert Towerops.Preseem.Baseline (compute_stats)

Extract only the pure compute_stats/1 function.

Files:

  • Create: src/towerops/preseem/baseline.gleam
  • Create: src/towerops_baseline_ffi.erl
  • Modify: lib/towerops/preseem/baseline.ex

Step 1: Create the Gleam module

/// Statistical baseline computation.
/// Compiles to `:towerops@preseem@baseline`.
import gleam/float
import gleam/int
import gleam/list

/// Result of statistical computation.
pub type Stats {
  Stats(mean: Float, stddev: Float, p5: Float, p95: Float)
}

/// Compute mean, stddev, p5, p95 from a list of float values.
/// List must have at least 1 element.
pub fn compute_stats(values: List(Float)) -> Stats {
  let sorted = list.sort(values, float.compare)
  let n = list.length(sorted)
  let n_float = int_to_float(n)
  let sum = float_sum(sorted)
  let mean = sum /. n_float

  let variance = compute_variance(sorted, mean, n)
  let stddev = float_sqrt(variance)

  let p5_idx = max_int(float_round(n_float *. 0.05) - 1, 0)
  let p95_idx = min_int(float_round(n_float *. 0.95) - 1, n - 1)

  let p5 = list_at(sorted, p5_idx)
  let p95 = list_at(sorted, p95_idx)

  Stats(
    mean: float_round_to(mean, 4),
    stddev: float_round_to(stddev, 4),
    p5: float_round_to(p5, 4),
    p95: float_round_to(p95, 4),
  )
}

fn compute_variance(sorted: List(Float), mean: Float, n: Int) -> Float {
  let divisor = int_to_float(max_int(n - 1, 1))
  sorted
  |> list.map(fn(v) { { v -. mean } *. { v -. mean } })
  |> float_sum()
  |> fn(s) { s /. divisor }
}

fn float_sum(values: List(Float)) -> Float {
  list.fold(values, 0.0, fn(acc, v) { acc +. v })
}

fn max_int(a: Int, b: Int) -> Int {
  case a > b { True -> a  False -> b }
}

fn min_int(a: Int, b: Int) -> Int {
  case a < b { True -> a  False -> b }
}

// FFI
@external(erlang, "towerops_baseline_ffi", "float_sqrt")
fn float_sqrt(value: Float) -> Float

@external(erlang, "towerops_baseline_ffi", "float_round_to")
fn float_round_to(value: Float, decimals: Int) -> Float

@external(erlang, "towerops_baseline_ffi", "float_round")
fn float_round(value: Float) -> Int

@external(erlang, "towerops_baseline_ffi", "list_at")
fn list_at(list: List(a), index: Int) -> a

@external(erlang, "erlang", "float")
fn int_to_float(i: Int) -> Float

Step 2: Create FFI

Create src/towerops_baseline_ffi.erl:

-module(towerops_baseline_ffi).
-export([float_sqrt/1, float_round_to/2, float_round/1, list_at/2]).

float_sqrt(Value) -> math:sqrt(Value).
float_round_to(Value, Decimals) -> 'Elixir.Float':'round'(Value, Decimals).
float_round(Value) -> round(Value).
list_at(List, Index) -> lists:nth(Index + 1, List).

Step 3: Update Elixir module

In lib/towerops/preseem/baseline.ex, replace compute_stats/1 and to_float/1:

defp compute_stats(values) do
  float_values = Enum.map(values, &to_float/1)
  stats = :towerops@preseem@baseline.compute_stats(float_values)
  # Gleam Stats record comes back as {:stats, mean, stddev, p5, p95}
  %{mean: elem(stats, 1), stddev: elem(stats, 2), p5: elem(stats, 3), p95: elem(stats, 4)}
end

defp to_float(v) when is_float(v), do: v
defp to_float(v) when is_integer(v), do: v * 1.0

Wait — Gleam custom types with fields compile to tuples on the BEAM. Stats(mean: 1.0, stddev: 2.0, p5: 0.5, p95: 9.5) becomes {stats, 1.0, 2.0, 0.5, 9.5}. So we use elem/2 to extract fields.

Step 4: Run tests

mix test test/towerops/preseem/baseline_test.exs

Expected: All tests pass.

Step 5: Commit

git add src/towerops/preseem/baseline.gleam src/towerops_baseline_ffi.erl lib/towerops/preseem/baseline.ex
git commit -m "Extract Baseline.compute_stats to Gleam"

Task 6: Convert Towerops.EctoTypes.IpAddress (parsing core)

Extract pure IP parsing/validation. :inet calls go through FFI.

Files:

  • Create: src/towerops/ecto_types/ip_address.gleam
  • Create: src/towerops_ip_address_ffi.erl
  • Modify: lib/towerops/ecto_types/ip_address.ex

Key decisions:

  1. Gleam module exports:

    • parse_string(value: String) -> Result(#(String, IpTuple), Nil) — parse, strip zone ID, reject CIDR
    • strip_zone_id(value: String) -> String
    • detect_version(tuple_size: Int) -> IpVersion
    • Type: pub type IpVersion { Ipv4 Ipv6 }
  2. FFI src/towerops_ip_address_ffi.erl wraps:

    • parse_address(String) -> {ok, Tuple} | {error, nil} — wraps :inet.parse_address/1
    • ntoa(Tuple) -> {ok, String} | {error, nil} — wraps :inet.ntoa/1
    • tuple_size(Tuple) -> Int
  3. Elixir wrapper keeps all Ecto Type callbacks and struct, delegates parsing to Gleam.

Step 1: Implement Gleam module and FFI

The Gleam module:

/// IP address parsing and validation.
/// Compiles to `:towerops@ecto_types@ip_address`.
import gleam/string

pub type IpVersion {
  Ipv4
  Ipv6
}

/// Parse an IP address string. Returns Ok(#(cleaned_string, ip_tuple)) or Error(Nil).
/// Strips zone IDs, rejects CIDR notation.
pub fn parse_string(value: String) -> Result(#(String, Dynamic), Nil) {
  case value {
    "" -> Error(Nil)
    _ -> {
      let cleaned = strip_zone_id(value)
      case string.contains(cleaned, "/") {
        True -> Error(Nil)
        False -> inet_parse(cleaned)
      }
    }
  }
}

/// Strip IPv6 zone ID (e.g., "fe80::1%eth0" -> "fe80::1").
pub fn strip_zone_id(value: String) -> String {
  case string.split_once(value, "%") {
    Ok(#(ip, _zone)) -> ip
    Error(_) -> value
  }
}

/// Detect IP version from tuple size (4 = IPv4, 8 = IPv6).
pub fn detect_version(size: Int) -> IpVersion {
  case size {
    4 -> Ipv4
    _ -> Ipv6
  }
}

@external(erlang, "towerops_ip_address_ffi", "parse_address")
fn inet_parse(value: String) -> Result(#(String, Dynamic), Nil)

Step 2: Run tests

mix test test/towerops/ecto_types/ip_address_test.exs

Step 3: Commit

git add src/towerops/ecto_types/ip_address.gleam src/towerops_ip_address_ffi.erl lib/towerops/ecto_types/ip_address.ex
git commit -m "Extract IpAddress parsing to Gleam"

Task 7: Convert Towerops.EctoTypes.SnmpOid (parsing/manipulation)

Extract numeric OID parsing and manipulation. Named OID resolution (SnmpKit) stays in Elixir.

Files:

  • Create: src/towerops/ecto_types/snmp_oid.gleam
  • Modify: lib/towerops/ecto_types/snmp_oid.ex

Key decisions:

  1. Gleam module exports:

    • parse_numeric_oid(value: String) -> Result(#(String, List(Int)), Nil) — validates, normalizes leading dot, returns (oid_string, parts)
    • is_numeric_oid(value: String) -> Bool
    • is_named_oid(value: String) -> Bool
    • ensure_leading_dot(value: String) -> String
    • parent_parts(parts: List(Int)) -> List(Int) — drop last
    • child_of(child: List(Int), parent: List(Int)) -> Bool
    • append_parts(parts: List(Int), additional: List(Int)) -> List(Int)
    • parts_to_string(parts: List(Int)) -> String — builds dotted-decimal with leading dot
  2. No FFI needed — all string manipulation + list operations available in Gleam stdlib.

  3. Note: already have snmpkit/snmp_lib/oid.gleam with some OID logic. This is separate — the Ecto type parsing vs. the SNMP protocol OID manipulation. Keep them separate since they serve different purposes.

  4. Elixir wrapper keeps Ecto callbacks, struct, resolve/1 (calls SnmpKit), and new/new!.

Step 1: Implement Gleam module

Use gleam/regexp for the OID patterns, gleam/int for parsing, gleam/list for manipulation.

Step 2: Run tests

mix test test/towerops/ecto_types/snmp_oid_test.exs

Step 3: Commit

git add src/towerops/ecto_types/snmp_oid.gleam lib/towerops/ecto_types/snmp_oid.ex
git commit -m "Extract SnmpOid parsing/manipulation to Gleam"

Task 8: Convert Towerops.Snmp.SensorChangeDetector (threshold/event logic)

Extract the pure decision logic (threshold checking, event building, value formatting). PubSub broadcasting and Repo queries stay in Elixir.

Files:

  • Create: src/towerops/snmp/sensor_change_detector.gleam
  • Create: src/towerops_sensor_ffi.erl
  • Modify: lib/towerops/snmp/sensor_change_detector.ex

Key decisions:

  1. Gleam module exports:

    • Types:
      • SensorInfo — id, descr, type, unit, last_value (the fields needed for detection)
      • Thresholds — warning_high, critical_high, warning_low, critical_low (all Option(Float))
      • SensorEvent — device_id, event_type, severity, message, metadata, occurred_at
    • Functions:
      • detect_events(sensor: SensorInfo, current_value: Float, device_id: String, timestamp: Dynamic, thresholds: Thresholds) -> List(SensorEvent)
      • format_sensor_value(value: Float, unit: String) -> String
      • value_was_over_threshold(last_value: Float, thresholds: Thresholds) -> Bool
      • value_is_normal(current_value: Float, thresholds: Thresholds) -> Bool
  2. FFI: float_round/2, float_abs/1, int_trunc/1 for numeric formatting.

  3. Elixir wrapper:

    • detect_and_broadcast/3 stays in Elixir
    • Calls get_device_id_from_sensor (Repo) and extract_thresholds (map access)
    • Builds SensorInfo tuple, calls Gleam detect_events
    • Broadcasts returned events via PubSub

Step 1: Implement Gleam module and FFI

Step 2: Run tests

mix test test/towerops/snmp/sensor_change_detector_test.exs

Expected: All 11 tests pass.

Step 3: Commit

git add src/towerops/snmp/sensor_change_detector.gleam src/towerops_sensor_ffi.erl lib/towerops/snmp/sensor_change_detector.ex
git commit -m "Extract SensorChangeDetector threshold/event logic to Gleam"

Task 9: Convert Towerops.Capacity.Resolver (unit normalization)

Extract the pure normalize_sensor_to_bps and normalize_unit functions.

Files:

  • Create: src/towerops/capacity/resolver.gleam
  • Modify: lib/towerops/capacity/resolver.ex

Key decisions:

  1. Gleam module exports:

    • normalize_to_bps(value: Float, unit: String, divisor: Float) -> Int — normalizes sensor value to bits-per-second
    • normalize_unit(unit: String) -> SpeedUnit
    • Type: pub type SpeedUnit { Bps Kbps Mbps Gbps }
  2. No FFI needed — just float arithmetic and string matching.

  3. Elixir wrapper keeps resolve_capacity/2, resolve_from_sensors/1 (Repo queries), and the manual/if_speed resolution.

Step 1: Implement

/// Capacity unit normalization.
/// Compiles to `:towerops@capacity@resolver`.
import gleam/float
import gleam/string

pub type SpeedUnit {
  Bps
  Kbps
  Mbps
  Gbps
}

/// Normalize a sensor value to bits per second.
pub fn normalize_to_bps(value: Float, unit: String, divisor: Float) -> Int {
  let adjusted = value /. divisor
  let bps = case normalize_unit(unit) {
    Bps -> adjusted
    Kbps -> adjusted *. 1000.0
    Mbps -> adjusted *. 1_000_000.0
    Gbps -> adjusted *. 1_000_000_000.0
  }
  float_to_int(bps)
}

pub fn normalize_unit(unit: String) -> SpeedUnit {
  case string.lowercase(unit) {
    "bps" -> Bps
    "kbps" -> Kbps
    "mbps" -> Mbps
    "gbps" -> Gbps
    _ -> Bps
  }
}

@external(erlang, "erlang", "trunc")
fn float_to_int(value: Float) -> Int

Step 2: Update Elixir

def normalize_sensor_to_bps(%Sensor{last_value: nil}), do: nil
def normalize_sensor_to_bps(%Sensor{last_value: v}) when v <= 0, do: nil

def normalize_sensor_to_bps(%Sensor{last_value: value, sensor_unit: unit, sensor_divisor: divisor}) do
  :towerops@capacity@resolver.normalize_to_bps(
    value / 1.0,
    unit || "",
    (divisor || 1) / 1.0
  )
end

Step 3: Run tests

mix test test/towerops/capacity/resolver_test.exs

Step 4: Commit

git add src/towerops/capacity/resolver.gleam lib/towerops/capacity/resolver.ex
git commit -m "Extract Capacity.Resolver unit normalization to Gleam"

Task 10: Convert Towerops.ConfigChanges.Correlator (pure math)

Extract safe_pct_change, determine_severity, build_description, maybe_add, stringify_metrics.

Files:

  • Create: src/towerops/config_changes/correlator.gleam
  • Create: src/towerops_correlator_ffi.erl
  • Modify: lib/towerops/config_changes/correlator.ex

Key decisions:

  1. Gleam module exports:

    • safe_pct_change(old: Float, new: Float) -> Float
    • determine_severity(latency_delta: Float, loss_delta: Float, throughput_delta: Float) -> String
    • build_description(latency_delta: Float, loss_delta: Float, throughput_delta: Float, sections: List(String)) -> String
    • stringify_metrics(avg_latency: Float, avg_jitter: Float, avg_loss: Float, avg_throughput: Float) -> List(#(String, Float))
  2. FFI: float_round/2 from Erlang.

  3. Elixir keeps: correlate/1, correlate_recent/2, all Repo queries, evaluate_impact, create_suspect_insight.

Step 1: Implement

Step 2: Run tests (no dedicated correlator test exists)

mix test

Ensure no regressions.

Step 3: Commit

git add src/towerops/config_changes/correlator.gleam src/towerops_correlator_ffi.erl lib/towerops/config_changes/correlator.ex
git commit -m "Extract Correlator math/severity to Gleam"

Task 11: Convert Towerops.LogFilter + Towerops.LoggerFilters

Bundle both into a single Gleam module for log noise filtering.

Files:

  • Create: src/towerops/log_filters.gleam
  • Create: src/towerops_log_filters_ffi.erl
  • Modify: lib/towerops/log_filter.ex
  • Modify: lib/towerops/logger_filters.ex

Key decisions:

  1. Log events are Erlang maps with atom keys. Gleam needs FFI to pattern match on these.

  2. Gleam module exports:

    • is_bandit_invalid_http_error(msg: String) -> Bool — checks string contains both "Bandit.HTTPError" and "Invalid HTTP version"
    • is_oban_shutdown(msg: String) -> Bool — checks for "EXIT" + ":shutdown"
    • is_oban_component(msg: String) -> Bool — checks for "Oban.Queue" or "Oban.Plugins"
    • is_shutdown_error(msg: String) -> Bool — checks for "port_died" or ("write_failed" + "epipe")
    • is_snmp_mib_error(msg: String) -> Bool — checks for "Cannot find module" + "At line 1 in (none)"
  3. Elixir wrappers keep the log event pattern matching (%{level: :error, msg: {:string, msg}}) and call Gleam for the string predicate checks.

  4. The structural pattern matching on log events (checking :level, :meta, :msg tuple shapes) stays in Elixir because it involves atoms and Erlang-specific map structures.

Step 1: Create Gleam module

/// Log message predicate checks for noise filtering.
/// Compiles to `:towerops@log_filters`.
import gleam/string

/// Check if message is a Bandit invalid HTTP version error.
pub fn is_bandit_invalid_http(msg: String) -> Bool {
  string.contains(msg, "Bandit.HTTPError")
  && string.contains(msg, "Invalid HTTP version")
}

/// Check if message is an Oban shutdown message (EXIT + :shutdown).
pub fn is_oban_shutdown_msg(msg: String) -> Bool {
  string.contains(msg, "EXIT") && string.contains(msg, ":shutdown")
}

/// Check if message references an Oban component.
pub fn is_oban_component(msg: String) -> Bool {
  string.contains(msg, "Oban.Queue")
  || string.contains(msg, "Oban.Plugins")
}

/// Check if message is Oban queue shutdown (component + received unexpected + EXIT + shutdown).
pub fn is_oban_queue_shutdown(msg: String) -> Bool {
  is_oban_component(msg)
  && string.contains(msg, "received unexpected message")
  && string.contains(msg, "EXIT")
  && string.contains(msg, ":shutdown")
}

/// Check if message is a shutdown error (port_died or write_failed+epipe).
pub fn is_shutdown_error(msg: String) -> Bool {
  string.contains(msg, "port_died")
  || { string.contains(msg, "write_failed") && string.contains(msg, "epipe") }
}

/// Check if message is an SNMP MIB resolution error.
pub fn is_snmp_mib_error(msg: String) -> Bool {
  string.contains(msg, "Cannot find module")
  && string.contains(msg, "At line 1 in (none)")
}

Step 2: Update Elixir wrappers

In lib/towerops/log_filter.ex:

defp bandit_invalid_http_error?(msg) do
  :towerops@log_filters.is_bandit_invalid_http(msg)
end

In lib/towerops/logger_filters.ex:

defp oban_source_shutdown?(%{meta: %{source: :oban}, msg: {:string, msg}}) when is_binary(msg) do
  :towerops@log_filters.is_oban_shutdown_msg(msg)
end

defp oban_queue_shutdown?(%{meta: %{error_logger: %{tag: :error_msg}}, msg: {:string, msg}}) when is_binary(msg) do
  :towerops@log_filters.is_oban_queue_shutdown(msg)
end

defp shutdown_log_message?(%{msg: {:string, msg}}) when is_binary(msg) do
  :towerops@log_filters.is_shutdown_error(msg)
end

defp snmp_mib_error?(%{msg: {:string, msg}}) when is_binary(msg) do
  :towerops@log_filters.is_snmp_mib_error(msg)
end

Step 3: Run tests

mix test test/towerops/log_filter_test.exs test/towerops/logger_filters_test.exs

Step 4: Commit

git add src/towerops/log_filters.gleam lib/towerops/log_filter.ex lib/towerops/logger_filters.ex
git commit -m "Extract log filter string predicates to Gleam"

Task 12: Convert Towerops.OnCall.Resolver

The on-call resolution is pure computation but complex — rotation math, layer stacking, time restrictions, overrides. The existing tests use database fixtures (preloaded Schedule struct).

Files:

  • Create: src/towerops/on_call/resolver.gleam
  • Create: src/towerops_on_call_ffi.erl
  • Modify: lib/towerops/on_call/resolver.ex

Key decisions:

  1. The preloaded Schedule struct has deeply nested associations (layers → members → user, overrides → user). Passing these complex Elixir structs to Gleam requires careful FFI mapping.

  2. Approach: The Gleam module takes flattened data structures, not Ecto structs:

    • Override = #(DateTime, DateTime, user_ref) — start_time, end_time, user
    • LayerMember = #(Int, user_ref) — position, user
    • Layer = record with rotation_type, rotation_interval, start_date, position, restriction info, members
    • resolve takes a list of overrides, list of layers, and current datetime
  3. FFI needed for:

    • DateTime comparison and diff (Elixir DateTime functions)
    • Time extraction and comparison
    • Time.from_iso8601 parsing
  4. Elixir wrapper:

    • resolve/2 extracts data from preloaded Schedule struct
    • Builds flat lists of override tuples and layer records
    • Calls Gleam resolve
    • Returns the user map

Actually, the DateTime/Time operations are complex to bridge. The Gleam gleam_time library may not have all the needed operations. A simpler approach: extract just the rotation math to Gleam (what position in rotation given elapsed seconds, interval, and member count), and keep the DateTime/Time logic in Elixir.

Revised approach — extract rotation computation:

Gleam module exports:

  • compute_rotation_position(elapsed_seconds: Int, rotation_seconds: Int, member_count: Int) -> Int
  • rotation_duration_seconds(rotation_type: String, rotation_interval: Int) -> Int

Elixir keeps: override resolution, layer filtering, time restriction checking, DateTime operations.

This is a smaller but cleaner extraction that avoids complex DateTime FFI.

Step 1: Implement Gleam module

/// On-call rotation computation.
/// Compiles to `:towerops@on_call@resolver`.

const seconds_per_day = 86_400
const seconds_per_week = 604_800

/// Compute rotation position (0-indexed) given elapsed time.
pub fn compute_rotation_position(
  elapsed_seconds: Int,
  rotation_seconds: Int,
  member_count: Int,
) -> Int {
  case member_count {
    0 -> 0
    _ -> { elapsed_seconds / rotation_seconds } % member_count
  }
}

/// Get rotation duration in seconds for a given type and interval.
pub fn rotation_duration_seconds(rotation_type: String, rotation_interval: Int) -> Int {
  case rotation_type {
    "daily" -> rotation_interval * seconds_per_day
    "weekly" -> rotation_interval * seconds_per_week
    "custom" -> rotation_interval * seconds_per_day
    _ -> rotation_interval * seconds_per_day
  }
}

Step 2: Update Elixir

Replace compute_rotation/2 and rotation_duration_seconds/1:

defp compute_rotation(%{members: []}, _datetime), do: nil

defp compute_rotation(layer, datetime) do
  members = Enum.sort_by(layer.members, & &1.position)
  member_count = length(members)

  if member_count == 0 do
    nil
  else
    elapsed_seconds = DateTime.diff(datetime, layer.start_date, :second)
    rotation_seconds = :towerops@on_call@resolver.rotation_duration_seconds(layer.rotation_type, layer.rotation_interval)
    position = :towerops@on_call@resolver.compute_rotation_position(elapsed_seconds, rotation_seconds, member_count)

    member = Enum.at(members, position)
    {:ok, member.user}
  end
end

Remove the old rotation_duration_seconds/1 private functions.

Step 3: Run tests

mix test test/towerops/on_call/resolver_test.exs

Step 4: Commit

git add src/towerops/on_call/resolver.gleam lib/towerops/on_call/resolver.ex
git commit -m "Extract OnCall.Resolver rotation computation to Gleam"

Task 13: Final verification and cleanup

Step 1: Run full test suite

mix test

Step 2: Run Gleam build

gleam build

Step 3: Run precommit checks

mix precommit

Step 4: Run Gleam format

gleam format src/

Step 5: Verify no regressions

mix test --cover

Check coverage hasn't dropped.

Step 6: Final commit if any formatting changes

git add -A
git commit -m "Format and verify gleam4 batch conversion"