Files
mxaccessgw/clients/go/README.md
Joseph Doherty bde042b4d4 feat(clients): CLI-04 typed single-item command parity (+CLI-30 unregister) — 4/5 clients
Every parity-critical single-item MXAccess command now has a typed session
helper instead of only a raw-Invoke escape hatch. Added per client:
- Phase 1: AdviseSupervisory, WriteSecured, WriteSecured2, AuthenticateUser,
  ArchestrAUserToId
- Phase 2: AddBufferedItem, SetBufferedUpdateInterval, Suspend, Activate
- CLI-30: Unregister (Rust + .NET; Go/Python already had it)

Each wraps the existing raw-command machinery (no new wire surface) and runs the
same MXAccess-level reply validation (hresult < 0 + MxStatusProxy). MXAccess
parity preserved: WriteSecured before AuthenticateUser+AdviseSupervisory surfaces
the native failure unchanged (not pre-validated/reordered). Credentials
(AuthenticateUser password, WriteSecured payloads) route through each client's
secret-redaction seam and never reach logs/exceptions/ToString/Debug/Display;
each suite asserts a distinctive credential is absent from surfaced errors. New
CLI subcommands source credentials via flag/env, never echoed.

- .NET: 21 helpers (validated + Raw), CLI subcommands, multi-secret CLI redactor.
  Build clean (0 warn), 102 passed.
- Go: 9 helpers + *Raw variants, redactSecrets seam, promoted CLI advise-supervisory
  to typed. gofmt/vet/build/test clean.
- Rust: 10 helpers incl. unregister; verified ensure_mxaccess_success runs on
  secured paths; error.rs credential scrub. fmt/check/test/clippy clean.
- Python: 9 async helpers, redact_secret seam + _invoke_redacted, CLI commands.
  145 passed.
- Shared doc: ClientLibrariesDesign "Typed Command Parity" section.

Java client typed parity is batched to windev (no local JRE); CLI-04 + CLI-30
stay open until it lands.

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 16:41:43 -04:00

20 KiB

Go Client

The Go client module contains the generated MXAccess Gateway protobuf bindings, a small handwritten mxgateway package, and the mxgw-go test CLI scaffold. The module uses the shared proto inputs documented in ../../docs/ClientProtoGeneration.md so gateway and client contracts stay in sync.

Layout

clients/go/
  go.mod
  generate-proto.ps1
  internal/generated/
  mxgateway/
  cmd/mxgw-go/

internal/generated contains code produced by protoc, protoc-gen-go, and protoc-gen-go-grpc. Do not edit generated files by hand.

Regenerating Protobuf Bindings

Run generation after the shared .proto files or the Go output path changes:

./generate-proto.ps1

The script uses the tool paths recorded in ../../docs/ToolchainLinks.md.

Build And Test

Run the Go module checks from clients/go:

go test ./...
go build ./...
go vet ./...

The tests parse the shared JSON fixtures, exercise value and status conversion, use bufconn for fake gateway auth and streaming behavior, and cover CLI JSON redaction.

Packaging

Build a local CLI executable from clients/go:

New-Item -ItemType Directory -Force ../../artifacts/clients/go | Out-Null
go build -o ../../artifacts/clients/go/mxgw-go.exe ./cmd/mxgw-go

Install the CLI into the active GOBIN or GOPATH/bin:

go install ./cmd/mxgw-go

Other Go modules can consume the library package with the module path gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway.

Client API

Use mxgateway.Dial with mxgateway.Options to configure plaintext or TLS transport, API-key metadata, dial timeout, and per-call timeout:

client, err := mxgateway.Dial(ctx, mxgateway.Options{
    Endpoint:  "localhost:5000",
    APIKey:    os.Getenv("MXGATEWAY_API_KEY"),
    Plaintext: true,
})

The gateway can auto-generate its own self-signed certificate (it has no PKI), so the client is lenient by default: a TLS connection (Plaintext: false) with no CACertFile/TLSConfig accepts whatever certificate the gateway presents (InsecureSkipVerify, with ServerNameOverride as the SNI when set). To verify instead, set CACertFile to pin a CA, or set RequireCertificateValidation: true to verify against the OS/system trust roots without pinning. See Gateway Configuration.

Client.OpenSession returns a Session with helpers for Register, AddItem, AddItem2, Advise, AdviseSupervisory, Write, WriteSecured, WriteSecured2, AuthenticateUser, ArchestrAUserToId, AddBufferedItem, SetBufferedUpdateInterval, Suspend, Activate, Events, and Close. Prefer SubscribeEvents or SubscribeEventsAfter for long-running streams because the returned subscription owns cancellation and exposes Close for deterministic goroutine cleanup. Raw protobuf messages remain available through the mxgateway package aliases and the Raw helper methods. Typed errors support errors.As for GatewayError, CommandError, and MxAccessError; command errors preserve the raw reply.

Reconnect-replay gap

Each EventResult carries exactly one of Event, ReplayGap, or Err. When you resume a stream with EventsAfter/SubscribeEventsAfter and a non-zero afterWorkerSequence, the gateway replays buffered events from that point. If the requested sequence predates the oldest event still retained in its replay ring, it delivers a single replay-gap sentinel at the head of the resumed stream: res.ReplayGap is non-nil (res.Event is nil, res.IsReplayGap() is true) and normal events follow it.

A gap means events were lost, so any locally cached tag/alarm state is now stale. On seeing it, discard your cached state and re-snapshot. To resume without provoking another gap, reconnect from just before the oldest retained sequence:

for res := range events {
    switch {
    case res.Err != nil:
        // terminal: stream ended (see ErrSlowConsumer for the overflow case)
        return res.Err
    case res.IsReplayGap():
        gap := res.ReplayGap
        log.Printf("replay gap: requested after %d, oldest available %d; re-snapshotting",
            gap.GetRequestedAfterSequence(), gap.GetOldestAvailableSequence())
        resnapshot()
        // to resume cleanly, reconnect with:
        //   session.EventsAfter(ctx, gap.GetOldestAvailableSequence()-1)
    default:
        handle(res.Event)
    }
}

The gateway sets ReplayGap only on StreamEvents results (never on a fresh, non-resumed stream and never on DrainEvents). The client makes the gateway's sentinel typed and observable; it never synthesizes or swallows it.

For alarms, the package exposes Client.QueryActiveAlarms for one-shot snapshots, Client.StreamAlarms for the server-streaming feed, and Client.AcknowledgeAlarm to ack an alarm by full reference. The streaming call returns a StreamAlarmsClient; cancel its context to terminate the stream. All three pass straight through to the gateway's central alarm monitor.

Write Semantics And Common Pitfalls

These are MXAccess parity behaviors that surprise new callers. The gateway forwards them unchanged — it does not paper over them.

Attributing a write to a user without AuthenticateUser

MXAccess only stamps a plain Write/Write2 with a Galaxy user id when the item carries an active supervisory advise. If you are not using the verified/secured path (AuthenticateUserWriteSecured/WriteSecured2) but still need the write attributed to a user id, you must first advise the item supervisory and then pass that user id on the write. Without the supervisory advise the userID on a plain write is ignored.

The session exposes a typed AdviseSupervisory helper alongside Advise/UnAdvise:

err := session.AdviseSupervisory(ctx, serverHandle, itemHandle)
// ...
err = session.Write(ctx, serverHandle, itemHandle, value, userID)

The CLI exposes the same command as advise-supervisory, and write takes -user-id.

Secured writes and user authentication

The verified/secured path has typed single-item helpers too: AuthenticateUser(ctx, serverHandle, verifyUser, verifyUserPassword) returns the resolved MXAccess user id, and WriteSecured / WriteSecured2 issue the secured write. Credentials passed to AuthenticateUser, and the string content of a WriteSecured/WriteSecured2 value, are kept out of any error the client surfaces (they route through the same redaction seam as the API key) and are never logged — callers must likewise keep them out of their own logs. MXAccess parity holds: a WriteSecured issued without a matching prior AuthenticateUser and supervisory advise fails natively, and that failure is surfaced unchanged rather than pre-empted. The CLI exposes authenticate-user (credential via -password-env, default MXGATEWAY_VERIFY_PASSWORD, or -password) and write-secured.

Array writes replace the whole array

A write to an array attribute replaces the entire array; it is not an element-wise patch. To change a subset of elements, send the full array with the unchanged elements included. For example, to change 2 elements of a 20-element array, build the MxValue from all 20 values (the 18 unchanged plus the 2 new ones). Sending only the 2 changed values overwrites the attribute with a 2-element array.

Session.WriteArrayElements offers a default-fill shorthand: pass only the indices you want to set along with a totalLength. The gateway expands the sparse representation into a full array before forwarding to MXAccess — every unmentioned index receives the element type's zero value (boolean false, integer 0, float 0.0, string "", time = Unix epoch). This is a RESET of unmentioned indices, not a preserve of existing values. Use the full-array form (read-modify-write) when existing element values must be preserved.

// Set element [3] of a 10-element float array; all other indices reset to 0.0.
err = session.WriteArrayElements(
    ctx,
    serverHandle, itemHandle,
    mxgateway.DataTypeFloat,
    10, // totalLength
    map[uint32]*mxgateway.MxValue{
        3: mxgateway.FloatValue(1.5),
    },
    userID,
)

AddItem, AddItem2, AddItemBulk, and AddBufferedItem auto-normalize a bare array attribute name to the [] array address form expected by MXAccess, so callers do not need to append [] themselves. Both forms are accepted; duplicates are deduplicated by the gateway.

Galaxy Repository browse

The GalaxyRepository service (proto package galaxy_repository.v1) is a read-only metadata-only browse over the AVEVA System Platform Galaxy Repository. It uses the same API-key authentication as the MXAccess Gateway and requires the metadata:read scope. Use mxgateway.DialGalaxy to obtain a *GalaxyClient that mirrors the connection-management conventions of Client:

galaxy, err := mxgateway.DialGalaxy(ctx, mxgateway.Options{
    Endpoint:  "localhost:5000",
    APIKey:    os.Getenv("MXGATEWAY_API_KEY"),
    Plaintext: true,
})
if err != nil {
    return err
}
defer galaxy.Close()

ok, err := galaxy.TestConnection(ctx)
deployTime, present, err := galaxy.GetLastDeployTime(ctx)
objects, err := galaxy.DiscoverHierarchy(ctx)

GetLastDeployTime returns (time.Time{}, false, nil) when the server reports present=false (no deploy recorded). DiscoverHierarchy returns the generated *GalaxyObject slice with each object's dynamic attributes populated for direct contract access.

Browsing lazily

For UI trees or OPC UA bridges, use BrowseChildren to walk one level at a time instead of loading the full hierarchy. Pass an empty request for root objects; subsequent calls set ParentGobjectId, ParentTagName, or ParentContainedPath. Filter fields match DiscoverHierarchy. Each response pairs Children with ChildHasChildren so you know which nodes to expand. See Galaxy Repository for full request and filter semantics.

import pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated/galaxy_repository/v1"

reply, err := galaxy.BrowseChildren(ctx, &pb.BrowseChildrenRequest{})
if err != nil {
    return err
}
for i, child := range reply.GetChildren() {
    fmt.Printf("%s expand=%v\n", child.GetTagName(), reply.GetChildHasChildren()[i])
}

High-level walker

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

galaxy, err := mxgateway.DialGalaxy(ctx, mxgateway.Options{
    Endpoint:  "localhost:5000",
    APIKey:    os.Getenv("MXGATEWAY_API_KEY"),
    Plaintext: true,
})
if err != nil {
    log.Fatal(err)
}
defer galaxy.Close()

roots, err := galaxy.Browse(ctx, nil)
if err != nil {
    log.Fatal(err)
}
for _, root := range roots {
    if root.HasChildrenHint() {
        if err := root.Expand(ctx); err != nil {
            log.Fatal(err)
        }
    }
    for _, child := range root.Children() {
        kind := "leaf"
        if child.HasChildrenHint() {
            kind = "has children"
        }
        fmt.Printf("%s (%s)\n", child.Object().GetTagName(), kind)
    }
}

Expand is idempotent — calling it twice fires only one RPC, and is safe under concurrent callers. To refresh after a Galaxy redeploy, call Browse again from the root.

Watching deploy events

WatchDeployEvents opens a server-streaming subscription. The server emits a bootstrap event with the current Galaxy state immediately on subscribe, then one DeployEvent per new deploy. Sequence is monotonic per server start; gaps signal dropped events. Pass a non-nil lastSeenDeployTime to suppress the bootstrap event when resuming from a known checkpoint:

streamCtx, cancel := context.WithCancel(ctx)
defer cancel()

events, errs, err := galaxy.WatchDeployEvents(streamCtx, nil)
if err != nil {
    return err
}

for {
    select {
    case ev, ok := <-events:
        if !ok {
            return nil // stream completed (server EOF or ctx cancelled)
        }
        log.Printf("seq=%d objects=%d attrs=%d",
            ev.GetSequence(), ev.GetObjectCount(), ev.GetAttributeCount())
    case streamErr := <-errs:
        if streamErr != nil {
            return streamErr // *GatewayError
        }
    case <-ctx.Done():
        return ctx.Err()
    }
}

Cancel the supplied context to tear down the stream cleanly. Both channels close after EOF, cancellation, or a terminal error; surfaced errors are wrapped in *GatewayError.

The CLI exposes the same RPC via galaxy-watch:

go run ./cmd/mxgw-go galaxy-watch -plaintext
go run ./cmd/mxgw-go galaxy-watch -plaintext -json
go run ./cmd/mxgw-go galaxy-watch -plaintext -last-seen-deploy-time 2026-04-28T10:00:00Z
go run ./cmd/mxgw-go galaxy-watch -plaintext -limit 5

The command runs until Ctrl+C (or the optional -limit is reached) and prints one line per event in text mode or one JSON object per event with -json.

CLI

The mxgw-go CLI emits JSON with redacted API keys for commands that connect to the gateway:

Subcommand reference

Every subcommand wired into the CLI. All accept the common flags (-endpoint, -plaintext, -api-key / -api-key-env, -ca-cert, -server-name-override, -call-timeout) and most accept -json.

Command Purpose
version Print client/contract versions.
open-session Open a gateway session and print its id.
close-session Close a session by id.
ping Round-trip a PING command (-session-id, -message).
register Register a client name on a session (-session-id, -client-name).
add-item Add an item handle (-session-id, -server-handle, -item).
advise Advise (subscribe) one item (-session-id, -server-handle, -item-handle).
advise-supervisory Advise one item supervisory — required before a user-id-attributed plain write.
subscribe-bulk Advise many items in one call.
unsubscribe-bulk Unadvise many item handles in one call.
read-bulk Read snapshots for many item handles in one call.
write Write one value (-type, -value).
write-secured Secured single-item write (-current-user-id, -verifier-user-id, -type, -value).
authenticate-user Authenticate a user, printing the resolved user id (-verify-user, -password-env/-password).
write-bulk Write many values (-item-handles, -values, counts must match).
write2-bulk write-bulk with a shared -timestamp-value (RFC 3339).
write-secured-bulk Secured bulk write (-current-user-id, -verifier-user-id).
write-secured2-bulk Secured bulk write with a shared timestamp.
bench-read-bulk Throughput benchmark (-duration-seconds, -warmup-seconds, -bulk-size).
stream-events Stream item-value events for a session (-session-id, -limit).
stream-alarms Stream the alarm feed (-filter-prefix, -limit).
acknowledge-alarm Acknowledge an alarm reference.
smoke End-to-end smoke workflow against one item.
galaxy-test-connection Probe the Galaxy Repository RPC connection.
galaxy-last-deploy Print the most recent deploy event.
galaxy-discover Discover deployed objects.
galaxy-watch Stream deploy events until Ctrl+C or -limit.
galaxy-browse Lazy/eager browse of the Galaxy object tree.
batch Read commands from stdin (see below).
go run ./cmd/mxgw-go version -json
go run ./cmd/mxgw-go open-session -endpoint localhost:5000 -plaintext -json
go run ./cmd/mxgw-go ping -session-id <id> -plaintext -json
go run ./cmd/mxgw-go register -session-id <id> -client-name mxgw-go -plaintext -json
go run ./cmd/mxgw-go add-item -session-id <id> -server-handle 1 -item Area001.Tag.Value -plaintext -json
go run ./cmd/mxgw-go advise -session-id <id> -server-handle 1 -item-handle 1 -plaintext -json
go run ./cmd/mxgw-go write -session-id <id> -server-handle 1 -item-handle 1 -type int32 -value 123 -plaintext -json
go run ./cmd/mxgw-go write-bulk -session-id <id> -server-handle 1 -item-handles 1,2 -values 10,20 -type int32 -plaintext -json
go run ./cmd/mxgw-go read-bulk -session-id <id> -item-handles 1,2 -plaintext -json
go run ./cmd/mxgw-go stream-events -session-id <id> -plaintext -json
go run ./cmd/mxgw-go stream-alarms -plaintext -json
go run ./cmd/mxgw-go smoke -item Area001.Tag.Value -plaintext -json
go run ./cmd/mxgw-go galaxy-test-connection -plaintext -json
go run ./cmd/mxgw-go galaxy-last-deploy -plaintext -json
go run ./cmd/mxgw-go galaxy-discover -plaintext -json
go run ./cmd/mxgw-go galaxy-watch -plaintext -json
go run ./cmd/mxgw-go galaxy-browse -plaintext -json

Use -api-key-env MXGATEWAY_API_KEY or -api-key <key> when authentication is enabled. CLI output redacts the key value and never writes the raw secret.

batch mode

batch reads one command line at a time from stdin and dispatches each through the same routing as the standalone subcommands; it is the interface the cross-language E2E harness drives. After every command's output it writes the end-of-result sentinel line __MXGW_BATCH_EOR__ to stdout and flushes, so the harness can frame each result. Blank/whitespace-only lines are skipped; only stdin EOF ends the session. Command errors are serialised as a JSON object ({"error":...,"type":"error"}) to stdout (not stderr) and still followed by the sentinel, so a failing command does not abort the batch. The input scanner buffer is widened to 16 MiB so a single long command line (e.g. a bulk write with thousands of handles) does not trip bufio's default 64 KiB token-too-long limit; a line that still exceeds 16 MiB surfaces as a framed error and ends the session.

Use TLS options for a secured gateway:

go run ./cmd/mxgw-go smoke -endpoint mxgateway.example.local:5001 -ca-cert C:\certs\mxgateway-ca.pem -server-name-override mxgateway.example.local -api-key-env MXGATEWAY_API_KEY -item Area001.Tag.Value -json

Integration Checks

Run live checks only when a gateway and MXAccess-backed worker are available:

$env:MXGATEWAY_INTEGRATION = '1'
$env:MXGATEWAY_ENDPOINT = 'localhost:5000'
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
$env:MXGATEWAY_TEST_ITEM = 'Area001.Tag.Value'
go run ./cmd/mxgw-go smoke -endpoint $env:MXGATEWAY_ENDPOINT -plaintext -api-key-env MXGATEWAY_API_KEY -item $env:MXGATEWAY_TEST_ITEM -json

Installing the Go client

The module is resolved directly from the git repo — no package registry:

go get gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go@v0.1.1

Then import:

import "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"

If your build environment cannot reach gitea.dohertylan.com directly, configure GOPROXY to point at an internal proxy that fronts the Gitea repo, or set GOPRIVATE=gitea.dohertylan.com/* to fetch the module straight from the VCS — this both bypasses the public module proxy and disables checksum-database (sum.golang.org) verification for that path. Add GOINSECURE=gitea.dohertylan.com/* if the host serves the module over plain HTTP rather than HTTPS.

Releasing a new version

Go modules in monorepo subdirectories use prefixed tags. To tag a release from this repo:

pwsh scripts/tag-go-module.ps1 -Version v0.1.1 -Push

The script validates semver, refuses to tag with uncommitted tracked changes, creates an annotated tag clients/go/v0.1.1, and (with -Push) pushes it to origin.