Notify — Architecture (Notifications Toolkit)
Audience: Notify toolkit maintainers and integrators; operators wiring channels, rules, and digests. Companion: the deployable Notifications Studio host that composes this toolkit is documented in
../notifier/architecture.md.
Scope. Implementation‑ready architecture for Notify (aligned with Epic 11 – Notifications Studio): a rules‑driven, tenant‑aware notification service that consumes platform events (scan completed, report ready, rescan deltas, attestation logged, admission decisions, etc.), evaluates operator‑defined routing rules, renders channel‑specific messages (Slack/Teams/Email/Webhook/PagerDuty/OpsGenie), and delivers them reliably with idempotency, throttling, and digests. It is UI‑managed, auditable, and safe by default (no secrets leakage, no spam storms).
Recent changes & contract notes
- Personal inbox authorization and degradation contract (updated 2026-07-20). Authenticated
GET /api/v2/notify/inboxbinds to the GUID subject claim by default and returns the same typed200envelope for empty and populated users. A supplied cross-useruserIdrequiresnotify.admin;notify.viewercannot enumerate another user inside the tenant. Message-id get/read/archive operations return the same typed404for absent and foreign rows, while an admin may operate cross-user. A durable-store timeout or database exception returns sealed RFC-7807503withRetry-After: 5,service=notify, and a correlationtraceId; it does not convert dependency failure into a false empty inbox or expose the repository exception. Missing authentication remains401. The Console retains the last successful snapshot and exposes the typed failure with an accessible retry instead of displaying a false empty inbox or current zero count. - Forward-only grouping repair (updated 2026-07-20). Already-shipped migration
003_sprint20260717_notifier_inbox_grouping.sqlremains byte-for-byte immutable so startup checksum validation succeeds on upgraded databases. New migration004_sprint20260720_secure_bounded_inbox_grouping.sqlmoves recoverable typed rows to the v2 SHA-256 key, preserves counters for old connector rows whose typed subject was never persisted, and converges fresh or applied-003 schemas idempotently.
The dated entries below form an append-only contract changelog — durability cutovers, route/compatibility decisions, and the focused tests that prove them. Skim for the latest deltas, then read the numbered sections (starting at §0) for the durable architecture.
Console frontdoor compatibility (updated 2026-03-10). The web console reaches Notifier Studio through the gateway-owned
/api/v1/notifier/*prefix, which translates onto the service-local/api/v2/notify/*surface without requiring browser calls to raw service-prefixed routes.Console admin routing truthfulness (updated 2026-04-21). The console uses
/api/v1/notify/*only for core Notify toolkit flows (channels, rules, deliveries, incidents, acknowledgements). Advanced admin configuration such as quiet-hours, throttles, escalation, and localization is owned by the Notifier frontdoor/api/v1/notifier/* -> /api/v2/notify/*; Platform no longer serves synthetic/api/v1/notify/*admin compatibility payloads. Digest schedule CRUD remains unavailable in the live API.Merged Notify compat surface restoration (updated 2026-04-22). The merged
src/Notify/*host now maps the admin compatibility routes expected behind/api/v1/notifier/*, including/api/v2/notify/channels*,/deliveries*,/simulate*,/quiet-hours*,/throttle-configs*,/escalation-policies*, and/overrides*. Unsupported operator override CRUD now returns an explicit501contract response instead of a misleading404, and focused proof lives insrc/Notify/__Tests/StellaOps.Notify.WebService.Tests/CrudEndpointsTests.cs.Merged Notify inbox surface (updated 2026-05-14).
StellaOps.Notify.WebServicenow exposes the live UI inbox endpoints under/api/v2/notify/inbox*(list, unread count, message detail, mark read, mark all read, archive). Router Gateway maps/api/v2/notify*tonotify-web:8080with a direct reverse-proxy route because thenotify-webimage is not required to carry the router messaging transport plugin. Focused proof lives insrc/Notify/__Tests/StellaOps.Notify.WebService.Tests/InboxEndpointsTests.csandsrc/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GatewayRouteSearchMappingsTests.cs.Connector-backed CLI delivery alias (updated 2026-05-15).
NotifyChannelDeliveryDispatcherresolvesNotifyChannelType.Clithrough the registeredInAppInboxconnector, matching the product decision that CLI notifications are an inbox read-surface rather than a separate delivery plugin. Focused proof lives insrc/Notify/__Tests/StellaOps.Notify.Engine.Tests/Delivery/NotifyChannelDeliveryDispatcherTests.cs.Compliance alert ingestion readiness (updated 2026-05-16).
POST /api/v1/events/nis2acceptsnis2.*telemetry events such asnis2.effectiveness.threshold_breachedand enqueues them only when the Notify event queue is configured. If the queue transport lacks required configuration, the endpoint fails closed with503 notify_event_queue_unavailable, a stabletransport, andmissingPrerequisitessuch asnotify:queue:redis:connectionString; it does not synthesize deliveries or compliance conclusions.Findings production alert delivery correlation (updated 2026-05-16).
POST /api/v1/events/findingsacceptsfindings.production_alert.evidence_seededevents from Findings Ledger and writes a queued durable delivery row throughIDeliveryRepositorywhen the payload names an enabled Notify channel. The row uses the producer event id as the idempotent delivery id, stores the alert/evidence payload and correlation id, and remainsqueueduntil the normal delivery worker sends it. Missing or disabled channels fail closed with503 notify_delivery_channel_unavailable; the bridge does not claim a sent or delivered state.Advanced assurance NIS2 seed-chain proof (updated 2026-05-16). The retained local WebService harness starts from the Findings production alert evidence seed event, reads the queued Notify delivery, and uses the same alert id, evidence bundle ref, evidence hash, and Notify delivery ref to drive the NIS2 incident timeline handoff through early-warning and notification milestones. The proof records Findings ledger refs and timeline transmissions locally; it is not a channel-delivery or regulator-submission claim.
Advanced assurance Notify fixture import (updated 2026-05-17).
POST /api/v1/qa/fixtures/advanced-assurance-golden/notify-importaccepts the local advanced-assurance seed manifest body and writes Notify-owned rows only: a deterministic enabled email channel, a queuedfindings.production_alert.evidence_seededdelivery, and NIS2, DORA, and CRA incident-reporting timelines containing production evidence and Notify delivery refs. The route isnotify.operatorgated, tenant-scoped by the validatedstellaops:tenantclaim, does not accept filesystem paths, and returns explicit false claims for channel delivery, regulator submission, and Findings ledger row ownership. Query proof is through/api/v1/regulatory/reporting-timelines?evidenceRef=...; setup/evidence proof is throughGET /api/v1/qa/fixtures/advanced-assurance-golden/timeline-readiness?evidenceRef=..., which reports evidence timeline readiness separately from operator handoff, auto-submit, live publication, and legal-compliance claims.Claim-scoped canonical tenant enforcement (updated 2026-06-11). Notify canonical/admin/event endpoints resolve tenant context from the envelope-bound
stellaops:tenantclaim throughIStellaOpsTenantAccessor/HttpContext.RequireTenant(). RawX-StellaOps-TenantIdand legacyX-Tenant-Idheaders are not data-tenancy sources; request body or query tenant values must match the claim or fail with403 tenant_claim_mismatch, and missing claims fail closed with400 tenant_missing. Focused proof lives insrc/Notify/__Tests/StellaOps.Notify.WebService.Tests/CrossTenantEnforcementTests.csandGenericEventsEndpointTests.cs.Operator provider-change admission (updated 2026-07-24). The consolidated
StellaOps.Notify.WebServicehost owns the literalPOST /api/v1/events/platformroute forplatform.crypto-provider-changed. It acceptsnotify.operatoror the exact Platform mutation scopes (crypto:admin,crypto:profile:admin,ops.admin), still enforces the claim-bound tenant andplatform.kind prefix, and does not widen any other producer route. Because Platform sends this event from a durable outbox, the literal route accepts an old occurrence timestamp while still rejecting timestamps more than five minutes in the future; event id, idempotency key, tenant binding, and durable inbox suppression remain the replay controls. Generic producer routes retain the normal ±5-minute clock window. The production host seeds the matching in-app/email templates with the/administration/profilere-enrollment action;StellaOps.Notifier.Workerowns the recipient dispatch.Runtime durability cutover (updated 2026-04-16). Default
src/Notifier/*production wiring now resolves queue and storage through the sharedStellaOps.Notify.PersistenceandStellaOps.Notify.Queuelibraries.NullNotifyEventQueueis allowed only in theTestingenvironment,notify.pack_approvalsis durable, and restart-survival proof is covered byNotifierDurableRuntimeProofTestsagainst real Postgres + Redis.Correlation incident/throttle durability (updated 2026-04-20). Non-testing Notify and Notifier hosts no longer keep incident correlation or throttle windows in process-local memory. Both hosts now swap
IIncidentManagerandINotifyThrottleronto PostgreSQL-backed runtime services usingnotify.correlation_runtime_incidentsandnotify.correlation_runtime_throttle_events, with restart-survival proof inNotifierCorrelationDurableRuntimeTests.Localization runtime durability (updated 2026-04-20). Non-testing Notify and Notifier hosts no longer keep tenant-managed localization bundles in process-local memory. Both hosts now swap
ILocalizationServiceonto a PostgreSQL-backed runtime service usingnotify.localization_bundles, while built-in system fallback strings remain compiled defaults, with restart-survival proof inNotifierLocalizationDurableRuntimeTests.Storm/fallback runtime durability (updated 2026-04-20). Non-testing Notify and Notifier hosts no longer keep storm detection state, tenant fallback chains, or per-delivery fallback attempts in process-local memory. Both hosts now swap
IStormBreakerandIFallbackHandleronto PostgreSQL-backed runtime services usingnotify.storm_runtime_states,notify.storm_runtime_events,notify.fallback_runtime_chains, andnotify.fallback_runtime_delivery_states, with restart-survival proof inNotifierStormFallbackDurableRuntimeTests.Escalation engine runtime durability (updated 2026-04-20). Non-testing Notify and Notifier hosts no longer keep live
IEscalationEnginestate in a process-local dictionary. Both hosts now swapIEscalationEngineonto a PostgreSQL-backed runtime service usingnotify.escalation_states, with restart-survival proof inNotifierEscalationRuntimeDurableTestsand startup-contract proof inNotifyEscalationRuntimeStartupContractTests.External ack/runtime channel durability (updated 2026-04-20). Non-testing Notifier worker hosts no longer depend on a process-local external-id bridge map or a webhook-only dispatch composition for external channels. The worker now composes
WebhookChannelDispatcherfor chat/webhook routes plusAdapterChannelDispatcherforEmail,PagerDuty, andOpsGenie, durably records providerexternalIdplusincidentIdmetadata into PostgreSQL-backed delivery state, and resolves PagerDuty/OpsGenie webhook acknowledgements through PostgreSQL-backed lookup after restart. Focused proof lives inNotifierWorkerHostWiringTestsandNotifierAckBridgeRuntimeDurableTests.Digest scheduler runtime composition (updated 2026-04-20). The non-testing Notifier worker now composes
DigestScheduleRunner,DigestGenerator, andChannelDigestDistributorin the live host. Scheduled digests remain configuration-driven and now resolve tenant IDs fromNotifier:DigestSchedule:Schedules:*:TenantIdsthroughConfiguredDigestTenantProviderinstead of the process-localInMemoryDigestTenantProvider. There is currently no operator-managed digest schedule CRUD surface in the live runtime;/digestsadministers open digest windows only. Focused proof lives inNotifierWorkerHostWiringTests.Suppression admin durability (updated 2026-04-16). Non-testing throttle configuration and operator override APIs no longer use live in-memory state. Both hosts now resolve canonical
/api/v2/throttles*and/api/v2/overrides*plus legacy/api/v2/notify/throttle-configs*and/api/v2/notify/overrides*through PostgreSQL-backed suppression services, with restart-survival proof inNotifierSuppressionDurableRuntimeTests.Escalation/on-call durability (updated 2026-04-16). Non-testing escalation-policy and on-call schedule APIs no longer use live in-memory services or compat repositories. Both hosts now resolve canonical
/api/v2/escalation-policies*and/api/v2/oncall-schedules*plus legacy/api/v2/notify/escalation-policies*and/api/v2/notify/oncall-schedules*through PostgreSQL-backed runtime services, with restart-survival proof inNotifierEscalationOnCallDurableRuntimeTests.Quiet-hours/maintenance durability (updated 2026-04-20). Non-testing quiet-hours calendars and maintenance windows no longer use live in-memory compat repositories or maintenance evaluators. Both hosts now resolve canonical
/api/v2/quiet-hours*plus legacy/api/v2/notify/quiet-hours*and/api/v2/notify/maintenance-windows*through PostgreSQL-backed runtime services on the sharednotify.quiet_hoursandnotify.maintenance_windowstables, with restart-survival proof inNotifierQuietHoursMaintenanceDurableRuntimeTests. Fixed-time daily/weekly cron expressions still project truthfully into canonical schedules, and compat-authored cron shapes that cannot be flattened losslessly now evaluate natively from persistedcronExpressionplusdurationmetadata instead of remaining inert after restart.Security/dead-letter durability (updated 2026-04-16). Non-testing webhook security, tenant isolation, dead-letter administration, and retention cleanup state no longer use live in-memory services. Both hosts now resolve
/api/v2/security*,/api/v2/notify/dead-letter*,/api/v1/observability/dead-letters*, and retention endpoints through PostgreSQL-backed runtime services on sharednotify.webhook_security_configs,notify.webhook_validation_nonces,notify.tenant_resource_owners,notify.cross_tenant_grants,notify.tenant_isolation_violations,notify.dead_letter_entries,notify.retention_policies_runtime, andnotify.retention_cleanup_executions_runtimetables, with restart-survival proof inNotifierSecurityDeadLetterDurableRuntimeTests.Notify WebService runtime boundary (updated 2026-04-24).
StellaOps.Notify.WebServicenow rejectsnotify:authority:allowAnonymousFallback=trueandnotify:storage:driver=memoryoutside Development and Testing. Direct in-memory admin/runtime registrations for incident correlation, throttles, quiet-hours, maintenance, escalation/on-call, suppression, webhook security, tenant isolation, dead-letter, and retention are Testing-only in the Notify host; production resolves the PostgreSQL-backed runtime services and Redis/NATS queue implementation.Notify persistence startup migrations (updated 2026-04-24). Both
AddNotifyPersistence(IConfiguration, ...)and the explicit-optionsAddNotifyPersistence(Action<PostgresOptions>)composition paths register the Notify PostgreSQL startup migration host. Any production host using the shared persistence library converges thenotifyschema from embedded SQL resources on startup instead of depending on manual bootstrap scripts.Shared incident-report timeline state machine (updated 2026-05-01).
StellaOps.Notify.WebService.Services.IncidentReportTimelineServiceowns the reusable reporting state machine for assurance packs and regulatory consumers. The service modelsOPEN -> EARLY_WARNING_SENT -> NOTIFICATION_SENT -> FINAL_REPORT_SENT -> CLOSED, records classification ledger refs separately from reporting transmissions, computes regime milestones, requires signer/payload hash/evidence refs per transmission, and emits SEV-1 evaluation alerts when the active milestone reaches 75 percent elapsed or misses its deadline.AssuranceReportingProfileRegistrynow projects NIS2 Article 23 and CRA Article 14 pack profiles over those same regime definitions; profiles add pack metadata, claim boundaries, channel families, operator approval posture, and readiness reason codes without introducing separate timeline persistence.Product CSAF feed connector (updated 2026-04-30).
StellaOps.Notify.Connectors.Csafowns Stella-as-manufacturer product advisory projection to canonical CSAF 2.0, DSSE wrapping with payload idproduct-csaf-advisory-v1, Signer Core offline verification, and deterministic operator filesystem feed drops. Public/.well-knownand feed routing remain Router-owned. Official CSAF JSON Schema validation staysblockeduntil approved local CSAF 2.0 schema assets are vendored and noticed; the connector usesstella-product-csaf-local-validation-v1meanwhile. Seedocs/modules/notify/channels/product-csaf.mdanddocs/contracts/stella-product-csaf-advisory-v1.md.DORA information-sharing runtime dispatch (updated 2026-06-09). The Notifier worker has a purpose-specific runtime path for persisted, enabled
NotifyChannelPurpose.DoraInfoSharingchannels. It bypasses generic webhook/template dispatch, builds a contracts-only DORA connector request from channel collection/subscriber settings plus frozen source events on the pending delivery, and delegates executable delivery toStellaOps.Notify.Connectors.DoraInfoSharingonly when that assembly is mounted under the configured Notify plugin directory. Missing bundles fail closed before signing or subscriber transport withconnector-bundle-not-mounted; signed-profile mounts with a missing or invalid detached signature are rejected before load whennotifier:dora:infoSharing:connector:EnforceSignatureVerification=true; disabled, missing-endpoint, verification-failing, or non-DORA-purpose channels also fail closed before false success. Successful delivery persists the connector-returnedNotifyDeliveryplusdora-info-sharing-subscriber-receipt-v1metadata through the durable delivery ledger. Seedocs/modules/notify/channels/dora-info-sharing.md.Concelier federation bundle fan-out (updated 2026-05-14). The Notifier worker consumes
concelier.federation.bundle.ready/.failedevents from Concelier, renders the bootstrap federation templates, creates one in-app inbox row keyed by the event id, and dispatches one email when the producer suppliesrecipientEmailin event attributes or payload. The render context enriches bundle payloads with short export/hash aliases and human-readable byte sizes so email and inbox templates do not show blank summary fields. Replay of the same event id collapses at the inbox unique constraint and skips duplicate email dispatch.Testing-only fallback boundary (updated 2026-04-20).
src/Notifier/*host startup now registers those durable quiet-hours, suppression, escalation/on-call, security, and dead-letter services directly for non-testing environments instead of composing an in-memory graph and replacing it later. The remaining in-memory admin services are isolated toTesting, with startup-contract proof inStartupDependencyWiringTests.- Simulation runtime parity (updated 2026-04-20). The canonical
/api/v2/simulate*endpoints and the legacy/api/v2/notify/simulate*endpoints insrc/Notifier/now resolve the same DI-composed simulation runtime, so throttling plus quiet-hours or maintenance suppression behave identically across route families.
- Simulation runtime parity (updated 2026-04-20). The canonical
Mounted plugin runtime (updated 2026-06-04). Notify channel connector bundles are mounted read-only from
devops/plugins/notify/<profile>/<plugin-id>/to/app/plugins/notify/<profile>/<plugin-id>/. The base compose pointsNOTIFY_NOTIFY__PLUGINS__DIRECTORYat/app/plugins/notify/baseand leavesNOTIFY_NOTIFY__PLUGINS__BASEDIRECTORYas/appfor relative local/dev paths. Config is mounted at/app/etc/plugins/notify, trust roots at/app/trust-roots/plugins/notify, and scratch/probe output belongs under/var/lib/stellaops/plugin-scratch/notify. Mounted executable bundles are not created by the service at startup, signature admission is enforced by default, and templates/routing rules remain data/config that must not execute code. Loader precedence is runtime configuration first:notify:plugins(directory,recursiveSearch,searchPatterns,orderedPlugins, andrequiredPlugins) controls discovery and admission order, whiledevops/etc/plugins/notify/registry.yamlremains the channel/provider configuration inventory consumed by operators and bootstrap tooling.Recommended and harness plugin profiles (updated 2026-06-06). The Notify runtime bundle producer now emits real signed
recommendedandharnessprofile directories instead of inventory-only markers.recommendedcarries Email and Webhook connector bundles.harnesscarries InApp/InAppInbox plusStellaOps.Notify.Plugin.Dummy, a dry-runINotifyChannelConnectorfor deterministic responded probes without external delivery.
0) Mission & boundaries
Mission. Convert facts from Stella Ops into actionable, noise-controlled signals where teams already live (chat, email, paging, and webhooks), with explainable reasons and deep links to the UI.
Boundaries.
- Notify does not make policy decisions and does not rescan; it consumes events from Scanner/Scheduler/Excititor/Concelier/Attestor/Zastava and routes them.
- Attachments are links (UI/attestation pages); Notify does not attach SBOMs or large blobs to messages.
- Secrets for channels (Slack tokens, SMTP creds) are referenced, not stored raw in the database.
- 2025-11-02 module boundary. Maintain
src/Notify/as the reusable notification toolkit (engine, storage, queue, connectors) andsrc/Notifier/as the Notifications Studio host that composes those libraries. Do not merge directories without an approved packaging RFC that covers build impacts, offline kit parity, and cross-module governance. - API versioning (updated 2026-07-12). The deployed API surface is served by a single web host,
notify-web(src/Notify/StellaOps.Notify.WebService):- It maps both
/api/v1/notify(the core toolkit — rules, channels, deliveries, templates) and the full/api/v2/notifyNotifications Studio with enterprise features (escalation policies, on-call schedules, storm breaker, inbox, retention, simulation, quiet hours, throttle, observability) —Program.cs:453-468(MapNotifyApiV2+MapIncident/Simulation/QuietHours/Throttle/Escalation/StormBreaker/Security/Observabilityendpoints). The full enterprise surface is host-owned here, not a subset. - The
StellaOps.Notifier.WebServiceundersrc/Notifier/— historically framed as the “Studio host” — is commented out of compose (“Slot 28: Notifier (MERGED into notify-web — kept commented for rollback)”,devops/compose/docker-compose.stella-services.yml) and is not deployed. Fromsrc/Notifier/only the delivery worker (notifier-worker) ships. That WebService is a divergent duplicate retained solely as a rollback path (see notifier dossier §1, audit corrections §A2); do not treat it as a live deployable.
- It maps both
1) Runtime shape & projects
The actual layout (verified against src/Notify/) is a single deployable WebService plus reusable libraries under __Libraries/. The delivery worker host lives in src/Notifier/(documented separately at docs/modules/notifier/architecture.md); there is no StellaOps.Notify.Worker project inside src/Notify/. Persistence is StellaOps.Notify.Persistence (not …Storage.Postgres).
src/Notify/
├─ StellaOps.Notify.WebService/ # REST host: core /api/v1/notify + merged /api/v2/notify (Studio)
├─ __Libraries/
│ ├─ StellaOps.Notify.Engine/ # rules engine, templates, idempotency, digests, throttles, channel-connector contracts + dispatcher
│ ├─ StellaOps.Notify.Models/ # DTOs (Rule, Channel, Event, Delivery, Template), schema-version migration
│ ├─ StellaOps.Notify.Persistence/ # canonical PostgreSQL persistence (notify schema, embedded SQL migrations) + InMemory
│ ├─ StellaOps.Notify.Storage.InMemory/ # in-memory repositories (Testing/offline)
│ ├─ StellaOps.Notify.Queue/ # event-queue abstraction (Redis/Valkey Streams or NATS JetStream)
│ ├─ StellaOps.Notify.WebHooks.Inbound/ # per-tenant inbound webhook signature gate (PagerDuty/OpsGenie acks)
│ └─ StellaOps.Notify.Connectors.*/ # channel plug-ins (see §5)
└─ __Tests/ # unit/integration suites per library/connector
Deployables:
- Notify.WebService (stateless API; serves both v1 toolkit and v2 Studio surfaces). The delivery worker is hosted by
src/Notifier, which composes these same libraries.
Dependencies: Authority (OpToks; DPoP/mTLS), PostgreSQL (notify schema), Redis/Valkey or NATS (event queue; NotifyQueueTransportKind = Redis | Nats), HTTP egress to Slack/Teams/Webhooks/PagerDuty/OpsGenie/Discord/Telegram, SMTP relay for Email.
Configuration. Notify.WebService bootstraps from the mounted runtime file
/app/etc/notify/notify.yamlin shipped containers (copy one of the flat templates such asetc/notify.yaml.sampleoretc/notify.prod.yamlintodevops/etc/notify/notify.yamlbefore compose startup). Usestorage.driver: postgresand providepostgres.notifyoptions (connectionString,schemaName, pool sizing, timeouts). Authority settings follow the platform defaults—when running locally without Authority, setauthority.enabled: falseand supplydevelopmentSigningKeyso JWTs can be validated offline.
api.rateLimitsexposes token-bucket controls for delivery history queries and test-send previews (deliveryHistory,testSend). Default values allow generous browsing while preventing accidental bursts; operators can relax/tighten the buckets per deployment.
Plug-ins and bundle classification. Notify channel connectors are packaged under
<baseDirectory>/plugins/notify; module-local classification metadata lives atsrc/Notify/plugins/notify/notify-bundles.json. The base bundle is required and contains onlyStellaOps.Notify.Connectors.InAppandStellaOps.Notify.Connectors.InAppInbox, which cover in-product transient notifications plus the durable inbox/CLI read surface. External delivery connectors are optional overlays:chat(Slack,Teams,Webhook,Discord,Telegram), andpaging(PagerDuty,OpsGenie). Regulatory/offline connectors are an optionalregulatoryoverlay (Csaf,Dora,DoraInfoSharing,Enisa,NCS).StellaOps.Notify.Connectors.Sharedis dependency-only and is not a channel bundle by itself.Notify discovery intentionally lists first-party connector entry assemblies and the custom lane
StellaOps.Notify.Plugin.*.dll; it does not use a broadStellaOps.Notify.Connectors.*.dllglob because dependency-only assemblies such asStellaOps.Notify.Connectors.Shared.dllmay be copied beside a connector for dependency resolution but must not be admitted or probed as plugins.plugins: baseDirectory: "/app" directory: "/app/plugins/notify/base" ensureDirectoryExists: false requireReadOnlyDirectory: true enforceSignatureVerification: true signature: provider: "cosign" publicKeyPath: "/app/trust-roots/plugins/notify/cosign.pub" useRekorTransparencyLog: false requiredPlugins: - StellaOps.Notify.Connectors.InApp - StellaOps.Notify.Connectors.InAppInbox orderedPlugins: - StellaOps.Notify.Connectors.InApp - StellaOps.Notify.Connectors.InAppInbox - StellaOps.Notify.Connectors.Slack - StellaOps.Notify.Connectors.Teams - StellaOps.Notify.Connectors.Email - StellaOps.Notify.Connectors.Webhook
requiredPluginsfails Notify readiness when the base bundle is absent;/healthzremains liveness-only while/readyzreturns503with the missing assembly names. Production compose also setsrequireReadOnlyDirectory=true, so a writable mounted plugin root keeps readiness red and/internal/plugins/statusreportsstatus=rejecteduntil the bundle is mounted:ro. If a mounted DLL is present but unsigned, signed by an untrusted key, or otherwise rejected before load, readiness fails with a plug-in admission reason and/internal/plugins/statusreportsstatus=rejected. IfrequiredPluginsororderedPluginsomit the base pair, Notify post-configuration prepends them so Offline Kit load order is deterministic. Optional duplicates are resolved by the existing first-loaded-wins rule in the dispatcher/health maps; the configured order therefore decides the winner and later duplicates are logged and ignored.The Offline Kit job copies the
plugins/notifytree into the air-gapped bundle; the ordered list keeps connector manifests stable across environments.devops/build/package-runtime-plugins.ps1 -Module notify -Profile <base|email|chat|paging|regulatory|recommended|harness|bad-signature>produces the generated profile directories from the connector projects, and-SignNotifyBundles -NotifyCosignKeyPath <key> -NotifyCosignPublicKeyPath <pub>must be used before a bundle is runtime-admissible in production compose. Signing/trust metadata belongs beside each bundle manifest (notify-plugin.json, or generatedmanifest.jsonfor DORA runtime adapters, plus checksums and<assembly>.dll.sigsignature material) before a bundle is promoted from source build output intodevops/plugins/notify/<profile>/<plugin-id>. The private key remains out of repo; the public key is copied to the mounted trust root ascosign.pub. Rekor transparency checks are disabled in the default compose profile so sealed/offline deployments verify against the mounted trust root rather than an external network.In the hosted Notifier worker, delivery execution is split across deterministic dispatch paths:
WebhookChannelDispatchercontinues to handle chat/webhook routes,AdapterChannelDispatcherresolvesPagerDuty, andOpsGeniethroughIChannelAdapterFactory, and the DORA information-sharing runtime dispatcher handles only channels whose purpose isdora-info-sharing. The DORA dispatcher referencesStellaOps.Notify.Connectors.DoraInfoSharing.Contractsonly; executable DORA export, DSSE, and subscriber-delivery services are loaded from the mounted Notify connector assembly through the Worker mounted-runtime adapter. When signed admission is enabled, the regulatory bundle must be rooted at/app/plugins/notify/regulatory/stellaops.notify.connector.dorainfosharing/, declare capabilitynotify:connector:dora-info-sharinginmanifest.json, match the assembly sha256, and verify the adjacent detached signature against/app/trust-roots/plugins/notify/cosign.pubor the configured trust root. The providerexternalIdemitted by adapter-backed channels must survive persistence so inbound webhook acknowledgements can be resolved after restart; the DORA dispatcher separately persists the connector-returned delivery ledger and subscriber receipt metadata.
Authority clients. Register two OAuth clients in StellaOps Authority:
notify-web-dev(audiencenotify.dev) for development andnotify-web(audiencenotify) for staging/production. Both require thenotify.viewer,notify.operator, andnotify.adminscopes (plusnotify.escalatewhere escalation workflows are used) and use DPoP-bound client credentials (client_secretin the samples). The resource server also tolerates the sharedstellaopsaudience alongsidenotify(seeNotifyWebServiceOptions.AuthorityOptions.Audiences). Reference entries live inetc/authority.yaml.sample, with placeholder secrets underetc/secrets/notify-web*.secret.example.
2) Responsibilities
- Ingest platform events from internal bus with strong ordering per key (e.g., image digest).
- Evaluate rules (tenant‑scoped) with matchers: severity changes, namespaces, repos, labels, KEV flags, provider provenance (VEX), component keys, admission decisions, etc.
- Control noise: throttle, coalesce (digest windows), and dedupe via idempotency keys.
- Render channel‑specific messages using safe templates; include evidence and links.
- Deliver with retries/backoff; record outcome; expose delivery history to UI.
- Test paths (send test to channel targets) without touching live rules.
- Audit: log who configured what, when, and why a message was sent.
3) Event model (inputs)
Notify subscribes to the internal event bus (produced by services, escaped JSON; gzip allowed with caps):
scanner.scan.completed— new SBOM(s) composed; artifacts readyscanner.report.ready— analysis verdict (policy+vex) available; carries deltas summaryscheduler.rescan.delta— new findings after Concelier/Excititor deltas (already summarized)scheduler.doctor.run_alert— scheduled Doctor run crossed its configured alert threshold; payload carries schedule/run IDs, pass/warn/fail counts, health score, configured alert channels, and the failed/warn check IDs for tenant-scoped routing.attestor.logged— Rekor UUID returned (sbom/report/vex export)zastava.admission— admit/deny with reasons, namespace, image digestsconselier.export.completed— new Concelier export ready (rarely notified directly; usually drives Scheduler). The event-kind string keeps the legacyconselier.spelling on purpose — it is the literal constantNotifyEventKinds.ConselierExportCompleted; do not “correct” it toconcelier.without changing the source contract.excitor.export.completed— new Excititor consensus snapshot (ditto). Likewise theexcitor.prefix is the literalNotifyEventKinds.ExcitorExportCompletedconstant, not a typo to repair in docs alone.nis2.effectiveness.threshold_breached— NIS2 KPI effectiveness target crossed; payload schemanis2-effectiveness-threshold-breach-v1, routed torole:<responsibleRole>from the Policy NIS2 control register.
Canonical envelope (bus → Notify.Engine):
{
"eventId": "uuid",
"kind": "scanner.report.ready",
"tenant": "tenant-01",
"ts": "2025-10-18T05:41:22Z",
"actor": "scanner-webservice",
"scope": { "namespace":"payments", "repo":"ghcr.io/acme/api", "digest":"sha256:..." },
"payload": { /* kind-specific fields, see below */ }
}
Examples (payload cores):
scanner.report.ready:{ "reportId": "report-3def...", "verdict": "fail", "summary": {"total": 12, "blocked": 2, "warned": 3, "ignored": 5, "quieted": 2}, "delta": {"newCritical": 1, "kev": ["CVE-2025-..."]}, "links": {"ui": "https://ui/.../reports/report-3def...", "rekor": "https://rekor/..."}, "dsse": { "...": "..." }, "report": { "...": "..." } }Payload embeds both the canonical report document and the DSSE envelope so connectors, Notify, and UI tooling can reuse the signed bytes without re-serialising.
scanner.scan.completed:{ "reportId": "report-3def...", "digest": "sha256:...", "verdict": "fail", "summary": {"total": 12, "blocked": 2, "warned": 3, "ignored": 5, "quieted": 2}, "delta": {"newCritical": 1, "kev": ["CVE-2025-..."]}, "policy": {"revisionId": "rev-42", "digest": "27d2..."}, "findings": [{"id": "finding-1", "severity": "Critical", "cve": "CVE-2025-...", "reachability": "runtime"}], "dsse": { "...": "..." } }zastava.admission:{ "decision":"deny|allow", "reasons":["unsigned image","missing SBOM"], "images":[{"digest":"sha256:...","signed":false,"hasSbom":false}] }
4) Rules engine — semantics
Rule shape (simplified):
name: "high-critical-alerts-prod"
enabled: true
match:
eventKinds: ["scanner.report.ready","scheduler.rescan.delta","zastava.admission"]
namespaces: ["prod-*"]
repos: ["ghcr.io/acme/*"]
minSeverity: "high" # min of new findings (delta context)
kev: true # require KEV-tagged or allow any if false
verdict: ["fail","deny"] # filter for report/admission
vex:
includeRejectedJustifications: false # notify only on accepted 'affected'
actions:
- channel: "slack:sec-alerts" # reference to Channel object
template: "concise"
throttle: "5m"
- channel: "email:soc"
digest: "hourly"
template: "detailed"
Evaluation order
- Tenant check → discard if rule tenant ≠ event tenant.
- Kind filter → discard early.
- Scope match (namespace/repo/labels).
- Delta/severity gates (if event carries
delta). - VEX gate (drop if event’s finding is not affected under policy consensus unless rule says otherwise).
- Throttling/dedup (idempotency key) — skip if suppressed.
- Actions → enqueue per‑channel job(s).
Idempotency key: hash(ruleId | actionId | event.kind | scope.digest | delta.hash | day-bucket); ensures “same alert” doesn’t fire more than once within throttle window.
Digest windows: maintain per action a coalescer:
- Window:
5m|15m|1h|1d(configurable); coalesces events by tenant + namespace/repo or by digest group. - Digest messages summarize top N items and counts, with safe truncation.
5) Channels & connectors (plug‑ins)
Channel config is two‑part: a Channel record (name, type, options) and a Secret reference (Vault/K8s Secret). Connectors are restart-time plug-ins discovered on service start (same manifest convention as Concelier/Excititor) and live under plugins/notify/<channel>/.
Channel connectors (verified against src/Notify/__Libraries/StellaOps.Notify.Connectors.*):
General-purpose channels:
- Slack: Bot token (xoxb‑…),
chat.postMessage+blocks; rate limit aware (HTTP 429). - Microsoft Teams: Incoming Webhook (or Graph card later); adaptive card payloads.
- Email (SMTP): TLS (STARTTLS or implicit), From/To/CC/BCC; HTML+text alt; DKIM optional.
- Generic Webhook: POST JSON with HMAC signature (Ed25519 or SHA‑256) in headers.
- PagerDuty: Events API v2 trigger/ack/resolve flow; durable
dedup_key/external id mapping is persisted with delivery state for restart-safe webhook acknowledgement handling. - OpsGenie: Alert create/ack/close flow; alias/external id is persisted with delivery state so inbound acknowledgement webhooks remain restart-safe.
- Discord / Telegram: chat connectors added under the consolidated connector contract.
- InApp / InAppInbox: in-product channels backing the live UI inbox (
/api/v2/notify/inbox*).NotifyChannelType.Cliis an alias that resolves through theInAppInboxconnector (CLI notifications are an inbox read-surface, not a separate delivery plug-in).
The application-level
NotifyChannelTypeenum (StellaOps.Notify.Models/NotifyEnums.cs) carries:Slack,Teams,Webhook,Custom,PagerDuty,OpsGenie,Cli,InAppInbox,InApp,Discord,Telegram(string-serialised viaJsonStringEnumConverter; Discord/Telegram appended last so existing serialized values are stable). By contrast, the PostgreSQLnotify.channel_typeenum intentionally carries only the original six external types (slack,teams,webhook,pagerduty,opsgenie). In-product channels (InApp/InAppInbox/Cli), theCustomtype, and the Discord/Telegram connectors persist withchannel_type = webhookwhile their trueNotifyChannelTypeis preserved losslessly in the channelmetadataJSON (round-tripped byToChannelEntity/ToNotifyChannel).
Regulatory / compliance connectors (handoff adapters, not general broadcast channels — see §7.1 and the per-channel dossiers):
- CSAF (
StellaOps.Notify.Connectors.Csaf) — Stella-as-manufacturer product advisory projection to CSAF 2.0. - DORA (
…Connectors.Dora,…Connectors.DoraInfoSharing) — DORA major-incident handoff and information-sharing subscriber delivery. - ENISA / CRA (
…Connectors.Enisa) and NIS2 CSIRT (…Connectors.NCS) — regulator handoff adapters.
Connector contract. The consolidated plug-in seam is INotifyChannelConnector (in StellaOps.Notify.Engine/ChannelConnectorContracts.cs), which extends the two legacy provider interfaces so a single registration satisfies health, test-preview, and wire delivery:
public interface INotifyChannelConnector
: INotifyChannelHealthProvider, // ChannelHealthContracts.cs — connector self-check
INotifyChannelTestProvider // ChannelTestPreviewContracts.cs — test-send preview
{
// Delivers a rendered notification; returns status + provider externalId + metadata.
Task<ChannelDeliveryOutcome> DeliverAsync(ChannelDeliveryContext context, CancellationToken cancellationToken);
}
Adopting the consolidated contract is opt-in: existing Email/Slack/Teams/Webhook connectors keep their separate INotifyChannelHealthProvider / INotifyChannelTestProvider registrations. NotifyChannelDeliveryDispatcher (Engine) routes a rendered delivery to the registered connector for the channel type. (ChannelDeliveryStatus is Delivered | Failed | Skipped.)
For hosted external channels, Notifier worker adapters implement IChannelAdapter and are selected by AdapterChannelDispatcher. Those adapters must emit stable provider identifiers (externalId, incidentId where applicable) so the IAckBridge webhook path can recover correlation from persisted delivery rows instead of process-local memory.
DeliveryContext includes rendered content and raw event for audit.
Test-send previews. Plug-ins can optionally implement INotifyChannelTestProvider to shape /channels/{id}/test responses. Providers receive a sanitised ChannelTestPreviewContext (channel, tenant, target, timestamp, trace) and return a NotifyDeliveryRendered preview + metadata. When no provider is present, the host falls back to a generic preview so the endpoint always responds.
Secrets: ChannelConfig.secretRef is a unified secret reference (builtin:// / vault:// / openbao:// / authref:// / inline dev schemes) resolved at send-time by INotifyChannelSecretResolver through ISecretProvider (ADR-031/032) — see §11 Security. The live worker resolves it fail-closed immediately before the connector runs; plug-in manifests (notify-plugin.json) declare capabilities and version.
6) Templates & rendering
Template engine: strongly typed, safe Handlebars‑style; no arbitrary code. Partial templates per channel. Deterministic outputs (prop order, no locale drift unless requested).
Variables (examples):
event.kind,event.ts,scope.namespace,scope.repo,scope.digestpayload.verdict,payload.delta.newCritical,payload.links.ui,payload.links.rekortopFindings[]withpurl,vulnId,severitypolicy.name,policy.revision(if available)
Helpers:
severity_icon(sev),link(text,url),pluralize(n, "finding"),truncate(text, n),code(text).
Channel mapping:
- Slack: title + blocks, limited to 50 blocks/3000 chars per section; long lists → link to UI.
- Teams: Adaptive Card schema 1.5; fallback text for older channels (surfaced as
teams.fallbackTextmetadata alongside webhook hash). - Email: HTML + text; inline table of top N findings, rest behind UI link.
- Webhook: JSON with
event,ruleId,actionId,summary,links, and rawpayloadsubset.
i18n: template set per locale (English default; Bulgarian built‑in).
6.1) Placeholder substitution & auto-escape (H1a)
Placeholder substitution in AdvancedTemplateRenderer is format-aware. When template.RenderMode == Html, payload-derived values resolved by the substitution helpers are auto-escaped via System.Net.WebUtility.HtmlEncode before they replace the placeholder token. This applies to:
{{property}}and{{nested.path}}simple placeholders.{{this}}inside{{#each}}array iterations.{{this}}and{{@key}}inside{{#each}}object iterations.
Markdown / PlainText / JSON modes are unchanged (no auto-escape) — they preserve the raw substituted string. Markdown bodies are author-controlled by template authors; downstream Markdown→HTML conversion (ConvertMarkdownToHtml) is a separate concern (see risk below). PlainText must not double-escape because it is delivered as text. JSON-mode bodies are wrapped in a JsonObject by ConvertToJson (Json+Json delivery is passthrough), and the surrounding JSON encoder already escapes appropriately.
Static template body is never encoded — only resolved placeholder values. Template authors emitting trusted HTML must place that markup in the static template body, not in payload fields. Triple-stash ({{{name}}}) opt-out for trusted HTML payloads is reserved as a follow-up enhancement; until it lands, all HTML-mode payload values are encoded.
Cross-link: the H1 finding in docs-archive/qa/audits/microservice-audit-pass2-2026-04-29.md is the source for this contract; implementation tracked in docs-archive/implplan/SPRINT_20260430_013_Notify_template_renderer_escape.md (NOTIFY-H1a-001 / -002 / -003).
Risks (carried as separate sprints):
ConvertMarkdownToHtml(AdvancedTemplateRenderer.cs:297) uses naive regex replacement for**bold**,*em*,`code`, and[label](url)and does not sanitize inline content. A malicious payload value substituted into a Markdown body could become unsanitized HTML after Markdown→HTML conversion. Fix requires a vetted Markdown sanitizer (Markdig + HtmlSanitizer or equivalent) and is out of scope for H1a.- Triple-stash
{{{name}}}opt-out is not yet implemented; if templates legitimately need to render pre-escaped HTML from payload, add the triple-stash branch toPlaceholderRegex/ProcessEachBlocksin a follow-up.
7) Data model (PostgreSQL)
Canonical JSON Schemas for rules/channels/events live in docs/modules/notify/resources/schemas/. Sample payloads intended for tests/UI mock responses are captured in docs/modules/notify/resources/samples/.
Database: stellaops_notify (PostgreSQL), schema notify(plus the notify_app helper schema holding the require_current_tenant() function). The schema is created and migrated forward-only from embedded SQL resources in StellaOps.Notify.Persistence via AddNotifyPersistence → the Notify startup migration host; there is no manual init-script dependency. The live set is 001_v1_notify_baseline.sql (the collapsed pre-1.0 001–012 chain) + 002_sprint040_escalation_external_id_indexes.sql + the seed baseline S001_v1_notify_seed_baseline.sql; the old numbered files sit under Migrations/_archived/pre_1.0/mig061/ and are excluded from embedding (.csproj Exclude="Migrations\_archived\**\*.sql"), so cite the baseline, not the archived numbers. Every base table enforces row-level security keyed on the app.tenant_id session GUC (notify_admin is a BYPASSRLS maintenance role).
The shapes below are indicative; actual tables use concrete typed columns (PostgreSQL, not Mongo — the legacy _id shorthand maps to a uuid id primary key). rules, channels, and templates additionally carry a metadata JSONB column that losslessly round-trips the full canonical NotifyRule/NotifyChannel/NotifyTemplate model.
rules(notify.rules) — columnsid, tenant_id, name, description, enabled, priority, event_types text[], filter jsonb, channel_ids uuid[], template_id, metadata, created_at, updated_at;UNIQUE(tenant_id, name).channels(notify.channels) — columnsid, tenant_id, name, channel_type (notify.channel_type enum), enabled, config jsonb, credentials jsonb, metadata, created_at, updated_at, created_by;UNIQUE(tenant_id, name). Secrets are referenced (secretRef), not stored raw.templates(notify.templates) — columnsid, tenant_id, name, channel_type, subject_template, body_template, locale, metadata;UNIQUE(tenant_id, name, channel_type, locale).deliveries(notify.deliveries, PARTITION BY RANGE (created_at) — partitions auto-managed bynotify.ensure_delivery_partitions(), 3 months back / 4 months ahead, plus adeliveries_defaultcatch-all)Actual columns:
id, tenant_id, channel_id, rule_id, template_id, status (notify.delivery_status enum: pending|queued|sending|sent|delivered|failed|bounced), recipient, subject, body, event_type, event_payload jsonb, attempt, max_attempts, next_retry_at, error_message, external_id, correlation_id, created_at, queued_at, sent_at, delivered_at, failed_at. (Thethrottled/digested/droppedstates below are pipeline outcomes recorded in metadata/correlation, not values of thedelivery_statusenum.) Indicative model view:{ id, tenantId, ruleId, actionId, eventId, kind, scope, status:"sent|failed|throttled|digested|dropped", externalId?, channelId, metadata?, attempts:[{ts, status, code, reason}], rendered:{ title, body, target }, // redacted for PII; body hash stored sentAt, lastError? }PagerDuty and OpsGenie deliveries durably carry the provider
externalIdplusmetadata.incidentIdso inbound webhook acknowledgements can be resolved after worker restart without relying on a process-local bridge map.External-ID column projection (2026-04-29, NOTIFY-ACKBRIDGE-002/003). The
externalIdandchannelIdare persisted as first-class indexed columns onnotify.deliveries, not as JSON metadata keys. The previous mapper left these columns NULL while embedding the values insideevent_payload.metadataonly, so the ack-bridge resolver could not find the row after a Postgres restart (the in-process cache that previously held the mapping is the only place where the values lived in a column-shaped form). The consolidated baseline001_v1_notify_baseline.sqldeclares both columns (external_idat line 195), backfills existing rows fromevent_payload -> 'metadata' ->> 'externalId'(line 1200), and adds the partial composite indexidx_notify_deliveries_external_id (channel_id, external_id) WHERE external_id IS NOT NULL(line 1225) for the resolver join — all folded in from the pre-1.0009_deliveries_external_id_columns.sql. The baseline likewise folds in010_deliveries_drop_metadata_externalid_keys.sql, which removes redundantmetadata.externalIdandmetadata.channelkeys from serialized delivery payloads after preserving top-level model fields. (Both009_*and010_*are archived and no longer embedded.) The durability contract: the resolver result survives a Postgres restart and a connection-pool drop; ifexternal_idis missing, the resolver does not fall back to JSON metadata. Seedocs/architecture/decisions/ADR-018-notify-external-id-columns.mdanddocs/modules/notifier/README.md.digests{ _id, tenantId, actionKey, window:"hourly", openedAt, items:[{eventId, scope, delta}], status:"open|flushed" }correlation_runtime_incidents{ tenantId, incidentId, correlationKey, eventKind, title, status:"open|acknowledged|resolved", eventCount, firstOccurrence, lastOccurrence, acknowledgedBy?, resolvedBy?, eventIds:[eventId...] }correlation_runtime_throttle_events{ tenantId, correlationKey, occurredAt } // short-lived, also cached in Valkeyescalation_states{ tenantId, policyId, incidentId?, correlationId, currentStep, repeatIteration, status:"active|acknowledged|resolved|expired", startedAt, nextEscalationAt, acknowledgedAt?, acknowledgedBy?, metadata }correlationIdis the durable lookup key for the live string incident id used by the runtime engine.metadatacarries the runtime-only fields that do not fit the canonical columns yet:stateId, externalpolicyId,levelStartedAt, terminal runtime status (stopped|exhausted),stoppedAt,stoppedReason, and the full escalationhistory.
Indexes (verified against 001_v1_notify_baseline.sql): rules by {tenant_id} and {tenant_id, enabled, priority DESC}; deliveries by {tenant_id}, {tenant_id, status}, {status, next_retry_at} WHERE status IN ('pending','queued'), {channel_id}, {correlation_id} WHERE correlation_id IS NOT NULL, {tenant_id, created_at DESC} (most-recent-first listing — there is no sent_at index), a BRIN index on created_at, and {external_id} WHERE external_id IS NOT NULL; channels by {tenant_id} and {tenant_id, channel_type}; templates by {tenant_id}. The ack-bridge resolver path {channel_id, external_id} WHERE external_id IS NOT NULL (partial composite, partition-propagated) is created at baseline line 1225.
7.1 Reporting incident timeline state machine
IncidentReportTimelineService is the shared service contract for regulator incident reporting cadences. NIS2, CRA, and DORA are registered by default. NIS2 has three transmission milestones:
| Milestone | State after send | Deadline | Ledger event kind | Required evidence keys |
|---|---|---|---|---|
early_warning | EARLY_WARNING_SENT | opened event + 24h | nis2.incident.reported | incident_opened_event_ref, impact_summary_ref |
notification | NOTIFICATION_SENT | opened event + 72h | nis2.incident.reported | incident_classified_event_ref, severity_classification_ref, operator_approval_ref |
final_report | FINAL_REPORT_SENT | opened event + 1 month | nis2.incident.reported | final_report_bundle_ref, early_warning_payload_hash, notification_payload_hash, operator_approval_ref |
The same registry path registers DORA through CreateDoraRegime(initialReportOffset) with the default initial report offset set to 4 hours, followed by 72-hour intermediate and 1-month final report milestones:
| Milestone | State after send | Deadline | Ledger event kind | Required evidence keys |
|---|---|---|---|---|
initial_report | EARLY_WARNING_SENT | opened event + 4h | dora.incident.reported | incident_opened_event_ref, dora_major_incident_classification_ref |
intermediate_report | NOTIFICATION_SENT | opened event + 72h | dora.incident.reported | incident_classified_event_ref, intermediate_report_bundle_ref, operator_approval_ref |
final_report | FINAL_REPORT_SENT | opened event + 1 month | dora.incident.reported | final_report_bundle_ref, initial_report_payload_hash, intermediate_report_payload_hash, operator_approval_ref |
CRA Article 14 reporting uses the same registry and starts its countdown from the cra.incident.classified ledger event so only CRA-reportable incidents enter the cadence:
| Milestone | State after send | Deadline | Ledger event kind | Required evidence keys |
|---|---|---|---|---|
early_warning | EARLY_WARNING_SENT | classified event + 24h | cra.incident.reported | incident_classified_event_ref, exploitation_evidence_ref |
vulnerability_notification | NOTIFICATION_SENT | classified event + 72h | cra.incident.reported | incident_classified_event_ref, vulnerability_notification_bundle_ref, operator_approval_ref |
final_report | FINAL_REPORT_SENT | classified event + 14d | cra.incident.reported | final_report_bundle_ref, early_warning_payload_hash, vulnerability_notification_payload_hash, operator_approval_ref |
Timeline inputs are expected to come from HLC-ordered ledger events. OpenedAt, ClassifiedAt, SentAt, and ClosedAt are supplied by the caller; TimeProvider is used only for evaluation of deadline status. Classification records the regime-specific classified ledger event kind/ref plus optional HLC value without advancing the reporting state. Each transmission records signer, normalized sha256:<hex> payload hash, source ledger event ref, optional HLC value, and sorted evidence refs. The service computes a deterministic ReplayHash from normalized timeline contents, including opened/classified/closed ledger refs, so frozen inputs produce stable replay evidence.
Late transmission after a deadline is rejected unless an operator override is supplied with actor, reason, timestamp, and optional ticket ref. Evaluation returns pending transmission schedules and a SEV-1 alert for the active milestone once 75 percent of the deadline has elapsed, or when the milestone is missed.
Timeline state is persisted in notify.incident_report_timeline_states through the Notify Postgres startup migration path. The row stores the normalized timeline JSON, current milestone deadline, deterministic ReplayHash, and ledger/audit refs extracted from the existing state-machine surface so a restarted host can reload the same shared NIS2 / CRA / DORA timeline without recalculating deadlines from process-local memory.
Nis2IncidentTimelineLedgerHandoffService is the Notify-owned handoff for the local Findings NIS2 ledger emitter. It opens, classifies, reports, and closes NIS2 incident timelines by calling INis2IncidentLedgerEventEmitter with caller-supplied event IDs/timestamps and then saving the Notify timeline only after the Findings append returns Success or Idempotent. Reported milestones carry the Notify notification timestamp, signer ref, authority/CSIRT intake ref, sha256:<hex> payload hash, evidence bundle ref, reachability subgraph refs, and VEX consensus refs into the nis2.incident.reported payload. The service does not synthesize ledger success: validation or conflict results fail the handoff before timeline persistence.
StellaOps.Notify.Connectors.Dora.DoraMajorIncidentOutboundChannel is the DORA major-incident handoff adapter. It encodes the pinned XBRL/iXBRL/envelope package, DSSE-signs the envelope bytes, writes the report artifacts plus a handoff manifest to an operator-controlled filesystem directory, and emits structured audit-event models for the caller to persist. Auto-submit remains default-off and is evaluated separately from the filesystem drop; production callers provide a normalized authorization snapshot derived from Authority’s GET /api/v1/tenants/{tenantId}/submission-authorizations?purpose=dora-incident&channelId=... projection. The connector does not call Authority directly. It accepts only the snapshot plus caller-supplied approval metadata, then fails closed unless auto-submit is enabled, an active Authority approver has submit or autoSubmitApproval, the approval scope matches tenant/channel/incident/report/variant, and the auth time is inside the default 5-minute fresh-auth window. The older channel-local notify.channel.dora.authorizedApprovers path remains available only as an offline deterministic unit-test fixture path.
StellaOps.Notify.Connectors.NCS.Nis2CsirtOutboundChannel is the NIS2 Article 23 CSIRT/NCA handoff adapter. It builds the local nis2-incident-report-envelope.v1 package, signs it through the SignerPipeline boundary, and produces a Notify delivery-ledger entry plus an audit-event model. The WebService operator route POST /api/v1/regulatory/nis2/incidents/{incidentId}/csirt-submissions invokes this adapter from a classified NIS2 timeline, persists the resulting Notify delivery row, and records the matching reporting milestone. DE, FR, and NL are represented as deterministic local portal stubs with operator-configured endpoints and blocked live-contract evidence. The generic signed-JSON webhook fallback is deliverable offline; PGP-email fallback prepares a deterministic manifest and fails closed until an approved local OpenPGP primitive is available through the Notify/crypto boundary.
8) External APIs (WebService)
StellaOps.Notify.WebService serves two route families on the same host (verified against src/Notify/StellaOps.Notify.WebService/Program.cs and Endpoints/NotifyApiEndpoints.cs):
- Core toolkit surface —
/api/v1/notify(configurable vianotify:api:basePath, default/api/v1/notify). Covers the reusable toolkit primitives: rules, channels, templates, deliveries, digests, locks, audit, and delivery stats. These handlers live inline inProgram.cs(apiGroup). - Notifications Studio surface —
/api/v2/notify(hard-coded group inMapNotifyApiV2, merged in from the former Notifier WebService). Covers rules, templates, incidents, the in-app inbox (/api/v2/notify/inbox*), plus the admin/enterprise endpoints mapped by the otherMap*Endpointscalls (simulation, quiet-hours, throttles, operator overrides, escalation, on-call, storm-breaker, localization, fallback, security, observability, reporting timelines, assurance profiles). Both families are actively served and production; v2 is not deprecated.
Scopes (Authority OpToks). Authorization uses three core scopes plus an escalation scope — there is no notify.read/notify.write pair:
| Scope | Policy constant | Grants |
|---|---|---|
notify.viewer | NotifyViewer / NotifyPolicies.Viewer | read-only: list/get channels, rules, templates, deliveries, digests, stats, observability |
notify.operator | NotifyOperator / NotifyPolicies.Operator | write: create/update/delete rules, channels, templates, deliveries, digests, locks, audit entries, test-send, simulation, ack-token issue/verify |
notify.admin | NotifyAdmin / NotifyPolicies.Admin | administrative: security config, signing-key rotation, tenant-isolation grants, retention. Implicitly satisfies operator/viewer checks. |
notify.escalate | NotifyEscalate | escalation actions: start/escalate/stop incidents, manage escalation policies and on-call schedules. Satisfied by operator/admin. |
Scope names are configurable under notify:authority:{viewerScope,operatorScope,adminScope} (defaults above) and are wired by AddStellaOpsScopePolicy in Program.cs. Scope policy constants are defined in src/Notify/StellaOps.Notify.WebService/Constants/NotifierPolicies.cs (v2 surface) and Security/NotifyPolicies.cs (v1 surface).
REST calls that touch tenant data require the validated stellaops:tenant claim (matching the canonical tenantId stored in PostgreSQL). Gateway-forwarded tenant headers may still appear as envelope context, but Notify handlers do not use raw tenant headers as a data-isolation source. Payloads on the core surface are normalised via NotifySchemaMigration before persistence to guarantee schema version pinning.
Authentication is enforced through Authority resource-server OpTok validation when notify:authority:enabled=true (the production default). When Authority is disabled for offline/dev, a symmetric developmentSigningKey validates JWTs; notify:authority:allowAnonymousFallback=true (Development/Testing only) installs an allow-all handler. The tenant contract is identical in every mode: tenant scope comes from the validated stellaops:tenant claim, and any request body or query tenant must match it.
Service configuration exposes notify:authority:* keys (issuer, metadata address, audiences — default notify plus the shared stellaops audience, signing key, scope names) so operators can wire the Authority JWKS or (in dev) a symmetric test key. Postgres:Notify:* / notify:postgres:notify:* keys cover PostgreSQL connection/schema overrides, and notify:queue:* configures the event queue transport (see §13).
Runtime configuration validation. NotifyRuntimeConfigurationValidator (in src/Notify/StellaOps.Notify.WebService/Hosting/) implements the shared IRuntimeConfigurationValidatorcontract introduced in Sprint 20260513_018 URCV-004. It refuses to bring the Notify WebService up when notify:storage:driver=memory or notify:authority:allowAnonymousFallback=true are configured outside Development / Testing. The validator runs at host StartAsync via the shared RuntimeConfigurationValidationHostedService, replacing the prior inline static check that lived in Program.cs.
Internal tooling can hit /internal/notify/<entity>/normalize to upgrade legacy JSON and return canonical output used in the docs fixtures.
Regulatory reporting timelines
GET /api/v1/regulatory/reporting-timelines?regime=nis2&active=true&limit=100&cursor=...-> list envelope{ schemaVersion, items, cursor, sourceOfTruth }for persisted incident-report timeline state.GET /api/v1/regulatory/reporting-timelines/countdown-summary?regime=dora&limit=6-> lightweight cockpit envelope{ schemaVersion, observedAt, items, hasMore, sourceOfTruth }.regimeis optional but, when present, must be one of the registered NIS2, DORA, or CRA timeline regimes. The default item cap is 6 and the hard maximum is 12.- Countdown items contain only the current persisted milestone (
regime,incidentId,timelineState,milestone,deadline, evaluateddeadlineStatus, signedsecondsRemaining,updatedAt, and adetailsHrefback to the full Notify timeline query). They intentionally omit milestone history, transmissions, evidence, alerts, and incident-dashboard composition. - The countdown read includes active rows with a persisted current deadline only, uses the existing
(tenant_id, current_deadline)partial index, and preserves the full query’s deterministic deadline/opened/incident/regime order.hasMore=truereports that the cap hid later clocks; the endpoint never relabels a partial list as a complete incident total. observedAt,deadlineStatus, andsecondsRemaininguse one injectedTimeProviderobservation. Regulatory offsets are not copied or recalculated in the projection: each deadline comes from the persisted timeline created by the registered NIS2, DORA, or CRA regime definition.- Tenant scope is resolved from the authenticated StellaOps tenant claim via
StellaOpsTenantResolver; raw tenant headers are ignored as a tenancy source. - Query filters:
regime,incidentId,evidenceRef, comma-delimitedstate,active,limit,cursor.evidenceRefmatches persisted transmission evidence ref values such as a production evidence bundle ref or Notify delivery ref so compliance reviewers can start from an evidence pack pointer instead of a synthetic incident id. Whenstateis omitted,active=trueexcludes closed timelines by default. - Ordering is deterministic: current milestone deadline ascending, opened time ascending, incident id ascending, regime ascending. Opaque cursors are base64url offset tokens.
- Each item includes
regime,incidentId,state, currentmilestone, currentdeadlineanddeadlineStatus, replay hash, milestone list, transmission list, payload hashes, signer refs, evidence refs, ledger refs, audit event ids, SEV-1 alerts, andupdatedAt. - Focused proof lives in
src/Notify/__Tests/StellaOps.Notify.WebService.Tests/ReportingTimelineEndpointsTests.csandsrc/Notify/__Tests/StellaOps.Notify.Persistence.Tests/IncidentReportTimelineStateRepositoryTests.cs. The countdown coverage verifies bounds, scope, fixed-clock values, lightweight response shape, persisted current-deadline filtering, cross-regime order, and tenant isolation.
Assurance reporting timeline profiles
GET /api/v1/assurance/reporting-timeline-profiles?frameworkId=nis2-> list envelope{ schemaVersion, items, sourceOfTruth }for pack profile metadata.GET /api/v1/assurance/reporting-timeline-profiles/{timelineProfileId}-> single profile such asnis2.article23.incident,dora.article19.incident, orcra.article14.incident.GET /api/v1/assurance/reporting-timeline-profiles/{timelineProfileId}/readiness-> redacted readiness response withoperatorHandoffReady,autoSubmitReady, stable reason codes, andsecretValuesExposed=false.- These read-only profile routes accept any of
notify.viewer,policy:read, orpolicy:audit; readiness always resolves tenant scope from the authenticated tenant claim rather than a forwarded tenant header. GET /api/v1/qa/fixtures/advanced-assurance-golden/timeline-readiness?evidenceRef=cas://evidence-packs/...-> local QA-only readiness view for the golden evidence pack across NIS2, DORA, and CRA. It proves whether Notify has timeline rows and required milestone evidence for that evidence ref, but keepsregulatorSubmissionClaimed=false,livePublicationClaimed=false, andlegalComplianceClaimed=false.- Profiles include
runtimeRegimeIdandtimelineSource=IncidentReportTimelineServiceto make the reuse boundary explicit. NIS2 and DORA remain customer/operator-owned with claim boundaryoperator-support. CRA Article 14 for Stella product-security usesmanufacturer-self; customer-manufacturer support must not reuse Stella manufacturer identity. - Readiness settings are descriptors over existing setup state: CSIRT destinations, public PGP recipient keys, auto-submit enablement, and operator approvals are tenant/operator choices; webhook signing key references and handoff publication roots are sealed environment configuration unless a deployment deliberately delegates the handoff target to tenant setup.
- Focused proof lives in
src/Notify/__Tests/StellaOps.Notify.WebService.Tests/AssuranceReportingProfileRegistryTests.csandsrc/Notify/__Tests/StellaOps.Notify.WebService.Tests/AssuranceReportingProfileEndpointsTests.cs.
Channels
POST /channels|GET /channels|GET /channels/{id}|PATCH /channels/{id}|DELETE /channels/{id}POST /channels/{id}/test→ send sample message (no rule evaluation); returns202 Acceptedwith rendered preview + metadata (base keys:channelType,target,previewProvider,traceId+ connector-specific entries); governed byapi.rateLimits:testSend.
GET /channels/{id}/health→ connector self‑check (returns redacted metadata: secret refs hashed, sensitive config keys masked, fallbacks noted viateams.fallbackText/teams.validation.*)Rules
POST /rules|GET /rules|GET /rules/{id}|PATCH /rules/{id}|DELETE /rules/{id}POST /rules/{id}/test→ dry‑run rule against a sample event (no delivery unless--send)
Deliveries
POST /deliveries→ ingest worker delivery state (idempotent viadeliveryId).GET /deliveries?since=...&status=...&limit=...→ list envelope{ items, count, continuationToken }(most recent first); base metadata keys match the test-send response (channelType,target,previewProvider,traceId); rate-limited viaapi.rateLimits.deliveryHistory. Seedocs/modules/notify/resources/samples/notify-delivery-list-response.sample.json.GET /deliveries/{id}→ detail (redacted body + metadata)POST /deliveries/{id}/retry→ force retry (admin, future sprint)
Admin
GET /delivery/stats(per-tenant success/fail/pending counts over a 24h window).GET /healthz(liveness, always 200 while the process is up) andGET /readyz(readiness; 503 with diagnostics until plug-in warmup completes) — both mapped at the host root, not under the API base path, andAllowAnonymous.POST /locks/acquire|POST /locks/release– worker coordination primitives (short TTL).POST /digests|GET /digests/{actionKey}|DELETE /digests/{actionKey}– manage open digest windows.POST /audit– append a structured audit entry.GET /auditnow proxies Timeline’s unified/api/v1/audit/events?modules=notify(cursor pagination;502if Timeline is unreachable — no local fallback) and is deprecated (sunset 2027-10-19) with aDeprecation/Sunsetheader.GET /api/v1/notify/anomaly-subscriptions|POST …|PUT …/{id}|DELETE …/{id}— tenant-scoped CRUD for Timeline anomaly notification subscriptions (notify.adminscope;Endpoints/AnomalySubscriptionEndpoints.cs). Subscriptions bind an anomalyruleKind(e.g.per_actor_volume_zscore,new_actor_ip,escalation_chain,off_hours_policy,failed_auth_spike,privilege_escalation) and minimumseverity(info|warning|error|critical) to a Notify channel. This is the live forcing function for the “anomaly suppression / noisy-rule routing” theme listed in §20 (the §20 entry tracks the further auto-pause / learned-threshold work, which is not yet implemented).
Note: the verbs above are listed relative to the core base path (
/api/v1/notify). The merged/api/v2/notifyfamily re-exposes rules/templates/incidents and adds the in-app inbox; admin/enterprise verbs (simulation, quiet-hours, throttles, overrides, escalation, on-call, storm-breaker, localization, fallback, security, observability, reporting-timeline, assurance-profile) are mapped under their own/api/v2/*prefixes (e.g./api/v2/escalation-policies,/api/v2/oncall-schedules,/api/v2/security,/api/v2/ack), not under/api/v2/notify.
8.1 Ack tokens & escalation workflows
To support one-click acknowledgements from chat/email, the Notify WebService mints DSSE ack tokens via Authority. (Status note: the public route names below — /notify/ack-tokens/* — are the contract design; the merged WebService currently implements token sign/verify under /api/v2/security/* (with /keys/rotate for rotation) and ack processing/inbound webhooks under /api/v2/ack*. Treat the /notify/ack-tokens/* paths as the intended Authority-fronted surface rather than verified live routes.)
POST /notify/ack-tokens/issue→ returns a DSSE envelope (payload typeapplication/vnd.stellaops.notify-ack-token+json) describing the tenant, notification/delivery ids, channel, webhook URL, nonce, permitted actions, and TTL. Requiresnotify.operator; requesting escalation requires the caller to holdnotify.escalate(andnotify.adminwhen configured). Issuance enforces the Authority-side webhook allowlist (notifications.webhooks.allowedHosts) before minting tokens.POST /notify/ack-tokens/verify→ verifies the DSSE signature, enforces expiry/tenant/action constraints, and emits audit events (notify.ack.verified,notify.ack.escalated). Scope:notify.operator(+notify.escalatefor escalation).POST /notify/ack-tokens/rotate→ rotates the signing key used for ack tokens, requiresnotify.admin, and emitsnotify.ack.key_rotated/notify.ack.key_rotation_failedaudit events. Operators must supply the new key material (file/KMS/etc. depending onnotifications.ackTokens.keySource); Authority updates JWKS entries withuse: "notify-ack"and retires the previous key.POST /internal/notifications/ack-tokens/rotate→ legacy bootstrap path (API-key protected) retained for air-gapped initial provisioning; it forwards to the same rotation pipeline as the public endpoint.
Authority signs ack tokens using keys configured under notifications.ackTokens. Public JWKS responses expose these keys with use: "notify-ack" and status: active|retired, enabling offline verification by the worker/UI/CLI.
Inbound PagerDuty and OpsGenie acknowledgement webhooks must resolve provider identifiers from durable delivery state (externalId plus incident metadata), not from process-local runtime maps. Restart-survival is a required property of the non-testing host composition.
8.1 Inbound external-platform webhooks (per-tenant signature gate)
External-platform acknowledgement webhooks (PagerDuty, OpsGenie) are gated by HMAC signature validation using a tenant-scoped secret resolved from the URL path segment. The legacy tenantless routes (/api/v2/ack/webhook/pagerduty, /api/v2/ack/webhook/opsgenie) have been removed (Sprint 20260430-005, audit finding A5); operators must reconfigure their PagerDuty / OpsGenie integrations to point at the per-tenant routes:
| Route | Method | Auth |
|---|---|---|
/api/v2/ack/webhook/pagerduty/{tenantId} | POST | platform HMAC signature (no bearer token) |
/api/v2/ack/webhook/opsgenie/{tenantId} | POST | platform HMAC signature + X-OpsGenie-Webhook-Timestamp |
Signature contract.
- PagerDuty — header
X-PagerDuty-Signature: v1=<hex>[,v1=<hex>...]is HMAC-SHA256 over the raw request body using the per-tenant signing secret. Multiplev1=entries are accepted (rotation); the request is allowed if any one matches. - OpsGenie — header
X-OpsGenie-Webhook-Signature(or the legacyX-OpsGenie-Signature) carries the HMAC-SHA256 (raw hex, optionally prefixed withsha256=) over the canonical string"{timestamp}:{body}". The accompanying headerX-OpsGenie-Webhook-Timestampcarries the Unix-epoch milliseconds-since-epoch timestamp; requests outside theNotify:Inbound:ReplayWindow(default five minutes) are rejected.
All HMAC operations route through StellaOps.Cryptography.IHmacAlgorithm, so regional crypto plugins (FIPS, GOST, SM, eIDAS) substitute the implementation transparently.
Tenant resolution. The validator reads the {tenantId} route segment only — never a header — and resolves the tenant against the configured Notify:Inbound:Tenants block. Multi-tenant deployments must configure one secret per tenant under Notify:Inbound:Tenants:<tenantId>:PagerDuty:Secret and Notify:Inbound:Tenants:<tenantId>:OpsGenie:Secret; values are SecretRef-style strings that the platform secret resolver expands (literal value, secret://, vault://).
Single-tenant fallback (audit-loud escape hatch). Legacy single-tenant deployments may enable Notify:Inbound:SingleTenantFallback:Enabled = true plus …:TenantId and …:PagerDuty:Secret / …:OpsGenie:Secret. Every use of the fallback emits a LogLevel.Critical audit event so accidental multi-tenant drift is loud. Multi-tenant deployments must leave this disabled.
Failure reasons (stable tokens). The filter returns 401 Unauthorized (or 404 Not Found for unknown tenant) with a problem-detail body whose reason field carries one of: tenant_unknown, tenant_missing, signature_missing, signature_invalid, timestamp_missing, timestamp_invalid, timestamp_too_old, timestamp_in_future, webhook_not_configured, platform_unsupported, ip_not_allowed. No secret material is ever reflected to the caller.
IP allowlist (defence-in-depth). Operators may configure a CIDR allow-list per platform via Notify:Inbound:PagerDutyAllowedSourceCidrs / Notify:Inbound:OpsGenieAllowedSourceCidrs. Empty list = allow-all. When non-empty, requests whose RemoteIpAddress is outside the list are rejected with reason = "ip_not_allowed".
Implementation lives in src/Notify/__Libraries/StellaOps.Notify.WebHooks.Inbound/ (filter + per-platform validators + secret resolver). The route registration is in src/Notify/StellaOps.Notify.WebService/Endpoints/EscalationEndpoints.cs.
Ingestion: workers do not expose public ingestion; they subscribe to the internal bus. The WebService producer bridge POST /api/v1/events/{producer} (in Extensions/GenericEventEndpointExtensions.cs, normally notify.operator scoped) is limited to allowlisted producer slugs and their required kind prefixes. The current allowlist includes concelier (concelier.*), attestor (attestor.*), findings (findings.*), nis2 (nis2.*), risk (risk.*), platform (platform.*), plus the explicitly any-kind scanner and release-orchestrator slugs. The literal /api/v1/events/platform route also accepts crypto:admin, crypto:profile:admin, or ops.admin because provider changes are composed inside those authenticated mutation requests; this exception does not authorize other producer slugs. The body tenant must equal the validated stellaops:tenant claim and eventId must be a non-empty GUID. Generic routes require the timestamp within ±5 minutes of the server clock (clock_skew). The literal Platform route preserves the durable outbox’s original occurrence time, accepts an old timestamp, and rejects a timestamp more than five minutes in the future; idempotent event admission remains mandatory. The findings.production_alert.evidence_seeded kind is handled specially (writes a queued durable delivery row); all other allowlisted kinds publish into the Notify event queue (notify:events stream, partitioned by tenant). Missing queue configuration is a terminal readiness failure (503 notify_event_queue_unavailable) rather than a best-effort/no-op acceptance. (Optional /events/test remains integration-test-only.)
9) Delivery pipeline (worker)
[Event bus] → [Ingestor] → [RuleMatcher] → [Throttle/Dedupe] → [DigestCoalescer] → [Renderer] → [Connector] → [Result]
└────────→ [DeliveryStore]
- Ingestor: N consumers with per‑key ordering (key = tenant|digest|namespace).
- RuleMatcher: loads active rules snapshot for tenant into memory; vectorized predicate check.
- Throttle/Dedupe: consult Valkey plus PostgreSQL
notify.correlation_runtime_throttle_events; if hit → recordstatus=throttled. - DigestCoalescer: append to open digest window or flush when timer expires.
- Renderer: select template (channel+locale), inject variables, enforce length limits, compute
bodyHash. - Connector: send; handle provider‑specific rate limits and backoffs;
maxAttemptswith exponential jitter; overflow → DLQ (dead‑letter topic) + UI surfacing.
Idempotency: per action idempotency key stored in Valkey (TTL = throttle window or digest window). Connectors also respect provider idempotency where available (e.g., Slack client_msg_id).
10) Reliability & rate controls
- Per‑tenant RPM caps (default 600/min) + per‑channel concurrency (Slack 1–4, Teams 1–2, Email 8–32 based on relay).
- Backoff map: Slack 429 → respect
Retry‑After; SMTP 4xx → retry; 5xx → retry with jitter; permanent rejects → drop with status recorded. - DLQ: NATS/Valkey stream
notify.dlqwith{event, rule, action, error}for operator inspection; UI shows DLQ items.
11) Security & privacy
- AuthZ: all APIs require Authority OpToks; actions scoped by tenant.
- Secrets — outbound channel credentials (SPRINT_20260703_002). A channel’s
config.secretRefis a real, resolved control (previously a dead field no backend expanded). At delivery time the live Notifier worker runsINotifyChannelSecretResolver(StellaOps.Notify.Engine) before any adapter sends: it parses each secret-bearing slot and, when the value is a resolvable reference (builtin:///vault:///openbao:///authref:///file:///base64:/plain:), resolves it through the unifiedISecretProvider(ADR-031/032) and injects the plaintext in-memory only into the field the connector reads (SMTP password ←secretRefplus ephemeralproperties["password"]for Email;properties["apiKey"]for OpsGenie;properties["routingKey"]for PagerDuty;properties["hmacSecret"]for Webhook;properties["botToken"]for Telegram). A plain literal or alegacy://notify/channels/{id}marker parses as “not a reference” and stays inline (back-compat). Fail-closed: an unresolvable reference (provider returns null / throws on a backend error) aborts the delivery — it is marked failed and never sent unauthenticated or with the reference echoed as the credential; theEmailChannelAdapteralso refuses to use an unresolvedbuiltin/vault/openbao/authrefreference as the literal SMTP password. The resolver never logs the resolved value. The worker host wiresAddSecretProviderKeySources+AddRoutingSecretProvider+ a fail-closed startup probe (mirrors ReleaseOrchestrator / Integrations); it owns no credential-store DB, sobuiltin://stored secrets are unsupported there (external + inline references resolve). - Secrets at rest — sealed (SPRINT_20260703_002 N3). The
StellaOps.Notify.WebServicehost wires the durable builtin secret store (AddSecretProviderKeySources+AddDurableBuiltinSecretStoreovercrypto.secret_store+AddRoutingSecretProvider, plus a bareNpgsqlDataSourcebecauseNotifyDataSourceis a customDataSourceBase; fail-closed startup probe reused from the worker). Two seams remove cleartext:- Write path.
POST /api/v1/notify/channelsrunsINotifyChannelSecretSealer(Security/NotifyChannelSecretSealer.cs) on the channel model before it is serialised. Every secret-bearing slot that is not already a reference — thesecretRefcontrol plus the closed property-key set (password/hmacSecret/apiKey/routingKey/botToken) — is sealed viaISecretProvider.SealAsyncand rewritten to the returnedbuiltin://…pointer, so both theconfigJSONB and the whole-channelmetadataJSONB (whichToNotifyChannelreads first) store only a pointer. A value already using abuiltin:///vault:///openbao:///authref:///file://reference or thelegacy://marker is left verbatim (idempotent); inlineplain:/base64:and bare literals are sealed. The complete property-key set is applied conservatively to every channel, not only to the channel type that normally consumes a key, so a custom or mistyped row cannot use a recognized credential key as a plaintext escape hatch. - Existing rows.
NotifyChannelSecretSealMigrationService(a startupIHostedService, ordered after the schema migrations) seals pre-N3 plaintext in place innotify.channels.config+notify.channels.metadata+notify.webhook_security_configs.secret_key. It is forward-only, idempotent (skips already-referenced values) and fail-closed (a seal failure crash-loops the host rather than serving a half-sealed state). - Sweep (must return 0 rows). After sealing, no secret slot holds cleartext:
WITH secret_values AS ( SELECT id::text AS row_id, 'config.secretRef' AS slot, config->>'secretRef' AS val FROM notify.channels UNION ALL SELECT id::text, 'config.password', config->'properties'->>'password' FROM notify.channels UNION ALL SELECT id::text, 'config.hmacSecret', config->'properties'->>'hmacSecret' FROM notify.channels UNION ALL SELECT id::text, 'config.apiKey', config->'properties'->>'apiKey' FROM notify.channels UNION ALL SELECT id::text, 'config.routingKey', config->'properties'->>'routingKey' FROM notify.channels UNION ALL SELECT id::text, 'config.botToken', config->'properties'->>'botToken' FROM notify.channels UNION ALL SELECT id::text, 'metadata.secretRef', metadata->'config'->>'secretRef' FROM notify.channels UNION ALL SELECT id::text, 'metadata.password', metadata->'config'->'properties'->>'password' FROM notify.channels UNION ALL SELECT id::text, 'metadata.hmacSecret',metadata->'config'->'properties'->>'hmacSecret' FROM notify.channels UNION ALL SELECT id::text, 'metadata.apiKey', metadata->'config'->'properties'->>'apiKey' FROM notify.channels UNION ALL SELECT id::text, 'metadata.routingKey',metadata->'config'->'properties'->>'routingKey' FROM notify.channels UNION ALL SELECT id::text, 'metadata.botToken', metadata->'config'->'properties'->>'botToken' FROM notify.channels UNION ALL SELECT id::text, 'webhook.secret_key', secret_key FROM notify.webhook_security_configs ) SELECT * FROM secret_values WHERE val IS NOT NULL AND val <> '' AND val NOT LIKE 'builtin://%' AND val NOT LIKE 'vault://%' AND val NOT LIKE 'openbao://%' AND val NOT LIKE 'authref://%' AND val NOT LIKE 'file://%' AND val NOT LIKE 'legacy://%'; - Resolution at delivery (shipped). Sealing writes
builtin://pointers intocrypto.secret_store. Thenotifier-worker— which resolves those pointers at delivery time — wires the durable store over the same platform Postgres:AddDurableBuiltinSecretStore("Notifier")(src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Program.cs:196, over a bareNpgsqlDataSourcebuilt fromnotifier:storage:postgres), which replaced the earlier no-DB floor (NotifierNoDatabaseCredentialStore). A newly-sealed channel therefore resolves without a coordinated follow-up deploy. Fail-closed is preserved bySecretProviderStartupProbe: a missing master key crash-loops the worker rather than degrading to plaintext. The worker is the deployed delivery host (devops/compose/docker-compose.stella-services.yml:2807,stellaops-notifier-worker); the Notifier WebService is commented out (merged intonotify-web). - Standalone Notifier admin host (HARD-1). When
StellaOps.Notifier.WebServiceis run outside the merged deployment, its production composition now wires the same durable builtin/Vault routing provider beforeAddPostgresNotifyAdminRuntimeServices. Therefore its webhook register/update path seals cleartext beforenotify.webhook_security_configspersistence, validation resolves the stored reference, and a missing KEK or mismatched backend fails startup. The explicitTestingenvironment remains in-memory. Production startup composition and a real-PostgreSQL round trip are covered byStartupDependencyWiringTestsandNotifierBuiltinSecretSealingTests. - Closed custody surface (HARD-4). A source sweep of the mounted channel connectors identifies
secretRef,properties.password,properties.hmacSecret,properties.apiKey,properties.routingKey, andproperties.botTokenas the complete persisted channel-credential slot set; inbound webhook HMAC keys additionally live innotify.webhook_security_configs.secret_key. The SMTP defaults bound from process configuration are not channel persistence. The write sealer, startup migration, resolver, tests, and SQL sweep now cover this same closed set.
- Write path.
- Egress TLS: validate SSL; pin domains per channel config; optional CA bundle override for on‑prem SMTP.
- Webhook signing: HMAC or Ed25519 signatures in
X-StellaOps-Signature+ replay‑window timestamp; include canonical body hash in header. Admin reads display stored references as a scheme-only label such asbuiltin://****; the public raw-key signing seam rejects every recognized reference and requires callers to resolve first. - Redaction: deliveries store hashes of bodies, not full payloads for chat/email to minimize PII retention (configurable).
- Quiet hours: per tenant (e.g., 22:00–06:00) route high‑sev only; defer others to digests.
- Loop prevention: Webhook target allowlist + event origin tags; do not ingest own webhooks.
12) Observability (Prometheus + OTEL)
notify.events_consumed_total{kind}notify.rules_matched_total{ruleId}notify.throttled_total{reason}notify.digest_coalesced_total{window}notify.sent_total{channel}/notify.failed_total{channel,code}notify.delivery_latency_seconds{channel}(end‑to‑end)- Tracing: spans
ingest,match,render,send; correlation id =eventId.
- Runbook + dashboard stub (offline import):
operations/observability.md,operations/dashboards/notify-observability.json(to be populated after next demo).
SLO targets
- Event→delivery p95 ≤ 30–60 s under nominal load.
- Failure rate p95 < 0.5% per hour (excluding provider outages).
- Duplicate rate ≈ 0 (idempotency working).
13) Configuration (YAML)
The authoritative WebService config keys are bound from the mounted runtime file etc/notify/notify.yaml inside the container content root (templates live as flat files such as etc/notify.yaml.sample plus notify.dev/stage/prod/airgap.yaml). Environment variables with the NOTIFY_ prefix override that file. The shape below reflects the actual NotifyWebServiceOptions (notify:storage, notify:postgres:notify, notify:authority, notify:api, notify:plugins, notify:telemetry) plus the event-queue options bound separately under notify:queue (NotifyEventQueueOptions; an optional notify:deliveryQueue mirrors it for the worker delivery stream). The limits/digests/quietHours/webhooks blocks below are illustrative pipeline tuning, not all part of NotifyWebServiceOptions:
storage:
driver: postgres # PostgreSQL-only after cutover; "memory" rejected outside Dev/Testing
postgres:
notify:
connectionString: "Host=postgres;Port=5432;Database=stellaops_notify;Username=stellaops;Password=stellaops;Pooling=true"
schemaName: notify
commandTimeoutSeconds: 30
authority:
enabled: true
issuer: "https://authority.stella-ops.local"
metadataAddress: "https://authority.stella-ops.local/.well-known/openid-configuration"
requireHttpsMetadata: true
allowAnonymousFallback: false # Dev/Testing only when true
audiences: ["notify", "stellaops"]
viewerScope: notify.viewer
operatorScope: notify.operator
adminScope: notify.admin
api:
basePath: "/api/v1/notify" # core toolkit surface; /api/v2/notify is mapped separately
internalBasePath: "/internal/notify"
tenantHeader: "X-StellaOps-TenantId" # gateway envelope context; handlers scope data by stellaops:tenant claim
notify:
queue:
transport: Redis # NotifyQueueTransportKind: Redis (Valkey via redis:// protocol) | Nats
redis:
connectionString: "redis://valkey:6379" # required when transport=Redis (else queue is "unavailable" → 503)
streams:
- { stream: "notify:events", consumerGroup: "notify-workers" }
nats:
url: "nats://nats:4222" # required when transport=Nats
stream: "NOTIFY_EVENTS"
subject: "notify.events"
deadLetterStream: "NOTIFY_EVENTS_DEAD"
# --- illustrative pipeline tuning (not all in NotifyWebServiceOptions) ---
limits:
perTenantRpm: 600
perChannel:
slack: { concurrency: 2 }
teams: { concurrency: 1 }
email: { concurrency: 8 }
webhook: { concurrency: 8 }
digests:
defaultWindow: "1h"
maxItems: 100
quietHours:
enabled: true
window: "22:00-06:00"
minSeverity: "critical"
webhooks:
sign:
method: "ed25519" # or "hmac-sha256"
keyRef: "ref://notify/webhook-sign-key"
14) UI touch‑points
- Notifications → Channels: add Slack/Teams/Email/Webhook/PagerDuty/OpsGenie; run health; rotate secrets.
- Notifications → Rules: create/edit YAML rules with linting; test with sample events; see match rate.
- Notifications → Deliveries: timeline with filters (status, channel, rule); inspect last error; retry.
- Digest preview: shows current window contents and when it will flush.
- Quiet hours: configure per tenant; show overrides.
- DLQ: browse dead‑letters; requeue after fix.
15) Failure modes & responses
| Condition | Behavior |
|---|---|
| Slack 429 / Teams 429 | Respect Retry‑After, backoff with jitter, reduce concurrency |
| SMTP transient 4xx | Retry up to maxAttempts; escalate to DLQ on exhaust |
| Invalid channel secret | Mark channel unhealthy; suppress sends; surface in UI |
| Rule explosion (matches everything) | Safety valve: per‑tenant RPM caps; auto‑pause rule after X drops; UI alert |
| Bus outage | Buffer to local queue (bounded); resume consuming when healthy |
| PostgreSQL slowness | Fall back to Valkey throttles; batch write deliveries; shed low‑priority notifications |
16) Testing matrix
- Unit: matchers, throttle math, digest coalescing, idempotency keys, template rendering edge cases.
- Connectors: provider‑level rate limits, payload size truncation, error mapping.
- Integration: synthetic event storm (10k/min), ensure p95 latency & duplicate rate.
- Security: DPoP/mTLS on APIs; secretRef resolution; webhook signing & replay windows.
- i18n: localized templates render deterministically.
- Chaos: Slack/Teams API flaps; SMTP greylisting; Valkey hiccups; ensure graceful degradation.
17) Sequences (representative)
A) New criticals after Concelier delta (Slack immediate + Email hourly digest)
sequenceDiagram
autonumber
participant SCH as Scheduler
participant NO as Notify.Worker
participant SL as Slack
participant SMTP as Email
SCH->>NO: bus event scheduler.rescan.delta { newCritical:1, digest:sha256:... }
NO->>NO: match rules (Slack immediate; Email hourly digest)
NO->>SL: chat.postMessage (concise)
SL-->>NO: 200 OK
NO->>NO: append to digest window (email:soc)
Note over NO: At window close → render digest email
NO->>SMTP: send email (detailed digest)
SMTP-->>NO: 250 OK
B) Admission deny (Teams card + Webhook)
sequenceDiagram
autonumber
participant ZA as Zastava
participant NO as Notify.Worker
participant TE as Teams
participant WH as Webhook
ZA->>NO: bus event zastava.admission { decision: "deny", reasons: [...] }
NO->>TE: POST adaptive card
TE-->>NO: 200 OK
NO->>WH: POST JSON (signed)
WH-->>NO: 2xx
18) Implementation notes
- Language: .NET 10; minimal API;
System.Text.Jsonwith canonical writer for body hashing; Channels for pipelines. - Bus: Valkey Streams (XGROUP consumers) or NATS JetStream for at‑least‑once with ack; per‑tenant consumer groups to localize backpressure.
- Templates: compile and cache per rule+channel+locale; version with rule
updatedAtto invalidate. - Rules: store raw YAML + parsed AST; validate with schema + static checks (e.g., nonsensical combos).
- Secrets: pluggable secret resolver (Authority Secret proxy, K8s, Vault).
- Rate limiting:
System.Threading.RateLimiting+ per-connector adapters.
19) Air-gapped bootstrap configuration
Air-gapped deployments ship a deterministic Notifier profile inside the Bootstrap Pack. The artefacts live under bootstrap/notify/ after running the Offline Kit builder and include:
notify.yaml— configuration derived frometc/notify.airgap.yaml, pointing to the sealed PostgreSQL/Authority endpoints and loading connectors from the local plug-in directory.notify-web.secret.example— template for the Authority client secret, intended to be renamed tonotify-web.secretbefore deployment.README.md— operator guide (docs/modules/notify/bootstrap-pack.md).
These files are copied automatically by ops/offline-kit/build_offline_kit.py via copy_bootstrap_configs. Operators mount the configuration and secret into the StellaOps.Notify.WebService container (Compose or Offline Kit) to keep sealed-mode roll-outs reproducible. (Notifier WebService was merged into Notify WebService; the notifier.stella-ops.local hostname is now an alias on the notify-web container.)
20) Roadmap (post-v1)
- Jira ticket creation and downstream issue-state synchronization.
- User inbox (in‑app notifications) + mobile push via webhook relay.
- Anomaly suppression: auto‑pause noisy rules with hints (learned thresholds).
- Graph rules: “only notify if not_affected → affected transition at consensus layer”.
- Label enrichment: pluggable taggers (business criticality, data classification) to refine matchers.
