diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiMetricAggregation.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiMetricAggregation.cs
new file mode 100644
index 00000000..df661750
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiMetricAggregation.cs
@@ -0,0 +1,125 @@
+namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
+
+///
+/// How a KPI metric must be folded when an hour of one-minute
+/// rows is
+/// aggregated into a single KpiRollupHourly row ("KPI History & Trends"
+/// hourly rollups, deferred #22). The correct fold differs by metric semantics, and
+/// choosing the wrong one silently corrupts long-range trend charts.
+///
+public enum KpiRollupAggregation
+{
+ ///
+ /// An instantaneous gauge (e.g. queue depth, buffered count, oldest-pending age).
+ /// The faithful hourly fold is last-value-per-hour — a gauge has
+ /// no meaningful sum across the 60 one-minute samples in an hour; summing would
+ /// invent a nonsensical total, whereas the latest reading is the value that a live
+ /// tile would have shown at the hour boundary.
+ ///
+ Gauge = 0,
+
+ ///
+ /// An already-windowed rate / delta counter (e.g. delivered-last-interval,
+ /// per-interval error counts). The faithful hourly fold is
+ /// sum-per-hour — each minute's sample is a fresh count of what
+ /// happened in that interval, so the hour's true total is the sum of its samples.
+ /// Folding a rate as last-value would keep one minute's delta and discard the other
+ /// 59, silently under-counting every long-range total.
+ ///
+ Rate = 1,
+}
+
+///
+/// Authoritative classification of every KPI metric emitted by the four
+///
+/// implementations as a or a
+/// , keyed by the (source, metric)
+/// pair. The hourly rollup fold consults this to pick last-value-per-hour vs.
+/// sum-per-hour for each series.
+///
+///
+///
+/// Keying by (source, metric) — not metric alone — is deliberate:
+/// deliveredLastInterval is emitted by both
+/// and ,
+/// so a metric name is not globally unique. Every metric string is taken from the
+/// emitting source verbatim (shared constants where they exist,
+/// otherwise the source's own private literal), so this catalog stays a faithful mirror
+/// of what actually lands in KpiSample.Metric.
+///
+///
+/// The catalog stores only the pairs; anything
+/// not listed — including an unknown, newly-added metric — resolves to
+/// . Gauge is the safe default: last-value-per-hour
+/// never fabricates a total, so a metric that slips in without being classified degrades
+/// to a plausible reading rather than crashing the fold or silently multiplying a count.
+///
+///
+public static class KpiMetricAggregationCatalog
+{
+ // Metric literals for the sources that keep their names private (no shared
+ // KpiMetrics constant exists for these). Charted metrics use the KpiMetrics catalog.
+ private const string SiteCallAuditDeliveredLastInterval = "deliveredLastInterval";
+ private const string SiteHealthAlarmEvalErrors = "alarmEvalErrors";
+ private const string SiteHealthEventLogWriteFailures = "eventLogWriteFailures";
+
+ ///
+ /// The (source, metric) pairs classified as
+ /// (sum-per-hour). Every other emitted metric is a .
+ ///
+ ///
+ ///
+ /// Rate members are per-interval / per-report deltas whose hour total is the sum of
+ /// its minute samples:
+ ///
+ /// NotificationOutbox/deliveredLastInterval — rows delivered in the last KPI interval.
+ /// SiteCallAudit/failedLastInterval — cached calls that failed permanently in the last KPI interval.
+ /// SiteCallAudit/deliveredLastInterval — cached calls delivered in the last KPI interval.
+ /// SiteHealth/scriptErrors, SiteHealth/alarmEvalErrors,
+ /// SiteHealth/deadLetters, SiteHealth/eventLogWriteFailures — raw error
+ /// counts per health-report interval (site health reports carry raw counts per
+ /// interval, not running totals), so they accumulate over the hour.
+ ///
+ ///
+ ///
+ /// Deliberately NOT Rate — the *LastHour audit metrics are Gauges.
+ /// AuditLog/totalEventsLastHour and AuditLog/errorEventsLastHour read as
+ /// "rate-like" by name, but 's sample source
+ /// computes each as a rolling trailing-one-hour count (a fixed 1-hour window,
+ /// re-evaluated at every capture). Each minute's value therefore already is the
+ /// last-hour total, and consecutive minutes overlap by 59 minutes. Summing the 60 samples
+ /// in an hour would count each event dozens of times (~60× over-count). The correct fold is
+ /// last-value-per-hour: at the hour boundary the latest rolling-hour count closely tracks
+ /// the hour's own volume, exactly like every other gauge. So both stay
+ /// , overriding the tentative plan list per the
+ /// authoritative source semantics.
+ ///
+ ///
+ private static readonly HashSet<(string Source, string Metric)> RatePairs = new()
+ {
+ (KpiSources.NotificationOutbox, KpiMetrics.NotificationOutbox.DeliveredLastInterval),
+ (KpiSources.SiteCallAudit, KpiMetrics.SiteCallAudit.FailedLastInterval),
+ (KpiSources.SiteCallAudit, SiteCallAuditDeliveredLastInterval),
+ (KpiSources.SiteHealth, KpiMetrics.SiteHealth.ScriptErrors),
+ (KpiSources.SiteHealth, SiteHealthAlarmEvalErrors),
+ (KpiSources.SiteHealth, KpiMetrics.SiteHealth.DeadLetters),
+ (KpiSources.SiteHealth, SiteHealthEventLogWriteFailures),
+ };
+
+ ///
+ /// Resolves how the metric identified by +
+ /// must be folded into an hourly rollup.
+ ///
+ /// The emitting KPI source (a value).
+ /// The metric name as persisted in KpiSample.Metric.
+ ///
+ /// for a known per-interval counter;
+ /// for every known gauge and for any
+ /// unknown / newly-added (source, metric) pair. Never throws — the fold must not
+ /// crash on a metric added after this catalog was last updated.
+ ///
+ public static KpiRollupAggregation Resolve(string source, string metric) =>
+ RatePairs.Contains((source, metric))
+ ? KpiRollupAggregation.Rate
+ : KpiRollupAggregation.Gauge;
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiMetricAggregationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiMetricAggregationTests.cs
new file mode 100644
index 00000000..7bbae2bf
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiMetricAggregationTests.cs
@@ -0,0 +1,151 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
+
+namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Kpi;
+
+///
+/// Tests for — the gauge-vs-rate
+/// classification the hourly rollup fold consults. Covers totality over every
+/// metric the four IKpiSampleSource implementations actually emit, the
+/// specific Rate set, a gauge sampling, and the unknown-metric default.
+///
+public class KpiMetricAggregationTests
+{
+ // Every (source, metric) pair emitted by the four sample sources, transcribed
+ // from the source code. The catalog must classify all of them (totality).
+ public static readonly (string Source, string Metric)[] AllEmittedMetrics =
+ {
+ // NotificationOutboxKpiSampleSource
+ (KpiSources.NotificationOutbox, "queueDepth"),
+ (KpiSources.NotificationOutbox, "stuckCount"),
+ (KpiSources.NotificationOutbox, "parkedCount"),
+ (KpiSources.NotificationOutbox, "deliveredLastInterval"),
+ (KpiSources.NotificationOutbox, "oldestPendingAgeSeconds"),
+
+ // SiteCallAuditKpiSampleSource
+ (KpiSources.SiteCallAudit, "buffered"),
+ (KpiSources.SiteCallAudit, "parked"),
+ (KpiSources.SiteCallAudit, "failedLastInterval"),
+ (KpiSources.SiteCallAudit, "deliveredLastInterval"),
+ (KpiSources.SiteCallAudit, "stuck"),
+ (KpiSources.SiteCallAudit, "oldestPendingAgeSeconds"),
+
+ // AuditLogKpiSampleSource
+ (KpiSources.AuditLog, "totalEventsLastHour"),
+ (KpiSources.AuditLog, "errorEventsLastHour"),
+ (KpiSources.AuditLog, "backlogTotal"),
+
+ // SiteHealthKpiSampleSource
+ (KpiSources.SiteHealth, "connectionsUp"),
+ (KpiSources.SiteHealth, "connectionsDown"),
+ (KpiSources.SiteHealth, "scriptErrors"),
+ (KpiSources.SiteHealth, "alarmEvalErrors"),
+ (KpiSources.SiteHealth, "sfBufferDepth"),
+ (KpiSources.SiteHealth, "deadLetters"),
+ (KpiSources.SiteHealth, "parkedMessages"),
+ (KpiSources.SiteHealth, "deployedInstances"),
+ (KpiSources.SiteHealth, "enabledInstances"),
+ (KpiSources.SiteHealth, "disabledInstances"),
+ (KpiSources.SiteHealth, "auditBacklogPending"),
+ (KpiSources.SiteHealth, "eventLogWriteFailures"),
+ };
+
+ // The (source, metric) pairs that must fold as Rate (sum-per-hour).
+ public static readonly (string Source, string Metric)[] RateMetrics =
+ {
+ (KpiSources.NotificationOutbox, "deliveredLastInterval"),
+ (KpiSources.SiteCallAudit, "failedLastInterval"),
+ (KpiSources.SiteCallAudit, "deliveredLastInterval"),
+ (KpiSources.SiteHealth, "scriptErrors"),
+ (KpiSources.SiteHealth, "alarmEvalErrors"),
+ (KpiSources.SiteHealth, "deadLetters"),
+ (KpiSources.SiteHealth, "eventLogWriteFailures"),
+ };
+
+ // A representative sampling of gauges — including the two *LastHour audit
+ // metrics that are rolling last-hour gauges, NOT rates (deliberate override).
+ public static readonly (string Source, string Metric)[] GaugeSampling =
+ {
+ (KpiSources.NotificationOutbox, "queueDepth"),
+ (KpiSources.NotificationOutbox, "oldestPendingAgeSeconds"),
+ (KpiSources.SiteCallAudit, "buffered"),
+ (KpiSources.SiteCallAudit, "stuck"),
+ (KpiSources.AuditLog, "backlogTotal"),
+ (KpiSources.AuditLog, "totalEventsLastHour"),
+ (KpiSources.AuditLog, "errorEventsLastHour"),
+ (KpiSources.SiteHealth, "connectionsUp"),
+ (KpiSources.SiteHealth, "connectionsDown"),
+ (KpiSources.SiteHealth, "sfBufferDepth"),
+ (KpiSources.SiteHealth, "deployedInstances"),
+ };
+
+ public static IEnumerable