Interface now lives in Commons.Interfaces.Services; AuditLog no longer gets it
transitively through Communication. Implementation stays in HealthMonitoring.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
Backfills the branches arch-review 08 flagged as thin, chosen for what they
decide rather than for count. Every new test was mutation-checked: the
guarded line was broken and the test went red.
SiteCallAudit (SiteCallAuditFailurePathTests, 17 cases, in-memory - the
18 MSSQL-backed actor tests skip without a database, so the ingest and read
fault paths had no runnable coverage):
- a failed page write holds the reconciliation cursor; the next tick re-pulls
the same window and advances only after the write lands
- the pinned latch publishes Pinned=true once across many pinned ticks and
Pinned=false once when the site starts honouring after_id
- a site-enumeration fault skips one tick without wedging the single-flight
guard
- an endless backlog drains at most 50 pages per tick, unpinned, and the next
tick resumes from the advanced cursor
- purge single-flight: ticks during a blocked purge are dropped
- ingest fault replies Accepted=false and the next upsert is accepted
- detail / KPI / per-site / per-node / query faults map to Success=false with
the root-cause message and an empty payload; the actor keeps serving
- query page size clamped to [1, 200]; an empty page carries no cursor
- Discard NotParked / OperationFailed (site message verbatim) with no audit
row; Discard before transport registration is SiteUnreachable
- a throwing audit sink leaves an Applied relay Applied; a missing mirror row
or unknown channel still audits the operator, falling back to ApiOutbound
DeploymentManager (DeploymentServiceFailurePathTests, 11 cases):
- a site that answers Failed commits a Failed record with the site's message
(no timeout marker), leaves instance state and the deployed snapshot
untouched, and audits Deploy (not DeployFailed)
- a prior non-timeout Failed record skips the query-before-redeploy
- a failing Failed-status write detaches pending changes and retries once
(none when nothing was detached); the caller still gets the original error
- Disable / Enable / Delete answered with Success=false leave the instance
unchanged, audit the attempt, and surface the site's message or the default
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
TemplateService.ReconcileDescendantsAsync - run after every base member
add/update/delete, template and native-alarm-source alike - loaded the whole
tracked template graph (every template, member and script body) on each call,
which after #66 was the dominant cost of those paths. It now calls the new
ITemplateEngineRepository.GetTemplateReconcileClosureAsync: the same tracked,
fully-included shape as GetAllTemplatesAsync, restricted to the edited
template's derived subtree plus its own inheritance chain. The reconcile is
inheritance-only (TemplateInheritanceResolver never follows compositions) and
every descendant's chain runs through the edited template, so that set is
everything it reads.
The walk (Commons TemplateReconcileClosure, the descendant-direction twin of
#66's TemplateAnalysisClosure) loads one children batch per subtree depth and
one batch per ancestor level, terminates on parent cycles and stops at
dangling parents. A template with no children - the common case - costs one
empty probe and loads nothing.
Evidence:
- TemplateReconcileClosureTests: for every template of a graph with a deep
subtree, an ancestor above the edited template, a composed module,
look-alike unrelated trees, a parent cycle and a dangling parent, the
closure-backed reconcile issues exactly the writes (add/update/delete,
audit, save) of the old whole-graph reconcile, and the resolver answers
identically for every descendant. Fails with the old service (whole-graph
load wired to throw) and with the ancestor walk removed (mutation-checked).
- TemplateReconcileClosureRepositoryTests (SQLite, recorded SQL): 5 templates
/ 10 attributes instead of 47 / 94, tracked with script bodies; a leaf costs
one probe query and tracks nothing.
- TemplateServiceTests: attribute add + update propagate two levels down with
GetAllTemplatesAsync wired to throw; existing propagation tests now run
against the closure via ServeReconcileClosureFromFullGraph.
ResyncInheritedMembersAsync (operator action) still reads the whole graph.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
SiteAuditBacklogSnapshot gains an optional trailing RejectedCount (gauge of
Rejected forward-state rows, read from the site store), carried on the wire as
SiteAuditBacklogSnapshotDto.rejected_count = 4 (additive; old sites read as 0,
old central ignores it). Health page shows an 'Audit rows rejected' badge.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
Interface goes to Commons.Interfaces.Services and the state record to
Commons.Messages.Health, so CentralCommunicationActor no longer needs the
HealthMonitoring namespace. Implementation stays in HealthMonitoring.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
Enables transitive pinning so the central SQLitePCLRaw.lib.e_sqlite3 2.1.12
pin applies everywhere (41 projects verified at 2.1.12). Required raising the
central floors: Newtonsoft.Json 13.0.4 and Microsoft.Extensions.* 10.0.13.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
Attribute/alarm/script add (collision check) and update (parent-lock lookup)
used GetAllTemplatesForAnalysisAsync, materialising every template, attribute,
alarm and script in the library per edit. They now call the new
ITemplateEngineRepository.GetTemplateAnalysisClosureAsync: the same untracked,
body-free analysis projection, restricted to the template's inheritance chain
plus the composition closure of every chain member - exactly what
TemplateResolver and CollisionDetector walk (a composed module's own parent
chain is never climbed, so it is not loaded). Adds use the edited template's
closure, updates the parent's.
The walk (Commons TemplateAnalysisClosure) loads one batch per closure depth,
requests each id once, terminates on cycles and skips dangling edges.
Evidence:
- TemplateAnalysisClosureTests: for every template of a graph with chains,
nested + diamond compositions, a derived module, cycles, dangling edges and
colliding unrelated templates, ResolveAllMembers / DetectCollisions /
FindMemberByCanonicalName answer identically from the closure and the whole
graph (mutation-checked).
- TemplateAnalysisClosureRepositoryTests (SQLite, recorded SQL): 6 templates /
12 attributes loaded instead of 47 / 94, in 4 queries (one per depth),
untracked, script bodies never selected.
- TemplateServiceTests: two new tests with the whole-graph load wired to throw
(fail on the old service); every existing add/update test now runs against
the closure via AnalysisClosureMock.
ReconcileDescendantsAsync still loads the tracked full graph; not in scope.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
The live AlarmStateUpdate already carried ack_time (24) but the Debug View
snapshot DTO did not, so snapshot-seeded alarm rows showed a null ack time
until the next live update. Additive google.protobuf.Timestamp ack_time = 21
(absent => null, including from a pre-#84 site), vendored C# regenerated with
docker/regen-proto.sh sitecommand, mapped both ways in SiteCommandDtoMapper.
The native golden sample now sets AckTime (the computed one leaves it null),
so the round-trip golden pins both the present and the absent decode.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
The bundle importer writes external systems, methods and database connections
through the raw DbContext, bypassing the ManagementActor handlers that
invalidate ExternalDefinitionLookupCache (#65), so the importing node served
pre-import definitions for up to DefinitionCacheTtl. New Commons seam
IExternalDefinitionCacheInvalidator, implemented by the cache and registered as
the same singleton; BundleImporter takes it optionally and invalidates once
after every committed import (never on rollback; never throws).
Peer-central-node invalidation (the other half of #83) is NOT done here: the
issue leaves open whether the 10 s TTL bound is acceptable for the peer.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
ScriptCallResult and RouteToCallResponse gain trailing Truncated/LogsTruncated,
filled from the site FlowTraceCollector after Complete(); RouteToCallResponseDto
fields 8/9 carry them (sitecommand regen). FlowDebugRunner ORs them into
FlowDebugRunResult on the script entry, and on the method entry through a new
optional truncation sink on RouteHelper.WithTraceSink (carried through every
With* clone). Previously a site run whose collector dropped rows came back as a
silently short list. Older sites send false.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
Central's Debug View live stream and Alarm Summary cache always saw false
because neither wire message had the field. Additive proto fields, vendored
C# regenerated with docker/regen-proto.sh (sitestream + sitecommand), mapped
both ways in StreamRelayActor/SiteStreamGrpcClient and SiteCommandDtoMapper.
The live stream publishes Alarm Actor events, which never carried the flag,
so InstanceActor.HandleAlarmStateChanged now stamps it before retaining and
publishing. A pre-#60 site sends the proto3 default. No UI badge yet.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
AccessorMirrorPairs rows now carry an ignoredRuntimeMethods list. An entry
must exist on the runtime accessor and must be ABSENT from the mirror, so a
stale or over-broad exclusion fails the theory. The (ScriptLogger, CompileLog)
pair joins the guarded set with FlushDropped excluded (run-end flush called
by ScriptRunLauncher, not script-reachable).
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
SiteScriptCompileCache now records each eviction sweep on scadabridge.site.script_compile_cache.evictions
(ScadaBridgeTelemetry). It rides the existing ZB.MOM.WW.ScadaBridge meter, which is already in
SiteServiceRegistration.ObservedMeters, so no new allowlist entry is needed; a Host test pins that
the meter stays allowlisted.
Each cached client records when it was last handed out; a periodic sweep (half of the new
CommunicationOptions.GrpcClientIdleTtl, default 30 min, 0 disables) disposes and removes entries
unused for the TTL that have no live subscription (SiteStreamGrpcClient.ActiveSubscriptionCount).
Lookup and the sweep's check-and-remove share one lock so a handed-out client is never disposed
under its caller; an evicted client is re-created on next GetOrCreate.
ManagementActor now drops the central definition cache post-commit (via the existing
AfterConfigurationCommit seam) after every external system, external-system method and
database connection create/update/delete, so an edit takes effect on the editing node
immediately instead of after DefinitionCacheTtl. ManagementService gains a project reference
to ExternalSystemGateway. The other central node still converges within the TTL.
An OperationCanceledException escaping RunAsync after ApplicationStopping has fired
(WindowsServiceLifetime.StopAsync gets a cancelled token after shutdown finished) is a
normal stop. Program.IsGracefulShutdown now routes it to an Information line; any other
exception, or a cancellation with no shutdown requested, still logs Fatal and rethrows.
Codec-encoded scalars ("True", "42.5") reached RealOpcUaClient.WriteValuesAsync
as strings and were sent as String Variants, so a Boolean/Double node answered
BadTypeMismatch (0x80740000).
RealOpcUaClient now reads each written node's DataType attribute once per
session (batched, cached, cleared on connect/disconnect; derived types and
enumerations resolved via the supertype chain) and converts non-null scalar
values with the new OpcUaWriteCoercion table: Boolean, SByte..UInt64, Float,
Double, String, DateTime, Guid, all culture-invariant. Overflow, fractional
values to integer types and unparseable strings are refused client-side with
BadTypeMismatch and a message naming the value and target type - never a
rounded value. Arrays, null, unmapped types and nodes whose DataType read
fails pass through unchanged. WriteValueAsync now surfaces the outcome's own
status code instead of always BadNodeIdUnknown. MxGateway path untouched.
Tests: OpcUaWriteCoercionTests (coercion table incl. overflow, invalid and
culture-sensitive strings) and OpcUaWriteCoercionLiveTests (RequiresOpcUa:
String writes to Double/Boolean/UInt32 opc-plc nodes read back typed; reverting
the coercion reproduces 0x80740000). Component-DataConnectionLayer.md updated.
DeploySiteAsync's body moves into a private core taking the site plus an
optional area-id filter; site deploy behaviour is unchanged. The subtree is
one in-memory ParentAreaId walk (cycle-safe) shared by the deploy and the
preview. DeploymentService gains an ITemplateEngineRepository dependency for
the area lookups.
Under DisableLogin /auth/token deliberately answers 404 (lane K). The tests
now probe it: on a login-disabled rig TokenEndpoint_ReturnsJwt skips and the
bad-credentials test asserts 404 (the exchange is closed); with login enabled
they keep the 200 + JWT and 401 assertions.
The Topology page has loaded lazily (bounded instance pages behind 'Load
instances' loaders) since 9ee402bb (2026-09-09); TopologyAreaTests was
updated then but DeploymentActionTests still relied on one Expand-all click
revealing instance rows, so all four facts timed out locating the row. Move
OpenStableTopologyAsync into TopologyPageHelper and use it from both.
The rename input bound _renameBuffer with the default onchange trigger. A
browser fires change only after the Enter keydown (or on blur, which cancels
by design), so CommitRename always saw the original name and silently
no-oped: a UI rename never persisted. Present since the page was written
(pre-dates 513fa1aa); the Playwright test masked it by dispatching a
synthetic change event before Enter. Bind on oninput instead.
Tests: bUnit InlineRename_TypeThenEnter_PersistsNewName (fails on the old
binding); the Playwright rename test now types + Enter like an operator.
Also fix the Playwright OpenStableTopologyAsync race: it counted 'Load
instances' loaders immediately after clicking Expand all, before the
circuit re-render landed, and returned an unexpanded tree; it now waits
for no collapsed branch first.
Deploys are compile-warmed off the actor thread and reach the four-slot
predecessor-stop queue in warm-completion order (cross-instance ordering is
not guaranteed by design). Since SR-P5 moved the config deserialization into
that warm task, the fifth-sent deploy often finishes warming before one of the
first four, takes a slot, and Queued4 terminated inside the 100 ms no-message
window (16/20 failures under parallel load). The test now waits for the four
in-flight predecessors to terminate, cancels the one still alive, and also
asserts the cancelled redeploy is answered Failed/superseded.