Commit Graph

1963 Commits

Author SHA1 Message Date
Joseph Doherty f5eed5e8fd feat(audit): persist OPC UA certificate-trust decisions to the central audit log (ScadaBridge#43)
New AuditChannel.Security with AuditKind.CertTrust / CertTrustRemove (appended, ordinals stable).
Both central entry points - the management actor handlers and the Central UI CertManagementService -
write a best-effort ICentralAuditWriter row after the site outcome is known (Delivered when all
reachable site nodes acked, Failed otherwise, incl. relay timeouts), naming actor, site, thumbprint
and connection. Shared builder: CertTrustAuditEvents.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:17:03 -05:00
Joseph Doherty f98369a786 feat(native-alarms): per-binding IsMesReportable on native alarm source bindings (ScadaBridge#59)
TemplateNativeAlarmSource.IsMesReportable + tri-state InstanceNativeAlarmSourceOverride
.IsMesReportableOverride flatten onto ResolvedNativeAlarmSource; NativeAlarmActor stamps every
mirrored condition and InstanceActor the quiet-binding placeholder, so ScriptAlarm,
Alarms.MesReportableAsync() and the #60 wire fields carry it. Additive to the 900-999 severity band.
Migration AddNativeAlarmSourceMesReportable. Management commands, CLI --mes-reportable, Central UI
(template dialog checkbox, instance override select), Transport DTOs/importer/diff and the revision
hash (true-only, so existing hashes are unchanged) carry it.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:09:54 -05:00
Joseph Doherty baf89b3df7 test+docs: serialise data-connection gauge tests, add metrics instrument reference (ScadaBridge#99)
DataConnectionGaugeTests and ExtendedInstrumentTests both mutate the process-global data
connection registry; put them in a non-parallel xUnit collection. Add docs/operations/metrics-reference.md.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 14:50:58 -05:00
Joseph Doherty 7848a3fe7c fix(transport): case-insensitive remaining library lookups; base-only template reads for import targets (ScadaBridge#95)
- Preview matches template folders, shared scripts, external systems, SMTP
  (by Host) and SMS (by AccountSid) with the #93 ToNameLookup comparer
  (OrdinalIgnoreCase, first wins, never throws), matching SQL Server's
  case-insensitive collation, so a differently-cased target row is reported as
  a conflict instead of New.
- Apply-side in-memory lookups do the same: ApplyTemplateFoldersAsync, the
  template folder-id map, ResolveFolderParentEdgesAsync, ApplySmtpConfigsAsync
  and ApplySmsConfigsAsync. An Overwrite now updates the existing row instead of
  adding a duplicate (folder names are only unique per parent, so the old
  ToDictionary could also throw).
- New ITemplateEngineRepository.GetBaseTemplatesWithChildrenAsync filters
  IsDerived in SQL. Preview and the Overwrite load in ApplyTemplatesAsync use it,
  so a derived slot template that shares a bundled name is never the import
  target. GetTemplatesWithChildrenAsync is unchanged for the id-keyed callers
  (export closure, rewire passes), which can need derived rows.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 14:30:17 -05:00
Joseph Doherty 3a31dd2de7 fix(host): follow an in-place site PSK rotation without a central restart (ScadaBridge#96)
SitePskProvider cached each site's gRPC preshared key forever; only site removal
invalidated it, so rotating SB-GRPC-PSK-{site} broke the central<->site link on
both central nodes until they restarted. The cached key is now dropped when:

- the secret is written through /admin/secrets: central replaces the secrets
  library's ISecretCacheInvalidator with SitePskRotationInvalidator, which also
  publishes SitePskInvalidated on a new DistributedPubSub topic
  (site-psk-invalidation) so the peer central node drops its copy and its
  resolver cache entry too (SitePskInvalidationSubscriber);
- a site rejects a central->site call with PermissionDenied/Unauthenticated:
  WithSiteCredentials wraps every per-site channel's handler in
  PskRejectionRefreshHandler, so the next call re-reads the key (covers
  out-of-process writes such as the secret CLI);
- a site->central call presents a token that does not match the cached key:
  CentralControlAuthInterceptor invalidates and re-reads once before refusing;
- ScadaBridge:Communication:SitePsks reloads.

Invalidation is case-insensitive because SecretName lowercases names. Rotation
runbook (topology-guide), Component-Communication/Host and the #85 cache audit
(D2) updated.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 14:30:17 -05:00
Joseph Doherty 7f479f3a5c feat(logging): stamp ExecutionId/InstanceName/ScriptName on the Serilog LogContext per run (ScadaBridge#56)
The site script and alarm on-trigger launchers push the run identity onto LogContext for the
run's lifetime and the host now enables Enrich.FromLogContext(), so log lines written on the
run's own async call chain carry the id. Bounded part only: LogContext does not cross an Akka
message hop, so Instance Actor/DCL/S&F lines and a site_events execution-id column remain open
(documented in the step-logs design doc section 9).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 14:20:02 -05:00
Joseph Doherty 5ef27fd9a3 feat(telemetry): deploy-failure, S&F activity, script-run and DCL-transition instruments (ScadaBridge#81)
Adds four instruments on the already-allow-listed ZB.MOM.WW.ScadaBridge meter:
scadabridge.deployments.failed{reason}, scadabridge.store_and_forward.activity{action,category},
scadabridge.site.script.runs{outcome} + scadabridge.site.script.duration{outcome} (ms),
scadabridge.site.data_connections.transitions{state}. Queue depth, DCL connection state and
deploy-applied already existed. No new Meter, so ZbTelemetryOptions.Meters needs no change.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 14:17:53 -05:00
Joseph Doherty 7892c622cf fix(health): site /health/active uses the shared role-scoped ActiveNodeHealthCheck (ScadaBridge#97)
Replace the private SitePairActiveNodeHealthCheck (503 whenever no ActorSystem, no data)
with the shared ZB.MOM.WW.Health ActiveNodeHealthCheck constructed with the site-{SiteId}
role: Degraded (200) during startup per the Health spec, and it publishes
selfAddress/activeRole/activeNode so the Overview dashboard can name the active site node.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 14:13:53 -05:00
Joseph Doherty a16b7b4e65 fix(central): audit per-node central caches; management API drops ApiMethodCache entries (ScadaBridge#85)
Audit (docs/plans/2026-11-09-central-cache-staleness-audit.md) of every central in-process
cache holding configuration or secret state, with its staleness bound on the peer central
node. Every config cache with peer staleness is TTL-bounded (5-60 s) and Traefik routes to the
active node only, so no peer case was clearly a bug and no cross-node broadcast is added; the
doc names the existing DistributedPubSub mediator (health replication topic) as the mechanism
if one is wanted. Owner decisions listed: RoleMappingCache 30 s revocation lag on the peer,
SitePskProvider caching a PSK forever across an in-place rotation (both nodes), and the #83
ExternalDefinitionLookupCache 10 s bound.

Fixed: ManagementActor API-method create/update/delete invalidated only the compiled handler,
not ApiMethodCache, so the node that made the change kept serving the pre-change row for up to
the 5 s TTL (a deleted method stayed callable; a cached not-found hid a new one). The handlers
now drop the entry post-commit, like the role-mapping cache.

Tests: ApiMethodCacheInvalidationTests (create/update/delete fail without the fix; a
negative control checks other methods stay cached).

(ScadaBridge#85)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:54:29 -05:00
Joseph Doherty 23c35df234 refactor(inbound-api): use the shared ZB.MOM.WW.Health IActiveNodeGate (ScadaBridge#80)
The InboundAPI carried its own IActiveNodeGate (implemented by Host/Health/ActiveNodeGate)
whose rule already matched the shared Health 0.3.0 AkkaActiveNodeGate — both route to
ClusterActiveNode (oldest Up member). The private interface and implementation are deleted;
InboundAPI references ZB.MOM.WW.Health (0.3.0, already pinned) and InboundApiEndpointFilter,
AuditWriteMiddleware, InboundMethodWarmupService and FlowDebugRunner consume
ZB.MOM.WW.Health.IActiveNodeGate. The Central branch of Program.cs registers
AkkaActiveNodeGate (unscoped, like the active-node health check).

Warm-up behaviour is kept: the shared gate answers false while no ActorSystem resolves, while
this member is not Up, and on any cluster-init fault. It resolves the ActorSystem through the
existing DI singleton bridge (GetOrCreateActorSystem), the same path the health probes use.

Tests: CompositionRoot asserts the shared gate type (fails with the registration removed);
the evaluator wiring test now drives AkkaActiveNodeGate; new warm-up test (no ActorSystem ->
not active). Nothing was missing from the Health 0.3.0 package.

(ScadaBridge#80)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:54:29 -05:00
Joseph Doherty 1ee2e171cf refactor(deployment): move PendingUpdatesCache to DeploymentManager so the CLI shares it (ScadaBridge#91)
`health pending-updates` (#63) called IPendingUpdatesEvaluator.EvaluateAsync directly
from ManagementActor because the cache lived in CentralUI, so every CLI call was a full
uncached fleet flatten. IPendingUpdatesCache/PendingUpdatesCache now live in
DeploymentManager.PendingUpdates and are registered by AddDeploymentManager; the
SingleFlightMemo<T> primitive moves to Commons.Caching (public) so both CentralUI's KPI /
alarm-summary caches and DeploymentManager can use it. HandleGetPendingUpdates reads the
shared cache; the UI circuits and the CLI now share one memo per node.

Tests: ManagementActor repeated-calls test (fails against the direct evaluator call:
2 evaluations instead of 1), AddDeploymentManager singleton-registration test; the cache
and memo tests move with their types.

(ScadaBridge#91)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:54:29 -05:00
Joseph Doherty c7d868c52e fix(transport): case-insensitive name lookups, batch preview lookups, correct flattener doc (ScadaBridge#93)
- ApplyTemplatesAsync/PreviewAsync template lookups use a case-insensitive,
  never-throwing comparer (SQL Server collation); base template wins over derived.
  Base names are already unique case-insensitively via the filtered unique index
  on Template.Name, derived templates are not covered by it.
- Preview reads database connections, notification lists and API methods with one
  GetAll query each instead of one by-name query per bundled row.
- Component-TemplateEngine.md: the flattener does not call GetAllTemplatesAsync;
  it loads chains via GetTemplateWithChildrenAsync.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:46:45 -05:00
Joseph Doherty bec07378cc test(deployment): de-flake unreachable-site reconcile breaker test; assert structurally, not by 2 s wall clock (ScadaBridge#92)
Under a loaded full-project run the serial prepare of 6 instances with a 300 ms
reconcile deadline took 2.6 s and tripped the fixed 2 s cap (5/10 full runs
failed under CPU load). Deadline raised to 2 s so it cannot expire before the
query is sent; the elapsed bound is now derived from the deadline (must beat
(n-1) deadlines) while the one-query assertion proves the breaker.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:44:02 -05:00
Joseph Doherty 7153a1974e perf(transport): make large-bundle apply linear in the template rewire passes (ScadaBridge#47)
Measured first: a scratch scaling probe (seed -> export -> load/preview/apply
into a fresh target and over itself, in-memory provider, every repository
call counted, DetectChanges counted via ChangeTracker.DetectingAllChanges)
showed load, preview and the instance pass scale linearly (500 / 1000 / 2000
instances apply in 0.13 / 0.27 / 0.35 s), but the post-flush template
rewire passes were quadratic: 100 / 400 / 800 composing, derived templates
applied in 0.5 / 8.1 / 26.5 s, with DetectChanges running 5N+10 times and
GetTemplateWithChildrenAsync 2N+2 times.

Cause: ResolveAlarmScriptLinksAsync, ResolveCompositionEdgesAsync and
ResolveInheritanceEdgesAsync found each template with
_dbContext.Templates.Local.FirstOrDefault(...) - per template, and per
composition edge / base lookup. Every DbSet.Local access runs a full
DetectChanges over everything the import has staged. The first two passes
also re-read every template's children one query at a time (a split query
per call on SQL Server).

Fix (no bundle-format or semantic change):
- each pass indexes the tracked templates by name once
  (SnapshotLocalTemplatesByName; ordinal, first wins like FirstOrDefault).
  These passes never stage a template or rename one, so the index stays
  exact for the pass;
- the alarm and composition passes read their templates' children in one
  batched GetTemplatesWithChildrenAsync, matched back by the stub's id;
- the alarm pass only loads templates whose bundled alarms name an
  on-trigger script (the only ones it can rewire).

Result on the same probe: 100 / 400 / 800 templates apply in
0.13 / 0.62 / 0.64 s; DetectChanges is a constant 9; per-template children
reads 0, batched reads 1 (add) / 2 (overwrite).

Evidence: ImportApplyScalingTests (Transport.IntegrationTests) exports 5 and
40 composing, derived templates with alarm -> script links, imports each
into an empty target (Add) and again (Overwrite), and asserts the imported
inheritance / composition / alarm-script links, zero per-template children
reads, at most 3 batched reads, and an identical DetectChanges count for 5
and 40 templates. Fails on the old importer (12 per-template reads), and
with a per-DTO Local snapshot reintroduced in one pass (DetectChanges 51
vs 16). Transport unit (198) and integration (138) suites pass.

Not changed: preview's per-name lookups for database connections,
notification lists and API methods stay per-name - moving them to an
in-memory ordinal match could change name matching under a case-insensitive
SQL collation.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:30:27 -05:00
Joseph Doherty 51d2f6c906 refactor(deployment): injectable terminal-purge rate limiter; test the swallowed failure (ScadaBridge#90)
DeploymentService.TryPurgeTerminalDeploymentRecordsAsync rate-limited its
opportunistic retention sweep with a process-wide static tick field, so only
one test in the assembly could drive the sweep and its swallow-on-failure path
was untested.

The limit now lives in TerminalPurgeRateLimiter: a TimeProvider-driven
compare-and-swap claim (MinInterval 6 h) registered by AddDeploymentManager as
a DI singleton, so every scoped DeploymentService still shares one limit per
node. DeploymentService takes it as an optional trailing constructor parameter;
a hand-built instance gets its own limiter. The sweep's cutoff now comes from
the same clock. Behaviour is otherwise unchanged.

Evidence (TerminalPurgeRateLimiterTests):
- claim once per interval on an injected clock; 16 concurrent claimants ->
  exactly one wins;
- two services with their own limiters both sweep, two sharing one sweep once;
- cutoff = injected now - retention;
- a failing artifact batch is logged as a warning with the exception and
  swallowed, the already-purged terminal rows are still reported, the next
  call inside the interval does not retry and the first call after it does;
  a first-batch failure returns 0 and skips the artifact purge;
- AddDeploymentManager registers the limiter as a singleton shared across scopes.
Mutation-checked: a process-wide static limiter fails 5 tests (incl. the
existing artifact-purge test); narrowing the catch fails both failure tests.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:30:27 -05:00
Joseph Doherty 6070cc2b9d perf(templates): resync and resolved-members read only the chain/subtree (ScadaBridge#89)
The last two whole-library template reads are gone. Both fed the
inheritance-only TemplateInheritanceResolver, which walks one template's
parent chain and never follows compositions.

- TemplateService.ResyncInheritedMembersAsync (operator repair, CLI + editor)
  now reads #86's GetTemplateReconcileClosureAsync (derived subtree + the
  template's chain, tracked). When nothing derives from the template the
  closure is empty, so it falls back to the new tracked
  GetTemplateChainAsync - resyncing a leaf reads only its own chain.
- ManagementActor.HandleGetResolvedTemplateMembers now reads
  GetTemplateChainAsync(id, trackChanges: false): the chain alone, untracked,
  with script bodies (the resolver projects script code, so #66's body-free
  analysis closure would not be equivalent).

The chain walk is factored out of TemplateReconcileClosure into
CollectChainAsync (one load per level, stops on parent cycles and dangling
parents) and shared by both.

Evidence:
- TemplateChainReadTests (#86 fixture): for every template plus an unknown
  id, the closure-backed resync issues exactly the writes, audit row and
  result of the whole-graph resync with GetAllTemplatesAsync wired to throw;
  the resolver answers identically from the chain and the whole graph.
  17 of these fail against the old service; 13 fail with the leaf chain
  fallback removed (mutation-checked).
- TemplateChainRepositoryTests (SQLite, recorded SQL): a 4-deep chain loads
  4 templates / 8 attributes instead of 46 / 92, one split-query set per
  level, untracked on request and tracked for the resync.
- ResolvedTemplateMembersReadTests: the actor answers from the untracked
  chain read and never calls GetAllTemplatesAsync (fails on the old actor).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:30:26 -05:00
Joseph Doherty d50433410d feat(health): surface pending-updates counts on the Health dashboard and CLI (ScadaBridge#63)
Health page gains PendingUpdatesHealthTiles (stale instances / sites with stale
artifacts, Deployer+Administrator only, site-scope narrowed, shared cache). New
GetPendingUpdates management command (Deployer) and 'health pending-updates' CLI
leaf. Flatten/cache computation untouched; the command reads
IPendingUpdatesEvaluator directly because the UI cache is not reachable from
ManagementService.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:18:54 -05:00
Joseph Doherty 004bf3cf06 feat(health): show site event-log/audit write and redaction failures on the Health dashboard (ScadaBridge#88)
New SiteWriteFailureBadges component renders warning badges for
SiteEventLogWriteFailures, SiteAuditWriteFailures and AuditRedactionFailure
when > 0 on online sites. The CLI 'health summary' already serialises the full
latest report, so these counters are visible there unchanged.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:13:58 -05:00
Joseph Doherty c4186509f6 refactor(health): move ISiteHealthCollector to Commons; Communication drops HealthMonitoring reference (ScadaBridge#87)
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
2026-11-09 13:13:58 -05:00
Joseph Doherty e5ad4c9f53 test(site-call-audit, deployment): cover error, retry and state-transition branches (ScadaBridge#73)
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
2026-11-09 13:10:31 -05:00
Joseph Doherty c29b99c63b perf(templates): descendant reconcile loads only the subtree + chain (ScadaBridge#86)
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
2026-11-09 13:10:31 -05:00
Joseph Doherty 0eac956d25 feat(health): report permanently rejected site audit rows on the site health report (ScadaBridge#69)
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
2026-11-09 13:05:48 -05:00
Joseph Doherty 3ae3f1bcef refactor(health): move ICentralHealthAggregator and SiteHealthState to Commons (ScadaBridge#71)
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
2026-11-09 13:05:48 -05:00
Joseph Doherty 4918210b78 build: adopt CentralPackageTransitivePinningEnabled, drop per-project SQLitePCLRaw pins (ScadaBridge#79)
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
2026-11-09 13:05:48 -05:00
Joseph Doherty 792f16c2d5 perf(templates): member add/update loads only the chain + composition closure (ScadaBridge#66)
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
2026-11-09 12:52:29 -05:00
Joseph Doherty d36825633b feat(streaming): carry AckTime on DebugAlarmStateDto (21) (ScadaBridge#84)
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
2026-11-09 12:45:21 -05:00
Joseph Doherty 03de3dcc14 fix(transport): drop the external-definition lookup cache after a committed bundle import (ScadaBridge#83)
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
2026-11-09 12:40:43 -05:00
Joseph Doherty 6d5bb6189f feat(flow-debugger): carry site-side trace truncation on the route reply (ScadaBridge#54)
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
2026-11-09 12:37:57 -05:00
Joseph Doherty 5310fd0e26 feat(streaming): carry IsMesReportable on AlarmStateUpdate (25) and DebugAlarmStateDto (20) (ScadaBridge#60)
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
2026-11-09 12:31:37 -05:00
Joseph Doherty 6bd1fd3072 test(scripts): per-row exclusion seam in CompileSurfaceParityTests; guard ScriptLogger/CompileLog (ScadaBridge#57)
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
2026-11-09 12:28:22 -05:00
Joseph Doherty 79957c4b96 feat(telemetry): export the site script compile-cache eviction counter as a Meter (ScadaBridge#68)
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.
2026-11-09 12:08:25 -05:00
Joseph Doherty e2b15bd520 fix(communication): evict idle SiteStreamGrpcClientFactory (site, endpoint) clients (ScadaBridge#67)
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.
2026-11-09 12:07:28 -05:00
Joseph Doherty e1734675e9 fix(management): invalidate ExternalDefinitionLookupCache on external-system / DB-connection edits (ScadaBridge#65)
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.
2026-11-09 12:05:20 -05:00
Joseph Doherty e0ec2a0507 fix(host): log a routine service stop as Information, not [FTL] (ScadaBridge#76)
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.
2026-11-09 12:05:20 -05:00
Joseph Doherty a5c3de63e9 fix(dcl): coerce scalar OPC UA writes to the node DataType (#58)
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.
2026-11-06 16:08:33 -05:00
Joseph Doherty 0e5402915b fix(centralui): pending-updates rail refreshes as soon as a deployment finishes — cache drops its snapshot on Success/Failed and signals the indicator, instead of serving the pre-deploy count for up to TTL + one poll 2026-10-22 17:11:18 -04:00
Joseph Doherty 6ed6800b84 fix(host): clear pooled SQLite handles before renaming the imported inbound-key file — Windows refused the rename, so the import re-ran every boot (QA vq03/vq04 2026-10-22) 2026-10-22 15:56:00 -04:00
Joseph Doherty e029dc1ccb fix(centralui): Deploy all runs in its own DI scope, live tick/Refresh/loaders pause during actions, dialog wording, failure/saved-state tests (Gitea #40) 2026-10-22 14:27:27 -04:00
Joseph Doherty bcd087efe8 feat(centralui): Topology 'Deploy all…' on site and area nodes with confirm dialog, Force and summary toast (Gitea #40) 2026-10-22 14:27:26 -04:00
Joseph Doherty d71f61e71b feat(centralui): Topology pre-loads and expands the first layer (sites -> top-level areas) (Gitea #39) 2026-10-22 14:27:26 -04:00
Joseph Doherty 1d38fb6c90 fix(cli): deploy area names a malformed summary as an invalid area deployment summary (Gitea #40 review) 2026-10-22 14:27:26 -04:00
Joseph Doherty 0956395d81 test(managementservice): MgmtDeployArea in-scope success path relays Force to a real DeploymentService and returns its summary (Gitea #40 review) 2026-10-22 14:27:26 -04:00
Joseph Doherty 74d5fba2b0 feat(cli): deploy area --id <areaId> [--force] bulk-deploys an area subtree with deploy site's deadline and exit-code contract (Gitea #40) 2026-10-22 14:27:26 -04:00
Joseph Doherty f72c72c52f feat(managementservice): MgmtDeployArea — Deployer-only, scoped by the area's owning site (fail-closed on an unknown area), long-running like MgmtDeploySite (Gitea #40) 2026-10-22 14:27:26 -04:00
Joseph Doherty eb1c9ea7f9 feat(deploymentmanager): DeployAreaAsync bulk-deploys an area subtree through the shared DeploySiteAsync core; PreviewSubtreeDeployAsync returns confirm counts (Gitea #40)
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.
2026-10-22 14:27:26 -04:00
Joseph Doherty a04eb3a752 test(playwright): token-endpoint tests tolerate DisableLogin
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.
2026-10-22 14:27:26 -04:00
Joseph Doherty 4ae3ed3fdd test(playwright): DeploymentActionTests load the lazy Topology tree via a shared helper
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.
2026-10-22 14:27:26 -04:00
Joseph Doherty 560adefb0c fix(centralui): Topology inline area rename commits the typed name on Enter (Gitea #38)
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.
2026-10-22 14:27:26 -04:00
Joseph Doherty e7896494f1 test(site-runtime): SR5 cancel-queued-redeploy test observes which predecessor is queued instead of assuming Queued4
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.
2026-10-22 10:35:37 -04:00
Joseph Doherty 7be22d2ac2 fix(auditlog): truncate over-width SiteCalls.LastError on the cached-telemetry dual-write and the reconciliation pull (review 2026-10-21 follow-up) 2026-10-22 09:58:46 -04:00