# Automatic Topology Inference Implementation Plan

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

**Goal:** Build a persistent topology model that infers device relationships, roles, and link types from existing SNMP polling data, replacing the current on-demand topology builder.

**Architecture:** New `Towerops.Topology` context with schemas for `DeviceLink` and `DeviceLinkEvidence`. Evidence collectors analyze LLDP/CDP neighbors, MAC tables, and ARP tables after each poll cycle. The existing `DevicePollerWorker` calls `Topology.process_device/1` at the end of its run. The `NetworkMapLive` and `DeviceLive.Show` consume the persistent data.

**Tech Stack:** Elixir/Phoenix, Ecto, PostgreSQL, Cytoscape.js, Phoenix PubSub

**Design doc:** `docs/plans/2026-02-12-automatic-topology-inference-design.md`

---

## Task 1: Database Migrations

**Files:**
- Create: `priv/repo/migrations/TIMESTAMP_add_device_role_to_devices.exs`
- Create: `priv/repo/migrations/TIMESTAMP_create_device_links.exs`
- Create: `priv/repo/migrations/TIMESTAMP_create_device_link_evidence.exs`

**Step 1: Generate the migrations**

```bash
cd /Users/graham/dev/towerops/towerops-web
mix ecto.gen.migration add_device_role_to_devices
mix ecto.gen.migration create_device_links
mix ecto.gen.migration create_device_link_evidence
```

**Step 2: Write the device role migration**

```elixir
defmodule Towerops.Repo.Migrations.AddDeviceRoleToDevices do
  use Ecto.Migration

  def change do
    alter table(:devices) do
      add :device_role, :string
      add :device_role_source, :string, default: "inferred"
    end
  end
end
```

**Step 3: Write the device_links migration**

```elixir
defmodule Towerops.Repo.Migrations.CreateDeviceLinks do
  use Ecto.Migration

  def change do
    create table(:device_links, primary_key: false) do
      add :id, :binary_id, primary_key: true
      add :source_device_id, references(:devices, type: :binary_id, on_delete: :delete_all),
        null: false
      add :target_device_id, references(:devices, type: :binary_id, on_delete: :delete_all)
      add :source_interface_id,
        references(:snmp_interfaces, type: :binary_id, on_delete: :nilify_all)
      add :target_interface_id,
        references(:snmp_interfaces, type: :binary_id, on_delete: :nilify_all)
      add :link_type, :string, null: false
      add :confidence, :float, null: false, default: 0.5
      add :metadata, :map, default: %{}
      add :discovered_remote_name, :string
      add :discovered_remote_ip, :string
      add :discovered_remote_mac, :string
      add :last_confirmed_at, :utc_datetime, null: false
      timestamps(type: :utc_datetime)
    end

    create index(:device_links, [:source_device_id])
    create index(:device_links, [:target_device_id])
    create unique_index(:device_links,
      [:source_device_id, :source_interface_id, :discovered_remote_mac],
      name: :device_links_src_iface_remote_mac)
  end
end
```

**Step 4: Write the device_link_evidence migration**

```elixir
defmodule Towerops.Repo.Migrations.CreateDeviceLinkEvidence do
  use Ecto.Migration

  def change do
    create table(:device_link_evidence, primary_key: false) do
      add :id, :binary_id, primary_key: true
      add :device_link_id,
        references(:device_links, type: :binary_id, on_delete: :delete_all),
        null: false
      add :evidence_type, :string, null: false
      add :evidence_data, :map, default: %{}
      add :observed_at, :utc_datetime, null: false
      timestamps(type: :utc_datetime)
    end

    create index(:device_link_evidence, [:device_link_id])
    create index(:device_link_evidence, [:observed_at])
  end
end
```

**Step 5: Run migrations**

```bash
mix ecto.migrate
```

Expected: Three migrations run successfully.

**Step 6: Commit**

```
feat: add topology inference database tables

Add device_role/device_role_source to devices table.
Create device_links and device_link_evidence tables for
persistent topology model.
```

---

## Task 2: Ecto Schemas

**Files:**
- Modify: `lib/towerops/devices/device.ex` (add role fields)
- Create: `lib/towerops/topology/device_link.ex`
- Create: `lib/towerops/topology/device_link_evidence.ex`

**Step 1: Write failing test for DeviceLink schema**

Create `test/towerops/topology/device_link_test.exs`:

```elixir
defmodule Towerops.Topology.DeviceLinkTest do
  use Towerops.DataCase

  alias Towerops.Topology.DeviceLink

  import Towerops.DevicesFixtures

  describe "changeset/2" do
    test "valid with required fields" do
      device = device_fixture()

      attrs = %{
        source_device_id: device.id,
        link_type: "lldp",
        confidence: 0.95,
        discovered_remote_mac: "aa:bb:cc:dd:ee:ff",
        last_confirmed_at: DateTime.utc_now()
      }

      changeset = DeviceLink.changeset(%DeviceLink{}, attrs)
      assert changeset.valid?
    end

    test "invalid without source_device_id" do
      attrs = %{link_type: "lldp", confidence: 0.95, last_confirmed_at: DateTime.utc_now()}
      changeset = DeviceLink.changeset(%DeviceLink{}, attrs)
      refute changeset.valid?
    end

    test "validates link_type inclusion" do
      device = device_fixture()

      attrs = %{
        source_device_id: device.id,
        link_type: "invalid",
        confidence: 0.95,
        last_confirmed_at: DateTime.utc_now()
      }

      changeset = DeviceLink.changeset(%DeviceLink{}, attrs)
      refute changeset.valid?
    end

    test "validates confidence range 0.0 to 1.0" do
      device = device_fixture()

      attrs = %{
        source_device_id: device.id,
        link_type: "lldp",
        confidence: 1.5,
        last_confirmed_at: DateTime.utc_now()
      }

      changeset = DeviceLink.changeset(%DeviceLink{}, attrs)
      refute changeset.valid?
    end
  end
end
```

**Step 2: Run test to verify it fails**

```bash
mix test test/towerops/topology/device_link_test.exs
```

Expected: Compilation error — `Towerops.Topology.DeviceLink` not found.

**Step 3: Implement DeviceLink schema**

Create `lib/towerops/topology/device_link.ex`:

```elixir
defmodule Towerops.Topology.DeviceLink do
  @moduledoc """
  Persistent record of a detected connection between two devices.

  Links are created by the topology inference engine based on evidence
  from LLDP/CDP neighbors, MAC tables, ARP tables, etc.

  When target_device_id is nil, the remote end is a discovered (unmanaged)
  device identified by discovered_remote_* fields.
  """
  use Ecto.Schema

  import Ecto.Changeset

  alias Towerops.Devices.Device
  alias Towerops.Snmp.Interface
  alias Towerops.Topology.DeviceLinkEvidence

  @valid_link_types ~w(lldp cdp mac_match wireless_association arp_inference manual)

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  schema "device_links" do
    field :link_type, :string
    field :confidence, :float, default: 0.5
    field :metadata, :map, default: %{}
    field :discovered_remote_name, :string
    field :discovered_remote_ip, :string
    field :discovered_remote_mac, :string
    field :last_confirmed_at, :utc_datetime

    belongs_to :source_device, Device
    belongs_to :target_device, Device
    belongs_to :source_interface, Interface
    belongs_to :target_interface, Interface

    has_many :evidence, DeviceLinkEvidence

    timestamps(type: :utc_datetime)
  end

  def changeset(link, attrs) do
    link
    |> cast(attrs, [
      :source_device_id,
      :target_device_id,
      :source_interface_id,
      :target_interface_id,
      :link_type,
      :confidence,
      :metadata,
      :discovered_remote_name,
      :discovered_remote_ip,
      :discovered_remote_mac,
      :last_confirmed_at
    ])
    |> validate_required([:source_device_id, :link_type, :last_confirmed_at])
    |> validate_inclusion(:link_type, @valid_link_types)
    |> validate_number(:confidence,
      greater_than_or_equal_to: 0.0,
      less_than_or_equal_to: 1.0
    )
    |> foreign_key_constraint(:source_device_id)
    |> foreign_key_constraint(:target_device_id)
    |> unique_constraint([:source_device_id, :source_interface_id, :discovered_remote_mac],
      name: :device_links_src_iface_remote_mac
    )
  end
end
```

**Step 4: Write failing test for DeviceLinkEvidence**

Create `test/towerops/topology/device_link_evidence_test.exs`:

```elixir
defmodule Towerops.Topology.DeviceLinkEvidenceTest do
  use Towerops.DataCase

  alias Towerops.Topology.DeviceLinkEvidence

  describe "changeset/2" do
    test "valid with required fields" do
      attrs = %{
        device_link_id: Ecto.UUID.generate(),
        evidence_type: "lldp_neighbor",
        evidence_data: %{"remote_chassis_id" => "aa:bb:cc:dd:ee:ff"},
        observed_at: DateTime.utc_now()
      }

      changeset = DeviceLinkEvidence.changeset(%DeviceLinkEvidence{}, attrs)
      assert changeset.valid?
    end

    test "invalid without evidence_type" do
      attrs = %{device_link_id: Ecto.UUID.generate(), observed_at: DateTime.utc_now()}
      changeset = DeviceLinkEvidence.changeset(%DeviceLinkEvidence{}, attrs)
      refute changeset.valid?
    end

    test "validates evidence_type inclusion" do
      attrs = %{
        device_link_id: Ecto.UUID.generate(),
        evidence_type: "invalid",
        observed_at: DateTime.utc_now()
      }

      changeset = DeviceLinkEvidence.changeset(%DeviceLinkEvidence{}, attrs)
      refute changeset.valid?
    end
  end
end
```

**Step 5: Implement DeviceLinkEvidence schema**

Create `lib/towerops/topology/device_link_evidence.ex`:

```elixir
defmodule Towerops.Topology.DeviceLinkEvidence do
  @moduledoc """
  Records why a device link is believed to exist.
  Enables explainability and re-evaluation of link confidence.
  """
  use Ecto.Schema

  import Ecto.Changeset

  alias Towerops.Topology.DeviceLink

  @valid_evidence_types ~w(lldp_neighbor cdp_neighbor mac_on_interface arp_entry wireless_registration subnet_match)

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  schema "device_link_evidence" do
    field :evidence_type, :string
    field :evidence_data, :map, default: %{}
    field :observed_at, :utc_datetime

    belongs_to :device_link, DeviceLink

    timestamps(type: :utc_datetime)
  end

  def changeset(evidence, attrs) do
    evidence
    |> cast(attrs, [:device_link_id, :evidence_type, :evidence_data, :observed_at])
    |> validate_required([:device_link_id, :evidence_type, :observed_at])
    |> validate_inclusion(:evidence_type, @valid_evidence_types)
    |> foreign_key_constraint(:device_link_id)
  end
end
```

**Step 6: Add device_role fields to Device schema**

Modify `lib/towerops/devices/device.ex`:

Add after `field :mikrotik_credential_source` (line 62):
```elixir
    # Topology role (auto-inferred or manually set)
    field :device_role, :string
    field :device_role_source, :string, default: "inferred"
```

Add `:device_role` and `:device_role_source` to the `cast/2` list in `changeset/2` (around line 120).

Add to the `@type t` spec:
```elixir
device_role: String.t() | nil,
device_role_source: String.t(),
```

**Step 7: Run all tests**

```bash
mix test test/towerops/topology/
```

Expected: All pass.

**Step 8: Commit**

```
feat: add DeviceLink and DeviceLinkEvidence schemas

Ecto schemas for persistent topology model. DeviceLink tracks
connections between devices with confidence scores. DeviceLinkEvidence
records why each link is believed to exist. Device schema gets
device_role and device_role_source fields.
```

---

## Task 3: Topology Context — Device Matching

The matching logic is the foundation everything else builds on. It resolves evidence (MAC, IP, hostname) to managed devices.

**Files:**
- Create: `lib/towerops/topology.ex`
- Create: `test/towerops/topology_test.exs`

**Step 1: Write failing tests for device matching**

Create `test/towerops/topology_test.exs`:

```elixir
defmodule Towerops.TopologyTest do
  use Towerops.DataCase

  alias Towerops.Topology

  import Towerops.AccountsFixtures
  import Towerops.DevicesFixtures

  setup do
    user = user_fixture()
    {:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)

    {:ok, site} =
      Towerops.Sites.create_site(%{name: "Test Site", organization_id: organization.id})

    router = device_fixture(%{
      name: "Core-Router",
      ip_address: "10.0.0.1",
      organization_id: organization.id,
      site: %Towerops.Sites.Site{id: site.id, organization_id: organization.id, name: "Test Site"}
    })

    switch = device_fixture(%{
      name: "Main-Switch",
      ip_address: "10.0.0.2",
      organization_id: organization.id,
      site: %Towerops.Sites.Site{id: site.id, organization_id: organization.id, name: "Test Site"}
    })

    # Create SNMP device with interface that has a known MAC
    snmp_device =
      %Towerops.Snmp.Device{}
      |> Towerops.Snmp.Device.changeset(%{device_id: router.id, sys_name: "core-router"})
      |> Towerops.Repo.insert!()

    interface =
      %Towerops.Snmp.Interface{}
      |> Towerops.Snmp.Interface.changeset(%{
        snmp_device_id: snmp_device.id,
        if_index: 1,
        if_name: "eth0",
        if_phys_address: "aa:bb:cc:00:11:22"
      })
      |> Towerops.Repo.insert!()

    %{
      organization: organization,
      router: router,
      switch: switch,
      snmp_device: snmp_device,
      interface: interface
    }
  end

  describe "build_device_lookup/1" do
    test "matches device by IP address", %{organization: org, switch: switch} do
      lookup = Topology.build_device_lookup(org.id)
      assert Topology.find_device_by_ip(lookup, "10.0.0.2") == switch.id
    end

    test "matches device by name (case-insensitive)", %{organization: org, router: router} do
      lookup = Topology.build_device_lookup(org.id)
      assert Topology.find_device_by_name(lookup, "core-router") == router.id
    end

    test "matches device by MAC address", %{organization: org, router: router} do
      lookup = Topology.build_device_lookup(org.id)
      assert Topology.find_device_by_mac(lookup, "aa:bb:cc:00:11:22") == router.id
    end

    test "returns nil for unknown IP", %{organization: org} do
      lookup = Topology.build_device_lookup(org.id)
      assert Topology.find_device_by_ip(lookup, "192.168.99.99") == nil
    end

    test "returns nil for unknown MAC", %{organization: org} do
      lookup = Topology.build_device_lookup(org.id)
      assert Topology.find_device_by_mac(lookup, "ff:ff:ff:ff:ff:ff") == nil
    end
  end
end
```

**Step 2: Run test to verify it fails**

```bash
mix test test/towerops/topology_test.exs
```

Expected: Compilation error — `Towerops.Topology` not found.

**Step 3: Implement the Topology context with device matching**

Create `lib/towerops/topology.ex`:

```elixir
defmodule Towerops.Topology do
  @moduledoc """
  Topology inference engine. Builds and maintains a persistent model of
  network device relationships by analyzing SNMP polling data.
  """

  import Ecto.Query

  alias Towerops.Devices.Device
  alias Towerops.Repo
  alias Towerops.Snmp.Interface

  @doc """
  Builds lookup maps for matching evidence to managed devices in an organization.
  Returns a map with :by_ip, :by_name, :by_mac keys.
  """
  def build_device_lookup(organization_id) do
    devices =
      Repo.all(
        from(d in Device,
          where: d.organization_id == ^organization_id,
          left_join: sd in assoc(d, :snmp_device),
          left_join: i in Interface,
          on: sd.id == i.snmp_device_id,
          preload: [snmp_device: {sd, interfaces: i}]
        )
      )

    by_ip = Map.new(devices, fn d -> {d.ip_address, d.id} end)

    by_name =
      devices
      |> Enum.filter(&(&1.name != nil))
      |> Map.new(&{String.downcase(&1.name), &1.id})

    by_mac =
      devices
      |> Enum.flat_map(fn device ->
        case device.snmp_device do
          nil -> []
          sd ->
            sd.interfaces
            |> Enum.filter(&(&1.if_phys_address != nil))
            |> Enum.map(&{&1.if_phys_address, device.id})
        end
      end)
      |> Map.new()

    %{by_ip: by_ip, by_name: by_name, by_mac: by_mac}
  end

  @doc "Find a device ID by IP address."
  def find_device_by_ip(lookup, ip), do: Map.get(lookup.by_ip, ip)

  @doc "Find a device ID by hostname (case-insensitive)."
  def find_device_by_name(lookup, name) when is_binary(name),
    do: Map.get(lookup.by_name, String.downcase(name))
  def find_device_by_name(_lookup, _name), do: nil

  @doc "Find a device ID by MAC address."
  def find_device_by_mac(lookup, mac), do: Map.get(lookup.by_mac, mac)

  @doc """
  Resolve a piece of evidence to a managed device ID.
  Tries MAC, then IP, then hostname. Returns nil if no match.
  """
  def resolve_device(lookup, %{mac: mac, ip: ip, name: name}) do
    find_device_by_mac(lookup, mac) ||
      find_device_by_ip(lookup, ip) ||
      find_device_by_name(lookup, name)
  end

  def resolve_device(lookup, attrs) do
    find_device_by_mac(lookup, Map.get(attrs, :mac)) ||
      find_device_by_ip(lookup, Map.get(attrs, :ip)) ||
      find_device_by_name(lookup, Map.get(attrs, :name))
  end
end
```

**Step 4: Run tests**

```bash
mix test test/towerops/topology_test.exs
```

Expected: All pass.

**Step 5: Commit**

```
feat: add Topology context with device matching

Build lookup maps to resolve evidence (MAC, IP, hostname) to
managed device IDs. Foundation for evidence collectors.
```

---

## Task 4: Evidence Collectors (LLDP/CDP)

**Files:**
- Modify: `lib/towerops/topology.ex`
- Modify: `test/towerops/topology_test.exs`

**Step 1: Write failing tests for LLDP/CDP evidence collection**

Add to `test/towerops/topology_test.exs`:

```elixir
  describe "collect_lldp_evidence/1" do
    test "returns evidence from LLDP neighbors", %{router: router, interface: interface} do
      # Create an LLDP neighbor on the router's interface
      {:ok, _neighbor} =
        Towerops.Snmp.upsert_neighbor(%{
          device_id: router.id,
          interface_id: interface.id,
          protocol: "lldp",
          remote_chassis_id: "dd:ee:ff:00:11:22",
          remote_system_name: "remote-switch",
          remote_address: "10.0.0.5",
          remote_port_id: "ge-0/0/1",
          remote_capabilities: ["switch", "bridge"],
          last_discovered_at: DateTime.utc_now()
        })

      evidence = Topology.collect_lldp_evidence(router.id)

      assert length(evidence) == 1
      [ev] = evidence
      assert ev.evidence_type == "lldp_neighbor"
      assert ev.confidence == 0.95
      assert ev.remote_mac == "dd:ee:ff:00:11:22"
      assert ev.remote_ip == "10.0.0.5"
      assert ev.remote_name == "remote-switch"
      assert ev.source_interface_id == interface.id
    end

    test "returns empty list when no LLDP neighbors", %{router: router} do
      assert Topology.collect_lldp_evidence(router.id) == []
    end
  end

  describe "collect_cdp_evidence/1" do
    test "returns evidence from CDP neighbors", %{router: router, interface: interface} do
      {:ok, _neighbor} =
        Towerops.Snmp.upsert_neighbor(%{
          device_id: router.id,
          interface_id: interface.id,
          protocol: "cdp",
          remote_chassis_id: "11:22:33:44:55:66",
          remote_system_name: "cisco-switch",
          remote_address: "10.0.0.6",
          remote_port_id: "Gi0/1",
          remote_capabilities: ["router"],
          last_discovered_at: DateTime.utc_now()
        })

      evidence = Topology.collect_cdp_evidence(router.id)

      assert length(evidence) == 1
      [ev] = evidence
      assert ev.evidence_type == "cdp_neighbor"
      assert ev.confidence == 0.95
    end
  end
```

**Step 2: Run test to verify it fails**

```bash
mix test test/towerops/topology_test.exs --trace
```

Expected: Fails — `collect_lldp_evidence/1` undefined.

**Step 3: Implement LLDP/CDP evidence collectors**

Add to `lib/towerops/topology.ex`:

```elixir
  alias Towerops.Snmp.Neighbor

  @doc "Collect link evidence from LLDP neighbors for a device."
  def collect_lldp_evidence(device_id) do
    collect_neighbor_evidence(device_id, "lldp", "lldp_neighbor", 0.95)
  end

  @doc "Collect link evidence from CDP neighbors for a device."
  def collect_cdp_evidence(device_id) do
    collect_neighbor_evidence(device_id, "cdp", "cdp_neighbor", 0.95)
  end

  defp collect_neighbor_evidence(device_id, protocol, evidence_type, confidence) do
    Repo.all(
      from(n in Neighbor,
        join: i in Interface,
        on: n.interface_id == i.id,
        where: n.device_id == ^device_id and n.protocol == ^protocol,
        select: %{
          neighbor_id: n.id,
          source_interface_id: i.id,
          remote_chassis_id: n.remote_chassis_id,
          remote_system_name: n.remote_system_name,
          remote_address: n.remote_address,
          remote_port_id: n.remote_port_id,
          remote_capabilities: n.remote_capabilities,
          last_discovered_at: n.last_discovered_at
        }
      )
    )
    |> Enum.map(fn row ->
      %{
        evidence_type: evidence_type,
        confidence: confidence,
        source_interface_id: row.source_interface_id,
        remote_mac: row.remote_chassis_id,
        remote_ip: row.remote_address,
        remote_name: row.remote_system_name,
        remote_port: row.remote_port_id,
        remote_capabilities: row.remote_capabilities,
        evidence_data: %{
          "neighbor_id" => row.neighbor_id,
          "remote_chassis_id" => row.remote_chassis_id,
          "remote_port_id" => row.remote_port_id,
          "last_discovered_at" => row.last_discovered_at
        }
      }
    end)
  end
```

**Step 4: Run tests**

```bash
mix test test/towerops/topology_test.exs
```

Expected: All pass.

**Step 5: Commit**

```
feat: add LLDP/CDP evidence collectors

Collect link evidence from SNMP neighbor data with 0.95 confidence.
Returns structured evidence maps ready for link upsert.
```

---

## Task 5: Evidence Collectors (MAC + ARP)

**Files:**
- Modify: `lib/towerops/topology.ex`
- Modify: `test/towerops/topology_test.exs`

**Step 1: Write failing tests for MAC and ARP evidence**

Add to `test/towerops/topology_test.exs`:

```elixir
  describe "collect_mac_evidence/2" do
    test "detects link when device MAC appears in another device's MAC table",
         %{organization: org, router: router, switch: switch} do
      # Create SNMP device + interface for the switch
      switch_snmp =
        %Towerops.Snmp.Device{}
        |> Towerops.Snmp.Device.changeset(%{device_id: switch.id, sys_name: "main-switch"})
        |> Repo.insert!()

      switch_iface =
        %Interface{}
        |> Interface.changeset(%{
          snmp_device_id: switch_snmp.id,
          if_index: 1,
          if_name: "ge-0/0/1",
          if_phys_address: "bb:cc:dd:00:11:22"
        })
        |> Repo.insert!()

      # The switch's MAC table shows the router's MAC on ge-0/0/1
      Towerops.Snmp.upsert_mac_address(%{
        device_id: switch.id,
        interface_id: switch_iface.id,
        mac_address: "aa:bb:cc:00:11:22",
        vlan_id: 1,
        entry_status: "learned",
        last_seen_at: DateTime.utc_now()
      })

      lookup = Topology.build_device_lookup(org.id)
      evidence = Topology.collect_mac_evidence(switch.id, lookup)

      assert length(evidence) == 1
      [ev] = evidence
      assert ev.evidence_type == "mac_on_interface"
      assert ev.confidence == 0.7
      assert ev.remote_mac == "aa:bb:cc:00:11:22"
    end

    test "ignores MAC addresses that don't match any device",
         %{organization: org, switch: switch} do
      switch_snmp =
        %Towerops.Snmp.Device{}
        |> Towerops.Snmp.Device.changeset(%{device_id: switch.id, sys_name: "main-switch"})
        |> Repo.insert!()

      switch_iface =
        %Interface{}
        |> Interface.changeset(%{
          snmp_device_id: switch_snmp.id,
          if_index: 1,
          if_name: "ge-0/0/1"
        })
        |> Repo.insert!()

      Towerops.Snmp.upsert_mac_address(%{
        device_id: switch.id,
        interface_id: switch_iface.id,
        mac_address: "ff:ff:ff:00:00:01",
        vlan_id: 1,
        entry_status: "learned",
        last_seen_at: DateTime.utc_now()
      })

      lookup = Topology.build_device_lookup(org.id)
      evidence = Topology.collect_mac_evidence(switch.id, lookup)

      # Unmatched MACs still produce evidence but with target_device_id = nil
      # They become discovered nodes
      assert length(evidence) == 1
      [ev] = evidence
      assert ev.remote_mac == "ff:ff:ff:00:00:01"
      assert ev.matched_device_id == nil
    end
  end

  describe "collect_arp_evidence/2" do
    test "detects link when ARP entry IP matches a device",
         %{organization: org, router: router, switch: switch} do
      Towerops.Snmp.upsert_arp_entry(%{
        device_id: router.id,
        ip_address: "10.0.0.2",
        mac_address: "bb:cc:dd:00:11:22",
        entry_type: "dynamic",
        last_seen_at: DateTime.utc_now()
      })

      lookup = Topology.build_device_lookup(org.id)
      evidence = Topology.collect_arp_evidence(router.id, lookup)

      matched = Enum.find(evidence, &(&1.matched_device_id == switch.id))
      assert matched != nil
      assert matched.evidence_type == "arp_entry"
      assert matched.confidence == 0.6
    end
  end
```

**Step 2: Run tests to verify failure**

```bash
mix test test/towerops/topology_test.exs
```

**Step 3: Implement MAC and ARP evidence collectors**

Add to `lib/towerops/topology.ex`:

```elixir
  alias Towerops.Snmp.ArpEntry
  alias Towerops.Snmp.MacAddress

  @doc "Collect link evidence from MAC address table entries."
  def collect_mac_evidence(device_id, lookup) do
    Repo.all(
      from(m in MacAddress,
        where: m.device_id == ^device_id,
        select: %{
          interface_id: m.interface_id,
          mac_address: m.mac_address,
          vlan_id: m.vlan_id,
          last_seen_at: m.last_seen_at
        }
      )
    )
    |> Enum.map(fn row ->
      matched_id = find_device_by_mac(lookup, row.mac_address)

      %{
        evidence_type: "mac_on_interface",
        confidence: 0.7,
        source_interface_id: row.interface_id,
        remote_mac: row.mac_address,
        remote_ip: nil,
        remote_name: nil,
        remote_port: nil,
        remote_capabilities: nil,
        matched_device_id: matched_id,
        evidence_data: %{
          "mac_address" => row.mac_address,
          "vlan_id" => row.vlan_id,
          "last_seen_at" => row.last_seen_at
        }
      }
    end)
    # Exclude the device's own MACs
    |> Enum.reject(fn ev -> ev.matched_device_id == device_id end)
  end

  @doc "Collect link evidence from ARP table entries."
  def collect_arp_evidence(device_id, lookup) do
    Repo.all(
      from(a in ArpEntry,
        where: a.device_id == ^device_id,
        select: %{
          interface_id: a.interface_id,
          ip_address: a.ip_address,
          mac_address: a.mac_address,
          last_seen_at: a.last_seen_at
        }
      )
    )
    |> Enum.map(fn row ->
      matched_id =
        find_device_by_mac(lookup, row.mac_address) ||
          find_device_by_ip(lookup, row.ip_address)

      %{
        evidence_type: "arp_entry",
        confidence: 0.6,
        source_interface_id: row.interface_id,
        remote_mac: row.mac_address,
        remote_ip: row.ip_address,
        remote_name: nil,
        remote_port: nil,
        remote_capabilities: nil,
        matched_device_id: matched_id,
        evidence_data: %{
          "ip_address" => row.ip_address,
          "mac_address" => row.mac_address,
          "last_seen_at" => row.last_seen_at
        }
      }
    end)
    |> Enum.reject(fn ev -> ev.matched_device_id == device_id end)
  end
```

**Step 4: Run tests**

```bash
mix test test/towerops/topology_test.exs
```

Expected: All pass.

**Step 5: Commit**

```
feat: add MAC and ARP evidence collectors

MAC evidence (0.7 confidence) cross-references MAC table entries
with known device interface MACs. ARP evidence (0.6 confidence)
matches ARP entries by IP and MAC. Both track unmatched entries
as potential discovered devices.
```

---

## Task 6: Confidence Merging and Link Upsert

**Files:**
- Modify: `lib/towerops/topology.ex`
- Modify: `test/towerops/topology_test.exs`

**Step 1: Write failing tests for confidence merging and link upsert**

```elixir
  describe "merge_confidence/2" do
    test "combines two independent confidence scores" do
      assert_in_delta Topology.merge_confidence(0.95, 0.7), 0.985, 0.001
    end

    test "combining with 0 returns the other score" do
      assert Topology.merge_confidence(0.8, 0.0) == 0.8
    end

    test "combining two high scores approaches 1.0" do
      assert_in_delta Topology.merge_confidence(0.95, 0.95), 0.9975, 0.001
    end
  end

  describe "upsert_link/1" do
    test "creates a new link from evidence", %{router: router, interface: interface} do
      now = DateTime.utc_now()

      {:ok, link} =
        Topology.upsert_link(%{
          source_device_id: router.id,
          target_device_id: nil,
          source_interface_id: interface.id,
          link_type: "lldp",
          confidence: 0.95,
          discovered_remote_mac: "dd:ee:ff:00:11:22",
          discovered_remote_ip: "10.0.0.5",
          discovered_remote_name: "remote-switch",
          last_confirmed_at: now,
          evidence: [%{
            evidence_type: "lldp_neighbor",
            evidence_data: %{"remote_chassis_id" => "dd:ee:ff:00:11:22"},
            observed_at: now
          }]
        })

      assert link.source_device_id == router.id
      assert link.confidence == 0.95
      assert link.link_type == "lldp"
      assert length(Repo.preload(link, :evidence).evidence) == 1
    end

    test "updates existing link and merges confidence", %{router: router, interface: interface} do
      now = DateTime.utc_now()

      # Create initial link
      {:ok, _link} =
        Topology.upsert_link(%{
          source_device_id: router.id,
          source_interface_id: interface.id,
          link_type: "lldp",
          confidence: 0.95,
          discovered_remote_mac: "dd:ee:ff:00:11:22",
          last_confirmed_at: now,
          evidence: [%{
            evidence_type: "lldp_neighbor",
            evidence_data: %{},
            observed_at: now
          }]
        })

      # Upsert again with MAC evidence
      {:ok, updated} =
        Topology.upsert_link(%{
          source_device_id: router.id,
          source_interface_id: interface.id,
          link_type: "mac_match",
          confidence: 0.7,
          discovered_remote_mac: "dd:ee:ff:00:11:22",
          last_confirmed_at: now,
          evidence: [%{
            evidence_type: "mac_on_interface",
            evidence_data: %{},
            observed_at: now
          }]
        })

      # Confidence should be merged, not replaced
      assert_in_delta updated.confidence, 0.985, 0.001
    end
  end
```

**Step 2: Run tests to verify failure**

```bash
mix test test/towerops/topology_test.exs
```

**Step 3: Implement confidence merging and link upsert**

Add to `lib/towerops/topology.ex`:

```elixir
  alias Towerops.Topology.DeviceLink
  alias Towerops.Topology.DeviceLinkEvidence

  @doc "Merge two independent confidence scores: 1 - (1-a)(1-b)"
  def merge_confidence(a, b) do
    1.0 - (1.0 - a) * (1.0 - b)
  end

  @doc """
  Create or update a device link. If a link already exists for the same
  source_device + source_interface + remote_mac, updates confidence
  (merged) and last_confirmed_at. Always appends new evidence.
  """
  def upsert_link(attrs) do
    evidence_attrs = Map.get(attrs, :evidence, [])
    link_attrs = Map.drop(attrs, [:evidence])

    existing =
      Repo.one(
        from(l in DeviceLink,
          where:
            l.source_device_id == ^link_attrs.source_device_id and
              l.source_interface_id == ^link_attrs[:source_interface_id] and
              l.discovered_remote_mac == ^link_attrs[:discovered_remote_mac]
        )
      )

    result =
      case existing do
        nil ->
          %DeviceLink{}
          |> DeviceLink.changeset(link_attrs)
          |> Repo.insert()

        link ->
          merged_confidence = merge_confidence(link.confidence, link_attrs.confidence)

          link
          |> DeviceLink.changeset(%{
            confidence: merged_confidence,
            last_confirmed_at: link_attrs.last_confirmed_at,
            target_device_id: link_attrs[:target_device_id] || link.target_device_id,
            metadata: Map.merge(link.metadata || %{}, link_attrs[:metadata] || %{})
          })
          |> Repo.update()
      end

    with {:ok, link} <- result do
      # Insert evidence records
      Enum.each(evidence_attrs, fn ev_attrs ->
        %DeviceLinkEvidence{}
        |> DeviceLinkEvidence.changeset(Map.put(ev_attrs, :device_link_id, link.id))
        |> Repo.insert!()
      end)

      {:ok, Repo.reload!(link)}
    end
  end
```

**Step 4: Run tests**

```bash
mix test test/towerops/topology_test.exs
```

Expected: All pass.

**Step 5: Commit**

```
feat: add confidence merging and link upsert

Links are created or updated via upsert keyed on source device +
interface + remote MAC. Confidence merges using 1-(1-a)(1-b).
Evidence records are always appended.
```

---

## Task 7: process_device/1 — Main Pipeline

Orchestrates evidence collection, link upsert, and role inference for a single device.

**Files:**
- Modify: `lib/towerops/topology.ex`
- Modify: `test/towerops/topology_test.exs`

**Step 1: Write failing test for process_device**

```elixir
  describe "process_device/1" do
    test "creates links from LLDP neighbors", %{router: router, switch: switch, interface: interface, organization: org} do
      # Create SNMP device for switch so its MAC is in the lookup
      switch_snmp =
        %Towerops.Snmp.Device{}
        |> Towerops.Snmp.Device.changeset(%{device_id: switch.id, sys_name: "main-switch"})
        |> Repo.insert!()

      _switch_iface =
        %Interface{}
        |> Interface.changeset(%{
          snmp_device_id: switch_snmp.id,
          if_index: 1,
          if_name: "ge-0/0/1",
          if_phys_address: "bb:cc:dd:00:11:22"
        })
        |> Repo.insert!()

      # Create LLDP neighbor on router pointing to the switch's MAC
      {:ok, _} =
        Towerops.Snmp.upsert_neighbor(%{
          device_id: router.id,
          interface_id: interface.id,
          protocol: "lldp",
          remote_chassis_id: "bb:cc:dd:00:11:22",
          remote_system_name: "Main-Switch",
          remote_address: "10.0.0.2",
          remote_port_id: "ge-0/0/1",
          remote_capabilities: ["switch", "bridge"],
          last_discovered_at: DateTime.utc_now()
        })

      assert {:ok, :changed} = Topology.process_device(router, org.id)

      # Verify link was created
      links = Topology.list_device_links(router.id)
      assert length(links) == 1
      [link] = links
      assert link.source_device_id == router.id
      assert link.target_device_id == switch.id
      assert link.link_type == "lldp"
      assert_in_delta link.confidence, 0.95, 0.01
    end

    test "creates discovered link when remote device is not managed",
         %{router: router, interface: interface, organization: org} do
      {:ok, _} =
        Towerops.Snmp.upsert_neighbor(%{
          device_id: router.id,
          interface_id: interface.id,
          protocol: "lldp",
          remote_chassis_id: "ff:ff:ff:00:00:01",
          remote_system_name: "unknown-device",
          remote_address: "10.99.99.99",
          remote_port_id: "eth0",
          remote_capabilities: [],
          last_discovered_at: DateTime.utc_now()
        })

      assert {:ok, :changed} = Topology.process_device(router, org.id)

      links = Topology.list_device_links(router.id)
      assert length(links) == 1
      [link] = links
      assert link.target_device_id == nil
      assert link.discovered_remote_mac == "ff:ff:ff:00:00:01"
      assert link.discovered_remote_name == "unknown-device"
    end

    test "returns :unchanged when no new evidence", %{router: router, organization: org} do
      assert {:ok, :unchanged} = Topology.process_device(router, org.id)
    end
  end
```

**Step 2: Run tests to verify failure**

```bash
mix test test/towerops/topology_test.exs
```

**Step 3: Implement process_device/1 and list_device_links/1**

Add to `lib/towerops/topology.ex`:

```elixir
  require Logger

  @doc """
  Main topology inference pipeline for a device. Called after each poll cycle.
  Collects evidence, upserts links, infers role. Returns {:ok, :changed} or {:ok, :unchanged}.
  """
  def process_device(device, organization_id) do
    lookup = build_device_lookup(organization_id)
    now = DateTime.utc_now()

    # Collect all evidence
    lldp = collect_lldp_evidence(device.id)
    cdp = collect_cdp_evidence(device.id)
    mac = collect_mac_evidence(device.id, lookup)
    arp = collect_arp_evidence(device.id, lookup)

    all_evidence = lldp ++ cdp ++ mac ++ arp

    if Enum.empty?(all_evidence) do
      {:ok, :unchanged}
    else
      # Group evidence by remote identifier (MAC preferred, then IP)
      grouped = group_evidence_by_remote(all_evidence)

      # Upsert links for each remote device
      changes =
        Enum.map(grouped, fn {_key, evidence_list} ->
          best = Enum.max_by(evidence_list, & &1.confidence)
          matched_id = resolve_device(lookup, %{
            mac: best.remote_mac,
            ip: best.remote_ip,
            name: best.remote_name
          })

          # Don't create self-links
          if matched_id == device.id do
            :skip
          else
            upsert_link(%{
              source_device_id: device.id,
              target_device_id: matched_id,
              source_interface_id: best.source_interface_id,
              link_type: best.evidence_type |> evidence_type_to_link_type(),
              confidence: combine_evidence_confidence(evidence_list),
              discovered_remote_mac: best.remote_mac,
              discovered_remote_ip: best.remote_ip,
              discovered_remote_name: best.remote_name,
              metadata: %{},
              last_confirmed_at: now,
              evidence: Enum.map(evidence_list, fn ev ->
                %{
                  evidence_type: ev.evidence_type,
                  evidence_data: ev.evidence_data,
                  observed_at: now
                }
              end)
            })
          end
        end)
        |> Enum.reject(&(&1 == :skip))

      has_changes = Enum.any?(changes, fn
        {:ok, _} -> true
        _ -> false
      end)

      if has_changes do
        # Broadcast topology change
        Phoenix.PubSub.broadcast(
          Towerops.PubSub,
          "topology:#{organization_id}",
          {:topology_updated, organization_id}
        )

        {:ok, :changed}
      else
        {:ok, :unchanged}
      end
    end
  end

  @doc "List all device links where this device is the source."
  def list_device_links(device_id) do
    Repo.all(
      from(l in DeviceLink,
        where: l.source_device_id == ^device_id,
        order_by: [desc: l.confidence]
      )
    )
  end

  # Group evidence by remote identifier (prefer MAC, fallback to IP)
  defp group_evidence_by_remote(evidence_list) do
    Enum.group_by(evidence_list, fn ev ->
      ev.remote_mac || ev.remote_ip || ev.remote_name || "unknown"
    end)
  end

  # Combine confidence scores from multiple evidence items
  defp combine_evidence_confidence(evidence_list) do
    evidence_list
    |> Enum.map(& &1.confidence)
    |> Enum.reduce(0.0, &merge_confidence/2)
  end

  defp evidence_type_to_link_type("lldp_neighbor"), do: "lldp"
  defp evidence_type_to_link_type("cdp_neighbor"), do: "cdp"
  defp evidence_type_to_link_type("mac_on_interface"), do: "mac_match"
  defp evidence_type_to_link_type("arp_entry"), do: "arp_inference"
  defp evidence_type_to_link_type("wireless_registration"), do: "wireless_association"
  defp evidence_type_to_link_type(_), do: "mac_match"
```

**Step 4: Run tests**

```bash
mix test test/towerops/topology_test.exs
```

Expected: All pass.

**Step 5: Commit**

```
feat: add process_device/1 topology pipeline

Orchestrates evidence collection from LLDP, CDP, MAC, and ARP data.
Groups evidence by remote device, resolves to managed devices,
upserts links with merged confidence. Broadcasts topology changes
via PubSub.
```

---

## Task 8: Device Role Inference

**Files:**
- Modify: `lib/towerops/topology.ex`
- Modify: `test/towerops/topology_test.exs`

**Step 1: Write failing tests for role inference**

```elixir
  describe "infer_device_role/2" do
    test "infers access_point from LLDP wlan-ap capability",
         %{router: router, interface: interface} do
      {:ok, _} =
        Towerops.Snmp.upsert_neighbor(%{
          device_id: router.id,
          interface_id: interface.id,
          protocol: "lldp",
          remote_chassis_id: "aa:bb:cc:00:11:22",
          remote_system_name: "core-router",
          remote_capabilities: ["wlan-ap"],
          last_discovered_at: DateTime.utc_now()
        })

      role = Topology.infer_device_role(router)
      assert role == "access_point"
    end

    test "infers core_router from router capability + many interfaces",
         %{router: router, snmp_device: snmp_device, interface: _interface} do
      # Add 10+ interfaces
      for i <- 2..12 do
        %Interface{}
        |> Interface.changeset(%{
          snmp_device_id: snmp_device.id,
          if_index: i,
          if_name: "eth#{i}"
        })
        |> Repo.insert!()
      end

      {:ok, _} =
        Towerops.Snmp.upsert_neighbor(%{
          device_id: router.id,
          interface_id: Repo.one(from(i in Interface, where: i.snmp_device_id == ^snmp_device.id, limit: 1)).id,
          protocol: "lldp",
          remote_chassis_id: "11:22:33:44:55:66",
          remote_capabilities: ["router"],
          last_discovered_at: DateTime.utc_now()
        })

      role = Topology.infer_device_role(router)
      assert role == "core_router"
    end

    test "returns unknown when no evidence", %{switch: switch} do
      role = Topology.infer_device_role(switch)
      assert role == "unknown"
    end

    test "infers ups from manufacturer", %{router: router, snmp_device: snmp_device} do
      snmp_device
      |> Towerops.Snmp.Device.changeset(%{manufacturer: "APC"})
      |> Repo.update!()

      role = Topology.infer_device_role(router)
      assert role == "ups"
    end
  end
```

**Step 2: Run tests to verify failure**

```bash
mix test test/towerops/topology_test.exs
```

**Step 3: Implement role inference**

Add to `lib/towerops/topology.ex`:

```elixir
  alias Towerops.Snmp.Device, as: SnmpDevice

  @ups_vendors ~w(apc cyberpower eaton tripp\ lite tripplite)
  @firewall_vendors ~w(fortinet fortigate palo\ alto paloalto pfsense sonicwall sophos)
  @server_platforms ~w(linux windows vmware esxi proxmox)

  @doc """
  Infer the role of a device from its SNMP data, neighbors, and links.
  Returns a role string. Does not write to the database.
  """
  def infer_device_role(device) do
    device = Repo.preload(device, snmp_device: :interfaces)

    snmp_device = device.snmp_device
    interface_count = if snmp_device, do: length(snmp_device.interfaces), else: 0
    manufacturer = if snmp_device, do: String.downcase(snmp_device.manufacturer || ""), else: ""
    sys_descr = if snmp_device, do: String.downcase(snmp_device.sys_descr || ""), else: ""

    # Collect LLDP/CDP capabilities reported BY other devices about this one
    own_capabilities = get_device_reported_capabilities(device.id)

    cond do
      "wlan-ap" in own_capabilities ->
        "access_point"

      "router" in own_capabilities and interface_count >= 10 ->
        "core_router"

      "router" in own_capabilities ->
        "distribution_switch"

      "bridge" in own_capabilities and interface_count >= 10 ->
        "access_switch"

      "bridge" in own_capabilities ->
        "access_switch"

      vendor_match?(manufacturer, @ups_vendors) ->
        "ups"

      vendor_match?(manufacturer, @firewall_vendors) or vendor_match?(sys_descr, @firewall_vendors) ->
        "firewall"

      platform_match?(sys_descr, @server_platforms) ->
        "server"

      true ->
        "unknown"
    end
  end

  @doc """
  Apply inferred role to a device, only if device_role_source is "inferred".
  """
  def maybe_update_device_role(device) do
    if device.device_role_source != "manual" do
      role = infer_device_role(device)

      if role != device.device_role do
        device
        |> Ecto.Changeset.change(%{device_role: role, device_role_source: "inferred"})
        |> Repo.update()
      else
        {:ok, device}
      end
    else
      {:ok, device}
    end
  end

  # Get capabilities that OTHER devices report about this device via LLDP/CDP
  defp get_device_reported_capabilities(device_id) do
    device = Repo.preload(%Towerops.Devices.Device{id: device_id}, snmp_device: :interfaces)

    case device.snmp_device do
      nil ->
        []

      sd ->
        mac_addresses =
          sd.interfaces
          |> Enum.map(& &1.if_phys_address)
          |> Enum.reject(&is_nil/1)

        if Enum.empty?(mac_addresses) do
          []
        else
          Repo.all(
            from(n in Neighbor,
              where: n.remote_chassis_id in ^mac_addresses,
              select: n.remote_capabilities
            )
          )
          |> List.flatten()
          |> Enum.uniq()
        end
    end
  end

  defp vendor_match?(value, vendors) do
    Enum.any?(vendors, &String.contains?(value, &1))
  end

  defp platform_match?(description, platforms) do
    Enum.any?(platforms, &String.contains?(description, &1))
  end
```

**Step 4: Run tests**

```bash
mix test test/towerops/topology_test.exs
```

Expected: All pass.

**Step 5: Wire role inference into process_device**

Add `maybe_update_device_role(device)` call at the end of `process_device/1`, before the return.

**Step 6: Commit**

```
feat: add device role inference

Infers device roles from LLDP/CDP capabilities, interface count,
vendor/manufacturer, and system description. Only overwrites
inferred roles, never manual overrides.
```

---

## Task 9: Hook into DevicePollerWorker

**Files:**
- Modify: `lib/towerops/workers/device_poller_worker.ex`

**Step 1: Add topology processing call**

In `lib/towerops/workers/device_poller_worker.ex`, modify `poll_device_neighbors/3` (line 315-334). After the existing PubSub broadcast, add:

```elixir
    # Run topology inference after neighbor data is saved
    case Towerops.Topology.process_device(device, device.organization_id) do
      {:ok, :changed} ->
        Logger.debug("Topology updated for #{device.name}")

      {:ok, :unchanged} ->
        :ok

      {:error, reason} ->
        Logger.warning("Topology inference failed for #{device.name}: #{inspect(reason)}")
    end
```

Also add `alias Towerops.Topology` to the module aliases at the top.

**Step 2: Verify existing tests still pass**

```bash
mix test test/towerops/workers/device_poller_worker_test.exs
```

Expected: All existing tests pass (topology is additive, doesn't break anything).

**Step 3: Commit**

```
feat: hook topology inference into polling pipeline

DevicePollerWorker calls Topology.process_device/1 after saving
neighbor data. Topology updates happen incrementally every poll
cycle with no new workers needed.
```

---

## Task 10: Stale Link Cleanup

**Files:**
- Modify: `lib/towerops/topology.ex`
- Modify: `test/towerops/topology_test.exs`

**Step 1: Write failing test for stale link cleanup**

```elixir
  describe "cleanup_stale_links/0" do
    test "marks old links as stale", %{router: router, interface: interface} do
      old_time = DateTime.add(DateTime.utc_now(), -25, :hour)

      {:ok, link} =
        %DeviceLink{}
        |> DeviceLink.changeset(%{
          source_device_id: router.id,
          source_interface_id: interface.id,
          link_type: "lldp",
          confidence: 0.95,
          discovered_remote_mac: "aa:bb:cc:dd:ee:ff",
          last_confirmed_at: old_time
        })
        |> Repo.insert()

      {stale_count, deleted_count} = Topology.cleanup_stale_links()

      updated = Repo.get!(DeviceLink, link.id)
      assert_in_delta updated.confidence, 0.1, 0.01
      assert stale_count >= 1
    end

    test "deletes very old links", %{router: router, interface: interface} do
      very_old = DateTime.add(DateTime.utc_now(), -73, :hour)

      {:ok, link} =
        %DeviceLink{}
        |> DeviceLink.changeset(%{
          source_device_id: router.id,
          source_interface_id: interface.id,
          link_type: "lldp",
          confidence: 0.95,
          discovered_remote_mac: "aa:bb:cc:dd:ee:ff",
          last_confirmed_at: very_old
        })
        |> Repo.insert()

      {_stale, deleted_count} = Topology.cleanup_stale_links()

      assert deleted_count >= 1
      assert Repo.get(DeviceLink, link.id) == nil
    end
  end
```

**Step 2: Run tests to verify failure**

**Step 3: Implement cleanup**

Add to `lib/towerops/topology.ex`:

```elixir
  @doc """
  Clean up stale links. Called periodically (e.g. hourly).
  - Links not confirmed in 24h: set confidence to 0.1
  - Links not confirmed in 72h: delete entirely
  Returns {stale_count, deleted_count}.
  """
  def cleanup_stale_links do
    now = DateTime.utc_now()
    stale_cutoff = DateTime.add(now, -24, :hour)
    delete_cutoff = DateTime.add(now, -72, :hour)

    # Delete very old links
    {deleted_count, _} =
      Repo.delete_all(
        from(l in DeviceLink, where: l.last_confirmed_at < ^delete_cutoff)
      )

    # Mark stale links
    {stale_count, _} =
      Repo.update_all(
        from(l in DeviceLink,
          where: l.last_confirmed_at < ^stale_cutoff and l.confidence > 0.1
        ),
        set: [confidence: 0.1]
      )

    {stale_count, deleted_count}
  end
```

**Step 4: Hook into NeighborCleanupWorker**

Find the existing `NeighborCleanupWorker` and add a call to `Towerops.Topology.cleanup_stale_links()` in its `perform/1`.

**Step 5: Run tests**

```bash
mix test test/towerops/topology_test.exs
```

**Step 6: Commit**

```
feat: add stale link cleanup

Links unconfirmed for 24h get confidence reduced to 0.1.
Links unconfirmed for 72h are deleted. Runs via existing
NeighborCleanupWorker hourly cron.
```

---

## Task 11: Update NetworkMapLive to Use Persistent Topology

**Files:**
- Modify: `lib/towerops/topology.ex` (add `get_topology_for_map/2`)
- Modify: `lib/towerops_web/live/network_map_live.ex`
- Modify: `test/towerops/topology_test.exs`

**Step 1: Write failing test for map topology query**

```elixir
  describe "get_topology_for_map/2" do
    test "returns nodes and edges for added-only tab",
         %{router: router, switch: switch, interface: interface, organization: org} do
      # Set up a link between router and switch
      {:ok, _} =
        Topology.upsert_link(%{
          source_device_id: router.id,
          target_device_id: switch.id,
          source_interface_id: interface.id,
          link_type: "lldp",
          confidence: 0.95,
          discovered_remote_mac: "bb:cc:dd:00:11:22",
          last_confirmed_at: DateTime.utc_now(),
          evidence: []
        })

      result = Topology.get_topology_for_map(org.id, "added")

      assert length(result.nodes) == 2
      assert length(result.edges) == 1
      assert Enum.all?(result.nodes, &(!&1.discovered))
    end

    test "includes discovered nodes on all tab",
         %{router: router, interface: interface, organization: org} do
      {:ok, _} =
        Topology.upsert_link(%{
          source_device_id: router.id,
          target_device_id: nil,
          source_interface_id: interface.id,
          link_type: "lldp",
          confidence: 0.95,
          discovered_remote_mac: "ff:ff:ff:00:00:01",
          discovered_remote_name: "unknown-cpe",
          discovered_remote_ip: "10.99.99.1",
          last_confirmed_at: DateTime.utc_now(),
          evidence: []
        })

      result = Topology.get_topology_for_map(org.id, "all")

      discovered = Enum.filter(result.nodes, & &1.discovered)
      assert length(discovered) == 1
    end
  end
```

**Step 2: Implement get_topology_for_map**

Add to `lib/towerops/topology.ex`:

```elixir
  @doc """
  Build topology data for the network map. Returns %{nodes, edges, stats}.
  Tab "added" = managed devices only. Tab "all" = managed + discovered.
  """
  def get_topology_for_map(organization_id, tab \\ "added") do
    # Get managed devices
    devices =
      Repo.all(
        from(d in Device,
          where: d.organization_id == ^organization_id,
          left_join: s in assoc(d, :site),
          left_join: sd in assoc(d, :snmp_device),
          preload: [site: s, snmp_device: sd]
        )
      )

    device_ids = Enum.map(devices, & &1.id)

    # Get all links from these devices
    links =
      Repo.all(
        from(l in DeviceLink,
          where: l.source_device_id in ^device_ids,
          preload: [:source_interface, :target_interface]
        )
      )

    # Build managed device nodes
    managed_nodes =
      Enum.map(devices, fn d ->
        %{
          id: d.id,
          label: d.name,
          type: role_to_type_atom(d.device_role),
          device_role: d.device_role,
          status: d.status,
          site_id: d.site_id,
          site_name: if(d.site, do: d.site.name),
          ip_address: d.ip_address,
          discovered: false,
          manufacturer: if(d.snmp_device, do: d.snmp_device.manufacturer)
        }
      end)

    # Build discovered nodes from links with nil target_device_id
    discovered_nodes =
      if tab == "all" do
        links
        |> Enum.filter(&is_nil(&1.target_device_id))
        |> Enum.uniq_by(& &1.discovered_remote_mac)
        |> Enum.map(fn link ->
          %{
            id: "discovered_#{link.discovered_remote_mac}",
            label: link.discovered_remote_name || link.discovered_remote_mac,
            type: :unknown,
            device_role: nil,
            status: :unknown,
            site_id: nil,
            site_name: nil,
            ip_address: link.discovered_remote_ip,
            discovered: true,
            manufacturer: nil,
            parent_device_id: link.source_device_id
          }
        end)
      else
        []
      end

    all_nodes = managed_nodes ++ discovered_nodes
    node_ids = MapSet.new(Enum.map(all_nodes, & &1.id))

    # Build edges
    edges =
      links
      |> Enum.map(fn link ->
        target_id =
          if link.target_device_id do
            link.target_device_id
          else
            "discovered_#{link.discovered_remote_mac}"
          end

        %{
          id: link.id,
          source: link.source_device_id,
          target: target_id,
          source_interface: if(link.source_interface, do: link.source_interface.if_name),
          target_interface: if(link.target_interface, do: link.target_interface.if_name),
          link_type: link.link_type,
          confidence: link.confidence,
          metadata: link.metadata
        }
      end)
      |> Enum.filter(fn edge ->
        MapSet.member?(node_ids, edge.source) and MapSet.member?(node_ids, edge.target)
      end)

    managed_count = length(managed_nodes)
    discovered_count = length(discovered_nodes)

    %{
      nodes: all_nodes,
      edges: edges,
      stats: %{
        total_devices: managed_count + discovered_count,
        added_devices: managed_count,
        discovered_devices: discovered_count,
        total_links: length(edges)
      },
      last_updated: DateTime.utc_now()
    }
  end

  defp role_to_type_atom("core_router"), do: :router
  defp role_to_type_atom("distribution_switch"), do: :switch
  defp role_to_type_atom("access_switch"), do: :switch
  defp role_to_type_atom("access_point"), do: :wireless
  defp role_to_type_atom("cpe"), do: :wireless
  defp role_to_type_atom("backhaul_radio"), do: :wireless
  defp role_to_type_atom("ups"), do: :server
  defp role_to_type_atom("firewall"), do: :firewall
  defp role_to_type_atom("server"), do: :server
  defp role_to_type_atom(_), do: :unknown
```

**Step 3: Update NetworkMapLive to use new function**

In `lib/towerops_web/live/network_map_live.ex`, change `load_topology_data/3`:

```elixir
  alias Towerops.Topology

  defp load_topology_data(socket, organization_id, tab) do
    topology = Topology.get_topology_for_map(organization_id, tab)

    socket
    |> assign(:topology, topology)
    |> assign(:loading, false)
    |> push_event("update_topology", %{topology: topology})
  end
```

Remove the old `filter_edges_for_nodes/3` private function (no longer needed).

**Step 4: Run tests**

```bash
mix test test/towerops/topology_test.exs
mix test test/towerops_web/live/network_map_live_test.exs 2>/dev/null; echo "done"
```

**Step 5: Commit**

```
feat: switch network map to persistent topology data

NetworkMapLive now reads from device_links instead of computing
topology on-demand from raw neighbor data. Supports both
managed-only and all-devices views.
```

---

## Task 12: Device Detail Page — Connected Devices Section

**Files:**
- Modify: `lib/towerops/topology.ex` (add `list_connected_devices/1`)
- Modify: `lib/towerops_web/live/device_live/show.ex`
- Modify: `lib/towerops_web/live/device_live/show.html.heex`

**Step 1: Write failing test for list_connected_devices**

Add to `test/towerops/topology_test.exs`:

```elixir
  describe "list_connected_devices/1" do
    test "returns managed and discovered links for a device",
         %{router: router, switch: switch, interface: interface} do
      now = DateTime.utc_now()

      # Managed link
      {:ok, _} =
        Topology.upsert_link(%{
          source_device_id: router.id,
          target_device_id: switch.id,
          source_interface_id: interface.id,
          link_type: "lldp",
          confidence: 0.95,
          discovered_remote_mac: "bb:cc:dd:00:11:22",
          last_confirmed_at: now,
          evidence: []
        })

      # Discovered link
      {:ok, _} =
        Topology.upsert_link(%{
          source_device_id: router.id,
          source_interface_id: interface.id,
          link_type: "lldp",
          confidence: 0.8,
          discovered_remote_mac: "ff:ff:ff:00:00:01",
          discovered_remote_name: "CPE-Customer",
          discovered_remote_ip: "10.0.5.47",
          last_confirmed_at: now,
          evidence: []
        })

      connected = Topology.list_connected_devices(router.id)
      assert length(connected) == 2
    end
  end
```

**Step 2: Implement list_connected_devices**

Add to `lib/towerops/topology.ex`:

```elixir
  @doc """
  List all devices connected to this device via device_links.
  Returns links with preloaded target device (if managed) and interfaces.
  """
  def list_connected_devices(device_id) do
    Repo.all(
      from(l in DeviceLink,
        where: l.source_device_id == ^device_id or l.target_device_id == ^device_id,
        preload: [:source_device, :target_device, :source_interface, :target_interface],
        order_by: [desc: l.confidence]
      )
    )
  end
```

**Step 3: Add connected devices to device detail page**

In `lib/towerops_web/live/device_live/show.ex`, add `connected_devices` to the assigns loaded in `load_equipment_data/2`. Load it when the overview tab is active.

In the template (`show.html.heex`), add a "Connected Devices" section to the overview tab that renders the links as a table. Show device name/IP, link type, confidence badge, and "Managed"/"Discovered" indicator. Link managed devices to their detail pages.

**Step 4: Run tests**

```bash
mix test test/towerops/topology_test.exs
```

**Step 5: Commit**

```
feat: show connected devices on device detail page

Overview tab shows persistent links to other devices with
link type, confidence, and managed/discovered indicators.
Managed devices link to their detail pages.
```

---

## Task 13: Device Role on Edit Page

**Files:**
- Modify: `lib/towerops_web/live/device_live/form_component.ex` (or wherever device edit form lives)

**Step 1: Add device_role dropdown to the device edit form**

Add a select input with all role options plus a "Reset to auto-detect" option. When a role is manually selected, set `device_role_source` to `"manual"`. When "Auto-detect" is selected, set `device_role_source` to `"inferred"` and `device_role` to nil (will be inferred on next poll).

**Step 2: Verify it renders and saves correctly**

```bash
mix test test/towerops_web/live/device_live/
```

**Step 3: Commit**

```
feat: add device role selector to edit page

Users can manually set device role or reset to auto-detect.
Manual overrides are preserved through topology inference cycles.
```

---

## Task 14: Cytoscape.js Role-Based Styling

**Files:**
- Modify: `assets/js/app.ts` (NetworkMap hook)

**Step 1: Update node styling to use device_role**

In the NetworkMap hook's Cytoscape style configuration, add role-based node shapes and colors matching the design doc table. Use the `device_role` field from node data. Keep backward compatibility with the `type` field.

**Step 2: Update edge styling for confidence**

- Confidence > 0.9: solid line, green
- Confidence 0.5-0.9: dashed line, yellow
- Confidence < 0.5: dotted line, gray

Add link_type to edge tooltip on hover.

**Step 3: Add discovered node styling**

Discovered nodes (on "all" tab): dashed border, muted opacity, smaller size.

**Step 4: Visual verification**

Start the dev server, navigate to `/network-map`, verify nodes show proper shapes and edges show confidence styling.

**Step 5: Commit**

```
feat: role-based node styling on network map

Nodes display shapes and colors based on device_role. Edges
show confidence via line style (solid/dashed/dotted). Discovered
nodes appear muted on the all-devices tab.
```

---

## Task 15: Run Full Test Suite and Precommit

**Step 1: Run precommit**

```bash
mix precommit
```

Expected: All checks pass (format, compile warnings-as-errors, tests).

**Step 2: Run dialyzer**

```bash
mix dialyzer
```

Expected: No new warnings from topology code.

**Step 3: Fix any issues found**

**Step 4: Final commit if any fixes needed**

---

## Task Summary

| Task | Description | Dependencies |
|------|-------------|--------------|
| 1 | Database migrations | None |
| 2 | Ecto schemas (DeviceLink, DeviceLinkEvidence, Device role fields) | Task 1 |
| 3 | Topology context — device matching | Task 2 |
| 4 | LLDP/CDP evidence collectors | Task 3 |
| 5 | MAC + ARP evidence collectors | Task 3 |
| 6 | Confidence merging and link upsert | Tasks 4, 5 |
| 7 | process_device/1 pipeline | Task 6 |
| 8 | Device role inference | Task 7 |
| 9 | Hook into DevicePollerWorker | Task 7 |
| 10 | Stale link cleanup | Task 2 |
| 11 | Update NetworkMapLive | Task 7 |
| 12 | Device detail — connected devices | Task 7 |
| 13 | Device role on edit page | Task 2 |
| 14 | Cytoscape.js role-based styling | Task 11 |
| 15 | Full test suite + precommit | All |

Tasks 4+5 can run in parallel. Tasks 10, 11, 12, 13 can run in parallel after Task 7.
