v4.3.0
[v4.3.0] - 2026-04-23
This is a consolidation release. For end users the only action required is
to flush any persistent cache on upgrade. The public Client, ClientBuilder,
and ClientKernelInterface surfaces are unchanged (only additive) — with one
visible behaviour change: NodeManagementModule is back in the default module
list (see Changed below).
Compliance
- ECC sequence numbers now follow OPC UA 1.05.4 (Part 6 §6.7.2.4). For ECC
policies the first sequence number is0(was1) and wraps at
UInt32.MaxValue. RSA is unchanged. Compatible with both pre- and
post-d188383UA-.NETStandard servers. No public API change. Covered by 12
new tests intests/Unit/Security/SecureChannelSequenceNumberTest.php. RequestHeader.timestampis now a validUtcTime. Per OPC UA 1.05 Part 4 §7.33 the field is 100-ns ticks since 1601-01-01; the client was writing0(which decodes to 1601-01-01), so servers withverifyRequestTimestampenabled (e.g. open62541) rejected every request withBadInvalidTimestamp (0x80230000). Fixed in all 7 call sites that build a RequestHeader:Protocol/AbstractProtocolService::writeRequestHeader,Protocol/SessionService(CreateSession + ActivateSession, secure + non-secure variants),Protocol/SecureChannelRequest(OpenSecureChannel),Security/SecureChannel(OPN inner),Client/ManagesSessionTrait(CloseSession),Client/ManagesSecureChannelTrait(CloseSecureChannel). Now every RequestHeader carrieswriteDateTime(new \DateTimeImmutable()).- Anonymous
policyIdis now discovered for all security modes.Client\ManagesConnectionTrait::performConnectguarded the GetEndpoints discovery call with$isSecure, so withSecurityPolicy::Nonethe client never read the server's advertisedUserTokenPolicy[0].policyIdand fell back to a hardcoded"anonymous"— the value UA-.NETStandard happens to use. Other servers (open62541:"open62541-anonymous-policy") replied withBadIdentityTokenInvalid (0x80200000). Discovery now runs whenever either the server certificate or the anonymous policy ID is still unknown, independent of the security mode. The cert-required-but-missing error is still raised only when the connection actually needs a cert. - NodeManagement service type IDs now reference the DefaultBinary encoding.
Protocol\ServiceTypeId::{ADD_NODES,ADD_REFERENCES,DELETE_NODES,DELETE_REFERENCES}_REQUESTheld the abstract*RequestDataType NodeIds (486 / 492 / 498 / 504); the binary protocol dispatches on the*Request_Encoding_DefaultBinaryobject NodeIds (488 / 494 / 500 / 506). Browse / Read / Write were already correct. The bug never surfaced in CI because UA-.NETStandard replies withServiceFaultto unsupported services and the client's former crash-on-ServiceFault (EncodingException: Buffer underflow) masked the wrong-type-id symptom.
The three RequestHeader / discovery / type-id items above were latent wire-format bugs — all three had no visible effect against UA-.NETStandard (permissive enough to tolerate them) and only surfaced once integration testing reached open62541 (see CI below).
Security (BREAKING for persistent caches)
- Removed
unserialize()from every cache code path.FileCache, the
Clientcache runtime, and the module-level cache writes now go through
Cache\WireCacheCodec— plain JSON gated by the existingWire\WireTypeRegistry
allowlist. Prevents PHP object-injection attacks on cache backends writable
by an untrusted party. - Pre-v4.3.0 cache entries are discarded on first access (cache miss +
refetch). Flush persistent caches on upgrade to avoid the transient
cold-cache period. - New:
Cache\CacheCodecInterface,Cache\WireCacheCodec,Exception\CacheCorruptedException. Types\StructureDefinitionandTypes\StructureFieldnow implement
Wire\WireSerializable(they are cached bydiscoverDataTypes()).
Added
ClientBuilder::setCacheCodec(?CacheCodecInterface)— override the default
codec. Omit to get the secureWireCacheCodecdefault.CoreWireTypes::registerForCache(WireTypeRegistry)— register only the
types actually cached (subset of::register()).ClientKernelInterface::getCacheCodec(): CacheCodecInterface— additive;
third-party implementations of the interface must add the method.- Client-side
ServiceFaultdecoding. When a server returns a top-levelServiceFault(TypeIdns=0;i=397, OPC UA 1.05 Part 4 §7.35) the client now raisesServiceExceptioncarrying theResponseHeader.ServiceResultinstead of reading past the empty fault body and throwing the misleadingEncodingException: Buffer underflow: need 4 bytes, have 0. New helperProtocol\ServiceFault::throwIf(NodeId, int)is invoked fromAbstractProtocolService::readResponseMetadata()(covers every module service in one hook) and from the twoSessionServicedecoders that have dedicated read paths (decodeCreateSessionResponse/decodeActivateSessionResponse). New constantProtocol\ServiceTypeId::SERVICE_FAULT = 397. Exception\ServiceUnsupportedException— dedicated subclass ofServiceException, raised byServiceFault::throwIfspecifically when theServiceResultisBadServiceUnsupported (0x800B0000). Lets callers distinguish "this server does not implement this service set" from other transport-level faults without string-matching on the exception message. ExtendsServiceException, so existing handlers continue to match.
Fixed
- Cleaned up dead
executeWithRetry()code in the (now-removed) concrete
Kernel\ClientKernel.Client\ManagesConnectionTrait::executeWithRetry()
is the single source of truth. The old method logged "retrying" and
re-threw without callingreconnect(), so no behaviour change for users. ManagesHandshakeTrait::performDiscoveryHandshake()now recognises anERRresponse during the HEL/ACK exchange and raises the sameHandshakeException("Server error during handshake: [<code>] <message>")as the main handshake. Previously the discovery path threw a genericMessageTypeException("Expected ACK response, got: ERR"), which was less informative and became the observed error whenever the main connect was preceded by discovery.
Changed
- Removed the unused
Kernel\ClientKernelconcrete class. It was never
instantiated at runtime —ClientimplementsClientKernelInterface
itself. The interface is unchanged. Third-party code mocking the concrete
class in tests should switch to mocking the interface. NodeManagementModuleis back inClientBuilder::defaultModules(). WithServiceFaultdecoding andServiceUnsupportedExceptionin place, the module is wired unconditionally — the builder does not probe the server at connect time, so there is zero added latency or network traffic for users who never call NodeManagement. If the server does not implement the service set, the first call toaddNodes()/deleteNodes()/addReferences()/deleteReferences()raisesServiceUnsupportedException("Server returned ServiceFault: 0x800B0000 BadServiceUnsupported"); subsequent calls behave identically. Default module count is now 8 (was 7 in v4.2.x, 8 in v4.1.x and earlier).
CI
- open62541 test server consumed via
php-opcua/extra-test-suite@v1.0.0. New sibling repo that ships a docker-compose stack of OPC UA servers not covered byuanetstandard-test-suite(open62541withNodeManagementon:24840, room for Prosys / Milo / node-opcua in future minor releases). Pre-built images are published to GHCR on tag push; every matrix leg of theintegrationjob consumes them via the composite action —docker compose pull+up -dwith a CI-specific override that setsrestart: "no"(the base compose usesrestart: unless-stoppedfor dev machines). Mandatory on every leg, not an opt-in per PHP version — same treatment asuanetstandard-test-suite. Warm step time ≈ 10–15 s vs the 3–5 min that an in-repo build from source would have required. No endpoint env var is threaded through the workflow: port24840is part of the suite's versioned contract andTestHelper::ENDPOINT_NODE_MANAGEMENThardcodes it, mirroring theENDPOINT_NO_SECURITY/ENDPOINT_USERPASS/ … constants used foruanetstandard-test-suite. composer format:checkpromoted to a dedicated, non-blockingformatjob.
Testing
- Expanded unit coverage with new and extended test files across
Cache,
Security,Types,Module,Wire,Client, andTestingnamespaces. tests/Unit/Protocol/ServiceFaultTest.php— 9 cases covering positive detection, status-code preservation, non-fault typeIds, namespace-0 guard, string-identifier guard, buggy-good-status edge case,ServiceUnsupportedExceptionforBadServiceUnsupported, baseServiceExceptionfor other statuses, subclass-of-ServiceExceptionbackward compatibility.tests/Unit/ClientBuilder/ModuleBuilderTest.php— updated to reflect the 8-module default.tests/Integration/NodeManagementTest.php— six tests un-skipped, tagged->group('integration')(no dedicatednode-managementgroup — theextra-test-suitedependency is mandatory, just likeuanetstandard-test-suite). Run whenever the caller starts both suites (locally) or uses both composite actions (CI); there is no env-var gate and no skip. New-node namespace switched from2(UA-.NETStandard-specific) to1(standard Application namespace).tests/Integration/Helpers/TestHelper::ENDPOINT_NODE_MANAGEMENT(constant) andconnectForNodeManagement()(helper) — both now resolve the endpoint from a hardcodedopc.tcp://localhost:24840, matchingENDPOINT_NO_SECURITY/ENDPOINT_USERPASS/ … in shape. The module is in the client defaults, so the helper is a one-linerClientBuilder::connect(self::ENDPOINT_NODE_MANAGEMENT).