# Phase 4b — Mac-verifiable driver gaps (design)

**Status:** approved 2026-06-16. Branch `feat/stillpending-phase-4b-driver-gaps` off master `c081917a`.

**Goal:** Close three independent, contract-free `stillpending.md` §2 driver gaps that are
shippable on their own and (for the first two) live-verifiable from this Mac:

1. **Modbus driver-type-string reconcile** — `"ModbusTcp"` (AdminUI/probe) vs `"Modbus"`
   (runtime factory + seed) → rig Modbus drivers fall to the raw-JSON editor; AdminUI-created
   Modbus drivers store an unloadable type string.
2. **Galaxy nested gobject hierarchy** — `GalaxyDiscoverer` renders the gobject tree flat.
3. **FOCAS `cnc_getfigure` auto-scale** — axis positions need the decimal-place figure
   hand-configured; the CNC reports it via `cnc_getfigure`.

The three touch disjoint projects (AdminUI + Modbus driver / Galaxy driver / FOCAS driver),
so implementers can run concurrently.

**Hard constraints (every task):** stage by explicit path, never `git add .`; never stage
`sql_login.txt` / `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/` / `pending.md` / `current.md` /
`docker-dev/docker-compose.yml` / `stillpending.md`; no force-push / no `--no-verify`;
**NO EF migration; NO Commons wire/proto contract change; NO bUnit** (Razor proven by live
`/run`). The `IFocasClient` addition is a driver-internal interface, not a wire contract; the
Galaxy proto is unchanged.

---

## Component 1 — Modbus driver-type-string reconcile

### Problem
The Modbus driver-type string is inconsistent:
- **Runtime canonical = `"Modbus"`** — `ModbusDriver.DriverType` (`ModbusDriver.cs:185`) and the
  factory registration key `ModbusDriverFactoryExtensions.DriverTypeName` (`:16`).
- **AdminUI = `"ModbusTcp"`** — `DriverIdentitySection.razor`, `DriverTypePicker.razor`,
  `DriverEditRouter.razor`, `ModbusDriverPage.razor`, `Uns/TagEditors/TagConfigEditorMap.cs`,
  `Uns/TagEditors/TagConfigValidator.cs`, + ~13 AdminUI.Tests rows.
- **Probe = `"ModbusTcp"`** — `ModbusDriverProbe.cs:29` (a third spelling).

Effect: the rig's `MAIN-modbus-eq` is seeded `DriverType="Modbus"`, so the `/uns` Tag modal's
`TagConfigEditorMap.Resolve` (keyed on `"ModbusTcp"`) misses → raw-JSON editor (and Phase 6's
Build-address button is unreachable). An AdminUI-created Modbus driver stores `"ModbusTcp"`,
which the runtime factory (`"Modbus"`) cannot instantiate.

### Approach (chosen): canonicalize on `"Modbus"`
`"Modbus"` is the runtime factory registration key — the hard runtime dependency. Canonicalizing
on it means **never touching the factory key or `ModbusDriver.DriverType`** and **no data
migration** (the dev-rig seed is already `"Modbus"`).

Changes — change the 7 outliers + tests to `"Modbus"`:
- `ModbusDriverProbe.cs:29` `DriverType => "ModbusTcp"` → `"Modbus"`.
- The 6 AdminUI files above: every `"ModbusTcp"` stored/dispatched value → `"Modbus"`.
- ~13 AdminUI.Tests rows seeding `DriverType="ModbusTcp"` → `"Modbus"`.

A friendly **display label** ("Modbus TCP") stays in the picker UI; only the *stored/dispatched
value* canonicalizes. Any legacy `"ModbusTcp"` `DriverInstance.DriverType` row (none on the rig)
is a documented one-line SQL `UPDATE` — not a migration.

*Rejected:* canonicalize on `"ModbusTcp"` (forces a runtime-factory-key change + seed + DB
migration — touches the load-bearing dependency); dual-alias registration (YAGNI machinery).

### Tests
Unit: `TagConfigEditorMap.Resolve("Modbus")` returns the Modbus editor; `TagConfigValidator`
validates `"Modbus"`; the picker/router dispatch on `"Modbus"`. Update the ~13 AdminUI.Tests rows.
**Live `/run`:** the rig's seeded `MAIN-modbus-eq` now opens the typed Modbus editor + Build-address
button in the `/uns` Tag modal (the exact thing Phase 6 couldn't drive).

---

## Component 2 — Galaxy nested gobject hierarchy

### Problem
`GalaxyDiscoverer.DiscoverAsync` (`Driver.Galaxy/Browse/GalaxyDiscoverer.cs`) calls
`builder.Folder(...)` at root level for every gobject, then adds the gobject's variables into it.
Large Galaxy models browse as a flat list.

### Approach (chosen): nest by `parent_gobject_id` (driver-internal, no gateway change)
The gateway proto `GalaxyObject` already carries the parentage we need:
`gobject_id` (1), `parent_gobject_id` (5), `is_area` (6), `hosted_by_gobject_id` (8).
`IAddressSpaceBuilder.Folder(...)` **returns `IAddressSpaceBuilder`**, so folders already nest
(`parentFolder.Folder(child, child)`) — no contract change.

Rewrite `DiscoverAsync` as a two-pass build:
1. **Pass 1 — folder map:** create a `gobject_id → folder-builder` entry for every gobject
   (browse name = `contained_name` ?? `tag_name`, as today). Don't attach to a parent yet.
2. **Pass 2 — link + populate:** for each gobject, resolve its folder under its parent's folder
   when `parent_gobject_id != 0` **and** that parent id is in the map; otherwise attach to the
   driver root (the current flat behaviour). Then add the gobject's variables into its own folder
   (unchanged per-attribute logic, incl. alarm-ref marking and the `[]`-suffix strip).

Properties:
- **Order-independent** — children may appear before parents (build the map first, link second).
- **Degrades to flat** — gateways that don't populate `parent_gobject_id` (returns 0) or models
  with the parent outside the returned set fall back to root attachment. No regression for the
  current flat data.
- Nest by the **model/area** relationship (`parent_gobject_id`), not deployment hosting
  (`hosted_by_gobject_id`) — that is AVEVA's natural browse tree.
- Cycle/self-parent guard: if `parent_gobject_id == gobject_id` or a cycle is detected, attach to
  root (defensive; real Galaxy models are a DAG/tree).

### Tests
Unit (Galaxy driver test project) — fake `IGalaxyHierarchySource` returns canned multi-level
`GalaxyObject` sets through a capturing `IAddressSpaceBuilder`:
- two-level nest (child folder under parent folder, variables in the right folder);
- **order-independence** (child returned before parent → still nests);
- **degrade-to-flat** (`parent_gobject_id=0`, and parent-id-not-in-set → both attach to root);
- defensive self-parent/cycle → root.
**Live `/run`:** Client.CLI browse the Galaxy driver tree vs the gateway on `10.100.0.48` →
nested folders instead of flat.

---

## Component 3 — FOCAS `cnc_getfigure` auto-scale

### Problem
`FocasDriver.PublishAxisSnapshot` divides axis positions by `10^PositionDecimalPlaces`
(`FocasDriver.cs:823-829`), where `PositionDecimalPlaces` is a **manual** per-device config knob
(default 0, added in Phase 4). The CNC reports the real figure via `cnc_getfigure`
(`IFocasClient.cs:219-221` notes the auto path "once that export lands").

### Approach (chosen): add a `cnc_getfigure` binding; auto wins, manual is the fallback
- Add a binding to `IFocasClient` (e.g. `Task<IReadOnlyList<int>> GetPositionFiguresAsync(CancellationToken)`
  returning the per-axis decimal-place figure). The **Wire** client P/Invokes `cnc_getfigure`;
  the **Fake** and **Unimplemented** clients return an empty/unsupported result.
- At init (alongside the existing axis-name/sysinfo reads), fetch the per-axis figures and cache
  them on the driver state.
- **Precedence (approved): auto wins; manual is the fallback.** When `cnc_getfigure` returns a
  usable figure for an axis, that figure is authoritative for that axis's scale. When it returns
  nothing / is unavailable (older FWLIB, Fake/Unimplemented backend, per-axis gap), fall back to
  the configured `PositionDecimalPlaces`. This keeps existing manual-knob configs working when the
  CNC doesn't report and matches the "auto-scale" intent (the CNC's own scaling is the truth).
- Resolve the effective decimal places **per axis** (the figure can differ by axis), so
  `PublishAxisSnapshot` divides each axis by its own `10^figure`.

*Rejected:* drop the manual knob entirely (loses the fallback for backends/sims that can't report);
leave manual-only (that's the current gap).

### Tests
Unit (FOCAS driver test project) with a fake `IFocasClient`:
- auto wins — fake returns figures → driver scales by the auto figure, ignoring a differing manual
  config;
- fallback — fake returns nothing/unsupported → driver uses the manual `PositionDecimalPlaces`;
- per-axis — different figures per axis scale independently;
- regression — the existing manual-only path (no getfigure) still divides correctly.
**Unit-proven only** (no CNC on this Mac — honestly operator-gated for a live read).

---

## Out of scope (deferred)
- S7 wide types / Timer-Counter, the cross-driver **array slice** (sink-contract change),
  AbCip/TwinCAT UDT member-paths, AbLegacy/TwinCAT bit-RMW writes, OpcUaClient `ReadEventsAsync`,
  Historian tie-cluster paging (#400). These remain the next slices of Phase 4b's theme.

## Done criteria
- `dotnet build ZB.MOM.WW.OtOpcUa.slnx` clean.
- Affected suites green: AdminUI.Tests (Modbus reconcile), Galaxy driver tests (nesting),
  FOCAS driver tests (auto-scale).
- **Live `/run`:** Modbus typed editor + Build-address reachable on the rig's seeded driver;
  Galaxy tree browses nested vs the gateway. FOCAS unit-proven (operator-gated live).
- Subagent-driven, per-task review by classification, final integration review, then merge + push.
