Skip to content

Commit f2a04ba

Browse files
authored
feat(opcua)!: read OPC UA for agents and MCP only beneath a seed's declared read roots (#2237)
* test(ci): publish the OPC UA live fixture on a Docker-picked port The OPC UA read-only check published its fixture on 127.0.0.1:54840, inside Linux's ephemeral range (32768-60999), where a client socket of an earlier step, open or in TIME_WAIT, can hold the port and Docker refuses the bind, as the live PostgreSQL fixtures did. The step now publishes on 127.0.0.1::50000, reads the port back with docker port and sets LIBREDB_OPCUA_LIVE_URL from it after the container runs, and the compose service block publishes the same way. The pinned literal was not load-bearing: the probes and both live check runs read the URL from the environment, and the provider never dials the endpoint the server advertises. live-environment.test.ts pins the new form; the step run locally as the workflow runs it passed on Bun and Node. The backlog entry for the fixed port is removed. * feat(seed): declare an OPC UA connection's read roots on its seed entry An OPC UA seed connection may now carry agentReadRoots, the node ids beneath which agent mode and MCP will read. Each root is a node id in the console's form with its namespace written by URI, at most 32 roots of at most 512 characters, and namespace 0 only from a fixed table of standard nodes that describe the server's state and limits. The grammar is checked when the seed file loads, through the engine's validateSeed, with one refusal per element naming its index, and again when the provider is built. The field is refused on every other engine and in defaults, stripped from the managed connection list so the browser never holds it, refused with 400 on a connection a caller supplies inline, and framed into the provider cache key. SEED_CONNECTIONS.md documents the field and its refusals, and SECURITY.md records the control. * feat(opcua): prove the set of nodes beneath an OPC UA connection's read roots proveReadSet walks down from the read roots on a read's own session and answers the set of nodes that read may answer. Each root is keyed through the server's NamespaceArray and refused whole when its URI is missing, listed twice, at index 0 or in the diagnostics namespace, or when the server does not answer its facts Good. The walk follows only Organizes, HasComponent, HasOrderedComponent and HasProperty forward, keys every target by its table entry so namespace 0 and the diagnostics namespace are never members, and prunes a node by its own class and data type, a standard identity property by qualified name, a withheld type definition and a credential name. Depth is a stop; the node and request bounds refuse the read whole, as does a refused continuation point. A namespace-0 root's members are its fixed rows. The browse shaping keeps each reference's type definition, and Read gains a second fixed attribute list, the four facts the walk reads. The credential name check and the namespace-0 type definition and data type tables come back as the prune's defence in depth. * feat(opcua)!: read OPC UA agent requests only beneath the seed's read roots Every OPC UA agent read now reads the server's NamespaceArray and proves the set beneath the connection's read roots on its own session before anything the request names is sent. An id outside that set, namespace 0 and the server's configuration, roles and sessions among them, answers exactly as an id the server does not hold, decided by a set lookup with nothing sent for it: a value read answers its row BadNodeIdUnknown with no value and no units, and a history read or a browse of one is refused with the server's own sentence for that status. A browse of a member answers the references the walk kept, with no Browse sent and a count of those that lead outside; a browse of the Objects folder lists the roots. Units come from the properties the walk kept, with no Browse, and one outside the roots is answered as not read. An in-set row is checked again on its own NodeClass and DataType at answer time. The withheld roots, the namespace-0 read list, the walk beneath them, the placement by inverse Browse and the Withheld row status are removed, and the agent's client no longer browses on the answer path. A statement-sending agent profile on a connection without read roots is refused at open with the new READ_ROOTS_REQUIRED deny code, and the runs route refuses such a Start with read-roots-required before a run exists, read from the new READ_ROOTS_REQUIRED record. Plan mode and the operations workflow are unaffected. Deployed OPC UA seeds keep the editor and lose agent runs until their operator adds agentReadRoots. The provider doc, AGENT.md, the language pack's texts, the plan label and the regenerated language surface golden follow. * feat(mcp): read OPC UA through run_opcua_read, only beneath the seed's read roots MCP_EXPOSABLE.opcua and MCP_STATEMENT_READ.opcua turn true, and the opcuaread language registers its MCP facet, run_opcua_read, which runs the agent's own request through queryReadOnly under the agent read-only profile, so a call reads only beneath the seed's read roots and answers every other node as one the server does not hold. READ_ROOTS_REQUIRED holds MCP to the roots: the seed schema refuses mcp: true without agentReadRoots at load, the MCP context refuses such a connection at open with READ_ROOTS_REQUIRED, and a governed record, which carries no roots, is never opted in. inspect_schema refuses an OPC UA connection by name. The tool description names the two facts an answer can reveal about nodes outside the roots, a member's outward reference count and a units property outside them. MCP.md, SEED_CONNECTIONS.md and the provider doc's new MCP section document it, and the backlog entry that kept MCP closed on OPC UA is removed. * test(opcua): hold the read roots to a real server's identity, session and credential nodes An in-process node-opcua server hangs, beneath a declared read root, a DI device's Lock with its LockingClient and LockingUser, a condition's ClientUserId, a session object of the standard type, a node named for a credential and a reference out to the Server object, and keeps a folder outside every root. Driven through the provider's own queryReadOnly over the library, every one of them answers a value read, a history read and a browse exactly as a missing id does, with the same requests sent and no planted value in any answer, the folder outside the roots is named by nothing a read sends, and a browse of the root lists the process value and counts the rest without naming them, sending nothing beyond the walk. * docs(backlog): narrow the inline seed-only refusal and file the read roots follow-ups D380 now says that an inline connection carrying OPC UA's read roots is refused and the other seed-only fields still are not, and D414 says how the read roots walk keys a target on another server until the id is written in the Part 6 spelling. B170 records that OPC UA proves its read roots on every read, with the walk measured on the pinned fixture, until a fixture shows a cache is worth its invalidation. B171 records that an operator cannot exclude one node beneath a read root. Both are cited in AGENT.md. * fix(opcua): split a server-written name into words in linear time The credential-name check runs on every name a server answers during the read roots walk, and its split of a run of capitals backtracked, so one long name could hold the event loop for seconds. The split now looks ahead one capital and a lower-case letter, which splits the same names in linear time. * refactor(agent): word the read roots refusal without naming an engine The runs route answers the refusal for every engine READ_ROOTS_REQUIRED names, so it takes an engine-neutral sentence from the registry beside that record instead of importing the OPC UA provider's, and the timeline and the snapshot advice drop the engine name too. * docs(mcp): name BrowseNext among the services run_opcua_read sends * fix(opcua): send a history read only for a member the walk classed as a Variable The walk checks the data type of a Variable alone, and a node can class itself as an Object while it keeps history, so a history read of any other member now answers as an id outside the read roots with nothing sent. The answer-time check also answers a Variable whose own Read returns no data type as outside, since the walk admitted it on one. The tests replay a units property the walk pruned by its own facts, a refused NodeClass and DataType, a NodeClass attribute that disagrees with the browse, a data type that changes between the walk and the answer and a clean root on the in-process server, the walk's deadline on Browse and BrowseNext, every spelling of the Objects folder, and the walk's request bound at exactly its size and one past it. * test(opcua): re-record the live agent and MCP captures beneath read roots The agent capture script read the fixture with no read roots, which a statement profile now refuses at open, so it could no longer run, and its capture described the earlier withheld design. Both live scripts now read through seeds whose roots are the Telemetry and DataAccess folders and Server.ServerStatus, on containers whose ports Docker picks, as the anonymous, a non-privileged and an administrator identity. The agent capture records the walk's request count and time, values with their units, history and browses, every id outside the roots beside a missing twin for each read kind with the requests each sent, a session object the administrator is shown, the walk over wider and narrower roots, and that a profile without roots opens no session. The MCP capture records the same through the tool's handler, the refused forms, the write control and the cancellation timings. Two unit tests hold the docs' claims to the captures, and opcua.md, the MCP cancellation row and the walk's backlog entry cite them. * fix(opcua): prune a node whose BrowseName is not answered, or whose valueless class answers a DataType The read roots walk matched the standard identity properties only when the server answered a node's BrowseName, so a server that refused that one attribute had a locking object or a condition's ClientUserId admitted with its value. A node whose BrowseName is not answered is now pruned with its subtree, since the name is the only thing that tells an identity property apart. A node whose own NodeClass attribute claims a class that bears no value was admitted without a data type check, so its subtree was walked and answered even when the node carried a withheld data type. None of those classes has a DataType, so a node of one that answers a DataType all the same is now pruned with its subtree. The in-process vendor server test holds both shapes beneath a declared root to the answer of a missing id, and the scripted walk test holds a root with a refused BrowseName to the whole read's refusal. * fix(opcua): hand a browse continuation point to the library as a Buffer The library's browseNext takes a continuation point only as a Buffer and throws on the Uint8Array the provider carries, so every continued or released browse failed before anything was sent. On a server that pages a Browse, the Keys panel could not open the folder, and an agent read whose roots the server paged was refused as a failed connection instead of being proved. The bridge now hands each point back as a Buffer, as the history read already does with its own point. The in-process vendor server lists one folder two references at a time: the Keys panel and the read roots walk both follow every point to its end, and a point the server refuses stops the agent read whole with the server's status. * fix(opcua): stop naming a type among the reasons a read root is refused A root's type definition is never read, since only a parent's browse names one and a root's parent is never browsed, so the type definition prune never applies to a root. The refusal of a pruned root now names only what can refuse it (its class, data type or name), and the provider doc states that the operator vouches for what kind of node each root is, with the session object case as a residual. * fix(opcua): bound a browsed level's continued pages and hand back the point a bound stops Now that a continued browse reaches the server, a server that answers every BrowseNext with another continuation point kept the Keys panel's level browse looping, gathering every reference. A level now stops after OPCUA_BROWSE_LEVEL_MAX_CONTINUATIONS continued pages, or once it holds more than OPCUA_BROWSE_LEVEL_MAX_REFERENCES references with a point still to follow; the point is released and the level is refused rather than shown in part. The read roots walk was already bounded by its request count and the read's time budget, but a BrowseNext either bound stopped left the server's point held until the session closed. The walk now releases it before refusing, uncounted. The whole-tree walk module had no caller outside its own tests, so it is removed with them instead of being bounded, and the provider doc says the editor walks nothing. * refactor(opcua): name the Devices companion namespace once, written from the Foundation's own URI
1 parent 8ae87a0 commit f2a04ba

98 files changed

Lines changed: 7858 additions & 3657 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/ci.yml‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -618,13 +618,16 @@ jobs:
618618
# The digest reaches the suite, so the image this step runs and the image the fixture documents
619619
# cannot drift apart: `tests/unit/db/opcua/live-environment.test.ts` compares them.
620620
export OPCUA_LIVE_DIGEST="${image#*@}"
621-
export LIBREDB_OPCUA_LIVE_URL="opc.tcp://127.0.0.1:54840/"
622621
trap 'docker rm -f libredb-ci-opcua > /dev/null' EXIT
623622
# Loopback only. Without the host address Docker publishes on 0.0.0.0, and this server is
624623
# started --autoaccept --unsecuretransport: it would accept any client on every interface of
625-
# the runner.
626-
docker run -d --name libredb-ci-opcua -p 127.0.0.1:54840:50000 "$image" \
624+
# the runner. The host port is Docker's pick: a fixed one inside the ephemeral range
625+
# (32768-60999) can be held by a client socket, open or in TIME_WAIT, and
626+
# Docker then refuses the bind with "address already in use".
627+
docker run -d --name libredb-ci-opcua -p 127.0.0.1::50000 "$image" \
627628
--pn=50000 --autoaccept --unsecuretransport --ut --sph --hn --slownodes=3 --fastnodes=3
629+
port="$(docker port libredb-ci-opcua 50000/tcp | head -n 1)"
630+
export LIBREDB_OPCUA_LIVE_URL="opc.tcp://127.0.0.1:${port##*:}/"
628631
629632
# 1. The server's own statement. opc-plc prints this once the simulation is up.
630633
logged=""

‎docker/opcua/compose-service.yml‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@ opcua:
2828
# Loopback only, and here it is the whole point rather than a habit: the flags above make this
2929
# server accept any client certificate on an unsecured channel. Published without the host
3030
# address, Docker's default is 0.0.0.0, and the fixture answered on every interface of whatever
31-
# machine ran it. `tests/unit/db/opcua/live-environment.test.ts` holds this line.
32-
- "127.0.0.1:54840:50000"
31+
# machine ran it. The host port is Docker's pick (`docker compose port opcua 50000` reads it):
32+
# a fixed one inside the ephemeral range (32768-60999) can be held by a client
33+
# socket and refuse the bind. `tests/unit/db/opcua/live-environment.test.ts` holds this line.
34+
- "127.0.0.1::50000"
3335
# NO HEALTHCHECK, and the reason is measured rather than an omission. A healthcheck runs INSIDE
3436
# the container, and nothing in this image can speak OPC UA to ask the server whether it is up:
3537
# `nc` and `wget` are absent (checked in the image), `curl` is present but OPC UA is a binary

‎docs/AGENT.md‎

Lines changed: 3 additions & 2 deletions
Large diffs are not rendered by default.

‎docs/API_DOCS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2440,6 +2440,7 @@ interface DatabaseConnection {
24402440
seedId?: string; // stable reference to seed config ID
24412441
agentUser?: string; // optional least-privilege principal every agent execution profile and MCP call signs in as (#328); a seed may set it, on a managed seed only (docs/SEED_CONNECTIONS.md)
24422442
agentPassword?: string; // that principal's secret, or the agent's whole token or key; secret-classified, sealed at rest by connection-secrets; a seed may set it, on a managed seed only
2443+
agentReadRoots?: readonly string[]; // OPC UA only: the read roots, node ids written with their namespace URI, beneath which agent mode and MCP read; set only by the seed file, never listed to the browser, refused inline
24432444
apiKeyId?: string; // Elasticsearch only (#708): API key pair, sent in preference to user/password when both halves are set (trimmed). Secret-classified like agentPassword, not public like user. OpenSearch refuses the pair
24442445
apiKeySecret?: string; // the pair's secret half; either alone (after trim) falls back to user/password rather than sending a key built from an empty half
24452446
registerMap?: string; // Modbus only: the register map, as the JSON document the provider parses - a list of points, each naming a unit, a table, a zero-based PDU address and a type. Bounded at 65536 bytes, checked before the parse; public-classified, because Modbus TCP defines no authentication and the document carries nothing to leak

‎docs/BACKLOG.md‎

Lines changed: 23 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ None of it is a GitHub issue.
2828
**Sections**
2929

3030
- [SQL statement reading](#sql-statement-reading) — S2-S11 · 8
31-
- [Drivers and connections](#drivers-and-connections) — D1-D452, U17 · 334
31+
- [Drivers and connections](#drivers-and-connections) — D1-D452, U17 · 333
3232
- [Value interpolation](#value-interpolation) — V1
3333
- [Row editing](#row-editing) — R1–R3 · 3
3434
- [Studio UI and query execution](#studio-ui-and-query-execution) — X2-X26, U2-U199 · 185
@@ -40,7 +40,7 @@ None of it is a GitHub issue.
4040
- [Security Phase 3 deferrals](#security-phase-3-deferrals) — K4-K8 · 5
4141
- [Security scanner triage](#security-scanner-triage) — SCAN1-SCAN2 · 2
4242
- [Agent M1 deferrals (#328)](#agent-m1-deferrals-328) — A2–A8 · 5
43-
- [Agent M2 deferrals (#329)](#agent-m2-deferrals-329) — B2-B169 · 81
43+
- [Agent M2 deferrals (#329)](#agent-m2-deferrals-329) — B2-B171 · 82
4444
- [Passkey deferrals (#785)](#passkey-deferrals-785) — PK1-PK9 · 8
4545
- [MCP server deferrals (#246)](#mcp-server-deferrals-246) — MCP1-MCP16 · 16
4646
- [Governance follow-ups](#governance-follow-ups) — GOV1-GOV31 · 31
@@ -3777,6 +3777,7 @@ Found on #1818, whose change does not touch the HTTP transport.
37773777

37783778
`SEED_ONLY_FIELDS` in `src/lib/db/connection-fields.ts` names the fields only a seed file may set, but `resolveConnection` in `src/lib/seed/resolve-connection.ts` does not check an inline connection against it.
37793779
For example, OPC UA's `clientCertificatePath` and `clientPrivateKeyPath` are seed-only, and the provider reads them from any config it is given, so a request that carries them inline reaches the provider with them.
3780+
One seed-only field is refused already: `resolveConnection` answers an inline connection that carries OPC UA's `agentReadRoots` with `400`, and the other seed-only fields are still not checked.
37803781

37813782
Found 2026-10-20 during the VS Code extension work, and deferred because it does not block that work.
37823783

@@ -4119,7 +4120,8 @@ Found 2026-10-30 in the pre-release browser E2E; pre-existing.
41194120

41204121
`shapeBrowse` in `src/lib/db/providers/industrial/opcua/library.ts` writes each reference's id with `reference.nodeId.toString()`, beside a comment saying it keeps `nsu=` where the server gave one.
41214122
`ExpandedNodeId.toString` in `node-opcua-nodeid` 2.187.0 appends the URI and the index after the identifier instead: measured, an id in namespace 0 with URI `urn:x` and server index 2 is written `ns=0;i=5;namespaceUri:urn:x;serverIndex:2`, and with URI alone `ns=0;i=5;namespaceUri:urn:x`, where Part 6 5.3.1.11 spells them `svr=2;nsu=urn:x;i=5` and `nsu=urn:x;i=5`.
4122-
So a reference a server answers that way is listed in the Keys panel under an id no request can name back, and a Read of it is refused by the console's parser; agent mode refuses the whole read that meets one (`unkeyed` in `agent-read.ts`), which is the stated rule there.
4123+
So a reference a server answers that way is listed in the Keys panel under an id no request can name back, and a Read of it is refused by the console's parser.
4124+
The read roots walk (`proveReadSet` in `agent-roots.ts`) keys such a target as a local id, which answers as outside the roots unless that local id is itself a member; once the id is written in the Part 6 spelling, a target on another server is outside by its server index.
41234125
Not reached on the pinned OPC PLC 2.15.10 fixture, whose references carry neither; found 2026-11-01 while verifying the OPC UA agent read.
41244126

41254127
**Done when:** `shapeBrowse` writes such an id in the Part 6 spelling (`nsu=` for a URI, `svr=` for a non-zero server index), the comment says what it does, a reference on another server is shown as not readable rather than offered as a key, and a library-bridge test pins both spellings.
@@ -4297,14 +4299,6 @@ Found 2026-11-03 while fixing that helper.
42974299

42984300
**Done when:** `agentServer` matches a request by its line and body, or refuses at build time when two captures of one principal share a line, with a helper test on the two root captures.
42994301

4300-
### D434. The OPC UA live step publishes its fixture on a fixed port inside the ephemeral range
4301-
4302-
The CI step that starts the OPC UA fixture publishes it on `127.0.0.1:54840` (`.github/workflows/ci.yml`, `LIBREDB_OPCUA_LIVE_URL`), and `tests/unit/db/opcua/live-environment.test.ts` pins that mapping.
4303-
54840 is inside Linux's ephemeral port range (32768 to 60999), where a client socket of an earlier step, open or in `TIME_WAIT`, can hold it, and Docker then refuses the bind with "address already in use"; the live PostgreSQL fixtures failed this way and now publish on a port Docker picks.
4304-
Found 2026-11-03 while moving those fixtures.
4305-
4306-
**Done when:** the OPC UA live step publishes on a port Docker picks and passes it to the probes through `LIBREDB_OPCUA_LIVE_URL`, or on a fixed port below 32768, with `live-environment.test.ts` pinning the new form.
4307-
43084302
### D435. A discovered connection cannot carry an agent credential
43094303

43104304
A seed may carry `agentUser` and `agentPassword` (`docs/SEED_CONNECTIONS.md`, "An agent credential"), but only an operator seed file sets them: no discovery source, the CapRover or the Kubernetes one among them, maps an agent credential from what it discovers.
@@ -9312,26 +9306,6 @@ Found 2026-10-31 when agent mode joined Prometheus; the MCP reason recorded 2026
93129306

93139307
**Done when:** a change of its own opens MCP statement reads on Prometheus once one of the two conditions above holds: the pack declares its MCP facet (`src/lib/agent/languages/promql/mcp.ts`, registered in `AGENT_LANGUAGE_MCP_FACETS`) under the same closed table, budgets and projection, and `MCP_STATEMENT_READ.prometheus` is true, with tests that an MCP read sends only the instant query's form, that no MCP answer carries the flags or a URL's userinfo or query string, and, where a narrowing layer is the condition, that a connection which bypasses it is refused at open; cited in `docs/AGENT.md`.
93149308

9315-
### B162. OPC UA's agent reads withhold the server's configuration, roles and sessions, and it has no MCP surface
9316-
9317-
Agent mode executes OPC UA reads under the `opcuaread` request language, one JSON request per call: a value read or a history read written as the console writes it, or a one-level browse of one node (`docs/providers/opcua.md`, section 14).
9318-
The boundary is the provider's structural closed seam: an agent read reaches the server only through a session opened for that read alone, through a client that holds Browse, BrowseNext, Read and HistoryRead and nothing else, with the reference type, result mask, attribute list and history details fixed by the provider and the browse direction forward but where the provider reads a node's parents, so no Write, Call, HistoryUpdate, node or reference change, RegisterNodes, TranslateBrowsePathsToNodeIds or subscription can be sent on the agent path.
9319-
No principal can be the boundary: the pinned fixture took a raw anonymous Write with status Good, and `ResetToServerDefaults` reported UserExecutable true to an anonymous session.
9320-
Values are the server's process data, so they are answered as the console renders them, each with its node's engineering units and EU range read from its own `EngineeringUnits` and `EURange` properties through the same two services, or "no units published" and "no range published" where it has none, never a unit derived from a name; the units lookup counts against the read's request bound, so a value is never answered without it.
9321-
Withheld on every agent-mode path, decided by node id before every read on the read's own session: `Server.ServerConfiguration` (i=12637), `RoleSet` (i=15606), the session diagnostics (i=3706, i=3707, i=3708) and the PubSub security groups and key push targets (i=15443, i=25440), with every node the server answers beneath them, so no trust list, account name, role identity mapping or open session's client, user identity or endpoint reaches an agent answer.
9322-
In namespace 0 agent mode reads only a list of standard nodes (the Root and Objects folders, the Server object, its state, its capabilities but `RoleSet`, and its diagnostics summary counters), so every standard descendant of a withheld root is refused by id even where the server will not let a walk see it; where the server refuses this identity the browse of a withheld node, each node the read answers outside namespace 0 is placed by its parents through an inverse Browse, and refused when they cannot be read.
9323-
Node ids are compared as one key: a GUID in lower case, an opaque id as its bytes, and an `nsu=` URI through the server's NamespaceArray, read on the read's session; an id with no key refuses the read.
9324-
An aggregating server that exposes another server's configuration under a namespace of its own is not recognised by these ids.
9325-
A browse or a history read past the row budget, any read past the byte or time budget, and a read needing more than 100 requests are refused, never cut, and one session is opened and closed per read, so nothing is held between reads.
9326-
On a handle of either agent profile the console and the Keys panel are refused, and the overview, health, performance, slow-query, session, table, index and storage readings answer their reason rather than an empty reading; the provider declares no kind the grounding walk lists, so an agent run is ungrounded and is told to read a node id the question names as it is written and to browse from the Objects folder only for any other.
9327-
So the language offers the investigation and the operations workflow only.
9328-
`MCP_EXPOSABLE.opcua` is false, and the language's MCP facet is null (`AGENT_LANGUAGE_MCP_FACETS` in `src/lib/agent/languages/mcp.ts`) with `MCP_STATEMENT_READ.opcua` false, so MCP `run_read_query` refuses an OPC UA connection, because no MCP surface that reads through the same withheld set and units lookup is designed yet; `READ_ONLY_ENFORCED.opcua` is unchanged.
9329-
Kept so that MCP access, and any widening of what agent mode reads on an OPC UA server, are decisions of their own rather than side effects of agent mode joining.
9330-
9331-
Found 2026-11-01 when agent mode joined OPC UA.
9332-
9333-
**Done when:** a per-engine exposure ruling opens MCP for OPC UA, an MCP surface that reads through the same seam, withheld set, units lookup and request bound is designed and `MCP_EXPOSABLE.opcua` turns true for it, the pack declares its MCP facet (`src/lib/agent/languages/opcuaread/mcp.ts`, registered in `AGENT_LANGUAGE_MCP_FACETS`), and `MCP_STATEMENT_READ.opcua` is true, with tests that no MCP read reaches a withheld node or answers a value without its units; cited in `docs/AGENT.md`.
9334-
93359309
### B166. Trino composes no estimating plan, profile, statistics reading or grounding capture, and has no MCP statement read
93369310

93379311
Agent mode executes statements on Trino on a server whose access control refuses every identity every catalog's functions but the builtin ones, and catalog management (Trino's read-only access control, alone or beside other access controls; file rules alone are refused, since a view runs as its owner and another identity's grant reaches this user through that identity's view), proved before every statement with calls that run nothing, every statement inside a read-only transaction Studio rolls back (`docs/providers/trino.md`, section 15).
@@ -9370,6 +9344,24 @@ Found 2026-11-02 while verifying `run_read_query` on Couchbase.
93709344

93719345
**Done when:** a 5010 whose `reason` carries 13014 reaches the agent path and `run_read_query` as the role refusal naming the missing role, read from the structured `reason` rather than its prose, a transport and a provider test pin the three captured answers, and `docs/providers/couchbase.md` no longer says the role is lost; cited in `docs/AGENT.md`.
93729346

9347+
### B170. OPC UA proves its read roots on every read
9348+
9349+
Every OPC UA agent and MCP read walks down from the connection's read roots on its own session before it answers (`proveReadSet` in `src/lib/db/providers/industrial/opcua/agent-roots.ts`), and nothing of the proved set is held for the next read, so a read of one value repeats the walk.
9350+
Measured on the pinned OPC PLC 2.15.10 fixture on 2026-11-04 (`tests/fixtures/opcua/2.15.10/agent-boundary/boundary.json`), with roots over the `Telemetry` and `DataAccess` folders and `Server.ServerStatus`, the walk took 32 requests for every identity and every read, the walk included, answered within 120 ms on loopback; a plant model with larger roots costs up to 150 walk requests a read.
9351+
A cache would have to be keyed by seed id, identity and root list, and invalidated when the server's model changes, which the read client cannot observe without a subscription.
9352+
Found 2026-11-04 while building the read roots.
9353+
9354+
**Done when:** a fixture where the walk dominates a read is measured, and a cache keyed by seed id, identity and root list with explicit invalidation is designed and shipped with tests, or this entry is closed with the measurement showing it is not needed; cited in `docs/AGENT.md`.
9355+
9356+
### B171. An OPC UA operator cannot exclude a node beneath a read root
9357+
9358+
Inside a declared read root the operator vouches for the subtree: the standard identity properties are pruned by qualified name, but a vendor's own identity or credential node that no table names is answered (`docs/providers/opcua.md`, section 14.3).
9359+
Today the only way to keep such a node out is to move the roots down past it.
9360+
A seed field `agentReadExclusions`, with the grammar and limits of `agentReadRoots` and applied as a prune by node id during the walk, would let an operator cut one node without moving roots.
9361+
Found 2026-11-04 while building the read roots, and left out of that change by decision.
9362+
9363+
**Done when:** an OPC UA seed may declare `agentReadExclusions`, held to the read roots grammar at load and when the provider is built, refused inline and in `defaults`, kept from the browser, pruned with its subtree during the walk so an excluded node answers as one the server does not hold, with seed, walk and provider tests and the field documented in `docs/SEED_CONNECTIONS.md` and `docs/providers/opcua.md`; cited in `docs/AGENT.md`.
9364+
93739365
## Passkey deferrals (#785)
93749366

93759367
Each was decided when passkey sign-in was designed, not overlooked.

0 commit comments

Comments
 (0)