f5475868f79a59e1e1ec7bdae4cda81d037f3b25
1816 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f5475868f7 |
fix(inspect): trim before the framing check; stop advisories swallowing the backstop (#556); pin the MQTT payloadFormat mode contract (#557 item 2)
#556 item 1 — leading whitespace defeated every Inspect's pre-filter. Seven factories (Modbus/S7/AbCip/AbLegacy/TwinCAT/FOCAS/Mqtt) tested `reference[0] != '{'`, so a padded blob returned EMPTY and a tag the driver refuses deployed clean. The check now trims first, matching the idiom Sql/Calculation/MTConnect/OpcUaClient already used. The raw reference is still what is handed to the mapper — the driver reads the stored blob, not a trimmed copy — so the gate keeps mirroring the runtime rather than becoming lenient. Both directions are pinned: a padded blob the mapper accepts still draws nothing, and one that is not object-framed AFTER trimming still draws nothing (it is not an equipment-tag TagConfig at all). Six of the seven mappers hand the blob to a whitespace-tolerant JsonDocument.Parse, so their verdict is unchanged by padding. MQTT is the exception: both its readers carry the same framing gate, so a padded blob is genuinely refused — which is why the untrimmed pre-filter was a real defect there and not a cosmetic mismatch. #556 item 2 — FOCAS and MQTT appended advisories unconditionally and then gated the catch-all rejection message on `warnings.Count == 0`, so an advisory (which fires on ACCEPTED blobs too, and says nothing about a refusal) silently swallowed the only message naming a refusal nothing else can. The two classes now accumulate in SEPARATE lists and the backstop gates on the rejection count alone — structural, not string matching. MQTT's swallow was reachable in production (a padded blob its readers refuse while every describe branch finds the parsed document well-formed); FOCAS's backstop is currently unreachable by audit, so its guard is protective. #557 item 2 — decided and pinned: KEEP the union verdict. Evidence: FromSparkplugTagConfig hard-codes PayloadFormat=Json and never consults the key; only MqttSubscriptionManager (the Plain ingest path) decodes by it; and MqttTagConfigModel.ToJson deliberately omits the key entirely in Sparkplug mode (#513). So a Sparkplug driver genuinely serves the tag and rejecting it would be stricter than the runtime. The cost — served behaviour depends on the driver's MqttMode, which no tag row records — is paid by an ADVISORY naming the key and the mode dependency, not by changing the verdict, so both gates keep agreeing. The same invalid value on a blob the union REFUSES stays a rejection; the class is decided by the accept gate, never by the key. Falsified: reverting Modbus's trim reddens the padded-rejection theory; neutering MQTT's backstop guard to the pre-#556 shape reddens the advisory-plus-unnameable-rejection test; disabling the #557 advisory reddens the contract pin; and for FOCAS, removing a describe branch alone stays green (the backstop covers it) while additionally neutering the guard goes red. |
||
|
|
7cfe7b4a24 | merge: #554 — authoring models consult the driver's own runtime parse (TagConfigParseGate): verdict from the parse, wording from Inspect; five drivers' non-enum gaps closed, deliberately-stricter rules kept and pinned | ||
|
|
5ca4f8041d |
fix(adminui): authoring models consult the driver's own parse for non-enum TagConfig rules (#554)
#549/#551 made every typed editor judge its driver's enum KEYS against the runtime's own reader and stopped there. Every other reason a reader refuses a tag met no gate until deploy, or until the driver silently skipped the tag: an absent Modbus `region`, an `"address":"notanumber"`, an S7 `stringLength` of 300, a TwinCAT `dataType: "Structure"` (a VALID enum name the reader refuses), a Sql blob the parser cannot recognise as Sql at all, an MQTT `"qos":null`. Rather than hand-mirroring N more rules into N models — the drift #551 closed on the deploy side — each `<Driver>TagConfigModel.Validate()` now asks its driver's own parse whether it would read the AS-LOADED blob, via the new `TagConfigParseGate`. One authority for the verdict AND the wording. Two cautions are load-bearing and are pinned by tests: - The gate is the PARSE VERDICT, never "the inspector said something". FOCAS, MQTT and Calculation inspectors emit advisories for blobs their reader ACCEPTS, so a non-empty-inspection gate would make the modal stricter than the runtime and leave rows nobody can save (#520/#535). Inspect is consulted only to word a refusal that already happened. - It judges the blob the model was LOADED FROM, not the typed fields — the lenient load ("address":"notanumber" ⇒ 0) is what hides the defect. Same capture-the-source shape as S7's `_dataTypeMissingInSource`. Deliberately-stricter editor rules were surveyed and KEPT, not aligned: AbLegacy's arrayLength > 256 (reader clamps, but the un-clamped value still sets ArrayDimensions), FOCAS's unparseable address (#540), MQTT wildcard topics + Sparkplug segment chars, and Calculation's trigger/50 ms floors. They are now recorded fleet-wide in a table the tests assert is still accurate. Behaviour change: Modbus `{}` is now refused. Modbus was the one mapped editor with no required field, and `{}` is exactly what the modal persists for a config nobody touched — the driver skips it and it reads BadNodeIdUnknown forever with the deploy green. Its pinned test asserted the old, weaker behaviour and is rewritten with the rationale. Falsification: neutering only the Modbus consult turns 14 tests red, all Modbus-scoped; every #549 enum test stays green. |
||
|
|
0e9d977f9a |
test(raw): write the browse-commit parity guard the mapper's comment cites (#555)
RawBrowseCommitMapper.BuildTagConfig's unknown-driver fallback claimed to be guarded by RawBrowseCommitMapperParityTests, which did not exist anywhere under tests/. The claim it made — "every BROWSABLE driver is handled above" — is the exact claim that stopped being true when SqlDriverBrowser registered (deferment.md G-6), so the comment was asserting the invariant instead of pinning it. The guard now exists and enumerates DriverTypeNames.All: - Browsability's bespoke arm is DERIVED from production, not hand-listed: the test runs AddAdminUI() and reads back every registered IDriverBrowser's DriverType — the same set BrowserSessionService.CanBrowse indexes. A driver that gains or loses a bespoke browser moves the guard with it. - The universal arm (DiscoveryDriverBrowser -> ITagDiscovery) is NOT derivable from the AdminUI assembly: it references driver Contracts/Browser projects, never the runtime drivers, and IDriverFactory is composed in the Host. Covered instead by requiring every declared driver type to be typed or explicitly exempted with a reason (Modbus, Calculation), with a third fact mechanically refuting any exemption the derived arm contradicts — the Sql failure mode. Falsified three ways, each red naming the offender: disabling the Sql branch trips the derived-arm fact; disabling the MTConnect branch trips the enumeration fact (the universal-arm half, which no bespoke registration would have caught); adding Sql to the exemption list trips the contradiction fact. Comment corrected to state what the test actually asserts, including the residual trust the universal arm leaves. |
||
|
|
1f351250ed | merge: #552 — Sql browse-commit states a topByTimestamp row selector from column-type metadata (catalog-order tie-break); no-candidate leaves refused in words at the modal | ||
|
|
7498d39e5b |
fix(sql): browse-commit states a topByTimestamp row selector (#552)
`RawBrowseCommitMapper.BuildSqlTagConfig` emitted a WideRow blob carrying only table + columnName. A wide-row tag must also say WHICH row it reads, so both `SqlEquipmentTagParser` (the runtime authority) and `SqlTagConfigModel.Validate` (the authoring gate) refused it — a browse-committed Sql tag was never readable, first silently as BadNodeIdUnknown and, since #551 put the gate on the import, loudly at commit. `SqlBrowseSession` now states a fourth address field: the table's preferred date/time-typed column, computed from the columns its attributes call ALREADY read (no extra catalog round-trip). Candidacy is decided by `ISqlDialect.MapColumnType` reporting `DriverDataType.DateTime` — never by column name, which would order a wide-row read by a string column that merely looks like a timestamp. Multiple candidates tie-break on FIRST IN CATALOG ORDER (every dialect's column SQL orders by ordinal position), so the pick is stable across calls and servers; it is a visible, editable default the typed editor round-trips. No candidate (a table with no date/time column) is an operator-actionable refusal, not an invented selector: the only remaining selector is a whereColumn/whereValue pair whose value is data, not schema, so `DescribeUncommittableLeaf` refuses that leaf by name, names the table, and points at the tag editor. The session and the mapper now agree via `SqlBrowseAddressFields` constants rather than two independently spelled string literals — a disagreement there is silent, and is exactly the shape this defect had. `RawTagConfigGateTests`' Sql leg flips from "records the gap" to "browse-commit output passes the gate", with the no-candidate case pinned as its own test. |
||
|
|
cf2af8a5a2 |
fix(csv): stop the typed-column merge laundering the TagConfigJson cell (#553)
ModelBackedCsvDriverTagMap.ApplyTypedColumns round-tripped the row's free-form
TagConfigJson cell through a driver model's FromJson/ToJson to fold the typed
columns in. That load is deliberately lenient — it has to be, so a poisoned blob
can be opened and repaired — so a typo'd value under a key the model owns
("dataType":"Intt16") came back out as the model's DEFAULT, before the #551
import gate or any later gate could judge it. The row imported clean and the tag
the operator described was gone. Only keys the model did not own survived to be
judged, which is why RawTagConfigGateTests looked like it covered this.
MTConnect carried the same defect from the export end. It is the one driver whose
blob accepts several spellings per field, and the runtime PREFERS driverDataType
over the dataType key the MtDataType column writes. The export residual carried
that alias (the map owned only the canonical key), so a re-imported row let it
out-rank the very column the operator had just edited — accepted, then silently
ignored at the driver — while the exported cell printed the spelling the driver
was ignoring, or nothing at all.
The fix preserves evidence; it does not move the verdict:
- ApplyTypedColumns writes the base cell's own tokens back over the model's
serialisation for every key no non-blank typed column overrode. The model still
stamps defaults for keys the row declares nowhere, so fragment cells compose
exactly as before.
- CsvTypedColumn gains ReadKeys / OwnedJsonKeys: the full ordered set of spellings
the RUNTIME reads for that column, mirroring TryFromTagConfig's FirstNonBlank
pairs. A firing column clears the out-ranking aliases; the export residual drops
them; the export cell shows the EFFECTIVE value.
- The review grid's override provenance reads the whole owned set, so an
overridden driverDataType is announced as an overridden dataType would be.
Nothing here decides which values are legal. The verdict stays with
TagConfigValidator on the assembled blob and with IRawTreeService.ImportTagsAsync
behind it — the deliberate reason NOT to follow the issue's own "run the validator
over the pre-merge cell" direction, which would have imposed completeness on a
cell that is legitimately a fragment and false-rejected every typed-column row.
Semantics chosen for the override contradiction: a cell token that a non-blank
column overrides is ACCEPTED with the column winning, and reported by name in
TypedOverrodeFallback. Typed-column-wins is the map's documented contract, the
override is announced rather than silent, and erroring would remove the only way
to repair a poisoned tag through the CSV surface. Every token that reaches the
stored blob is judged; a token the operator overrode is discarded openly.
Falsification: neutering the restore loop + alias handling turns exactly 7 of the
15 new tests red, while RawTagConfigGateTests (#551), RawTagCsvRoundTripTests and
CsvColumnMapReflectionTests all stay green — layering, not replacement.
AdminUI 1248, MTConnect 534, Modbus 373, S7 285, TwinCAT 205 — all green.
|
||
|
|
333ea48805 | merge: #551 — TagConfig gate residuals: accept-gate-first Inspects (7 factories), service-enforced authoring validation (create/update/import), S7 absent-dataType + AbCip members + Modbus stringByteOrder, strict-runtime doc sweep | ||
|
|
05b424788f |
fix(tagconfig): close the four #551 residuals — accept-gate-first Inspect, three authoring rules, CSV/service gate, doc sweep
Gitea #551. 1. CSV import ran no driver-typed config gate. Its typed columns were always strictly parsed, but the free-form TagConfigJson column reached the database unread, so a CSV could author a tag the driver rejects — one that deploys green and reads BadNodeIdUnknown forever. RawTagCsvMapper.Parse now names the offending row in the staged review grid, and RawTreeService enforces the same gate on CreateTag/UpdateTag/ImportTags: a form cannot be the guarantee (the API and any future caller reach none of them, and this repo has no bUnit). A rejected import row is an ERROR — the batch inserts nothing and the row is named — never a skip. Note the brief's premise that manual entry rides ImportTagsAsync is wrong: RawManualTagEntryModal commits row-by-row via CreateTagAsync, which is why the gate had to land on all three write paths. 2/3. The seven pre-#549 Inspect methods ran a hand-list of DescribeInvalidEnum checks, so every rejection cause not on the list was invisible. They are now ACCEPT-GATE-FIRST (the MTConnect/Sql/OpcUaClient/Calculation shape): ask the driver's own reader for the verdict, then explain the refusal, with a catch-all so "the parse rejects but Inspect said nothing" is unreachable. That closes S7's absent dataType, AbCip members[], Modbus stringByteOrder and the type-dependent guards, plus causes the issue did not name (absent region/address/tagPath/symbolPath/topic, TwinCAT Structure, MQTT's Sparkplug tuple). FOCAS and MQTT keep advisories about ACCEPTED blobs, so they diagnose rather than return empty. Authoring half: ModbusTagConfigModel gains stringByteOrder; AbCipTagConfigModel asks the new AbCipTagDefinitionFactory.DescribeInvalidMember; S7TagConfigModel captures whether the SOURCE blob declared dataType and asks S7TagDefinitionFactory.DataTypeKey/DeclaresDataType. One authority per rule. 4. Doc sweep: the six factory Inspect summaries, EquipmentTagConfigInspector's class remarks and AdminOperationsActor's comment all claimed invalid enums are "silently defaulted by the lenient runtime" — false since R2-11 Phase C made the runtime reads strict. They describe rejections now. |
||
|
|
8971861b7c |
fix(adminui): MTConnect editor must read driverDataType at the runtime's precedence and drop the alias on save (#550)
MTConnectTagDefinitionFactory.TryFromTagConfig resolves the coercion type as
FirstNonBlank(blob.DriverDataType, blob.DataType) — driverDataType OUT-RANKS
dataType — while MTConnectTagConfigModel read and wrote dataType alone. That
broke in both directions at once:
* a blob carrying "driverDataType": "Strng" was accepted at authoring while
the runtime rejected the tag (the residual slice of #549), and
* a VALID driverDataType silently overrode whatever type the editor displayed
and saved, so the editor showed a lie and a save RETYPED the tag.
Both halves land together, mirroring the existing IdKeys pattern:
* FromJson reads DataTypeKeys = [driverDataType, dataType] in the runtime's
precedence, with the runtime's own per-key semantics — absent/JSON-null and
present-but-blank fall through, a non-string token stops the walk (the
runtime deserialise throws JsonException on it and rejects the whole tag),
and the raw TOKEN is captured so Validate() judges what the driver judges.
* ToJson drops every non-canonical DataTypeKeys entry and writes dataType as
the enum NAME, so the alias can never out-rank the key the editor writes on
the next load.
* Validate() now names the offending spelling rather than always saying
"dataType".
Doing only one half silently retypes existing tags on their next save; the
falsification runs recorded in the PR confirm each half-fix reddens a distinct
test.
|
||
|
|
3a70a4e6ea | merge: #549 — authoring never accepts a TagConfig enum the runtime rejects (6 models, one shared authority) | ||
|
|
a3c0ec037f |
fix(adminui): authoring must not accept a TagConfig enum the runtime rejects (#549)
MTConnectTagConfigModel read `dataType` with TagConfigJson.GetEnum, which falls
back to String on ANY unrecognised value. A misspelled name ("Strng") from a
hand-authored blob, a CSV import or the API was therefore accepted at authoring
and silently rewritten, while the driver's strict parse rejected it and the tag
died with BadNodeIdUnknown. Found by the G-7 parity tests' falsifiability
control, which had to use the digits-only shape to have a case that failed.
The rule is not restated in the AdminUI. MTConnectTagDefinitionFactory exposes
its parse as `IsAcceptedDataTypeName`, documented as the one authority the
driver, the deploy gate and the authoring model all consult; the model's
Validate() asks it about the raw value the blob carries. The old digits-only
message is NOT retained as a special case: the runtime draws no distinction
between a bare number and a typo — one predicate, one Inspect warning — so a
second message here would be a second statement of a rule this model does not
own. The raw value is captured as the JSON TOKEN, because the runtime
deserialises the key into a `string?` DTO field and an unquoted number throws
there too.
The survey found the same asymmetry at six more editors, contrary to the
assumption that those runtimes silently default: R2-11 Phase C moved every
driver's TagConfig enum read to TagConfigJson.TryReadEnumStrict, which REJECTS
the tag on a present-but-invalid string, while the editors kept loading them
with a silent fallback. Modbus (region/dataType/byteOrder), S7, AbCip, AbLegacy,
TwinCAT and FOCAS now check their enums in Validate() through the runtime's own
TagConfigJson.DescribeInvalidEnumText — added alongside a public
ClassifyEnumText that TryReadEnum now delegates to, so the JSON-token door and
the string door reach one implementation. Mqtt and Sql already carried
equivalent strict checks and are untouched.
Care taken NOT to become stricter than the runtime: a present-but-non-string
token stays accepted for those six (TryReadEnum calls it absent), as does an
absent key, each pinned by a test.
Also adds a direct ProjectReference to Core.Abstractions from the AdminUI — the
save gate now leans on a type it was consuming only transitively.
|
||
|
|
b4f8cb8de7 |
refactor(opcuaclient): move NamespaceMap to a .Session leaf so Contracts is dependency-light
G-7 wired OpcUaClientTagDefinitionFactory.Inspect into the deploy-time TagConfig gate, which gave ControlPlane a ProjectReference to Driver.OpcUaClient.Contracts — and with it, transitively, the whole OPC UA client stack (5 OPCFoundation packages) into every host that loads the control plane. NamespaceMap was the only reason Contracts carried OPCFoundation.NetStandard.Opc.Ua.Client: its entire public surface is ISession/NamespaceTable/NodeId-typed. It moves to a new leaf project, ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session, referenced by the runtime .Driver and by .Browser. Contracts is left with Core.Abstractions and NOTHING else — the same shape as Calculation.Contracts. The namespace stays ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient, so no consumer's `using` changed. The .Session -> .Contracts direction is deliberate: the blob read NamespaceMap needs is OpcUaClientTagDefinitionFactory.TryReadNodeId, the one authority it shares with the deploy gate, and that must stay in the dependency-light half. Why a new project rather than folding NamespaceMap into the runtime .Driver and having .Browser reference it (the Driver.Mqtt.Browser #512-style deliberate exception): the AdminUI project-references .Browser and NOT .Driver, so that shape would pull the OpcUaClient driver runtime — plus Core, Opc.Ua.Configuration and Secrets.Abstractions — into the AdminUI's build/deploy graph. That is exactly the violation #512 spent two extractions removing from Mqtt.Browser; re-introducing it to save one csproj is a bad trade. Guard: ContractsIsDependencyLightTests (Driver.OpcUaClient.Tests) asserts the Contracts manifest names no Opc.Ua* assembly, plus a counterpart asserting .Session does. An ABSENCE assertion is sound on the manifest where a presence one is not — the compiler elides an unused reference, but it MUST emit one the moment Opc.Ua-typed code is added. Falsified by temporarily re-adding the package plus an Opc.Ua.NodeId-typed member: red on Opc.Ua.Types, green again on removal. Evidence — `dotnet list ControlPlane package --include-transitive | grep -i opc`: before: OPCFoundation.NetStandard.Opc.Ua.{Client,Configuration,Core, Security.Certificates,Types} 1.5.378.106 after: none (and 0 Opc.Ua*.dll in ControlPlane's output directory) |
||
|
|
0695f682c2 | merge: G-7 closed — Contracts-side TagConfig Inspect for Sql/MTConnect/Calculation/OpcUaClient wired into the deploy gate | ||
|
|
1469fbb4d1 |
feat(deploy): wire the four G-7 inspectors into the deploy-time TagConfig gate
Adds the Sql / MTConnect / Calculation / OpcUaClient Contracts ProjectReferences to ControlPlane and their entries to EquipmentTagConfigInspector, closing the G-7 asymmetry: the deploy gate now checks every driver the AdminUI checks. Galaxy is the sole remaining unmapped type and is so by design — its dotted tag_name.AttributeName reference has no typed shape, and the AdminUI has no typed editor for it either, so the two gates agree. Adding the ProjectReference is what makes EquipmentTagConfigInspectorCoverageTests demand a map entry (its sweep scans the output directory, so an unreferenced Contracts assembly is invisible to it). Falsified: deleting the Sql entry fails Every_driver_shipping_an_Inspect_is_wired_into_the_dispatch_map with ["SqlTagDefinitionFactory"]; restored. Tests: three cases per driver in EquipmentTagConfigInspectorTests (canonical valid config warns nothing / present-but-invalid field draws a warning naming it / broken JSON warns without throwing), plus a Calculation control pinning that the two shapes the runtime accepts and the editor refuses draw nothing. OpcUaClient moved out of the unmapped-driver theory. New TagConfigAuthoringDeployParityTests (in AdminUI.Tests, the only suite that sees both gates) pins the direction that matters: a config the AdminUI model's own ToJson produces and Validate accepts draws no deploy warning. Its falsifiability control — the same driver types warning about a blob the editor refuses — caught a pre-existing, unrelated authoring-side gap: MTConnectTagConfigModel reads dataType leniently, so a misspelled name loads as String and saves clean while the strict runtime rejects it. Left alone (rewriting the editors is out of scope); the control uses the digits-only shape both gates refuse, and the case is documented inline. |
||
|
|
2947a2b9e2 |
feat(drivers): Contracts-side TagConfig Inspect for Sql, MTConnect, Calculation and OpcUaClient
Deferment-register G-7: these four were validated by the AdminUI's TagConfigValidator at authoring but by nothing at deploy, so config arriving by CSV import or API met no gate at all. The deploy gate dispatches to a driver's *TagDefinitionFactory.Inspect in its Contracts assembly, and none of the four had one. Rather than mirror the AdminUI models (a mirror of an accept/reject boundary drifts silently, with the gate describing a shape the driver no longer reads), each Inspect is built on the driver's own runtime parse: - Sql wraps the existing SqlEquipmentTagParser, which already lived in Sql.Contracts. The P3-deferred Query model stays refused; Inspect surfaces the refusal rather than weakening it. - MTConnect's blob reader MOVED out of MTConnectDriver (it was private, with a private DTO) into MTConnectTagDefinitionFactory; the driver now calls it. Behaviour unchanged. - OpcUaClient's nodeId read MOVED off NamespaceMap into OpcUaClientTagDefinitionFactory; NamespaceMap now calls it. Behaviour unchanged. - Calculation gains a Contracts assembly it never had. Only the JSON reading moved there (CalculationTagDefinitionFactory.TryReadTagConfig); building a CalculationTagDefinition still needs EquipmentScriptPaths from Commons and stays in the driver, so the split is at the blob boundary. The new project is deliberately reference-free — a calc blob is strings, a bool and an int, with no Core.Abstractions type in it. Each Inspect runs the parse FIRST and returns empty on success, so "never stricter than the runtime" is structural rather than a promise: a warning is only reachable for a blob the driver would have refused and then skipped silently. Calculation additionally reports two present-but-silently-discarded fields (a non-boolean changeTriggered reads as true; a non-positive-integer timerIntervalMs is dropped), and deliberately does NOT warn about a trigger-less tag or a sub-50ms timer — the editor refuses both, the runtime honours both, and the deploy gate follows the runtime. |
||
|
|
0739f4b9a4 | merge: RawBrowseModal post-await staleness guard (deferment §0.1 race ported from the 2026-08-05 sibling fix) | ||
|
|
87637e4e2c |
fix(adminui): guard RawBrowseModal's resolve against a superseded open
Blazor does not serialize OnParametersSetAsync against its own outstanding task, and the modal's `_open` bool cannot tell two opens apart. ResetAndResolveAsync awaits LoadMergedProbeConfigAsync and ResolveGroupContextAsync and then wrote _driverType/_mergedConfig/_effectiveDeviceId/_groupPrefix/_canBrowse/_disabledReason with no staleness re-check, so a close-reopen onto another device let the first open's late continuation install its device's browse context under the displayed modal — "Connect & browse" would silently dial the wrong controller and the commit would land under the wrong tag-group. Ports the per-open `_openGeneration` counter proven in the two sibling modals (RawManualTagEntryModal, RawTagModal, 2026-08-05) to every post-await write here: after both awaits, in the catch's error banner, and in the finally. The finally is the one departure from the sibling shape and it is load-bearing — this modal resolves inside a try/finally, so a stale `return` still runs the tail and would drop the LIVE open's spinner mid-resolve. Pinned by four RawBrowseModalStaleOpenTests facts, each observed red before the guard and each re-falsified afterwards by reverting only its own guard. The modal calls StateHasChanged on the open path, which throws without a render handle, so the test attaches an inert Renderer subclass (file-local BL0006 pragma); [Parameter]s are set by reflection per the BL0005 convention. AdminUI suite 1117/1117 green. |
||
|
|
205cc4a4a3 |
feat(galaxy): per-code OPC UA mapping for the MxStatusDetail vocabulary
Follow-up to
|
||
|
|
20a816dbf4 |
feat(galaxy): consume the gateway's correlated write statuses; close archreview 06/S-1
The mxaccessgw OnWriteComplete correlation is live (server b948e69, worker 53f69cd — all four unary write kinds), so every write reply's statuses[0] now carries the real COM commit outcome. Live-gating the cutover on windev found and fixed three defects: - StatusCodeMap.FromMxStatus truncated the correlated failure row's MxStatus detail code to a byte (1007 & 0xFF = 239 -> OPC DA Good band), mapping a REFUSED write to Good. New invariant: a row that is not IsSuccess() never maps Good — details > 0xFF (and contradictory Good-band substatuses) route by category (SECURITY_ERROR -> BadUserAccessDenied, named const covered by StatusCodeParityTests; COMMUNICATION/CONFIGURATION keep their codes). - The borrow-from-subscription-registry write optimization could never commit: a subscription handle is plain-advised, and MXAccess refuses both the no-login write through it (SECURITY_ERROR detail 1008, live-captured) and the supervisory advise upgrade (E_INVALIDARG) — one advise per HANDLE, not per attribute. Removed: writer subscribedHandleSource ctor param + borrow branch, GalaxyDriver wiring, SubscriptionRegistry.TryResolveItemHandle + its forward index and test file. The writer always AddItems its own supervisory-advised handle, which commits fine while the same attribute is subscribed (live-proven; pinned by the new write-while-subscribed smoke). - (gateway-side, fixed there) the correlation initially covered WriteSecured only, while the dominant no-login path emits plain Write. Live gate, all legs PASSED against windev: happy write Good off a confirmed status row with galaxy.writes.unconfirmed + advise_failed both FLAT; read-only refusal surfaces Bad-family (the #5 node-revert input Galaxy could never produce before); WriteCompletionWaitMilliseconds=0 control -> provisional Good + metered unconfirmed (override removed after, restoration re-verified). New env-gated smokes: Live_correlated_write_statuses_confirm_commit_and_surface_refusal, Live_write_to_subscribed_tag_commits_via_own_handle (replaces the borrow smoke), GatewayGalaxyWriteMeterLiveTests (run filtered — meters are process-wide). |
||
|
|
dac7e1885d |
feat(resilience): route IDriver.InitializeAsync through the capability invoker (R2-10 T10 closed)
The connect path was the one capability call the resilience pipeline never saw, so a driver that could not connect contributed zero breaker input and the /hosts resilience panel read 'All targets healthy' against a dead endpoint. InitializeAsync now routes through the invoker as DriverCapability.Initialize, keyed on the driver-instance id. - No pipeline retry: the actor's RetryConnect timer is the retry loop (parser forces retryCount overrides to 0, same funnel as write-shaped). - Breaker threshold 2, not the Read-sized 5: the breaker samples 30 s and the reconnect timer fires every 10 s, so at most three attempts fit one window — threshold 5 could never open. Pinned by its own test. - An open breaker fast-fails into the same InitializeFailed path as a real connect failure; the 15 s break window costs at most one extra tick. The best-effort teardown ShutdownAsync stays unwrapped. - Companion fix: DriverCapabilityInvokerFactory.Create now invalidates the instance's tracker snapshots with its pipelines — a respawn during an outage discarded the only breaker whose OnClosed could clear the OPEN flag, stranding a false 'Breaker OPEN' chip forever. Live gate PASSED on docker-dev (image symbol-verified per container): rtu-gate against dead 10.100.0.35:5021 opened its Initialize breaker on both centrals with the red 'BREAKER OPEN' chip on /hosts; a socat 5021→5020 forwarder recovered it (both breakers closed within 1 s, panel back to 'All targets healthy'); healthy MAIN-modbus recorded zero breaker events. Full suite 50/50 green. Evidence: docs/plans/2026-08-07-adminui-followups-and-live-gates.md §7. |
||
|
|
0125634400 |
docs(secrets): correct stale non-functional warning on the replication block — pair-local replication is live-gated
The Secrets:Replication _comment claimed the feature was KNOWN NON-FUNCTIONAL against ZB.MOM.WW.Secrets.Replicator.AkkaDotNet 0.2.0 and said "do not enable until the library is fixed". That has been false since 0.2.2 — 0.2.1 fixed the shadowed ISecretReplicator registration and 0.2.2 the hosted-process DI deadlock — and the 2026-08-07 three-mesh live gate proved replication works (PASS 4/4, docs/plans/2026-08-07-secrets-pair-local-live-gate.md). Replaced with the truthful operator-facing note: functional since 0.2.2, still default-off, PAIR-LOCAL in scope (mesh Phase 6 gives each application cluster its own 2-node mesh, so a secret converges within its pair and nowhere else), same KEK required per pair, and rewrap-all runs per node because re-wraps do not replicate. Comment-only; no behaviour change. Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1 |
||
|
|
8b10d32728 |
fix(focas): disambiguate duplicate sibling axis/spindle names in the fixed tree (#548)
A CNC can report duplicate sibling names — the wonder Makino returns two
axes both called AA — which made the Axes/ subtree untrustworthy: the
browser offered two identical folders the commit gate had to refuse, and
the poll loop's name-keyed snapshot cache let the second axis silently
overwrite the first. Spindles and servo loads had the same collision in
their own keying, plus a resolve-to-first-match on the spindle read path.
Scheme: every member of a collision set gets a 1-based occurrence-ordinal
suffix in slot order (AA_1, AA_2); unique names stay bare. Chosen over the
alternatives deliberately:
- Names, not slot numbers, stay the primary identity — the segment map is
re-derived from the live device each session, so a name-keyed path
follows the physical axis across config changes, where a slot-qualified
path would silently rebind on any axis-list shift and rename every axis
on every machine for one pathological CNC.
- The WHOLE collision set is suffixed: bare AA ceases to exist when
ambiguous, so a tag authored against it reads BadNodeIdUnknown rather
than one arbitrary axis's data — the #534/#540 silent-wrongness class.
- Occurrence ordinal, not absolute slot: inserting/removing an unrelated
axis never renames an existing duplicate pair. If the collision-set
membership itself changes, segments change on the next browse and stale
authored segments read Bad, never another axis's data.
One pure helper (FixedTreeNames.Disambiguate, collision-proof against
genuine names by suffix escalation) feeds FocasFixedTreeCache.AxisSegments
/ SpindleSegments, and the emitter, axis snapshot keys, servo-load keys
(cnc_rdsvmeter answers in slot order, so the k-th AA load belongs to the
k-th AA axis) and the spindle read resolution all consume those — they can
no longer disagree. The nameless-spindle S{n} fallback applies before
disambiguation so a fallback name colliding with a real one is handled
like any other duplicate.
Tests are red-first on the pre-fix driver (all five driver-level tests
fail on master's code): browse emits AA_1/AA_2 and no bare AA, each
duplicate reads its own position and servo load, duplicate spindles
resolve per slot, bare AA reads Bad. The wire half runs live against
focas-mock (duplicate names survive the managed wire parse and publish as
suffixed segments); the mock's dynamic store is name-keyed so the
distinct-values half stays with the offline tests. FOCAS 327/327,
integration 11/11, FOCAS.Cli 52, AdminUI 1113, ControlPlane 231.
Closes #548.
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
|
||
|
|
97a837c147 |
fix(abcip): controller browse honours the authored EtherNet/IP port (#539)
The two discovery-path libplctag tags (@tags symbol-list enumerator, @udt template reader) still passed the bare Gateway host, so a browse against a device authored on a non-default port silently browsed whatever answers on 44818 — #534's defect surviving in its discovery half. Both sites now consume AbCipTagCreateParams.LibplctagGateway, with the Tag construction extracted into internal static builders so the seam is unit-assertable (the #534 tests pinned the property's composition but nothing pinned each site's consumption, which is exactly how these two survived). New guard: LibplctagGatewayConsumptionGuardTests scans both AB drivers' sources and fails on any libplctag Tag built from the bare Gateway, so a future call site cannot reintroduce the silent 44818 redirect. Live-gated against the fixture host: browse of ab://10.100.0.35:44818 returns the nine-tag symbol table; browse of ab://10.100.0.35:44999 (nothing listening — the issue's exact repro) now fails ErrorTimeout where it previously returned the 44818 fixture's table. Closes #539. Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1 |
||
|
|
9420f8efff |
Merge fix/issue-546-panel: wire DriverStatusPanel into /hosts driver rows (#546)
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1 |
||
|
|
d01b717eb9 |
Merge fix/issue-542-fleetstatus: delete the dead fleet-status chain (#542)
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1 |
||
|
|
4870785209 |
docs(auth): #543 add the one-public-ctor remarks paragraph to the two newly-pinned interceptors
ConfigServeAuthInterceptor and LocalDbSyncAuthInterceptor gained HasExactlyOnePublicConstructor pin tests in this branch but lacked the sibling-standard <remarks> paragraph naming them (TelemetryStream/MeshCommand/CentralCommand all carry it). Cosmetic consistency item from the #543 code review. Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1 |
||
|
|
b5607d355a |
refactor(fleet): #542 review residue — drop dead proxyOptions local, retire stale compliance defer line
The proxyOptions local in WithOtOpcUaControlPlaneSingletons was already unused on master (pre-existing dead code, not orphaned by the deletion commit — every registration passes singletonOptions); removed while the file is open. The phase-6-1 compliance script still Assert-Deferred'd the FleetStatusHub /hosts follow-up — an item that is now deleted, not deferred — replaced with a retirement comment. Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1 |
||
|
|
e669fb349a |
fix(adminui): #546 recycle DriverStatusPanel on Enabled flip via @key
DriverStatusPanel decides whether to subscribe exactly once, in OnInitializedAsync (`if (!Enabled) return;`), and exposes no OnParametersSet hook. The detail row was keyed only by DriverInstanceId, so a live config edit that flipped a row's Enabled true->false — surfaced on the page by the 5 s LoadConfigAsync refresh — reused the same panel instance: the markup correctly switched to "Disabled - not deployed" while the panel's two store SnapshotChanged subscriptions and its 5 s staleness timer stayed wired underneath, until the operator happened to collapse the row. Including the flag in the detail row's key recycles the component instead. Both directions verified against the panel's own lifecycle: true->false disposes the old instance (unsubscribing both stores, draining both timers) and mounts one that skips subscription setup entirely; false->true disposes an instance that never subscribed (the unsubscribes are no-ops, both timers null) and mounts one that subscribes and primes from the stores. The data row's key is unchanged. No behaviour change when Enabled is stable. Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1 |
||
|
|
f1c188114b |
refactor(fleet): #542 delete the dead fleet-status chain (broadcaster, SignalR bridge, hub, messages)
#542 filed FleetStatusBroadcaster as blind to site nodes since the Phase 6 per-cluster mesh split: it derives "the fleet" from central's own Akka gossip ring, which after the split is just central's own pair. Scoping the cross-mesh rewrite found the deeper truth — the whole chain is dead end to end, so this deletes it rather than reviving it. - Zero consumers. FleetStatusBroadcaster published FleetStatusChanged on the DPS "fleet-status" topic; FleetStatusSignalRBridge forwarded it to FleetStatusHub ("/hubs/fleet-status"); nothing anywhere connected. The AdminUI bans self-connecting HubConnections, no browser JS subscribes, and /fleet (Fleet.razor) reads NodeDeploymentState from the ConfigDb plus IClusterRoleInfo on a 5 s poll instead. - Its second input never existed. DriverHostStatusHeartbeat had no production sender — only the broadcaster's own test told it — so CurrentRevision was always null and the 30 s staleness -> Degraded flip could never fire. - Post-Phase-6 DPS is pair-local, so even the central-only view it did produce was an accident of topology rather than a design. The fleet visibility that actually works is unchanged and already cross-mesh-correct: /fleet over NodeDeploymentState, and the four Phase 5 per-node telemetry dials. Precedent for deleting inert machinery rather than activating it: #522 (driver stability tiers), #507 (discovered-node injection), #525 (GetMemoryFootprint). Deleted: FleetStatusBroadcaster (+ its nested DriverHostStatusHeartbeat), its admin singleton registration and FleetStatusBroadcasterKey, FleetStatusSignalRBridge and its key, FleetStatusHub and its route mapping, and the FleetStatusChanged / FleetNodeStatus / FleetNodeHealth messages. Both Fleet/ directories survive — ControlPlane keeps ClusterNodeAddressReconciler, Commons keeps GetDiagnostics. Docs swept where they claimed a fleet-status channel exists (Telemetry.md's Phase 5 non-migrated channels, Configuration.md, CLAUDE.md); completed-program plan docs under docs/plans and docs/v2 are left as the historical record they are. Build 0 errors, 868 warnings — byte-identical to master's rebuild baseline. ControlPlane.Tests 231/231, AdminUI.Tests 1109/1109. Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1 |
||
|
|
d33e4e20b5 |
feat(adminui): #546 wire DriverStatusPanel into /hosts driver rows (collapsed expander)
DriverStatusPanel was orphaned — zero references repo-wide — so the Restart and Reconnect driver commands had NO reachable UI at all, despite working end to end over the new gRPC mesh transport. Nothing failed to build, test or deploy while that was true, which is why it went unnoticed. The panel is now reachable per driver row on /hosts, behind a per-row "Controls" expander that is collapsed by default: the table stays scannable, and only the opened panels hold snapshot-store subscriptions. Expansion state is a HashSet keyed by DriverInstanceId held on the page, so it survives the 5 s refresh that replaces every row record. Both rows are @key'd by instance id so the panel — which binds its DriverInstanceId once in OnInitializedAsync — can never be diffed onto a different driver. The panel's Enabled parameter is fed from real config rather than defaulted: HostsDriverInstanceInfo/HostsDriverRow now carry DriverInstance.Enabled, which LoadConfigAsync already had in hand. A snapshot with no matching configured instance is treated as enabled — the row exists only because live health arrived, so "Disabled — not deployed" would contradict the same row. The expander is visible to every AuthenticatedRead viewer; the panel self-gates only its Reconnect/Restart buttons on DriverOperator. A source-scan pin (HostsPanelWiringTests) asserts /hosts still renders the panel and still binds it to the row's own driver, cluster and enabled state — removing the tag would silently restore the orphaned state. Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1 |
||
|
|
f10f4c3754 |
fix(auth): #543 whitespace-only configured key fails closed in the three pre-existing gRPC auth interceptors
TelemetryStreamAuthInterceptor, ConfigServeAuthInterceptor and LocalDbSyncAuthInterceptor all decided "is a key configured?" with string.IsNullOrEmpty. A whitespace-only configured value therefore passed that guard and fell through to the constant-time comparison — where it is satisfied by a whitespace-only bearer token. The gate would be open to anyone who guessed a blank credential. This backports the check the two NEW mesh interceptors already use: MeshCommandAuthInterceptor (and its central-side clone) validate with string.IsNullOrWhiteSpace, so the four/five planes now answer the same way to the same misconfiguration. Nothing else changes — the denial status codes (PermissionDenied / Unauthenticated / PermissionDenied respectively) and every denial message are untouched, and no key material is logged. No deployment configures a whitespace key: the docker-dev rig and production both set real keys, so this is a fail-closed hardening of an unreachable-today misconfiguration rather than a live exposure. Tests: each of the three test files gains a ..._WithBlankKeyConfigured_IsDenied fact mirroring MeshCommandAuthInterceptorTests.ExecuteMethod_WithBlankPskConfigured_IsDenied (configured key " ", presented "Bearer "), verified red before the fix. ConfigServeAuthInterceptor and LocalDbSyncAuthInterceptor also gain the HasExactlyOnePublicConstructor pin their two siblings already had — Grpc.AspNetCore silently stops invoking an interceptor with more than one public constructor, which would disable auth without failing anything. Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1 |
||
|
|
cd7135570c |
refactor(mesh): collapse MeshPaths to local actor names — no longer a wire contract (review follow-up)
MeshPaths.CentralCommunication / .NodeCommunication (the "/user/..." full-path constants) had
ZERO production consumers after the ClusterClient deletion: both gRPC services resolve their
comm actor through ActorRegistry by typed key, never by ActorSelection over a path. Only the
path-probe test still read them, while the class doc went on calling them "the wire contract
of the central↔node boundary" — a claim that stopped being true the moment nothing addressed
an actor across a process boundary by name.
- The two full-path constants are deleted; the two Name constants remain.
- MeshPaths' doc now says what it is (the local actor-naming convention the registry-keyed
services are spawned under) and records what it stopped being, so the next reader does not
re-derive a contract from the file's history.
- MeshCommActorPathTests composes "/user/" + Name itself — the absolute path is that test's
own addressing detail now, not something the product declares. Renamed
Both_mesh_comm_actors_exist_at_their_contract_paths ->
..._are_spawned_under_their_declared_names, because what it still pins is that the two
registration sites actually ran on a real booted node; a rename is now a compile error, so
"contract" was the wrong word for what would break.
- NodeCommunicationActor's summary no longer cross-references the deleted constant or calls
the name an address. CentralCommunicationActor was checked and makes no such claim.
|
||
|
|
4d2dfb5b50 |
test(mesh): step 7 — replace the boundary test with MeshGrpcBoundaryTests and sweep the comments
MeshGrpcBoundaryTests replaces the MeshClusterClientBoundaryTests deleted in step 1. Three
self-seeded single-member clusters sharing the ActorSystem name, joined ONLY by gRPC, driving
the REAL CentralCommunicationActor -> MeshGrpcCommandDialer -> Kestrel h2c ->
MeshCommandAuthInterceptor -> NodeCommandGrpcService -> NodeCommunicationActor -> EventStream:
1. No_cross_mesh_Akka_association_forms_between_central_and_either_site — the program's own
point, kept as a test rather than a live-gate note: each cluster stays single-member and
none ever holds another's address as a member.
2. One_dispatch_through_central_reaches_a_node_in_every_cluster_mesh — the fan, with each
site holding its OWN per-cluster PSK, so a dial that selected the wrong cluster's key
would be refused at the far side.
3. Falsifiability_control_a_dial_carrying_the_wrong_cluster_key_delivers_nothing — without
it, (2) cannot distinguish "the gRPC boundary carried it" from "something else in the
process did". The same node then accepts the right key, so the silence is the auth gate
and not a dead endpoint.
All three run fully offline — no Docker fixture — and pass.
Comment sweep across every remaining clusterclient|receptionist hit in src/ and tests/:
the program label becomes "mesh gRPC command plane", and every comment that described the
deleted transport as live now describes what actually runs (Dps = pair-local default,
Grpc = cross-mesh). mesh_transport.proto's header/service comments are reworded; no message,
field or enum is touched — the contract is locked. MeshCommActorPathTests is reshaped rather
than retired: the two paths still exist and still matter, but what a mismatch costs is now a
misleading Unavailable from the gRPC ingress rather than a silently dropped receptionist send.
The only surviving mentions of the old names are the ones that must survive: the fail-closed
rules that name "ClusterClient" as a retired value refused at boot, their tests, and this
test class's own account of what it replaces.
|
||
|
|
af3b0c3146 |
refactor(mesh): step 6 — delete the ClusterClient mode value; split topology now requires Grpc
MeshTransportOptions.ModeClusterClient is gone. MeshTransportOptionsValidator accepts Dps or Grpc and nothing else, so a node left carrying the string "ClusterClient" is now REFUSED at boot naming MeshTransport:Mode and both remaining values — deliberately fail-closed rather than falling back to Dps, which on a split-topology node would deliver nothing at all. A new fact pins exactly that. SplitTopologyTransportValidator tightens from an allow-list of two to Grpc only, as its own comment said it would once ClusterClient was deleted. Dps on a cluster-role node stays refused; the rule is still an explicit allow-list, so a typo'd mode fails too. Tests: the two ClusterClient-mode facts in MeshTransportOptionsValidatorTests are deleted (their Dps siblings already assert the same rule for the one remaining non-Grpc mode), the SplitTopology fixtures move to Grpc, and the three-mode Theories in ControlPlaneStarterOrderTests / CentralCommunicationActorTests become two-mode. |
||
|
|
85ca86e955 |
refactor(mesh): step 5 — delete MeshTransport:CentralContactPoints, its rules and its compose entries
The property, the four validator rules that graded it (empty / non-akka.tcp / actor-path suffix / one-bad-among-good) and all twelve docker-compose entries (6 nodes x 2) are gone. The compose comment blocks that explained "/system/receptionist is appended at construction time" and the client's contact rotation go with them, and the surrounding prose now describes the Grpc command plane it actually runs. CentralGrpcEndpoints' doc no longer says it "replaces" the Akka contact points — there is nothing left to replace — and ContactRefreshSeconds' failure message names the node dispatch set rather than the ClusterClient contact points. `rg CentralContactPoints src tests docker-dev` is now empty. |
||
|
|
d67a7e5d0b |
refactor(mesh): step 4 — drop the receptionist extension and akka.cluster.client from akka.conf
akka.extensions loses ClusterClientReceptionistExtensionProvider and the whole akka.cluster.client block (buffer-size / reconnect-timeout / receptionist.role) goes with it. The DistributedPubSub extension and the maximum-frame-size / log-frame-size-exceeding canary both stay — DPS is the pair-local transport and the frame canary guards every remoting message, neither of which the cross-mesh deletion touches. MeshTransportHoconTests drops its three ClusterClientSettings/ClusterReceptionistSettings asserts and keeps the frame-size pin and the DPS-extension pin, renamed since it no longer has a receptionist to survive alongside. |
||
|
|
5d7ecdf737 |
refactor(mesh): step 3 — delete both ClusterClientReceptionist registrations
Central's CentralCommunicationActor and a node's NodeCommunicationActor no longer register with a receptionist; the ActorRegistry entry each already carried is now the whole reachability story, and it is what CentralCommandGrpcService / NodeCommandGrpcService resolve to hand an inbound message over. Both `using Akka.Cluster.Tools.Client` directives are gone with them. |
||
|
|
ab5b958d47 |
refactor(mesh): step 2 — delete the node-side ClusterClient and its centralClient parameter
WithOtOpcUaRuntimeActors no longer builds a ClusterClient from MeshTransport:CentralContactPoints, and NodeCommunicationActor.Props takes only the gRPC IApplyAckSender. The ackRouter gate narrows from `useClusterClient || useGrpc` to `useGrpc`, and the CentralClusterClientName constant is gone. NodeCommunicationActor's no-transport warning now names MeshTransport:CentralGrpcEndpoints and MeshTransport:Mode only — the old text pointed an operator at a key that no longer exists. Ripples in this step: the two ClusterClient-shaped facts in NodeCommunicationActorTests (Send-not-SendToAll, and sender-wins-over-a-client) are deleted — the first has no subject left, the second guarded a both-transports-present state that is now unconstructible. The Runtime ServiceCollectionExtensions fact keeps its ackRouter pin and drops its "no ClusterClient actor exists" half for the same reason. |
||
|
|
0db40a5e7f |
refactor(mesh): step 1 — delete the central ClusterClient factory and per-cluster client cache
IMeshClusterClientFactory/DefaultMeshClusterClientFactory are gone, along with
CentralCommunicationActor's _clients map, RebuildClient, the SendToAll fan and the
receptionist-address half of the contact load. LoadContactsFromDb SURVIVES — it feeds the
gRPC dialer's endpoint resolution and the known-cluster gate — but now projects only the
columns those two need (the AkkaPort/ActorPath.TryParse parse and its Malformed list went
with the receptionist addresses).
Ripples fixed in this step because the build must be green at every commit:
- ControlPlane/ServiceCollectionExtensions drops the factory argument.
- CentralCommunicationActorTests: the six ClusterClient-only facts are deleted (each has a
Grpc counterpart that already asserted the same property independently), the three
transport-agnostic facts move to Grpc mode, and the Dps fact keeps its own coverage.
- MeshClusterClientFactoryTests deleted (its subject is gone).
- MeshClusterClientBoundaryTests deleted here rather than in step 7: it drives
DefaultMeshClusterClientFactory directly, so it cannot compile past this commit. Its
replacement, MeshGrpcBoundaryTests, lands in step 7 and keeps the load-bearing assertion
(two separate meshes, no cross-mesh Akka association).
|
||
|
|
d7c9ead64e |
test(mesh): close review findings on the PreStart-race logging fix — non-vacuous once-per-incarnation pin, broken-dialer symmetry, comment idiom
Follow-up to
|
||
|
|
a94eb8a335 |
fix(mesh): PreStart resolve race logs honestly — quiet on heal, loud on permanent absence
PreStart's eager resolve of IMeshCommandDialer/IMeshPskProvider loses a
millisecond-wide race against the Akka builder's remaining starters on
essentially every admin boot (live gates: 1A 2/3 boots, 1B 4/4 boots) and
self-heals at the memoized Dialer/PskProvider properties' first use. Before
this change that produced two FALSE Errors per healthy boot, which made a
soak error-grep on a healthy rig impossible.
Resolve() now takes a `firstUse` flag and logs at Debug on BOTH call sites
(PreStart and the memoized properties' retry) — from inside Resolve a lost
race and a genuinely broken factory are indistinguishable, so severity is
decided by the CONSUMER of a still-null result after the retry, not by
Resolve itself:
- Dialer: DispatchOverGrpc's existing per-dispatch Error ("no command
dialer is wired") is unchanged and is now the ONLY loud signal for that
dependency.
- PskProvider: PublishKnownClusters gained a new Error carrying the old
PreStart guard's diagnostic content (gate never published, cached keys
never dropped, rotated key needs restart), guarded to fire ONCE per
actor incarnation via `_pskProviderAbsenceReported` — that handler runs
on the contact-refresh timer for the actor's whole life, so an
unguarded Error there would repeat every ContactRefreshSeconds forever.
Design choice for the "factory never supplied" case (Dps/ClusterClient test
constructions that pass no factories at all): kept silent at BOTH call
sites for both dependencies, same as before. For the dialer this was
already true (DispatchOverGrpc's check doesn't care why the dialer is
null). For the PSK provider the new once-guarded Error is gated on
`_pskProviderFactory is not null` specifically so the many existing
ClusterClient/Dps facts in CentralCommunicationActorTests that never wire a
PSK provider (using the bare Spawn helper) keep passing unmodified — a
never-supplied factory is a deliberate wiring/test choice, not a resolve
failure, and Resolve() already stayed silent about it for the same reason.
Two new tests pin the net behavior: a healthy boot (factories that throw
once then succeed) produces zero Warning/Error end-to-end through PreStart,
the first contact load and the first dispatch; a permanently-broken PSK
provider logs the absence exactly once across two contact loads, not once
per refresh.
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
|
||
|
|
07bc1878fc | docs(mesh): sweep the remaining doubled-quorum comment sites — the coordinator absorbs duplicates idempotently | ||
|
|
667639e6ab |
fix(mesh): per-endpoint deadline + review fixes — failover survives a hanging central
CRITICAL. The ack deadline was one absolute DateTime computed once for the whole endpoint walk. Against the characteristic WAN failure — a central that HANGS (SYN black-hole, frozen host) rather than refusing — endpoint 1 burned the whole AckTimeoutSeconds and endpoint 2 was then dialled with a deadline already in the past: instant DeadlineExceeded, no packet leaving the node, the surviving central never really tried. Failover looked like it ran and did not. It also contradicted MeshTransportOptions' own documented "applied per endpoint, not per ack". The deadline is now computed fresh inside the loop; headers stay hoisted (read-only, sequential walk). Two test gaps that let that survive are closed: nothing pinned the ackRouter gate (narrowing it back to ClusterClient-only compiled and passed every test while dead-ending every Grpc ack to DPS), and the deadline fact's lower bound accepted any positive timeout at all. Both new pins are mutation-verified. Docs corrected where they overstated the case: duplicate acks CANNOT mis-seal a deployment — ConfigPublishCoordinator.HandleAck keys _acks by NodeId behind an expected-set guard, so a duplicate overwrites its own entry. The single-endpoint rule is defence in depth and a bound on wasted work, not a correctness dependency; and the walk is at-least-once on the deadline path, not at-most-once. |
||
|
|
308c6aaa0f |
feat(mesh): gRPC ApplyAck path behind Mode=Grpc
Task 1B.2 — the node→central half of the ClusterClient→gRPC migration. GrpcApplyAckSender walks MeshTransport:CentralGrpcEndpoints in order and STOPS AT THE FIRST SUCCESS. That single `return` is the whole single-count guarantee: the transport it replaces was ClusterClient.Send, deliberately not SendToAll, because both central nodes forward to the SAME ConfigPublishCoordinator singleton proxy — an ack posted to both would have the coordinator count one node twice and could seal a deployment on a doubled count. The list is an availability list, not a fan-out list. No retry beyond that one ordered pass, no queue: the receptionist buffered nothing and retried nothing, and this is like-for-like, not an upgrade to delivery guarantees. All endpoints failing is still a DROP — but a loud one, naming the node, deployment, correlation id and the status each endpoint answered with, which is what separates "central is down" from "central rejected this node's key". The bearer never reaches a log; only endpoints and status codes do. Per-endpoint GrpcChannel cache and the endpoint loop clone GrpcDeploymentArtifactFetcher; the cluster id in x-otopcua-cluster comes from this node's own cluster-<id> role via RoleParser, off the same bound Cluster:Roles ClusterRoleInfo reads. SendAsync never throws: the caller discards the task, so an escaping exception would become an unobserved fault — a drop with no log line, the exact failure mode this migration removes. NodeCommunicationActor gains an OPTIONAL ackSender; every existing test compiles and passes unedited. It is checked before the ClusterClient so the two can never both fire. The registration arm creates no ClusterClient under Grpc. NOTE — one wiring change beyond the actor: DriverHostActor's ackRouter is now `useClusterClient || useGrpc ? nodeComm : null`. Left at ClusterClient-only, a Grpc node's ack would have gone out on the DPS deployment-acks topic and never reached the sender at all. |
||
|
|
e1db90f9c9 |
fix(mesh): 1B.1 review follow-ups
Two review items from Task 1B.1, both diagnostic: 1. CentralCommandGrpcService's "actor not ready" Warning omitted the peer and the correlation id while the decode-failure Warning six lines above carried both. These are the only two ways an ack fails to reach the coordinator, and an operator has to correlate either against the node's own log. Decoding has already succeeded on this path, so the DECODED correlation id is free. 2. A fused driver,admin,cluster-MAIN node under Mode=Grpc owes BOTH validator surfaces at once (driver keys AND ConfigServe:GrpcListenPort). The two blocks are independent `if`s; chained as `else if` a fused central node would be graded on one surface only and boot with nowhere for any ack to land. Pinned from both sides plus the fully-configured pass. |
||
|
|
7e7df337cc | feat(mesh): CentralCommandService on the ConfigServe listener | ||
|
|
e54883a8e0 | fix(mesh): memoize dialer/provider on first use — a lost PreStart race self-heals at the first refresh (review-prescribed) | ||
|
|
c0475f2df4 |
fix(mesh): resolve dialer + PSK provider lazily — central boots again
Phase 1A live gate defect 4. Neither central node booted on this branch,
in ANY transport mode:
Akka.Hosting.MissingActorRegistryEntryException: No actor registered
for key ...ControlPlane.AuditWriterActorKey
at AdminUI.Audit.ActorAuditWriter..ctor(ActorRegistry registry)
at ControlPlane.ServiceCollectionExtensions.<WithOtOpcUaControlPlaneSingletons>
WithOtOpcUaControlPlaneSingletons' first WithActors starter resolved
IMeshCommandDialer and IMeshPskProvider eagerly for the Props. Their
graph reaches back into the registry — dialer -> IMeshPskProvider ->
ISecretResolver -> AddZbSecrets -> IAuditWriter -> ActorAuditWriter,
whose ctor calls registry.Get<AuditWriterActorKey>() — and
WithSingleton<AuditWriterActorKey> is registered ~60 lines LATER in the
same builder. Boot died before the entry existed. Mode-independent: the
resolve happened before anything consulted MeshTransport:Mode, so Dps
(the default, and the rollback target) died exactly like Grpc.
Both dependencies now arrive as Func<> and are invoked in PreStart, by
which point the host is fully built — the same shape, for the same
class of reason, as the coordinator proxy Func one argument earlier.
PreStart re-resolves on restart rather than caching across one; both
are container singletons, so a restart gets the same instances. A
resolution throw is caught and logged rather than propagated: a throw
out of PreStart is an actor failure, the supervisor answers it with a
restart, and a broken DI graph is not transient — that is an unbounded
restart loop that would take the transport-independent ack path down
with it.
Regression pin: ControlPlaneStarterOrderTests boots a REAL admin host
through WithOtOpcUaClusterBootstrap + WithOtOpcUaControlPlaneSingletons
in production starter order, with the production graph from
IMeshCommandDialer down to a registry-bound IAuditWriter, and asserts
StartAsync itself — the failure occurs while the builder's starters
execute, so building a ServiceProvider and resolving by hand cannot
reproduce it. Verified RED against the eager resolve: 3/3 modes fail
with MissingActorRegistryEntryException. A second fact pins that the
deferral did not become 'never resolved' — the actor must resolve the
provider from the container and drive it through the contact refresh,
signalled by a TaskCompletionSource (PreStart runs on the actor thread
and the contact load is a piped DB read, so no synchronous assertion
after StartAsync is correct).
Why nothing caught this: the dialer's container-activation test resolves
from a collection with no secrets registration, so its graph stops one
hop in; the actor facts inject fakes and never touch DI; and the
existing whole-host boots register no mesh provider, so the eager
lookups returned null and walked nothing.
Two aggravators recorded by the gate, no code here: the failed process
did not exit (docker Up while /health/ready 503s — Phase 2 readiness
concern, tracked), and this is the third instance of the same
eager-resolve-during-AddAkka class after scadaproj#1 and mesh Phase 6's
IClusterRoleInfo boot crash.
ControlPlane.Tests 236 -> 240, Host.Tests 92, Cluster.Tests 184,
0 warnings in every touched project.
|
||
|
|
48dc2120c6 |
fix(mesh): gate follows the endpoint not the mode + review fixes
The known-cluster gate published only under Mode=Grpc, but the interceptor it feeds is registered on every admin node and 1B maps CentralCommandService by LISTENER PORT — neither consults the mode. A dark-switch rollback to ClusterClient or Dps would therefore have left that endpoint serving with the gate permanently inactive, silently restoring both the timing oracle and the pre-auth secret-store/audit flood, as a side effect of a rollback nobody would connect to authentication. The gate is central's cluster inventory, not transport state, so it is now decoupled from the mode entirely: - SetKnownClusters + InvalidateAll run on EVERY successful contact load in EVERY mode, and the contact-refresh timer starts in all three. Under Dps this is a ConfigDb query every ContactRefreshSeconds that did not run before; central always has a ConfigDb, and one indexed read a minute is the cheaper half of the trade. - The GATE set is built from ALL ClusterNode rows. The eligibility filter moved out of the query's WHERE and into the loop, so the FAN set is byte-for-byte what it was (enabled && !maintenance) while the gate widens. This also dissolves the maintenance coupling flagged in 1A.2: a cluster whose nodes are all disabled or in maintenance can still ack, and refusing it would make maintenance mode an authentication switch. Central still refuses clusters it has never heard of. - HandleContactsLoaded keeps a documented order — targets adopted, gate published, cache dropped — now pinned by test. Guards: PreStart mirrors the null-dialer error for a null PSK provider (silent absence is the worst outcome there — nothing malfunctions, so nothing is investigated, while the gate never publishes and keys never rotate); a failed contact load now says out loud that the gate is stale and, before any load has succeeded, inactive; MeshGrpcCommandDialer clamps a non-positive DispatchTimeoutSeconds to 10 with a construction Warning, because the validator for that key is driver-role-scoped and a pure-admin central reads it unvalidated; the dialer's local PSK refusal was shortened so its remediation survives the actor's 200-char truncation of remote-authored status detail; the portless-node warn-once set is rebuilt each refresh, so a node that regains then loses a port warns again — the regression is the occurrence worth seeing. Docs/pins: dialer disposal comment no longer claims one-way; the two interceptor-test comments claiming Matches has a default implementation corrected (it deliberately has none); new pins for per-refresh invalidation, the SetKnownClusters-before-InvalidateAll order, and one channel per endpoint via a new CachedChannelCount. The_known_cluster_set_is_not_published_by_the_other_two_modes is replaced — its premise is obsolete — by three facts: every mode publishes, gate set is a strict superset of the fan set, and the handler ordering. ControlPlane.Tests 230 -> 236, Host.Tests 92, Cluster.Tests 184, 0 warnings. |