diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyBrowseSession.cs
new file mode 100644
index 00000000..6349a953
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyBrowseSession.cs
@@ -0,0 +1,191 @@
+using System.Collections.Concurrent;
+using MxGateway.Client;
+using MxGateway.Contracts.Proto.Galaxy;
+using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
+
+///
+/// Galaxy browse over the gateway's .
+/// The gateway returns the deployed hierarchy as a flat
+/// list, so this session fetches the full set once on ,
+/// caches it by TagName (and GobjectId for parent lookup), and serves
+/// subsequent calls in-memory. Attribute fetches are
+/// per-object via DiscoverHierarchyAsync(MaxDepth=0, IncludeAttributes=true).
+/// Owns the supplied and disposes it best-effort.
+///
+internal sealed class GalaxyBrowseSession : IBrowseSession
+{
+ private readonly MxGatewaySession _session;
+ private readonly GalaxyRepositoryClient _client;
+ private readonly ConcurrentDictionary _byTagName = new(StringComparer.Ordinal);
+ private readonly ConcurrentDictionary _byGobjectId = new();
+ private bool _disposed;
+
+ /// Opaque token identifying this session in the AdminUI registry.
+ public Guid Token { get; } = Guid.NewGuid();
+
+ /// Wall-clock time of the most recent successful Root/Expand/Attributes call.
+ public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;
+
+ ///
+ /// Initializes a new session wrapping a connected gateway client. The factory
+ /// in GalaxyDriverBrowser (Task 9) constructs both the session and the
+ /// repository client and hands them off here for the session's lifetime.
+ ///
+ /// Gateway session to dispose when the browse closes.
+ /// Galaxy repository client to query for hierarchy and attributes.
+ internal GalaxyBrowseSession(MxGatewaySession session, GalaxyRepositoryClient client)
+ {
+ _session = session ?? throw new ArgumentNullException(nameof(session));
+ _client = client ?? throw new ArgumentNullException(nameof(client));
+ }
+
+ ///
+ /// Fetches the full Galaxy hierarchy from the gateway, populates the cache,
+ /// and returns the top-level objects (those with no parent in the deployed model).
+ ///
+ public async Task> RootAsync(CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ var all = await _client.DiscoverHierarchyAsync(
+ new DiscoverHierarchyOptions { IncludeAttributes = false }, cancellationToken)
+ .ConfigureAwait(false);
+
+ // Populate caches so later ExpandAsync calls can resolve children in-memory.
+ foreach (var obj in all)
+ {
+ _byTagName[obj.TagName] = obj;
+ _byGobjectId[obj.GobjectId] = obj;
+ }
+
+ // Roots are objects whose parent isn't part of the returned set (typically
+ // ParentGobjectId == 0 for WinPlatforms / top-level Areas).
+ var roots = all
+ .Where(o => o.ParentGobjectId == 0 || !_byGobjectId.ContainsKey(o.ParentGobjectId))
+ .ToList();
+
+ LastUsedUtc = DateTime.UtcNow;
+ return Project(roots);
+ }
+
+ ///
+ /// Returns the direct children of the cached Galaxy object identified by
+ /// (the object's TagName). Throws
+ /// if the tag hasn't been handed out by a
+ /// prior Root/Expand call (i.e. it's not in the cache).
+ ///
+ public Task> ExpandAsync(string nodeId, CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (!_byTagName.TryGetValue(nodeId, out var parent))
+ {
+ throw new ArgumentException(
+ $"Galaxy object '{nodeId}' is not in the current browse-session cache. " +
+ "Re-open the browser or expand its parent first.", nameof(nodeId));
+ }
+
+ var children = _byGobjectId.Values
+ .Where(o => o.ParentGobjectId == parent.GobjectId)
+ .ToList();
+
+ LastUsedUtc = DateTime.UtcNow;
+ return Task.FromResult(Project(children));
+ }
+
+ ///
+ /// Fetches the attributes of the Galaxy object identified by
+ /// via DiscoverHierarchyAsync(MaxDepth=0, RootTagName=nodeId, IncludeAttributes=true).
+ /// Returns an empty list if the gateway has no matching object.
+ ///
+ public async Task> AttributesAsync(string nodeId, CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ var rows = await _client.DiscoverHierarchyAsync(
+ new DiscoverHierarchyOptions
+ {
+ RootTagName = nodeId,
+ MaxDepth = 0,
+ IncludeAttributes = true,
+ }, cancellationToken).ConfigureAwait(false);
+
+ LastUsedUtc = DateTime.UtcNow;
+ var obj = rows.FirstOrDefault();
+ if (obj is null) return Array.Empty();
+
+ var result = new List(obj.Attributes.Count);
+ foreach (var attr in obj.Attributes)
+ {
+ var driverType = !string.IsNullOrEmpty(attr.DataTypeName)
+ ? attr.DataTypeName
+ : attr.MxDataType.ToString(System.Globalization.CultureInfo.InvariantCulture);
+ result.Add(new AttributeInfo(
+ Name: attr.AttributeName,
+ DriverDataType: driverType,
+ IsArray: attr.IsArray,
+ SecurityClass: MapSecurityClass(attr.SecurityClassification)));
+ }
+ return result;
+ }
+
+ ///
+ /// Projects s to s, ensuring
+ /// every projected object is also written to the by-tag cache so later
+ /// calls can find it. Galaxy nodes are always
+ /// — leaves only appear in the attribute
+ /// side-panel, never in the tree.
+ ///
+ private IReadOnlyList Project(IReadOnlyList nodes)
+ {
+ var result = new List(nodes.Count);
+ foreach (var obj in nodes)
+ {
+ // Belt-and-braces: ensure the cache holds every node we hand back so
+ // ExpandAsync can resolve it on the next round-trip.
+ _byTagName[obj.TagName] = obj;
+ _byGobjectId[obj.GobjectId] = obj;
+
+ var displayName = !string.IsNullOrEmpty(obj.ContainedName) ? obj.ContainedName : obj.TagName;
+ var hasChildrenHint = _byGobjectId.Values.Any(o => o.ParentGobjectId == obj.GobjectId);
+ result.Add(new BrowseNode(
+ NodeId: obj.TagName,
+ DisplayName: displayName,
+ Kind: BrowseNodeKind.Folder,
+ HasChildrenHint: hasChildrenHint));
+ }
+ return result;
+ }
+
+ ///
+ /// Maps the Galaxy raw security-classification integer to a display string.
+ /// Buckets: 0=FreeAccess, 1=Operate, 2=Tune, 3=Configure, 4=ViewOnly;
+ /// anything else surfaces as Unknown(N).
+ ///
+ private static string MapSecurityClass(int raw) => raw switch
+ {
+ 0 => "FreeAccess",
+ 1 => "Operate",
+ 2 => "Tune",
+ 3 => "Configure",
+ 4 => "ViewOnly",
+ _ => $"Unknown({raw})",
+ };
+
+ ///
+ /// Idempotently tears down the underlying gateway session. Swallows exceptions
+ /// on shutdown — the registry's reaper may be racing a client-initiated close.
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ try
+ {
+ await _session.DisposeAsync().ConfigureAwait(false);
+ }
+ catch
+ {
+ // Best-effort: a gateway-side close that hits a torn-down channel is normal.
+ }
+ }
+}