Notifier — Architecture (Notifications Studio Host)

Status: Implemented Source: src/Notifier/ Owner: Notify Guild Scope. Architecture dossier for Notifier, the src/Notifier/ tree that composes the reusable Notify toolkit into runtime services. This document covers how Notifier wires the Notify libraries and where the persistence and queue boundaries sit. For the toolkit internals (rules engine semantics, event model, template engine, schema design), read the companion dossier: ../notify/architecture.md.

⚠ Deployed topology (corrected 2026-07-12, SPRINT_20260712_009 CLO-7). Of the two projects in src/Notifier/, only StellaOps.Notifier.Worker (notifier-worker) is deployed. StellaOps.Notifier.WebService is commented out of compose# Slot 28: Notifier (MERGED into notify-web — kept commented for rollback) (devops/compose/docker-compose.stella-services.yml) — so it is not an operator-deployable host. The full Notifications Studio REST surface (v1 + the complete v2 enterprise route set) is served by notify-web(src/Notify/StellaOps.Notify.WebService, Program.cs:453-468). The Notifier WebService is a divergent duplicate of that surface retained solely as a rollback path; the KEEP decision is deliberate (audit corrections §A2) but the code must not be read as the live API host. Sections below that describe the WebService describe the rollback copy, not the deployed API. This dossier’s central “Notifier is the deployable host” framing predates the merge and is being reconciled.


1) Purpose & scope

Notifier owns the runtime composition that turns the Notify libraries into running services. The src/Notifier/ tree contains two projects:

Both compose libraries that live in src/Notify/through dependency injection. The split between the two trees is intentional:

ComponentPathRole
Notifysrc/Notify/Reusable toolkit — engine, models, connectors, persistence, queue. A library set that downstream systems can embed without inheriting the Studio host.
Notifiersrc/Notifier/Deployable hostWebService + Worker that compose the Notify toolkit into the Studio.

This boundary is the 2025-11-02 module boundary decision (recorded in ../notify/architecture.md§0 and in README.md): keep src/Notify/ as the reusable toolkit and src/Notifier/ as the host that composes it, and do not merge the directories without an approved packaging RFC covering build impacts, Offline Kit parity, and cross-module governance.

What Notifier does not own


2) Service composition (WebService + Worker)

Both deployables are .NET minimal-host ASP.NET Core apps. They bind configuration from appsettings.json plus environment variables under the NOTIFIER_ prefix, then compose the Notify libraries via DI extension methods.

2.1 WebService

StellaOps.Notifier.WebService (entry: Program.cs) wires, in order:

The WebService also references the Worker project directly, because several admin/runtime service contracts (channels, security, storm-breaker, dead-letter, retention, observability, escalation, tenancy, templates, correlation) are defined in the Worker assembly and reused by the API surface.

The HTTP surface combines endpoint-class registrations (MapRuleEndpoints, MapTemplateEndpoints, MapIncidentEndpoints, MapSimulationEndpoints, MapQuietHoursEndpoints, MapThrottleEndpoints, MapOperatorOverrideEndpoints, MapEscalationEndpoints, MapStormBreakerEndpoints, MapLocalizationEndpoints, MapFallbackEndpoints, MapSecurityEndpoints, MapObservabilityEndpoints) under /api/v2/{resource} (e.g. /api/v2/rules, /api/v2/templates, /api/v2/simulate, /api/v2/incidents, /api/v2/escalations, /api/v2/quiet-hours, /api/v2/throttles, /api/v2/overrides, /api/v2/security, /api/v2/storm-breaker, /api/v2/localization, /api/v2/ack, /api/v2/fallback, /api/v1/observability) with inline-mapped routes in Program.cs for channels/deliveries (/api/v2/notify/channels, /api/v2/notify/deliveries) and the event-ingress surface (/api/v1/notify/*, /api/v1/events/{producer}). It also wires a WebSocket live incident feed: UseWebSockets(...) plus MapIncidentLiveFeed() at /api/v2/incidents/live (header- or query-tenant-scoped subscriptions).

Event ingress. Producers push events over HTTP as well as the queue. Typed v1 routes exist for pack approvals (/api/v1/notify/pack-approvals, with an /{packId}/ack companion), attestation events (/api/v1/notify/attestation-events), and risk events (/api/v1/notify/risk-events). A single producer-polymorphic route POST /api/v1/events/{producer} (Sprint 20260513_002 G1) accepts envelopes from a whitelisted producer set — concelier, attestor, risk — enforcing that the event kind carries the producer’s prefix (concelier. / attestor. / risk.) and that the body tenant matches the X-StellaOps-TenantId header; it is gated by notify.operator and tagged Events. All /api/v1/* routes emit RFC 8594 Deprecation/Sunset headers (Sunset 31 Mar 2026). Ingest routes enqueue a NotifyEvent onto the notify:events stream for the Worker to dequeue.

Environment gating is a first-class contract. Direct in-memory runtime registrations exist only for the Testing environment; non-testing hosts resolve PostgreSQL-backed runtime services and a real Redis/NATS queue. Startup-contract proof for this gating lives in the Notifier test suites (e.g. StartupDependencyWiringTests).

2.2 Worker

StellaOps.Notifier.Worker (entry: Program.cs, built via CreateSlimBuilder) composes:


3) Data flow

The host wires a single linear pipeline; the logic of each stage lives in the Notify toolkit.

                         ┌──────────────────────────────────────────────┐
 platform events         │  StellaOps.Notifier.Worker (host)             │
 (Scanner, Scheduler,    │                                              │
  VEX Lens, Attestor,    │   NotifierEventWorker  ── leases ──► Queue   │
  Concelier, Zastava …)  │        │                  (Notify.Queue)     │
        │                │        ▼                                     │
        └── enqueue ────►│   NotifierEventProcessor                     │
                         │        │  rule eval (Notify.Engine)          │
                         │        │  throttle / digest / quiet hours    │
                         │        ▼                                     │
                         │   template render (Notify.Engine + Templates)│
                         │        │                                     │
                         │        ▼                                     │
                         │   dispatch:                                  │
                         │     WebhookChannelDispatcher (chat/webhook)  │
                         │     AdapterChannelDispatcher (Email/PD/OG)   │
                         │     DoraInfoSharingRuntimeDispatcher (DORA)  │
                         └────────────────┬─────────────────────────────┘
                                          │ persist delivery state
                                          ▼
                         notify PostgreSQL schema (Notify.Persistence)
                                          ▲
 admin / browse ◄────────────────────────┘
 (StellaOps.Notifier.WebService: rules, channels, deliveries, simulation,
  quiet hours, throttles, escalation, on-call, dead-letter, retention)
  1. Ingress. Platform events arrive on the internal event bus and are leased by the hosted NotifierEventWorker from the queue transport configured under notifier:queue.
  2. Evaluation. NotifierEventProcessor applies tenant-scoped rules through the Notify engine (INotifyRuleEvaluator / DefaultNotifyRuleEvaluator), then noise controls — throttles, digest coalescing, quiet-hours/maintenance suppression. The semantics of matchers, idempotency keys, and digest windows are defined by the toolkit (see ../notify/architecture.md§4 and ../notify/rules.md).
  3. Render. Matched actions are rendered to channel-specific payloads via the template services.
  4. Delivery. Rendered messages are dispatched through the deterministic dispatcher split and persisted as delivery rows.
  5. Admin / browse. The WebService exposes CRUD and read surfaces over the same persisted state, including delivery history, simulation dry-runs, and the enterprise admin endpoints.

Inbound acknowledgements (PagerDuty dedup_key, OpsGenie alias) resolve against notify.deliveries.external_id — a first-class indexed column — so external acks survive restarts and pool drops (see README.md“ack-bridge external-ID durability”).

3.1 In-app inbox alert grouping (re-notification cap)

A repeating platform signal (e.g. deployment.drift re-firing every 30–60 s per drifted component) must not create one inbox row per fire — that once let unreadCount climb to ~5,300 and saturated the bell badge. The in-app inbox (notify.inbox) therefore collapses repeats server-side:


4) Persistence & queue wiring

4.1 Persistence — schema ownership boundary

The notify PostgreSQL schema is owned by the toolkit (StellaOps.Notify.Persistence) and hosted by Notifier:

The take-away: Notify owns persistence; Notifier hosts it. Notifier never defines tables — it points the toolkit at a connection string and runs the toolkit’s migrations.

Durable state is the production default. The legacy in-memory repositories are isolated to the Testing environment; restart-survival of submit → persist → process → readback against real Postgres + Redis is proven by the Notifier durable-runtime test suites (e.g. NotifierDurableRuntimeProofTests, plus the suppression/escalation/quiet-hours/security dead-letter durable variants).

4.2 Queue binding

The event queue is bound by AddNotifyEventQueue(configuration, "notifier:queue") from StellaOps.Notify.Queue. The transport is selected from configuration via NotifyQueueTransportKind:

When required configuration is missing the registration falls back to UnavailableNotifyEventQueue, which fails closed rather than silently dropping events; the NullNotifyEventQueue no-op fallback is permitted only in the Testing environment (registered via TryAddSingleton in Program.cs). A Notify queue health check (NotifyQueueHealthCheck) is registered alongside so the host reports queue readiness.


5) Dependency matrix

What the Notifier host pulls from the Notify toolkit (verified from the project references):

Notify libraryPulled byProvides to the host
StellaOps.Notify.ModelsWorker (and transitively WebService)DTOs / contracts (Rule, Channel, Event, Delivery, Template, channel type/purpose enums).
StellaOps.Notify.EngineWebService, WorkerRules engine, rule evaluation, idempotency, digests, throttles.
StellaOps.Notify.PersistenceWebService, WorkerCanonical Postgres repositories + notify-schema startup migrations.
StellaOps.Notify.QueueWebService, WorkerEvent-queue client (Redis/Valkey Streams or NATS JetStream) + queue health checks.
StellaOps.Notify.Connectors.InApp, StellaOps.Notify.Connectors.InAppInboxNotify plug-in base bundleRequired in-product connectors. Missing either connector leaves the companion Notify.WebService /readyz endpoint unready with an actionable missing-plugin error.
StellaOps.Notify.Connectors.DoraInfoSharing.ContractsWorkerHost-owned DORA info-sharing request/result/signer/verifier contracts. The executable connector implementation remains outside the Worker compile graph.
Optional StellaOps.Notify.Connectors.* overlaysWorker / Notify plug-in overlaysExternal delivery adapters (Email, Slack, Teams, Webhook, PagerDuty, OpsGenie, Discord, Telegram) and regulatory/offline adapters (CSAF, DORA, DoraInfoSharing, ENISA, NCS). These are classified in src/Notify/plugins/notify/notify-bundles.json and should stay out of the core publish graph except for explicit contracts packages.

Connectors are restart-time plug-ins loaded from plugins/notify (see ../notify/architecture.mdsection 1). The 2026-06-06 DORA boundary slice removed the Worker ProjectReference to the executable StellaOps.Notify.Connectors.DoraInfoSharing implementation. DoraInfoSharingRuntimeDispatcher now builds only the host-owned request, signer, verifier, and delivery ledger contracts from StellaOps.Notify.Connectors.DoraInfoSharing.Contracts; MountedDoraInfoSharingConnectorRuntime loads the implementation assembly from the configured plugins/notify directory through PluginHost and invokes DoraInfoSharingSubscriberDeliveryService, DoraInfoSharingExportService, and DoraInfoSharingDsseService through that contract surface.

The signed regulatory profile uses these runtime paths:

When notifier:dora:infoSharing:connector:EnforceSignatureVerification=true, the mounted bundle must clear shared signed-runtime admission before executable code is loaded: the manifest.json id must equal stellaops.notify.connector.dorainfosharing, module must be notify, profile must be regulatory, contractVersion must be runtime-bundle.v1, capabilities must include notify:connector:dora-info-sharing, the assembly sha256 must match the manifest, and the adjacent <assembly>.sig must verify against the configured trust root. If the mounted assembly is absent, the Worker fails the DORA dispatch path before signing or subscriber transport with connector-bundle-not-mounted, and the probe report emits a blocking stellaops.notify.connector.dorainfosharing row when that plugin is requested. If the bundle is mounted but unsigned or signed by an untrusted key, the probe row is rejected with an actionable signature reason. devops/build/package-runtime-plugins.ps1 -Module notify -Profile regulatory emits the regulatory bundle metadata, and docker-compose.plugins.notify-connectors.yml mounts the regulatory profile into Notifier Worker. The remaining acceptance evidence is image audit proof that the implementation DLL exists only under /app/plugins plus live pluginized compose collector evidence.

The host also pulls non-Notify platform libraries: Router transport (StellaOps.Router.AspNet, StellaOps.Router.Transport.Messaging) and Authority integration (StellaOps.Auth.ServerIntegration) on the WebService; air-gap egress policy (StellaOps.AirGap.Policy), canonical JSON, cryptography, and worker-health on the Worker; audit emission and localization across both.


6) Deployment distinction

Deployed topology (corrected 2026-07-12). The 2025-11-02 module boundary decision intended src/Notifier/ to be the deployable Studio host; the delivered topology differs and this section records the delivered reality:

Keeping the two trees separate preserves packaging flexibility, Offline Kit parity (connectors, default templates, and seed rules ship as a plugins/notify tree the air-gap builder copies verbatim), and cross-module governance. The directories must not be merged without an approved packaging RFC.

Note on hostnames. In merged-console deployments the historical notifier.stella-ops.local hostname can appear as an alias on the notify-web container; the gateway routes Studio traffic through the notifier frontdoor onto the service-local API surface, which mixes /api/v2/{resource} resource groups (rules, templates, simulate, incidents, escalations, quiet-hours, throttles, overrides, security, storm-breaker, localization, ack, fallback) with the inline /api/v2/notify/{channels,deliveries} and /api/v1/* ingress routes. Routing/frontdoor details live in ../notify/architecture.md.


Toolkit (Notify):

Notifier host:

Related modules:


8) Epic alignment