Commit Graph

4472 Commits

Author SHA1 Message Date
Joseph Doherty dfc62cef14 fix(ab-transport): map CIP 0x1C/0x20/0xFF-0x2104 as refusals; remap the PCCC STS table
K-F1 (AbCip #737 F1): FromCip maps general 0x1C and 0x20 to Unsupported and
0xFF/0x2104 to OutOfBounds, so a controller refusal is per-tag Bad instead of a
BadReply comms fault. A Forward Open refused with one of them is still
BadConnection. Every CIP code is now listed with its meaning; 0x01/0x07 stay
opaque on purpose.

K-F3 (AbLegacy F8, review 07 r0 I4): FromPccc follows the DF1 STS table:
0x20/0x30/0x90 -> BadConnection (a bridge reporting an unreachable END node),
0x50 and EXT 0x06 -> NotFound, EXT 0x07 -> OutOfBounds, 0x60 -> NotAllowed,
0x70/0xB0 -> DeviceStateConflict. Golden ParseReply tests per STS, and an
end-to-end AbLegacy test: STS 0x30 degrades with LastSuccessfulRead preserved;
the 0x60 positive control stays Healthy and advances it. Both were red before.
Driver edits are doc remarks and the two runtime tests the old table pinned.

Refs #747
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:43:37 -04:00
Joseph Doherty f0d807e7b7 fix(opcuaclient): review r0 minors m1-m10
- m1: the form hint now says ProbeTimeoutSeconds can only shorten Test Connect's
  10 s budget (passing the configured value as the button's budget is shared code -
  follow-up).
- m2: a superseded init no longer overwrites _options (generation-checked under
  _probeLock) or the cert-validation handler (handed back and published with the
  session; detached when not installed; a previous one detached on replace).
- m3: an ack none of whose condition ids resolves throws BadNodeIdUnknown + Warning.
- m4: an ack with no reply (transport failure / non-caller timeout) degrades.
- m5: no session before the gate is the same ServiceResultException(BadNotConnected).
- m6: the caller-cancelled-write deviation is documented.
- m7: a reply during a published outage advances the stamp but does not write
  Healthy; the keep-alive recovery ends the outage.
- m8: test for a non-caller OperationCanceledException write -> Degraded.
- m9: the browse session owns and disposes the user certificate it loaded
  (OpcUaUserIdentityFactory.LoadCertificate; a key-less cert is disposed before the
  throw); AdminUI-host certificate path documented.
- m10: a subscribe overtaken by a shutdown / re-init removes its half-built
  subscription instead of registering it (data and alarm).
- Sync Dispose drains each gate for 500 ms (was 2 s).

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:42:29 -04:00
Joseph Doherty e7e84e0577 fix(mqtt): review minors - QoS 0 is not answer evidence, caller cancels stay cancels, rebirth wait cap, bound/trie pins (R06 G2 MQTT review minors 2 3 4 6 7 8)
2: only a PUBACKed (QoS >= 1) publish advances LastSuccessfulRead; a QoS 0 Sparkplug
command completing means only that the bytes left the local socket.
3: a no-verdict SUBSCRIBE caused by the caller's own cancellation now throws
OperationCanceledException (refs still Bad + pending), so the breaker no longer
counts a pipeline timeout as a failure.
4: the rebirth wait is the window capped at 15 s, below the AdminUI 20 s per-call
timeout.
6: a 10-topic test pins the 8-in-flight publish bound. 7: partial-level wildcards and
$share filters are pinned never to confirm. 8: the intended init-vs-shutdown
supersede is commented at the re-check.

Refs #744
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:39:24 -04:00
Joseph Doherty 846c8d6335 fix(alarms): move scripted-alarm comment trail to its own append-only table (R05:4)
Every Part 9 transition used to re-serialize the alarm's whole comment trail
into alarm_condition_state.comments_json and replicate it to the pair peer:
O(n) per transition, O(n^2) over an alarm's life (measured: one ack at 10k
comments shipped 1,080,553 oplog bytes; now ~1.6 KB, the same as at 10).

- New replicated table alarm_condition_comment, PK (scripted_alarm_id, seq),
  seq = the entry's ordinal in the trail. Ordinal (not content) keying makes
  the two pair nodes' copies of one logical entry (each stamped by its own
  clock, since both nodes apply every command) collapse to one row under LWW.
- LocalDbAlarmConditionStateStore.SaveAsync appends only entries past the
  stored MAX(seq) (INSERT OR IGNORE, append-only) and upserts a fixed-size
  condition row with comments_json = "[]", in one transaction. Remove drops
  the trail with the row. Load merges the table with any inline trail.
- comments_json column kept (always "[]") so the condition table's shape and
  replication digest are unchanged and old-shape rows still load exactly.
- AlarmConditionStateSchema.MigrateInlineComments: idempotent per-row move of
  inline trails, run by LocalDbSetup.OnReady after RegisterReplicated so the
  moved entries replicate. Comment table joins ReplicatedTables (now five).

Tests: constant transition payload at 10 vs 10k comments (+ instrument
positive control), old-shape back-compat load, migration idempotency /
partial re-run / lazy move, OnReady boot migration captured in the oplog,
and two loopback pair tests (A->B and B->A trail convergence; the shared
entry converges to one copy, with a distinct-alarm positive control).

Refs #748
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:36:33 -04:00
Joseph Doherty 099f253321 fix(sql): review minors - superseded-reader polls, rebind faults, disposed-reader guard, JSON position (R06 G2 review m-1 m-2 m-4 m-6 m-7)
m-1: a poll that captured a reader a rebind then disposed retries once on the current
reader; with no newer reader the typed SqlPollReaderDisposedException is marked
classified, so HandlePollError no longer degrades for it. Groups report a disposed
reader as a sentinel so WhenAll surfaces the typed exception, not a bare cancel.
m-2: TryAddRef refuses new groups once Dispose ran, matching the doc comment.
m-4: a rebind / tag-table failure in InitializeAsync now writes Faulted with a
sanitized reason (generation-checked) and host Stopped instead of latching
Initializing with no LastError.
m-6: SecurityClass-as-descriptor is documented at the Sql site (Commons field is a
follow-up). m-7: a position-less JsonException renders '?' instead of blanks.

Refs #744
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:35:52 -04:00
Joseph Doherty edd2e23b7a test(mtconnect): de-race the raised-count step-back test
The one-shot /sample failure was enqueued while the FIRST enumeration could still be
before its own one-shot check (the fake releases the call-count waiter first), so
under suite load the failure hit the first stream instead of the raised one
(intermittent). It is now enqueued after the first stream has consumed a chunk.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:35:34 -04:00
Joseph Doherty f9fbd4ee0d fix(cli): AbCip subscribe serialises and guards console writes; Ctrl-C exits cleanly; arrays render
R07 U7: Driver.Cli.Common gains RunCancellableAsync (moved up from AbLegacy) and
CreateChangeLineWriter (one lock-serialised line per change; a failing console
write is logged and swallowed so it cannot fault the poll loop). AbCip
probe/read/write print Cancelled. on Ctrl-C instead of a stack trace, and AbCip
subscribe uses the shared writer. SnapshotFormatter renders arrays/lists as
[a, b, c] instead of System.Int32[].

Refs #747
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:34:48 -04:00
Joseph Doherty e03f343f14 fix(mtconnect): read-path restart publishes the new cursor; document the review trade-offs (review m6, m1, m4, m5, m8)
- m6: a /current read that sees a new Agent instanceId now also publishes the new
  process's cursor (generation-checked, captured with the read's session), so a
  later subscription does not open at the dead instance's sequence and flicker
  through a clear + re-baseline. Pinned in the T-2 test.
- Documented (code + docs/drivers/MTConnect.md): m1 the bounded stale-read window of
  a stream stalled without failing; m4 a refused multi-pair PUT's retry may re-send
  an already-applied pair (harmless); m5 a non-Agent 200 now reads reachable to the
  ping; m8 a refused raised count takes one transient blip before the step-back.
- m10 confirmed: no host key is persisted (DriverHostStatus dropped, #521) - the
  redacted HostName only changes an in-memory /hosts key for a query-bearing URI.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:33:14 -04:00
Joseph Doherty c1b9c7ec5c fix(sql): attribute a duplicated key to its own tags; key-value cap gets 2x headroom (R06:S-1 review I-1, m-3)
One stray duplicate row used to fail every tag in a key-value group or none of them,
depending on whether an unrelated key was absent. Duplicated keys are now refused
per key: only that key's tags publish BadConfigurationError and the rest of the group
reads normally. The key-value cap reads up to 2x the distinct keys (+1 to detect
excess), so a few stray duplicates are attributed per key and only a gross excess
(history/events table) fails the whole group. Duplicated-key refusals count as
answered refusals for health. The row-cap, duplicate-key and multi-row warnings now
use the per-plan limiter (m-3); the reader-wide limiter is removed. Unit tests pin
both the all-keys-present and absent-key cases with Good controls; live SQL Server
gate added (23/23).

Refs #744
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:32:23 -04:00
Joseph Doherty b2c064bec3 fix(mtconnect): an unanswered write degrades; one success writer for reads and writes (review I-3, m7)
Coordinator ruling on F3: a write with no reply (transport failure / deadline)
sets Degraded with the stamp preserved and a type-only LastError, through a new
_writeDegraded flag folded into PublishHealthy. One success writer is shared:
RecordReadAnswered (a /current answer, an Agent refusal, a live-index read)
clears the read AND write degradations; RecordWriteAnswered clears the write
one; neither clears the other path's stream degradation. Tests both directions
(answered write clears it, good read clears it) plus the converse (an answered
write does not clear a failed read); red with each half removed.
m7: a good /current resets the refusal-dedup latch, so a refusal recurring after a
recovery is logged again (tested).

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:31:26 -04:00
Joseph Doherty 2d8bb037f5 fix(client): failover runs under the subscription gate; ConnectAsync refuses when connected
R08 4.5: every subscription operation that awaits I/O and the failover's
teardown + reconnect + replay share one async gate, and every read/write of the
subscription fields (failover's null-outs included) is under _subscriptionLock.
A subscribe in flight when a failover starts is recorded first and then replayed
onto the new session instead of landing on the dying session's subscription. The
dead session is disposed before the gate is taken so a blocked call fails fast.
A DisconnectAsync during a failover is no longer undone by it. ConnectAsync on a
connected or reconnecting client throws instead of overwriting (and leaking) the
live session.

Refs #747
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:30:35 -04:00
Joseph Doherty 31af7acde0 test(mtconnect): make the STAB-20 retention test deterministic again (review I-2)
29c829b5 let the test accept 'already finished' for the stale-session pump, which
since T-1 is nearly always true, so deleting the retention reintroduced STAB-20
silently. A new CannedAgentClient.HoldSampleExit knob keeps that pump alive past
the retire point; the test now asserts strictly that it is still running
(positive control) AND retained, then releases it and asserts shutdown joined
it. Red with the _supersededPumps.Add removed. The pump's own self-exit on a
gone generation is covered by MTConnectStaleCursorTests (pump A completes, the
current pump keeps streaming).

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:29:04 -04:00
Joseph Doherty 13b29e8d4a Merge commit 'd520f77b' into remed/lane-Modbus 2026-10-22 16:28:49 -04:00
Joseph Doherty 8f0046ed05 Merge branch 'master' into remed/lane-G2 2026-10-22 16:28:15 -04:00
Joseph Doherty 540b1632c5 perf(client): lazy child detection in BrowseAsync, NodeId-indexed UI notifications
R08 3.6: BrowseAsync no longer issues one HasChildren Browse per Object child; an
Object reports HasChildren (may have children) and is confirmed when expanded or
recursed. ISessionAdapter.HasChildrenAsync is removed. The UI subscriptions panel
routes each notification through a NodeId index kept in step with
ActiveSubscriptions (add/remove/replace/reset) instead of scanning every row.

Refs #747
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:27:24 -04:00
Joseph Doherty f48a065c21 fix(mtconnect): a superseded pump writes no health, flags or index state (review I-1, m2, m3)
- The pump's delivered/degraded writes (stream flag, live flag, PublishHealthy /
  Degrade) now happen in TryMarkStreamDelivered / TryDegradeStream: one critical
  section under _subscriptionLock that re-checks the session generation. A pump
  wedged in a subscriber past its last check can no longer report a replaced
  session's (down) stream Healthy and make the read path trust it.
- m3: the index and condition tracker are installed in the same lock as the
  session + generation and captured under it (TryCaptureSessionState), so a stale
  chunk cannot fold into the new session's index.
- Teardown also bumps the generation, so an abandoned pump cannot write over the
  shutdown's health either.
- m2: an Agent restart clears the live flag before clearing the index, so reads
  go to /current until the re-baseline lands.
Red test: the wedged-pump harness with the new stream failing, plus a positive
control (the current pump's chunk restores Healthy).

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:27:23 -04:00
Joseph Doherty a74a18e49a fix(client): offer exactly the served aggregates in the CLI and UI
R08 2.3: AggregateType drops StandardDeviation (no backend serves it; always
BadAggregateNotSupported) and gains Range, Delta, MinimumActualTime,
MaximumActualTime, PercentGood and PercentBad. A parity test pins the enum to
AggregateFamilies.Served and each member to its same-named Part 13 ObjectId.
The CLI parses every enum name (plus aliases) and the UI picker lists them all.
Existing members keep their numeric values; 6 is retired.

Refs #747
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:27:08 -04:00
Joseph Doherty d520f77ba0 Merge Wave-2 integration: array writes reachable for AbCip/AbLegacy/TwinCAT (#737 #738 #739)
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:25:16 -04:00
Joseph Doherty 0200619322 fix(modbus): review r2 - region+address keys always win; string-only means no keys
N2: pre-#741 rows carry region/address beside an addressString with no marker
(the old driver never read the string, the old model preserved it as an
unknown key). The r1 rule resolved those from the string - a silent remap or
BadNodeIdUnknown on upgrade. The driver now loads from region+address whenever
both are present (classic, editor-pinned, pre-lane); only a blob with NO keys
is string-only and resolved through the parser. This is safe because the model
no longer stamps keys onto a string-only tag. The model distinguishes
string-only / legacy-string-beside-keys / pinned, and the editor hint says so.
Test matrix over every persisted shape (keys only, pre-lane keys+string,
pre-lane stamped HR/0+string, string only, marker+keys, marker no keys) under
every family; the pre-lane row also through a model save and a CSV
export->import.
n5: a typed CSV address cell is an authored address - it drops addressString
and the marker (new CsvTypedColumn.ClearsKeys) instead of being discarded.
n6: Validate skips the region/dataType/byteOrder enum checks on a string-only
tag (the driver ignores them). n7: docs/v2/modbus-addressing.md restates the
key-presence rule.

Refs #741
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:23:37 -04:00
Joseph Doherty b943f48e01 Merge master (7feffc96) into remed/lane-G1
Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:22:30 -04:00
Joseph Doherty 57ae69a05e fix(client): release a capped HistoryRead cursor, read (not release) follow-up pages, detach deleted subscriptions
R08 1.4: raw and modified history reads share HistoryReadPaging. A read stopped by
the caller's cap releases the server continuation point. The raw path also passed
releaseContinuationPoints = (cp != null), which released page two instead of reading
it, so every raw read was silently truncated to its first server page.

R08 1.5: DefaultSubscriptionAdapter removes its Subscription from the SDK Session
on delete/dispose (and a failed CreateAsync removes it too), so re-targeted alarm
subscriptions no longer accumulate in Session.Subscriptions.

Refs #747
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:21:22 -04:00
Joseph Doherty 1f82ecfc0d Merge commit '7feffc96' into remed/lane-Modbus 2026-10-22 16:19:17 -04:00
Joseph Doherty 44ea6595e5 feat(arrays): advertise AbCip/AbLegacy/TwinCAT array writes in DriverArrayWriteSupport
The Wave-2 lanes gave AbCip, AbLegacy and TwinCAT real whole-array write
encoders (review 07 U10/U9), but the capability table still listed all three
as scalar-only, so every array node materialised read-only and a client write
was BadNotWritable before it reached the driver.

Rows added, each listing only what WriteAsync encodes end to end:
- AbCip: SInt/USInt/Int/UInt/DInt/UDInt/Real/Dt/LInt/ULInt/LReal, declared
  length, not a safetyTag. BOOL/STRING/Structure arrays stay read-only.
- AbLegacy: Int/AnalogInt on N/B/A, Long on L, Float on F (file-width match),
  plain <file><n>:<word> address, <= 256 elements; not /bit, .sub, indirect,
  S:/I:/O:, nor native PLC-5.
- TwinCAT: every codec element type, declared length; not a BOOL bit-in-word
  path.
Elements convert in all three encoders, so the rows never key on an exact
node DataType (ScadaBridge int[] -> Int16 stays writable); they leave out
only node types whose elements never convert (DateTime/String over numeric,
Boolean on AbLegacy, STRING under DateTime on TwinCAT: culture-dependent).

Tests: driver-level parity for each driver (every element type x node type
plus shape cases, against the real write path), the AbLegacy parity tests
un-skipped, Core.Abstractions row tests, a composer test (three drivers
writable, refused shapes read-only), and the existing AbCip negative cases
moved to a BOOL array. Docs: Uns.md table, per-driver docs, data-plane rules.

Refs #737 #738 #739

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:17:48 -04:00
Joseph Doherty d77a538e17 test(mqtt): pass the test cancellation token to the wedged-init lifecycle test
Refs #744
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:17:09 -04:00
Joseph Doherty 7feffc963a Merge lane S7: S7 driver remediation (#742)
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:11:24 -04:00
Joseph Doherty a7506e7ed4 test(sql): live SQL Server gate for the row cap (R06:S-1)
An events table with 40 rows for one where-pair selector: the capped SELECT TOP (2)
runs as real T-SQL, the ambiguous tag publishes BadConfigurationError, a single-row
station reads Good through the same statement, and the driver stays Healthy. Live
run 2026-10-22: SQL Server 22/22, Postgres 7/7 and Oracle 6/6 (LIMIT n / FETCH FIRST n
ROWS ONLY after WHERE) all green on 10.100.0.35.

Refs #744
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:10:12 -04:00
Joseph Doherty f51055b509 test(opcuaclient,mtconnect): live-fixture cases for the lane G1 fixes
Against opc-plc (opc.tcp://10.100.0.35:50000): a shutdown with three live
subscriptions is bounded and the same instance re-initialises and reads (O-9 +
lifecycle generation through the real SDK); a server-refused write
(BadTypeMismatch) passes through and keeps the driver Healthy with its stamp
advanced (health contract); the browse side-panel reports a live variable's type,
shape and access (O-5).
Against the MTConnect fixture Agent: the headers-only ping reaches it and fails on
a dead port (T-7); a delivering /sample stream serves reads and reports its lag and
effective count on the diagnostics bag (T-4/T-5).

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:07:01 -04:00
Joseph Doherty 3531834cce fix(modbus): review r1 - resolved address keys win only when the editor pinned them
N1: the m2 precedence trusted region/address whenever the keys were present,
but the AdminUI model stamps defaulted HoldingRegisters/0 on every save, so a
string-only tag (CSV import, or any editor toggle) silently became HR[0] -
reads AND writes. The resolved keys are now authoritative only when the editor
marked them ("addressResolved": true, written by Resolve); an unmarked
addressString is always resolved through the parser with the driver's family.
The model no longer stamps address fields for a string-only tag, CSV export
leaves its address cells blank, and the editor hint distinguishes string-only
from pinned tags. Tests: CSV import and editor save of a string-only tag, a
stamped-default blob, and the pinned positive control across families.
n1: docs/v2/modbus-addressing.md updated (FC23 opt-in, maxReadGap semantics,
coalescing scope, addressString/family rules, coil coercion, refusals).
n2: form round-trip tests for UseFC23ForMultiRegisterWrites /
DisableReadCoalescing. n4: the editor keeps unresolved address text when the
parent echoes back its own emit. n3: noted, no change.

Refs #741
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:06:02 -04:00
Joseph Doherty 2a28c06fc7 fix(sql): a skipped tag reports the deploy gate's reason, pinned for the deferred Query model (R06:S-4)
A model:Query tag reached the runtime skip ledger as a generic 'did not map'. The
ledger reason (and the skip warning) now comes from SqlTagDefinitionFactory.Inspect -
the deploy gate's own explanation over the same parser - so /hosts names the
deferral. A test pins that the deploy gate names Query through the parser. Making it
a hard deploy error in Warn mode is a ControlPlane change (follow-up); MySql/Odbc and
allowWrites remain feature issues.

Refs #744
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:04:59 -04:00
Joseph Doherty ac01c88c0e fix(mtconnect): an Agent refusal of /current is a reply, not a comms fault
Health contract: an MTConnectError document (NO_DEVICE, INVALID_REQUEST, ...) is the
Agent answering. The parsers now mark such an InvalidDataException
(MTConnectXml.IsAgentRefusal - the type is sealed, so a Data marker, every other
handler unchanged); ReadAsync reports the batch Bad but does not degrade the read
path, advances LastSuccessfulRead, clears a prior read degradation and warns once
per distinct refusal. An unparseable answer stays a comms fault.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:03:38 -04:00
Joseph Doherty a7d21d5a65 feat(sql): IBatchSubscribable and lifecycle-generation-stamped health (R02:P1, R06:S-9)
R02:P1: SqlDriver runs PollGroupEngine, so it now implements IBatchSubscribable and
raises one OnDataChangeBatch per poll burst (DriverInstanceActor forwards one message
per cycle instead of one per tag).
S-9: InitializeAsync/ShutdownAsync take a lifecycle generation; their health writes
apply only while current, and a poll that started under an older generation (one that
outlived the engine's teardown wait) no longer writes its verdict onto the new
configuration. Poll-driven updates can no longer end Initializing - only the
initialize's own final write does. Lifecycle writes now preserve LastSuccessfulRead.

Refs #744
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:03:23 -04:00
Joseph Doherty 0ad460c5c4 Merge lane B: OPC UA address space remediation (#732)
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:01:44 -04:00
Joseph Doherty d67634cc4f fix(mtconnect): redact the Agent URI everywhere it is shown (R06:T-8)
The driver logged and surfaced the raw AgentUri (which may carry user:pass@) as
HostName (the /hosts key), in ~20 log lines and in exception messages that reach
DriverHealth.LastError, and the client's request-URI messages (timeouts, stream
end, not-a-stream, framing) embedded it too - while the Test-Connect probe
deliberately redacted it. New MTConnectUriRedaction (user-info, and for the
configured string its query, removed; a clean URI is unchanged so host keys do
not move); the driver computes its display form once per options document.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:00:40 -04:00
Joseph Doherty ab29b76be0 perf(sql): rate-limit per-poll warnings and cache poll plans per reference list (R06:S-6, S-7)
S-6: a frozen group's BadTimeout warning and a failing group's query-error warning
(with SQL text) logged every poll per group indefinitely; both are now rate-limited
per plan (once a minute), and an uncoercible cell warns once per tag instead of once
per poll. Every poll still publishes the Bad status.
S-7: plans and the slot map were rebuilt every poll although a poll loop passes the
same reference list each cycle. They are cached per list instance (weakly keyed) and
rebuilt only when a reference resolves to a different definition instance, which a
catalog-gate swap produces.

Refs #744
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:00:25 -04:00
Joseph Doherty 081fa693af fix(s7): access-denied is an answer; review round-1 minors (R07:H4 follow-through)
I-1: a PUT/GET-disabled CPU (every read denied) stayed Faulted with
LastSuccessfulRead frozen, so the comms-loss watchdog reconnected and
re-subscribed it every 90 s forever. Both access-denied arms now also record
an answer: Faulted is kept (worst state wins), the stamp advances, and the
sticky write fault still clears only on a real success. Pinned end to end in
Runtime.Tests: the real S7Driver under the real DriverInstanceActor watchdog
is not reconnected across 2x the threshold; the control (every read timing
out) is.

Minors:
- m-1: injectable pass clock (UtcNow); "LastSuccessfulRead advanced" is now
  asserted strictly, including the write-refusal case.
- m-2: the editor uses S7TagShapeRules.IsByteAnchored instead of its own list.
- m-3: a release that races an init publish also cancels the probe loop the
  publish started after the release took its snapshot.
- m-4: a retained poll failing during a lifecycle operation writes no health.
- m-5: a multi-var chunk that fell back memoises its members refused alone.
- m-7: access-denied needs S7.Net's exact wording and no transport fault in
  the chain.
- m-8: sync teardown clears the write-side Degraded owner; a sync Dispose
  racing a running shutdown is tested.

Refs #742
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:57:53 -04:00
Joseph Doherty d4b9fa1c29 perf(mtconnect): headers-only connectivity probe (R06:T-7)
The connectivity probe parsed the full /probe device model every 5 s and discarded
it. It now calls a new IMTConnectAgentClient.PingAsync: one GET /probe read with
ResponseHeadersRead and a status check; the body is dropped unread. Same failure
surface as ProbeAsync (the default implementation delegates to it). The probe-tick
tests now count pings and pin that no tick issues a full /probe; the shutdown
test's absence assertion gained a positive control.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:57:43 -04:00
Joseph Doherty 0a5aa9fc43 fix(sql): row-cap key-value and where-pair plans with a TOP/LIMIT N+1 dialect seam (R06:S-1)
Key-value and where-pair plans had no row limit, so a selector pointed at a history
or events table materialised every matching row on every poll. ISqlDialect gains
RowLimitPrefix/RowLimitSuffix(n) (SQL Server TOP (n), Postgres LIMIT n, Oracle FETCH
FIRST n ROWS ONLY); plans carry a RowCap (distinct keys, or 1) and ask for RowCap+1
rows; the reader also stops client-side at RowCap+1. Getting the extra row proves the
selector ambiguous: the group publishes BadConfigurationError with a rate-limited
warning. The reader reports those answered refusals so the driver counts them as
answer evidence, and any refusal-only poll now restores Healthy after a comms fault.

Refs #744
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:57:13 -04:00
Joseph Doherty f05e3bf6f4 perf(mtconnect): one Agent write request per device (R06:T-6)
Writes were one POST per slot although the Agent's PUT surface accepts several
id=value pairs. The slots surviving the local refusals now go out as one
WriteObservationsAsync(device, pairs) per device (new IMTConnectAgentClient method;
the default loops the single-pair call). A batch closes at another device or a
repeated DataItem so same-item writes keep request order; a refused multi-slot
request is retried per slot so one refused DataItem costs only its own slot.
Health contract: any Agent answer (accepted or refused) advances
LastSuccessfulRead with the state following the read / stream flags.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:55:37 -04:00
Joseph Doherty fd6ecf8293 fix(modbus): review r0 - FC23 opt-in, explicit maxReadGap 0 = off, legacy coil types load, one health writer
Coordinator rulings and Important findings from review r0:
1. FC23 is opt-in (new UseFC23ForMultiRegisterWrites, default false); a config
   without it - including the persisted "disableFC23": false every form-saved
   config carries - keeps the FC16 wire. DisableFC23 stays the hard kill-switch.
2. An explicit maxReadGap 0 means coalescing off again (its pre-lane meaning);
   the gap-8 default applies only to an absent key.
3. Coils/DiscreteInputs tags persisted with a register dataType (the old
   editor saved Int16) are coerced to Bool, never refused, with no deploy
   warning (a warning can be promoted to a blocking error). The editor loads
   them as Bool. Register region + Bool is still refused.
4. One comms-fault slot for reads and writes: a good read clears a write's
   transport fault and vice versa.
Minors: Dispose writes its own Unknown health (m1); persisted region/address
are authoritative over addressString so a Family change cannot remap a live
tag (m2); a malformed-but-delivered reply no longer fails the unit fast (m3);
a fresh socket published after a concurrent dispose is torn down (m4); the
editor resets address text per tag (m5); master merged (m6); the FC23 latch
log says re-init restores it (m7). Back-compat tests use the old persisted
JSON shapes and fail without the fixes.

Refs #741
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:55:32 -04:00
Joseph Doherty cb9a48ee96 Merge lane AbLegacy: AbLegacy driver remediation (#738)
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:50:46 -04:00
Joseph Doherty 21b4f00f46 feat(mtconnect): /sample lag measurement + adaptive count (R06:T-5)
Subscription throughput was hard-capped at SampleCount / SampleIntervalMs with no
lag detection: a machine producing more fell behind the ring buffer forever,
re-baselining every buffer-length with every value backlog-stale.

- The parser carries the header's optional lastSequence; each chunk measures
  lag = lastSequence + 1 - nextSequence and whether it was saturated (full count).
- Three consecutive saturated chunks with a positive (or unknown) lag reopen the
  stream at the cursor reached with count doubled, up to 16x SampleCount (max
  65536), through a new IMTConnectAgentClient.SampleAsync(from, count, ct) overload
  (default implementation ignores count). A raised stream that delivers nothing
  steps back to the last good count; at the ceiling one Warning per pump.
- MTConnectDriver implements IDriverDiagnostics: sampleCount, sampleLag,
  sampleSaturatedChunks, sampleCountRaises on the /hosts bag. The cap is
  documented on SampleCount.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:50:42 -04:00
Joseph Doherty 7f5d959fb9 feat(sql): surface IS_NULLABLE on browsed column attributes (R06:S-5)
Every dialect projects IS_NULLABLE and nothing read it. The browse session now reads
it (tolerantly: a dialect that does not project it reads as unknown) and the picker's
descriptor line says 'nullable' / 'NOT NULL', so an operator knows a cell can come
back NULL (Uncertain, or Bad under nullIsBad) before committing the tag. The runtime
catalog loader keeps reading names only - nothing at runtime consumes nullability.

Refs #744
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:49:24 -04:00
Joseph Doherty c12269c221 Merge commit '5135a036' into remed/lane-Modbus 2026-10-22 15:47:50 -04:00
Joseph Doherty 91b7e9541b fix(sql): withhold the JSON parser's text on a malformed driver config (R06:S-8)
The factory quoted System.Text.Json's message (and attached the JsonException) into
the failure that reaches InitializeFailed -> health LastError -> /hosts; a pasted
connection-string literal is exactly the malformed text that lands there. The
message now names the instance and the line/byte position only, as the probe and
browser already do.

Refs #744
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:47:18 -04:00
Joseph Doherty 04ad774235 Merge commit '5135a036' into remed/lane-S7 2026-10-22 15:46:38 -04:00
Joseph Doherty ff4e210a27 fix(opcua): replay kept scripted conditions after a rebuild; fit window values onto re-shaped nodes (Lane B fix round 3)
N3 (critical): the node manager carries only NATIVE conditions' state over a
full rebuild; scripted conditions come back fresh and rely on the engine's
re-assert, which since the off-thread plan is handled in the window and lands
on the discarded instance. On a rebuild the condition replay scope is now
AddedConditions plus every enabled scripted condition in the new composition
(native kept conditions stay carried-over, not replayed). The test sink again
models the real rebuild: it clears non-native condition state only.

N4: a surgical in-place shape edit (DataType / IsArray / ArrayLength) resets the
raw node and its UNS mirror to BadWaitingForInitialData without ensuring them,
so the window's value was not replayed and the two NodeIds disagreed. The
applier records re-shaped nodes (MaterialisationRecording.Reshaped); their
deferred value replays only once fitted to the new shape (VariableShape.TryFit:
as is, or converted invariantly; never an old-typed value - WriteValues assigns
the value verbatim with no type check), otherwise it is dropped at Debug and the
node keeps waiting. Status-only entries are not replayed onto a re-shaped node.

Minors: m1/m2 documented on _replayScope (degraded-path only); m3 corrected the
false carry-over premise in the scope doc (the ScriptedAlarmHostActor ordering
remark is outside this lane - F8); m4 the replay Debug log reports alarm-state
and alarm-quality counts again.

Refs #732
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:46:29 -04:00
Joseph Doherty 16f401b9e7 fix(sql): dispose the poll reader's concurrency gate once no group holds it (R06:S-3)
SqlPollReader's SemaphoreSlim was never disposed and a new reader is built per
rebind. The reader is now IDisposable with a reference count (owner + one per
in-flight group): the gate is disposed only when the last group - including a
timed-out one still wedged in the provider - releases it, so nothing ever releases
into a disposed semaphore. ApplyBinding disposes the replaced reader; the final
DisposeAsync disposes the live one (Shutdown does not, since a re-init without a
rebind keeps polling through it).

Refs #744
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:46:21 -04:00
Joseph Doherty 29c829b540 test(mtconnect): a stale-session pump may finish before the restart retires it
Since the T-1 generation guard a pump stops on its own once its session generation
is gone, so the STAB-20 test's 'the retired pump is retained' assertion raced it
(intermittent failure). The test now accepts either outcome - retained for shutdown
to join, or already finished - which is what the retention exists to guarantee.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:45:54 -04:00
Joseph Doherty e3cb4f0c7b fix(ablegacy): opaque replies are neither answers nor faults; round-2 minors
Review r1 N1 (Important): the I1 fix made IsDeviceRefusal default-open and
mapped libplctag's ERR_REMOTE_ERR to an answer. libplctag folds EVERY non-zero
PCCC STS and CIP status into that code, including a routing hop's "unconnected
send timed out" / "connection lost" / "remote node missing", and native
BadReply covers truncated / mismatched / unmapped replies - so a powered-off
PLC behind a bridge read as reachable: Healthy, LastSuccessfulRead advancing,
the comms-loss watchdog unable to fire.

Three classes now, each pinned both ways:
- no reply (Timeout/BadConnection/Abort/Pending/unknown): comms fault;
- answered - an ALLOW-LIST again (NotFound/NotAllowed/OutOfBounds/TooLarge/
  TooSmall/TypeMismatch/DeviceStateConflict): proves reachability;
- OPAQUE (BadReply/Partial; ERR_REMOTE_ERR/ERR_BAD_STATUS now translate to the
  existing BadReply, OPC UA BadCommunicationError as before r0): per-tag Bad,
  handle kept, coalescing fallback, never fail-fast, never an answer. Option
  (a): a device whose pass/call produced only opaque outcomes is Degraded with
  LastSuccessfulRead not advanced, so the watchdog reaches a dead routed PLC.
  The probe and index reads classify the same way.

Minors:
- N2: a mismatched array (type vs data file) READS BadTypeMismatch instead of
  decoding the file at the wrong width as Good.
- N3: a swept indirect resolution is never reconnected under a forgotten gate;
  that one call answers neutrally.
- N4: an index read answered Unsupported is neutral, not a transport fault.
- N5: ArrayFileMismatch checks indirect addresses (the file letter is static).
- Test nit: the evicted-while-waiting test signals gate arrival via a seam
  instead of sleeping.

Live: 51/51 on ab_server (slc500/micrologix/plc5 x both transports), incl. an
opaque-only libplctag device staying Degraded with LastSuccessfulRead frozen.

Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:45:04 -04:00
Joseph Doherty c529948745 perf(mqtt): publish a write batch concurrently, serialised per topic; writes feed health (R06:M-8)
WriteAsync published one awaited QoS 1 round-trip per request, so 50 writes to a
stalled broker cost 50 publish deadlines. Different topics now publish concurrently
(at most 8 in flight); same-topic writes stay in request order. A caller cancel still
fills every slot. A successful publish advances LastSuccessfulRead. MiniBroker now
frames packets properly and records/PUBACKs PUBLISHes.

Refs #744
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:42:50 -04:00