# Client LazyBrowseNode Walker Implementation Plan

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

**Goal:** Add an idempotent-expand `LazyBrowseNode` walker plus a low-level `BrowseChildren*Async` wrapper to each of the five language clients (.NET, Python, Rust, Go, Java), with unit tests against the existing fake-transport fixtures in each. Install Temurin 21 + Gradle on the macOS dev host so Java work is local.

**Architecture:** Each client's existing `GalaxyRepositoryClient` (or `GalaxyClient`) gains two new public methods: a raw `BrowseChildrenRawAsync(request)` wrapper that handles auth/retry/transport, and a high-level `BrowseAsync(options?)` factory returning `LazyBrowseNode[]`. Each `LazyBrowseNode` exposes the underlying `GalaxyObject`, a `HasChildrenHint` boolean, a `Children` collection (empty until expanded), and an idempotent `ExpandAsync()` method that calls `BrowseChildren(parent_id=this.id)` once, walks all sibling pages, and populates `Children` with fresh unexpanded `LazyBrowseNode`s. Subsequent `ExpandAsync` calls are no-ops.

**Tech Stack:** .NET 10 (C#), Python 3.12 (asyncio + grpcio), Rust 1.95 (tonic), Go 1.26, Java 21 (Gradle, grpc-java).

**Design source:** `docs/plans/2026-05-28-client-walker-design.md`.

---

## Task 0: Worktree state check + branch (or continue on existing)

**Classification:** trivial
**Estimated implement time:** ~1 min
**Parallelizable with:** none

**Files:** (none)

**Step 1: Confirm branch**

Run: `git branch --show-current && git status --short`
Expected: on `feat/lazy-browse-children` (where the prior work landed), clean except for the usual untracked `*-docs-*.md` review artifacts.

The walker work continues on the same branch — it's a follow-up to the just-shipped `BrowseChildren` RPC. No new branch needed.

Step 2: Confirm the design doc is committed (sanity check that we have the design to follow).

Run: `git log --oneline -1 docs/plans/2026-05-28-client-walker-design.md`
Expected: shows the design commit (`eaf4793` or similar).

No commit in this task.

---

## Task 1: Java toolchain bootstrap

**Classification:** small
**Estimated implement time:** ~3 min (mostly Homebrew download)
**Parallelizable with:** Tasks 2, 3, 4, 5 (Java work is Task 6 and depends on this; the other languages don't need it)

**Files:** (none — environmental change only)

This task is run-or-defer: if Homebrew install completes, the Java task (Task 6) proceeds; if it fails, Task 6 is skipped and the Java README still gets the docs-only update in Task 7.

**Step 1: Install Temurin 21**

```bash
brew install temurin@21 2>&1 | tail -10
```

Expected: clean install or "already installed". If it errors due to Homebrew taps or network, report and STOP — Tasks 2/3/4/5 proceed independently.

**Step 2: Install Gradle**

```bash
brew install gradle 2>&1 | tail -5
```

Expected: clean install.

**Step 3: Verify**

```bash
java -version 2>&1 | head -2
gradle --version 2>&1 | head -5
```

Expected: `openjdk version "21..."` and `Gradle 8.x` (or 9.x — accept either).

**Step 4: Set `JAVA_HOME` if needed**

```bash
echo "$JAVA_HOME"
```

If empty, find the JDK path:
```bash
/usr/libexec/java_home -v 21
```

The plan does NOT modify shell init files (out of scope — too permanent for a project task). If `JAVA_HOME` is empty, Task 6's implementer will `export JAVA_HOME=$(/usr/libexec/java_home -v 21)` inline before each `gradle` invocation.

**Step 5: Verify the Java client builds against its existing committed generated tree**

```bash
cd /Users/dohertj2/Desktop/MxAccessGateway/clients/java && \
  ( [ -n "$JAVA_HOME" ] || export JAVA_HOME=$(/usr/libexec/java_home -v 21) ) && \
  gradle build -x test 2>&1 | tail -15
```

Expected: `BUILD SUCCESSFUL`. If the build fails with a proto-plugin or dependency-resolution error, this is the time to find out — DON'T spend more than 15 minutes debugging. Report the error in your task summary; Task 6 will be skipped and the rest of the plan proceeds.

No commit in this task (environmental only).

---

## Task 2: .NET — LazyBrowseNode walker + tests

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Tasks 3, 4, 5, 6 (different language trees)

**Files:**
- Modify: `clients/dotnet/ZB.MOM.WW.MxGateway.Client/IGalaxyRepositoryClientTransport.cs` (add `BrowseChildrenAsync` method to the interface)
- Modify: `clients/dotnet/ZB.MOM.WW.MxGateway.Client/GrpcGalaxyRepositoryClientTransport.cs` (implement the new interface method by delegating to the generated client)
- Create: `clients/dotnet/ZB.MOM.WW.MxGateway.Client/BrowseChildrenOptions.cs`
- Create: `clients/dotnet/ZB.MOM.WW.MxGateway.Client/LazyBrowseNode.cs`
- Modify: `clients/dotnet/ZB.MOM.WW.MxGateway.Client/GalaxyRepositoryClient.cs` (add `BrowseChildrenRawAsync` + `BrowseAsync`)
- Modify: `clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/FakeGalaxyRepositoryTransport.cs` (record BrowseChildren calls + canned replies)
- Create: `clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/LazyBrowseNodeTests.cs`

### Step 1: Write the failing tests

Create `LazyBrowseNodeTests.cs` with the six facts from the design's "Tests" section. Use the existing `MxGatewayClientOptions` and `FakeGalaxyRepositoryTransport` patterns (read `GalaxyRepositoryClientTests.cs` for the construction idiom — likely `new GalaxyRepositoryClient(transport)` with the fake).

```csharp
using Grpc.Core;
using Xunit;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;

namespace ZB.MOM.WW.MxGateway.Client.Tests;

public sealed class LazyBrowseNodeTests
{
    [Fact]
    public async Task Browse_NoParent_ReturnsRoots()
    {
        FakeGalaxyRepositoryTransport fake = NewFake();
        fake.BrowseChildrenReplies.Enqueue(BuildReply(
            children: [BuildObject(1, "Plant", isArea: true), BuildObject(99, "Other")],
            childHasChildren: [true, false],
            cacheSequence: 7));

        using GalaxyRepositoryClient client = new(fake);
        IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();

        Assert.Equal(2, roots.Count);
        Assert.Equal("Plant", roots[0].Object.TagName);
        Assert.True(roots[0].HasChildrenHint);
        Assert.False(roots[0].IsExpanded);
        Assert.False(roots[1].HasChildrenHint);
    }

    [Fact]
    public async Task Expand_PopulatesChildrenAndMarksExpanded()
    {
        FakeGalaxyRepositoryTransport fake = NewFake();
        fake.BrowseChildrenReplies.Enqueue(BuildReply(
            children: [BuildObject(1, "Plant", isArea: true)],
            childHasChildren: [true],
            cacheSequence: 7));
        fake.BrowseChildrenReplies.Enqueue(BuildReply(
            children: [BuildObject(2, "Mixer_001")],
            childHasChildren: [false],
            cacheSequence: 7));

        using GalaxyRepositoryClient client = new(fake);
        IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
        await roots[0].ExpandAsync();

        Assert.True(roots[0].IsExpanded);
        Assert.Single(roots[0].Children);
        Assert.Equal("Mixer_001", roots[0].Children[0].Object.TagName);
        Assert.Equal(2, fake.BrowseChildrenCalls.Count); // roots + expand
    }

    [Fact]
    public async Task Expand_CalledTwice_NoSecondRpc()
    {
        FakeGalaxyRepositoryTransport fake = NewFake();
        fake.BrowseChildrenReplies.Enqueue(BuildReply(
            children: [BuildObject(1, "Plant", isArea: true)],
            childHasChildren: [true],
            cacheSequence: 7));
        fake.BrowseChildrenReplies.Enqueue(BuildReply(
            children: [BuildObject(2, "Mixer_001")],
            childHasChildren: [false],
            cacheSequence: 7));

        using GalaxyRepositoryClient client = new(fake);
        IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
        await roots[0].ExpandAsync();
        await roots[0].ExpandAsync(); // no-op

        Assert.Equal(2, fake.BrowseChildrenCalls.Count); // still just roots + one expand
    }

    [Fact]
    public async Task Expand_UnknownParent_ThrowsMxGatewayException()
    {
        FakeGalaxyRepositoryTransport fake = NewFake();
        fake.BrowseChildrenReplies.Enqueue(BuildReply(
            children: [BuildObject(1, "Plant", isArea: true)],
            childHasChildren: [true],
            cacheSequence: 7));
        fake.BrowseChildrenExceptions.Enqueue(
            new RpcException(new Status(StatusCode.NotFound, "parent not found")));

        using GalaxyRepositoryClient client = new(fake);
        IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();

        // The existing client maps RpcException -> MxGatewayException via MxGatewayErrors helper;
        // use whatever wrapper type GalaxyRepositoryClient.DiscoverHierarchyAsync currently throws
        // for NotFound — match that exact type.
        await Assert.ThrowsAnyAsync<MxGatewayException>(() => roots[0].ExpandAsync().AsTask());
    }

    [Fact]
    public async Task Expand_MultiPageSiblings_GathersAllPages()
    {
        FakeGalaxyRepositoryTransport fake = NewFake();
        fake.BrowseChildrenReplies.Enqueue(BuildReply(
            children: [BuildObject(1, "Plant", isArea: true)],
            childHasChildren: [true],
            cacheSequence: 7));
        // Two pages of children under Plant.
        BrowseChildrenReply page1 = BuildReply(
            children: [BuildObject(2, "Mixer_001"), BuildObject(3, "Mixer_002")],
            childHasChildren: [false, false],
            cacheSequence: 7);
        page1.NextPageToken = "7:abc:2";
        fake.BrowseChildrenReplies.Enqueue(page1);
        fake.BrowseChildrenReplies.Enqueue(BuildReply(
            children: [BuildObject(4, "Pump_001")],
            childHasChildren: [false],
            cacheSequence: 7));

        using GalaxyRepositoryClient client = new(fake);
        IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
        await roots[0].ExpandAsync();

        Assert.Equal(3, roots[0].Children.Count);
        Assert.Equal(new[] { "Mixer_001", "Mixer_002", "Pump_001" },
            roots[0].Children.Select(c => c.Object.TagName));
        // 1 (roots) + 2 (page1 + page2 under Plant) = 3
        Assert.Equal(3, fake.BrowseChildrenCalls.Count);
        // Page 2 request carries the page token from page 1.
        Assert.Equal("7:abc:2", fake.BrowseChildrenCalls[^1].Request.PageToken);
    }

    [Fact]
    public async Task Browse_WithFilter_ForwardsToRequest()
    {
        FakeGalaxyRepositoryTransport fake = NewFake();
        fake.BrowseChildrenReplies.Enqueue(BuildReply(
            children: [],
            childHasChildren: [],
            cacheSequence: 7));

        using GalaxyRepositoryClient client = new(fake);
        await client.BrowseAsync(new BrowseChildrenOptions { TagNameGlob = "Mixer*", AlarmBearingOnly = true });

        BrowseChildrenRequest req = fake.BrowseChildrenCalls[0].Request;
        Assert.Equal("Mixer*", req.TagNameGlob);
        Assert.True(req.AlarmBearingOnly);
    }

    private static FakeGalaxyRepositoryTransport NewFake()
        => new(new MxGatewayClientOptions { Endpoint = "fake", ApiKey = "mxgw_test_secret", UsePlaintext = true });

    private static GalaxyObject BuildObject(int id, string tag, bool isArea = false)
        => new() { GobjectId = id, TagName = tag, BrowseName = tag, IsArea = isArea };

    private static BrowseChildrenReply BuildReply(
        IReadOnlyList<GalaxyObject> children,
        IReadOnlyList<bool> childHasChildren,
        ulong cacheSequence)
    {
        BrowseChildrenReply reply = new()
        {
            TotalChildCount = children.Count,
            CacheSequence = cacheSequence,
        };
        reply.Children.AddRange(children);
        reply.ChildHasChildren.AddRange(childHasChildren);
        return reply;
    }
}
```

If `MxGatewayClientOptions` / `UsePlaintext` etc. don't compile, read an existing test file (`GalaxyRepositoryClientTests.cs`) and copy its construction exactly.

### Step 2: Run tests to verify they fail (compile error)

```bash
dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/ZB.MOM.WW.MxGateway.Client.Tests.csproj --filter "FullyQualifiedName~LazyBrowseNodeTests" 2>&1 | tail -20
```

Expected: compile failure — `BrowseAsync`, `LazyBrowseNode`, `BrowseChildrenOptions`, `BrowseChildrenReplies`, `BrowseChildrenCalls`, `BrowseChildrenExceptions` don't exist yet.

### Step 3: Extend the transport interface

In `IGalaxyRepositoryClientTransport.cs`, add (mirroring the existing `DiscoverHierarchyAsync`):

```csharp
/// <summary>Returns direct children of a parent in the Galaxy hierarchy.</summary>
Task<BrowseChildrenReply> BrowseChildrenAsync(
    BrowseChildrenRequest request,
    CallOptions callOptions);
```

### Step 4: Implement the transport method

In `GrpcGalaxyRepositoryClientTransport.cs`, add the implementation by delegating to the generated client. Find the existing `DiscoverHierarchyAsync` implementation in the same file and mirror its body exactly, substituting `BrowseChildrenAsync` for `DiscoverHierarchyAsync`.

### Step 5: Add `BrowseChildrenOptions.cs`

```csharp
namespace ZB.MOM.WW.MxGateway.Client;

/// <summary>
///     Filters and shape options for <see cref="GalaxyRepositoryClient.BrowseAsync"/>.
///     Mirror of <see cref="DiscoverHierarchyOptions"/> for the lazy-browse path.
/// </summary>
public sealed class BrowseChildrenOptions
{
    /// <summary>Restrict to children whose Galaxy category is in this set.</summary>
    public IReadOnlyList<int> CategoryIds { get; init; } = [];

    /// <summary>Restrict to children whose template chain contains any of these tokens.</summary>
    public IReadOnlyList<string> TemplateChainContains { get; init; } = [];

    /// <summary>Optional glob-style filter on <c>tag_name</c>.</summary>
    public string? TagNameGlob { get; init; }

    /// <summary>Whether to populate each <c>GalaxyObject.Attributes</c>. Null leaves the server default.</summary>
    public bool? IncludeAttributes { get; init; }

    /// <summary>Restrict to children that bear at least one alarm attribute.</summary>
    public bool AlarmBearingOnly { get; init; }

    /// <summary>Restrict to children that have at least one historized attribute.</summary>
    public bool HistorizedOnly { get; init; }
}
```

### Step 6: Add `LazyBrowseNode.cs`

```csharp
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;

namespace ZB.MOM.WW.MxGateway.Client;

/// <summary>
///     One node in a lazy-loaded Galaxy browse tree. Holds the underlying
///     <see cref="GalaxyObject"/> and exposes <see cref="ExpandAsync"/> to fetch
///     its direct children on demand. Expansion is one-shot: a second call is a
///     no-op. Pagination of large sibling sets is handled internally.
/// </summary>
public sealed class LazyBrowseNode
{
    private readonly GalaxyRepositoryClient _client;
    private readonly BrowseChildrenOptions _options;
    private readonly List<LazyBrowseNode> _children = [];
    private bool _isExpanded;

    internal LazyBrowseNode(
        GalaxyRepositoryClient client,
        GalaxyObject @object,
        bool hasChildrenHint,
        BrowseChildrenOptions options)
    {
        _client = client;
        Object = @object;
        HasChildrenHint = hasChildrenHint;
        _options = options;
    }

    /// <summary>The underlying Galaxy object for this node.</summary>
    public GalaxyObject Object { get; }

    /// <summary>True when the server reports this node has at least one matching descendant.</summary>
    public bool HasChildrenHint { get; }

    /// <summary>Direct children loaded by <see cref="ExpandAsync"/>; empty until then.</summary>
    public IReadOnlyList<LazyBrowseNode> Children => _children;

    /// <summary>True after the first <see cref="ExpandAsync"/> call completes.</summary>
    public bool IsExpanded => _isExpanded;

    /// <summary>
    ///     Fetches direct children from the gateway and populates
    ///     <see cref="Children"/>. Idempotent: subsequent calls are no-ops.
    /// </summary>
    public async Task ExpandAsync(CancellationToken cancellationToken = default)
    {
        if (_isExpanded)
        {
            return;
        }

        string pageToken = string.Empty;
        HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
        do
        {
            BrowseChildrenRequest request = GalaxyRepositoryClient.BuildBrowseChildrenRequest(_options);
            request.ParentGobjectId = Object.GobjectId;
            request.PageToken = pageToken;

            BrowseChildrenReply reply = await _client
                .BrowseChildrenRawAsync(request, cancellationToken)
                .ConfigureAwait(false);

            for (int i = 0; i < reply.Children.Count; i++)
            {
                bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
                _children.Add(new LazyBrowseNode(_client, reply.Children[i], hint, _options));
            }

            pageToken = reply.NextPageToken;
            if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
            {
                throw new MxGatewayException(
                    $"Galaxy BrowseChildren returned a repeated page token '{pageToken}'.");
            }
        }
        while (!string.IsNullOrWhiteSpace(pageToken));

        _isExpanded = true;
    }
}
```

### Step 7: Extend `GalaxyRepositoryClient`

In `GalaxyRepositoryClient.cs`, add a new constant (next to `DiscoverHierarchyPageSize`) and these new methods (place them after `DiscoverHierarchyRawAsync`):

```csharp
private const int BrowseChildrenPageSize = 500;

/// <summary>Returns root-level browse nodes (objects with no parent).</summary>
public Task<IReadOnlyList<LazyBrowseNode>> BrowseAsync(CancellationToken cancellationToken = default)
    => BrowseAsync(null, cancellationToken);

/// <summary>Returns root-level browse nodes filtered by the given options.</summary>
public async Task<IReadOnlyList<LazyBrowseNode>> BrowseAsync(
    BrowseChildrenOptions? options,
    CancellationToken cancellationToken = default)
{
    BrowseChildrenOptions effective = options ?? new BrowseChildrenOptions();
    List<LazyBrowseNode> roots = [];
    string pageToken = string.Empty;
    HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
    do
    {
        BrowseChildrenRequest request = BuildBrowseChildrenRequest(effective);
        request.PageToken = pageToken;
        BrowseChildrenReply reply = await BrowseChildrenRawAsync(request, cancellationToken).ConfigureAwait(false);

        for (int i = 0; i < reply.Children.Count; i++)
        {
            bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
            roots.Add(new LazyBrowseNode(this, reply.Children[i], hint, effective));
        }

        pageToken = reply.NextPageToken;
        if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
        {
            throw new MxGatewayException(
                $"Galaxy BrowseChildren returned a repeated page token '{pageToken}'.");
        }
    }
    while (!string.IsNullOrWhiteSpace(pageToken));

    return roots;
}

/// <summary>Issues a raw BrowseChildren RPC without result wrapping.</summary>
public Task<BrowseChildrenReply> BrowseChildrenRawAsync(
    BrowseChildrenRequest request,
    CancellationToken cancellationToken = default)
{
    ArgumentNullException.ThrowIfNull(request);
    ThrowIfDisposed();

    return _safeUnaryRetryPipeline.ExecuteAsync(
        token => _transport.BrowseChildrenAsync(request, CreateCallOptions(token)),
        cancellationToken).AsTask();
}

internal static BrowseChildrenRequest BuildBrowseChildrenRequest(BrowseChildrenOptions options)
{
    BrowseChildrenRequest request = new()
    {
        PageSize = BrowseChildrenPageSize,
        AlarmBearingOnly = options.AlarmBearingOnly,
        HistorizedOnly = options.HistorizedOnly,
    };
    request.CategoryIds.Add(options.CategoryIds);
    request.TemplateChainContains.Add(options.TemplateChainContains);
    if (!string.IsNullOrWhiteSpace(options.TagNameGlob))
    {
        request.TagNameGlob = options.TagNameGlob;
    }
    if (options.IncludeAttributes.HasValue)
    {
        request.IncludeAttributes = options.IncludeAttributes.Value;
    }
    return request;
}
```

The exact retry-pipeline call shape (`AsTask()` etc.) must match how `DiscoverHierarchyRawAsync` looks in the same file — copy that idiom.

### Step 8: Extend `FakeGalaxyRepositoryTransport`

Add (mirroring the existing `DiscoverHierarchyCalls` / `DiscoverHierarchyReply` / `DiscoverHierarchyExceptions` triple):

```csharp
/// <summary>Records BrowseChildren RPC calls.</summary>
public List<(BrowseChildrenRequest Request, CallOptions CallOptions)> BrowseChildrenCalls { get; } = [];

/// <summary>Default reply for BrowseChildren when the queue is empty.</summary>
public BrowseChildrenReply BrowseChildrenReply { get; set; } = new();

/// <summary>Queue of replies, dequeued in FIFO order.</summary>
public Queue<BrowseChildrenReply> BrowseChildrenReplies { get; } = new();

/// <summary>Queue of exceptions, dequeued in FIFO order.</summary>
public Queue<Exception> BrowseChildrenExceptions { get; } = new();

/// <summary>Records the call and returns a queued reply, queued exception, or the default.</summary>
public Task<BrowseChildrenReply> BrowseChildrenAsync(
    BrowseChildrenRequest request,
    CallOptions callOptions)
{
    BrowseChildrenCalls.Add((request, callOptions));
    if (BrowseChildrenExceptions.TryDequeue(out Exception? exception))
    {
        return Task.FromException<BrowseChildrenReply>(exception);
    }
    if (BrowseChildrenReplies.TryDequeue(out BrowseChildrenReply? reply))
    {
        return Task.FromResult(reply);
    }
    return Task.FromResult(BrowseChildrenReply);
}
```

### Step 9: Build + test

```bash
dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx 2>&1 | tail -10
```
Expected: 0 warnings, 0 errors.

```bash
dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/ZB.MOM.WW.MxGateway.Client.Tests.csproj --filter "FullyQualifiedName~LazyBrowseNodeTests" 2>&1 | tail -15
```
Expected: 6/6 PASS.

```bash
dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/ZB.MOM.WW.MxGateway.Client.Tests.csproj 2>&1 | tail -5
```
Expected: all green (no regression in `GalaxyRepositoryClientTests` etc.).

### Step 10: Commit

```bash
git add clients/dotnet/
git commit -m "client/dotnet: LazyBrowseNode walker for lazy hierarchy browse"
```

---

## Task 3: Python — LazyBrowseNode walker + tests

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Tasks 2, 4, 5, 6

**Files:**
- Modify: `clients/python/src/zb_mom_ww_mxgateway/galaxy.py`
- Create or modify: `clients/python/src/zb_mom_ww_mxgateway/options.py` (add `BrowseChildrenOptions` dataclass; check if `DiscoverHierarchyOptions` already lives here — if so, sibling it)
- Modify: `clients/python/tests/test_galaxy.py` (add new tests at end of file; extend `FakeGalaxyStub` with BrowseChildren queue)

### Step 1: Read the existing patterns first

- `clients/python/src/zb_mom_ww_mxgateway/galaxy.py` — find `discover_hierarchy` (line 117) for the page-walking idiom, error mapping (`map_rpc_error`, `MxGatewayError`), and how the client uses `self._stub`.
- `clients/python/src/zb_mom_ww_mxgateway/options.py` — see existing options dataclass shape.
- `clients/python/tests/test_galaxy.py` — see `FakeGalaxyStub` (line 271), `FakeUnary` (286), `FakeStream` (304), and `test_discover_hierarchy_returns_proto_objects` (97) for the test pattern.

### Step 2: Write failing tests

Append to `clients/python/tests/test_galaxy.py`:

```python
@pytest.mark.asyncio
async def test_browse_no_parent_returns_roots() -> None:
    stub = FakeGalaxyStub()
    stub.browse_children_replies.append(
        _build_browse_reply(
            children=[_obj(1, "Plant", is_area=True), _obj(99, "Other")],
            child_has_children=[True, False],
            cache_sequence=7,
        )
    )
    async with GalaxyRepositoryClient(
        ClientOptions(endpoint="fake", plaintext=True),
        stub_factory=lambda channel: stub,
    ) as client:
        roots = await client.browse()

    assert len(roots) == 2
    assert roots[0].object.tag_name == "Plant"
    assert roots[0].has_children_hint is True
    assert roots[0].is_expanded is False
    assert roots[1].has_children_hint is False


@pytest.mark.asyncio
async def test_browse_expand_populates_children_and_marks_expanded() -> None:
    stub = FakeGalaxyStub()
    stub.browse_children_replies.append(
        _build_browse_reply([_obj(1, "Plant", is_area=True)], [True], 7))
    stub.browse_children_replies.append(
        _build_browse_reply([_obj(2, "Mixer_001")], [False], 7))

    async with GalaxyRepositoryClient(
        ClientOptions(endpoint="fake", plaintext=True),
        stub_factory=lambda channel: stub,
    ) as client:
        roots = await client.browse()
        await roots[0].expand()

    assert roots[0].is_expanded is True
    assert len(roots[0].children) == 1
    assert roots[0].children[0].object.tag_name == "Mixer_001"
    assert len(stub.browse_children_calls) == 2  # roots + expand


@pytest.mark.asyncio
async def test_browse_expand_idempotent_no_second_rpc() -> None:
    stub = FakeGalaxyStub()
    stub.browse_children_replies.append(
        _build_browse_reply([_obj(1, "Plant", is_area=True)], [True], 7))
    stub.browse_children_replies.append(
        _build_browse_reply([_obj(2, "Mixer_001")], [False], 7))

    async with GalaxyRepositoryClient(
        ClientOptions(endpoint="fake", plaintext=True),
        stub_factory=lambda channel: stub,
    ) as client:
        roots = await client.browse()
        await roots[0].expand()
        await roots[0].expand()

    assert len(stub.browse_children_calls) == 2  # roots + one expand


@pytest.mark.asyncio
async def test_browse_expand_unknown_parent_raises_mxgateway_error() -> None:
    stub = FakeGalaxyStub()
    stub.browse_children_replies.append(
        _build_browse_reply([_obj(1, "Plant", is_area=True)], [True], 7))
    stub.browse_children_exceptions.append(_grpc_not_found("parent not found"))

    async with GalaxyRepositoryClient(
        ClientOptions(endpoint="fake", plaintext=True),
        stub_factory=lambda channel: stub,
    ) as client:
        roots = await client.browse()
        with pytest.raises(MxGatewayError):
            await roots[0].expand()


@pytest.mark.asyncio
async def test_browse_expand_multi_page_gathers_all_pages() -> None:
    stub = FakeGalaxyStub()
    stub.browse_children_replies.append(
        _build_browse_reply([_obj(1, "Plant", is_area=True)], [True], 7))
    page1 = _build_browse_reply(
        [_obj(2, "Mixer_001"), _obj(3, "Mixer_002")], [False, False], 7)
    page1.next_page_token = "7:abc:2"
    stub.browse_children_replies.append(page1)
    stub.browse_children_replies.append(
        _build_browse_reply([_obj(4, "Pump_001")], [False], 7))

    async with GalaxyRepositoryClient(
        ClientOptions(endpoint="fake", plaintext=True),
        stub_factory=lambda channel: stub,
    ) as client:
        roots = await client.browse()
        await roots[0].expand()

    names = [c.object.tag_name for c in roots[0].children]
    assert names == ["Mixer_001", "Mixer_002", "Pump_001"]
    assert len(stub.browse_children_calls) == 3
    assert stub.browse_children_calls[-1].page_token == "7:abc:2"


@pytest.mark.asyncio
async def test_browse_with_filter_forwards_to_request() -> None:
    stub = FakeGalaxyStub()
    stub.browse_children_replies.append(_build_browse_reply([], [], 7))

    async with GalaxyRepositoryClient(
        ClientOptions(endpoint="fake", plaintext=True),
        stub_factory=lambda channel: stub,
    ) as client:
        await client.browse(BrowseChildrenOptions(
            tag_name_glob="Mixer*", alarm_bearing_only=True))

    req = stub.browse_children_calls[0]
    assert req.tag_name_glob == "Mixer*"
    assert req.alarm_bearing_only is True


def _obj(gid: int, tag: str, is_area: bool = False) -> galaxy_pb.GalaxyObject:
    return galaxy_pb.GalaxyObject(
        gobject_id=gid, tag_name=tag, browse_name=tag, is_area=is_area)


def _build_browse_reply(children, child_has_children, cache_sequence):
    reply = galaxy_pb.BrowseChildrenReply(
        total_child_count=len(children), cache_sequence=cache_sequence)
    reply.children.extend(children)
    reply.child_has_children.extend(child_has_children)
    return reply


def _grpc_not_found(message: str):
    # Match the way other tests in this file construct an grpc.aio.AioRpcError.
    # If that helper exists already, reuse it; otherwise replicate the existing
    # patterns inline.
    import grpc
    return grpc.aio.AioRpcError(
        code=grpc.StatusCode.NOT_FOUND,
        initial_metadata=grpc.aio.Metadata(),
        trailing_metadata=grpc.aio.Metadata(),
        details=message,
    )
```

Imports to add at top of file:
```python
from zb_mom_ww_mxgateway.options import BrowseChildrenOptions
```

If `GalaxyRepositoryClient` doesn't currently take a `stub_factory` kwarg, look at how existing tests inject the fake stub and copy that exact mechanism — the construction may use module-level patching instead. Match what works.

### Step 3: Run tests to verify they fail

```bash
cd clients/python && python -m pytest tests/test_galaxy.py -k "browse and not deploy" -v 2>&1 | tail -25
```

Expected: collection errors or import errors — `BrowseChildrenOptions` and `client.browse()` don't exist.

### Step 4: Add `BrowseChildrenOptions` to `options.py`

```python
@dataclass(frozen=True)
class BrowseChildrenOptions:
    """Filters and shape options for ``GalaxyRepositoryClient.browse``."""

    category_ids: Sequence[int] = ()
    template_chain_contains: Sequence[str] = ()
    tag_name_glob: str | None = None
    include_attributes: bool | None = None
    alarm_bearing_only: bool = False
    historized_only: bool = False
```

Match the existing `DiscoverHierarchyOptions` style: frozen dataclass if that's what it uses; `__slots__` if that's the convention.

### Step 5: Extend `galaxy.py`

Add to the top, after existing imports:
```python
from dataclasses import dataclass, field
from zb_mom_ww_mxgateway.options import BrowseChildrenOptions
```

Add a new module-level class `LazyBrowseNode` after the existing `GalaxyRepositoryClient` class definition (or as a nested class — pick whichever the file's style prefers):

```python
class LazyBrowseNode:
    """One node in a lazy-loaded Galaxy browse tree.

    Calling ``expand`` once fetches direct children and populates
    ``children``. Subsequent calls are no-ops.
    """

    def __init__(
        self,
        client: "GalaxyRepositoryClient",
        obj: galaxy_pb.GalaxyObject,
        has_children_hint: bool,
        options: BrowseChildrenOptions,
    ) -> None:
        self._client = client
        self._object = obj
        self._has_children_hint = has_children_hint
        self._options = options
        self._children: list["LazyBrowseNode"] = []
        self._is_expanded = False

    @property
    def object(self) -> galaxy_pb.GalaxyObject:
        return self._object

    @property
    def has_children_hint(self) -> bool:
        return self._has_children_hint

    @property
    def children(self) -> list["LazyBrowseNode"]:
        return list(self._children)

    @property
    def is_expanded(self) -> bool:
        return self._is_expanded

    async def expand(self) -> None:
        if self._is_expanded:
            return
        async for child in self._client._iter_browse_children(
                parent_gobject_id=self._object.gobject_id,
                options=self._options):
            self._children.append(child)
        self._is_expanded = True
```

Add to `GalaxyRepositoryClient`:

```python
_BROWSE_CHILDREN_PAGE_SIZE = 500

async def browse(
    self,
    options: BrowseChildrenOptions | None = None,
) -> list[LazyBrowseNode]:
    """Returns root browse nodes (objects with no parent)."""
    effective = options or BrowseChildrenOptions()
    return [child async for child in self._iter_browse_children(
        parent_gobject_id=None, options=effective)]

async def _iter_browse_children(
    self,
    *,
    parent_gobject_id: int | None,
    options: BrowseChildrenOptions,
):
    page_token = ""
    seen: set[str] = set()
    while True:
        request = galaxy_pb.BrowseChildrenRequest(
            page_size=self._BROWSE_CHILDREN_PAGE_SIZE,
            page_token=page_token,
            alarm_bearing_only=options.alarm_bearing_only,
            historized_only=options.historized_only,
        )
        if parent_gobject_id is not None:
            request.parent_gobject_id = parent_gobject_id
        request.category_ids.extend(options.category_ids)
        request.template_chain_contains.extend(options.template_chain_contains)
        if options.tag_name_glob:
            request.tag_name_glob = options.tag_name_glob
        if options.include_attributes is not None:
            request.include_attributes = options.include_attributes

        try:
            reply = await self._stub.BrowseChildren(request, metadata=self._auth_metadata())
        except grpc.aio.AioRpcError as error:
            raise map_rpc_error("browse children", error) from error

        for index, obj in enumerate(reply.children):
            hint = index < len(reply.child_has_children) and reply.child_has_children[index]
            yield LazyBrowseNode(self, obj, hint, options)

        page_token = reply.next_page_token
        if not page_token:
            return
        if page_token in seen:
            raise MxGatewayError(
                f"Galaxy BrowseChildren returned a repeated page token '{page_token}'.")
        seen.add(page_token)
```

The exact `metadata=self._auth_metadata()` / `try/except` pattern must match the existing `discover_hierarchy` method — copy from line 117 of `galaxy.py`.

### Step 6: Extend `FakeGalaxyStub` in `test_galaxy.py`

Add (mirroring the existing `discover_hierarchy_replies` pattern):

```python
def __init__(self) -> None:
    # ... existing init ...
    self.browse_children_calls: list = []
    self.browse_children_replies: list = []
    self.browse_children_exceptions: list = []

def BrowseChildren(self, request, metadata=None):
    self.browse_children_calls.append(request)
    if self.browse_children_exceptions:
        raise self.browse_children_exceptions.pop(0)
    if self.browse_children_replies:
        return _await_returning(self.browse_children_replies.pop(0))
    return _await_returning(galaxy_pb.BrowseChildrenReply())
```

(`_await_returning` is the existing helper that wraps a value in something awaitable to match grpc.aio's interface — if it doesn't exist by that name, find the equivalent in `FakeUnary` / `FakeStream`.)

### Step 7: Run tests

```bash
cd clients/python && python -m pytest tests/test_galaxy.py -v 2>&1 | tail -25
```

Expected: all 6 new browse tests pass; existing tests still pass.

### Step 8: Commit

```bash
git add clients/python/
git commit -m "client/python: LazyBrowseNode walker for lazy hierarchy browse"
```

---

## Task 4: Rust — LazyBrowseNode walker + tests

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Tasks 2, 3, 5, 6

**Files:**
- Modify: `clients/rust/src/galaxy.rs` (add `LazyBrowseNode`, `BrowseChildrenOptions`, `browse`, `browse_children_raw`)
- Modify: `clients/rust/tests/client_behavior.rs` (add tests; extend the `FakeGalaxy` impl from line 265+ to record BrowseChildren calls)

### Step 1: Read existing patterns

- `clients/rust/src/galaxy.rs` lines 145-186 — `discover_hierarchy` for paging idiom.
- `clients/rust/src/error.rs` — error variants. `GalaxyError::NotFound` or generic `Error::NotFound`.
- `clients/rust/tests/client_behavior.rs` — existing `discover_hierarchy` test for the pattern; the `FakeGalaxy` impl is in `clients/rust/src/galaxy.rs` lines 265+ as a test module (`#[cfg(test)] mod tests`).

Wait — the `FakeGalaxy` impl is in `clients/rust/src/galaxy.rs:265` (visible from the earlier grep). Confirm: the fake might be inside `galaxy.rs` itself, not `tests/`. If so, tests live in the same file via `#[cfg(test)] mod tests`. Follow the existing convention.

### Step 2: Write failing tests

In whichever file holds the existing `discover_hierarchy` test, add 6 facts modeled on it:

```rust
#[tokio::test]
async fn browse_no_parent_returns_roots() {
    let (mut client, fake) = start_fake_with_replies(vec![
        build_browse_reply(
            vec![obj(1, "Plant", true), obj(99, "Other", false)],
            vec![true, false],
            7,
        ),
    ]).await;

    let roots = client.browse(None).await.expect("browse roots");
    assert_eq!(roots.len(), 2);
    assert_eq!(roots[0].object().tag_name, "Plant");
    assert!(roots[0].has_children_hint());
    assert!(!roots[0].is_expanded());
    assert!(!roots[1].has_children_hint());
}

#[tokio::test]
async fn browse_expand_populates_children_and_marks_expanded() { /* ... */ }

#[tokio::test]
async fn browse_expand_idempotent_no_second_rpc() { /* ... */ }

#[tokio::test]
async fn browse_expand_unknown_parent_returns_not_found_error() { /* ... */ }

#[tokio::test]
async fn browse_expand_multi_page_gathers_all_pages() { /* ... */ }

#[tokio::test]
async fn browse_with_filter_forwards_to_request() { /* ... */ }
```

Helper functions `obj`, `build_browse_reply`, `start_fake_with_replies` — model on whatever helpers exist for the `discover_hierarchy` tests in the same file.

### Step 3: Run tests to confirm failure

```bash
cd clients/rust && cargo test --workspace browse_ 2>&1 | tail -15
```
Expected: compile errors — `client.browse`, `BrowseChildrenOptions`, `LazyBrowseNode` don't exist.

### Step 4: Add `BrowseChildrenOptions` struct in `galaxy.rs`

```rust
#[derive(Debug, Clone, Default)]
pub struct BrowseChildrenOptions {
    pub category_ids: Vec<i32>,
    pub template_chain_contains: Vec<String>,
    pub tag_name_glob: Option<String>,
    pub include_attributes: Option<bool>,
    pub alarm_bearing_only: bool,
    pub historized_only: bool,
}
```

### Step 5: Add `LazyBrowseNode`

Because Rust ownership makes parent→client back-references awkward, use `Arc` and interior mutability for `Children`:

```rust
use std::sync::Arc;
use tokio::sync::Mutex;

pub struct LazyBrowseNode {
    inner: Arc<LazyBrowseNodeInner>,
}

struct LazyBrowseNodeInner {
    client: GalaxyClient,           // GalaxyClient must be Clone (or wrap in Arc)
    object: GalaxyObject,
    has_children_hint: bool,
    options: BrowseChildrenOptions,
    state: Mutex<LazyBrowseNodeState>,
}

struct LazyBrowseNodeState {
    children: Vec<LazyBrowseNode>,
    is_expanded: bool,
}

impl LazyBrowseNode {
    pub fn object(&self) -> &GalaxyObject { &self.inner.object }
    pub fn has_children_hint(&self) -> bool { self.inner.has_children_hint }

    pub async fn children(&self) -> Vec<LazyBrowseNode> {
        let state = self.inner.state.lock().await;
        state.children.iter().map(|n| LazyBrowseNode { inner: Arc::clone(&n.inner) }).collect()
    }

    pub async fn is_expanded(&self) -> bool {
        self.inner.state.lock().await.is_expanded
    }

    pub async fn expand(&self) -> Result<(), Error> {
        let mut state = self.inner.state.lock().await;
        if state.is_expanded { return Ok(()); }

        let new_children = self.inner.client.clone().browse_children_inner(
            Some(self.inner.object.gobject_id),
            self.inner.options.clone(),
        ).await?;

        state.children = new_children;
        state.is_expanded = true;
        Ok(())
    }
}
```

If `GalaxyClient` doesn't implement `Clone`, refactor to wrap the inner state in `Arc<Mutex<_>>` for the channel — but check the existing struct first. The earlier grep showed `pub struct GalaxyClient { ... }` with `&mut self` methods, so it's likely not Clone. In that case, store a clone of the gRPC `Channel` (tonic channels ARE Clone) and rebuild the raw client per call:

```rust
struct LazyBrowseNodeInner {
    channel: tonic::transport::Channel,
    auth: AuthMetadata,    // whatever the existing client uses for headers
    object: GalaxyObject,
    has_children_hint: bool,
    options: BrowseChildrenOptions,
    state: Mutex<LazyBrowseNodeState>,
}
```

Pick whichever fits the codebase with the least churn. Document the choice in the task summary.

### Step 6: Add `browse` and `browse_children_raw` to `GalaxyClient`

```rust
impl GalaxyClient {
    pub async fn browse(
        &mut self,
        options: Option<BrowseChildrenOptions>,
    ) -> Result<Vec<LazyBrowseNode>, Error> {
        self.browse_children_inner(None, options.unwrap_or_default()).await
    }

    pub async fn browse_children_raw(
        &mut self,
        request: BrowseChildrenRequest,
    ) -> Result<BrowseChildrenReply, Error> {
        let mut request = tonic::Request::new(request);
        self.attach_auth(&mut request)?;
        let reply = self.raw.browse_children(request).await
            .map_err(|status| map_status_error("browse children", status))?
            .into_inner();
        Ok(reply)
    }

    async fn browse_children_inner(
        &mut self,
        parent_gobject_id: Option<i32>,
        options: BrowseChildrenOptions,
    ) -> Result<Vec<LazyBrowseNode>, Error> {
        let mut nodes = Vec::new();
        let mut page_token = String::new();
        let mut seen: HashSet<String> = HashSet::new();
        loop {
            let mut request = BrowseChildrenRequest {
                page_size: 500,
                page_token: page_token.clone(),
                category_ids: options.category_ids.clone(),
                template_chain_contains: options.template_chain_contains.clone(),
                tag_name_glob: options.tag_name_glob.clone().unwrap_or_default(),
                alarm_bearing_only: options.alarm_bearing_only,
                historized_only: options.historized_only,
                ..Default::default()
            };
            if let Some(pid) = parent_gobject_id {
                request.parent = Some(browse_children_request::Parent::ParentGobjectId(pid));
            }
            if let Some(attrs) = options.include_attributes {
                request.include_attributes = Some(attrs);
            }

            let reply = self.browse_children_raw(request).await?;
            for (i, obj) in reply.children.into_iter().enumerate() {
                let hint = reply.child_has_children.get(i).copied().unwrap_or(false);
                nodes.push(self.make_lazy_node(obj, hint, options.clone()));
            }

            page_token = reply.next_page_token;
            if page_token.is_empty() { break; }
            if !seen.insert(page_token.clone()) {
                return Err(Error::Other(format!(
                    "Galaxy BrowseChildren returned a repeated page token '{page_token}'.")));
            }
        }
        Ok(nodes)
    }

    fn make_lazy_node(
        &self,
        object: GalaxyObject,
        has_children_hint: bool,
        options: BrowseChildrenOptions,
    ) -> LazyBrowseNode {
        // ... construct the LazyBrowseNodeInner with whatever channel/auth state is needed ...
    }
}
```

The exact `Parent::ParentGobjectId` enum variant name comes from prost's oneof generation — check `clients/rust/src/generated/galaxy_repository.v1.rs` for the exact module path. If `parent` is generated as `parent: Option<browse_children_request::Parent>`, the snippet above is correct.

### Step 7: Extend `FakeGalaxy` impl

In whatever `#[cfg(test)] mod tests { ... impl GalaxyRepository for FakeGalaxy { ... } }` block exists (the Task 7 stub at line ~265 of `galaxy.rs`), replace the `unimplemented!()` body of `browse_children` with a real recorded-call implementation:

```rust
async fn browse_children(
    &self,
    request: Request<BrowseChildrenRequest>,
) -> Result<Response<BrowseChildrenReply>, Status> {
    let req = request.into_inner();
    self.browse_children_calls.lock().await.push(req.clone());
    if let Some(error) = self.browse_children_errors.lock().await.pop() {
        return Err(error);
    }
    let reply = self.browse_children_replies
        .lock().await
        .pop_front()
        .unwrap_or_default();
    Ok(Response::new(reply))
}
```

And add the corresponding fields to the `FakeGalaxy` struct:
```rust
browse_children_calls: Mutex<Vec<BrowseChildrenRequest>>,
browse_children_replies: Mutex<VecDeque<BrowseChildrenReply>>,
browse_children_errors: Mutex<Vec<Status>>,
```

### Step 8: Build + test

```bash
cd clients/rust && cargo check --workspace 2>&1 | tail -15
```
Expected: clean.

```bash
cd clients/rust && cargo test --workspace 2>&1 | tail -10
```
Expected: all green (new browse tests + existing discover_hierarchy).

```bash
cd clients/rust && cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -10
```
Expected: clean.

### Step 9: Commit

```bash
git add clients/rust/
git commit -m "client/rust: LazyBrowseNode walker for lazy hierarchy browse"
```

---

## Task 5: Go — LazyBrowseNode walker + tests

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Tasks 2, 3, 4, 6

**Files:**
- Modify: `clients/go/mxgateway/galaxy.go`
- Modify: `clients/go/mxgateway/options.go` (add `BrowseChildrenOptions`)
- Modify: `clients/go/mxgateway/galaxy_test.go` (add tests + extend `fakeGalaxyServer` from line 370)

### Step 1: Read existing patterns

- `clients/go/mxgateway/galaxy.go:150` — `DiscoverHierarchy` paging idiom.
- `clients/go/mxgateway/galaxy_test.go:96` — `TestGalaxyDiscoverHierarchyReturnsObjects` for the test pattern.
- `clients/go/mxgateway/galaxy_test.go:370` — `fakeGalaxyServer` struct (extend it).
- `clients/go/mxgateway/options.go` — existing `DiscoverHierarchyOptions` to mirror.

### Step 2: Failing tests

Append to `galaxy_test.go`:

```go
func TestGalaxyBrowseNoParentReturnsRoots(t *testing.T) { /* ... */ }
func TestGalaxyBrowseExpandPopulatesChildrenAndMarksExpanded(t *testing.T) { /* ... */ }
func TestGalaxyBrowseExpandIdempotentNoSecondRpc(t *testing.T) { /* ... */ }
func TestGalaxyBrowseExpandUnknownParentReturnsNotFoundError(t *testing.T) { /* ... */ }
func TestGalaxyBrowseExpandMultiPageGathersAllPages(t *testing.T) { /* ... */ }
func TestGalaxyBrowseWithFilterForwardsToRequest(t *testing.T) { /* ... */ }
```

Each follows the existing `Test...DiscoverHierarchy...` pattern: spin up a `fakeGalaxyServer`, dial a `GalaxyClient` against it, queue replies/errors via the fake, assert behavior and recorded calls.

### Step 3-7: Implement

Add to `options.go`:
```go
type BrowseChildrenOptions struct {
    CategoryIds           []int32
    TemplateChainContains []string
    TagNameGlob           string
    IncludeAttributes     *bool
    AlarmBearingOnly      bool
    HistorizedOnly        bool
}
```

Add to `galaxy.go`:
```go
type LazyBrowseNode struct {
    client          *GalaxyClient
    object          *pb.GalaxyObject
    hasChildrenHint bool
    options         BrowseChildrenOptions
    mu              sync.Mutex
    children        []*LazyBrowseNode
    isExpanded      bool
}

func (n *LazyBrowseNode) Object() *pb.GalaxyObject { return n.object }
func (n *LazyBrowseNode) HasChildrenHint() bool { return n.hasChildrenHint }
func (n *LazyBrowseNode) Children() []*LazyBrowseNode {
    n.mu.Lock(); defer n.mu.Unlock()
    out := make([]*LazyBrowseNode, len(n.children))
    copy(out, n.children)
    return out
}
func (n *LazyBrowseNode) IsExpanded() bool {
    n.mu.Lock(); defer n.mu.Unlock()
    return n.isExpanded
}

func (n *LazyBrowseNode) Expand(ctx context.Context) error {
    n.mu.Lock(); defer n.mu.Unlock()
    if n.isExpanded { return nil }
    parentID := n.object.GobjectId
    children, err := n.client.browseChildrenInner(ctx, &parentID, n.options)
    if err != nil { return err }
    n.children = children
    n.isExpanded = true
    return nil
}

const browseChildrenPageSize = 500

func (c *GalaxyClient) Browse(ctx context.Context, opts *BrowseChildrenOptions) ([]*LazyBrowseNode, error) {
    effective := BrowseChildrenOptions{}
    if opts != nil { effective = *opts }
    return c.browseChildrenInner(ctx, nil, effective)
}

func (c *GalaxyClient) BrowseChildrenRaw(ctx context.Context, req *pb.BrowseChildrenRequest) (*pb.BrowseChildrenReply, error) {
    callCtx, cancel := c.callContext(ctx)
    defer cancel()
    return c.raw.BrowseChildren(callCtx, req)
}

func (c *GalaxyClient) browseChildrenInner(
    ctx context.Context,
    parentGobjectID *int32,
    opts BrowseChildrenOptions,
) ([]*LazyBrowseNode, error) {
    var nodes []*LazyBrowseNode
    pageToken := ""
    seen := map[string]struct{}{}
    for {
        req := &pb.BrowseChildrenRequest{
            PageSize:              browseChildrenPageSize,
            PageToken:             pageToken,
            CategoryIds:           opts.CategoryIds,
            TemplateChainContains: opts.TemplateChainContains,
            TagNameGlob:           opts.TagNameGlob,
            AlarmBearingOnly:      opts.AlarmBearingOnly,
            HistorizedOnly:        opts.HistorizedOnly,
        }
        if parentGobjectID != nil {
            req.Parent = &pb.BrowseChildrenRequest_ParentGobjectId{ParentGobjectId: *parentGobjectID}
        }
        if opts.IncludeAttributes != nil {
            req.IncludeAttributes = opts.IncludeAttributes
        }

        reply, err := c.BrowseChildrenRaw(ctx, req)
        if err != nil { return nil, mapGalaxyError("browse children", err) }

        for i, obj := range reply.Children {
            hint := i < len(reply.ChildHasChildren) && reply.ChildHasChildren[i]
            nodes = append(nodes, &LazyBrowseNode{
                client: c, object: obj, hasChildrenHint: hint, options: opts,
            })
        }

        pageToken = reply.NextPageToken
        if pageToken == "" { return nodes, nil }
        if _, dup := seen[pageToken]; dup {
            return nil, fmt.Errorf("Galaxy BrowseChildren returned a repeated page token %q", pageToken)
        }
        seen[pageToken] = struct{}{}
    }
}
```

Adjust the `BrowseChildrenRequest_ParentGobjectId` type name to match what protoc-gen-go actually generated for the oneof — check `galaxy_repository.pb.go` for the exact identifier. Adjust `mapGalaxyError` to whatever the existing client uses (look at how `DiscoverHierarchy` wraps errors).

### Step 8: Build + test

```bash
cd clients/go && gofmt -l ./... 2>&1
cd clients/go && go build ./... 2>&1 | tail -5
cd clients/go && go test ./... 2>&1 | tail -5
```
Expected: gofmt clean, build clean, tests green.

### Step 9: Commit

```bash
git add clients/go/
git commit -m "client/go: LazyBrowseNode walker for lazy hierarchy browse"
```

---

## Task 6: Java — LazyBrowseNode walker + tests

**Classification:** standard
**Estimated implement time:** ~6 min (~10 min if proto regen needed)
**Parallelizable with:** Tasks 2, 3, 4, 5
**Blocked by:** Task 1 (toolchain bootstrap must succeed)

If Task 1 reported failure, **skip this task** and have the doc-update task (Task 7) cover only Java's README snippet without expecting a working build.

**Files:**
- Modify: `clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/GalaxyRepositoryClient.java`
- Create: `clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/BrowseChildrenOptions.java`
- Create: `clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/LazyBrowseNode.java`
- Modify: `clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/GalaxyRepositoryClientTests.java`

### Step 1: Regen protos to pick up BrowseChildren

```bash
cd clients/java
[ -n "$JAVA_HOME" ] || export JAVA_HOME=$(/usr/libexec/java_home -v 21)
gradle generateProto 2>&1 | tail -10
```

Expected: `BUILD SUCCESSFUL`. The generated tree under `src/main/generated/main/{java,grpc}/galaxy_repository/` should now contain `BrowseChildrenRequest`, `BrowseChildrenReply`, and a `browseChildren` method on the generated stub.

Verify:
```bash
grep -l "browseChildren\|BrowseChildren" clients/java/zb-mom-ww-mxgateway-client/src/main/generated/ -r 2>&1 | head
```
Expected: at least one hit per generated file (the message class + the gRPC stub).

### Step 2-7: Implement the walker

Follow the same pattern as the other languages, adapted for Java. Use `synchronized` for the expand-once invariant; `CompletableFuture` for async if the existing client surface is async (check `GalaxyRepositoryClient.java`). If the existing client is sync (using `BlockingStub`), make the walker sync too — match the file.

Test layout: the existing `GalaxyRepositoryClientTests.java` uses an `InProcessServer` or `MockServer` pattern — copy that exactly. Don't invent a new test fixture.

### Step 8: Build + test

```bash
cd clients/java && gradle build 2>&1 | tail -15
```
Expected: `BUILD SUCCESSFUL`.

### Step 9: Commit

```bash
git add clients/java/
git commit -m "client/java: LazyBrowseNode walker for lazy hierarchy browse"
```

If Task 1 reported failure: skip this entire task. Report "Java skipped — toolchain unavailable" and proceed.

---

## Task 7: README updates — walker example per client

**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Tasks 2-6 once they're done (or as a final consolidation task)

**Files:**
- Modify: `clients/dotnet/README.md`
- Modify: `clients/python/README.md`
- Modify: `clients/rust/README.md`
- Modify: `clients/go/README.md`
- Modify: `clients/java/README.md`

### Step 1: For each README

Find the existing "Browsing lazily" section (added in commit `0d6193c`). Below the existing raw-RPC snippet, add a short "High-level walker" subsection:

```
#### High-level walker

For UI trees, the client provides a `LazyBrowseNode` walker that handles
sibling pagination and the `child_has_children` hint for you:

[code snippet per language]

`ExpandAsync` is idempotent — calling it twice fires only one RPC. To
refresh after a Galaxy redeploy, call `BrowseAsync` again from the root.
```

Per-language snippet (5-10 lines each):

**.NET:**
```csharp
using GalaxyRepositoryClient client = ...;
var roots = await client.BrowseAsync();
foreach (var node in roots)
{
    if (node.HasChildrenHint) await node.ExpandAsync();
    foreach (var child in node.Children) Console.WriteLine(child.Object.TagName);
}
```

**Python:**
```python
async with GalaxyRepositoryClient(opts) as client:
    roots = await client.browse()
    for node in roots:
        if node.has_children_hint:
            await node.expand()
        for child in node.children:
            print(child.object.tag_name)
```

**Rust:**
```rust
let mut client = GalaxyClient::connect(opts).await?;
let roots = client.browse(None).await?;
for node in &roots {
    if node.has_children_hint() {
        node.expand().await?;
    }
    for child in node.children().await {
        println!("{}", child.object().tag_name);
    }
}
```

**Go:**
```go
client, _ := mxgateway.DialGalaxy(ctx, opts)
roots, _ := client.Browse(ctx, nil)
for _, node := range roots {
    if node.HasChildrenHint() {
        _ = node.Expand(ctx)
    }
    for _, child := range node.Children() {
        fmt.Println(child.Object().GetTagName())
    }
}
```

**Java:**
```java
GalaxyRepositoryClient client = ...;
List<LazyBrowseNode> roots = client.browseAsync(null).get();
for (LazyBrowseNode node : roots) {
    if (node.hasChildrenHint()) node.expandAsync().get();
    node.getChildren().forEach(c -> System.out.println(c.getObject().getTagName()));
}
```

### Step 2: Commit

```bash
git add clients/dotnet/README.md clients/python/README.md clients/rust/README.md clients/go/README.md clients/java/README.md
git commit -m "docs: per-client High-level walker example using LazyBrowseNode"
```

---

## Task 8: Final integration build and verification

**Classification:** standard
**Estimated implement time:** ~3 min
**Parallelizable with:** none (final gate)

### Step 1: Build everything that's locally buildable

```bash
dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx 2>&1 | tail -5
cd clients/rust && cargo build --workspace 2>&1 | tail -3 && cd -
cd clients/go && go build ./... 2>&1 | tail -3 && cd -
cd clients/python && python -m pytest --collect-only 2>&1 | tail -3 && cd -
```
If Java toolchain installed (Task 1 succeeded):
```bash
cd clients/java && gradle build 2>&1 | tail -5 && cd -
```

### Step 2: Run all client tests

```bash
dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/ZB.MOM.WW.MxGateway.Client.Tests.csproj 2>&1 | tail -5
cd clients/python && python -m pytest 2>&1 | tail -5 && cd -
cd clients/rust && cargo test --workspace 2>&1 | tail -5 && cd -
cd clients/go && go test ./... 2>&1 | tail -5 && cd -
cd clients/java && gradle test 2>&1 | tail -5 && cd -    # if Java succeeded
```
All should be green.

### Step 3: Self-review the cumulative diff

```bash
git log --oneline main..HEAD
git diff --stat main..HEAD | tail -30
```

Confirm each new commit corresponds to one plan task.

### Step 4: Stop — do NOT push or open a PR

Final commit only happens after the user reviews this task's output.
