Notify — Architecture (Notifications Toolkit)
Audience: Notify toolkit maintainers and integrators; operators wiring channels, rules, and digests. Hosts:
src/Notify/StellaOps.Notify.WebService(notify-web) andsrc/Notify/StellaOps.Notify.Worker(notify-worker).../notifier/architecture.mdis a retirement pointer for the predecessor module, frozen atsrc/__Obsoleted/Notifier/on 2026-09-08.
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
Historical simulation (NTF-17, 2026-09-09 source change).
POST /api/v2/notify/simulatereads Notify audit events through the existing signed, tenant-bound Timeline query client. It pages within the requested[periodStart, periodEnd)window and acceptsmaxEventsfrom 1 to 10000. The reader recovers the original dotted action and payload from the Timeline event and refuses foreign-tenant, foreign-module, malformed and looping pages. Timeline failure returns502withnotify_history_unavailable; it never becomes an empty successful simulation or a local audit-table fallback. The route retains its viewer-plus-operator authorization requirements. Verification:NotifyHistoricalSimulationTestsexercises the real Notify host, PostgreSQL composition and signed Timeline HTTP client; the source change has not been deployed by this task.Audit result status (NTF-15, source verified against
7f76082d13). Audit emission uses the status declared by an unexecuted HTTP result, including 201/204 and 4xx/5xx responses, for both metadata and severity. An already-started response retains its sent status. This corrects acknowledgements that returned 204 while their audit metadata reported 200.Console native-path repoint (RAR-6, 2026-08-30; source package, deployment unverified).
API_PATH_PREFIXES.notifieruses Notify’s published/api/v2/notifyroot. Operator-override calls use the separatenotifierOverrideskey at/api/v2/overrides, preserving the destination of the more-specific legacy rewrite. Both providers resolve through the configured gateway origin. The core/api/v1/notifyroot, channel-test/preview calls,/api/v2/notify/inbox, audit and regulatory roots remain unchanged. Existing HTTP verbs, bodies and response adapters are unchanged; a native-path repoint is not a contract repair. In particular, the admin override model still differs from the suppression-bypass API (actor,reason, positivedurationMinutes, POST/{id}/revoke, no PUT/DELETE)./api/v2/notify/overridesdeliberately returns501 notify_overrides_not_supportedand must not replace/api/v2/overrides. Template-update and delivery-pagination gaps also remain separate work. Retain the static aliases until the served Console and authenticated reads have been verified; this package neither deletes routes nor deploys the Console. Backend contract baseline:c62a2aacfa453345d80802f0c35a4df79e37405e,NotifyApiEndpoints.cs,NotifyAdminCompatEndpoints.csandOperatorOverrideEndpoints.cs. Re-verify the client roots withrg -n 'notifier:|notifierOverrides:' src/Web/StellaOps.Web/src/app/core/api/api-path-prefixes.tsand current owner declarations withGET /api/openapi/aggregate.Database move and routing are separate gates.
SPRINT_20260722_015records NTF-9 DONE on 2026-08-28. That receipt does not install the historical/api/notify/v1proposal or establish successor-worker delivery. Native endpoint publication carries the current Notify paths without the retired wildcard. Follow the current-state section of the routing runbook, not its historical swap commands.Consumer build seam ruled (Q-11 + Q-19 + Q-24, 2026-08-27): two verified-closed Notify-owned projects. External services stop compiling Notify implementation. The ruled seam is one BCL-only wire-contract project (measured against what consumers actually import and explicitly not the broad
Notify.Modelsdomain assembly) plus a separate producer-owned, enqueue-onlyStellaOps.Notify.EventClient. The client contains the Redis/NATS enqueue composition needed to preserve serialized bytes, stream/subject, idempotency, partition, attributes and failure behavior; it exposes no lease/claim, delivery-queue, health-check, persistence, host or migration surface. It serves Platform’s backup/NIS2 emitters (Q-19 — the domain-neutralStellaOps.Eventingalternative was rejected for those emitters to preserve ordering/delivery semantics),Scheduler.WorkerandReleaseOrchestrator.WebApi. Corrected 2026-09-04 (NTF-11 census): ExportCenter is not a seam consumer — it holds noNotify.Models/Notify.Queuereference at all, only twoNotify.Connectors.Dora*references in its TESTS csproj. Consumers repoint in their owning sprints under the same seam shape; the Notifier predecessor hosts die at NTF-10. Settlement receipts:SPRINT_20260722_015(Q-11/Q-24) andSPRINT_20260722_026(Q-19).Consumer seam implementation status (2026-09-04, NTF-11): BUILT; no consumer repointed yet.
StellaOps.Notify.ContractsandStellaOps.Notify.EventClientexist insrc/and are registeredcross-service-client-sdkindocs/architecture/build-boundary/ownership-manifest.json.- What moved. The event envelope (
NotifyEvent,NotifyEventScope),NotifyEventKinds,NotifyValidation, the queue record (NotifyQueueEventMessage,NotifyQueueEnqueueResult), the queue field names, the transport kind, the unavailable-queue exception and the transport options moved into the BCL-only contract project. Namespaces are unchanged (StellaOps.Notify.Models/StellaOps.Notify.Queue), so no source consumer changed, and both predecessor assemblies carryTypeForwardedTofor every moved public type so a prebuilt artifact still loads. - What did NOT move. The lease/claim contracts,
INotifyEventQueue’s consumer half,NotifyDeliveryQueueMessage, the delivery queue, the queue health checks and the wholeNotify.Modelsdomain (rules, channels, templates, escalation, schedules) stay in the Notify family. - One producer, one encoder.
StellaOps.Notify.Queuenow COMPOSES the event client for its own enqueue path instead of keeping a second implementation, and both build their wire record throughNotifyEventWireEncoderin the contract assembly. Serialized bytes, stream/subject, idempotency token, partition key, attributes and dedup behaviour are therefore identical by construction rather than by convention. - The one deliberate behaviour difference. The pre-split JetStream publish path created the worker’s durable consumer as a side effect. The enqueue-only client provisions the event and dead-letter STREAMS but not the durable, which is consumer-side; JetStream retains stream messages whether or not a consumer exists, so nothing is lost and the worker creates its own durable on first lease.
NatsNotifyEventQueue— inside the family — still ensures the durable before delegating, so in-family behaviour is unchanged. - Verification.
NotifyExternalSeamConformanceTests(purity/closure/enqueue-only, each rule paired with a control that runs the same detector againstStellaOps.Notify.Queue) andNotifyExternalSeamContractTests(type forwarders and wire parity, against the built assemblies). The contract assembly’sdeps.jsonlists exactly one library — itself, with no dependencies. The current generic ingress fixes the stream and tenant-only partition key and is still not equivalent. Consumers must not copy this transport composition into their own families.
- What moved. The event envelope (
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 are historical contract records, not current deployment instructions. In particular, the legacy frontdoor, ReverseProxy, wildcard and prepared-before-NTF-9 statements are superseded by the RAR-6 routing notes above. They preserve the decisions and tests at their recorded dates.
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; the delivery runtime inStellaOps.Notify.Delivery, composed bynotify-worker, owns the recipient dispatch.Runtime durability cutover (2026-04-16; the wiring described here now lives in
StellaOps.Notify.Delivery). Production wiring 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.Consolidated target connection repoint (prepared 2026-08-23, not deployed). F-NTF9-1 option 1 preserves the existing repository implementation instead of porting the 74-file DAL. Both target roles call
AddConsolidatedNotifyPersistencefirst (canonicalSTELLAOPS_POSTGRES_NOTIFY_CONNECTION, fail-closed, fresh target migrations), thenAddNotifyRuntimeRepositories(data source + repositories only, no legacy migration host), thenUseConsolidatedNotifyLeaseBackedLocks. That last target-only selection is load-bearing: the retained DAL’sLockRepositoryaddresses predecessor-onlynotify.locks, while the target adapter maps exact tenant/resource identity onto P6eventing.leases, passes owner/TTL unchanged, and releases only under an exact holder predicate. The compatibility API provides mutual exclusion but cannot expose a fencing handle/transaction; true destination fencing still requires directILeaseManager.EnsureFencedAsync. The predecessor keepsAddNotifyPersistenceand its table semantics for rollback until NTF-10. Source and Compose are prepared; the running estate remains unchanged until the NTF-9 maintenance-window recreate/copy/soak/forcing proof.Notify Web doctor adoption (prepared 2026-08-24, default-off, not deployed). Now that
notify-webresolves only the family’s own database, it hosts the same fivedoctor-check/v1checks asnotify-workerand protectsGET /doctor/notify-web/checkswith the estateops.healthcapability rather than a Notify domain scope. The web role owns the default-off Platform registrar. During the NTF-9 activation it rides the configured token client installed by the tenant-catalog replica; a different declared Doctor client id fails before startup, while a rehearsal with no shared host identity must provide non-blank Doctor Authority/client credentials. Compose leaves registration false. Both Standard Authority descriptors give the existingstellaops-notifyidentity the additionalplatform:doctor:registergrant; focused conformance pins the duplicate entries and full least-privilege scope set. Source desired state is not a live grant: Authority reconciliation, a fresh token proof, and a proven Platform capability row remain activation prerequisites.notify-workercontinues to serve checks locally and deliberately has no registrar.NIS2 ledger owner hand-off (2026-09-11). Notify commits its timeline, tenant-partitioned P6 envelope and durable domain-event receipt atomically in its own database. Findings consumes the closed contract and commits its ledger event and checkpoint in its own transaction. The initial two-tenant live write/replay proof passed before removing the old in-process Findings writer and implementation reference. An enabled producer returns
Queued; disabled activation refuses writes with HTTP 503. Replay compares a separate request digest that normalizes only the server-assignedRecordedAt; the first full payload, timestamp and SHA remain unchanged. The owner routes expose bounded authenticated catch-up and exact tenant-scoped consumer reporting. Explicit retention controls and a positive remote-consumer lease remain mandatory. Forward migration005_nis2_request_identity.sqlrefuses unexpected old receipts rather than inventing their fingerprints.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 subscriber 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 (2026-04-20). Host startup registers the 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 to
Testing, with startup-contract proof inStartupDependencyWiringTests.- Simulation runtime parity (2026-04-20). The canonical
/api/v2/simulate*endpoints and the/api/v2/notify/simulate*endpoints resolve the same DI-composed simulation runtime, so throttling plus quiet-hours or maintenance suppression behave identically across route families.
- Simulation runtime parity (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.
- Module boundary (2025-11-02 decision, SUPERSEDED 2026-09-08). The original decision kept
src/Notify/as a reusable toolkit andsrc/Notifier/as the Notifications Studio host composing it.SPRINT_20260722_015is the approved consolidation that replaced it: the host family merged intosrc/Notify/, and NTF-10 frozesrc/Notifier/atsrc/__Obsoleted/Notifier/. The boundary that survives is withinsrc/Notify/— hosts depend on same-family libraries, never on each other. - 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 predecessor
StellaOps.Notifier.WebService— historically framed as the “Studio host” — was merged intonotify-webat NTF-5 and commented out of compose from then on. Its siblingnotifier-workerwas the family’s delivery consumer until 2026-09-04. Both are gone: NTF-10 froze the whole predecessor module atsrc/__Obsoleted/Notifier/on 2026-09-08 (freeze record:src/__Obsoleted/Notifier/AGENTS.md). Neither is a deployable, and no live project may reference either.
- It maps both
1) Runtime shape & projects
The layout (verified against src/Notify/) is one WebService plus the notify-worker role and reusable libraries under __Libraries/. Since NTF-10 (2026-09-08) these are the family’s only deployables; the predecessor under src/Notifier/ is frozen at src/__Obsoleted/Notifier/. Persistence stays split on purpose: StellaOps.Notify.Persistence.Consolidated owns migrations for the family’s own stellaops_notify database, while StellaOps.Notify.Persistence retains the proven runtime DAL.
CM-2 (2026-09-09) retires the external NotifyMigrationModulePlugin and Platform’s reference to the runtime DAL. That plugin’s prefix matched four predecessor SQL resources, so its removal closes the local CLI path that could apply the old lineage to a supplied database (the CLI path and the plugin mechanism were themselves deleted on 2026-09-14, SPRINT_20260722_021 PLT-4). Notify’s owned startup registration and both persistence assemblies remain in their existing roles. Platform publishes through the closed Notify.Contracts and Notify.EventClient seam. The NIS2 effectiveness threshold payload and its two routing records move unchanged from Models into Contracts to complete that producer surface; Models forwards their existing assembly identities, and no transport or persistence dependency enters Contracts.
src/Notify/
├─ StellaOps.Notify.WebService/ # REST host: core /api/v1/notify + merged /api/v2/notify (Studio)
├─ StellaOps.Notify.Worker/ # staged successor role; pipeline activation is NTF-9 window work
├─ __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/ # surviving runtime DAL; legacy migrations remain rollback-only
│ ├─ StellaOps.Notify.Persistence.Consolidated/ # target migration authority + bounded own-DB seams
│ ├─ 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).
- Notify.Worker (
notify-worker) — the family’s only delivery consumer since 2026-09-04, when a constructed forcing function proved one exactconsumer.role=notify-workerdelivery with the predecessor stopped.
The successor worker’s delivery pipeline is a three-gate composition: a real queue, the checkpoint-backed Authority tenant census, and the checkpoint-backed Platform environment_state replica in stellaops_notify. The last gate supplies the storage-neutral IAirGapPostureSource. Endpoint-bearing rule channels read installation posture once before queue admission. Email is external: EmailChannelAdapter resolves the effective SMTP host and port, then re-reads posture immediately before every network attempt, including retries. The same adapter is the final SMTP choke for rule, FederationBundle and CryptoProvider-change delivery, closing both a queued-unsealed to send-sealed transition and a seal during retry backoff. A declared seal strengthens the configured egress policy while retaining its allowlist, an unreadable replica blocks external delivery, a witnessed NotDeclared result falls back to local bootstrap/override policy, and a replicated unsealed result never weakens a stricter local seal. InAppInbox, InApp and CLI are internal and remain available.
Eventing Reliability and Catalog.Replication independently deduplicate their startup-migration registration; the full successor composition hosts one runner for each module even when tenant and posture replicas overlap consolidated persistence. The optional StellaOps.Notify.Connectors.Email bundle has another BCL SMTP transport. notify-web can load and DI-register it, but current production source has no caller of the connector delivery dispatcher or transport. It must acquire the same replicated posture guard before any host activates connector delivery; its package presence alone is not an enforcement claim. All successor gates stay default-off until the NTF-9 window.
Verified against source commit ce3ed472dcd20f662ddbf53e15b7df1dd33de9d5; the specs moved to StellaOps.Notify.Delivery.Tests at NTF-15 and their namespace was renamed at NTF-10. Re-verify with pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Notify/__Tests/StellaOps.Notify.Delivery.Tests/StellaOps.Notify.Delivery.Tests.csproj -Class "StellaOps.Notify.Delivery.Tests.Channels.EmailChannelAdapterAirGapPostureTests", the same wrapper with class StellaOps.Notify.Delivery.Tests.EventProcessorAirGapPostureTests, methods *FlagOn* and *FullSuccessorComposition_RegistersEventingAndCatalogReplicaMigrationsExactlyOnce*, plus rg -n "INotifyChannelDeliveryDispatcher|SystemNetSmtpDeliveryTransport" src -g "*.cs".
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.Contractsand the neutralStellaOps.Dora.InfoSharing; since SPRINT_20260730_001 MBI-5c only the executable subscriber-delivery service is loaded from the mounted Notify connector assembly through the Worker mounted-runtime adapter, while the export and DSSE services are host-compiled and constructed directly. 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.platform.backup.run-completed/platform.backup.run-failed— a product-owned backup cycle reported in (SPRINT_20260802_003 BRP-3/BRP-4). Published by platform-web straight onto the event queue, not throughPOST /api/v1/events/platform: the producer is the service itself rather than an operator request, so there is no caller to take a tenant claim from. Idempotent on the run id.platform.backup.run-missed— a backup that should have happened did not. Unlike every other kind on this list it announces an absence, so it cannot ride a producer’s own action: platform-web polls the declared schedule (platform.backup_policy) and emits when the expectation is violated. Announced once per missed deadline, claimed inplatform.backup_alertsbefore publishing so it survives restarts and replicas.platform.backup.storage-low— the backup target is below a declared free-space floor, or the newest cycle reported no readable capacity for it at all (the second case fails closed on purpose: an unmeasurable target must not read as a healthy one). Same poller, same announce-once claim.Operator-facing detail for the four backup kinds — subscribing, and what to do on receipt of each — lives in
docs/runbooks/deploy/backup-restore-ops.md. Prometheus rules namedStellaBackupFailed/StellaBackupStorageFullare the intended future mirror of these, and remain unimplemented pending an observability stack.
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). Target roles create and migrate it forward-only from embedded SQL resources in StellaOps.Notify.Persistence.Consolidated via AddConsolidatedNotifyPersistence; there is no manual init-script dependency and no generic shared-connection fallback. AddNotifyRuntimeRepositories then reuses the existing NotifyDataSource and repository implementations from StellaOps.Notify.Persistence without registering that assembly’s legacy migration lineage; UseConsolidatedNotifyLeaseBackedLocks replaces only the predecessor lock repository so no target runtime SQL reaches notify.locks. The rollback predecessor retains the old AddNotifyPersistence path until retirement. The target migration set is 001_notify_consolidated_baseline.sql, 002_nis2_csirt_signing_work.sql, and 003_nis2_incident_ledger_handoff_idempotency.sql; clean startup carries no Seed-category data. Production notify-web retains two compatibility bootstrap writers by default: six federation-bundle template upserts and two crypto-provider-change template upserts. Notify:BootstrapTemplateSeeding:Enabled=false omits both only for an NTF-9 target whose copied-template parity is already recorded, preventing those timestamp-writing upserts from invalidating a retained-target fingerprint; predecessors keep the default true. The NTF-9 operator procedure converges that empty target first, data-copies the exact 32-table source/target intersection, excludes notify.locks and both migration ledgers, compares canonical hashes plus sequence/identity state, and keeps the source writer freeze through rollback’s named unfreeze point; see Notify NTF-9 data move. The tenant domain tables enforce FORCE row-level security keyed on app.tenant_id; retention, dead-letter, and tenant-admin runtime tables are deliberately outside that carried policy set. NotifyDataSource inherits DataSourceBase, which parameterizes both tenant session GUCs for ordinary repository connections. Estate-wide schedulers use a default-off local replica of Authority’s tenants catalog and require its durable checkpoint before enumerating active tenant slugs; they never discover tenancy from Notify rows. The owner remains NOBYPASSRLS; notify_admin is historical baseline residue, not a runtime grant.
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.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 NIS2 transition/handoff orchestrator. Its outbox payload is defined only by the closed StellaOps.Notify.Nis2Ledger.Contracts assembly; the general StellaOps.Notify.Models assembly is not a cross-service seam. Absent or false Notify:Nis2LedgerOutbox:Enabled selects LegacyNis2IncidentTimelineHandoffStore: it calls the local Findings emitter and saves the timeline only after Success or Idempotent, preserving the deployed response while never migrating the Findings schema. Explicit true selects PostgresNis2IncidentTimelineOutboxStore: the timeline upsert, tenant-partitioned P6 envelope and notify.nis2_incident_ledger_handoffs EventId receipt claim share one transaction. notify.nis2-incident-ledger remains the logical wire name; the physical producer key is the injective, case-sensitive notify.nis2-incident-ledger.tenant.<lowercase-utf8-hex> mapping of an exact tenant id. Tenant ids are rejected before any write when empty, untrimmed, control-bearing, invalid Unicode, or over 128 UTF-8 bytes; P6 stream columns are PostgreSQL TEXT, and the resulting application-bound key is at most 291 characters. If the EventId already owns an identical committed receipt, the replay transaction rolls back its tentative envelope/timeline write and returns that receipt; changed immutable content under the same EventId fails as nis2_outbox_event_id_conflict. Reported milestones carry the Notify timestamp, signer ref, authority/CSIRT intake ref, sha256:<hex> payload hash, evidence bundle ref, reachability subgraph refs, and VEX consensus refs. Forward migration 004_nis2_tenant_stream_partition.sql rekeys original global-stream rows and matching receipt sequences in one transaction, retains the empty legacy stream state and unattributed registrations, is SQL-idempotent, and refuses malformed tenant payloads or an existing destination stream at another epoch.
The same explicit opt-in also activates producer-owned retention for this pull stream. Setup must provide Notify:Nis2LedgerOutbox:RemoteConsumerLease and every Eventing:OutboxRetention control; retention must be enabled, the lease must be shorter than the configured window, and RequirePublished=false is required because the durable remote checkpoint is the consumption witness. Retention expands only the exact delimiter-terminated notify.nis2-incident-ledger.tenant. family from eventing.stream_state; a near-prefix is never matched. GET /api/v1/notify/internal/nis2-incident-ledger/events returns one bounded current-epoch page with the captured head, retention horizon, explicit epoch/gap flags, the stable logical stream and the authenticated tenant. POST /api/v1/notify/internal/nis2-incident-ledger/consumers/{consumerId} registers/resets or reports an exact cursor for that same tenant partition. Both routes require notify.operator plus HttpContext.RequireTenant(); no request-body/query tenant is accepted. Findings registers before its first pull, requires the echoed tenant to equal its configured token tenant, keeps tenant-specific inbox/checkpoint/lease identity, reports only its committed checkpoint, and fails closed when retained history no longer covers that checkpoint. The Findings ProjectReference remains only for the default-off compatibility adapter and is removed with the boundary pin only after the successor is deployed, caught up to the recorded owner head, the producer flag is flipped, and the live forcing proof passes.
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 and the default-off WebService sidecar signs it by calling Signer’s POST /api/v1/signer/sign/dsse; notify-web has no production ProjectReference to Signer and no longer composes Signer Core, Infrastructure, KeyManagement, or a local key seeder. The operator route POST /api/v1/regulatory/nis2/incidents/{incidentId}/csirt-submissions persists own-database work before the call and recovers prepared -> signed -> submission_recorded -> timeline_recorded -> terminal under Eventing fenced leases and deterministic backoff. The terminal row retains nis2-csirt-delivery-ledger.v1. DE, FR, and NL remain deterministic local portal stubs with operator-configured endpoints and blocked live-contract evidence. An accepted external submission whose response is lost may be retried and duplicated because no portal idempotency/receipt-recovery contract is verified; Signer response loss is separately replay-safe through its durable idempotency store.
This sidecar is source-complete but not live-enabled. It uses only STELLAOPS_POSTGRES_NOTIFY_CONNECTION (or the feature-specific test key Postgres:NotifyConsolidated:ConnectionString) and migrates consolidated Notify plus Eventing Reliability on a keyed data source before retry starts; it never falls back to the live shared connection. The family-wide connection repoint is now prepared in source: both target roles register the consolidated migration authority and the existing runtime DAL against the same own-database value. NTF-9 still gates activation because the running estate has not been copied/recreated/soaked, the delivery hosted pipeline is not active, and the Findings timeline handoff remains foreign. Authority client enrollment (aud=signer, signer:sign, tenant, client secret), matching PoE/key enrollment, secret sealing, and a cryptographically validating DPoP path are also required. The direct Signer endpoint currently validates only DPoP structure and Auth.Client does not sender-constrain the token request. The planned Notify owner remains NOBYPASSRLS; ordinary repository connections establish the tenant GUC. The two active estate-wide selectors now have a default-off, checkpoint-gated Authority tenant-replica seam and tenant-loop source path. The seam is persistence-owned: consolidated persistence registers one attributed pool for stellaops_notify before Eventing/CatalogReplication, and notify-worker registers exactly one tenants drain. Both tracked Standard manifests grant catalog:replicate to stellaops-notify; live Authority reconciliation, restart, token-claim validation, and a protected-feed HTTP 200 completed 2026-08-22. The flag remains off and the worker’s delivery pipeline remains idle until the remaining NTF-9 pipeline and database gates close.
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. STELLAOPS_POSTGRES_NOTIFY_CONNECTION is the deployment-canonical PostgreSQL input for both target roles; Postgres:Notify:ConnectionString is the service-specific host/test override and never falls back to STELLAOPS_POSTGRES_CONNECTION. 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 its own Notify database for credential storage when the delivery pipeline is enabled, so storedbuiltin://references resolve through the same owner schema as write sealing. - Secrets at rest — sealed (SPRINT_20260703_002 N3). The
StellaOps.Notify.WebServicehost wires the durable builtin secret store (AddSecretProviderKeySources+AddCredentialStorePersistence+AddPostgresSecretCredentialStorewith explicitcryptoschema and Notify-owned connection +AddRoutingSecretProvider, reusing consolidated persistence’s single unkeyedNpgsqlDataSource; 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. The predecessor registration is retired. The consolidated own-database host does not register a cross-tenant startup scan: its unkeyed connection has no tenant GUC and the runtime owner remains
NOBYPASSRLS, so a cross-tenant selector is invalid. InsteadAddConsolidatedNotifyChannelSecretSealingreuses the one pool already owned by consolidated persistence and refuses startup unless setup explicitly suppliesNotify:SecretSealing:ExistingRowsConverged=trueafter the source+target plaintext sweep, referenced credential ciphertext/nonce parity, identical KEK, and target-resolution receipt innotify-ntf9-data-move.md#credential-convergence-attestation. Missing, false, or malformed values fail with that exact procedure; Compose and the example environment default the receipt to false. Write-path sealing and the KEK startup probe remain active after admission. - 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, and the delivery host resolves them at delivery time over the durable builtin store. Fail-closed is preserved bySecretProviderStartupProbe: a missing master key crash-loops the host rather than degrading to plaintext. The deployed delivery host isnotify-worker; the predecessornotifier-worker, which held this wiring until 2026-09-04, is retired and its sources are frozen atsrc/__Obsoleted/Notifier/. - Admin-host secret custody (HARD-1). The admin composition wires the durable builtin/Vault routing provider before
AddPostgresNotifyAdminRuntimeServices, so the 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. A real-PostgreSQL round trip is covered byNotifierBuiltinSecretSealingTestsinStellaOps.Notify.Delivery.Tests. This clause was written for the standalone predecessor admin host, which retired at NTF-10; the custody requirement it states binds the survivingnotify-webcomposition. - 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)- Rule-generated delivery metadata carries the protected
consumer.rolewitness. The runtime derives it from the exact host application and rejects unknown identities before processing; rule metadata cannot override it.notify-workeris the only value written since 2026-09-04.notifier-workerremains a recognised value because rows written before the cutover carry it. - Sealed mode treats
InAppInbox,InApp, and itsClialias as internal delivery; their lack of an external endpoint cannot suppress the NTF-9 in-product witness. - 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" }
deadLetterStreamName: "notify:events:dead" # NTF-13: events whose retries are exhausted land here with reason + origin stream; before this the Redis transport neither retried nor dead-lettered a failed event
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
The consolidation regression suite is verified against source commit 7f76082d13 (NTF-15). It covers the surviving v2 handlers, provider-change fanout and suppression, restart persistence, and producer ingestion through isolated Valkey to durable delivery creation and HTTP readback. Audit assertions capture the Timeline emission boundary; the fixture does not publish audit events to an operator’s estate. Retired aliases have explicit refusal assertions.
Re-verify with dotnet test src/Notify/__Tests/StellaOps.Notify.WebService.Tests/StellaOps.Notify.WebService.Tests.csproj and dotnet test src/Notify/__Tests/StellaOps.Notify.Delivery.Tests/StellaOps.Notify.Delivery.Tests.csproj. The shared audit-status contract is covered by dotnet test src/__Libraries/__Tests/StellaOps.Audit.Emission.Tests/StellaOps.Audit.Emission.Tests.csproj.
Historical audit-backed simulation has a separate known gap: its reader still queries the retired local audit table. NTF-17 tracks the Timeline-owned reader; explicit-event simulation acceptance does not establish historical-reader availability.
- 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.
