# site_events Volume Policy — WP3.2 Stage (a) Design Memo

**Date:** 2026-08-15 · **Author:** fable (design) → sonnet (stage b implement)
**Parent:** `docs/plans/2026-08-14-arch-review-remediation-plan.md` §5, WP3.2
**Finding:** #High site_events (policy half; writer mechanics already fixed in WP1.7)

---

## 1. The problem, quantified

Every script run emits **two Info rows** into `site_events` — "started" and "completed" —
from `ScriptExecutionActor` (`src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptExecutionActor.cs`,
lines ~275 and ~288). `site_events` is a CDC-replicated table (`SiteLocalDbSetup.OnReady`,
`db.RegisterReplicated("site_events")`), so each row does not cost one write — the capture
triggers (`TriggerSqlGenerator` in `~/Desktop/scadaproj/ZB.MOM.WW.LocalDb`) add **one
`__localdb_oplog` row + one `__localdb_row_version` row per INSERT**, the batch ships to the
peer and is applied there, and 30 days later the retention purge DELETEs the row — which the
same triggers capture as **an oplog tombstone + a row_version tombstone**, also shipped.

Net: one script run ≈ **12 physical row-writes across the pair** plus two replication round
trips (insert-time and purge-time), for two rows nobody looks at unless something went wrong.
A modest site running 10 script executions/second writes ~1.7M `site_events` rows/day plus the
same again in oplog traffic. WP1.7 batched the commits and sliced the purge — the *mechanics*
are fine; this memo fixes the *volume*.

Every other `site_events` writer is per-occurrence, not per-run: DCL connect/disconnect
(`DataConnectionActor`), alarm state transitions (`AlarmActor`, `NativeAlarmActor`), S&F
parking (`StoreAndForwardService`), script **errors** (timeout / failure / stuck-watchdog /
recursion-limit). Those stay untouched — they are the rows the log exists for.

---

## 2. Decision (a) — per-run events off by default; interval summaries instead

### Policy

1. **Per-run Started/Completed Info events are OFF by default.** The Error emissions
   (timeout, failure, stuck-watchdog in the CTS callback, recursion-limit in
   `ScriptRuntimeContext`) are unchanged and unconditional.
2. **An interval aggregate replaces them:** a new `ScriptRunSummaryRecorder` (SiteRuntime)
   accumulates per-`(instanceName, scriptName)` counters — started, completed, failed,
   timedOut, plus total/max duration ms — incremented from the same call sites that today
   emit the two Info events. A timer flush (hosted service, Site nodes only) emits **one**
   site event per interval: `event_type "script"`, severity `Info`, `instance_id` NULL,
   source `"ScriptRunSummary"`, message = headline totals (e.g. "1,243 runs: 1,240 completed,
   2 failed, 1 timed out across 17 scripts"), details = per-script JSON breakdown, capped at
   the top 50 scripts by run count with an "others" rollup so a pathological deployment
   cannot mint oversized rows. **Zero activity ⇒ no row** (the standby therefore emits
   nothing naturally — only the active node runs scripts). Counters reset on flush.
3. **Per-script opt-in for debugging** restores the per-run rows for named scripts without a
   redeploy (mechanism below).

At the default interval this is ≤288 summary rows/day instead of 2 rows/run — the CDC tax on
the residual table content (errors + lifecycle + summaries) is negligible, which is what makes
Decision (b) cheap to settle on merit rather than on cost.

### Config keys (`ScadaBridge:SiteRuntime`, on `SiteRuntimeOptions`)

| Key | Default | Meaning |
|---|---|---|
| `PerRunScriptEvents` | `false` | Global switch back to legacy per-run Started/Completed rows. |
| `PerRunScriptEventScripts` | `[]` | Per-script opt-in list; entries `"{InstanceName}/{ScriptName}"`, `"{InstanceName}/*"` supported. Effective when the global switch is off. |
| `ScriptRunSummaryIntervalSeconds` | `300` | Summary flush interval; `0` disables summaries. Validated ≥ 0. |

**Hot toggle, no new plumbing:** the execution path already threads `serviceProvider`
(it resolves `ISiteEventLogger` at line ~135); it additionally resolves
`IOptionsMonitor<SiteRuntimeOptions>` and reads `CurrentValue` per run, so with
`reloadOnChange` on the mounted appsettings an operator can flip per-run events for one
script live, debug, and flip back — no restart, no redeploy, no template/schema change.
Fallback to the constructor-passed `SiteRuntimeOptions` when the provider is null (tests).
This keeps stage (b)'s blast radius exactly what the plan's dependency table promises:
**SiteEventLogging + one SiteRuntime call site** — the opt-in deliberately does NOT ride the
script config entity, because that would drag Template Engine, flattened config, Commons
DTOs and the deploy pipeline into a volume-policy fix.

### What the Site Event Log page loses and what replaces it

- **Loses:** the two Info rows per run. Operators who used them as a liveness signal get the
  `ScriptRunSummary` rows instead (findable via the existing keyword search — no UI change
  required), the unchanged Error rows for anything abnormal, and the existing health-report
  script-error counters. Call this out in the release note: the page's texture changes.
- **Gains:** per-interval activity summaries with per-script counts and durations — strictly
  more diagnostic signal per row than "started"/"completed" pairs.
- Optional follow-up (not required for stage b): surface run counts on the health snapshot
  via a new default-interface `ISiteHealthCollector` member. If anyone instead reaches for a
  Prometheus counter, remember `ZbTelemetryOptions.Meters` is an allowlist — an unlisted
  meter exports nothing, silently.

**WP3.1 coordination:** WP3.1 deletes `ScriptExecutionActor` and runs the guarded task under
`ScriptActor`. Per the plan's dependency table, **3.1 merges first**; stage (b) lands this
policy at wherever the two emissions live after that move. The policy is about the emission
call site, not the actor, so the design is unaffected by the move.

---

## 3. Decision (b) — keep `site_events` replicated; fix the *reason* on record

### Verdict: KEEP registered. But the plan's presumed justification is false, and the real one is different.

The plan's default recommendation said "keep replicated — central event-log queries hit
either node." **That presumption does not survive contact with the code:**

- The event-log query handler is a **ClusterSingleton** scoped to the site role:
  `AkkaHostedService.cs` (~905–917) starts `EventLogHandlerActor` via `SingletonRegistrar`
  ("event-log-handler", `role: siteRole`) and registers the **singleton proxy** as the
  handler.
- Both central→site transports route through the same table:
  `SiteCommandDispatcher.ResolveRoute` sends `EventLogQueryRequest` to `_eventLogHandler` —
  the singleton proxy (`SiteCommandDispatcher.cs` ~165). The dispatcher is the single routing
  truth for the Akka actor path AND `SiteCommandGrpcService`, so even when central's
  NodeA→NodeB channel flip lands the gRPC call on the **standby**, the query is forwarded to
  the **active** node's handler, whose `EventLogQueryService` reads the active node's local
  SQLite. **No code path ever reads the standby's `site_events` replica while it is standby.**

So by the plan's own test ("if nothing reads it on the peer, deregister it"), dereg would
follow — except one consumer remains, and it is the one Phase 1 built the replication for:

- **Post-failover history continuity.** When the active node dies, the singleton restarts on
  the survivor and its query service reads the survivor's local copy — which has content
  *only because* it was replicated. Without registration, a failover starts an empty event
  log at precisely the moment an operator is investigating an incident. That is not
  hypothetical: it was the shipped pre-Phase-1 behavior (the stale comment at
  `AkkaHostedService.cs` ~905 still describes it), and Phase 1 explicitly fixed it —
  `SiteEventLogger`'s doc header and `SiteServiceRegistration.cs` ~79 ("so the pair stops
  losing them on failover") record the intent.

With Decision (a) removing the dominant per-run volume, the residual replication cost is
small, and the failover-continuity benefit lands exactly when the log is most valuable.
**Keep `site_events` in `RegisterReplicated`.** Rig posture note: site-b/site-c run
unreplicated by deliberate choice; there the log stays node-local and failover starts fresh —
that is the documented trade of not configuring a peer, unchanged by this memo.

Stage (b) must also **fix the stale comment** at `AkkaHostedService.cs` ~905–908 ("The event
log is node-local SQLite and is not replicated") — the singleton rationale it states is still
correct, the replication claim is two phases out of date. The correct rationale to write:
singleton = queries always read the actively-written copy; replication = the standby's copy
exists so the singleton has history after it moves.

### Purge traffic — coordinate with WP3.3, do not block on it

Retention/cap purge DELETEs are trigger-captured today: every event pays the CDC tax **twice**
(insert + tombstone), and tombstones linger for `TombstoneRetention` (7 days). WP3.3's menu
includes clock-based local purges that never enter the oplog. When that API lands:

- switch `EventLogPurgeService` to the oplog-bypassing delete,
- run the **retention** purge on **both** nodes (drop the active-node gate for it): the age
  cutoff is deterministic, so both nodes converge independently without shipping tombstones.
  (The 1GB **cap** purge stays per-node local hygiene; minor divergence is harmless.)
- Accepted edge: with no tombstones, a lagging peer's late oplog INSERT can resurrect an
  already-expired row; the next purge tick removes it. Harmless for an operational log.

If WP3.3's API is not merged when stage (b) lands, keep the current WP1.7 purge shape and
leave the switch as a recorded follow-up — Decision (a) already removed the bulk of the
tombstone traffic.

---

## 4. Migration / compat

- **No schema change, no wire change.** `EventLogQueryRequest`/`EventLogQueryResponse`,
  `EventLogEntry`, and the opaque continuation token are untouched — central↔site version
  skew is a non-issue.
- **Existing rows:** nothing to migrate; historical per-run rows age out via the 30-day
  retention window.
- **Pair upgrade:** site pairs already stop/start together (topology guide), so both nodes
  adopt the new emission policy in the same instant — no mixed-emission pair exists. If the
  WP3.3 local-purge lands later, its first tick on the formerly-gated standby simply purges
  rows the active had already deleted; converges in one tick.
- **Config:** absent keys = new defaults (per-run OFF, summaries every 300s). Operators who
  want the legacy behavior set `PerRunScriptEvents: true` — byte-for-byte the old rows.
- **Release note:** Site Event Log page loses per-run Started/Completed rows by default;
  `ScriptRunSummary` rows replace them; per-script opt-in documented.

---

## 5. Stage (b) test plan

1. **Default-off:** successful run emits no Info events; timeout/failure/stuck-watchdog
   Error paths still emit (site-runtime tests at the post-WP3.1 call site).
2. **Global opt-in:** `PerRunScriptEvents=true` → started+completed emitted with the legacy
   message shape.
3. **Per-script opt-in:** exact `"Instance/Script"` match emits; `"Instance/*"` wildcard
   emits; non-matching script stays silent.
4. **Hot toggle:** fake `IOptionsMonitor` flipped mid-test changes behavior on the next run
   without actor restart.
5. **Summary recorder:** N runs with mixed outcomes across scripts → exactly one flush event
   with correct counts and capped details JSON (>50 scripts → top-50 + "others" rollup);
   zero-activity interval → no row; counters reset after flush; concurrent increments are
   race-free.
6. **Replication stance pinned:** test asserting `site_events` remains in
   `SiteLocalDbSetup`'s `RegisterReplicated` set (guards Decision (b) against drive-by
   "optimization").
7. **If the WP3.3 purge switch lands in the same window:** purge deletes produce zero
   `__localdb_oplog` rows; both-node retention convergence test.
8. **Live probes (Phase 4 gate):** rig site-a `site_events` growth rate before/after under
   the demo script load; failover drill confirms the new active node serves pre-failover
   event history (replication continuity, the reason (b) kept it).

---

## 6. Affected documents (Phase 4 collects)

- `docs/requirements/Component-SiteEventLogging.md` — volume policy, summary events, the
  replication rationale (failover continuity, NOT either-node queries), purge/oplog note.
- `docs/requirements/Component-SiteRuntime.md` — per-run emission policy + the three config
  keys. **This file currently carries unrelated user WIP — stage (b) must merge around it,
  and this memo's commit deliberately excludes it.**
- Code comment fix (stage b, not a doc): `AkkaHostedService.cs` ~905 stale "not replicated"
  rationale.
- `Component-HealthMonitoring.md` only if the optional run-count snapshot member is taken.
