diff --git a/src/TeamsISO.Engine/Discovery/ParticipantTracker.cs b/src/TeamsISO.Engine/Discovery/ParticipantTracker.cs
new file mode 100644
index 0000000..3b94340
--- /dev/null
+++ b/src/TeamsISO.Engine/Discovery/ParticipantTracker.cs
@@ -0,0 +1,104 @@
+using TeamsISO.Engine.Domain;
+
+namespace TeamsISO.Engine.Discovery;
+
+///
+/// Maintains the operator-facing participant list, applying the rename heuristic
+/// from the v1 spec: if a participant source disappears and another participant
+/// source with the same MachineName appears within ,
+/// the existing transfers to the new source.
+///
+public sealed class ParticipantTracker
+{
+ private readonly TimeSpan _renameWindow;
+ private readonly Func _now;
+ private readonly List _participants = new();
+ private readonly List _recentlyRemoved = new();
+
+ public ParticipantTracker(TimeSpan renameWindow, Func now)
+ {
+ _renameWindow = renameWindow;
+ _now = now;
+ }
+
+ public IReadOnlyList Participants =>
+ _participants.Select(m => m.ToRecord()).ToList();
+
+ public void Apply(DiscoveryEvent ev)
+ {
+ switch (ev)
+ {
+ case DiscoveryEvent.Added a when a.Source.Kind == NdiSourceKind.Participant:
+ HandleAdded(a.Source);
+ break;
+ case DiscoveryEvent.Removed r when r.Source.Kind == NdiSourceKind.Participant:
+ HandleRemoved(r.Source);
+ break;
+ }
+ }
+
+ private void HandleAdded(NdiSource source)
+ {
+ var now = _now();
+ PruneRecentlyRemoved(now);
+
+ var match = _recentlyRemoved.FirstOrDefault(rr => rr.MachineName == source.MachineName);
+ if (match is not null)
+ {
+ var existing = _participants.First(p => p.Id == match.Id);
+ existing.DisplayName = source.DisplayName!;
+ existing.CurrentSource = source;
+ existing.LastSeen = now;
+ _recentlyRemoved.Remove(match);
+ return;
+ }
+
+ _participants.Add(new MutableParticipant(
+ Id: Guid.NewGuid(),
+ DisplayName: source.DisplayName!,
+ CurrentSource: source,
+ FirstSeen: now,
+ LastSeen: now));
+ }
+
+ private void HandleRemoved(NdiSource source)
+ {
+ var now = _now();
+ var existing = _participants.FirstOrDefault(p =>
+ p.CurrentSource is not null && p.CurrentSource.FullName == source.FullName);
+ if (existing is null)
+ return;
+
+ existing.CurrentSource = null;
+ _recentlyRemoved.Add(new RecentlyRemoved(existing.Id, source.MachineName, now));
+ }
+
+ private void PruneRecentlyRemoved(DateTimeOffset now)
+ {
+ _recentlyRemoved.RemoveAll(rr => now - rr.RemovedAt > _renameWindow);
+ }
+
+ private sealed class MutableParticipant
+ {
+ public Guid Id { get; init; }
+ public string DisplayName { get; set; }
+ public NdiSource? CurrentSource { get; set; }
+ public DateTimeOffset FirstSeen { get; init; }
+ public DateTimeOffset LastSeen { get; set; }
+
+ public MutableParticipant(Guid Id, string DisplayName, NdiSource? CurrentSource,
+ DateTimeOffset FirstSeen, DateTimeOffset LastSeen)
+ {
+ this.Id = Id;
+ this.DisplayName = DisplayName;
+ this.CurrentSource = CurrentSource;
+ this.FirstSeen = FirstSeen;
+ this.LastSeen = LastSeen;
+ }
+
+ public Participant ToRecord() =>
+ new(Id, DisplayName, CurrentSource, FirstSeen, LastSeen);
+ }
+
+ private sealed record RecentlyRemoved(Guid Id, string MachineName, DateTimeOffset RemovedAt);
+}
diff --git a/src/tests/TeamsISO.Engine.Tests/Discovery/ParticipantTrackerTests.cs b/src/tests/TeamsISO.Engine.Tests/Discovery/ParticipantTrackerTests.cs
new file mode 100644
index 0000000..0892fab
--- /dev/null
+++ b/src/tests/TeamsISO.Engine.Tests/Discovery/ParticipantTrackerTests.cs
@@ -0,0 +1,103 @@
+using TeamsISO.Engine.Discovery;
+using TeamsISO.Engine.Domain;
+
+namespace TeamsISO.Engine.Tests.Discovery;
+
+public class ParticipantTrackerTests
+{
+ private static NdiSource Source(string machine, string display) =>
+ new($"{machine} (Teams - {display})", machine, NdiSourceKind.Participant, display);
+
+ private static DateTimeOffset T0 => DateTimeOffset.UnixEpoch;
+
+ [Fact]
+ public void Added_CreatesParticipant()
+ {
+ var tracker = new ParticipantTracker(renameWindow: TimeSpan.FromSeconds(5), now: () => T0);
+ tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Jane")));
+
+ tracker.Participants.Should().HaveCount(1);
+ var p = tracker.Participants[0];
+ p.DisplayName.Should().Be("Jane");
+ p.CurrentSource!.MachineName.Should().Be("PC1");
+ }
+
+ [Fact]
+ public void Removed_NullsCurrentSourceButKeepsParticipant()
+ {
+ var time = T0;
+ var tracker = new ParticipantTracker(TimeSpan.FromSeconds(5), () => time);
+ tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Jane")));
+
+ time = T0.AddSeconds(1);
+ tracker.Apply(new DiscoveryEvent.Removed(Source("PC1", "Jane")));
+
+ tracker.Participants.Should().HaveCount(1);
+ tracker.Participants[0].CurrentSource.Should().BeNull();
+ }
+
+ [Fact]
+ public void RenameWithinWindow_TransfersParticipantId()
+ {
+ var time = T0;
+ var tracker = new ParticipantTracker(TimeSpan.FromSeconds(5), () => time);
+ tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Jane")));
+ var originalId = tracker.Participants[0].Id;
+
+ time = T0.AddSeconds(1);
+ tracker.Apply(new DiscoveryEvent.Removed(Source("PC1", "Jane")));
+
+ time = T0.AddSeconds(3);
+ tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Jane (PM)")));
+
+ tracker.Participants.Should().HaveCount(1);
+ tracker.Participants[0].Id.Should().Be(originalId);
+ tracker.Participants[0].DisplayName.Should().Be("Jane (PM)");
+ }
+
+ [Fact]
+ public void RenameAfterWindow_TreatsAsNewParticipant()
+ {
+ var time = T0;
+ var tracker = new ParticipantTracker(TimeSpan.FromSeconds(5), () => time);
+ tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Jane")));
+ var originalId = tracker.Participants[0].Id;
+
+ time = T0.AddSeconds(1);
+ tracker.Apply(new DiscoveryEvent.Removed(Source("PC1", "Jane")));
+
+ time = T0.AddSeconds(20);
+ tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Bob")));
+
+ tracker.Participants.Should().HaveCount(2);
+ tracker.Participants.Should().Contain(p => p.Id == originalId);
+ tracker.Participants.Should().Contain(p => p.DisplayName == "Bob" && p.Id != originalId);
+ }
+
+ [Fact]
+ public void DifferentMachine_DoesNotInheritIdentity()
+ {
+ var time = T0;
+ var tracker = new ParticipantTracker(TimeSpan.FromSeconds(5), () => time);
+ tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Jane")));
+
+ time = T0.AddSeconds(1);
+ tracker.Apply(new DiscoveryEvent.Removed(Source("PC1", "Jane")));
+
+ time = T0.AddSeconds(2);
+ tracker.Apply(new DiscoveryEvent.Added(Source("PC2", "Jane (PM)")));
+
+ tracker.Participants.Should().HaveCount(2);
+ }
+
+ [Fact]
+ public void NonParticipantSources_AreIgnored()
+ {
+ var tracker = new ParticipantTracker(TimeSpan.FromSeconds(5), () => T0);
+ var screen = new NdiSource("PC1 (Teams Screen Share)", "PC1", NdiSourceKind.ScreenShare, null);
+
+ tracker.Apply(new DiscoveryEvent.Added(screen));
+
+ tracker.Participants.Should().BeEmpty();
+ }
+}