Przeglądaj źródła

Unify system and network discovery through the MAC bridge

The same physical machine seen by two collectors used to become two
cards, because their identity schemes cannot derive each other: the
agent identifies by machine-id, a scan by MAC. Now they meet in the
middle — one machine, one card, whichever collector ran first.

The agent's side of the bridge: `rpk discover system` records the
machine's physical-NIC MACs (loopback and virtual interfaces excluded)
as a `macs` label, normalised by the same code that reads ARP tables so
both sides always agree on the spelling. The local docker collector
builds its host card through the same mapper, so it participates for
free.

The resolver's side: after an id lookup misses, a MAC shared with
exactly one stored card of the same kind unifies onto that card. The
stronger identity wins — an agent id replaces a scan id; a scan id is
nulled before the merge so it can never downgrade one. Ambiguity never
unifies: a MAC claimed by two stored cards identifies nothing, and two
agent-grade identities sharing a MAC (cloned VMs) stay apart — that is
what machine-ids are for. Names remain user-owned: the stored card
keeps its name, so a scan-first card keeps its generated name until
renamed once, after which every collector follows it.

Proxmox remains outside the bridge (its API view carries no host MACs);
the existing suffix protection still applies there, and the
no-MAC-label case keeps its dedicated regression test.

18 new tests: resolver-level contracts (claim, enrich, ambiguity,
cloned-VM refusal, kind mismatch, id-beats-MAC, spelling-independent
matching, hand-written adoption), facts/mapper coverage, and both
headline flows e2e through the real server. Proven live on this
machine: scan-first created host-e64e5425, `rpk discover system`
updated that same card to the sys identity with OS/cores/RAM, and a
rescan reported "no changes".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tim Jones 14 godzin temu
rodzic
commit
f89a2a6fb0

+ 10 - 0
RackPeek.Domain/Discovery/DiscoveryId.cs

@@ -29,6 +29,16 @@ public static class DiscoveryId {
         return $"{Prefix}:{scheme}:{Convert.ToHexString(hash, 0, 8).ToLowerInvariant()}";
     }
 
+    /// <summary>The scheme segment of an id — "sys" for rpk1:sys:… — or null for anything malformed.</summary>
+    public static string? Scheme(string? discoveryId) {
+        if (string.IsNullOrWhiteSpace(discoveryId))
+            return null;
+
+        var parts = discoveryId.Split(':');
+
+        return parts.Length == 3 ? parts[1] : null;
+    }
+
     /// <summary>Short, stable fragment used to disambiguate generated names.</summary>
     public static string ShortSuffix(string discoveryId) {
         var lastColon = discoveryId.LastIndexOf(':');

+ 100 - 3
RackPeek.Domain/Discovery/DiscoveryIdResolver.cs

@@ -38,6 +38,8 @@ public static class DiscoveryIdResolver {
             .Where(r => !string.IsNullOrWhiteSpace(r.DiscoveryId))
             .ToDictionary(r => r.DiscoveryId!, r => r, StringComparer.OrdinalIgnoreCase);
 
+        Dictionary<string, Resource> existingByMac = BuildMacMap(existing);
+
         // Tolerant of a hand-edited file that managed to get two resources of the
         // same name: the first wins, rather than crashing the import.
         var existingByName = new Dictionary<string, Resource>(StringComparer.OrdinalIgnoreCase);
@@ -48,7 +50,7 @@ public static class DiscoveryIdResolver {
         var renames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 
         foreach (Resource resource in incomingWithId) {
-            var resolved = ResolveName(resource, existingById, existingByName);
+            var resolved = ResolveName(resource, existingById, existingByName, existingByMac);
 
             if (resolved.Equals(resource.Name, StringComparison.OrdinalIgnoreCase))
                 continue;
@@ -81,8 +83,11 @@ public static class DiscoveryIdResolver {
         var incomingNames = new HashSet<string>(incoming.Select(r => r.Name), StringComparer.OrdinalIgnoreCase);
 
         foreach (Resource resource in incomingWithId) {
+            // MAC unification may have nulled a scan card's id so the merge cannot
+            // downgrade the stored identity — such a card has nothing to look up here.
             if (resource.RunsOn.Count == 0
-                || !existingById.TryGetValue(resource.DiscoveryId!, out Resource? stored)
+                || string.IsNullOrWhiteSpace(resource.DiscoveryId)
+                || !existingById.TryGetValue(resource.DiscoveryId, out Resource? stored)
                 || stored.RunsOn.Count == 0)
                 continue;
 
@@ -97,11 +102,17 @@ public static class DiscoveryIdResolver {
     private static string ResolveName(
         Resource resource,
         Dictionary<string, Resource> existingById,
-        Dictionary<string, Resource> existingByName) {
+        Dictionary<string, Resource> existingByName,
+        Dictionary<string, Resource> existingByMac) {
         // Known id: the stored resource wins on name, whatever the user has renamed it to.
         if (existingById.TryGetValue(resource.DiscoveryId!, out Resource? matched))
             return matched.Name;
 
+        // Unknown id, but a MAC in common with exactly one stored card: the same
+        // physical machine seen by two collectors, unified onto the stored card.
+        if (TryUnifyByMac(resource, existingByMac, out var unifiedName))
+            return unifiedName;
+
         // Unknown id and the name is free: nothing to reconcile.
         if (!existingByName.TryGetValue(resource.Name, out Resource? sameName))
             return resource.Name;
@@ -128,6 +139,92 @@ public static class DiscoveryIdResolver {
         return resource.Name;
     }
 
+    /// <summary>
+    ///     The bridge between collectors that cannot derive each other's ids: the agent
+    ///     records the machine's MACs (a "macs" label), the scan identifies it by one (a
+    ///     "mac" label). A shared MAC on a stored card of the same kind means the same
+    ///     box — the incoming card adopts the stored card's name so the merge lands on
+    ///     it, and the stronger identity wins: an agent id replaces a scan id, a scan id
+    ///     never replaces anything (it is nulled here so the merge cannot downgrade).
+    ///     Ids from two agent-grade collectors sharing a MAC (cloned VMs, or Proxmox's
+    ///     view of a guest) are never unified — that is what machine-ids are for.
+    /// </summary>
+    private static bool TryUnifyByMac(
+        Resource resource,
+        Dictionary<string, Resource> existingByMac,
+        out string unifiedName) {
+        unifiedName = string.Empty;
+
+        foreach (var mac in MacsOf(resource)) {
+            if (!existingByMac.TryGetValue(mac, out Resource? stored))
+                continue;
+
+            // The box the user documented as a Server and the OS a scan saw on it are
+            // different cards on purpose; unification is for same-kind cards only.
+            if (stored.GetType() != resource.GetType())
+                continue;
+
+            var incomingIsNet = DiscoveryId.Scheme(resource.DiscoveryId) == DiscoveryId.NetworkScheme;
+
+            // A stored card with a MAC but no id yet: adoption, same as the name-based
+            // adoption case — the incoming id gets stamped onto it by the merge.
+            if (string.IsNullOrWhiteSpace(stored.DiscoveryId)) {
+                unifiedName = stored.Name;
+
+                return true;
+            }
+
+            var storedIsNet = DiscoveryId.Scheme(stored.DiscoveryId) == DiscoveryId.NetworkScheme;
+
+            // Both scan-grade or both agent-grade: not safe to unify on a MAC alone.
+            if (incomingIsNet == storedIsNet)
+                continue;
+
+            if (incomingIsNet)
+                resource.DiscoveryId = null;
+
+            unifiedName = stored.Name;
+
+            return true;
+        }
+
+        return false;
+    }
+
+    /// <summary>The MACs a resource claims, from its "mac" and "macs" labels, normalised.</summary>
+    private static IEnumerable<string> MacsOf(Resource resource) {
+        IEnumerable<string?> raw = [
+            resource.Labels.GetValueOrDefault("mac"),
+            .. (resource.Labels.GetValueOrDefault("macs") ?? string.Empty).Split(
+                ',',
+                StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+        ];
+
+        return raw
+            .Select(ArpTableParser.NormaliseMac)
+            .Where(mac => mac != null)
+            .Select(mac => mac!)
+            .Distinct();
+    }
+
+    /// <summary>
+    ///     mac → the one stored resource claiming it. A MAC claimed by two stored
+    ///     resources identifies nothing and is dropped: ambiguity never unifies.
+    /// </summary>
+    private static Dictionary<string, Resource> BuildMacMap(IReadOnlyList<Resource> existing) {
+        var map = new Dictionary<string, Resource>(StringComparer.OrdinalIgnoreCase);
+        var ambiguous = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+
+        foreach (Resource resource in existing)
+            foreach (var mac in MacsOf(resource))
+                if (!ambiguous.Contains(mac) && !map.TryAdd(mac, resource) && !ReferenceEquals(map[mac], resource)) {
+                    map.Remove(mac);
+                    ambiguous.Add(mac);
+                }
+
+        return map;
+    }
+
     private static void RewriteRunsOn(IReadOnlyList<Resource> incoming, Dictionary<string, string> renames) {
         foreach (Resource resource in incoming)
             for (var i = 0; i < resource.RunsOn.Count; i++)

+ 13 - 1
RackPeek.Domain/Discovery/SystemFacts.cs

@@ -15,7 +15,13 @@ public static class DiscoveryUnits {
 public sealed record BlockDeviceFact(string Name, long SizeBytes, bool Rotational);
 
 /// <summary>A network interface, reduced to the parts that pick a primary address.</summary>
-public sealed record NicFact(string Name, bool IsUp, bool IsLoopback, bool HasGateway, string? Ipv4);
+public sealed record NicFact(
+    string Name,
+    bool IsUp,
+    bool IsLoopback,
+    bool HasGateway,
+    string? Ipv4,
+    string? Mac = null);
 
 /// <summary>
 ///     Everything a probe managed to read off the host, still in its raw form.
@@ -64,6 +70,12 @@ public sealed record SystemFacts {
 
     public string? Ip { get; init; }
     public IReadOnlyList<DriveFact> Drives { get; init; } = [];
+
+    /// <summary>
+    ///     The machine's physical-NIC MACs, normalised. What lets an agent-discovered
+    ///     card and a network-scanned card of the same box find each other.
+    /// </summary>
+    public IReadOnlyList<string> Macs { get; init; } = [];
 }
 
 public sealed record DriveFact(string Type, int SizeGb);

+ 17 - 0
RackPeek.Domain/Discovery/SystemFactsParser.cs

@@ -28,6 +28,7 @@ public static class SystemFactsParser {
             RamGb = ParseRamGb(raw),
             Type = type,
             Ip = SelectPrimaryIp(raw.Nics),
+            Macs = SelectMacs(raw.Nics),
 
             // A container sees the host's block devices through /sys/block. They belong
             // to the machine underneath it, so reporting them here would attribute
@@ -105,6 +106,22 @@ public static class SystemFactsParser {
                ?? usable.FirstOrDefault()?.Ipv4;
     }
 
+    /// <summary>
+    ///     The MACs a network scan could see this machine by: real interfaces only — a
+    ///     docker bridge's MAC never crosses the wire, so recording it could only cause
+    ///     a false unification. Normalised by the same code that reads ARP tables, so
+    ///     both sides of the bridge always agree on the spelling.
+    /// </summary>
+    internal static List<string> SelectMacs(IReadOnlyList<NicFact> nics) {
+        return nics
+            .Where(n => n is { IsUp: true, IsLoopback: false } && !IsVirtual(n.Name))
+            .Select(n => ArpTableParser.NormaliseMac(n.Mac))
+            .Where(mac => mac != null)
+            .Select(mac => mac!)
+            .Distinct()
+            .ToList();
+    }
+
     internal static bool IsVirtual(string name) {
         string[] prefixes = ["docker", "br-", "veth", "virbr", "tailscale", "utun", "tun", "tap", "cni", "flannel"];
 

+ 16 - 1
RackPeek.Domain/Discovery/SystemProbeCommon.cs

@@ -48,7 +48,22 @@ internal static class SystemProbeCommon {
             nic.OperationalStatus == OperationalStatus.Up,
             nic.NetworkInterfaceType == NetworkInterfaceType.Loopback,
             hasGateway,
-            ipv4);
+            ipv4,
+            FormatMac(nic));
+    }
+
+    /// <summary>Lowercase colon-separated, matching what ARP tables report — or null.</summary>
+    private static string? FormatMac(NetworkInterface nic) {
+        try {
+            var bytes = nic.GetPhysicalAddress().GetAddressBytes();
+
+            return bytes.Length == 6
+                ? string.Join(':', bytes.Select(b => b.ToString("x2")))
+                : null;
+        }
+        catch {
+            return null;
+        }
     }
 
     /// <summary>Reads a file, returning null for anything unreadable rather than throwing.</summary>

+ 8 - 1
RackPeek.Domain/Discovery/SystemResourceMapper.cs

@@ -15,7 +15,7 @@ public static class SystemResourceMapper {
             DiscoveryId.SystemScheme,
             facts.MachineId ?? facts.Hostname);
 
-        return new SystemResource {
+        var resource = new SystemResource {
             Kind = SystemResource.KindLabel,
             Name = DiscoveryNaming.Suggest(
                 nameOverride ?? DiscoveryNaming.HostLabel(facts.Hostname),
@@ -31,5 +31,12 @@ public static class SystemResourceMapper {
                 ? null
                 : facts.Drives.Select(d => new Drive { Type = d.Type, Size = d.SizeGb }).ToList()
         };
+
+        // The bridge to network discovery: a scan identifies this machine by one of
+        // these, so carrying them lets the resolver land both collectors on one card.
+        if (facts.Macs.Count > 0)
+            resource.Labels["macs"] = string.Join(",", facts.Macs);
+
+        return resource;
     }
 }

+ 21 - 5
Shared.Rcl/wwwroot/raw_docs/discovery-guide.md

@@ -371,11 +371,27 @@ machine's identity. Two caveats:
   no type, OS, cores or RAM, so a re-scan can never overwrite the details you (or an
   agent collector) filled in afterwards.
 
-The known limitation above applies here twice over: a host discovered by `rpk discover
-system` (machine-id identity) and by a network scan (MAC identity) becomes two
-resources, the second visibly suffixed. Keep whichever card you prefer and delete the
-other; the scan will keep updating the one that carries its id. The machine running
-the sweep also finds itself — same rule.
+### One machine, one card — across collectors
+
+`rpk discover system` records the machine's physical MAC addresses (a `macs` label),
+and a scan identifies machines by exactly those MACs — so **the two collectors land on
+the same card**, whichever ran first:
+
+- Scan first: the sweep creates the card; when the agent later runs on that box, it
+  claims the card, fills in the OS/cores/RAM, and upgrades its identity to the
+  machine-id. Every rescan afterwards keeps updating that same card via the MAC.
+- Agent first: a later sweep recognises the box and just refreshes its address —
+  never touching the identity or anything you or the agent wrote.
+
+The card keeps whatever name it already had (names are always user-owned), so a
+scan-first card keeps its generated `host-…` name until you rename it once. A MAC that
+two stored cards both claim unifies nothing — ambiguity always falls back to separate
+cards — and agent-grade identities never unify with each other on a MAC alone (cloned
+VMs can share one; that is what machine-ids are for). The machine running the sweep
+finds itself, and unifies with its own `rpk discover system` card the same way.
+
+Proxmox remains the exception: its API view carries no host MACs, so the known
+limitation above still applies between `discover proxmox` and the other collectors.
 
 ### Being a good citizen
 

+ 155 - 0
Tests.Discovery/MacUnificationTests.cs

@@ -0,0 +1,155 @@
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Servers;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     The MAC bridge between collectors: an agent card carries the machine's MACs
+///     (a "macs" label), a scan card carries the one it was found by (a "mac" label),
+///     and a shared MAC means the same box — so both collectors land on one card,
+///     whichever arrived first. These pin the resolver's side of that contract.
+/// </summary>
+public class MacUnificationTests {
+    private const string _mac = "dc:a6:32:0f:11:22";
+
+    private static SystemResource ScanCard(string name = "host-595109fb", string mac = _mac) => new() {
+        Kind = SystemResource.KindLabel,
+        Name = name,
+        DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, mac),
+        Ip = "192.168.1.20",
+        Labels = { ["mac"] = mac }
+    };
+
+    private static SystemResource AgentCard(
+        string name = "nas01",
+        string machineId = "machine-a",
+        string macs = _mac) => new() {
+            Kind = SystemResource.KindLabel,
+            Name = name,
+            DiscoveryId = DiscoveryId.Create(DiscoveryId.SystemScheme, machineId),
+            Type = "baremetal",
+            Os = "Debian",
+            Cores = 12,
+            Labels = { ["macs"] = macs }
+        };
+
+    [Fact]
+    public void An_agent_claims_the_scan_card_and_upgrades_its_identity() {
+        // Scan ran first; now `rpk discover system` reports the same box.
+        List<Resource> existing = [ScanCard()];
+        List<Resource> incoming = [AgentCard()];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        // The stored card's name wins (names are user-owned), and the agent's id
+        // survives so the merge upgrades the card to the stronger identity.
+        Assert.Equal("host-595109fb", incoming[0].Name);
+        Assert.StartsWith("rpk1:sys:", incoming[0].DiscoveryId);
+    }
+
+    [Fact]
+    public void A_scan_enriches_the_agents_card_without_touching_its_identity() {
+        // Agent ran first; now a sweep sees the same box from outside.
+        List<Resource> existing = [AgentCard()];
+        List<Resource> incoming = [ScanCard()];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal("nas01", incoming[0].Name);
+        // The weak scan id is dropped so the merge cannot downgrade the sys id.
+        Assert.Null(incoming[0].DiscoveryId);
+    }
+
+    [Fact]
+    public void The_macs_are_matched_however_each_side_spells_them() {
+        // The agent records padded lowercase; suppose a stored label was hand-edited
+        // to Windows-style dashes — normalisation makes them the same machine anyway.
+        SystemResource stored = ScanCard();
+        stored.Labels["mac"] = "DC-A6-32-0F-11-22";
+        List<Resource> existing = [stored];
+
+        List<Resource> incoming = [AgentCard()];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal("host-595109fb", incoming[0].Name);
+    }
+
+    [Fact]
+    public void An_id_match_always_beats_a_mac_match() {
+        // The scan card was renamed by the user; a rescan must follow its own id to
+        // the rename, not rediscover it via the MAC of some other card.
+        SystemResource renamed = ScanCard(name: "storage-primary");
+        List<Resource> existing = [renamed, AgentCard(macs: _mac)];
+        List<Resource> incoming = [ScanCard()];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal("storage-primary", incoming[0].Name);
+        Assert.NotNull(incoming[0].DiscoveryId);
+    }
+
+    [Fact]
+    public void A_mac_claimed_by_two_stored_cards_identifies_nothing() {
+        // Ambiguity never unifies: fall through to the ordinary name rules.
+        List<Resource> existing = [
+            AgentCard(name: "clone-a", machineId: "machine-a"),
+            AgentCard(name: "clone-b", machineId: "machine-b")
+        ];
+        List<Resource> incoming = [ScanCard(name: "host-xyz")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal("host-xyz", incoming[0].Name);
+        Assert.NotNull(incoming[0].DiscoveryId);
+    }
+
+    [Fact]
+    public void Two_agent_grade_identities_sharing_a_mac_never_unify() {
+        // Cloned VMs can share a NIC MAC while having distinct machine-ids; the
+        // machine-id is the authority between agent-grade collectors.
+        List<Resource> existing = [AgentCard(name: "vm-a", machineId: "machine-a")];
+        List<Resource> incoming = [AgentCard(name: "vm-b", machineId: "machine-b")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal("vm-b", incoming[0].Name);
+    }
+
+    [Fact]
+    public void A_mac_on_a_different_kind_of_card_is_not_a_bridge() {
+        // The user put a mac label on the Server card describing the box's hardware;
+        // the scan's System card is a different kind of thing and stays separate.
+        var server = new Server {
+            Kind = "Server",
+            Name = "rack-server",
+            Labels = { ["mac"] = _mac }
+        };
+
+        List<Resource> incoming = [ScanCard(name: "host-xyz")];
+
+        DiscoveryIdResolver.ResolveNames([server], incoming);
+
+        Assert.Equal("host-xyz", incoming[0].Name);
+    }
+
+    [Fact]
+    public void A_hand_written_card_with_a_mac_label_is_adopted_like_a_name_match() {
+        // No id on the stored card: whoever arrives first with an identity stamps it.
+        var handWritten = new SystemResource {
+            Kind = SystemResource.KindLabel,
+            Name = "nas01",
+            Type = "baremetal",
+            Labels = { ["mac"] = _mac }
+        };
+
+        List<Resource> incoming = [ScanCard(name: "host-xyz")];
+
+        DiscoveryIdResolver.ResolveNames([handWritten], incoming);
+
+        Assert.Equal("nas01", incoming[0].Name);
+        Assert.NotNull(incoming[0].DiscoveryId); // the scan id gets stamped on
+    }
+}

+ 73 - 3
Tests.Discovery/NetworkDiscoveryMergeTests.cs

@@ -66,9 +66,9 @@ public class NetworkDiscoveryMergeTests {
 
     [Fact]
     public async Task A_scan_never_steals_the_identity_of_an_agent_discovered_host() {
-        // nas01 already exists with a machine-id identity from `rpk discover system`.
-        // The scan sees the same box from outside and proposes the same name with a
-        // MAC identity — the resolver must keep them apart, not merge one over the other.
+        // nas01 exists with a machine-id identity but WITHOUT the macs label an agent
+        // records (an older agent, or a hand-stripped label) — so there is no MAC
+        // bridge, and the resolver must keep the cards apart rather than guess.
         var agentDiscovered = DiscoveryDocument.ToYaml([
             new SystemResource {
                 Kind = SystemResource.KindLabel,
@@ -93,6 +93,76 @@ public class NetworkDiscoveryMergeTests {
         Assert.Contains("os: Debian", stored); // nothing on the original was touched
     }
 
+    [Fact]
+    public async Task An_agent_claims_a_scanned_card_and_every_collector_lands_on_it_after() {
+        // The unification headline, scan-first: the sweep found the box, then
+        // `rpk discover system` runs on it. Same MAC, so it is the same card — the
+        // agent's identity and detail land on the scan's card instead of duplicating.
+        using var api = new DiscoveryApiFixture();
+
+        await api.PublishAsync(ScanYaml(Nas()));
+
+        SystemResource agent = SystemResourceMapper.ToResource(new SystemFacts {
+            Hostname = "nas01.lan",
+            MachineId = "machine-a",
+            Os = "Debian 12",
+            Cores = 12,
+            RamGb = 64,
+            Type = "baremetal",
+            Ip = "192.168.1.20",
+            Macs = ["dc:a6:32:0f:11:22"]
+        });
+
+        ImportYamlResponse claim = await api.PublishAsync(DiscoveryDocument.ToYaml([agent]));
+
+        // Nothing added: the agent updated the scan's card (which keeps its name).
+        Assert.Empty(claim.Added);
+        Assert.Equal(["nas01"], claim.Updated);
+
+        var stored = api.StoredYaml;
+        Assert.Contains("rpk1:sys:", stored); // identity upgraded to the agent's
+        Assert.DoesNotContain("rpk1:net:", stored);
+        Assert.Contains("os: Debian 12", stored);
+        Assert.Contains("mac: dc:a6:32:0f:11:22", stored);
+        Assert.Contains("macs: dc:a6:32:0f:11:22", stored);
+        Fixture.AssertConformsToSchema(stored);
+
+        // ...and a rescan afterwards still lands on that same card via the MAC.
+        ImportYamlResponse rescan = await api.PublishAsync(ScanYaml(Nas(ip: "192.168.1.99")));
+
+        Assert.Empty(rescan.Added);
+        Assert.Contains("rpk1:sys:", api.StoredYaml); // never downgraded
+        Assert.Contains("ip: 192.168.1.99", api.StoredYaml); // but freshly addressed
+    }
+
+    [Fact]
+    public async Task A_scan_enriches_an_agent_discovered_card_instead_of_duplicating_it() {
+        // The reverse order: the agent documented the box first, then a sweep sees it.
+        SystemResource agent = SystemResourceMapper.ToResource(new SystemFacts {
+            Hostname = "nas01",
+            MachineId = "machine-a",
+            Os = "Debian 12",
+            Cores = 12,
+            RamGb = 64,
+            Type = "baremetal",
+            Macs = ["dc:a6:32:0f:11:22"]
+        });
+
+        using var api = new DiscoveryApiFixture(DiscoveryDocument.ToYaml([agent]));
+
+        ImportYamlResponse scan = await api.PublishAsync(ScanYaml(Nas()));
+
+        Assert.Empty(scan.Added);
+        Assert.Equal(["nas01"], scan.Updated);
+
+        var stored = api.StoredYaml;
+        Assert.Contains("rpk1:sys:", stored); // the agent identity is untouched
+        Assert.DoesNotContain("rpk1:net:", stored);
+        Assert.Contains("ip: 192.168.1.20", stored); // the scan contributed the address
+        Assert.Contains("os: Debian 12", stored);
+        Fixture.AssertConformsToSchema(stored);
+    }
+
     [Fact]
     public async Task A_scan_adopts_a_hand_written_system_of_the_same_name() {
         // The inverse case: the user typed the card themselves, so it has no id yet.