Commit Graph

624 Commits

Author SHA1 Message Date
Joseph Doherty 9d2834e30a docs+log(siteeventlogging): explain the site_events purge oplog-backlog burst (R7)
The daily site_events retention purge (and the storage-cap trim) is CDC-captured
on a replication-enabled site node exactly like any other write — correct by
design, since LocalDb Phase 2 deliberately has no purge-exemption path — so the
backlog jumps by the deleted batch size at purge time. LocalDbOplogBacklog /
localdb_oplog_depth spike, drain, and an operator watching the gauge with no
context reads it as a replication fault.

Documentation + one log line, no behaviour change:

- topology-guide.md gains "Reading the replication backlog — the daily
  site_events purge burst": when it fires (PurgeInterval 24h, anchored to the
  active node's PROCESS START, not a wall-clock hour, so it moves after every
  failover), where it shows (replicated nodes only — not rig site-b/site-c),
  the healthy signature (LocalDbReplicationConnected stays true, backlog
  returns to ~0) and what a genuine fault looks like instead.
- Component-SiteEventLogging.md Storage records the same under retention/purge;
  Component-HealthMonitoring.md gains the two previously-undocumented
  LocalDbReplicationConnected / LocalDbOplogBacklog metric rows carrying the
  caveat, with cross-references both ways.
- EventLogPurgeService emits one Information line naming the row count and the
  expected transient backlog when a purge deleted rows on a replication-enabled
  node, so the spike is correlatable in the log. Replication-awareness comes in
  as a Host-supplied SiteEventLogReplicationCheck delegate, mirroring the
  existing SiteEventLogActiveNodeCheck seam: SiteLocalDbSetup.ReplicationIsConfigured
  goes internal so the PeerAddress-OR-ApiKey rule stays in one place and
  SiteEventLogging never learns to read LocalDb config. Unregistered ⇒ no note,
  matching the default that replication is opt-in and off.

Both delete paths carry the note (a cap trim is usually the larger burst); the
predicate is try/caught since a log-wording check must never break the purge.

Tests: 5 new EventLogPurgeServiceTests cases (replicated logs it, unreplicated
does not, zero-rows does not, cap purge logs it, throwing predicate still purges
and swallows) via a local capturing ILogger. SiteEventLogging 81/81 green,
Host 490/490 green, full solution build clean (0 warnings).
2026-08-15 03:26:30 -04:00
Joseph Doherty 2b74851f96 docs(plans): residual remediation plan — 7 remaining deferred items, one opus agent each 2026-08-15 03:13:51 -04:00
Joseph Doherty f228ac223f docs(plans): register open rows 29-32 for load-test findings (F1 1-hour run, WP-4 residual scope, F3 burst ceiling, F4 test flake) 2026-08-15 03:07:22 -04:00
Joseph Doherty 40eff637a1 Merge branch 'target-scale-load-test' — deferred register #25 delivered: 375k-subscription target-scale load test with measured evidence (absorbs row 50) 2026-08-15 03:01:51 -04:00
Joseph Doherty 94e8301e44 test(loadharness): retain raw resource samples; record findings F4 and the discarded run
HarnessRunResult now carries every ResourceSample, not just the window summaries.
Motivated by finding F1: the definitive run recorded ZERO gen-2 collections across
45M events, so a positive least-squares heap slope cannot be told apart from
gen-2 garbage that was simply never collected. The summary alone cannot settle
that; the series can. The reported run predates this field -- noted as such in the
results doc rather than implied otherwise.

Also records:
  - the second full-scale run was DISCARDED, not reported: a verification build
    overlapped the start of its measurement window, and a contaminated measurement
    is not evidence.
  - finding F4, a pre-existing test-isolation flake in
    QueueDepthGaugeTests.Gauge_TracksBufferedDepth_AcrossEnqueueDrainAndPark
    (fails in a full-suite run, passes in isolation -- shared static gauge carrying
    state across tests). It cannot originate here: this branch changes zero src/
    files vs its base 986e6e7a. Left unfixed deliberately; filed for separate triage.

Verified: full slnx build clean; SiteRuntime 604/604, Communication 691/691 pass;
TargetScaleHarnessSmokeTests passes (78s).
2026-08-15 03:01:17 -04:00
Joseph Doherty da65605e41 docs(plans): WP-4 target-scale load test RESULTS + close register #25 and row 50
Full-scale run executed on this machine: 10 sites x 500 instances x 75 tags =
375,000 live tag subscriptions, 37,518 updates/s achieved vs 37,500 nominal,
45,021,375 updates over a 20-minute steady-state window (M4 Pro, 14 cores, 48 GB,
with the 8-node docker rig still running so the figures are pessimistic).

11 clean passes, 1 pass with a caveat, 0 failures:
  tag latency  P50 0.88ms  P99 4.57ms  max 37.41ms  (1.1M samples, end-to-end)
  stream       900,675 delivered, 0 dropped at 100 live subscribers
  health       collect+ingest P99 0.31ms, 10/10 sites tracked
  debug view   P99 2.19ms, 264 completed, 0 timeouts
  deploy       500 instances to a site in 2.6s
  cpu          41% of ONE core = 2.9% of the box
  memory       working-set slope +8.83 MB/min

Three findings, reported rather than tuned away:
  F1 (Low) 20 min with ZERO gen-2 collections cannot fully settle the leak
     question; the heap demonstrably sawtooths but an uncompacted gen-2 makes a
     positive slope ambiguous. The 1-hour run would settle it. Not tuned.
  F2 (informational, by design) a deferred S&F backlog sits for one full
     DefaultRetryInterval (28.9s measured) before anything drains --
     EnqueueAsync(attemptImmediateDelivery:false) stamps LastAttemptAt. Easy to
     misread as slow drainage, so drain is reported as two numbers: retry wait,
     then 3,533 msg/s of actual capacity.
  F3 (positive) slow-subscriber isolation is TOTAL: 4 healthy subscribers at
     100.00% with zero drops while a peer lost 197,028/200,000 events entirely
     within its own bounded channel. Mechanism recorded link by link.

Also records what the run does NOT prove (not clustered, not real-network, not
real OPC UA, not 1 hour) and the four WP-4 sub-criteria this harness does not
cover, so the evidence is not over-read.

Register rows 25 and 50 -> RESOLVED 2026-08-15; remediation execution-log
residual 7 -> resolved; phase-8-checklist WP-4 section replaced with the
measured numbers.
2026-08-15 02:55:59 -04:00
Joseph Doherty 8abebdae33 docs(plans): target-scale load test harness design memo (WP-4 / register #25)
Records harness architecture, the real-vs-faked table with a justification per fake,
metric definitions, falsifiable pass/fail thresholds derived from the WP-4 acceptance
criteria, and the eight deviations from the WP-4 protocol with reasons — including the
1-hour to 20-minute sustained-window shortening (memory reported as a slope so a
shorter window still answers the leak question) and the four [xc-*] criteria this
harness does not cover.
2026-08-15 02:27:47 -04:00
Joseph Doherty 491df111ea fix(dcl): derive tag-resolution health counts from per-tag authoritative state
Closes arch-review remediation residual #1 (DCL unsubscribe-during-reconnect
count staleness).

DataConnectionActor tracked TotalSubscribedTags/ResolvedTags as two int fields
incremented and decremented at five independent sites. ReSubscribeAll clears the
very maps those decrements key off (_subscriptionIds, _unresolvedTags) while
deliberately preserving _subscriptionsByInstance, so an unsubscribe landing
inside a reconnect window matched NEITHER decrement branch: the total leaked +1
per subscribe/reconnect/unsubscribe churn cycle, permanently and cumulatively.
The 37f13e2e discard gate stopped the orphan-handle half of that race; it could
not stop the counters drifting, because they were state of their own.

Both counts are now DERIVED at report time from the authoritative per-tag
collections, which makes the drift unrepresentable rather than merely guarded:

  total    = _instancesByTag.Count   (the per-tag counted set the residual
                                      called for — distinct tags with at least
                                      one subscribing instance)
  resolved = _subscriptionIds.Count  (tags for which the adapter holds a handle)

Two semantic corrections fall out of the derivation:

- A tag whose subscribe failed at CONNECTION level now counts toward the total.
  It was excluded before, yet the reconnect re-subscribe re-issued it from
  _subscriptionsByInstance and booked it as resolved — resolved above total, and
  a total driven negative by the eventual unsubscribe.
- _tagSubscriberCount is deleted. It duplicated _instancesByTag exactly, so
  HandleUnsubscribe's last-subscriber test is now "did UnindexTag drop the key?"
  — still O(1), with no parallel count that can disagree about when a handle is
  released. The subscribe-success promotion split (fresh vs. unresolved→resolved)
  also goes: it existed only to pick which scalar to bump; set sizes get
  DataConnectionLayer-020's double-count cases right for free.

Behavior is otherwise unchanged — same logging, same handle release, same
unresolved-tag probing, same in-flight-unsubscribe discard semantics (the long
comment block there is updated for the mechanics that changed).

Tests: five TagResolutionCounts_* cases in DataConnectionActorBatchTests
covering the churn repro (3 cycles), a shared tag losing one instance mid
reconnect, connection-level failure then recovery, plain subscribe/unsubscribe
cycles, and a completed reconnect re-subscribe. Verified failing against the
pre-fix actor (churn: total 1 not 0; connection-level: total 0 not 1) and
passing after. Full DCL suite 319/319; solution builds with 0 warnings.

Docs: Component-DataConnectionLayer.md health-reporting section describes the
derived counts; residuals register item 1 marked RESOLVED.
2026-08-15 02:05:35 -04:00
Joseph Doherty 7804fe7958 docs: arch-review remediation — component docs sweep, execution log, residuals register
Final consistency sweep per plan §6: verified component docs against shipped
WP1-WP3 + adversarial-review-fix state, corrected drift found in SiteRuntime
(recursion-exempt run cap, stale ScriptExecutionActor/AlarmExecutionActor
references), TemplateEngine (BundleImporter watermark path), DeploymentManager
(phase-2 PendingDeployment staging), CentralUI (shared KPI cache, dedup'd alarm
poll, render coalescing), StoreAndForward (rate-limited drop logging), and
ConfigurationDatabase (documented DbContext-pooling non-adoption). Updated the
docs/components/ developer-reference set (SiteRuntime, SiteEventLogging,
InboundAPI) to drop the deleted per-run actor classes. Amended one known-issue
for the superseding MaxBatchSize:64 read-page pin. Added CLAUDE.md bullets for
stream graceful-completion reconnect, the required site audit DB path, honest
CLI HTTP timeouts, bulk DeploySiteAsync, and LocalDb 0.2.1. New execution log
records the phase→commit map, gate results, adversarial-review tally, the
three test-flake root causes, and the nine-item residuals register.
2026-08-15 01:30:59 -04:00
Joseph Doherty a9ca51e008 Merge branch 'worktree-agent-a465fb3cd6ec48cd3' into arch-review-remediation 2026-08-14 23:53:00 -04:00
Joseph Doherty fd5e023d08 fix(comms): review findings — consumer-based debug orphan net, foreign-cancel triad, honest onConnected, served-row-exact retirement, full-rate reconcile
F1 (HIGH) DebugStreamBridgeActor: the 5-minute orphan net measured the MAILBOX
(SetReceiveTimeout), and once stream events were correctly marked
INotInfluenceReceiveTimeout nothing recurring reset it — the snapshot lands once
and GrpcStreamStable once — so every healthy session self-terminated at ~6 min
with a false "Site disconnected". Replaced with a periodic self-tick
(ConsumerLivenessCheckInterval, 30s) over a consumer-last-seen stamp renewed only
by DebugStreamConsumerAlive, which DebugStreamService Tells on a shared timer to
every session still in its registry (holding a session there IS "a consumer is
attached" — both the Blazor view and the SignalR hub release it on
dispose/disconnect, and it works headless). Reverting the wrapper was rejected: it
would restore the quiet-instance orphan bug.

F2 (MED) SiteStreamGrpcClient: the RpcException(Cancelled) filter now requires
cts.IsCancellationRequested. A peer-originated / channel-dispose Cancelled fired
none of onError/onCompleted/onConnected, leaving SiteAlarmAggregatorActor with
_streamDown=false forever (IsLive stuck true, reconcile reopen guard never fired).

F3 (MED) SiteStreamGrpcClient: a header TIMEOUT is no longer reported as
connected — that shape is exactly what an unreachable site produces, and it
cleared _streamDown, consumed _seedOnConnect and launched a full snapshot fan-out
at a dead site. AwaitHeadersAsync returns bool; the first received event is the
fallback connected signal, fired at most once from headers OR first event.

F4 (LOW-MED) SqliteAuditWriter.MarkReconciledUpToAsync: the blanket below-cursor
UPDATE retired late-stamped inserts that were never served (then age-purged —
silent loss). The flip is now bounded by insertion order: a Pending row retires
only if its rowid is at or below the high-water mark of rows this instance has
served from ReadPendingSinceAsync (clamped on purge, since SQLite reuses rowids);
Forwarded rows are exempt (central ACKed them over the push path). At-least-once
is unchanged.

F5 (LOW) Documented the liveness dependency (a served row never covered by a later
cursor stays Pending forever; PurgeExpiredAsync never purges Pending) in
ISiteAuditQueue + Component-AuditLog.md, and added a cheap site-health signal:
SiteAuditBacklogReporter logs a rate-limited warning when the existing
oldest-pending metric exceeds 24h.

F6 (MED) SiteAlarmAggregatorActor: _fanoutSinceLastTick was armed by the
reconcile's OWN fan-out, so steady state ran fan-out→skip→fan-out→skip — one
reconcile per 2x interval (120s), halving the not-reporting refresh and the alarm
reconcile backstop. The skip is now armed only by connect/failover-driven seeds
(initial, _seedOnConnect, and a re-seed queued behind one).

Tests: Communication.Tests 691 passed (+13), AuditLog.Tests 382 passed (+5).
2026-08-14 23:52:25 -04:00
Joseph Doherty 09e350ab0d Merge branch 'worktree-agent-adf34e265d2dcae96' into arch-review-remediation 2026-08-14 23:47:00 -04:00
Joseph Doherty 5d075f1374 fix(central): review findings — no client-side audit truncation, insert-first upsert, QI-safe scripts, honest operator replies
Six adversarial-review findings in the central SQL/ingest layer.

F1 (AuditLogRepository.InsertChunkAsync) — the set-based ingest declared each
string parameter at its COLUMN width (Actor/Target 256, Action 64, Outcome 16,
Category 32, SourceNode 64), so SqlClient truncated an over-long value at bind
time and committed the mutilated row — silent, in an append-only store, with no
PayloadTruncated flag — while the per-row and reconciliation paths sent the same
value in full and let the server reject it with 2628. Bind at the value's own
length instead; explicit SqlDbType is kept (it fixes the VALUES constructor's
derived column types and datetime2 precision). Design: reject everywhere,
truncate nowhere — matching today's per-row behaviour.

F2 (SiteCallAuditRepository.UpsertAsync) — the single-statement upsert ran the
monotonic UPDATE first and INSERTed only if nothing matched. Two writers racing
the first packet of one TrackedOperationId (the cached dual-write and the
reconciliation pull carry DIFFERENT lifecycle states) both matched nothing, and
the loser then skipped its INSERT or swallowed a 2627 — dropping its
Status/RetryCount/HttpStatus/TerminalAtUtc. Legs swapped to
`IF NOT EXISTS … INSERT; UPDATE <monotonic>` — still one round trip, and the
loser's UPDATE now lands on the winner's row. The duplicate-key catch re-runs
the monotonic UPDATE for the same reason. Moved to raw SQL with explicitly-typed
parameters so the intricate rank predicate exists in exactly one place (an
untyped DateTime would bind as `datetime` and round the freshness tiebreaker).

F3 (docs/plans/sql/*.sql) — filtered-index DDL failed with error 1934 under the
documented `docker exec … sqlcmd` path, which defaults QUOTED_IDENTIFIER OFF;
once IX_Notifications_Delivered exists, QI-OFF DML on Notifications fails too.
All four scripts now open with `SET QUOTED_IDENTIFIER ON; SET ANSI_NULLS ON; GO`
(own batch, so it is in force when the next batch parses), and the migration
convention in Component-ConfigurationDatabase.md documents `sqlcmd -I`. Verified
live: the pre-fix script fails 1934 without -I, the fixed one applies.

F4 (SiteCallAuditActor) — the off-mailbox reconciliation/purge passes reuse the
injected repository, so tests drove one DbContext from the pass and a mailbox
handler concurrently. Serialized at the CALL via a private SerializedRepository
wrapper applied only by the test constructors, rather than running the pass
on-mailbox: production keeps its PipeTo shape untouched, and the existing
"a blocked drain does not stall ingest/query/KPI" regression tests stay
meaningful (they would have been invalidated by suspending the mailbox).

F5 (AuditLogIngestActor) — when the batch failed because the 20 s IngestBudget
expired, the per-row fallback reused the same expired token: N instant failures,
N counter bumps, zero accepted. The fallback now gets a fresh 5 s budget (inside
the 30 s outer Ask), and a blown budget bumps the failure counter ONCE for the
batch instead of once per row.

F6 (NotificationOutboxRepository.UpdateAsync) — ExecuteUpdate's row count was
discarded, so an operator Retry/Discard of a notification the retention purge had
already deleted reported success (the pre-ExecuteUpdate code threw
DbUpdateConcurrencyException). UpdateAsync now returns whether a row matched; the
operator one-shots answer "notification not found" and emit no audit row for the
action that did not happen, while the dispatcher logs a warning (its delivery
already happened; nothing to retry). GetByIdAsync switched to AsNoTracking since
the write is out-of-band.

Tests: 5 new SQL-backed regressions (over-long Target rejected on both paths +
boundary round-trip; concurrent first-write and already-created-by-another-writer
upserts; vanished-row UpdateAsync), a token-identity pin on the ingest fallback,
a repository-concurrency detector for the SiteCallAudit passes, and vanished-row
operator-path tests. The F1/F2/F4 regressions were each confirmed failing against
the pre-fix code. Suites: ConfigurationDatabase 369, AuditLog 378, SiteCallAudit
66, NotificationOutbox 152 — all green, solution builds with 0 warnings.
2026-08-14 23:46:28 -04:00
Joseph Doherty 950c54c5fc fix(runtime): review findings — recursion-safe run cap, atomic detach counter, summary edge cases, per-row event-log fallback 2026-08-14 23:42:29 -04:00
Joseph Doherty c254d0740e perf(sitelog): sampled per-run events; interval run summaries; site_events replication policy pinned
Implements WP3.2 stage (b) per docs/plans/2026-08-15-site-events-policy-design.md.

- Per-run instance-script Started/Completed Info site events are now off by
  default (SiteRuntimeOptions.PerRunScriptEvents=false) instead of firing on
  every run, closing the dominant site_events writer. Gated at the ScriptRunLauncher
  call sites (moved there from ScriptExecutionActor by WP3.1). Error-level events
  (timeout/failure/stuck-watchdog/recursion-limit) remain unconditional.
- ScriptRunSummaryRecorder accumulates per-(instance, script) run counters and a
  new site-only ScriptRunSummaryFlushService emits one aggregate "script" Info
  site event per ScriptRunSummaryIntervalSeconds (default 300s), top-50-script
  breakdown with an "others" rollup, zero-activity intervals emit nothing.
- Per-script opt-in via PerRunScriptEventScripts ("Instance/Script" exact or
  "Instance/*" wildcard), matched by the new pure ScriptRunEventPolicy. All three
  options are read from IOptionsMonitor<SiteRuntimeOptions> per run, so the
  policy is hot-togglable without a restart.
- Fixed the stale "event log is not replicated" comment at AkkaHostedService.cs
  (~905): site_events IS registered in SiteLocalDbSetup.ReplicatedTables — the
  singleton is what makes queries always hit the actively-written copy;
  replication is what gives the singleton history to read after a failover
  (memo Decision (b)). site_events replication itself is unchanged (still
  registered) and already pinned by
  tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbCdcRegistrationTests.cs.
- Updated Component-SiteEventLogging.md (Volume Policy section, corrected
  Storage/replication rationale) and Component-SiteRuntime.md (Script Run
  Launch + Error Handling sections).
2026-08-14 22:56:08 -04:00
Joseph Doherty 799fd041ec Merge branch 'worktree-agent-a2b4268818a1b6201' into arch-review-remediation 2026-08-14 22:37:43 -04:00
Joseph Doherty c4fc1f8ecd perf(runtime): split trigger evals from the blocking pool; bounded, deadline-aware execution 2026-08-14 22:36:15 -04:00
Joseph Doherty cca7f1786d chore(deps): LocalDb 0.2.0 — dereg cleanup, late-opt-in baselining, byte-budget replication 2026-08-14 22:22:18 -04:00
Joseph Doherty 312216ff2b docs(plans): script pool split design — WP3.1a 2026-08-14 21:47:48 -04:00
Joseph Doherty 6cfb2dd858 docs(plans): site_events volume policy design — WP3.2a 2026-08-14 21:45:23 -04:00
Joseph Doherty a5882753dd perf(comms+audit): close phase-2 residuals — direct ingest path, monotonic timeouts, synthetic probe, not-reporting set, cursor-exact audit pull 2026-08-14 21:38:23 -04:00
Joseph Doherty edd925e277 Merge branch 'worktree-agent-a0098b40576d74cfd' into arch-review-remediation 2026-08-14 21:15:32 -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 1040dc0fcc docs(plans): DCL batch seam design — WP2.1a 2026-08-14 20:30:35 -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 87e25c790c Merge branch 'worktree-agent-a143c0cc0b4d07e76' into arch-review-remediation 2026-08-14 20:14:13 -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 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 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 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 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 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 fdc6b0c2bb chore(secrets): adopt ZB.MOM.WW.Secrets 0.6.2 and close the pre-host guard gap
0.6.x refuses a secret store whose path is relative or inside the content root,
because a store in the deployment directory is destroyed by an ordinary upgrade —
the failure that wiped the MxGateway API-key store on 2026-08-09 and read as an
auth outage rather than a deployment error.

The pin alone would not have protected this repo. Program.cs expands ${secret:}
before the host exists, composing secrets into a throwaway ServiceCollection with
no IHostEnvironment, so the guard would not run at the moment the migrator creates
the store. That composition now lives in SecretsRegistration with an explicit
content root — resolved to match what the host resolves later, including the
Windows-Service case where the pre-host CWD is still system32 — and is covered by
PreHostSecretsContentRootTests, verified by simulating the regression and
confirming it fails on the leftover file rather than on the exception.

The docker rig needed a fix too: /app/data is absolute but inside the container's
content root, so all 8 nodes would have failed to boot. Each node's data directory
is now mounted a second time at /data; same host directory, so existing stores
carry over untouched.

Verified: build clean, 29 test assemblies green (Playwright's 159 failures are the
pre-existing SEC-36 login baseline). Not yet deployed — the rig runs the old
config until someone redeploys.
2026-08-11 09:16:31 -04:00
Joseph Doherty d14e0ee4b1 fix(ui): gate detail modals on user intent, not on the row resolving
The sweep's modal re-key (holding the row's id and re-resolving it, rather than
holding the record) also used that resolve as the modal's visibility gate. That
makes the modal's existence a function of list contents: any render where the
row is momentarily unresolvable unmounts the whole subtree and disposes every
event-handler id inside it, Close's included. A click already in flight against
a disposed handler makes the renderer throw GetRequiredEventBindingEntry during
DispatchEventAsync — which is how this surfaced, as an intermittent failure of
CloseButton_DismissesModal (989/990 on one run, green on re-run).

The record-held form made that structurally impossible: the modal existed
because the user opened it, and no list mutation could retract that. This
restores the property while keeping the re-key's actual benefit. Visibility now
gates on the held id; the resolve drives only content. An unresolvable row
degrades to an explicit notice and hides the row-scoped actions, while the frame
and Close stay mounted. Detail fetched by id still renders, so the user does not
lose the body they opened.

Applied to all four surfaces that shared the construction: NotificationReport,
ConfigurationAuditLog, ParkedMessages (offcanvas drawer) and SiteCallsReport.

Modal_StaysOpen_WhenItsRowLeavesThePage drops the opened row from the next query
and asserts the modal survives, keeps its fetched body, hides Retry/Discard, and
that Close still works. It was run against a deliberately restored defective
gate and failed there before passing here — a regression test that passes both
ways would be worthless against a race. 20 consecutive runs of the previously
flaky class: no failures. CentralUI.Tests 991/991, solution build 0/0.

The plan doc gains a section recording that the sweep was reported as
behaviour-preserving when it was not, and why the merge review missed it.
2026-08-11 06:03:06 -04:00
Joseph Doherty 9e243493fb ui: Central UI density/consistency sweep + Theme 0.4.1
Applies the family-wide admin-UI cleanup playbook to the Central UI so the
Blazor surfaces stop diverging from the shared kit: buttons are grouped rather
than individually sized, long cell values are contained instead of widening
tables, and hard-coded colours give way to theme tokens.

The headline fix is that MainLayout passed Accent="#2f5fd0" to ThemeShell,
which the kit emits as an inline style on the shell root. Being a descendant of
<html>, it beat the [data-bs-theme="dark"] override for the entire app, so the
dark accent had never rendered. Declaring --accent in site.css :root instead
lets both schemes resolve; light is unchanged because the value already matched
the kit's light default.

Theme pins to 0.4.1, which upstreams the local .btn sizing block verbatim, so
that block is deleted here rather than duplicated. Verified byte-identical
before removal; the repo now declares no --bs-btn-* anywhere.

NOT purely cosmetic, contrary to the sweep's stated scope: four detail-modal
surfaces (NotificationReport, ConfigurationAuditLog, ParkedMessages,
SiteCallsReport) were additionally refactored from holding the selected record
to holding its id and re-resolving from the current page each render, with the
resolve doubling as the visibility gate. A background refresh that drops the
row now closes the modal instead of showing a stale snapshot. This is a
behaviour change and is called out rather than buried: a full-suite run turned
up one intermittent CentralUI failure, CloseButton_DismissesModal, whose stack
(GetRequiredEventBindingEntry during DispatchEventAsync) indicates the handler
was disposed between render and click — a window the previous field-held record
made structurally impossible. Treat the modal lifecycle here as unreviewed.

Build 0/0; suite green apart from that one intermittent failure.
2026-08-11 05:50:12 -04:00
Joseph Doherty b6f383a225 docs(notifications): record EWS live-gate PASS (rig -> on-prem Exchange, Delivered first attempt) 2026-08-10 07:29:06 -04:00