diff --git a/docs/Historian.md b/docs/Historian.md
index 2d1c665e..02d4f9d5 100644
--- a/docs/Historian.md
+++ b/docs/Historian.md
@@ -77,13 +77,22 @@ consumes the historian. The existing enable gates (`ServerHistorian:Enabled`, `A
| Value kinds recorded | `Double`, `Int64`, `Boolean` (numeric analog only) | all: `Double`, `Int64`, `Boolean`, `String`, `DateTime`, plus `Empty` (a Bad sample with no value) | same as `SqlServer` |
| Status code stored | Sent as DA quality but **discarded** by the gateway (C-002); AVEVA stores every sample Good | Stored bit-for-bit (`StatusCode` int column) | Stored bit-for-bit (unsigned, in a `LONG`) |
| Comms-loss / driver-Bad publishes | Dropped, counted in `DroppedUnsupportedKind` | Stored as an `Empty` outage-marker sample carrying the Bad code (see [below](#typed-values-real-status-and-outage-markers)) | Stored as an `Empty` outage-marker sample, as on `SqlServer` |
-| Processed aggregates | Twelve: the ten per-sample ones (Average, Count, Minimum, Maximum, Range, Start, End, Delta, Min/MaxActualTime) evaluated from raw reads with the SQL backends' evaluator — identical results (#681) — plus TimeAverage and Total from AVEVA's native modes | The seventeen in the [aggregate table](#aggregates-processed-reads) (incl. Part 13 time-weighted, #671) | The same seventeen, identical results |
+| Processed aggregates | **Ten** by default: the per-sample ones (Average, Count, Minimum, Maximum, Range, Start, End, Delta, Min/MaxActualTime) evaluated from raw reads with the SQL backends' evaluator — identical results (#681). TimeAverage and Total from AVEVA's native modes **only with `ServerHistorian:Gateway:EnableNativeTimeWeighted=true`** (default off: Good-status wrong values until historiangw#27). TimeAverage2 / Total2 / Interpolative / PercentGood / PercentBad: never | The seventeen in the [aggregate table](#aggregates-processed-reads) (incl. Part 13 time-weighted, #671) | The same seventeen, identical results |
+| Reverse / end-only Raw and Events reads | Emulated: walked back from bounded forward reads by the server (`ReverseRawReader` / `ReverseEventsReader`) — correct, but many round trips on a sparse tag | Native newest-first | Native newest-first |
+| Capped Events read | The cap is applied client-side (the gateway RPC has no max-events field); the stream past it is drained up to `Gateway:EventDrainBudget` rows | `TOP` | `LIMIT` |
+| Retention | AVEVA Historian's own | `SqlServer:RetentionDays` purge (opt-in) | `QuestDb:RetentionDays` TTL (opt-in) |
+| Health connection flags | `Gateway:ProbeInterval` probe + read outcomes | Read outcomes | Read outcomes |
| HistoryUpdate | No (`BadHistoryOperationUnsupported`) | Yes (Insert / Replace / Update / DeleteRaw / DeleteAtTime) | Yes — soft delete (tombstones), see [below](#historyupdate-soft-delete) |
| Modified-value history (`IsReadModified`, #668) | No (`BadHistoryOperationUnsupported`) | Yes — `SampleModified`, atomic with the change | Yes — `{prefix}sample_modified`, written before the change, marked applied after it (an orphan is not served) |
| Alarm history | gateway `SendEvent`, one RPC per event, up to 8 in flight (no batch RPC yet) | `Event` table (insert-if-absent), multi-row statements of 150 events | `{prefix}event` table (dedup upsert on `(ts, event_id)`), multi-row INSERTs of 500 events |
| Provisioning (`EnsureTags`) | numeric types only; others skipped | `MERGE` upsert of every request, all types | Upsert into `{prefix}tag`, all types |
| Connection | `Endpoint` + `ApiKey` + TLS keys (top-level `ServerHistorian:*`) | `ServerHistorian:SqlServer:*` | `ServerHistorian:QuestDb:*` |
+**Continuous historization on `Gateway` logs a startup Warning** (when its live writer is built, i.e. only with
+`ContinuousHistorization:Enabled`): every sample's quality is discarded (stored Good), comms-loss outage markers are dropped,
+and non-numeric values are not recorded — the rows above. The AdminUI has no backend picker (the backend is configuration-only);
+this table is the place to compare them.
+
Recording stays **Primary-only** (#537) and **at-least-once** with the #538 per-tag quarantine on every backend;
HistoryRead is served from any node (the database is shared). HistoryUpdate is **Primary-only** since #674
(`BadInvalidState` on any other node — see [HistoryUpdate](#primary-only-674)). The historian connection string is
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianProvider.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianProvider.cs
index 493869a7..d940cf58 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianProvider.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianProvider.cs
@@ -35,15 +35,26 @@ public sealed class GatewayHistorianProvider : IHistorianBackendProvider
public IHistorianDataSource CreateDataSource(IServiceProvider sp) => GatewayHistorian.CreateDataSource(_options, sp);
///
+ // Built only when continuous historization is enabled on this backend, so this is where its startup Warning lives:
+ // the gaps are silent at run time (the gateway accepts a quality it then discards).
public IHistorianValueWriter CreateValueWriter(IServiceProvider sp)
{
ArgumentNullException.ThrowIfNull(sp);
var loggerFactory = sp.GetService() ?? NullLoggerFactory.Instance;
var logger = sp.GetService>()
?? NullLogger.Instance;
+ logger.LogWarning(ContinuousHistorizationWarning);
return new GatewayHistorianValueWriter(HistorianGatewayClientAdapter.Create(_options, loggerFactory), logger);
}
+ /// The startup Warning for continuous historization on this backend (see docs/Historian.md, the capability matrix).
+ internal const string ContinuousHistorizationWarning =
+ "Continuous historization is enabled on the Gateway historian backend, which stores less than the SQL backends: " +
+ "the gateway discards each sample's quality (AVEVA stores every recorded value Good, so a driver's Bad or Uncertain " +
+ "publish is historized as Good), comms-loss outage markers are dropped (reads interpolate across an outage), and " +
+ "non-numeric values (String, DateTime) are not recorded. Use the SqlServer or QuestDb backend where stored status, " +
+ "outage markers or non-numeric history matter.";
+
///
public IAlarmHistorianWriter CreateAlarmWriter(IServiceProvider sp) => GatewayHistorian.CreateAlarmWriter(_options, sp);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayContinuousHistorizationWarningTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayContinuousHistorizationWarningTests.cs
new file mode 100644
index 00000000..6ec80582
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayContinuousHistorizationWarningTests.cs
@@ -0,0 +1,57 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder;
+using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
+
+///
+/// Continuous historization on the Gateway backend silently loses what the SQL backends keep — every sample's
+/// quality (the gateway stores Good), comms-loss outage markers and non-numeric values — so building the live writer
+/// (which only happens when continuous historization is enabled) logs one startup Warning naming the gaps.
+///
+public sealed class GatewayContinuousHistorizationWarningTests
+{
+ private sealed class CapturingProvider : ILoggerProvider
+ {
+ public readonly List<(LogLevel Level, string Message)> Entries = new();
+ public ILogger CreateLogger(string categoryName) => new L(this);
+ public void Dispose() { }
+
+ private sealed class L(CapturingProvider owner) : ILogger
+ {
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+ public bool IsEnabled(LogLevel logLevel) => true;
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter)
+ {
+ lock (owner.Entries) owner.Entries.Add((logLevel, formatter(state, exception)));
+ }
+ }
+ }
+
+ [Fact]
+ public async Task Building_the_gateway_live_writer_warns_once_about_what_the_backend_drops()
+ {
+ var capture = new CapturingProvider();
+ using var sp = new ServiceCollection().AddLogging(b => b.AddProvider(capture)).BuildServiceProvider();
+ var provider = new GatewayHistorianProvider(
+ new ServerHistorianOptions { Endpoint = "http://localhost:5222", UseTls = false, ApiKey = "histgw_test_secret" });
+
+ var writer = Assert.IsType(provider.CreateValueWriter(sp));
+
+ var warning = capture.Entries.Where(e => e.Level == LogLevel.Warning).ShouldHaveSingleItem().Message;
+ warning.ShouldContain("quality");
+ warning.ShouldContain("Good");
+ warning.ShouldContain("outage");
+ warning.ShouldContain("non-numeric");
+ warning.ShouldContain("SqlServer");
+ await writer.DisposeAsync();
+
+ // Positive control in the other direction: the read path alone (no continuous historization) does not warn.
+ capture.Entries.Clear();
+ await using var ds = (GatewayHistorianDataSource)provider.CreateDataSource(sp);
+ capture.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
+ }
+}