Files
lmxopcua/docs/plans/2026-07-15-universal-discovery-browser-design.md
T
Joseph Doherty fd0bec4ffe
v2-ci / build (push) Successful in 3m30s
v2-ci / unit-tests (push) Failing after 8m53s
docs(driver-expansion): resolve all open design concerns from the review pass
Every concern the 7-agent review parked is now decided and integrated:

- universal browser: in-flight capture coalescing keyed (driverType,
  config-hash) + global cap of 4 concurrent captures; CanBrowse catches a
  throwing TryCreate and defensively shuts down the throwaway instance;
  cleanup ShutdownAsync bounded at 10s (R2-01); picker Refresh = close +
  re-capture. Coalesced sessions share an immutable tree, so DisposeAsync
  drops the session's reference, not the tree.
- mtconnect: UNAVAILABLE pinned to BadNoCommunication (fleet's
  BadCommunicationError stays reserved for the driver's own transport
  failures); TIME_SERIES sampleCount flows into ArrayDim; Subscribe timeout
  bounds only the stream-start handshake; library version/TFM folded into
  the license checklist.
- mqtt: browse rebirth is an explicit DriverOperator-gated "Request rebirth"
  button (RequestRebirthAsync(scope)) - never fired by open/root/expand;
  hand-rolled reconnect loop committed for P1 (MQTTnet v5 dropped
  ManagedMqttClient); Wave-2-start library re-verification checkbox;
  implement from the Sparkplug v3.0 spec text.
- bacnet: P1 opens with a first-spike checklist (package TFM, BBMD/
  segmented-RPM/COV API smoke, unicast-I-Am fixture behavior); AdminUI-node
  browse registers a second foreign device - BBMD FD-table sizing note.
- sql-poll: split-node browse needs env parity for Sql__ConnectionStrings__
  refs on admin nodes (actionable error + session-only pasted literal);
  one-row-per-key query contract (last-wins + rate-limited warning);
  absent key -> BadNoData; operationTimeout > commandTimeout authoring rule;
  Browser->Driver.Sql SqlClient transitive on AdminUI accepted on record.
- omron: FINS framer gated on W227 + live golden vectors; CIP string layout
  is the first P1 live-gate item; AbCip harness static-init anti-pattern
  note; writable-defaults-true operator warning.
- modbus-rtu: first P1 step confirms pymodbus exposes framer=rtu on a TCP
  server (custom-script fallback otherwise).
- program doc: new cross-cutting rule - driver ctors must be connection-free
  (the universal browser's CanBrowse throwaway instances depend on it).
2026-07-15 16:54:09 -04:00

25 KiB
Raw Blame History

Universal Discover-backed browser (DiscoveryDriverBrowser) — design

Status: draft, 2026-07-15. Foundational Wave-0 item of the next-driver roadmap (docs/research/drivers/README.md).

1. Motivation

Today the AdminUI address picker is backed by one bespoke IDriverBrowser per driver type — only OpcUaClient and Galaxy exist, each a full hand-written IBrowseSession (connect + lazy level-at-a-time browse). Every other driver's picker is a static manual-entry stub.

But the existing-driver browse audit (docs/research/drivers/00-existing-driver-browse-audit.md) found that AbCip, TwinCAT, and FOCAS already contain complete, unit-tested symbol enumeration — it lives in their runtime ITagDiscovery.DiscoverAsync, streamed into an IAddressSpaceBuilder, and is simply never surfaced to the picker. The same is true of every future discovery-capable driver (MTConnect /probe, BACnet Who-Is/object-list).

The insight: DiscoverAsync already produces exactly the tree the picker wants. Instead of writing N bespoke browsers, write one generic browser that captures whatever a driver streams into the builder and re-presents it. This is the highest-leverage single piece of browse work on the roadmap.

2. The seam that makes it work

ITagDiscovery.DiscoverAsync(IAddressSpaceBuilder builder, ct) does not return a tree — it streams structured nodes into a builder (Core.Abstractions/IAddressSpaceBuilder.cs):

IAddressSpaceBuilder Folder(string browseName, string displayName);       // → child builder
IVariableHandle      Variable(string browseName, string displayName, DriverAttributeInfo a);
void                 AddProperty(string browseName, DriverDataType t, object? v);

Two facts close the loop:

  • Folder(...) returns a child builder scoped inside the folder → the driver's hierarchy is expressed as the builder-nesting graph. Capture the nesting and you inherit the driver's tree (folders, UDT structure, component nesting) — this is not a flat dump.
  • DriverAttributeInfo.FullName is the driver-side full reference for read/write addressing — i.e. exactly the value the picker commits as TagConfig.FullName (DriverAttributeInfo.cs:13). No per-driver commit logic needed.

So a capturing IAddressSpaceBuilder run against any driver's DiscoverAsync yields a BrowseNode tree whose leaves already carry the commit value and the datatype metadata.

3. Architecture — two-tier browse

BrowserSessionService.OpenAsync(driverType, configJson)
        │
        ├─ bespoke IDriverBrowser registered for driverType?  ── yes ──►  use it   (Tier 2)
        │        (OpcUaClient, Galaxy, later: large ControlLogix/BACnet)
        │
        └─ no bespoke browser ───►  DiscoveryDriverBrowser  (Tier 1, universal fallback)
                                     gated on online-discovery capability
  • Tier 1 — universal DiscoveryDriverBrowser (this doc): the default. Covers any driver whose DiscoverAsync enumerates from the device.
  • Tier 2 — bespoke IBrowseSession (existing pattern, 2026-05-28-driver-browsers-design.md): reserved for large / structured / lazy address spaces where eager one-shot discovery is too heavy. A bespoke browser overrides the universal one simply by being registered for that DriverType.

3.1 Resolution change in BrowserSessionService (required)

BrowserSessionService currently indexes browsers with browsers.ToDictionary(b => b.DriverType)one browser per type, and a duplicate DriverType key throws at construction. Therefore the universal browser cannot be one of the injected IEnumerable<IDriverBrowser> (it has no single DriverType, and registering it under every type would collide). Instead:

  • Introduce IUniversalDriverBrowser with Task<IBrowseSession> OpenAsync(string driverType, string configJson, CancellationToken ct) and bool CanBrowse(string driverType, string configJson).
  • Inject it into BrowserSessionService as a separate optional dependency (not part of the IDriverBrowser set). OpenAsync resolves bespoke-first, falls back to the universal browser when no bespoke browser matches and CanBrowse is true; otherwise returns the existing "no browser registered" result (→ picker shows manual entry).
  • RootAsync/ExpandAsync/AttributesAsync/CloseAsync are unchanged — the universal browser returns an ordinary IBrowseSession that lands in the same BrowseSessionRegistry (TTL reaper, 20 s per-call timeout) as today.

4. Components

4.1 CapturingAddressSpaceBuilder (the core mechanism)

An in-memory IAddressSpaceBuilder that records the streamed tree instead of materializing OPC UA nodes. New home: src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/ (Commons already owns BrowseNode/IBrowseSession and is referenced by AdminUI; IAddressSpaceBuilder/ DriverAttributeInfo live in Core.Abstractions, which Commons can reference).

Builder call Captured as Serves
Folder(bn, dn) node { id = parentPath + "/" + bn, display = dn, Folder, children[] }; returns a child capturing builder scoped to it BrowseNode(id, dn, Folder, HasChildrenHint=true)
Variable(bn, dn, attr) leaf { id = attr.FullName, display = dn, Leaf, attr }; returns a stub IVariableHandle{ FullReference = attr.FullName } BrowseNode(attr.FullName, dn, Leaf, false) + AttributeInfo
AddProperty(...) attached to the current node's property bag (optional; feeds the side-panel) AttributesAsync
IVariableHandle.MarkAsAlarmCondition(info) records IsAlarm=true on the leaf; returns a no-op IAlarmConditionSink (browse never delivers live transitions) AttributeInfo.IsAlarm=true (picker pre-fills a default alarm block, per existing Galaxy behaviour)

Leaf → AttributeInfo mapping (drives the side-panel + commit): AttributeInfo(Name: bn, DriverDataType: attr.DriverDataType.ToString(), IsArray: attr.IsArray, SecurityClass: attr.SecurityClass.ToString(), IsAlarm: attr.IsAlarm). Commit NodeId = attr.FullNameTagConfig.FullName; attr.ArrayDim/IsHistorized/WriteIdempotent are available to pre-fill the typed tag editor.

The builder enforces a node-count cap (config, default e.g. 50 000): once exceeded it stops recording and marks the tree truncated=true so the session can surface "results truncated — narrow the driver config or use manual entry" rather than OOM on a 100 k-node backend.

4.2 DiscoveryDriverBrowser : IUniversalDriverBrowser

OpenAsync(driverType, configJson, ct):

  1. driver = _driverFactory.TryCreate(driverType, ephemeralId, PatchForBrowse(driverType, configJson))null ⇒ throw a clean "driver type not available in this host" (see §6 wiring).
  2. if (driver is not ITagDiscovery disc || !disc.SupportsOnlineDiscovery) ⇒ throw "driver has no online discovery — author tags manually" (should not happen — CanBrowse already gated it, but defend).
  3. await driver.InitializeAsync(patchedConfigJson, openCts.Token)real device connect, under an open-timeout (config, default 60 s; separate from the 20 s per-call browse timeout, which only covers Root/Expand/Attributes serving from memory).
  4. var capture = new CapturingAddressSpaceBuilder(nodeCap); await disc.DiscoverAsync(capture, openCts.Token); — eager, whole-tree, one-shot. Except when disc.RediscoverPolicy == UntilStable (FOCAS): its FixedTreeCache is filled by a background loop a couple of seconds after connect, so an immediate single pass captures an empty device folder (the comment above FocasDriver.DiscoverAsync states this explicitly). For UntilStable drivers, re-run the capture on a short interval until the node set is non-empty and stable across two passes, bounded by the open-timeout — the same contract DriverInstanceActor honours at deploy.
  5. await driver.ShutdownAsync(...) in a finally — the tree is fully captured; hold nothing live. (No lazy expand ⇒ no need to keep the connection.) The shutdown itself is bounded (the R2-01 per-op-deadline rule, program doc §3.1 — no unbounded waits anywhere): it runs under its own 10 s linked CTS, because a driver wedged during discovery may be equally wedged in shutdown, and cleanup must not extend the open beyond open-timeout + that bound. On shutdown timeout: log a warning, abandon the instance (drop the reference — never block on it or rethrow), and still return / fail the open on the discovery outcome — a hung shutdown neither fails a successful capture nor masks the real discovery error.
  6. return new CapturedTreeBrowseSession(capture.Root).

Capture concurrency — coalescing + global cap

Every OpenAsync is heavy: it constructs, connects, and fully enumerates a real driver against the live device. Repeated Browse clicks (or two operators picking against the same controller) must not stack parallel symbol walks on one PLC. Two controls, both inside DiscoveryDriverBrowser:

  • In-flight coalescing, keyed on (driverType, hash of configJson) (e.g. SHA-256 of the caller-supplied JSON; PatchForBrowse is a pure function of driverType, so hashing the pre-patch config is equivalent per key). A second open for the same key awaits the same capture task rather than starting another walk; each awaiting caller then receives its own CapturedTreeBrowseSession (own token, own TTL in the BrowseSessionRegistry) over the shared, immutable captured tree. A failed capture fails every coalesced awaiter with the same error. The coalescing map holds in-flight captures only — no result caching: the entry is removed when the capture completes (success or failure), so the next open for that key (including a Refresh, §4.3) triggers a fresh capture.
  • A global cap on simultaneous capturesMaxConcurrentCaptures = 4 (a const, like BrowserSessionService.PerCallTimeout). Excess opens queue (semaphore, FIFO) and still honour the caller's cancellation token while queued. Coalesced awaiters don't consume a slot — only distinct in-flight captures count against the cap.

CanBrowse(driverType, configJson) = TryCreate (cheap, no connect) yields a non-null instance and that instance is ITagDiscovery { SupportsOnlineDiscovery: true }. Used by the AdminUI to decide whether to render the Browse button vs. manual entry, before any connect. Two hardening rules:

  • TryCreate can throw, not just return null. Factories parse configJson inside TryCreate, so malformed/half-typed JSON (exactly what a picker holds mid-authoring) can surface as an exception. CanBrowse must catch any exception → false — it is a UI affordance gate and must never throw into the page.
  • Defensive teardown of the throwaway instance. Whatever the outcome (not discovery-capable, flag false, or an exception after construction), CanBrowse best-effort ShutdownAsyncs/disposes any instance it did create — bounded, swallow-all — so probing never leaks driver instances.

Invariant the throwaway-instance pattern depends on: driver constructors are connection-free today — every driver in the tree connects in InitializeAsync, never in its ctor. CanBrowse (construct → inspect → discard, potentially on every picker render) and the capture path's construct-then-patch flow both rely on this. A future driver that opens a device connection in its constructor would silently turn CanBrowse into a connect storm — treat "no I/O in driver ctors" as a driver-authoring rule this browser now depends on (worth a line in the program doc's cross-cutting rules when P1 lands).

4.3 CapturedTreeBrowseSession : IBrowseSession

Serves entirely from the in-memory captured tree (the shape the MTConnect research report proposed for its cached-/probe session — now subsumed by this generic component): RootAsync = top-level nodes; ExpandAsync(nodeId) = the captured children of that node; AttributesAsync(nodeId) = the leaf's AttributeInfo (+ any captured properties). Token/LastUsedUtc per the interface; DisposeAsync drops the session's reference to the tree (the tree is immutable and may be shared by coalesced sessions, §4.2 — disposing one session never affects another). No I/O after open.

Point-in-time snapshot + Refresh. The captured tree is a snapshot taken at open time and never updates for the session's whole TTL — a tag added on the controller after open does not appear. The picker therefore exposes a Refresh action = close the session + re-open it (CloseAsync then OpenAsync with the same driverType/configJson): a fresh capture that flows through the same coalescing/cap path as any open. Because the coalescing map holds in-flight captures only (no result caching), Refresh always yields a new capture — or joins one already in flight, which is equally fresh. Cheap to build (the picker already has both calls); the UI should label browse results as "snapshot taken at open/refresh time".

5. The online-discovery capability gate

The distinction the gate encodes: does DiscoverAsync enumerate from the device (browsable) or merely replay pre-declared tags (flat-address drivers — Modbus/S7/MELSEC/AbLegacy/ Omron-FINS, where it would show only what you already authored)?

Add a default-interface property on ITagDiscovery (mirrors the existing RediscoverPolicy => UntilStable default member on the same interface — zero churn for drivers that don't opt in):

/// <summary>True when DiscoverAsync enumerates the tag set from the live backend
/// (browsable), rather than replaying pre-declared/authored tags. Default false.</summary>
bool SupportsOnlineDiscovery => false;
  • Return true (device-enumerated): OpcUaClient, BACnet, MTConnect, and — config-gated — AbCip/TwinCAT/FOCAS (their device enumeration sits behind a config flag; the browse patch guarantees it at open time, see §5.1).
  • Leave false (authored replay): Modbus, Modbus-RTU, S7, MELSEC, AbLegacy, Omron (CIP + FINS) — their pickers stay manual-entry (Omron gets an offline tag-export importer later); and MQTT/Sparkplug + SQL poll, whose runtime DiscoverAsync is also deliberately authored-only — they get bespoke browsers instead (observation-window / INFORMATION_SCHEMA walk — see the program doc §4), because a universal replay of authored tags would discover nothing new.

(Galaxy/OpcUaClient keep their bespoke browsers regardless — the flag only governs the universal fallback.)

5.1 Per-driver browse-config patch (PatchForBrowse) — the one wrinkle

For AbCip/TwinCAT/FOCAS, device enumeration is config-gated: AbCip/TwinCAT's DiscoverAsync only walks the controller when EnableControllerBrowse = true, and FOCAS only emits its FixedTree when FixedTree.Enabled = true (default false, FocasFixedTreeOptions.Enabled) — but the picker holds the authoring config (often false). The universal browser therefore merges a tiny, declarative per-type JSON patch before constructing the driver:

static readonly IReadOnlyDictionary<string, string> BrowsePatches = new Dictionary<string,string>(OrdinalIgnoreCase) {
    ["AbCip"]   = """{ "EnableControllerBrowse": true }""",
    ["TwinCAT"] = """{ "EnableControllerBrowse": true }""",
    ["FOCAS"]   = """{ "FixedTree": { "Enabled": true } }""",
    // OpcUaClient/BACnet/MTConnect: no patch — discovery is unconditional
};

This is a dictionary entry, not code — it keeps the "zero per-driver browser" property while being honest that browse-mode activation is one declarative fact per config-gated driver. SupportsOnlineDiscovery for AbCip/TwinCAT/FOCAS returns true because the patch guarantees browse mode at open time. (FOCAS additionally needs the §4.2 UntilStable settle — the patch turns the FixedTree on; the settle waits for its cache to fill.)

6. Wiring & deployment

  • Register in AdminUI DI (EndpointRouteBuilderExtensions.AddAdminUI): services.AddSingleton<IUniversalDriverBrowser, DiscoveryDriverBrowser>();one line, no per-driver registration (contrast the two bespoke lines at EndpointRouteBuilderExtensions.cs:49-50).
  • Depends on IDriverFactory (Core.Abstractions). In the fused Host this is the real DriverFactoryRegistryAdapter (all Driver.*.Register() ran) → every discovery-capable type is constructible. On a standalone AdminUI with no driver assemblies it's NullDriverFactoryTryCreate returns null → CanBrowse false → universal browse simply unavailable (graceful; picker falls back to manual entry). No new failure mode.
  • Network/credentials posture is unchanged from the existing browsers: OpcUaClient/Galaxy browsers already connect to live backends from the AdminUI process using credentials in configJson. The universal browser just extends that to more driver types — same role gating (picker is behind the existing ConfigEditor/browse authorization), same credentials-in-JSON handling, same BrowseSessionRegistry TTL reaping.

7. Where universal applies vs. bespoke

Driver Universal covers it? Bespoke later?
AbCip Yes (patch → EnableControllerBrowse) Only if controller symbol set too large for eager one-shot (UDT drill-down)
TwinCAT Yes (patch) Only for very large symbol sets / Flat-mode caveats
FOCAS Yes (patch → FixedTree.Enabled + §4.2 UntilStable settle) Unlikely — surface is small/curated
MTConnect Yes (/probe) Optional — /probe is already whole-model; eager is fine
BACnet Yes for small sites Yes for large multi-device sites (lazy per-device object-list)
MQTT/Sparkplug No (SupportsOnlineDiscovery=false — runtime discovery is authored-only by design) Yes — bespoke observation-window MqttBrowseSession (its design doc §4)
SQL poll No (authored replay) Yes — bespoke INFORMATION_SCHEMA schema browser (its design doc)
OpcUaClient (bespoke already) Keep bespoke (100k-node lazy browse)
Galaxy (bespoke already) Keep bespoke (two-stage attribute pick)
Modbus/RTU, S7, MELSEC, AbLegacy, Omron No (SupportsOnlineDiscovery=false) N/A — not browsable (Omron: offline importer P-later)

8. Limits (why bespoke browsers still exist)

  1. Eager, not lazy. DiscoverAsync runs the whole enumeration at open. For huge trees (ControlLogix 10 k+ tags, OPC UA 100 k nodes) the node-cap truncates and the UX is worse than a bespoke per-click ExpandAsync. Graduate those to Tier 2.
  2. Runs the real driver in-process (construct + connect + one-shot discover + bounded shutdown) — heavier than the lightweight ad-hoc bespoke session, and requires the driver assemblies in the host (fused Host: yes). The §4.2 coalescing + MaxConcurrentCaptures cap bound the aggregate load, but each individual capture is still a full device walk.
  3. No incrementally-fetched attribute panel beyond what DriverAttributeInfo + AddProperty carry (fine for the picker's needs; Galaxy's richer two-stage pick stays bespoke).

9. Testing

  • Unit — CapturingAddressSpaceBuilder: drive it with a fake ITagDiscovery that streams a known Folder/Variable/AddProperty/alarm graph; assert the BrowseNode tree, leaf NodeId == FullName, AttributeInfo mapping, node-cap truncation, and the no-op alarm sink.
  • Unit — DiscoveryDriverBrowser: fake IDriverFactory returning a fake driver; assert Initialize→Discover→Shutdown ordering (shutdown always runs, even on discovery failure), open-timeout, PatchForBrowse merge, CanBrowse gate (true for SupportsOnlineDiscovery, false otherwise / TryCreate→null), and the UntilStable settle (a fake UntilStable driver whose tree only fills on the second discovery pass must yield the full tree; a never-stable one must stop at the open-timeout).
  • Unit — capture coalescing: two concurrent OpenAsync calls with the same (driverType, configJson) run one capture (fake driver counts Initialize/Discover invocations) and both callers receive distinct session tokens over the shared tree; disposing one session leaves the other browsable; a failed capture fails both awaiters; a subsequent open after completion (Refresh path) runs a new capture (in-flight-only map, no result caching).
  • Unit — capture cap queueing: with MaxConcurrentCaptures = 4 and >4 concurrent opens on distinct keys (fake drivers gated on a test signal), at most 4 captures are in flight at once; queued opens complete after a slot frees; a queued open observes caller cancellation.
  • Unit — CanBrowse hardening: a fake factory whose TryCreate throws on malformed configJson yields CanBrowse == false (no exception escapes); when TryCreate returns a non-discovery (or flag-false) instance, CanBrowse is false and the created instance was shut down/disposed.
  • Unit — bounded shutdown: a fake driver whose ShutdownAsync never completes — the open still returns within open-timeout + the 10 s shutdown bound, on the discovery outcome, for both legs: successful capture ⇒ a usable session is returned; failed discovery ⇒ the discovery error (not a shutdown timeout) surfaces. The wedged instance is abandoned, not awaited.
  • Unit — BrowserSessionService resolution: bespoke-first, universal-fallback, and manual-entry-when-neither; confirm no ToDictionary collision.
  • Integration (fixture-gated): point the universal browser at the existing driver fixtures on 10.100.0.35 — AbCip (ControlLogix sim) and the OPC UA reference server — and assert a real captured tree; reuse the driver fixtures already in tests/.
  • Live-verify: on docker-dev, open the /uns TagModal picker for an AbCip driver and confirm the controller tag tree renders and a picked leaf commits TagConfig.FullName (the usual live-/run discipline — Razor binding bugs pass unit tests).

10. Phasing & effort

  • P1 — universal browser end-to-end: CapturingAddressSpaceBuilder + CapturedTreeBrowseSession + DiscoveryDriverBrowser + IUniversalDriverBrowser + BrowserSessionService fallback + ITagDiscovery.SupportsOnlineDiscovery default member + the AbCip/TwinCAT/FOCAS patches + the UntilStable settle loop (§4.2) + capture coalescing and the MaxConcurrentCaptures cap + the bounded (10 s) shutdown + hardened CanBrowse (catch-throwing-TryCreate, defensive teardown) + DI line + AdminUI CanBrowse→Browse-button gate + the picker Refresh action (§4.3). Set SupportsOnlineDiscovery=true on OpcUaClient(n/a—bespoke), AbCip, TwinCAT, FOCAS. ~35 days.
  • P2 — light up new drivers for free: as MTConnect/BACnet land, each sets SupportsOnlineDiscovery=true (+ patch if config-gated) and gets a working picker with no browser code. (MQTT/Sparkplug and SQL poll ship bespoke browsers instead — program doc §4.)
  • P3 — graduate to bespoke only where §8.1 bites (large BACnet sites, ControlLogix UDT drill-down): add a bespoke IBrowseSession that overrides the universal fallback for that type.

Net: one ~35 day piece replaces the previously-planned per-driver browser builds for AbCip + TwinCAT (and pre-covers FOCAS + every future discovery driver), with bespoke lazy browsers reserved for the genuinely-large trees.

11. v3 forward note (Raw/UNS two-subtree — lands after this)

This browser ships before v3 (docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md) against the current /uns TagModal picker. When v3's Raw tree lands, three things about the consumer change while the session/tree layer here (CapturingAddressSpaceBuilder, CapturedTreeBrowseSession, IUniversalDriverBrowser, BrowserSessionService resolution) carries over unchanged:

  1. Commit contract: §2's "commits TagConfig.FullName" describes the pre-v3 picker. In v3, TagConfig no longer carries identity (the tag's identity is its RawPath); the picked leaf's attr.FullName is written into the driver-typed TagConfig as an ordinary address field, and the commit creates raw Tag rows (name from browse name, datatype from DriverAttributeInfo) under a Device/TagGroup — optionally mirroring the captured folder nesting as TagGroups.
  2. Config input: v3 moves endpoint/connection settings from DriverConfig into DeviceConfig. OpenAsync(driverType, configJson, ct)'s configJson becomes the merged Driver + Device config composed by the caller (the /raw browse modal); PatchForBrowse merges on top as today.
  3. Browse-button gate: unchanged mechanism (CanBrowse / bespoke registration), hosted in the /raw tree's "Add tags ▸ Browse device…" action instead of the equipment TagModal.