Commit Graph

2447 Commits

Author SHA1 Message Date
Joseph Doherty 4cd1441984 Merge branch 'worktree-agent-aae48b78656e5a4e0' into arch-review-remediation
# Conflicts:
#	src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Repositories/SiteExternalSystemRepository.cs
2026-08-14 21:16:11 -04:00
Joseph Doherty edd925e277 Merge branch 'worktree-agent-a0098b40576d74cfd' into arch-review-remediation 2026-08-14 21:15:32 -04:00
Joseph Doherty d494911190 Merge branch 'worktree-agent-acc1e4b5202e79d46' into arch-review-remediation 2026-08-14 21:15:31 -04:00
Joseph Doherty b2181b80bd Merge branch 'worktree-agent-ae22af64445b321d4' into arch-review-remediation 2026-08-14 21:15:31 -04:00
Joseph Doherty f25b75a64d Merge branch 'worktree-agent-a7084b23177344196' into arch-review-remediation 2026-08-14 21:15:31 -04:00
Joseph Doherty d93f96d08a Merge branch 'worktree-agent-a3b474b485c0288de' into arch-review-remediation 2026-08-14 21:15:31 -04:00
Joseph Doherty 48b3c40a7f perf(deploy): flatten-session caching, bulk DeploySiteAsync, paged management queries 2026-08-14 21:14:22 -04:00
Joseph Doherty d15c5f02ea perf(dcl): batch subscribe/read/write seam, bounded reconnect, sharded subscriptions 2026-08-14 21:14:04 -04:00
Joseph Doherty 2ce0ad7ed1 perf(comms): alarms-only seed, capped buffers, at-least-once audit pull 2026-08-14 21:10:19 -04:00
Joseph Doherty 5db2a810c0 perf(central): set-based ingest, aligned partition purge, KPI query shapes, EF hygiene 2026-08-14 21:07:12 -04:00
Joseph Doherty a212283104 perf(misc): cached hot-path lookups, bounded observer queue, alarm-priority stream path
WP2.6 (arch-review remediation, cross-cutting misc):
- SiteExternalSystemRepository: name/ID-indexed ExternalSystemDefinitionCache replaces
  the fetch-all + reverse-map scan on every by-ID/method lookup; loaded once per
  redeploy, invalidated by DeploymentManagerActor after HandleDeployArtifacts applies
  external-system changes. Static JsonSerializerOptions for method-list parsing.
- Inbound API: short-TTL ApiMethodCache fronts the per-request ApiMethod repository
  fetch; invalidated by name via the existing ScriptArtifactChangeSubscriber/
  IScriptArtifactChangeBus pipeline, self-healing via TTL for changes the bus
  doesn't cover (e.g. Management API edits).
- StoreAndForward: the cached-call audit-observer queue — the one unbounded channel
  left in the system — is now bounded (ObserverQueueCapacity, default 10,000) with
  DropOldest overflow and a dropped-notification counter.
- SiteStreamManager: alarm state changes now travel a dedicated publish
  source/broadcast hub, isolated from the (far higher-volume) attribute path, so an
  attribute storm can no longer evict a pending alarm transition; the alarm hand-off
  queue is bounded with a drop counter surfaced on the site health report
  (SiteStreamAlarmDropCount via the new SiteStreamAlarmDropReporter), and publishing
  is skipped entirely at zero subscribers on either path.
- CLI ManagementHttpClient: explicit 30s HttpClient.Timeout on the shared
  construction (was the 100s framework default), overridable via
  SCADABRIDGE_HTTP_TIMEOUT_SECONDS.

Deviation: the failback-probe heartbeat item is NOT included — its only viable
surface (CentralChannelProvider.cs / heartbeat consumers) lives entirely in the
Communication project, explicitly off-limits to this work package this phase.

Tests: SiteRuntime.Tests (550), InboundAPI.Tests (278), StoreAndForward.Tests (133),
CLI.Tests (390), HealthMonitoring.Tests (97) — all green after full solution build.
2026-08-14 20:59:43 -04:00
Joseph Doherty 8c0b36b2fa perf(ui): shared KPI cache, live-cache-backed alarm summary, coalesced debug renders 2026-08-14 20:53:38 -04:00
Joseph Doherty 1040dc0fcc docs(plans): DCL batch seam design — WP2.1a 2026-08-14 20:30:35 -04:00
Joseph Doherty 70cf718949 Merge branch 'worktree-agent-ac58bf5116b74ff84' into arch-review-remediation 2026-08-14 20:14:13 -04:00
Joseph Doherty 628a14c299 Merge branch 'worktree-agent-a44b52ad62ac16433' into arch-review-remediation 2026-08-14 20:14:13 -04:00
Joseph Doherty c6e26a1a6f Merge branch 'worktree-agent-a7e4ca4e5668d81b3' into arch-review-remediation 2026-08-14 20:14:13 -04:00
Joseph Doherty c7a89cbe88 Merge branch 'worktree-agent-a95eaaa8a3181ad4c' into arch-review-remediation 2026-08-14 20:14:13 -04:00
Joseph Doherty da62e01105 Merge branch 'worktree-agent-af8a44154c4e2dbba' into arch-review-remediation 2026-08-14 20:14:13 -04:00
Joseph Doherty 663ce2f14f Merge branch 'worktree-agent-a27e5c199270a6d63' into arch-review-remediation 2026-08-14 20:14:13 -04:00
Joseph Doherty 87e25c790c Merge branch 'worktree-agent-a143c0cc0b4d07e76' into arch-review-remediation 2026-08-14 20:14:13 -04:00
Joseph Doherty 2e4e41a8f7 fix(auditlog): site audit DB onto the data volume; required path + soft flush
Closes WP1.2 of the arch-review remediation plan (finding #2, High):
SqliteAuditWriterOptions.DatabasePath defaulted to CWD-relative "auditlog.db",
which on the docker rig resolves onto the container's ephemeral overlayfs
(not the mounted /app/data volume), silently discarding the pending audit
forward-state backlog on every recreate; nothing in docker/ or docker-env2/
overrode it; FlushIntervalMs was validated but never read by the writer loop
(one commit per event even at trickle rate); and no PRAGMA synchronous was
set (SQLite's FULL default fsyncs every commit).

- DatabasePath now has no default (mirrors ZB.MOM.WW.LocalDb's LocalDbOptions.Path)
  and is required pre-host for Site nodes only, via a new StartupValidator raw-config
  check (top-level "AuditLog:SiteWriter:DatabasePath", NOT nested under ScadaBridge:
  AddAuditLog binds that section off the configuration root). SqliteAuditWriterOptionsValidator
  deliberately does NOT check DatabasePath itself, because AddAuditLog runs its
  ValidateOnStart on both Central and Site composition roots but only Site nodes
  ever resolve the writer — checking it there would fail Central's boot too.
- All 8 site-node appsettings under docker/ and docker-env2/ now set
  AuditLog:SiteWriter:DatabasePath to /app/data/auditlog.db (mounted volume,
  survives container recreate, same convention as LocalDb:Path); the local-dev
  base appsettings.Site.json sets ./data/auditlog.db to match.
- The writer loop now honors FlushIntervalMs: after draining the immediately
  available burst, it keeps the transaction open (bounded by FlushIntervalMs
  from the first event) waiting for more trickle-rate events before committing,
  instead of flushing (and fsyncing) per event.
- PRAGMA synchronous = NORMAL on the write connection — audit is best-effort by
  design (CLAUDE.md: "Audit-write failure NEVER aborts the user-facing action"),
  so NORMAL's narrower power-loss window is an acceptable trade for far fewer
  fsyncs; WAL mode still guarantees no corruption.
- Tests: StartupValidator site-required/blank/central-exempt cases; writer
  trickle-load single-transaction coalescing + beyond-interval separate-transaction
  regression (new FlushCountForTests seam); options-validator doc updates reflecting
  the moved responsibility. Full suite runs green: AuditLog.Tests 368/368,
  Host.Tests 480/480.

One-time migration note: the existing container-local auditlog.db (wherever it
landed under CWD) is abandoned by this change, not migrated — already-forwarded
rows are safe centrally (AuditLog is the durable copy), and any still-Pending
rows on the abandoned path are lost once. This is the exact bug being fixed, not
a new loss: those rows were already living outside the mounted volume and would
not have survived the next container recreate regardless. Cross-reference
docs/known-issues/2026-07-20-cached-telemetry-drain-hot-loop.md, which this
placement bug caused.
2026-08-14 20:13:31 -04:00
Joseph Doherty 7ebdcd370a perf(host): install CDC capture only when replication is configured
SiteLocalDbSetup.OnReady registered all ten replicated tables
unconditionally, so a deliberately unreplicated site node (site-b and
site-c on the rig) carried the full 30-trigger CDC set forever. Every
write to those tables paid two extra INSERTs plus a json_object
serialization of the whole row, inside the caller's own transaction, and
appended to an oplog nothing ever drains. Arch-review finding #5 (High),
repo half; the library half — trigger cleanup API and O(1) backlog — is
WP3.3.

The ten RegisterReplicated calls are now behind a guard on whether the
node has LocalDb:Replication:PeerAddress OR LocalDb:Replication:ApiKey.
Either key counts, and the OR is load-bearing rather than defensive:
replication is one bidirectional stream that exactly one side dials, so
only the initiator sets PeerAddress. Verified against the rig — site-a
node-a has PeerAddress + ApiKey, site-a node-b (passive) has ApiKey
alone, site-b/site-c have no Replication section at all. Keying on
PeerAddress alone would have stripped capture from every passive node and
silently made each pair converge in one direction only.

The load-bearing ordering documented in the file is preserved: DDL still
precedes registration, and the legacy migrator still runs unconditionally
after it — an unreplicated node must still absorb its pre-Phase-1 files,
and it has no peer for those rows to be invisible to.

Known residual, documented in-file and in the topology guide: a database
file first created by an older build keeps its stale __localdb_* triggers.
The guard decides whether triggers are installed, not whether existing
ones are removed, and the library has no removal API until WP3.3. Moot on
the docker rig, where a schema-change redeploy recreates the volumes.
The inverse is also now documented: enabling replication on a site that
has run without it does not baseline existing rows, since CDC never
recorded them in __localdb_row_version and the snapshot resync streams
from that ledger.

Tests: new SiteLocalDbCdcRegistrationTests asserts trigger presence and
absence via sqlite_master across all four config shapes (none, ApiKey
only, PeerAddress + ApiKey, and the notification-table exclusion), plus
DDL-still-runs and migrator-still-runs on the unreplicated branch.
SiteLocalDbWiringTests and the integration site-pair harness now
configure an ApiKey — mirroring the rig's passive node — so their
registration and convergence assertions still describe a replicating
node. 483/483 Host.Tests pass; the 20 offline LocalDb convergence tests
still pass.
2026-08-14 19:59:53 -04:00
Joseph Doherty 125055d998 perf(runtime): O(1) attribute resolution, precomputed types, coalesced static writes, shared JSON options 2026-08-14 19:57:26 -04:00
Joseph Doherty 34a3f4bb69 fix(comms): reconnect on graceful stream completion — kills the 4h silent stream death 2026-08-14 19:57:08 -04:00
Joseph Doherty 600659d579 perf(sql): sweep/KPI covering indexes + sliced notification terminal purge 2026-08-14 19:55:48 -04:00
Joseph Doherty 2cfcd89052 perf(sitelog): batched event-log commits and sliced retention purge 2026-08-14 19:54:58 -04:00
Joseph Doherty c5e66ed4e4 fix(comms): fail known-dead sends immediately instead of burning Ask timeouts 2026-08-14 19:49:00 -04:00
Joseph Doherty 0b201e410c docs(plans): arch-review remediation plan — multi-phase, subagent-executable program for all 2026-08-14 review findings 2026-08-14 19:23:56 -04:00
Joseph Doherty ee193cd2bb test(centralui): pin the Administrator-only /admin/secrets nav link
The Secrets management UI (ZB.MOM.WW.Secrets Secrets.Ui, mounted at
/admin/secrets) has been linked from the NavMenu Admin section since the
Theme adoption, but no NavMenu test asserted it. Add bUnit coverage that
the item renders for an Administrator and is absent for a
Designer+Deployer principal, matching the existing role-gate test style.
2026-08-13 09:29:49 -04:00
Joseph Doherty b71fbae36e fix(docker+tests): restore rig LDAP login via redundant local GLAuth pair + FallbackServers
The rig pointed at the shared 10.100.0.35 GLAuth whose serviceaccount password
was rotated (SEC-36), so central login had been failing ('Authentication service
is misconfigured') and a TEMP DisableLogin workaround was pending. Central nodes
now point at the local redundant pair (scadaproj/infra/glauth-redundant,
host.docker.internal:3893 + FallbackServers :3894), where the dev bind password
is correct — live-gated on the redeployed rig: login OK, primary-kill failover,
sticky preference (bind-count proven), walk-back on backup-kill.

AuthFlowTests factory bound as cn=admin for search-then-bind, but the current
directory grants the search capability only to serviceaccount (admin searches
return 50 Insufficient access) — stale since the GLAuth config evolved; the test
had been skipping on the closed port and failed once anything answered :3893.
Now binds as serviceaccount; AuthFlowTests 5/5 against the pair.
2026-08-13 08:49:05 -04:00
Joseph Doherty d28824d967 chore(deps): bump ZB.MOM.WW.Auth to 0.2.1 — AD continuation-referral fix 2026-08-13 08:42:53 -04:00
Joseph Doherty d4bb4e17f2 docs(plans): AddTemplateScript OOM re-tested on wonder — no longer reproduces (both forms, throwaway template, <1s) 2026-08-12 18:28:05 -04:00
Joseph Doherty d6b1942932 docs(plans): record uptime-matched wonder working-set re-read — 4.5x/2.0x reduction confirmed flat 2026-08-12 18:25:00 -04:00
Joseph Doherty 4a026d256e docs(plans): record wonder-app-vd03 live-gate PASS — leak closed in production (8.3x metadata collapse) 2026-08-12 18:10:02 -04:00
Joseph Doherty 20f2ae05ca docs(plans): record rig live-gate PASS for the resolver cache (+2 objs/compile vs +289 pre-fix) 2026-08-12 17:49:29 -04:00
Joseph Doherty df17b53f08 docs(plans): mark resolver-cache tasks 1-8 complete 2026-08-12 16:50:43 -04:00
Joseph Doherty d412fc3696 docs(scripts): record the shared caching metadata resolver invariant 2026-08-12 16:44:44 -04:00
Joseph Doherty 702de910ad test(scripts): pin null-result and definition-agnostic caching; review polish 2026-08-12 16:42:27 -04:00
Joseph Doherty b1c3783578 fix(scripts): memoize missing-assembly resolution on the design-time compile gate 2026-08-12 16:39:59 -04:00
Joseph Doherty 34e1a78e68 fix(scripts): memoize missing-assembly resolution in the UI script editor/sandbox 2026-08-12 16:39:38 -04:00
Joseph Doherty 8ecbc3fccb fix(scripts): memoize missing-assembly resolution on inbound API method compiles 2026-08-12 16:38:51 -04:00
Joseph Doherty 71284adcc4 fix(scripts): memoize missing-assembly resolution on the site compile path 2026-08-12 16:38:04 -04:00
Joseph Doherty 089b16980f fix(scripts): correct closure-size figure in regression-test comment 2026-08-12 16:37:22 -04:00
Joseph Doherty 431a5f6433 test(scripts): pin that repeat compiles resolve the assembly closure zero times 2026-08-12 16:34:37 -04:00
Joseph Doherty 2c8690a34f feat(scripts): add process-wide caching metadata resolver for script compiles 2026-08-12 16:33:52 -04:00
Joseph Doherty 901cec9026 docs(plans): script-compile metadata resolver cache plan 2026-08-12 16:30:53 -04:00
Joseph Doherty 5a781c706c fix(scripts): build Roslyn ScriptOptions once per process, not per compile
ScriptOptions.WithReferences(Assembly[]) resolves each assembly through
MetadataReference.CreateFromFile, which does not cache: every call mints a
fresh AssemblyMetadata -> PEReader -> NativeHeapMemoryBlock holding an
unmanaged copy of the assembly metadata that nothing disposes. Building the
options per compile therefore leaked native memory permanently — invisible to
the GC, to gcdump and to the managed allocation counters, so the working set
grew while the GC heap did not.

Diagnosed from a live dump of a wonder-app-vd03 Site node: 2,885 MB working
set 78 min after a cold start, only 150 MB live GC heap, ~2,469 MB on the
default process heap across ~6,700 undisposed AssemblyMetadata instances
against 473 DLLs on disk.

Three sites, all hoisted to static readonly:
- SiteRuntime ScriptCompilationService (the dumped one)
- InboundAPI InboundScriptExecutor — same defect on the central node; method
  compiles recur on every re-registration and revision change
- CentralUI ScriptAnalysisService — CreateFromFile per sandbox run

ScriptAnalysis RoslynScriptCompiler also builds options per call but draws
from the static ScriptTrustPolicy.DefaultReferences, so it mints no metadata
and is left alone.

Guarded by reference-equality on the artifact rather than by watching memory:
a bytes-watching test would be flaky, and the leak is native so the managed
counters cannot see it at all. The test is proven to fail before the fix.

This does NOT close the AddTemplateScript OOM — that path was shown twice not
to compile scripts. It explains how a long-running node reaches a native
memory state where a large allocation fails with gigabytes free, which is a
lead worth re-testing, not a closure.
2026-08-12 09:51:08 -04:00
Joseph Doherty 0974de1df5 docs(skills): track the two project skills CLAUDE.md treats as authoritative
CLAUDE.md points at .claude/skills/scadabridge-components/SKILL.md as the
component catalog and .claude/skills/scadabridge-cluster-ops/SKILL.md as the
source for the management URL, credentials and rebuild/redeploy commands — but
neither file was tracked, so a fresh clone got instructions referencing content
that was not there.

Only the skill files are added; .claude/settings.local.json and
scheduled_tasks.lock stay ignored via .gitignore as before.
2026-08-12 05:15:37 -04:00
Joseph Doherty 006202f3c7 fix(audit): fail closed when a configured redactor is unavailable (#35)
Component-AuditLog.md has always required "we over-redact, never under-redact,
on configuration faults", but the body / SQL-parameter redactors violated it.

AuditRegexCache rejects a pattern that is malformed OR whose compile exceeds a
100 ms budget, caching the rejection for the process lifetime.
ScadaBridgeAuditRedactor then simply dropped the rejected pattern from its
redactor set and emitted the payload anyway — publishing precisely the values
the operator configured it to suppress, onto a row that looks entirely normal
downstream. Recovery required a process restart and the only signal was one
Warning line. The SQL path was worse: TryGetSqlParamRedactor returned a bare
false for both "no redactor configured for this connection" and "the configured
one will not compile", and CLAUDE.md records SQL parameter capture as on by
default.

Two changes:

1. Fail closed. A pattern that is CONFIGURED but unavailable now over-redacts
   the whole payload and increments AuditRedactionFailure, reusing the existing
   safety-net path. "Not configured at all" stays permissive — conflating those
   two states is the actual defect, so both are pinned by tests.

2. Precompile off the hot path. The audit-log roadmap specifies patterns are
   "precompiled at startup; rejected if compile takes >100ms"; the implementation
   had drifted to compiling lazily on first event, which put a wall-clock budget
   on a hot path under production load. RegexOptions.Compiled emits IL during
   construction, so a busy node could blow the budget on a perfectly valid
   pattern. Warm-up now runs at construction and on every options reload. The
   residual window between a reload and its warm-up is safe because that path
   now fails closed.

Warm-up deliberately does not fail the boot — an unusable pattern degrades the
node to over-redaction (safe, loud) rather than refusing to start. Reading
CurrentValue happens inside the warm-up try so an options provider that throws
still surfaces via Apply's over-redact path, not the constructor
(OuterCatch_OptionsThrows_NeverLeaks_AllSensitiveFieldsOverRedacted).

Also de-flakes GrpcCentralTransportTests.DeadlineExceeded_IsNotRetriedOnThePeer,
which is how this was found. It black-holed node A behind a 300 ms deadline, but
on a saturated machine the call could fail to even START — a genuinely-unsent
failure that IsConnectFailure correctly fails over on, so node B's ack arrived
instead of the expected Status.Failure. The test read as a flake while actually
reporting that its own premise had not held. Split in two: the hard rule now
injects an explicit DeadlineExceeded via a trailers-only response (deterministic,
load-independent), and a new BlackHoledNode_DoesNotHang covers the
deadline-is-actually-applied half with both nodes black-holed so no ack can
arrive down any path.

Verified: both fixes were confirmed to fail before they pass — reverting the
fail-closed guard fails exactly the 5 fail-closed tests while the 4 controls
still pass, and adding DeadlineExceeded to IsConnectFailure fails the rewritten
transport test. AuditLog 367/367, Host.Tests GrpcCentralTransport 8/8, solution
build clean. The previously-intermittent
Filter_PicksUp_NewBodyRedactor_OnConfigReload is green in a full sweep for the
first time.

Not addressed here, and noted on #35: the 100 ms wall-clock budget remains a
weak proxy for catastrophic backtracking (RegexOptions.Compiled defers JIT to
first match, so construction time measures the wrong thing), and a rejection is
still cached permanently. Both are now safe rather than dangerous, so they are
hardening rather than a leak.
2026-08-12 03:04:50 -04:00
Joseph Doherty 7e594054e4 build(deps): adopt ZB.MOM.WW.Auth 0.2.0 and MxGateway client/contracts 0.2.0
Brings the two shared-library families that had fallen behind the Gitea feed up
to latest; the other 20 ZB.MOM.WW.* packages were already at feed-latest.

Auth 0.1.5 -> 0.2.0 is LDAP failover only: a new LdapOptions.FallbackServers
list, an endpoint walk with sticky preference in LdapAuthService, and per-hop
warning logs. Purely additive — with FallbackServers empty (the committed
default everywhere) the walk collapses to exactly one attempt, so behaviour is
unchanged until someone opts in. Nothing in Auth.ApiKeys changed, so there is
no key-store schema migration to sequence.

MxGateway 0.1.1 -> 0.2.0 matters more than the version gap suggests: upstream
records that four clients "had drifted onto the already-published 0.1.2/0.1.1
while their APIs kept changing underneath", so 0.1.1 was a stale label rather
than a stable point. Real deltas since: status/HRESULT reply validation made
conformant across clients (CLI-37/38), the ReplayGap reconnect sentinel
surfaced as a typed signal (CLI-15), and typed single-item command parity
(CLI-04/30).

The one change touching the write path — correlating OnWriteComplete onto plain
Write/Write2 replies — is server-side. Its proto diff is comment-only and the
behaviour lives in the Worker, so ScadaBridge gains the correlated statuses when
the gateway server is upgraded, not from this bump. No wire-contract break.

Verified: restore and full solution build clean (0 warnings, 0 errors); all 22
shared packages resolve to a single version each across all 57 projects, with no
split resolution. Security 181/181, InboundAPI 269/269, DataConnectionLayer
277/277 — the three suites that consume these packages directly.

Full-sweep residue is pre-existing and unrelated, confirmed 3/3 green in
isolation for the first two:
  - GrpcCentralTransportTests.DeadlineExceeded_IsNotRetriedOnThePeer — a
    saturated TestServer can fail to START the call, which IsConnectFailure
    correctly treats as provably-unsent and fails over; the harness cannot
    guarantee the DeadlineExceeded it means to exercise.
  - AuditLogOptionsBindingTests.Filter_PicksUp_NewBodyRedactor_OnConfigReload —
    not a reload race (the test is synchronous); it trips the 100 ms compile
    budget in AuditRegexCache, which fails OPEN. Filed as its own issue.
  - CentralUI Playwright 159/173 — login failures against the running rig,
    which was built from the pre-bump image and so cannot be affected by this
    change: 20 from the known SEC-36 GLAuth password rotation, 136 cascading
    from the login throttle.

The rig still runs the previous libraries; it needs a docker/deploy.sh rebuild
to pick these up.
2026-08-12 02:44:28 -04:00