# Client Lazy-Browse Walker Helpers + Per-Language Tests

Date: 2026-05-28
Status: approved, ready for implementation plan

## Problem

The `BrowseChildren` RPC shipped (branch `feat/lazy-browse-children`, merged
or pending merge), but each language client exposes only the raw generated
gRPC stub. Callers must hand-write recursion, sibling pagination, and
NotFound translation themselves. Only one client (.NET) has a smoke test,
and it is skippable.

This work adds a small high-level walker to each client and unit tests so
callers can build OPC UA-style browse trees without re-implementing the
same plumbing five times.

## Scope

Each of the five clients (.NET, Python, Rust, Go, Java) gains:

1. A low-level `BrowseChildren*Async` wrapper on the existing
   `GalaxyRepositoryClient`, mirroring the existing `DiscoverHierarchy*Async`
   shape.
2. A high-level `LazyBrowseNode` type plus a `BrowseAsync` factory.
3. Five unit tests against the language's existing fake-transport fixture.

Plus a one-time toolchain bootstrap so the Java client builds locally on
the macOS dev host (Homebrew install of Temurin 21 + Gradle).

## Architecture

`LazyBrowseNode` is shared in shape across languages:

```text
LazyBrowseNode {
  Object               GalaxyObject       (immutable, from server)
  HasChildrenHint      bool               (server's child_has_children value)
  Children             list<LazyBrowseNode>  (empty until Expand)
  IsExpanded           bool
  ExpandAsync(ct)      Task               (idempotent; no-op after first call)
}
```

`GalaxyRepositoryClient.BrowseAsync(parent?, ct)` returns a list of root
`LazyBrowseNode`s. Empty `parent` means structural roots. Each returned
node is unexpanded; the caller invokes `ExpandAsync` to fetch direct
children. After expand, `Children` is a list of further `LazyBrowseNode`s.

**Pagination is hidden.** `ExpandAsync` walks `next_page_token` internally
until all siblings of this parent are gathered. Callers see one flat
`Children` list.

**Errors:** server `NotFound` becomes a language-idiomatic typed error
(`MxGatewayException` in .NET, `GalaxyNotFoundError` in Python,
`GalaxyError::NotFound` in Rust, typed error in Go,
`GalaxyNotFoundException` in Java).

**Filters:** `BrowseAsync` accepts a `BrowseChildrenOptions` (or
language-equivalent) mirroring the existing `DiscoverHierarchyOptions`. The
same options apply to every `ExpandAsync` call rooted from that factory
call — stored on the node so child expansions inherit them.

## Per-language API

Each language adapts to its own idioms; the structure is parallel.

### .NET (`clients/dotnet/ZB.MOM.WW.MxGateway.Client/GalaxyRepositoryClient.cs`)

```csharp
public sealed class LazyBrowseNode
{
    public GalaxyObject Object { get; }
    public bool HasChildrenHint { get; }
    public IReadOnlyList<LazyBrowseNode> Children { get; }
    public bool IsExpanded { get; }
    public Task ExpandAsync(CancellationToken ct = default);
}

public Task<IReadOnlyList<LazyBrowseNode>> BrowseAsync(
    BrowseChildrenOptions? options = null,
    CancellationToken ct = default);

public Task<BrowseChildrenReply> BrowseChildrenRawAsync(
    BrowseChildrenRequest request,
    CancellationToken ct = default);
```

### Python (`clients/python/src/zb_mom_ww_mxgateway/galaxy.py`)

```python
@dataclass
class LazyBrowseNode:
    object: GalaxyObject
    has_children_hint: bool
    children: list["LazyBrowseNode"]
    is_expanded: bool
    async def expand(self) -> None: ...

async def browse(
    self,
    options: BrowseChildrenOptions | None = None,
) -> list[LazyBrowseNode]: ...
```

### Rust (`clients/rust/src/galaxy.rs`)

```rust
pub struct LazyBrowseNode { /* private fields; Arc<Mutex<>> for Children */ }

impl LazyBrowseNode {
    pub fn object(&self) -> &GalaxyObject;
    pub fn has_children_hint(&self) -> bool;
    pub fn children(&self) -> Vec<LazyBrowseNode>;  // cloned snapshot
    pub fn is_expanded(&self) -> bool;
    pub async fn expand(&self) -> Result<(), GalaxyError>;
}

pub async fn browse(
    &self,
    options: Option<BrowseChildrenOptions>,
) -> Result<Vec<LazyBrowseNode>, GalaxyError>;
```

### Go (`clients/go/mxgateway/galaxy.go`)

```go
type LazyBrowseNode struct { /* unexported */ }
func (n *LazyBrowseNode) Object() *pb.GalaxyObject
func (n *LazyBrowseNode) HasChildrenHint() bool
func (n *LazyBrowseNode) Children() []*LazyBrowseNode
func (n *LazyBrowseNode) IsExpanded() bool
func (n *LazyBrowseNode) Expand(ctx context.Context) error

func (c *Client) Browse(
    ctx context.Context,
    opts *BrowseChildrenOptions,
) ([]*LazyBrowseNode, error)
```

### Java (`clients/java/zb-mom-ww-mxgateway-client/`)

```java
public final class LazyBrowseNode {
    public GalaxyObject getObject();
    public boolean hasChildrenHint();
    public List<LazyBrowseNode> getChildren();
    public boolean isExpanded();
    public CompletableFuture<Void> expandAsync();
}

public CompletableFuture<List<LazyBrowseNode>> browseAsync(
    BrowseChildrenOptions options);
```

If the existing Java client surface is synchronous, mirror that — both
sync and async variants are acceptable as long as the choice matches the
client's existing convention.

## Tests

Each language adds these six facts against its existing fake-transport
fixture (`FakeGalaxyRepositoryTransport` in .NET, the equivalent in each
other client):

| # | Test | Purpose |
|---|------|---------|
| 1 | `Browse_NoParent_ReturnsRoots` | factory returns roots, each unexpanded, hint reflects fake's `child_has_children` |
| 2 | `Expand_PopulatesChildrenAndMarksExpanded` | one ExpandAsync call fires one BrowseChildren RPC; Children populated; IsExpanded flips |
| 3 | `Expand_CalledTwice_NoSecondRpc` | idempotency — fake records RPC count == 1 |
| 4 | `Expand_UnknownParent_ThrowsGalaxyNotFound` | server NotFound surfaces as language-typed error |
| 5 | `Expand_MultiPageSiblings_GathersAllPages` | fake returns NextPageToken on first call; helper walks pages until empty; flat Children list |
| 6 | `Browse_WithFilter_ForwardsToRequest` | options propagate into the wire request (`tag_name_glob` etc.) |

No new live-only tests in this batch. The existing
`BrowseChildrenSmokeTests` in .NET covers wire compatibility.

## Java toolchain bootstrap

The macOS dev host lacks a JVM. Install via Homebrew (one-time):

```bash
brew install temurin@21
brew install gradle
```

Verify:
```bash
java -version       # expect 21.x
gradle --version    # expect 8.x or 9.x
```

If the Temurin formula does not auto-link `JAVA_HOME`, add to shell init:
```bash
export JAVA_HOME="$(/usr/libexec/java_home -v 21)"
```

Then verify the existing Java client builds against its committed
generated tree:
```bash
cd clients/java
gradle build -x test
```

If build succeeds, regenerate protos to pick up `BrowseChildren`:
```bash
gradle generateProto
```

This produces the new Java RPC stubs that the walker work depends on.

**Failure path:** if Homebrew installs but `gradle build` fails for an
environmental reason (e.g., proto plugin version mismatch), fall back to
"defer Java" — implement the other four clients and document that Java
walker work waits for the Windows host. Do not spend more than ~30
minutes debugging local Java issues; the Windows host already builds the
Java client cleanly.

## Documentation updates

Each client's `README.md` "Browsing lazily" snippet (added in commit
`0d6193c`) gets one short example block showing the high-level walker
in addition to the existing raw-RPC snippet. Approximately three
sentences plus a 5-line code block per language.

No changes to `gateway.md`, `docs/GalaxyRepository.md`, or
`docs/DesignDecisions.md` — those describe the wire contract; the
walkers are client-side ergonomics, not part of the wire surface.

## Non-goals

- Async iterator / streaming walker (rejected in brainstorming —
  encourages eager-to-completion consumption that defeats laziness).
- Explicit `RefreshAsync` on `LazyBrowseNode` (single-shot expand is
  enough; caller invalidates the tree by re-calling `BrowseAsync`).
- Tree-builder helpers that pre-fetch the whole hierarchy (that's just
  `DiscoverHierarchy` with extra round-trips).
- Server changes — the wire contract is final.
- Cross-client integration test runner — each client tests in isolation.
- Java regen on Mac if Homebrew install fails — defer to Windows host.
