N3: a keyless blob carrying the addressResolved marker was served from its
string by the driver but saved as HR[0] by the editor model / CSV path. The
precedence rule now lives in ONE pure resolver in Driver.Modbus.Contracts
(ModbusTagAddressResolution.Classify/Resolve): keys when region+address are
both present, else a string-only addressString resolved with the driver's
family (refused when it cannot parse), else refused. The marker never decides.
Consumers that now ask it instead of restating it: the driver's tag reader
(ModbusTagDefinitionFactory.FromTagConfig), the deploy gate (Inspect /
DescribeRejection), the editor model (AddressShape / IsStringOnly /
HasKeyAddress / ResolveWith, ToJson stamping, Validate), the CSV export cells
and the editor hint. The model writes keys only when it loaded them or a field
was authored - never defaults it did not resolve (also closes two divergences
the new matrix found: malformed-string+marker and non-string addressString were
saved as HR[0]).
One table-driven test (ModbusAddressAuthorityAgreementTests) runs 15 persisted
shapes through driver, deploy gate, model load/save/Validate and the real CSV
export->import under all four families; breaking the CSV export or reverting
the model to the marker rule fails it.
Minors: m1 CSV address cells keep the string+marker while it still describes
them (lossless unchanged round trip), drop it when contradicted, and the review
grid reports the drop; m2 the pre-lane test uses the real export residual; m3
unmarked string-only + typed-cell case added; m4 stale docs/summary and the
unused factory parameter removed.
Refs #741
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
- 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
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
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
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
U1: a tag's addressString (Modicon / mnemonic / DL205 V-memory / MELSEC
D-X-Y grammar) now resolves through ModbusAddressParser with the DRIVER's
Family and MelsecSubFamily at every initialize and is authoritative; the
family-unaware gates (deploy Inspect, AdminUI parse consult) accept what
some family resolves. The tag editor gets an address-text field that
resolves with the owning driver's family and persists both the string and
the resolved region/offset/type fields; a hand edit drops the string.
DisableFC23 now gates FC23: by default a 2..121-register holding write is
one FC23 write + read-back (a unit answering exception 01, or failing FC23 at
the transport, moves to FC16 for the rest of the generation); RTU framing
deframes the FC23 reply. Live-checked: the pymodbus fixture implements FC23.
Also: the AdminUI driver form serialises ModbusDriverOptions (TimeSpan
strings), which the factory DTO never read - every timing field on the form
was dead. The DTO now accepts both shapes (*Ms keys win).
Refs #741
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
U2: MaxReadGap defaults to 8 and 0 now means adjacent-only rather than
off; a new DisableReadCoalescing switch (factory DTO + AdminUI form) turns it
off entirely. The planner also merges Coils/DiscreteInputs (FC01/FC02, gap in
bits), String scalars and BitInRegister tags - sixteen bit tags on one status
word are one FC03 instead of sixteen. Auto-prohibition and its re-probe /
bisection use the region's own function code (a coil range was re-probed
with FC04).
Refs #741
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Review r0 M9. The AdminUI form wrote coalesceReads:true on every save, pinning
today's default into every edited driver. AbLegacyDriverOptions.CoalesceReads
is now bool? (null = default ON, never serialised, same encoding as
Transport); the form omits the key when the switch is on and writes only an
explicit false. Persisted coalesceReads:true keeps loading as on.
Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
P6: Coils/DiscreteInputs carry only Bool and register regions never Bool;
the factory (and so the deploy-gate Inspect and the AdminUI parse consult)
refuses a mismatch, and an absent dataType defaults per region (Bool for a
bit region). A HoldingRegisters+Bool tag used to throw out of the coalescing
planner and stall every tag of the subscription; the planner also skips any
incompatible pair defensively. The tag editor keeps the type compatible when
the region changes; the CLI refuses the pair up front. The array-write parity
test records the Coils divergence from DriverArrayWriteSupport (owned outside
this lane) as follow-up F1.
Refs #741
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Review 07 P7 (rest): adjacent words on one data file were never coalesced
(one round trip per tag) although the array path proves the transport can read
a span. Within a device pass, scalar tags on one N/B/F/L file within 8
elements of each other (block <= 200 data bytes, under the 236-byte typed-read
limit) now ride one multi-element read and are decoded from their offsets with
exactly the CLR types the single-tag path returns (int / float / bool).
Only shapes the single path decodes identically are coalesced: indirect,
sub-element, array, file-number-less (S:/I:/O:) and width-mismatched tags keep
the single path. A block the device refuses (an authored element past the end
of the file, a native PLC-5 non-N file) falls back to per-tag reads for its
members and its shape is remembered; a comms fault fails the members together.
New driver-config key coalesceReads (default true) with an AdminUI switch
(Razor change: AbLegacyDriverForm, for the Wave 4 live check). Adds an
env-gated ab_server parity case (coalesced == per-tag values).
Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
P8: on the native transport the bounded-parallel read window is also the cap
on services per Multiple Service Packet (the device's shared scheduler packs
whatever is queued, within the connection's request/reply byte budget), so the
default 8 cost a 1000-tag controller ~125 round trips per poll instead of ~10.
The effective window (AbCipDriver.ReadWindow) is now max(64, option) when the
native tag factory serves reads; libplctag and injected factories keep the
option exactly. Option doc + AdminUI help text updated (Razor: Wave 4 live
check).
P7 addendum: a work item now takes its window slot only after its runtime
exists. A cold create queues on its device's single-flight connect gate, and
holding a slot there let one dead device's backoff-expiry attempt occupy the
whole window for a full Timeout, stalling every other device's reads.
Refs #737
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
FocasDataType.String was authorable on any address, but no area read returns text: PMC
fell through to a byte read, PARAM to Int32, MACRO to a double, and the String node
received a boxed number at Good.
One rule, FocasTagDefinitionFactory.DescribeDataTypeMismatch, now backs every path:
- FromTagConfig rejects the pair, so the driver skips + reports it (BadNodeIdUnknown,
never a Good number) and Inspect names the reason at deploy;
- FocasTagConfigModel.Validate refuses it on the typed fields;
- the AdminUI editor only offers String where it is readable and shows the rule's text
inline (kept visible for a loaded blob that already carries it);
- the CLI read/subscribe/probe/write commands refuse it at the boundary.
String stays valid on FIXED: paths (Identity/Program name/mode text) and is ignored on
ALARM: tags; an unparseable address keeps its own diagnosis.
Razor change (FocasTagConfigEditor.razor) needs the Wave 4 live check.
Refs #740
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
- Writes: a second write to a tag already inside the driver waits in the
queue behind it (_tagsInFlight) while other tags still run under the
cap of 4; queued writes are stamped and failed BadTimeout at dequeue
once older than the 8 s host Ask budget (Props queuedWriteBudget)
instead of being written late.
- Initialise attempts are bounded pessimistically (initializeTimeout,
default 30 s = 2x Tier C) on top of the invoker's token-only timeout:
an overrun is logged as abandoned, reported InitializeFailed so the
retry loop continues, and its late result is dropped by generation.
Generations come from a process-wide Interlocked counter so a prior
incarnation's late result cannot collide (F6).
- PostStop shares one shutdownTimeout budget across shutdown + dispose.
- TelemetryDialSupervisor cancels the establishment deadline on
StreamStopped; FleetDiagnosticsClient builds the ActorSelection inside
the try (a malformed node degrades to "unavailable"); the diagnostics
page labels the column "Last health publish".
- Tests: same-tag serialisation with a distinct-tag positive control;
stale queued write BadTimeout; token-ignoring hung init -> later
attempt connects; stopwatch assertion demoted to a ceiling.
Refs #731
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
DriverInstanceDiagnostics.Name carries the string driver-instance id
(what /hosts, telemetry and the artifact key on) and DriverInstanceId
the DB row Guid; the wire record has no slot for the authored display
name. The write-cap test asserts entry order per slot pair instead of
completion order of two concurrent writes.
Refs #731
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
DriverHostActor wraps the health publisher it hands its children in a
ChildHealthLedger (last snapshot per driver id, forwarded to the real
publisher), so GetDiagnostics now answers with each child's row id,
authored name, last-published state, device counts (host statuses when
the driver probes hosts, else one device) and last-change time — instead
of Guid.Empty / "Spawned" / 0 / now. Entries are forgotten on StopChild
and operator Restart.
IFleetDiagnosticsClient gains its first consumer: the new
/fleet/diagnostics page (node selector + ?node= query, revision,
running-from-cache notice, per-driver table). FleetDiagnosticsClient
logs the degrade-to-empty path at Warning.
Review ref: 02/U1.
Refs #731
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Review 2026-10-21 G-5, G-6, G-8 ("wire them all").
- G-5: GatewayGalaxyHierarchySource now pages the DiscoverHierarchy RPC
itself (DiscoverHierarchyRawAsync) at Repository.DiscoverPageSize. The
mxaccessgw client's typed DiscoverHierarchyAsync overloads hard-code a
5000-row page and DiscoverHierarchyOptions carries no page size, so the
authored knob reached nothing; the raw request takes page_size on the
wire, so it is honoured here (non-positive → 5000). The loop keeps the
client's repeated-page-token guard.
- G-6: GalaxyDriverProbe reads ProbeTimeoutSeconds from the config and
runs under min(caller budget, configured cap) — the knob can shorten a
probe, never extend it past the host's budget. The TCP preflight now
shares that deadline instead of hanging on the caller's token alone.
- G-8: GatewayGalaxyDataWriter runs a batch as one lane per distinct tag,
concurrently and bounded at 8 (MaxConcurrentWriteLanes); same-tag
writes keep their authored order inside one lane and results land at
their request's index. Acks stay serial by design.
- GalaxyDriverForm: help text now says what the two knobs actually do.
Refs #735
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Clears CommentChecker findings in src/ (3,280 -> 209, the remainder being false
positives like PLC-5/IEEE-754/M0.0) by adding missing summaries/params/returns,
normalising inheritdoc usage, and removing Gitea/review tracking IDs while keeping
the rationale they annotated. Comment-only: comment-stripped sources are identical
to the parent commit.
- Endpoint rewrite: the driver now connects to the configured host AND port (scheme
match, advertised path kept) instead of the server-advertised URL — fixes NAT/port-map
upstreams and lets an in-test proxy carry the session (SDK SelectEndpoint rule).
- TcpStallProxy + OpcUaClientDriverStallWireTests: the in-place blip (#670) is now
wire-tested on loopback — one Bad, frozen read stamp, same session, identical value
republished Good, a post-recovery change arrives, no source-time regression after
the SDK's late backlog flush.
- Defect found by that test, fixed: a change published right after a stall rode a
Publish response the SDK timed out and never republished, leaving the node Good on
the pre-change value indefinitely. New trailing reconcile read (Timeout +
KeepAliveInterval after recovery) publishes any Good value newer than the last
accepted one, through the replay guard's mark.
- IDriverConnectionLossSource (Core.Abstractions): opt-in definitive-loss signal.
DriverInstanceActor attaches in PreStart / detaches in PostStop and turns a raise
into a disconnect only while Connected. OpcUaClient raises once per session on
keep-alive BadSessionIdInvalid/BadSessionClosed/BadSessionNotActivated/
BadServerHalted (measured: the SDK never re-opens a halted channel) or a dropped
subscription; ambiguous codes stay with the #527 watchdog.
- Replay guard: one Information summary per recovery when the window closes
("dropped N late notifications across M refs"), with a timer so quiet
subscriptions log it too.
- ReconnectPeriod deleted (options, AdminUI form, docs, tests); stored configs that
carry reconnectPeriod still parse in the factory, probe and browser.
- Docs: OpcUaClient.md, DriverLifecycle.md, driver-specs.md, CLAUDE.md.
- OtOpcUaNodeManager overrides HistoryUpdateData / HistoryDeleteRawModified / HistoryDeleteAtTime,
block-bridging to the optional IHistorianDataUpdater under the shared HistoryRead batch limiter +
deadline. Insert/Replace/Update map to HistoryUpdateMode; Remove, IsDeleteModified, events and
structure data are BadHistoryOperationUnsupported. Per-item codes stitched back in request order
(MinValue SourceTimestamp => BadInvalidTimestamp, untypeable value => BadTypeMismatch); success
clears the base's pre-seeded Unsupported error.
- HistoryWrite AccessLevel bit OR-ed in for historized nodes when an updater is wired (raw + UNS);
it is dispatch-load-bearing in the SDK base. OtOpcUaSdkServer.SetHistorianDataUpdater; the hosted
service assigns it from an optional HistorianUpdaterHolder before SetSink, clears it on stop.
- AuthorizeHistoryUpdate: ACL on => scoped OpcUaOperation.HistoryUpdate decision (replaces the role
check); ACL off => #563 key re-check + new OpcUaDataPlaneRoles.HistoryUpdate role, conferred by
ApiKeyInterimRoleMapper only from a Cluster-scoped HistoryUpdate grant (WriteOperate does not).
- AdminUI ACL editor offers NodePermissions.HistoryUpdate.
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
HistoryRead is served server-side by IHistorianDataSource; nothing ever dispatched
to a driver's IHistoryProvider. Removes IHistoryProvider, the OpcUaClient history
passthrough, DriverCapability.HistoryRead (+ resilience tier rows and AdminUI row),
HistoryReadStatusException and its node-manager catches, and the tests that only
covered them. Shared result types and HistoryAggregateNotSupportedException stay
(moved to Historian/HistoryReadContracts.cs). A stored ResilienceConfig still
carrying a HistoryRead key parses (diagnostic only; new test).
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
An ALARM: address (*, <Type>, <Type>/<n>, <Type>/<lo>-<hi>, T<code>, OTHER) on a raw tag that
also carries the cross-driver alarm object is ONE condition with ConditionId == RawPath and a
Kind (Raise / Retrigger / Clear), so DriverHostActor.ForwardNativeAlarm routes it and
NativeAlarmProjector activates it. Fixes all seven triage defects:
1. SubscribeAlarmsAsync treated the host's RawPaths as device hosts -> nothing polled.
2. Decorated "{host}#{Type}:{Number}" ConditionId -> every event dropped.
3. No Kind -> projector kept prior state.
4. WireFocasClient.ReadAlarmsAsync answered [] on failure -> mass clear + un-acked re-raise.
It now throws; a failed device read keeps every condition's state.
5. Acks lost on reconnect -> told-record dedup (MTConnect #626 pattern) held for the driver's
lifetime; same-set re-subscribe / re-init re-raise nothing; changed set prunes leavers only.
6. Subscribe threw while AlarmProjection.Enabled=false -> the tag is the opt-in; flag is a no-op.
7. Alarm tags could not be authored -> ALARM: grammar (FocasAlarmSelector, Contracts) backs the
editor, the address picker, the deploy-time inspector and driver init.
Severity: authored alarm.severity -> exact OpcUaSeverity; else the 16i-era type bucket. The type
table stays the only one (moved to Contracts, documented 16i-era; 30i codes not invented; raw
T<code> selector + raw code in every message). Read-only: ack is server-side. Also clears the
value table on a re-initialise (#630 gap that let a removed/moved tag keep reading).
E2E: Runtime.Tests FocasNativeAlarmEndToEndTests drives the real driver through the real
DriverHostActor and asserts the condition goes Active then inactive.
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
Strict width+kind eligibility for sum-batched scalars (others read singly); latch only on ADS
1793/1794, split 1792/1797/1798 without latching; 32 KiB per-command budget and 500-item ceiling;
short sum response handled per item. Diagnostics: opt-in diagnosticsEnabled (default off), reuse the
probe's ReadState, sample after the host-state transition, JitterMs renamed HeadroomMs, compact
per-device line so many devices fit the 16-entry cap, unavailable-symbol latch cleared on reconnect.
AdminUI: min/max on sum-read max, diagnostics checkbox. Pure planner extracted and unit-tested.
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
Sum-read (#310): batches a poll's reads per device into sum commands capped at sumReadMaxItems,
per-item ADS errors mapped per tag, falls back to individual reads when the target refuses the
command. Diagnostics (#314): ADS state, app name, online-change count, task cycle/exec/jitter via
IDriverDiagnostics. Re-implemented against the current raw-byte read path (stranded auto/driver-gaps
commits predate it). Sum-write not ported. Unit tests only; live gate pending a route.
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
Nothing read the mapping table beyond the startup validator; v3 UNS projection is
explicit via UnsTagReference. Legacy keys in stored DriverConfig are ignored (pinned).
Reverts the docker-dev opc-plc seed workaround from fdee129f.
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
New S7DataType members WString/Char/WChar/Dtl/Date/Tod/Time/S5Time mapped onto existing
DriverDataType (String / DateTime / Int32 ms); writes refuse out-of-range with BadOutOfRange.
Optional localTsap/remoteTsap/connectionType through options, factory JSON, probe and AdminUI form.
NegotiatedPduSize snapshotted at connect and exposed via GetDiagnostics (DriverHealth has no bag).
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
M1: PageErrorBoundary wraps the body of every InteractiveServer page; the inert layout boundary is removed.
m1: DisposeBridge cancels its CTS when the 5 s wait times out.
m2: a CSV import that commits after the modal closed still raises OnSaved.
m3: document SecurityMode=None no-fallback in Client.CLI.md / Client.UI.md.
m4: browsePageDelayMs written only when loaded or changed; negative flagged.
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe