FU1 of the Notification Outbox follow-ups. EmailNotificationDeliveryAdapter
carried verbatim private copies of credential redaction, SMTP error
classification, and address validation because the NotificationService
helpers were internal. This eliminates the divergence risk by promoting the
helpers to public and deleting the adapter's copies.
- CredentialRedactor: internal -> public.
- Extract SmtpErrorClassifier + SmtpErrorClass enum into a new public static
class; NotificationDeliveryService now routes classification through it
(behavior unchanged). Adds focused SmtpErrorClassifierTests.
- NotificationDeliveryService.ValidateAddresses: internal -> public; the
adapter calls it directly.
- EmailNotificationDeliveryAdapter: deleted ScrubCredentials, ClassifySmtpError,
SmtpErrorClass, IsTransientSmtpError and ValidateAddresses copies.
No InternalsVisibleTo hack — specific helpers promoted to public. Both test
suites green; full solution builds clean.
Wire the Notification Outbox into the Host central role:
- Program.cs: call AddNotificationOutbox() on the central path (binds
NotificationOutboxOptions via BindConfiguration; no explicit Configure).
- AkkaHostedService.RegisterCentralActors(): create the NotificationOutboxActor
as a non-role-scoped central cluster singleton + proxy, then send
RegisterNotificationOutbox(proxy) to the CentralCommunicationActor.
- appsettings.Central.json: add the ScadaLink:NotificationOutbox section with
the NotificationOutboxOptions defaults.
- SiteServiceRegistration: remove the now-dead AddNotificationService() call -
sites forward notifications to central rather than delivering over SMTP, and
no site component consumes the SMTP machinery.
- Host.csproj: add the ScadaLink.NotificationOutbox project reference.
- Tests: add central outbox singleton/proxy actor-path assertions, drop the
site OAuth2TokenService/INotificationDeliveryService resolution assertions,
and add NotificationOutbox to the component-library IConfiguration check.
Notify.To(list).Send(subject,body) now generates a NotificationId GUID,
enqueues a Notification-category message into the site Store-and-Forward
Engine, and returns the NotificationId immediately (Task<string>). The
NotificationId is the single idempotency key end-to-end: it is the S&F
message Id, it is carried inside the buffered NotificationSubmit payload,
and it is the id the forwarder submits to central.
NotificationForwarder now deserializes the buffered payload as a
NotificationSubmit and reads NotificationId from it (re-stamping only the
site-owned SourceSiteId / SourceInstanceId), instead of deriving the id
from StoreAndForwardMessage.Id.
Adds NotifyHelper.Status(id): queries central via the site communication
actor; reports the site-local Forwarding state while the notification is
still buffered at the site, maps central's response when found, and
Unknown otherwise. Adds a NotificationDeliveryStatus record.
SiteCommunicationActor gains a NotificationStatusQuery forwarding handler
mirroring NotificationSubmit. StoreAndForwardService.EnqueueAsync gains an
optional messageId parameter and exposes GetMessageByIdAsync.
A composition-derived template now stores its contained name — the
composition slot's InstanceName (e.g. "Pump"), unique only within its
owner — instead of the dotted global path ("Motor Controller.Pump").
The qualified hierarchical name is computed on read.
- TemplateNaming.QualifiedName: walks the OwnerCompositionId chain to
build the dotted path; null-safe, cycle-guarded.
- TemplateConfiguration: the unique index on Template.Name becomes
filtered (WHERE IsDerived = 0) — base templates stay globally unique;
derived templates' uniqueness is the existing (TemplateId,
InstanceName) index on TemplateComposition.
- Migration ContainedDerivedTemplateNames: rewrites derived rows to the
contained name; Down rebuilds the dotted names via a recursive CTE
before restoring the global index.
- TemplateService: composition create/rename store the contained name;
the dotted-name collision pre-checks and cascade-rename are removed
(a slot rename no longer touches nested derived templates).
- TemplateEdit: title shows the contained name; the qualified path is a
breadcrumb subtitle; "composed inside" uses the owner's qualified name.
TDD: 4 TemplateNaming tests + updated composition tests. TemplateEngine
293, ConfigurationDatabase 114, CentralUI 316 green. Migration applied to
the dev cluster and verified in the browser (Motor Controller.Pump now
titled "Pump"; nested Motor Controller.Pump.TempSensor resolves).
Design: docs/plans/2026-05-18-contained-template-names-design.md
The template script editor had no input for MinTimeBetweenRuns, so a
WhileTrue trigger configured through the UI always saved a null interval
and degraded to a single edge fire. The Add/Edit Script modal now has a
"Min time between runs" number+unit (ms/sec/min) field.
- Visible only for ValueChange / Conditional / Expression triggers — the
auto-firing triggers MinTimeBetweenRuns throttles. Hidden for Interval
(its own period is the cadence), Call (invoked explicitly, never
throttled), and None.
- For a WhileTrue Conditional/Expression trigger the field is labelled as
the re-fire interval and shows a warning while it is blank.
- Wired through the new-script and edit-script save paths (edit previously
only preserved the existing value, never let the user change it).
New DurationInput helper does the TimeSpan <-> number+unit conversion;
ScriptTriggerConfigCodec.SupportsMinTimeBetweenRuns classifies trigger
types. Both TDD'd — 21 new tests. CentralUI suite 316 green; verified
end-to-end in the browser (visibility per trigger type, WhileTrue warning,
save/reload round-trip).
Conditional and Expression script triggers gain an optional `mode` field
in their TriggerConfiguration JSON:
- OnTrue (default): unchanged edge/per-change firing. An absent mode field
parses as OnTrue, so every existing trigger config behaves identically.
- WhileTrue: fires on the false->true edge, then re-fires on a periodic
timer while the condition holds; stops on the true->false edge. The
re-fire cadence is the script's MinTimeBetweenRuns; with none configured
the trigger degrades to a single edge fire and logs a warning.
ScriptActor tracks condition truth state and manages a dedicated
"whiletrue-trigger" timer. ScriptTriggerConfigCodec and ScriptTriggerEditor
round-trip the mode and expose an OnTrue/WhileTrue selector for the two
trigger kinds. Design: docs/plans/2026-05-18-whiletrue-trigger-mode-design.md
Tests: 7 ScriptActor runtime tests (edge fire, timer re-fire, stop,
re-arm, no-MinTimeBetweenRuns degrade, OnTrue regressions) + 14 codec /
editor tests. SiteRuntime suite 206 green, CentralUI suite 295 green.
InstanceActor._tagPathToAttribute was a Dictionary<string,string> — one tag
path mapped to a single attribute. When two attributes reference the same PLC
node (e.g. two composed cooling-tank modules both reading ns=3;s=Tank.Level,
or a pump's TempSensor and AlarmSensor both reading ns=3;s=Sensor.Reading),
SubscribeToDcl's map assignment overwrote, so only the last-registered
attribute ever received values — the rest stayed permanently Uncertain.
The map is now Dictionary<string,List<string>>; HandleTagValueUpdate fans each
update out to every attribute referencing the tag path, and each distinct tag
path is still subscribed only once per connection.
The role-navigation and navigation E2E tests asserted on a stale nav model —
labels 'Data Connections', 'Instances', 'Areas' that NavMenu.razor no longer
uses, 'Connections' mapped to /admin instead of /design, and Event Logs /
Parked Messages treated as all-roles when they are Deployment-role gated.
SitesPage_ShowsTable expected an HTML <table> but Sites.razor renders site
cards. Corrected the expectations to the actual NavMenu/Sites markup; the
role-based authorization itself was already correct. Suite: 43/43.
- ScadaLinkWebApplicationFactory removed the AkkaHostedService SINGLETON, not
just its IHostedService registration, so IClusterNodeProvider's factory
(Program.cs) could not resolve it — 10 tests failed at host build. Now removes
only the factory-registered IHostedService descriptors and keeps the singleton.
- Configure an LDAP service account so ResolveUserDnAsync does search-then-bind
against GLAuth (whose DN layout the no-service-account fallback DN never
matched), fixing LoginEndpoint_WithValidLdapCredentials.
- IntegrationSurfaceTests: ApiKeyValidator now matches keys by HMAC hash over
GetAllApiKeysAsync (ConfigurationDatabase-012); the test mocked the removed
GetApiKeyByValueAsync path. Suite now 64/64.
ConfigurationDatabase-012 made ApiKeyHasher fail fast on a missing/weak HMAC
pepper, so resolving ApiKeyValidator from the central composition root now
requires ScadaLink:InboundApi:ApiKeyPepper to be configured. The composition-
root test's in-memory config now supplies a test pepper, like JwtSigningKey.
DiffDialogTests.SetupBodyLockInterop registered bUnit SetupVoid planned
invocations that were never completed; DisposeAsync_WhileOpen awaited
DiffDialog.DisposeAsync -> TryUnlockBodyAsync -> InvokeVoidAsync on one of
them, suspending the test forever so the test host never exited (regression
from the CentralUI-023 catch-narrowing). SetupBodyLockInterop now uses Loose
JSInterop mode. Also dispose the leaked WebApplication instances in the Auth
tests (FileSystemWatcher + ConsoleLoggerProcessor threads) and the extra
ServiceProvider in the DebugView tests. Suite now runs 281 tests in ~7s and
exits cleanly.
A heartbeat-registered site that has never sent a full report now has
LastReportReceivedAt = null instead of the year-0001 sentinel. TimestampDisplay
accepts DateTimeOffset? and renders null as a placeholder ('awaiting first
report') rather than a ~2000-year-stale date. Cross-module: HealthMonitoring +
CentralUI.
Inbound-API bearer credentials are no longer persisted in plaintext. ApiKey now
holds a KeyHash (peppered HMAC-SHA256); the key is shown once at creation and
only its hash is stored. Lookup and validation hash the presented candidate.
Cross-module: Commons (ApiKey, ApiKeyHasher), ConfigurationDatabase (mapping +
HashApiKeyValue migration), InboundAPI (ApiKeyValidator), ManagementService
(key creation), CentralUI (ApiKeys.razor). Existing keys must be re-issued.