Skip to content

feat(modbus): read a device's coils and registers, by address or by a named map - #1792

Merged
yusuf-gundogdu merged 72 commits into
mainfrom
feat/modbus-provider
Oct 16, 2026
Merged

yusuf-gundogdu merged 72 commits into
mainfrom
feat/modbus-provider

Conversation

@yusuf-gundogdu

Copy link
Copy Markdown
Member

Issue #1723. Modbus TCP, read only: coils, discrete inputs, input registers, holding registers, and the identification document. The device enforces nothing of its own, so the provider sends four read function codes and FC 43/14, and nothing else.

What it reads

A connection is one device address. Without a register map it reads a range by address; with one, each point has a name, a unit id, a table, a zero-based PDU address and a type. The box says which, and says it in the vendor's terms: vendor documentation often counts addresses from one, and these are the addresses that go on the wire.

Test layers

  • Fixture — oitc/modbus-server pinned by digest, loopback only, with values multiplied so an off-by-one shows as eleven units rather than one.
  • Integration — recorded answers through the real provider and the real framing, with no Docker.
  • Live check — against the fixture under Bun and under Node, thirteen checks each.
  • Browser spec — the dialog, the tree, the four tables, the identification read and the refusals.
  • CI step — two independent readiness checks, cleanup on exit, and a run that fails rather than skips when the fixture never answers.

Three defects the layers found

Each is fixed here, and each is held by a case that goes red when the fix is reverted.

  • The identification read returned nothing, silently. One byte of offset shifted every field: the parser read conformity from moreFollows, the object count from the first object's id, and reported zero objects with no warning. A device that published its vendor, product code and revision appeared to publish none. readIdentification had no unit test at all, and the live screen test had never run identify, so neither layer had ever been down this path.
  • The SSL / TLS panel was offered and never read. SSLConfig carries mode, caCert, clientCert and clientKey; the provider read ssl.enabled, which is the SSH tunnel's field and does not exist on SSLConfig. The condition was therefore never true: a connection to an mbaps device went out as plain TCP, and the device reset it. Reading the panel through connection-options.ts, where every other provider reads it, also restores servername and the host check that hand-rolled material had dropped.
  • The register map had nowhere to be entered. The provider read it from the connection and the API carried it, but no field declared it, so the named-point tree was unreachable from the screen. It is a field now, with the size bound read before the document is parsed.

Counts

EXTERNAL_DATABASE_TYPES moves from 37 to 38. Every count that follows from a registration is re-derived rather than edited by hand.

Merge order

This is the second of five providers from one chain. The order measured cheapest is MTConnect, Modbus, NETCONF, Sparkplug B, SNMP, and MTConnect is already in. The order is not only about conflicts: three READMEs say MTConnect came before Modbus, a case anchors that sentence, and the engine count follows the same sequence, so a different order makes those sentences wrong rather than merely conflicting. If a different order is wanted, saying so before the third opens costs nothing; afterwards it costs a rewrite of the prose in nine languages.

…issue #1723

Ten modules under src/lib/db/providers/industrial/modbus and nine test files under
tests/unit/db/modbus. No shared file is touched: the type union, the exhaustive
compatibility records, the dialect registries, the connection form, the engine
counters, the READMEs and docs/SECURITY.md are all unchanged, and the registration
lines are added when the shared-file order reaches this provider.

Each rule carries the measurement it rests on, in the module that implements it.

The request timer is not an improvement, it is the only thing that ends a stalled
read. Measured under Node 24 and Bun 1 against a device made to vanish without
sending FIN or RST, with the interface left in place and packets dropped: with
setTimeout(3000) the read ended at 3005 ms and 3029 ms; with no timer nothing
happened for 143995 ms and the socket still read as ESTABLISHED. The socket state
is therefore never consulted for health, and a timeout's wording comes from our
own timer rather than from err.code, which never arrives in that case.

The map takes the PDU address, counted from zero, and v1 parses no manual
reference number. Read from the primary source, Modicon PI-MBUS-300 Rev. J of
June 1996: printed page 18 defines 4XXXX as one-based, and pages 58 and 62 define
6XXXXX as zero-based Extended Memory, a different table. The six-digit form tools
use, 400001, does not appear in that document at all, so a parser for it would
collide silently with the meaning the specification gives six digits.

Word and byte order are required per point and have no default, because a wrong
order reads back as a plausible number with no exception raised. 64-bit integers
decode as bigint, because the measured value is past Number.MAX_SAFE_INTEGER.

A connection whose map is absent lists nothing and never throws, and its counts
answer unavailable rather than zero. The red schema error toast is raised only in
the catch block of use-connection-manager, so a throw would greet every
unconfigured connection with an error, and a zero would be a claim about the
device that Modbus cannot support.

The valve re-reads every outbound frame because the builders are our code and the
valve is about everything that is not. Three fixture servers took a write from a
client that presented nothing, and over MODBUS/TCP Security a certificate carrying
a ReadOnly role wrote as well, so the read-only guarantee is this provider's own.

Verification, in this worktree against the repository's own harness: 143 tests
pass, tsc reports no error in these files, biome and eslint are clean. Every rule
is also mutation tested in a separate worktree: 35 mutations, 35 caught. Two of
those mutations exist because they were not caught at first, which showed that an
MBAP length test and a pacing test did not bind the rules their names claimed.

(cherry picked from commit d798a9c23a426295adda70caf9a4fea2978ba107)
…s on purpose

ModbusProvider extends BaseDatabaseProvider and answers every declaration surface.
The tree is built from the connection's register map with no device traffic: no
Modbus request lists units, tables, registers or names, and the specification
calls the mapping between the data model and the device totally vendor specific.

The class declares no queryDialect, and that is a gap rather than a choice. The
dialect union is itself a registration surface: a member arrives with three
records, and docs/ADDING_A_PROVIDER.md:1088 says the build fails until all three
exist. Declaring it here before those records exist does not typecheck, measured.
The cost is real and is not hidden: a json provider with no dialect of its own is
given MongoDB's tab type, so until the registration change lands this provider's
console reads as MongoDB. tests/unit/db/modbus/index.test.ts pins the absence, so
the registration change cannot land without turning that test red and making
someone decide deliberately.

Three answers are deliberate rather than incidental. Counts answer unavailable
when no map is set and never zero, because with no map this provider does not
know how many coils the device has and zero would be a claim about the hardware.
Health omits activeConnections rather than sending a fabricated zero, which is
the rule that field's own docblock states. The monitoring surfaces answer empty
rather than inventing figures a device does not publish.

The read console refuses with the reason: the client, the register map, the block
planner and the frame reader are in place and tested, and the request parser that
turns one JSON document into those calls is the next change. Refusing is better
than returning something that looks like a read.

Verification in this worktree: 159 tests pass, tsc reports no error in these
files, biome and eslint clean. Those checks now run from a gate script rather
than by hand, and the gate earned its place immediately: it refused this commit
once, for four type errors in the new test file that bun test did not see while
reporting 159 passing.

(cherry picked from commit 551bbe1bb6b74c986b759a70923aec1dbe4a5597)
…ain Error differently

The first version of the provider threw plain Error for its three refusals: the
read console, the maintenance surface and an unknown point. That was a defect,
found by measuring rather than by review.

The product distinguishes the two. isQueryError in src/lib/db/errors.ts tests
instanceof QueryError, and a reader such as the Kubernetes provider's object
layer turns a QueryError into { unavailable } while letting anything else
propagate as an unexpected failure. So a refusal thrown as a plain Error reaches
the user as a crash instead of as the sentence it was written to carry, and the
sentence is the whole point of refusing rather than returning something empty.

The provider argument of QueryError is left out deliberately. It takes a
DatabaseType, and this type-id is not a member of that union until the
registration change lands; it is filled in there.

Three tests bind it, one per refusal, so the plain Error cannot come back. They
assert the instance rather than the message, because the message is allowed to
change and the type is not.

(cherry picked from commit 96aada8a4019e4ea48d0db260762f7877c6b8dfe)
…d and which already diverged

provider-surface.ts declared this provider's capabilities a second time, in a
MODBUS_SURFACE object. Nothing in the product read it: the provider declares its
capabilities inline in getCapabilities. So its seventeen tests were passengers.
They were green whatever the provider did, and they protected a duplicate rather
than the product.

Measured, and worse than unused. The duplicate declared queryDialect: "modbus"
while the real class deliberately declares none, which is the one point the
provider documents as a gap. The two had already contradicted each other on
exactly the fact the documentation calls out.

Worst of the three, and the reason this is a fix and not a tidy-up: index.ts's own
docblock pointed a reader at provider-surface.test.ts as the file that binds the
gap. A reader following that pointer would have found a test asserting the
opposite. The pointer now names index.test.ts, which is the file that actually
binds it.

What was worth keeping were the two sentences the product has to say out loud,
about the read-only guarantee and about revocation not being checked. They move
into constants.ts, which index.ts imports, because a sentence nothing imports is
a sentence that drifts. The capability assertions are rewritten against the real
getCapabilities, so they now fail when the provider changes.

160 tests pass, tsc reports no error in these files, biome and eslint clean, all
through the gate script.

(cherry picked from commit 7395ec74b7c9259dceee8eac9a3a167ebc82e702)
… was asserted for one type

values.ts declares eight scalar types and the tests exercised seven. int64 was
the one left out, which meant the rule it shares with uint64, that a 64-bit
integer decodes as a bigint because the measured value is past
Number.MAX_SAFE_INTEGER, rested on a single case.

Measured rather than suspected: turning int64 into a Number left the whole suite
green. One case cannot tell a rule from a constant.

The replacement reads the same four words two ways and the answers differ by the
full unsigned range, -1 against 18446744073709551615, which is the only shape of
test that tells the two decoders apart. A second case pins the minimum, where a
number would lose the value silently rather than visibly.

The scale and offset test stays a single case, and now says why that is enough:
the two candidate formulas disagree on this data, 15 against 10.5, and the test
asserts the second answer is not produced rather than leaving a reader to work it
out. Both are bound by mutations.

(cherry picked from commit bfb9c7560d7f80e6071f18f40180201a543f09ad)
…hape rather than its intent

The three shared helpers do not treat DB_HTTP_BLOCK_PRIVATE_HOSTS the same way,
and that asymmetry is the whole difficulty. Read from egress-policy.ts rather
than inferred: assertPublicLiteralHost checks the flag itself and returns early
when it is off; assertPublicDnsAnswers checks no flag at all and always enforces;
guardedNodeOptions returns an empty object when the flag is off, so the lookup is
never installed and the DNS guard is never reached.

The flag check therefore lives in the caller, and the only correct use of the DNS
guard is to let socketEgress decide whether to hand one back. Calling the DNS
guard directly, or installing the lookup unconditionally, refuses every
name-addressed device even with the flag off. For this provider that is the
common case rather than an edge: a programmable controller sits on a local
network and is usually addressed by name.

One test exists for that regression alone, and a mutation proves it discriminates:
installing the lookup unconditionally fails the name test and leaves the IP
literal test green, because a literal never calls the lookup. A fixture of
literals only would have left the branch dead and the mutation harmless, so both
address forms are present.

refusesLinkLocal is a choice whose cost runs the other way here than it does for
a broker. True also refuses a device that lost its DHCP lease and self-assigned a
169.254 address, which is a real device an operator may need to read. False
leaves the range where a cloud instance's metadata lives reachable. The default is
true, the safer end, and the other is an explicit argument so the decision is
visible at the call site. Issue #1723 MB-S8 leaves it open.

The dial carries its own timer for the reason section 5.1 measured, and replaces
it with the request timer once connected: they measure different things, and a
dial timer left running would end a healthy connection mid-read. One attempt and
no retry, because a read that may have reached a device is never sent again.

171 tests pass through the gate.

(cherry picked from commit a1e74c89be60032b70d51b22041f365de3371228)
…aching the network

A mutation run found the dial timer unbound: setting it to zero left the whole
suite green, while the commit that introduced it said the dial carries its own
timer. Measuring why exposed a second, worse fault.

The connector seam the tests were meant to use was dead code. The line that makes
the socket still called node:net directly, because the edit that introduced the
seam did not match after formatting. So the three dial tests were dialling a real
blackhole address and passing on the real socket's timeout rather than on ours:
green for the wrong reason, which is the failure mode a passing test hides best.

The seam is now wired, and the tests supply a socket that announces nothing. Two
mutations hold it: zeroing the timer fails all three dial tests, and removing the
destroy on timeout fails exactly one, the test that asserts nothing is left half
open. The second is the discriminating one, which is what makes the set worth
having rather than three tests that fail together for one reason.

A dead path is worse than a missing one. A missing path fails; a dead path makes
the measurement lie, and the mutation that should have caught it stays harmless
because nothing reaches the code it changes.

174 tests pass through the gate.

(cherry picked from commit 84971122d5b27bb54b42ac8485df55e57471d334)
…n by scope rather than by shape

Two unwrapped paths, one of them caught by this provider's own test and only
intermittently, which is the worst way to find anything: a race decided whether
the suite was green. Six runs here passed and another session's run did not.

The criterion is scope, not type, and it is written in the comment this code
already cited: a failure that breaks ONE read is converted so the caller can
report that kind alone, and a failure that breaks every read alike rises, because
otherwise every surface answers unavailable while the connection looks healthy.

A malformed register map breaks every read alike. Measured, six surfaces of six:
connect, listContainers, countObjects, listObjects, getHealth and getOverview all
parse the same document, so there is no kind left that works. That is not a failed
read, it is a connection whose configuration is wrong, so it becomes a
DatabaseConfigError and rises. The previous commit made it a QueryError, which
fixed the 500 and still dressed a configuration fault as a query failure.

A socket that errors or closes has also ended the connection, not one read, so it
becomes a ConnectionError carrying the host and port in its own fields rather than
in its sentence. A raw ECONNRESET used to reach the caller unwrapped.

Both boundaries are selective, and that is tested by identity rather than by
class: the shared egress guard's own refusal and an unrelated TypeError come back
as the same object, so an error read and re-wrapped on the way out would still
fail the test. Converting everything would be as wrong as converting nothing,
because a programming mistake dressed as a device problem sends the reader to the
device.

The scope question is answered in advance for the seven classes not yet on a user
path, in a table beside the boundary, with the reason for each. They get a line
when their path exists: a conversion for an unreachable class is a branch no test
can enter.

The flaky test is no longer flaky because its two outcomes are no longer
different. 191 tests pass through the gate; seven mutations hold the two
boundaries, including one that breaks pass-through identity.

(cherry picked from commit 5f05d67955a48f58d28aa8b8d63dd6d807a23cdc)
…omparing a constant with itself

Every limit in constants.ts was mutated to see which the suite actually holds.
Six are bound. Four were not, and all four failed the same way: the tests held
the mechanism and left the number free.

UNIT_ID_SERIAL_RESERVED_FROM was tested with units 7 and 250, a sample on either
side of the threshold rather than the threshold. Any value between them passes, so
moving it to 200 broke nothing. 247 and 248 are the only pair that can pin it.

DIAL_TIMEOUT_MS and REQUEST_TIMEOUT_MS were never read: every test that touches a
timer injects its own value, so the declared figures were decoration. Both now
have a test on the default path, the one that reads them.

MAX_TRANSACTIONS_IN_FLIGHT changes no behaviour, because the session's queue
serialises regardless. That makes it exactly the kind of value a test has to
assert directly: TCP 4.4.1.2 requires a client to publish the figure, so the
number itself is the contract, and a separate test shows the queue enforces one
independently of it.

The dial timeout then exposed a sharper mistake of mine. The first fix asserted
armed === DIAL_TIMEOUT_MS, which compares the constant with itself: mutating it
to 1 ms armed 1 ms and the test agreed. A number that is a contract has to be
written out as a literal, with the measurement that chose it, so both timers now
assert 3000 and assert it is above the 2137 ms an absent unit cost through the
measured gateway, which is the reason the figure is what it is.

Eight constant mutations join the harness. 198 tests pass through the gate.

(cherry picked from commit 62a55964b1abce176d5571139e7081d347bb032c)
… is not a bound

Six fragmentation and bound cases were run against the frame reader. Four passed
on the first attempt and two did not, and the two say something worth recording.

The four that passed: byte at a time, a split at every point inside the seven byte
header, one frame followed by the first byte of the next, and chunk sizes chosen
not to align with frame boundaries. TCP knows nothing of frame boundaries, so a
reader is only correct if it is correct for every split. These passed because the
partial frame lives in an instance field rather than a local, which is the fault
that loses data when a large answer arrives in several segments.

The two that failed are the reason this is a fix. The reader joined the whole
incoming chunk to its buffer and checked the header afterwards, so five megabytes
in one write were held before any rule ran, and the bytes stayed held after the
refusal. That is the exact fault measured in modbus-serial and jsmodbus, and the
reason this module is ours rather than theirs. A bound that only runs after the
body is buffered is not a bound, and the docblock claiming otherwise was wrong
about its own code.

The reader now takes from a chunk only what a frame can still need. A complete
Modbus TCP ADU is 260 bytes, seven of header plus 254 less the unit id, so that
is the most it ever holds for an incomplete frame, and the figure is written out
with its arithmetic. A refused frame's bytes are dropped rather than kept:
holding them serves nothing and holding them is the fault itself. Several frames
in one write are served from that chunk by walking an offset, with no copy.

Four mutations hold it, including one that moves the partial frame back into a
local variable, which is the data-loss shape, and one that changes the retained
maximum. 205 tests pass through the gate.

(cherry picked from commit 27fa32a436ca3faa1f9fe46eb58601db410fef2f)
The reader clears its buffer when a header is refused, and the test named for that
rule asserted only the throw. A reader that kept the refused header passed it
unchanged, and the growth test allowed it too because its bound was the header's
own seven bytes.

Measure the held bytes after a refusal on both refusal paths, and read a well
formed answer afterwards: a reader that kept the bytes reads the stale length a
second time and never hears a device that recovers.

(cherry picked from commit 01d8a114233b579aa59f774b562d2dd50690bae3)
knip is a required check and it named three: the 802 port constant, a re-export
of the map error from the object lister, and the span helper. All three were
published and unread, so the check was red on this branch while the test suite
stayed green.

The 802 port stays a fact in the comment beside 502. It becomes a name again when
the provider reaches the socket and has two ports to choose between; a test
written only to read the constant would assert nothing.

Readers of the map error already import it from register-map, including the test
for the object lister, so the re-export served no one. The span helper is read
only inside the planner.

(cherry picked from commit 05e40e4528e9625749c77f0d5d7080e720187138)
Deleting it turned knip green by removing the evidence. The TLS dial exists in
client.ts and the provider does not reach the socket yet, so no code is in a
position to choose between 502 and 802: the name is waiting for a connection, not
left over from one.

The gate carries it as a single expected unread export with an end condition. When
index.ts imports ./client the allowance goes red and comes out, so the gap cannot
outlive the wiring that closes it.

(cherry picked from commit 426937b5be8a6cbe9451ebc7141153e281c5959d)
The editor text is one JSON read request in three shapes: named points of the map,
one range by address, or the device identification objects. Every shape is settled
here, before a socket exists, because a request that cannot be built is text the
caller should hear about while they are still looking at it.

The schema is strict. A key the parser does not know is refused rather than
ignored: ignoring it runs a different read from the one that was written, and the
answer is then read as the answer to a question nobody asked.

The protocol's bounds are not repeated here. checkRead owns them and this parser
calls it, so a widened limit cannot pass in one file while it fails in the other,
and the tests for those four rules assert the words checkRead writes.

A name is checked against the map because Modbus has no discovery: a name means
what the user's map says it means, and the useful moment to say a name is absent
is while it is still in hand.

Three refusals exist for values that would otherwise be dropped without a word: a
count that does not divide by the type's span, an order key with no type to order,
and a type on a table that carries bits.

(cherry picked from commit 583a2ee959b9b5f73917562545a76eb6cf1148c6)
matchToRequest was written for this, tested on its own, and called by nothing.
exchangeOnce resolved frames[0] instead, which gets two cases wrong: a late
answer to a request already given up on reads as the answer to this one, and a
stray frame arriving in the same chunk as the real answer hides it, because only
the first frame was ever looked at.

The expectation is now a required option rather than one a caller may omit: an
exchange that cannot say what it asked for cannot tell a stray from an answer,
so there is no sensible default to leave it at.

A frame nobody asked for is dropped and the wait goes on. A frame that claims to
answer this request and cannot still fails. The device's own refusal resolves as
an exception rather than as an answer, so a caller cannot read an exception code
as data.

(cherry picked from commit 044950b59a97e950ea94fc1e7df545c750b8d2cf)
The wire resolved with a complete ADU, which means the frame reader had already
read the header to know where the frame ended and whoever received those bytes
would read the same header again. Two readings of one header is one reading too
many, and the second is the one that drifts.

Exchange moves to framing, beside Match, because what a frame MEANS is a framing
question and both the socket and the session need the type. Match is now Exchange
plus the ending that is not an ending: a frame nobody asked for.

The session never looks inside an answer, it only carries one, so its tests echo
the request back as the answer: what they are about is ordering, timing and
pacing.

(cherry picked from commit 7c79bd54916abfb1ee33d55600e593ade590fded)
Function code 43, MEI type 14: the protocol's only self-description, and the only
read a device answers without a register map.

One answer is parsed here and the walk is not. A device may answer in several
transactions, and following that chain needs a connection this module does not
have. What it owns is the shape of one answer and what an object's bytes mean.

Every length the device declares is checked against the bytes that arrived, in
three separate ways, because they fail differently: a count that overruns the
frame, a length that overruns the frame, and a length past what any frame can
carry. Each one would otherwise show a neighbouring object's bytes as this
object's value, which a reader cannot tell from data.

A value is text only when its bytes really are UTF-8, decided by re-encoding
rather than by looking for odd bytes: Buffer's decoder substitutes U+FFFD instead
of failing, so bytes that are not UTF-8 come back as a string either way and the
only reliable question is whether encoding it again gives the same bytes. The
private objects 0x80 to 0xFF carry whatever a vendor chose.

The per-object bound is derived from MBAP_LENGTH_MAX rather than written beside
it, so it cannot stay still while the frame bound moves.

(cherry picked from commit 365d8d726135fc024dcacfa8340f5093a4f233ce)
A payload can carry bytes that read as a perfectly good MBAP header, because
every byte of a register is data the device chose. The reader advances by the
length the header declares and never looks inside a body, so it is immune by
construction, but every test until now used payloads that happened to contain no
header-shaped bytes. Three cases now say so: a value carrying a whole header, two
frames where the second's header appears verbatim inside the first's body, and
the same two split byte by byte.

The close after a framing error was held by a stand-in Error, which binds any
failure closes and says nothing about a framing error in particular. The real
class now travels that path, and the reason for the close and the identity of the
error the caller receives are asserted separately, because they are two claims.

The 802 port goes back to being a sentence beside 502. Kept as an exported
constant it was a published name with no reader; kept as an unexported one it was
an unused local eslint names. What stands in production because it is absent is
nothing: a connection cannot be made at all yet. It returns as a name in the
change that dials, where some code actually chooses between the two ports.

(cherry picked from commit 1547b66b256232ff0e6956144d2c15dde5492cb7)
The type-id joins the DatabaseType union, the dialect union and the tab-type
union in one change, because none of the three compiles without the records that
follow it.

Measured rather than listed: adding the id to the unions and reading what the
compiler asked for. 33 records across 22 files, of which 36 of the 69 errors were
one missing DIALECT_GENERATORS record reported again as implicit-any cascade.

The surfaces the compiler does NOT force are the ones that matter most here, and
each was found by running the suite rather than by reading a checklist: the
factory's switch, whose default throws a string, so a missing case is silent and
the provider could never be constructed; the supported-types sentence beside it,
pinned by regex; the nine READMEs and their numerals; the outward-facing catalog
copy in twelve files, where a numeral summarises a list that has to name the
engine; and the census tests that enumerate every engine by hand.

The palette gains stone. Every base hue and every -alt step was an engine's by the
time this one registered, and of the four greys Tailwind ships that no identity
used, stone, neutral and zinc clear every contrast test while gray-800 does not
clear AA in light, measured by running the test against all twelve candidates. A
warm grey stands furthest from slate, which is OPC UA's, and those two are the
industrial pair a reader has to tell apart on one tree.

The provider now declares its own dialect, which closes the gap its own test
pinned: a json provider with no dialect of its own is given MongoDB's tab type by
resolveTabType, so the console read as MongoDB.

(cherry picked from commit b2c0177bba572d602fa90935e636fa3315594200)
Eight test files were red on this branch and the gate said green, because the
gate ran my own directory and the registration touched forty shared files. The
gate now derives its list instead: every test that names the engine. That is what
found the rest of this commit.

The provider doc did not exist. docs/providers/ holds one file per engine and
modbus was the only one missing, which is not a stale number but an absent file,
and two censuses were failing on it.

The container path was declared and not checked. assertContainerPathShape pairs a
declaration with a call, and the declaration alone lets a path of the wrong length
reach the map lookup, which then answers no such point and names the wrong
mistake. Wiring it turned four of my own tests red at once: they read at the empty
path, which the product never sends - the route checks the shape before a provider
is reached - so the tests were pointed at a unit path and the guard kept.

The seed schema rejected the engine outright. Its enum is kept in step by hand and
the file says so: a missing id is not a compile error, it is a seed file the server
refuses with invalid enum value for a connection type the product supports.
MEASURED: modbus was refused at the type level while mtconnect reached the field
checks.

The migration generator emitted SQL for it, and two more hand-kept exclusion lists
inside the tests had to name it. Four populations carried a total that moves with
the fleet: the edit abstainers, the column census, the source census and the
maintenance surface.

The refusal classes now carry what the reader acts on. Eight of nine fell through
as plain Errors, so the user's own bad request arrived with the code reserved for
our defect. Request, map, decode and valve are four different next actions and now
four different classes, and the valve deliberately stays ours.

A union-keyed record holds one direction only. MEASURED on FUNCTION_CODE_OF_TABLE:
an extra key typechecked clean while a missing member failed, because the
annotation sits on the binding and Object.freeze's return is not a fresh literal.
With satisfies inside the call, both directions fail.

(cherry picked from commit d854c2f094339a4751d504d842841f8d94767a36)
…t all

A test that names only the class cannot tell six refusals apart. MEASURED by
making each one carry another's sentence: two survived with every test green, so
decodeScalar could refuse an unknown type with the wrong words and nothing said
so. Both now assert the sentence a reader acts on.

The container path guard was wired this evening and nothing asserted it: removing
the call left the suite green. Three tests now hold it, including the control that
the shape the tree actually sends is still accepted.

The derivation behind the gate's surface stripe grew a second source. Naming the
engine finds the tests the registration touched, and misses the ones that must
change without ever mentioning it, which is how the numeral dictionary slipped
through. It now also takes every test changed on this branch.

(cherry picked from commit 1ccfcbeb4b4a26a46e0bef850e43bcbd7d4d5e1a)
The engine was registered, connect() returned successfully, and no byte ever left
the process: index.ts imported constants, pdu, register-map and values, and
nothing imported client. A registered engine whose connect succeeds and whose
reads refuse is worse than an unregistered one, because the showcase line in nine
READMEs promises it.

read.ts is the step that was missing. Everything it calls already existed and was
tested; what did not exist was the order. A request is parsed, the points are
planned into as few block reads as the limits allow, each block is one exchange,
and the words come back decoded under the orders the map states.

query() dials for the read and destroys the socket in finally. Nothing is held
between reads, which is the rule connect() already documented: a libmodbus server
serving one client at a time left a second client unanswered for 3003 ms while the
first stayed open. The request is settled BEFORE the socket, so a refusal the
caller can fix costs no connection, and that order is asserted.

getHealth() now does what its docblock claimed for a while: a dial and one
quantity-1 read at a point the map names. A connect alone is not health and
neither is the socket's state; health is an answer, or the absence of one.

The measurement is a TCP server written by hand rather than a mock, so the frame
reader, the valve, the transaction ids and the decoder all run. It asserts both
ends: the bytes the device received are a read for the address the map names, and
the rows carry the value those bytes answered with. Two reads in a row both carry
transaction id 1, which is the evidence the first socket was really closed.

The identification walk is bounded three ways at once, and a walk cut short keeps
what it has: the objects already read are real.

(cherry picked from commit 68b30fb3adfb818c1e331c9eeb2d377124b403b6)
…wrote

NON_SQL_DESTRUCTIVE_VOCABULARY is a registration surface nobody's list held: a
type carries a row exactly when readsSqlText says its text is not SQL, and this
engine joined that set in the registration without joining this record. The row
names NO operation on purpose, in the shape MTConnect and Prometheus use: the
encoder builds four read function codes and the identification request and
nothing else, so naming an operation would claim this provider can send it.

Three censuses move with it and were updated together rather than one at a time:
the row count, the engines that decide alone, and the engines whose statements
stay out of the AI analysis.

Nine of my files were unformatted and no gate looked. biome runs on the host for
knip's reason - the container has no linux-arm64 binary - and the stripe covers my
own two directories, because the other unformatted files on this branch are
inherited and belong to the session whose rebase resolves them.

(cherry picked from commit d8d8ce0334899b819fe2192fb2b1a07c70484f07)
…ad as connected

Health read one point when the map named one and asked the device nothing when it
did not, so a connection whose map is empty answered successfully against any
address at all, reachable or not.

A plain dial, and deliberately not an identification read. 43/14 is not mandatory:
a device that refuses it is one that does not know the function rather than one
that is not there, and reporting that as a health failure would blame the wrong
side, which is the fault class this provider keeps running into.

The dial claims exactly one thing, that the address answered a TCP connection.
Nothing is sent and nothing is read. That sentence is in the provider doc rather
than in the answer because HealthInfo has no field for it, and a sentence in a
size field would be a worse answer than the unknown that is there.

Three tests hold it: an open listener passes with no ADU sent, a closed port
fails, and a connection WITH a map does not take this path at all, which is what
keeps the stronger check from being quietly replaced by the weaker one.

(cherry picked from commit b215f4e7f5ffc8262995321a9b528a8898843dd6)
The previous commit went in through a CLOSED gate: the two commands were written
on separate lines rather than chained, so the gate's verdict was printed and not
read. Two things it had found went in with it.

A health test ran with no server and asserted the shape of the answer. The moment
health began to dial on an empty map, that test started spending three seconds
proving a dial timeout and failing. The claim is about the answer's shape, not
about a socket, so it moved to the file that has a device to answer it rather than
being weakened where it stood.

The new tests were unformatted, which the format stripe added an hour ago caught
and which the same unread verdict let through.

(cherry picked from commit 672e9314aa951ba2d88231ccc49cb4bb24885e24)
Three sentences cited the standard where the true subject was us.

Maintenance refused with "Modbus offers no maintenance operation", which is false
about the protocol: function code 08 can clear a device's counters and restart its
communications. What is true is that this provider builds four reads and the
identification read and nothing else, so the sentence says that and names 08 as
the thing it does not build.

An undefined exception code was reported as one "which Modbus does not define".
That is a claim about the specification and it could not be checked here, because
the specification PDF in hand is compressed and its text could not be read. If the
code is in fact defined, the sentence blamed the device for a missing row in our
own table, so it now says the table is ours.

The per-object identification bound is arithmetic of ours, derived from the frame
bound rather than printed in the standard, and the sentence presented it as the
protocol's number.

Measured with a string extractor rather than a line regex: the first pass matched
code across line ends and reported 67 sentences, most of which were not sentences.
Twelve cite the protocol, and the valve's own line is the control that the scan
sees what it claims to scan.

(cherry picked from commit 93c1f97ab7970cf39a49b9d7b5da8d57b5ed056e)
…ter that bled

Five mutations against what was wired today, and two survived.

The order of parse and dial was bound only by accident: the tests that noticed it
have no server, so dialling first fails them for the wrong reason, and the one
test written for the rule counted ADUs. A dial sends nothing, so an ADU count
cannot tell "refused before the socket" from "dialled, then refused" - both
answer zero. The device now counts CONNECTIONS, and the accepted read is the
control that an accepted request really does dial.

Dropping socket.destroy() from the finally broke no test at all. Every read dials
its own socket, so a leaked one is invisible to both the transaction ids and the
connection count: a second read dials again either way. The device now counts
CLOSES, which is the only end of this that can see a socket left open.

And the counters themselves were wrong before they were useful. They were
module-level and reset per test, and a close event from the previous test's socket
arrived after the reset and landed in the next test's count: connections 2, closes
3, with no bug in the provider at all. A late event cannot know which test it
belongs to, so each device keeps its own counters now.

97 mutations, 97 caught.

(cherry picked from commit 771f53ccc810a33237a60fd0f7a7a5ee931f5459)
connect() parsed the register map and opened nothing, so it returned successfully
for an address with no device behind it - MEASURED at 0 ms against a closed port.
The test-connection route reports a health failure after a successful connect as a
degraded success, so Test Connection answered success: true for a device that does
not exist, and the route's own note says the dialog gates its save on that answer.
A reader told "connected, but no health data" goes to fix their map when the
fault is the address.

Both directions measured. Before: a closed port returned successfully. After: it
is refused with "The connection to 127.0.0.1:1 failed: connect ECONNREFUSED",
which names the address, and an open listener is still accepted. The route's
success: false follows from the connectPhase branch I read at :128 and did not
execute; what I ran is the provider.

A dial and not an identification read: 43/14 is not mandatory, so a device that
refuses it is one that does not know the function rather than one that is not
there. The socket is closed at once, because what section 4.7 measured as
dangerous is HOLDING a connection - a second client went 3003 ms unanswered while
the first stayed open - and not opening one twice.

The map check still runs first, and a malformed map is refused with no socket
opened at all. "An empty map is a normal state" moved to the wired tests with
the dial, because proving it now needs an address that answers.

(cherry picked from commit 88c654ac2a7884d5e39de693fab5a0238353ab68)
…real route

The measurement that justified the dial is now a test rather than a paragraph.
Before the dial, an address with no device answered HTTP 200 with
success: true, degraded: true and the message "Connected, but this server
answered no health data", and the route's own note says the dialog gates its save
on that answer. MTConnect answered 503 in the same run, so it was never the
route's general behaviour.

Measured over the real route handler, not a mock of it: an address with no device
is 503 CONNECTION_ERROR naming 127.0.0.1:9, with no success and no degraded flag,
both with an empty map and with a one-point map - the two were byte-identical
before, which is why both are asserted. An address that answers is a plain 200
with no degraded flag, and the device answers a real read for it.

My first run of this measurement was invalid and I noticed from the output rather
than from the result: the resolveConnection mock had the wrong signature, the
route saw an undefined connection and answered 500 for all three cases. A 500 on
every case is not a measurement of anything.

(cherry picked from commit 1ab0d88ff12000293d4faa4699b7017ab4a0dd06)
…from

MEASURED, and this was the worse half of the same hole. Against a port that
ACCEPTS a connection and never answers, an empty-map connection answered
{"success":true,"message":"Connection successful"} in 0 to 9 ms, three runs
identical. The dial succeeded because TCP was accepted, and health could not fail
because there was nothing to read. The refused-port case at least said degraded
and named the address; this one carried no hint at all. And an empty map is a NEW
connection's default state, so that was the answer before a user had written a
map, on a wrong port as readily as on a right one.

The repair is an honest answer rather than another probe. No guessed read sits
behind it: a wrong unit id costs 2137 ms behind a gateway, so a timeout there
would blame a device that IS present, and 43/14 is not mandatory either.

connectWarnings and not testConnectionWarnings, because it needs no request - it
reads the connection's own map. Measured rather than assumed: the route collects
it at :140 and returns it as warnings, the hook composes the sentence a user reads
at use-connection-form.ts:344, and the modal renders it at :1979 with an amber
tone rather than a green tick. The route half is executed by these tests; the
hook and modal half was read, and the test says so.

Three cases, because two of them would have been mistaken for a fourth: a silent
port with no map carries the caution, a REAL device with no map carries the SAME
caution - the caution is about the map, not the port - and a real device with a
point carries none at all.

(cherry picked from commit 92d180c6f2d9f9efe7d6226a03d7141829f486ac)
1677 added 18 commits October 16, 2026 05:33
`describe()` names what the caller wrote, and its last arm answers with the type
for a value that is none of null, a list, text or a number. Every case in the
table passed one of the four the arms above it catch, so the line was the only
one in this file the suite never ran. A boolean reaches it.

Measured rather than guessed: the lines above it are hit 11, 37, 45, 77 and 61
times and this one zero, which is what an unreachable arm looks like next to a
reachable one.
The walk sliced one byte off the answer before parsing it. `InboundFrame.body`
is already the PDU after the function code, so it starts at the MEI type, which
is where the six-byte head MB 6.21 defines starts and where the parser reads
from. The parser's own sentence says so ("the PDU after the function code") and
`identification.test.ts` builds exactly that shape.

Measured on the wire rather than read off the page. A three-object basic answer
was assembled as MB 6.21 describes it, pushed through the real `FrameReader`,
and both shapes handed to the parser:

  frame.body  0e 01 01 00 00 03  00 04 "Acme"  01 04 "AC-1"  02 03 "1.4"

  the body itself   objects=3  [[0,"Acme"],[1,"AC-1"],[2,"1.4"]]  more=false
  body.subarray(1)  objects=0  []                                 more=false

One byte along, every field moves: the conformity level is read from More
Follows, More Follows from the next object id, and the object count from the
first object's id.

Two symptoms, one cause, and the fix is measured against both. A device whose
More Follows byte is 0x00 answered into an empty list, so `identify` returned no
rows and no warning - a device that publishes its identification read as a
device that has none. A device sending 0xFF put that byte where the conformity
level is read and the parser threw instead.

Why nothing caught it. There was no `read.test.ts` in this provider: all 44
lines of `readIdentification` sit in the uncovered list the coverage gate
prints, and the live screen run read the four tables but never ran `identify`.
The gap did not merely fail to find the defect, it is what kept it hidden.

The new file drives `runRead` behind a scripted `Exchanger`, with no socket: the
walk's three bounds, the two warnings that keep a half-read list, the refusal
sentences, and the two shapes a range answer comes in. Canary, run twice: with
the slice put back, six cases go red, named in the run - the three walk cases,
the level case, the non-increasing guard and the round bound.
Two `require("node:net")` calls stood where the module's own import already
reaches: `Socket` was imported for its type and taken again at runtime through
`require`. `bun run lint` is a CI gate and ESLint refuses a `require()` import,
so this file failed the lint job with two errors while every other gate on the
branch was green.

Measured, not assumed: `bun run lint` exits 1 before and 0 after, with the two
errors named in the run, and the file's nine cases pass either way - the change
is how the class is reached, not what it does.
`expectationOf` declared its five-field result inline, and those five fields are
`Expectation`, field for field, which `framing.ts` already exports and
`matchToRequest` already takes. The function exists to produce exactly what that
function consumes, and until now only structural typing said so: a reader had to
collect the shape out of the middle of a signature and compare it by eye with
the interface two modules over.

Nothing about the bytes changes. `framing.ts` imports only `constants.ts`, so
the type import adds no cycle.

A side fact worth recording because it is what sent someone looking: bun's
coverage reporter emits line records for the fields of an inline return type,
and those lines can never execute, so they sat in the uncovered list against a
gate that admits no exemption. Naming the type removes the lines rather than
silencing them.
`transactionsInFlight` had no reader anywhere: one match in the whole tree, its
own declaration. The field behind it is live - the cap at
`MAX_TRANSACTIONS_IN_FLIGHT` reads it, and so do the two lines that raise and
lower it - but the public getter over it was never called by the provider, the
tests or the end-to-end suite.

The test that cares about concurrency measures it from the other side already:
the fake wire counts how many exchanges are outstanding at once, which is what
the rule is about, and that count does not depend on the session exposing one.

Writing a case for an accessor nothing calls would have turned the gate green
while leaving the member exactly as dead.
Four surfaces, each uncovered for its own reason rather than by one oversight.

The tree's generated requests. The object tree writes a read for whatever was
clicked, and a click lands at two depths: a point, whose name the map already
holds everything for, and a unit, which has no point to name and so gets the one
shape that needs no map. Only the first was ever generated in a test. Both texts
now go through `parseReadRequest`, the same function the provider runs them
through, so "runs as is" is measured rather than claimed - a generated request
that does not parse is a click that opens an editor and refuses on execute.

The overview. Every surface case so far handed it a malformed map, which refuses
before the answer is built, so the answer itself had never been read. It is
assembled from the map because a device publishes none of what an overview asks
for, and the two figures a device does not publish are words rather than
guessed-at numbers. The second case holds that distinction: a connection with no
map reports `unknown`, not `0 points`.

The transaction id. The module says an id is never reused on one connection and
that the counter wraps at sixteen bits. That is a claim about a full cycle, and
a case taking three ids measures the first half of the sentence and none of the
second. The cycle is now taken in full: 65535 ids, each once, none of them zero,
and the next one after the wrap is the first again.

Three endings of an exchange. Besides the timer and an answer, an exchange ends
when the device hangs up, when a chunk is refused at the MBAP header, or when
the caller lets the wire go. The first two reached the caller through lines no
case ran; the third destroys the socket under the wire. Each is driven with a
supplied socket, so none of them races a real dial.
"FC 43 with MEI type 14. The only other request this provider can produce."
described `buildIdentification` and sat above `expectationOf`, thirty lines
higher, where it read as a claim about the expectation reader instead. Moved to
the function it is about.

Nothing executable changed. Worth its own commit because the class of fault is
worth naming: a sentence that is correct, that no gate can see, and that is
wrong only about WHERE it stands. The formatter, the linter, the type checker
and the coverage gate all pass over it, so the only thing that finds one is
somebody reading the lines in order.
Five of the eight line citations this engine's comments carry pointed at the
wrong line, and three of the five were wrong against every tree: the anchors
they name have not moved between main, the branch base and this branch.

  use-connection-manager.ts:335-362   the "Schema Error" toast is at 411,
                                      inside the catch at 399; 335-362 holds a
                                      different setSchemaError(null)
  use-connection-form.ts:344          cautionSentence is at 408 in all three
  ConnectionModal.tsx:1979            an SSH private key textarea; the caution
                                      banner is the testResult.tone === "warning"
                                      arm
  ResultsGrid.tsx:736                 a comment; autoFocus is at 746
  ResultsGrid.tsx:823                 blank; onDoubleClick is at 846

The fix names the symbol instead of correcting the number, because the number
is the part that cannot stay true. `connection-fingerprint.ts` already carries
this rule and a test enforces it: that module names `getOrCreateProvider` and
`src/lib/db/factory.ts` with no line number, and the suite asserts
`not.toMatch(/factory\.ts:\d/)` on its source. A citation that survives a
rebase is worth more than one that is exact for a day.

The three citations that are correct are left as they are, measured rather than
assumed: `errors.ts:245`, `kubernetes/objects.ts:348` and `route.ts:146-160`
all say what their comment claims, and no commit in this stack touches any of
those three files, so none of them moves when this branch lands.
MEASURED FIRST, because the trigger was not a design review. Bun's coverage
reporter emits a line record for `export class FrameReader {` that never
credits a hit, and the 100% gate admits no exemption. The control settles what
it is: a byte-for-byte copy of the same class, in the same file, driven by the
same test in the same run, reported 42 where the original reported 0. The body
was never the cause, so no case could ever have closed it. Across a full run,
all 37 raw reports carrying this file read `DA:42,0`, and `merge-lcov.mjs`
takes the maximum per line, so the merge cannot have turned a hit into a zero -
our side was cleared before the tool was blamed. The report is also inconsistent
with itself: it declares `FNF:8 FNH:6` while carrying no `FN` name at all, and
the import on line 21 reads 190 hits.

TRIED AND DID NOT WORK, named so the next hand does not spend the night on
them: an explicit constructor; moving the declaration down one line; splitting
`export` off the declaration; moving the class to the end of the file; moving
it to a file of its own, where every number transferred verbatim; and renaming
it off `FrameReader`, which is the repository's only duplicated `export class`
name - the other is `rabbitmq/amqp/frame-reader.ts` - a strong candidate that
turned out wrong. Ten blank lines at the top shifted every record by exactly
ten, so the line map was live throughout.

THE CHANGE. The reader held one field and two operations: no inheritance, no
`instanceof` test, no second implementation, one `new` in the whole tree. A
class buys none of what it costs here, and the module's other seam,
`matchToRequest`, is already a function. It is now a closure behind
`createFrameReader`. The exported surface is deliberately unchanged:
`FrameReader` is still the type a caller names, now an interface, and
`reader.buffered` is still a value because the returned object keeps the
getter. Call sites change in one way only, from `new FrameReader()` to
`createFrameReader()`.

AND THE OBJECTION, because leaving it out would teach the next hand that we
reshape code to suit a tool: nobody would have asked for this change on its own
merits, and the reason it was made is a defect in the coverage reporter.

Measured on this tree: `framing.ts` has no uncovered line, the gate reads
165842/165842, the suite is 1527 files and 57006 cases with none failing, and
typecheck, format, lint and knip exit 0.

Canary, two rounds, each one putting the class back: `coverage:check` exits 1
naming `framing.ts: 1 line(s) [42]`, and exits 0 at 165842/165842 once the
class is gone again. The first writing of that canary reported two green rounds
and proved nothing, because the shell handed `git checkout` the whole file list
as a single path and the class was never restored; the rewrite counts the
restored declaration before each round and prints every exit code, so a round
that measures nothing cannot read as a round that passed.
…agree

The catalog note told a publisher two different things on two adjacent lines.
Both began "do not publish the thirty-eight-engine wording against a version
that", and then one said "predates Apache Spark" and the next "predates
MTConnect", each followed by its own chain of variants. A publisher reading it
cannot tell which threshold to apply, and the chain the reader follows decides
which body goes to the catalog.

The second line was wrong beyond being a duplicate, which is why it is the one
that goes. It offered "the thirty-five-engine variant for a release with OPC UA
and not MTConnect" and named `pcsc-listing.html` at 0df2b6a as that body. That
body carries thirty-four engines, not thirty-five, and the rung skips Apache
Spark, which is the engine that sits between OPC UA and MTConnect. Measured by
reading the listing at each commit: 0df2b6a lists thirty-four, f447c9b lists
thirty-five with OPC UA and no Apache Spark, main at d7ec5b1 lists thirty-six
with Apache Spark and no MTConnect.

What was missing is this branch's own rung. The count above it moved to
thirty-eight when Modbus registered, but the chain still started at
thirty-five, so the two steps below the new count had nowhere to be read from.
It now starts where the count does: thirty-seven for a release with MTConnect
and not Modbus, then thirty-six for one with Apache Spark and not MTConnect,
then the chain as it already was.

The thirty-seven-engine body carries no commit of its own here on purpose: that
release is not out, and naming a branch tip would point a publisher at
something no tag carries. The note already leaves a body unnamed where none is
published, so this follows what it does rather than inventing a reference.
…wice

Adding Modbus to the mailed listing needed eleven characters the 1494-character
budget did not have, and the room was taken from the opening sentence: "a SQL
IDE to Rancher-managed Kubernetes clusters" became "to Rancher-managed
clusters". That left the submitted body saying something different from the
copy it is cut down from. `CATALOG_LISTING.md` and `docs/RANCHER.md` both still
say "Rancher-managed Kubernetes clusters", and a reader comparing the two would
find a difference nobody decided.

The room comes from a claim the listing already makes twice instead. The second
paragraph said "Runs on your own infrastructure" and the bullet list says
"Self-hosted, air-gap friendly: no external services" four lines later, which
is the same promise in two places. Dropping the first leaves the promise
standing where it is already made, and the sentence that carried it keeps what
only it says: who develops and supports the product.

Measured: the pasted length goes from 1493 to 1471 against the 1494 limit, so
the headroom goes from one character to twenty-three - enough that the next
engine does not have to buy its room from a sentence again. The size gate and
the copy gates pass, 80 cases over three files.
`yazilanlar` is a Turkish word, and it named the list that collects what the
fake socket was asked to write. It is now `written`, which is what the list
holds.

Worth saying how it survived: a scan for Turkish writing looks for the letters
Turkish has and English does not, and this word carries none of them once it is
spelled without its diacritics. The repository-wide sweep came back clean on
this branch - eleven hits, every one of them deliberate (two maintainer names
in the operator bundles, a collation defect documented with `Müşteri` against
`Müsteri`, a multi-byte measurement, a Unicode fixture list) - and this
identifier was invisible to it. The second pass that found it searched for
Turkish words spelled in ASCII instead, and this file was the only place in
`src/` or `tests/` carrying one.

The rename is seven occurrences in one file, nothing exported, and the file's
thirty-five cases pass unchanged.
A Modbus device answers no discovery request, so the register map is the only
thing that can name a point - and until now nothing could write one. The
provider read `registerMap` off the connection through a cast, no type declared
it, and the only places that set it were tests. The dialog's own caution said
"until a map names a point there is nothing to read", describing a state a user
could not leave.

WHERE IT LIVES WAS TREATED AS OPEN AND IS NOT. `keyof DatabaseConnection` is a
write list: `types.ts` says so where it explains why `WithTunnelFarEnd` is
deliberately NOT a field, and three exhaustive readers answer the one question
it asks - `FIELD_OWNERSHIP`, `CONNECTION_RELEVANCE` and `CONNECTION_FIELDS`, on
what a user may fill in, send and have stored. A document the user writes is on
that list, and length is no argument against it: BigQuery carries a whole key
file's JSON today. All three readers named themselves in `tsc` the moment the
field appeared, which is the mechanism working rather than a test remembering.

Not `password`, which is where BigQuery's key sits, because that one IS a
credential. Modbus TCP defines no authentication at all, so there is nothing
here to leak, and `secret` would mask the document in the dialog - a document
nobody can read is a document nobody can correct.

`resolution` in `CONNECTION_RELEVANCE`, where Kubernetes' namespace list is
`cosmetic`, and the difference is the point: the namespace list FILTERS names
the cluster defines, while this document DEFINES them. `volts` is unit 1,
holding register 100 under one map and can be unit 3, coil 5 under another, so
a read keyed to a connection whose map changed would match a name to an address
it was not taken from - the one direction `connectionResolutionKey` exists to
make impossible.

AND A BOUND, because there was none. Each point was checked and nothing checked
how many there were, so with the field in place a document of any size would
reach the parser, the payload and storage without meeting a rule. 65536 bytes,
checked before `JSON.parse` for the reason the frame reader bounds at the
header: a rule that runs after the work has not bounded the work. The figure is
measured rather than chosen - the provider's own example map is 79 bytes a
point and the widest possible point is 248, so this admits about 264 of the
widest or 829 plain ones, where a device's named points run to tens or low
hundreds.

Canary, and the first writing of it was wrong in a way worth recording. The
modal's case proves the box is drawn for an engine that declares the field, but
it reads the test file's MIRROR of the config, so removing the field from the
real `connectionFields` left it green. The real-config case in
`db-ui-config.test.ts` is the one that goes red, and both are kept: one binds
the box to the declaration, the other binds the declaration to the engine.
Removing the bound turns two parser cases red, named in the run.
`api-docs-types.test.ts` compares the `DatabaseConnection` block in
`docs/API_DOCS.md` against the interface and failed the moment the field
existed in one and not the other. That gate is the reason this is a separate
commit rather than a footnote: a field a client can send has to be readable by
whoever writes the client.

The line says what the document is, that it is bounded at 65536 bytes with the
check before the parse, and why it is public-classified - Modbus TCP defines no
authentication, so the map carries nothing to leak.
Both were found by an independent reviewer flipping them and watching nothing
happen: `registerMap: "resolution"` changed to `"cosmetic"` left the suite at
1510 of 1510, and `MAX_REGISTER_MAP_BYTES` multiplied sixteen-fold did the
same. A decision no case holds is a decision the next hand can undo without
being told.

The figure gets its own case because of what kind of number it is. Its
neighbours are pinned as values - `MAX_ADU_BYTES` at 260,
`MAX_IDENTIFICATION_OBJECT_BYTES` at 244 - and those DERIVE from the protocol,
so moving either would be moving Modbus. 65536 was chosen from a measured
range, which is exactly the kind of number that needs a declaration of its own.
The second case states the headroom rather than restating the figure, and it
computes both ends from real documents: a plain point and one with every
optional field set.

The classification case separates the two maps that matter. A different
document moving the key proves little on its own, so the case also takes two
maps of equal length naming one point each - `volts` at unit 1, holding
register 100 and `volts` at unit 3, coil 5 - because the same NAME over a
different address is what the key has to tell apart.

AND THE REASON IN THE DOCBLOCK WAS WRONG, which is the more useful half of
this. It argued from `CONNECTION_RELEVANCE`'s summary criterion - which
database, and as whom - and `registerMap` answers neither: the same device
answers, and Modbus TCP authenticates nobody. A correct decision defended by a
criterion it does not meet is an argument the next reader will rightly reject.
The reason is what READS the key: `use-provider-metadata` makes it part of
`readKey`, `use-connection-pulse` keys the pulse on it, `SchemaDiff` keys its
snapshot on it, so a map that changed while the key did not would leave the
previous map's points drawn. That sentence replaces the old one in `types.ts`
and in `use-connection-payload.ts`.
The case said its two documents were "two maps of equal length naming one point
each". They were not: 86 bytes against 70, because `holding_register` is longer
than `coil`. The claim it proves was still true - the key does separate the same
name over a different address - but the sentence under it was not, and a reader
would take it as "length is held constant here, so the address is what moved".

The key carries the whole document, so any byte of difference moves it on its
own. A case whose two maps differ in length therefore passes without ever
reaching the address, which makes the length the premise rather than a detail.
The second map now carries a unit of measure as padding, bringing it to the
same eighty-six bytes, and the test asserts the two lengths are equal before it
compares the keys: the premise is measured where it used to be asserted in a
comment.

Found by an independent reviewer who counted the bytes. It is the same class as
the `resolution` reason this branch corrected an hour earlier - a right
conclusion resting on a wrong ground - and the fix is the same shape: let the
test carry what the comment was claiming.
…not have

The dialog offers the panel for this engine and says so - "MODBUS/TCP Security
listens on 802 and the SSL panel turns it on" - and the provider tested
`config.ssl?.enabled === true`. `SSLConfig` has no `enabled`: it has `mode`,
and `enabled` is the SSH tunnel's word. The test was therefore never true, so
`mbaps` could not be reached from the dialog at all, whatever the user set.

Measured, not inferred: `enabled` appears on no SSL type in the tree, and the
only reader of `ssl.enabled` in `src/` was this one line.

Six sentences in the product describe the mode that line made unreachable. The
dialog's comment says the panel turns it on; the refusal for exception 01 says
"on an mbaps connection this can also mean it refused the certificate's role";
`MODBUS_NO_REVOCATION_SENTENCE` tells the user what mbaps does not check;
`valve.ts` and `docs/providers/modbus.md` describe a certificate carrying a
role; and the doc states "mbaps checks the chain and the host name, and not
revocation, and says so". None of it could happen.

The panel is now read by `nodeTlsMaterial`, the one place every other engine
reads it - MTConnect, RabbitMQ, S3, Kubernetes and the HTTP transport all go
through it - so `disable` is no TLS rather than TLS that verifies nothing, a
mode the panel does not define is refused, and half a client pair is refused
rather than dialled with half of one. The dial converts it with
`nodeTlsConnectOptions`, as the AMQP dial does, which carries the two things
the hand-spread object dropped: `servername` for a name-addressed device, and
`checkServerIdentity` against the host the user typed rather than the address
the lookup returned.

Reading the panel now lives in `connection-options.ts`, which is where every
other panel-reading provider keeps it. That is what makes it measurable: a pure
step over a connection record, tested without opening a socket. Nine cases
there for what the panel yields, three in `client.test.ts` for what the dial
does with it - the two halves were broken in opposite directions at once, so
each needs its own.

One of those cases was written wrong first and the correction is worth keeping:
it expected a panel with no mode to be refused, and the shared reader defaults
the mode to `verify-full`. That default is the point. The old condition read a
missing field and dialled PLAIN; a panel switched on now verifies even when the
mode is the one thing the user did not set.
The panel fix had unit cases on both halves - what the panel yields and what
the dial does with it - and no case that put the two together against something
that really speaks TLS. These four do, on loopback, with the repository's own
throwaway CA.

They are a reviewer's three readings turned into cases, and the three are a
set. Plain TCP answers five rows with the 7000 series, which says the working
path is sound. The same device over TLS with the panel's CA answers the same
five rows, which is the reading the product could not produce before. A CA that
signed nothing here is refused with a sentence about the certificate, which is
what separates a trust anchor that is APPLIED from one that is carried and
ignored: a provider passing empty material would fail this case too, but it
would fail the one above it as well.

The fourth case keeps the symptom. With the panel unread, Modbus bytes go to a
TLS listener and the listener resets - `read ECONNRESET`, which is what the
reviewer saw and what named the cause. An empty `tls: {}` would have failed as
a handshake that cannot verify, so the two defects are told apart by the
sentence, not by the failure. Asserting the message has no "certificate" in it
pins that difference, so a future hand that stops reading the panel sees the
user's own sentence rather than a generic error.
Comment thread tests/integration/db/modbus-provider.test.ts Fixed
Comment thread tests/integration/db/modbus-provider.test.ts Fixed
@codecov

codecov Bot commented Oct 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

1677 added 4 commits October 16, 2026 06:59
…ues in

Three refusal sentences sorted a list of accepted values with a bare `.sort()`, which
SonarCloud reports as three critical bugs: a sort with no comparator orders by UTF-16 code
unit, and a caller who meant alphabetical gets another order in another alphabet.

MEASURED before choosing. All three sets are closed lists of ASCII identifiers declared in
this repository: the request's own keys (`read`; `unit`, `table`, `address`, `count`, `type`,
`wordOrder`, `byteOrder`; `identify`), the four table names, and the eight types `values.ts`
decodes. Sorting them by code unit put them in the same order as `String.localeCompare` did
in en-US, de-DE, sv-SE, tr-TR and lt-LT, so no sentence changes a word.

`localeCompare` is not what was written down, although these sentences are read by a user:
the values in them are identifiers from this source, not text a device or a reader supplied,
so a collation would buy nothing and would make a sentence's word order depend on the
runtime's locale data. An explicit code-unit comparator keeps the order the sentences already
had, and the reason sits above it.
… byte

The test device looked its reader up in a two-key object indexed by the function code read
out of the ADU, which CodeQL reports twice as `js/unvalidated-dynamic-method-call`.

MEASURED: `adu[7]` is one byte, so the index is a number in 0..255; each object held exactly
two own keys; and the guard above the lookup narrows the code to those two, which leaves no
other target reachable. The pattern was already as narrow as the scanner asks for. But a
scanner cannot read a guard, and a red gate is red whatever the pattern does, so the dynamic
key is gone rather than argued with: the table is now chosen by the same condition that was
guarding it.

Behaviour unchanged, and the device is what proves it: 375 tests across the Modbus unit,
provider and mbaps files pass, the provider reads the same five 7000-series rows over a real
socket as before.
`blocks.map((b) => b.unitId).sort()` passes today for one reason: the ids are 1 and 3, and
one digit reads the same as text as it does as a number. MEASURED: `[3, 10].sort()` answers
`[10, 3]`, so the assertion would hold a wrong order the day a unit id of 10 reaches this
test, and it would hold it silently.

The trap is already written down twenty lines away, in objects.test.ts, which measures that
`[1, 3, 10]` orders differently as numbers and as text before asserting the order. This is the
same sort with no comparator, in a file that never met a two-digit id.

The two string sorts in this file are left as they are: the default comparator is a total
order on text, which is what those assertions ask for.
…y so

Six of the nine cases in e2e/modbus-provider.spec.ts dial 127.0.0.1:1502. The e2e job serves
nothing there, so on this branch's run they dialled it and got `connect ECONNREFUSED` eighteen
times, which is the whole of the E2E red. MEASURED rather than assumed: the `e2e` job of
ci.yml declares no `services:`, runs no compose command and no `docker run` - its steps are
checkout, Bun, dependencies, Playwright browsers, the test run, the subpath run and the report
upload. The Modbus live check of docker/modbus/ci-step.yml does start a container on 1502, but
it is pasted into the `functional-smoke` job, which is not where the Playwright projects run,
so pasting it would not have changed this.

So the spec asks the device whether it is there and skips naming the address, which is what
e2e/mtconnect-provider.spec.ts and e2e/opcua-screens.spec.ts do with their live cases.

The probe asks a MODBUS question, not whether the port accepts: it sends one register read and
requires the reply's MBAP header to echo the transaction and unit it sent. tests/live/
modbus-ready-probe.ts measured why - against a published container port Docker answers the
accept in 5 ms while the server behind it took 332 ms to listen, so an accept is not a
readiness fact. The threshold is "speaks", not "serves this fixture's values", so a WRONG
fixture stays red while an absent one skips.

MEASURED both directions locally: with the fixture up, 9 passed and 0 skipped; with
MODBUS_E2E_TARGET pointed at a port nothing serves, 6 skipped and 3 passed - the same six.

docker/modbus/e2e-project.md said the opposite of two things and both are corrected there. It
said no project block lands, because the second-server projects exist for the bucket Test
Connection spends and this spec never clicks Test Connection; the second half is true and the
conclusion was still wrong, because use-connection-form.ts:1321-1343 is one probe for both
buttons and it POSTs the rate-limited /api/db/test-connection. The block this PR adds is
right, and so is its entry in SECOND_SERVER_SPECS: that test passes 63, not the 57 the old
text quoted.
@sonarqubecloud

Copy link
Copy Markdown

@yusuf-gundogdu
yusuf-gundogdu merged commit 1989908 into main Oct 16, 2026
40 checks passed
@yusuf-gundogdu
yusuf-gundogdu deleted the feat/modbus-provider branch October 16, 2026 09:00
yusuf-gundogdu added a commit that referenced this pull request Oct 16, 2026
… kept

main had moved six PRs ahead (#1794, #1799, #1800, #1792 Modbus, #1804, #1801) and GitHub
called the branch unmergeable. 73 files conflicted in 131 hunks, and the resolution rule was
one sentence: WHERE BOTH SIDES ADDED AN ENTRY, BOTH ENTRIES STAY, main's first because Modbus
landed first.

NUMBERS DERIVED FROM THE MERGED TREE, not carried from either side: EXTERNAL 39, SHIPPED 40,
WIRE_COMPATIBLE 27 (unchanged by either engine), connectable 66. The four denominators stay
apart - "thirty-nine engines" is EXTERNAL, "forty type-ids" and "all forty ids" are SHIPPED,
"sixty-six named engines" is EXTERNAL plus the wire-compatible relatives.

WHAT THE LINE-BY-LINE MERGE GOT WRONG, each caught by a gate rather than by reading:
  - seven src and four test files lost a closing brace where two entries' tails were identical,
    so the entry was rebuilt from main's file with this engine's record moved in whole;
  - `migration-generator.ts` kept the record but lost BOTH plain-list entries;
  - `dialect-editors.test.ts`, `object-source-declarations.test.ts`, `mcp-documentation.test.ts`,
    `connection-fingerprint.test.ts` and `BRAND_MESSAGING.md` each carried a duplicated claim,
    one copy from each side, with different numbers in three of them;
  - the golden dialect fixture cannot be merged line by line at all: it was regenerated with
    the command its own docblock gives, and the gate compares it byte for byte;
  - the operator CSVs were regenerated with `make -C operator bundle` after the source manifest
    was corrected, not edited by hand.

AND THREE DEFECTS OF MY OWN RESOLUTION, found by scanning for them rather than by trusting it:
a double comma in README.md, a space before a comma in both chart READMEs, and a stale "and"
left in the operator CSV's engine list. The scans are in the record: `,,`, ` ,`, duplicated
adjacent lines, and engine-name counts per fleet list.

NINE TRANSLATIONS carry both engines in every fleet list and the numeral in each file's own
form: a word in en/zh/es/pt/ru/hi/ur and a figure in ja/ko. The Urdu pair had to be matched by
code point, because the word I typed was not the word the file holds.

THE RECENCY CHAIN MOVED, which a gate holds at both ends: Sparkplug B is now the newest, Modbus
came before it, and `not.toContain("Modbus is the newest")` is what keeps the previous head from
quietly claiming a title it no longer holds.

MEASURED, twice, lane taken, chart dependency built: 1550 files, 57560 tests, 57555 pass,
5 skip, 0 fail, 0 not run, exit 0. format, lint, typecheck, knip, readme:check, security:check,
distribution:check, channels:showcase:check and chart:check all exit 0.

ONE FINDING THAT IS NOT MINE: main dropped "Apache Spark" from the engine list in
docs/AGENT_GUIDE.md (the base has it, origin/main does not). The name is restored here because
the line had to be edited for this engine anyway and shipping a list that silently lost an
engine is worse; it is reported rather than hidden.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants