dohertj2 dohertj2
  • Joined on 2026-02-20
dohertj2 merged pull request dohertj2/lmxopcua#144 2026-04-20 00:30:17 -04:00
AclsTab Probe-this-permission (#196 slice 1)
dohertj2 created pull request dohertj2/lmxopcua#144 2026-04-20 00:30:04 -04:00
AclsTab Probe-this-permission (#196 slice 1)
dohertj2 created branch acls-tab-probe in dohertj2/lmxopcua 2026-04-20 00:30:04 -04:00
dohertj2 pushed to acls-tab-probe at dohertj2/lmxopcua 2026-04-20 00:30:04 -04:00
ecc2389ca8 AclsTab Probe-this-permission — first of three #196 slices. New /clusters/{ClusterId}/draft/{GenerationId} ACLs-tab gains a probe card above the grant table so operators can ask the trie "if cn=X asks for permission Y on node Z, would it be granted, and which rows contributed?" without shell-ing into the DB. Service thinly wraps the same PermissionTrieBuilder + PermissionTrie.CollectMatches call path the Server's dispatch layer uses at request time, so a probe answer is by construction identical to what the live server would decide. New PermissionProbeService.ProbeAsync(generationId, ldapGroup, NodeScope, requiredFlags) — loads the target generation's NodeAcl rows filtered to the cluster (critical: without the cluster filter, cross-cluster grants leak into the probe which tested false-positive in the unit suite), builds a trie, CollectMatches against the supplied scope + [ldapGroup], ORs the matched-grant flags into Effective, compares to Required. Returns PermissionProbeResult(Granted, Required, Effective, Matches) — Matches carries LdapGroup + Scope + PermissionFlags per matched row so the UI can render the contribution chain. Zero side effects + no audit rows — a failing probe is a question, not a denial. AclsTab.razor gains the probe card at the top (before the New-grant form + grant table): six inputs for ldap group + every NodeScope level (NamespaceId → UnsAreaId → UnsLineId → EquipmentId → TagId — blank fields become null so the trie walks only as deep as the operator specified), a NodePermissions dropdown filtered to skip None, Probe button, green Granted / red Denied badge + Required/Effective bitmask display, and (when matches exist) a small table showing which LdapGroup matched at which level with which flags. Admin csproj adds ProjectReference to Core — the trie + NodeScope live there + were previously Server-only. Five new PermissionProbeServiceTests covering: cluster-level row grants a namespace-level read; no-group-match denies with empty Effective; matching group but insufficient flags (Browse+Read vs WriteOperate required) denies with correct Effective bitmask; cross-cluster grants stay isolated (c2's WriteOperate does NOT leak into c1's probe); generation isolation (gen1's Read-only does NOT let gen2's WriteOperate-requiring probe pass). Admin.Tests 92/92 passing (was 87, +5). Admin builds 0 errors. Remaining #196 slices — SignalR invalidation + draft-diff ACL section — ship in follow-up PRs so the review surface per PR stays tight.
dohertj2 merged pull request dohertj2/lmxopcua#143 2026-04-20 00:06:31 -04:00
Pin ab_server to libplctag v2.6.16 + real SHA256 hashes
dohertj2 pushed to v2 at dohertj2/lmxopcua 2026-04-20 00:06:31 -04:00
852c710013 Merge pull request (#143) - Pin ab_server to libplctag v2.6.16
8ce5791f49 Pin libplctag ab_server to v2.6.16 — real release tag + SHA256 hashes for all three Windows arches. Closes the "pick a current version + pin" deferral left by the #180 PR docs stub. Verified the release lands ab_server.exe inside libplctag_2.6.16_windows_<arch>_tools.zip alongside plctag.dll + list_tags_* helpers by downloading each tools zip + unzip -l'ing to confirm ab_server.exe is present at 331264 bytes. New ci/ab-server.lock.json is the single source of truth — one file the CI YAML reads via ConvertFrom-Json instead of duplicating the hash across the workflow + the docs. Structure: repo (libplctag/libplctag) + tag (v2.6.16) + published date (2026-03-29) + assets keyed by platform (windows-x64 / windows-x86 / windows-arm64) each carrying filename + sha256. docs/v2/test-data-sources.md §2.CI updated — replaces the prior placeholder (ver = '<pinned libplctag release tag>', expected = '<pinned sha256>') with the real v2.6.16 + 9b78a3de... hashes pinned table, and replaces the hardcoded URL with a lockfile-driven pwsh step that picks windows-x64 by default but swaps to x86/arm64 by changing one line for non-x64 CI runners. Hash-mismatch path throws with both the expected + actual values so on the first drift the CI log tells the maintainer exactly what to update in the lockfile. Two verification notes from the release fetch: (1) libplctag v2.6.16 tools zips ship ab_server.exe + plctag.dll together — tests don't need a separate libplctag NuGet download for the integration path, the extracted tools dir covers both the simulator + the driver's native dependency; (2) the three Windows arches all carry ab_server.exe, so ARM64 Windows GitHub runners (when they arrive) can run the integration suite without changes beyond swapping the asset key. No code changes in this PR — purely docs + the new lockfile. Admin tests + Core tests unchanged + passing per the prior commit.
Compare 2 commits »
dohertj2 pushed to pin-libplctag-ab-server at dohertj2/lmxopcua 2026-04-20 00:06:22 -04:00
8ce5791f49 Pin libplctag ab_server to v2.6.16 — real release tag + SHA256 hashes for all three Windows arches. Closes the "pick a current version + pin" deferral left by the #180 PR docs stub. Verified the release lands ab_server.exe inside libplctag_2.6.16_windows_<arch>_tools.zip alongside plctag.dll + list_tags_* helpers by downloading each tools zip + unzip -l'ing to confirm ab_server.exe is present at 331264 bytes. New ci/ab-server.lock.json is the single source of truth — one file the CI YAML reads via ConvertFrom-Json instead of duplicating the hash across the workflow + the docs. Structure: repo (libplctag/libplctag) + tag (v2.6.16) + published date (2026-03-29) + assets keyed by platform (windows-x64 / windows-x86 / windows-arm64) each carrying filename + sha256. docs/v2/test-data-sources.md §2.CI updated — replaces the prior placeholder (ver = '<pinned libplctag release tag>', expected = '<pinned sha256>') with the real v2.6.16 + 9b78a3de... hashes pinned table, and replaces the hardcoded URL with a lockfile-driven pwsh step that picks windows-x64 by default but swaps to x86/arm64 by changing one line for non-x64 CI runners. Hash-mismatch path throws with both the expected + actual values so on the first drift the CI log tells the maintainer exactly what to update in the lockfile. Two verification notes from the release fetch: (1) libplctag v2.6.16 tools zips ship ab_server.exe + plctag.dll together — tests don't need a separate libplctag NuGet download for the integration path, the extracted tools dir covers both the simulator + the driver's native dependency; (2) the three Windows arches all carry ab_server.exe, so ARM64 Windows GitHub runners (when they arrive) can run the integration suite without changes beyond swapping the asset key. No code changes in this PR — purely docs + the new lockfile. Admin tests + Core tests unchanged + passing per the prior commit.
dohertj2 created pull request dohertj2/lmxopcua#143 2026-04-20 00:06:22 -04:00
Pin ab_server to libplctag v2.6.16 + real SHA256 hashes
dohertj2 created branch pin-libplctag-ab-server in dohertj2/lmxopcua 2026-04-20 00:06:21 -04:00
dohertj2 pushed to v2 at dohertj2/lmxopcua 2026-04-19 23:59:23 -04:00
05ddea307b Merge pull request (#142) - ab_server per-family profiles
32dff7f1d6 ab_server integration fixture — per-family profiles + documented CI-fetch contract. Closes task #180 (AB CIP follow-up — ab_server CI fixture). Replaces the prior hardcoded single-family fixture with a parametric AbServerProfile abstraction covering ControlLogix / CompactLogix / Micro800 / GuardLogix. Prebuilt-Windows-binary fetch is documented as a CI YAML step rather than fabricated C#-side, because SHA-pinned binary distribution is a CI workflow concern (libplctag owns releases, we pin a version + verify hash) not a test-framework concern. New AbServerProfile record + KnownProfiles static class at tests/.../AbServerProfile.cs. Four profiles: ControlLogix (widest coverage — DINT/REAL/BOOL/SINT/STRING atomic + DINT[16] array so the driver's @tags Symbol-Object decoder + array-bound path both get end-to-end coverage), CompactLogix (atomic subset — driver-side ConnectionSize quirk from PR 10 still applies since ab_server doesn't enforce the narrower limit), Micro800 (ab_server has no dedicated --plc micro800 mode — falls back to controllogix while driver-side path enforces empty routing + unconnected-only per PR 11; real Micro800 coverage requires a 2080 lab rig), GuardLogix (ab_server has no safety subsystem — profile emulates the _S-suffixed naming contract the driver's safety-ViewOnly classification reads in PR 12; real safety-lock behavior requires a 1756-L8xS physical rig). Each profile composes --plc + --tag args via BuildCliArgs(port) — pure string formatter so the composition logic is unit-testable without launching the simulator. AbServerFixture gains a ctor overload taking AbServerProfile + port (defaults back to ControlLogix on parameterless ctor so existing test suites keep compiling). Fixture's InitializeAsync hands the profile's CLI args to ProcessStartInfo.Arguments. New AbServerTheoryAttribute mirrors AbServerFactAttribute but extends TheoryAttribute so a single test can MemberData over KnownProfiles.All + cover all four families. AbCipReadSmokeTests converted from single-fact to theory parametrized over KnownProfiles.All — one row per family reads TestDINT + asserts Good status + Healthy driver state. Fixture lifecycle is explicit try/finally rather than await using because IAsyncLifetime.DisposeAsync returns ValueTask + xUnit's concrete IAsyncDisposable shim depends on xunit version; explicit beats implicit here. Eight new unit tests in AbServerProfileTests.cs (runs without the simulator so CI green even when the binary is absent): BuildCliArgs composes port + plc + tag flags in the documented order; empty seed-tag list still emits port + plc; SeedTag.ToCliSpec handles both 2-segment scalar + 3-segment array; KnownProfiles.ForFamily returns expected --plc arg for every family (verifies Micro800 + GuardLogix both fall back to controllogix); KnownProfiles.All covers every AbCipPlcFamily enum value (regression guard — adding a new family without a profile fails this test); ControlLogix seeds every atomic type the driver supports; GuardLogix seeds at least one _S-suffixed safety tag. Integration tests still skip cleanly when ab_server isn't on PATH. 11/11 unit tests passing in this project (8 new + 3 prior). Full Admin solution builds 0 errors. docs/v2/test-data-sources.md gets a new "CI fixture" subsection under §2.Gotchas with the exact GitHub Actions YAML step — fetch the pinned libplctag release, SHA256-verify against a pinned hash recorded in the repo's CI lockfile (drift = fail closed), extract, append to PATH. The C# harness stays PATH-driven so dev-box installs (cmake + make from source) work identically to CI.
Compare 2 commits »
dohertj2 merged pull request dohertj2/lmxopcua#142 2026-04-19 23:59:22 -04:00
ab_server fixture � per-family profiles + CI-fetch contract (#180)
dohertj2 created branch ab-server-fixture-profiles in dohertj2/lmxopcua 2026-04-19 23:59:11 -04:00
dohertj2 pushed to ab-server-fixture-profiles at dohertj2/lmxopcua 2026-04-19 23:59:11 -04:00
32dff7f1d6 ab_server integration fixture — per-family profiles + documented CI-fetch contract. Closes task #180 (AB CIP follow-up — ab_server CI fixture). Replaces the prior hardcoded single-family fixture with a parametric AbServerProfile abstraction covering ControlLogix / CompactLogix / Micro800 / GuardLogix. Prebuilt-Windows-binary fetch is documented as a CI YAML step rather than fabricated C#-side, because SHA-pinned binary distribution is a CI workflow concern (libplctag owns releases, we pin a version + verify hash) not a test-framework concern. New AbServerProfile record + KnownProfiles static class at tests/.../AbServerProfile.cs. Four profiles: ControlLogix (widest coverage — DINT/REAL/BOOL/SINT/STRING atomic + DINT[16] array so the driver's @tags Symbol-Object decoder + array-bound path both get end-to-end coverage), CompactLogix (atomic subset — driver-side ConnectionSize quirk from PR 10 still applies since ab_server doesn't enforce the narrower limit), Micro800 (ab_server has no dedicated --plc micro800 mode — falls back to controllogix while driver-side path enforces empty routing + unconnected-only per PR 11; real Micro800 coverage requires a 2080 lab rig), GuardLogix (ab_server has no safety subsystem — profile emulates the _S-suffixed naming contract the driver's safety-ViewOnly classification reads in PR 12; real safety-lock behavior requires a 1756-L8xS physical rig). Each profile composes --plc + --tag args via BuildCliArgs(port) — pure string formatter so the composition logic is unit-testable without launching the simulator. AbServerFixture gains a ctor overload taking AbServerProfile + port (defaults back to ControlLogix on parameterless ctor so existing test suites keep compiling). Fixture's InitializeAsync hands the profile's CLI args to ProcessStartInfo.Arguments. New AbServerTheoryAttribute mirrors AbServerFactAttribute but extends TheoryAttribute so a single test can MemberData over KnownProfiles.All + cover all four families. AbCipReadSmokeTests converted from single-fact to theory parametrized over KnownProfiles.All — one row per family reads TestDINT + asserts Good status + Healthy driver state. Fixture lifecycle is explicit try/finally rather than await using because IAsyncLifetime.DisposeAsync returns ValueTask + xUnit's concrete IAsyncDisposable shim depends on xunit version; explicit beats implicit here. Eight new unit tests in AbServerProfileTests.cs (runs without the simulator so CI green even when the binary is absent): BuildCliArgs composes port + plc + tag flags in the documented order; empty seed-tag list still emits port + plc; SeedTag.ToCliSpec handles both 2-segment scalar + 3-segment array; KnownProfiles.ForFamily returns expected --plc arg for every family (verifies Micro800 + GuardLogix both fall back to controllogix); KnownProfiles.All covers every AbCipPlcFamily enum value (regression guard — adding a new family without a profile fails this test); ControlLogix seeds every atomic type the driver supports; GuardLogix seeds at least one _S-suffixed safety tag. Integration tests still skip cleanly when ab_server isn't on PATH. 11/11 unit tests passing in this project (8 new + 3 prior). Full Admin solution builds 0 errors. docs/v2/test-data-sources.md gets a new "CI fixture" subsection under §2.Gotchas with the exact GitHub Actions YAML step — fetch the pinned libplctag release, SHA256-verify against a pinned hash recorded in the repo's CI lockfile (drift = fail closed), extract, append to PATH. The C# harness stays PATH-driven so dev-box installs (cmake + make from source) work identically to CI.
dohertj2 created pull request dohertj2/lmxopcua#142 2026-04-19 23:59:11 -04:00
ab_server fixture � per-family profiles + CI-fetch contract (#180)
dohertj2 pushed to v2 at dohertj2/lmxopcua 2026-04-19 23:18:07 -04:00
42649ca7b0 Merge pull request (#141) - Redundancy OTel + SignalR
1f3343e61f OpenTelemetry redundancy metrics + RoleChanged SignalR push. Closes instrumentation + live-push slices of task #198; the exporter wiring (OTLP vs Prometheus package decision) is split to new task #201 because the collector/scrape-endpoint choice is a fleet-ops decision that deserves its own PR rather than hardcoded here. New RedundancyMetrics class (Singleton-registered in DI) owning a System.Diagnostics.Metrics.Meter("ZB.MOM.WW.OtOpcUa.Redundancy", "1.0.0"). Three ObservableGauge instruments — otopcua.redundancy.primary_count / secondary_count / stale_count — all tagged by cluster.id, populated by SetClusterCounts(clusterId, primary, secondary, stale) which the poller calls at the tail of every tick; ObservableGauge callbacks snapshot the last value set under a lock so the reader (OTel collector, dotnet-counters) sees consistent tuples. One Counter — otopcua.redundancy.role_transition — tagged cluster.id, node.id, from_role, to_role; ideal for tracking "how often does Cluster-X failover" + "which node transitions most" aggregate queries. In-box Metrics API means zero NuGet dep here — the exporter PR adds OpenTelemetry.Extensions.Hosting + OpenTelemetry.Exporter.OpenTelemetryProtocol or OpenTelemetry.Exporter.Prometheus.AspNetCore to actually ship the data somewhere. FleetStatusPoller extended with role-change detection. Its PollOnceAsync now pulls ClusterNode rows alongside the existing ClusterNodeGenerationState scan, and a new PollRolesAsync walks every node comparing RedundancyRole to the _lastRole cache. On change: records the transition to RedundancyMetrics + emits a RoleChanged SignalR message to both FleetStatusHub.GroupName(cluster) + FleetStatusHub.FleetGroup so cluster-scoped + fleet-wide subscribers both see it. First observation per node is a bootstrap (cache fill) + NOT a transition — avoids spurious churn on service startup or pod restart. UpdateClusterGauges groups nodes by cluster + sets the three gauge values, using ClusterNodeService.StaleThreshold (shared 30s convention) for staleness so the /hosts page + the gauge agree. RoleChangedMessage record lives alongside NodeStateChangedMessage in FleetStatusPoller.cs. RedundancyTab.razor subscribes to the fleet-status hub on first parameters-set, filters RoleChanged events to the current cluster, reloads the node list + paints a blue info banner ("Role changed on node-a: Primary → Secondary at HH:mm:ss UTC") so operators see the transition without needing to poll-refresh the page. IAsyncDisposable closes the connection on tab swap-away. Two new RedundancyMetricsTests covering RecordRoleTransition tag emission (cluster.id + node.id + from_role + to_role all flow through the MeterListener callback) + ObservableGauge snapshot for two clusters (assert primary_count=1 for c1, stale_count=1 for c2). Existing FleetStatusPollerTests ctor-line updated to pass a RedundancyMetrics instance; all tests still pass. Full Admin.Tests suite 87/87 passing (was 85, +2). Admin project builds 0 errors. Task #201 captures the exporter-wiring follow-up — OpenTelemetry.Extensions.Hosting + OTLP vs Prometheus + /metrics endpoint decision, driven by fleet-ops infra direction.
Compare 2 commits »
dohertj2 merged pull request dohertj2/lmxopcua#141 2026-04-19 23:18:06 -04:00
Redundancy OTel Meter + RoleChanged SignalR push (#198)
dohertj2 created pull request dohertj2/lmxopcua#141 2026-04-19 23:17:57 -04:00
Redundancy OTel Meter + RoleChanged SignalR push (#198)
dohertj2 created branch redundancy-otel-signalr in dohertj2/lmxopcua 2026-04-19 23:17:57 -04:00
dohertj2 pushed to redundancy-otel-signalr at dohertj2/lmxopcua 2026-04-19 23:17:57 -04:00
1f3343e61f OpenTelemetry redundancy metrics + RoleChanged SignalR push. Closes instrumentation + live-push slices of task #198; the exporter wiring (OTLP vs Prometheus package decision) is split to new task #201 because the collector/scrape-endpoint choice is a fleet-ops decision that deserves its own PR rather than hardcoded here. New RedundancyMetrics class (Singleton-registered in DI) owning a System.Diagnostics.Metrics.Meter("ZB.MOM.WW.OtOpcUa.Redundancy", "1.0.0"). Three ObservableGauge instruments — otopcua.redundancy.primary_count / secondary_count / stale_count — all tagged by cluster.id, populated by SetClusterCounts(clusterId, primary, secondary, stale) which the poller calls at the tail of every tick; ObservableGauge callbacks snapshot the last value set under a lock so the reader (OTel collector, dotnet-counters) sees consistent tuples. One Counter — otopcua.redundancy.role_transition — tagged cluster.id, node.id, from_role, to_role; ideal for tracking "how often does Cluster-X failover" + "which node transitions most" aggregate queries. In-box Metrics API means zero NuGet dep here — the exporter PR adds OpenTelemetry.Extensions.Hosting + OpenTelemetry.Exporter.OpenTelemetryProtocol or OpenTelemetry.Exporter.Prometheus.AspNetCore to actually ship the data somewhere. FleetStatusPoller extended with role-change detection. Its PollOnceAsync now pulls ClusterNode rows alongside the existing ClusterNodeGenerationState scan, and a new PollRolesAsync walks every node comparing RedundancyRole to the _lastRole cache. On change: records the transition to RedundancyMetrics + emits a RoleChanged SignalR message to both FleetStatusHub.GroupName(cluster) + FleetStatusHub.FleetGroup so cluster-scoped + fleet-wide subscribers both see it. First observation per node is a bootstrap (cache fill) + NOT a transition — avoids spurious churn on service startup or pod restart. UpdateClusterGauges groups nodes by cluster + sets the three gauge values, using ClusterNodeService.StaleThreshold (shared 30s convention) for staleness so the /hosts page + the gauge agree. RoleChangedMessage record lives alongside NodeStateChangedMessage in FleetStatusPoller.cs. RedundancyTab.razor subscribes to the fleet-status hub on first parameters-set, filters RoleChanged events to the current cluster, reloads the node list + paints a blue info banner ("Role changed on node-a: Primary → Secondary at HH:mm:ss UTC") so operators see the transition without needing to poll-refresh the page. IAsyncDisposable closes the connection on tab swap-away. Two new RedundancyMetricsTests covering RecordRoleTransition tag emission (cluster.id + node.id + from_role + to_role all flow through the MeterListener callback) + ObservableGauge snapshot for two clusters (assert primary_count=1 for c1, stale_count=1 for c2). Existing FleetStatusPollerTests ctor-line updated to pass a RedundancyMetrics instance; all tests still pass. Full Admin.Tests suite 87/87 passing (was 85, +2). Admin project builds 0 errors. Task #201 captures the exporter-wiring follow-up — OpenTelemetry.Extensions.Hosting + OTLP vs Prometheus + /metrics endpoint decision, driven by fleet-ops infra direction.
dohertj2 pushed to v2 at dohertj2/lmxopcua 2026-04-19 23:09:39 -04:00
251f567b98 Merge pull request (#140) - AlarmSurfaceInvoker
404bfbe7e4 AlarmSurfaceInvoker — wraps IAlarmSource.Subscribe/Unsubscribe/Acknowledge through CapabilityInvoker with multi-host fan-out. Closes alarm-surface slice of task #161 (Phase 6.1 Stream A); the Roslyn invoker-coverage analyzer is split into new task #200 because a DiagnosticAnalyzer project is genuinely its own scaffolding PR (Microsoft.CodeAnalysis.CSharp.Workspaces dep, netstandard2.0 target, Microsoft.CodeAnalysis.Testing harness, ProjectReference OutputItemType=Analyzer wiring, and four corner-case rules I want tests for before shipping). Ship this PR as the runtime guardrail + callable API; the analyzer lands next as the compile-time guardrail. New AlarmSurfaceInvoker class in Core.Resilience. Three methods mirror IAlarmSource's three mutating surfaces: SubscribeAsync (fan-out: group sourceNodeIds by IPerCallHostResolver.ResolveHost, one CapabilityInvoker.ExecuteAsync per host with DriverCapability.AlarmSubscribe so AlarmSubscribe's retry policy kicks in + returns one IAlarmSubscriptionHandle per host); UnsubscribeAsync (single-host, defaultHost); AcknowledgeAsync (fan-out: group AlarmAcknowledgeRequests by resolver-mapped host, run each host's batch through DriverCapability.AlarmAcknowledge which does NOT retry per decision #143 — alarm-ack is a write-shaped op that's not idempotent at the plant-floor level). Drivers without IPerCallHostResolver (Galaxy single MXAccess endpoint, OpcUaClient against one remote, etc.) fall back to defaultHost = DriverInstanceId so breaker + bulkhead keying still happens; drivers with it get one-dead-PLC-doesn't-poison-siblings isolation per decision #144. Single-host single-subscribe returns [handle] with length 1; empty sourceNodeIds fast-paths to [] without a driver call. Five new AlarmSurfaceInvokerTests covering: (a) empty list short-circuits — driver method never called; (b) single-host sub routes via default host — one driver call with full id list; (c) multi-host sub fans out to 2 distinct hosts for 3 src ids mapping to 2 plcs — one driver call per host; (d) Acknowledge does not retry on failure — call count stays at 1 even with exception; (e) Subscribe retries transient failures — call count reaches 3 with a 2-failures-then-success fake. Core.Tests resilience-builder suite 19/19 passing (was 14, +5); Core.Tests whole suite still green. Core project builds 0 errors. Task #200 captures the compile-time guardrail: Roslyn DiagnosticAnalyzer at src/ZB.MOM.WW.OtOpcUa.Analyzers that flags direct invocations of the eleven capability-interface methods inside the Server namespace when the call is NOT inside a CapabilityInvoker.ExecuteAsync/ExecuteWriteAsync/AlarmSurfaceInvoker.*Async lambda. That analyzer is the reason we keep paying the wrapping-class overhead for every new capability.
Compare 2 commits »