diff --git a/server/internal/app/openapi/openapi.yaml b/server/internal/app/openapi/openapi.yaml
index 04af08f44..17b667d43 100644
--- a/server/internal/app/openapi/openapi.yaml
+++ b/server/internal/app/openapi/openapi.yaml
@@ -12285,6 +12285,14 @@ components:
type: object
enabled:
type: boolean
+ resolveOpenIncidents:
+ type: boolean
+ description: >-
+ Close the check's open incidents (outage and degraded) in the same
+ request, with resolution type `check_disabled`, so they do not stay
+ open on a check that no longer runs. Only valid together with
+ `enabled: false`; otherwise a `VALIDATION_ERROR`. Defaults to false:
+ incidents stay open.
period:
type: string
regions:
diff --git a/server/internal/app/server.go b/server/internal/app/server.go
index 6575afde6..a45233bbf 100644
--- a/server/internal/app/server.go
+++ b/server/internal/app/server.go
@@ -2068,6 +2068,7 @@ func (s *Server) SetupRoutes(ctx context.Context) {
// Deleting a check resolves its open incidents (the DB layer does that);
// this tells whoever was paged, once, that it closed (spec 2026-10-08-02).
checksService.SetCheckDeletedIncidentNotifier(incidentsService)
+ checksService.SetCheckDisableIncidentResolver(incidentsService)
// The MCP surface builds its OWN statuspages.Service (mcp.NewHandler runs
// far earlier in this function), and every NewService starts with its own
// view memo. Point it at this one's, or an MCP-driven page edit evicts a map
diff --git a/server/internal/db/models/incident.go b/server/internal/db/models/incident.go
index a08664330..d33722588 100644
--- a/server/internal/db/models/incident.go
+++ b/server/internal/db/models/incident.go
@@ -38,6 +38,10 @@ const (
// org deletion, the test API. The DB layer's DeleteCheck sets it in the
// same transaction as the soft delete, so no incident outlives its check.
ResolutionTypeCheckDeleted = "check_deleted"
+ // ResolutionTypeCheckDisabled closes an active incident because its check
+ // was disabled by hand with resolveOpenIncidents (spec 2026-10-23-01). The
+ // check did not necessarily recover: it simply stopped running.
+ ResolutionTypeCheckDisabled = "check_disabled"
)
// Incident kinds. `kind` discriminates what an incident row is ABOUT. It is a
diff --git a/server/internal/email/templates/incident-resolved.html b/server/internal/email/templates/incident-resolved.html
index ff7454d5e..07c3ec89a 100644
--- a/server/internal/email/templates/incident-resolved.html
+++ b/server/internal/email/templates/incident-resolved.html
@@ -1,12 +1,12 @@
-{{define "subject"}}{{if .SubjectPrefix}}{{.SubjectPrefix}} {{end}}{{if .CheckDeleted}}⚪ {{t "incident.resolved.deletedSubject" "check" .CheckName}}{{else}}🟢 {{if .Duration}}{{t "incident.resolved.titleAfter" "check" .CheckName "duration" (duration .Duration)}}{{else}}{{t "incident.resolved.title" "check" .CheckName}}{{end}}{{end}}{{if .IncidentNumber}} (#{{.IncidentNumber}}){{end}}{{end}}
-{{define "preheader"}}{{if .CheckDeleted}}{{t "incident.resolved.deletedPreheader" "check" .CheckName}}{{else}}{{if .Duration}}{{t "incident.resolved.downFor" "duration" (duration .Duration)}} {{end}}{{t "incident.resolved.passing"}}{{end}}{{end}}
+{{define "subject"}}{{if .SubjectPrefix}}{{.SubjectPrefix}} {{end}}{{if .CheckDeleted}}⚪ {{t (print "incident.resolved." .ClosedKind "Subject") "check" .CheckName}}{{else}}🟢 {{if .Duration}}{{t "incident.resolved.titleAfter" "check" .CheckName "duration" (duration .Duration)}}{{else}}{{t "incident.resolved.title" "check" .CheckName}}{{end}}{{end}}{{if .IncidentNumber}} (#{{.IncidentNumber}}){{end}}{{end}}
+{{define "preheader"}}{{if .CheckDeleted}}{{t (print "incident.resolved." .ClosedKind "Preheader") "check" .CheckName}}{{else}}{{if .Duration}}{{t "incident.resolved.downFor" "duration" (duration .Duration)}} {{end}}{{t "incident.resolved.passing"}}{{end}}{{end}}
{{template "base.html" .}}
-{{define "statusbanner"}}{{if .CheckDeleted}}{{template "statusbanner-render" dict "Variant" "comment" "Label" (t "incident.pill.checkDeleted")}}{{else}}{{template "statusbanner-render" dict "Variant" "recovered" "Label" (t "incident.pill.recovered")}}{{end}}{{end}}
+{{define "statusbanner"}}{{if .CheckDeleted}}{{template "statusbanner-render" dict "Variant" "comment" "Label" (t (print "incident.pill.check" .ClosedPill))}}{{else}}{{template "statusbanner-render" dict "Variant" "recovered" "Label" (t "incident.pill.recovered")}}{{end}}{{end}}
{{define "content"}}
{{$check := nolink .CheckName}}{{if .CheckURL}}{{$check = tHTML "incident.checkLink" "url" .CheckURL "check" $check}}{{end}}
-
{{if .CheckDeleted}}{{tHTML "incident.resolved.deletedTitle" "check" $check}}{{else}}{{tHTML "incident.resolved.title" "check" $check}}{{end}}
+ {{if .CheckDeleted}}{{tHTML (print "incident.resolved." .ClosedKind "Title") "check" $check}}{{else}}{{tHTML "incident.resolved.title" "check" $check}}{{end}}
{{if .CheckDeleted}}
- {{t "incident.resolved.deletedBody"}}
+ {{t (print "incident.resolved." .ClosedKind "Body")}}
{{else if .Duration}}
{{tHTML "incident.resolved.leadDuration" "duration" (duration .Duration)}}
{{else}}
@@ -23,9 +23,9 @@ {{if .CheckDeleted}}{{tHTML "incident.resolved.deletedTitle" "check" $check}
{{template "button" dict "URL" .IncidentURL "Label" (t "common.incidentActions.viewIncident") "Variant" "secondary"}}
{{end}}
{{end}}
-{{define "text"}}{{if .CheckDeleted}}{{t "incident.resolved.deletedTitle" "check" .CheckName}}
+{{define "text"}}{{if .CheckDeleted}}{{t (print "incident.resolved." .ClosedKind "Title") "check" .CheckName}}
-{{t "incident.resolved.deletedBody"}}
+{{t (print "incident.resolved." .ClosedKind "Body")}}
{{else}}{{t "incident.resolved.textTitle" "check" .CheckName}}
{{t "incident.resolved.textBody" "check" .CheckName}}
diff --git a/server/internal/handlers/checks/check_disable_incidents_test.go b/server/internal/handlers/checks/check_disable_incidents_test.go
new file mode 100644
index 000000000..13b4cf04a
--- /dev/null
+++ b/server/internal/handlers/checks/check_disable_incidents_test.go
@@ -0,0 +1,121 @@
+package checks_test
+
+// Disabling a check with resolveOpenIncidents closes its open incidents with
+// resolution type check_disabled (spec 2026-10-23-01).
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/fclairamb/solidping/server/internal/db/models"
+ "github.com/fclairamb/solidping/server/internal/handlers/checks"
+)
+
+func TestDisableResolvesOpenIncidents(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+ ctx := t.Context()
+ world := newDegradedDisableWorld(t, "disable-resolves")
+ world.checks.SetCheckDisableIncidentResolver(world.incidents)
+
+ check := models.NewCheck(world.org.UID, "acme", "http")
+ r.NoError(world.dbSvc.CreateCheck(ctx, check))
+
+ outage := models.NewIncident(world.org.UID, check.UID, time.Now().Add(-time.Hour), "acme is down")
+ r.NoError(world.dbSvc.CreateIncident(ctx, outage))
+ degraded := world.openDegraded(t, check.UID)
+
+ // Positive control: disabling without the flag leaves both active.
+ _, err := world.checks.UpdateCheck(ctx, world.org.Slug, check.UID, &checks.UpdateCheckRequest{
+ Enabled: boolPtr(false),
+ })
+ r.NoError(err)
+ r.Equal(models.IncidentStateActive, world.incident(t, outage.UID).State)
+ r.Equal(models.IncidentStateActive, world.incident(t, degraded.UID).State)
+
+ _, err = world.checks.UpdateCheck(ctx, world.org.Slug, check.UID, &checks.UpdateCheckRequest{
+ Enabled: boolPtr(false),
+ ResolveOpenIncidents: boolPtr(true),
+ })
+ r.NoError(err)
+
+ for _, uid := range []string{outage.UID, degraded.UID} {
+ got := world.incident(t, uid)
+ r.Equal(models.IncidentStateResolved, got.State)
+ r.NotNil(got.ResolutionType)
+ r.Equal(models.ResolutionTypeCheckDisabled, *got.ResolutionType)
+ r.NotNil(got.ResolvedAt)
+
+ events, listErr := world.dbSvc.ListEvents(ctx, &models.ListEventsFilter{
+ OrganizationUID: world.org.UID,
+ IncidentUID: &uid,
+ EventTypes: []models.EventType{models.EventTypeIncidentResolved},
+ })
+ r.NoError(listErr)
+ r.Len(events, 1, "one incident.resolved event per incident")
+ }
+
+ // A retry with nothing open is a no-op, and emits no second event.
+ _, err = world.checks.UpdateCheck(ctx, world.org.Slug, check.UID, &checks.UpdateCheckRequest{
+ Enabled: boolPtr(false),
+ ResolveOpenIncidents: boolPtr(true),
+ })
+ r.NoError(err)
+
+ for _, uid := range []string{outage.UID, degraded.UID} {
+ events, listErr := world.dbSvc.ListEvents(ctx, &models.ListEventsFilter{
+ OrganizationUID: world.org.UID,
+ IncidentUID: &uid,
+ EventTypes: []models.EventType{models.EventTypeIncidentResolved},
+ })
+ r.NoError(listErr)
+ r.Len(events, 1, "retry must not emit a second incident.resolved event")
+ }
+}
+
+func TestResolveOpenIncidentsWithoutDisableOverHTTPIs400(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ router := newFullChecksRouter(t)
+ base := "/orgs/acme/checks"
+
+ body := `{"type":"http","name":"acme","config":{"url":"https://acme.com"}}`
+ rec := checksCall(t, router, http.MethodPost, base, body)
+ r.Equal(http.StatusCreated, rec.Code, rec.Body.String())
+ var created struct {
+ UID string `json:"uid"`
+ }
+ r.NoError(json.Unmarshal(rec.Body.Bytes(), &created))
+
+ rec = checksCall(t, router, http.MethodPatch, base+"/"+created.UID, `{"resolveOpenIncidents":true}`)
+ r.Equal(http.StatusBadRequest, rec.Code, rec.Body.String())
+ r.Contains(rec.Body.String(), "VALIDATION_ERROR")
+}
+
+func TestResolveOpenIncidentsWithoutDisableIsRejected(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+ ctx := t.Context()
+ world := newDegradedDisableWorld(t, "disable-rejects")
+ world.checks.SetCheckDisableIncidentResolver(world.incidents)
+
+ check := models.NewCheck(world.org.UID, "acme", "http")
+ r.NoError(world.dbSvc.CreateCheck(ctx, check))
+ outage := models.NewIncident(world.org.UID, check.UID, time.Now().Add(-time.Hour), "acme is down")
+ r.NoError(world.dbSvc.CreateIncident(ctx, outage))
+
+ for _, req := range []*checks.UpdateCheckRequest{
+ {ResolveOpenIncidents: boolPtr(true)},
+ {Enabled: boolPtr(true), ResolveOpenIncidents: boolPtr(true)},
+ } {
+ _, err := world.checks.UpdateCheck(ctx, world.org.Slug, check.UID, req)
+ r.Error(err)
+ }
+
+ r.Equal(models.IncidentStateActive, world.incident(t, outage.UID).State)
+}
diff --git a/server/internal/handlers/checks/disable_incidents.go b/server/internal/handlers/checks/disable_incidents.go
new file mode 100644
index 000000000..98664e6bb
--- /dev/null
+++ b/server/internal/handlers/checks/disable_incidents.go
@@ -0,0 +1,54 @@
+package checks
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/fclairamb/solidping/server/internal/db/models"
+)
+
+// errResolveOpenNeedsDisable rejects resolveOpenIncidents without enabled:false.
+// Listed in isCheckFieldValidationError so it is a 400.
+var errResolveOpenNeedsDisable = errors.New("resolveOpenIncidents requires enabled: false")
+
+// CheckDisableIncidentResolver closes the open incidents of a check that was
+// just disabled by hand (spec 2026-10-23-01). It is an interface because the
+// implementation lives in handlers/incidents, which this package cannot import.
+type CheckDisableIncidentResolver interface {
+ ResolveOnCheckDisable(ctx context.Context, orgUID, checkUID string) ([]string, error)
+}
+
+// SetCheckDisableIncidentResolver wires the resolver. Every service serving a
+// check write path (HTTP routes, MCP) must set it, or resolveOpenIncidents is
+// silently ignored.
+func (s *Service) SetCheckDisableIncidentResolver(resolver CheckDisableIncidentResolver) {
+ s.disableResolver = resolver
+}
+
+func validateResolveOpenIncidents(req *UpdateCheckRequest) error {
+ if req.ResolveOpenIncidents != nil && *req.ResolveOpenIncidents &&
+ (req.Enabled == nil || *req.Enabled) {
+ return errResolveOpenNeedsDisable
+ }
+
+ return nil
+}
+
+// resolveIncidentsOnDisable fires on any write that sets enabled:false with
+// resolveOpenIncidents:true, not only on a true to false transition, so a retry
+// after a failed resolve still closes them.
+func (s *Service) resolveIncidentsOnDisable(
+ ctx context.Context, check *models.Check, req *UpdateCheckRequest,
+) error {
+ if s.disableResolver == nil || req.ResolveOpenIncidents == nil || !*req.ResolveOpenIncidents ||
+ req.Enabled == nil || *req.Enabled {
+ return nil
+ }
+
+ if _, err := s.disableResolver.ResolveOnCheckDisable(ctx, check.OrganizationUID, check.UID); err != nil {
+ return fmt.Errorf("resolve open incidents: %w", err)
+ }
+
+ return nil
+}
diff --git a/server/internal/handlers/checks/handler.go b/server/internal/handlers/checks/handler.go
index a4e9a0f40..05d93fa04 100644
--- a/server/internal/handlers/checks/handler.go
+++ b/server/internal/handlers/checks/handler.go
@@ -987,6 +987,7 @@ func isCheckFieldValidationError(err error) bool {
errors.Is(err, errFlapBackoffTooSmall) ||
errors.Is(err, errMaxRecoveryMultTooSmall) ||
errors.Is(err, errInvalidTraceroutePolicy) ||
+ errors.Is(err, errResolveOpenNeedsDisable) ||
errors.Is(err, errInvalidFailQuorum) ||
// Placement (spec 2026-09-25-06): a contradictory or unplaceable
// placement request is the caller's to fix.
diff --git a/server/internal/handlers/checks/service.go b/server/internal/handlers/checks/service.go
index c40dda7f2..64e64d02e 100644
--- a/server/internal/handlers/checks/service.go
+++ b/server/internal/handlers/checks/service.go
@@ -622,6 +622,9 @@ type Service struct {
// degraded detection is turned off (spec 2026-09-24-08). nil = not wired,
// in which case nothing is resolved; see SetDegradedIncidentResolver.
degradedResolver DegradedIncidentResolver
+ // disableResolver closes a check's open incidents when it is disabled with
+ // resolveOpenIncidents (spec 2026-10-23-01). nil = not wired, nothing resolved.
+ disableResolver CheckDisableIncidentResolver
// checkDeletedNotifier tells whoever was paged that a deleted check's
// incident closed (spec 2026-10-08-02). nil = not wired: the incidents
// still resolve, silently. See SetCheckDeletedIncidentNotifier.
@@ -2004,6 +2007,10 @@ type UpdateCheckRequest struct {
RegionCount *int `json:"regionCount,omitempty"`
RegionPool *[]string `json:"regionPool,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
+ // ResolveOpenIncidents closes the check's open incidents with resolution
+ // type `check_disabled` in the same write. Only valid alongside
+ // `enabled: false` (spec 2026-10-23-01).
+ ResolveOpenIncidents *bool `json:"resolveOpenIncidents,omitempty"`
// Internal is decoded only so it can be refused — see
// ErrInternalFieldNotWritable (spec 2026-08-27-01).
Internal *bool `json:"internal,omitempty"`
@@ -2279,6 +2286,9 @@ func (s *Service) UpdateCheck(
return CheckResponse{}, coverErr
}
}
+ if validateErr := validateResolveOpenIncidents(req); validateErr != nil {
+ return CheckResponse{}, validateErr
+ }
if req.Enabled != nil {
update.Enabled = req.Enabled
}
@@ -2404,6 +2414,10 @@ func (s *Service) UpdateCheck(
return CheckResponse{}, resolveErr
}
+ if resolveErr := s.resolveIncidentsOnDisable(ctx, check, req); resolveErr != nil {
+ return CheckResponse{}, resolveErr
+ }
+
// Reconcile check jobs if regions, period, spread, enabled, or config
// changed (a regionSpread-only edit re-levels the per-region phases).
if regionsChanged || req.Period != nil || req.RegionSpread != nil ||
diff --git a/server/internal/handlers/incidents/check_disabled.go b/server/internal/handlers/incidents/check_disabled.go
new file mode 100644
index 000000000..0b9660332
--- /dev/null
+++ b/server/internal/handlers/incidents/check_disabled.go
@@ -0,0 +1,105 @@
+package incidents
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+
+ "github.com/fclairamb/solidping/server/internal/audit"
+ "github.com/fclairamb/solidping/server/internal/db/models"
+)
+
+// ResolveOnCheckDisable closes every active incident (outage and degraded) of a
+// check the user just disabled with resolveOpenIncidents (spec 2026-10-23-01).
+// Without it they would stay active forever: a disabled check has no jobs, so
+// no result ever arrives to auto-resolve them.
+//
+// Incidents resolve with resolution_type = "check_disabled" and resolved_by =
+// the caller when known, so history and notifications say the check was
+// disabled rather than that it recovered. Returns the UIDs it resolved.
+func (s *Service) ResolveOnCheckDisable(ctx context.Context, orgUID, checkUID string) ([]string, error) {
+ active, _, err := s.db.ListIncidents(ctx, &models.ListIncidentsFilter{
+ OrganizationUID: orgUID,
+ CheckUIDs: []string{checkUID},
+ States: []models.IncidentState{models.IncidentStateActive},
+ })
+ if err != nil {
+ return nil, fmt.Errorf("list active incidents: %w", err)
+ }
+
+ if len(active) == 0 {
+ return nil, nil
+ }
+
+ check, checkErr := s.db.GetCheck(ctx, orgUID, checkUID)
+ if checkErr != nil {
+ check = nil
+ }
+
+ actorUID := audit.ActorFromContext(ctx).UserUID
+ resolved := make([]string, 0, len(active))
+
+ for _, incident := range active {
+ if err := s.resolveForCheckDisable(ctx, orgUID, incident, check, actorUID); err != nil {
+ return resolved, err
+ }
+
+ resolved = append(resolved, incident.UID)
+ }
+
+ return resolved, nil
+}
+
+func (s *Service) resolveForCheckDisable(
+ ctx context.Context, orgUID string, incident *models.Incident, check *models.Check, actorUID string,
+) error {
+ now := s.clock.Now()
+ state := models.IncidentStateResolved
+ resolutionType := models.ResolutionTypeCheckDisabled
+ update := models.IncidentUpdate{
+ State: &state,
+ ResolvedAt: &now,
+ ResolutionType: &resolutionType,
+ }
+
+ if actorUID != "" {
+ update.ResolvedBy = &actorUID
+ }
+
+ if err := s.db.UpdateIncident(ctx, incident.UID, &update); err != nil {
+ return fmt.Errorf("resolve incident %s: %w", incident.UID, err)
+ }
+
+ incident.State = state
+ incident.ResolvedAt = &now
+ incident.ResolutionType = &resolutionType
+ incident.ResolvedBy = update.ResolvedBy
+
+ // Cancel BEFORE emitting, or the sweep also drops the resolved
+ // notifications about to be queued.
+ s.cancelPendingNotifications(ctx, incident.UID, nil)
+
+ payload := models.JSONMap{
+ keyCheckUID: incident.CheckUID,
+ keyResolutionType: resolutionType,
+ keyResolvedAt: now,
+ keyDurationSeconds: int64(now.Sub(incident.StartedAt).Seconds()),
+ }
+ if check != nil {
+ payload[keyCheckSlug] = check.Slug
+ payload[keyCheckName] = check.Name
+ }
+
+ if actorUID != "" {
+ payload[payloadKeyActorUID] = actorUID
+ }
+
+ if err := s.emitEvent(ctx, orgUID, models.EventTypeIncidentResolved, incident, payload); err != nil {
+ slog.WarnContext(ctx, "Failed to emit check-disabled resolve event",
+ "incidentUid", incident.UID, "error", err)
+ }
+
+ s.publishResolved(ctx, incident)
+
+ return nil
+}
diff --git a/server/internal/i18n/locales/de/incident.json b/server/internal/i18n/locales/de/incident.json
index 8169906de..5250e1d75 100644
--- a/server/internal/i18n/locales/de/incident.json
+++ b/server/internal/i18n/locales/de/incident.json
@@ -20,6 +20,7 @@
"down": "Ausgefallen",
"recovered": "Wiederhergestellt",
"checkDeleted": "Prüfung gelöscht",
+ "checkDisabled": "Prüfung deaktiviert",
"escalated": "Eskaliert",
"reopened": "Wiedereröffnet",
"comment": "Kommentar",
@@ -94,6 +95,10 @@
"deletedTitle": "{check} wurde gelöscht",
"deletedPreheader": "{check} wurde gelöscht, daher wurde der Vorfall geschlossen.",
"deletedBody": "Die Prüfung wurde gelöscht, daher wurde dieser Vorfall geschlossen. Das heißt nicht, dass der Dienst wieder läuft.",
+ "disabledSubject": "{check} wurde deaktiviert, Vorfall geschlossen",
+ "disabledTitle": "{check} wurde deaktiviert",
+ "disabledPreheader": "{check} wurde deaktiviert, daher wurde der Vorfall geschlossen.",
+ "disabledBody": "Die Prüfung wurde deaktiviert, daher wurde dieser Vorfall geschlossen. Sie hat sich nicht unbedingt erholt.",
"textTitle": "{check} wiederhergestellt",
"textBody": "Der Vorfall für {check} wurde gelöst."
},
diff --git a/server/internal/i18n/locales/en/incident.json b/server/internal/i18n/locales/en/incident.json
index 305ddf1f1..2adfe5a63 100644
--- a/server/internal/i18n/locales/en/incident.json
+++ b/server/internal/i18n/locales/en/incident.json
@@ -20,6 +20,7 @@
"down": "Down",
"recovered": "Recovered",
"checkDeleted": "Check deleted",
+ "checkDisabled": "Check disabled",
"escalated": "Escalated",
"reopened": "Reopened",
"comment": "Comment",
@@ -94,6 +95,10 @@
"deletedTitle": "{check} was deleted",
"deletedPreheader": "{check} was deleted, so its incident was closed.",
"deletedBody": "The check was deleted, so this incident was closed. It did not necessarily recover.",
+ "disabledSubject": "{check} was disabled, incident closed",
+ "disabledTitle": "{check} was disabled",
+ "disabledPreheader": "{check} was disabled, so its incident was closed.",
+ "disabledBody": "The check was disabled, so this incident was closed. It did not necessarily recover.",
"textTitle": "{check} recovered",
"textBody": "The incident for {check} has been resolved."
},
diff --git a/server/internal/i18n/locales/es/incident.json b/server/internal/i18n/locales/es/incident.json
index 5991c032f..2fabbeb78 100644
--- a/server/internal/i18n/locales/es/incident.json
+++ b/server/internal/i18n/locales/es/incident.json
@@ -20,6 +20,7 @@
"down": "Caído",
"recovered": "Recuperado",
"checkDeleted": "Comprobación eliminada",
+ "checkDisabled": "Comprobación desactivada",
"escalated": "Escalado",
"reopened": "Reabierto",
"comment": "Comentario",
@@ -94,6 +95,10 @@
"deletedTitle": "Se eliminó {check}",
"deletedPreheader": "Se eliminó {check}, así que su incidente se cerró.",
"deletedBody": "Se eliminó la comprobación, así que este incidente se cerró. Eso no significa que el servicio se haya recuperado.",
+ "disabledSubject": "Se desactivó {check}, incidente cerrado",
+ "disabledTitle": "Se desactivó {check}",
+ "disabledPreheader": "Se desactivó {check}, por lo que su incidente se cerró.",
+ "disabledBody": "La comprobación se desactivó, por lo que este incidente se cerró. No necesariamente se recuperó.",
"textTitle": "{check} se ha recuperado",
"textBody": "El incidente de {check} se ha resuelto."
},
diff --git a/server/internal/i18n/locales/fr/incident.json b/server/internal/i18n/locales/fr/incident.json
index adb50c39c..489bd472e 100644
--- a/server/internal/i18n/locales/fr/incident.json
+++ b/server/internal/i18n/locales/fr/incident.json
@@ -20,6 +20,7 @@
"down": "En panne",
"recovered": "Rétabli",
"checkDeleted": "Contrôle supprimé",
+ "checkDisabled": "Contrôle désactivé",
"escalated": "Escaladé",
"reopened": "Rouvert",
"comment": "Commentaire",
@@ -94,6 +95,10 @@
"deletedTitle": "Le contrôle {check} a été supprimé",
"deletedPreheader": "Le contrôle {check} a été supprimé, son incident a donc été clos.",
"deletedBody": "Le contrôle a été supprimé, cet incident a donc été clos. Cela ne veut pas dire que le service est rétabli.",
+ "disabledSubject": "Le contrôle {check} a été désactivé, incident clos",
+ "disabledTitle": "Le contrôle {check} a été désactivé",
+ "disabledPreheader": "Le contrôle {check} a été désactivé, son incident a donc été clos.",
+ "disabledBody": "Le contrôle a été désactivé, cet incident a donc été clos. Il n'est pas forcément rétabli.",
"textTitle": "Rétablissement de {check}",
"textBody": "L'incident de {check} est résolu."
},
diff --git a/server/internal/jobs/jobtypes/job_incident_resolution_notice.go b/server/internal/jobs/jobtypes/job_incident_resolution_notice.go
index fd39807e2..c4faebfe1 100644
--- a/server/internal/jobs/jobtypes/job_incident_resolution_notice.go
+++ b/server/internal/jobs/jobtypes/job_incident_resolution_notice.go
@@ -202,6 +202,10 @@ func telegramResolvedDetail(incident *models.Incident) string {
return "closed because the check was deleted"
}
+ if incident.ResolutionType != nil && *incident.ResolutionType == models.ResolutionTypeCheckDisabled {
+ return "closed because the check was disabled"
+ }
+
if incident.ResolvedAt == nil {
return ""
}
diff --git a/server/internal/mcp/handler.go b/server/internal/mcp/handler.go
index 58e7b7a59..6cd3834ed 100644
--- a/server/internal/mcp/handler.go
+++ b/server/internal/mcp/handler.go
@@ -190,6 +190,7 @@ func NewHandler(
// delete_check tells whoever was paged that the check's incident closed,
// exactly as over the HTTP API (spec 2026-10-08-02).
handler.checksSvc.SetCheckDeletedIncidentNotifier(incidentsSvc)
+ handler.checksSvc.SetCheckDisableIncidentResolver(incidentsSvc)
if cfg != nil {
handler.baseURL = cfg.Server.BaseURL
diff --git a/server/internal/mcp/tools_checks.go b/server/internal/mcp/tools_checks.go
index 3ba865ba8..89081dc1b 100644
--- a/server/internal/mcp/tools_checks.go
+++ b/server/internal/mcp/tools_checks.go
@@ -337,6 +337,9 @@ func updateCheckDef() ToolDefinition {
schemaKeyEnabled: boolProp(
"Toggle whether the check runs. Set to false to pause the check, true to resume it.",
),
+ "resolveOpenIncidents": boolProp(
+ "With enabled:false, also close the check's open incidents (resolution type check_disabled).",
+ ),
schemaKeyPeriod: stringProp(
"New check interval as HH:MM:SS, e.g. \"00:00:30\" for 30 seconds.",
),
@@ -392,6 +395,7 @@ func (h *Handler) toolUpdateCheck(ctx context.Context, orgSlug string, args map[
req.RegionPool = &v
}
req.Enabled = getBoolArg(args, schemaKeyEnabled)
+ req.ResolveOpenIncidents = getBoolArg(args, "resolveOpenIncidents")
if v := getStringArg(args, schemaKeyPeriod); v != "" {
req.Period = &v
}
diff --git a/server/internal/notifications/check_deleted.go b/server/internal/notifications/check_deleted.go
index af004cce5..a5956716b 100644
--- a/server/internal/notifications/check_deleted.go
+++ b/server/internal/notifications/check_deleted.go
@@ -9,8 +9,44 @@ const (
checkDeletedTag = "[CHECK DELETED] "
recoveredTag = "[RECOVERED] "
checkDeletedSentence = "The check was deleted, so this incident was closed. It did not necessarily recover."
+
+ // Same idea for a check disabled by hand with resolveOpenIncidents (spec
+ // 2026-10-23-01).
+ checkDisabledTag = "[CHECK DISABLED] "
+ checkDisabledSentence = "The check was disabled, so this incident was closed. It did not necessarily recover."
)
+// ResolvedByCheckDisable reports whether the incident was closed because its
+// check was disabled.
+func ResolvedByCheckDisable(incident *models.Incident) bool {
+ return incident != nil && incident.ResolutionType != nil &&
+ *incident.ResolutionType == models.ResolutionTypeCheckDisabled
+}
+
+// closedWithoutRecovery is true when the incident was closed because its check
+// was deleted or disabled: the check did not necessarily recover.
+func closedWithoutRecovery(incident *models.Incident) bool {
+ return ResolvedByCheckDeletion(incident) || ResolvedByCheckDisable(incident)
+}
+
+// closedSentence is the one-line reason for a closedWithoutRecovery incident.
+func closedSentence(incident *models.Incident) string {
+ if ResolvedByCheckDisable(incident) {
+ return checkDisabledSentence
+ }
+
+ return checkDeletedSentence
+}
+
+// closedTag is the title prefix for a closedWithoutRecovery incident.
+func closedTag(incident *models.Incident) string {
+ if ResolvedByCheckDisable(incident) {
+ return checkDisabledTag
+ }
+
+ return checkDeletedTag
+}
+
// ResolvedByCheckDeletion reports whether the incident was closed because its
// check was deleted.
func ResolvedByCheckDeletion(incident *models.Incident) bool {
@@ -21,9 +57,27 @@ func ResolvedByCheckDeletion(incident *models.Incident) bool {
// resolvedTag is the title prefix of a resolved notification: "[RECOVERED] ",
// or "[CHECK DELETED] " when the check was deleted rather than recovered.
func resolvedTag(incident *models.Incident) string {
- if ResolvedByCheckDeletion(incident) {
- return checkDeletedTag
+ if closedWithoutRecovery(incident) {
+ return closedTag(incident)
}
return recoveredTag
}
+
+// closedKind ("deleted" or "disabled") and closedPill ("Deleted" or
+// "Disabled") pick the email i18n keys of a closedWithoutRecovery incident.
+func closedKind(incident *models.Incident) string {
+ if ResolvedByCheckDisable(incident) {
+ return "disabled"
+ }
+
+ return "deleted"
+}
+
+func closedPill(incident *models.Incident) string {
+ if ResolvedByCheckDisable(incident) {
+ return "Disabled"
+ }
+
+ return "Deleted"
+}
diff --git a/server/internal/notifications/check_disabled_test.go b/server/internal/notifications/check_disabled_test.go
new file mode 100644
index 000000000..4a75d82b0
--- /dev/null
+++ b/server/internal/notifications/check_disabled_test.go
@@ -0,0 +1,26 @@
+package notifications
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/fclairamb/solidping/server/internal/db/models"
+)
+
+func TestResolvedTagCheckDisabled(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ disabled := models.ResolutionTypeCheckDisabled
+ incident := &models.Incident{ResolutionType: &disabled}
+
+ r.True(ResolvedByCheckDisable(incident))
+ r.False(ResolvedByCheckDeletion(incident))
+ r.Equal(checkDisabledTag, resolvedTag(incident))
+ r.Equal(checkDisabledSentence, closedSentence(incident))
+ r.Equal("disabled", closedKind(incident))
+
+ auto := models.ResolutionTypeAuto
+ r.Equal(recoveredTag, resolvedTag(&models.Incident{ResolutionType: &auto}))
+}
diff --git a/server/internal/notifications/discord.go b/server/internal/notifications/discord.go
index c70acafd0..81559b3b8 100644
--- a/server/internal/notifications/discord.go
+++ b/server/internal/notifications/discord.go
@@ -640,8 +640,8 @@ func (ds *DiscordSender) buildIncidentResolvedEmbed(payload *Payload) discord.Em
fields = appendDiscordRegionField(fields, payload)
description := "The incident has been automatically resolved."
- if ResolvedByCheckDeletion(payload.Incident) {
- description = checkDeletedSentence
+ if closedWithoutRecovery(payload.Incident) {
+ description = closedSentence(payload.Incident)
}
return discord.Embed{
@@ -809,6 +809,9 @@ func (ds *DiscordSender) buildResolvedUpdateMessage(payload *Payload) *discord.M
if ResolvedByCheckDeletion(payload.Incident) {
title = fmt.Sprintf("%sClosed %s incident: the check was deleted", incidentRefPrefix(payload.Incident), checkName)
}
+ if ResolvedByCheckDisable(payload.Incident) {
+ title = fmt.Sprintf("%sClosed %s incident: the check was disabled", incidentRefPrefix(payload.Incident), checkName)
+ }
return &discord.Message{
Embeds: []discord.Embed{{
diff --git a/server/internal/notifications/email.go b/server/internal/notifications/email.go
index 3d08cb745..0a89f769f 100644
--- a/server/internal/notifications/email.go
+++ b/server/internal/notifications/email.go
@@ -662,7 +662,9 @@ func buildIncidentViewModel(
"HasRelapses": payload.Incident.RelapseCount > 1, // a bool: the email job decodes numbers as float64
"DashboardURL": dashboardRootURL(payload.AppBaseURL),
"DocsURL": docsURL(payload.AppBaseURL),
- "CheckDeleted": ResolvedByCheckDeletion(payload.Incident),
+ "CheckDeleted": closedWithoutRecovery(payload.Incident),
+ "ClosedKind": closedKind(payload.Incident),
+ "ClosedPill": closedPill(payload.Incident),
}
// The cause and the probed target are what an on-call reader needs to
diff --git a/server/internal/notifications/googlechat.go b/server/internal/notifications/googlechat.go
index 762af145c..065d64db1 100644
--- a/server/internal/notifications/googlechat.go
+++ b/server/internal/notifications/googlechat.go
@@ -263,8 +263,8 @@ func (s *GoogleChatSender) buildWidgets(payload *Payload, checkName string) []go
}
func (s *GoogleChatSender) resolvedSubtitle(payload *Payload) string {
- if ResolvedByCheckDeletion(payload.Incident) {
- return checkDeletedSentence
+ if closedWithoutRecovery(payload.Incident) {
+ return closedSentence(payload.Incident)
}
if payload.Incident.ResolvedAt != nil {
diff --git a/server/internal/notifications/gotify.go b/server/internal/notifications/gotify.go
index a15c2a4bd..b1bbc5bda 100644
--- a/server/internal/notifications/gotify.go
+++ b/server/internal/notifications/gotify.go
@@ -222,8 +222,8 @@ func (s *GotifySender) buildDownBody(payload *Payload, checkName string) string
func (s *GotifySender) buildResolvedBody(payload *Payload, checkName string) string {
var builder strings.Builder
- if ResolvedByCheckDeletion(payload.Incident) {
- builder.WriteString(checkDeletedSentence + "\n")
+ if closedWithoutRecovery(payload.Incident) {
+ builder.WriteString(closedSentence(payload.Incident) + "\n")
}
fmt.Fprintf(&builder, "Check: %s (%s)\n", checkName, payload.Check.Type)
diff --git a/server/internal/notifications/matrix.go b/server/internal/notifications/matrix.go
index 71006c1a2..c6ab6202e 100644
--- a/server/internal/notifications/matrix.go
+++ b/server/internal/notifications/matrix.go
@@ -316,8 +316,8 @@ func (s *MatrixSender) downLines(payload *Payload, checkName string) []string {
func (s *MatrixSender) resolvedLines(payload *Payload, checkName string) []string {
lines := []string{fmt.Sprintf("Check: %s (%s)", checkName, payload.Check.Type)}
- if ResolvedByCheckDeletion(payload.Incident) {
- lines = append([]string{checkDeletedSentence}, lines...)
+ if closedWithoutRecovery(payload.Incident) {
+ lines = append([]string{closedSentence(payload.Incident)}, lines...)
}
if payload.Incident.ResolvedAt != nil {
diff --git a/server/internal/notifications/mattermost.go b/server/internal/notifications/mattermost.go
index 76ff18eb7..85efe9a61 100644
--- a/server/internal/notifications/mattermost.go
+++ b/server/internal/notifications/mattermost.go
@@ -277,8 +277,8 @@ func (s *MattermostSender) buildResolvedFields(payload *Payload, checkName strin
{Short: true, Title: mmFieldCheck, Value: checkName},
}
- if ResolvedByCheckDeletion(payload.Incident) {
- fields = append(fields, mattermostField{Short: false, Title: "Reason", Value: checkDeletedSentence})
+ if closedWithoutRecovery(payload.Incident) {
+ fields = append(fields, mattermostField{Short: false, Title: "Reason", Value: closedSentence(payload.Incident)})
}
if payload.Incident.ResolvedAt != nil {
diff --git a/server/internal/notifications/msteams.go b/server/internal/notifications/msteams.go
index fa8bb9e68..6c71c5470 100644
--- a/server/internal/notifications/msteams.go
+++ b/server/internal/notifications/msteams.go
@@ -247,8 +247,8 @@ func (s *MSTeamsSender) buildFacts(payload *Payload, checkName string) []msTeams
}
if payload.EventType == eventTypeIncidentResolved {
- if ResolvedByCheckDeletion(payload.Incident) {
- facts = append(facts, msTeamsFact{Title: "Reason", Value: checkDeletedSentence})
+ if closedWithoutRecovery(payload.Incident) {
+ facts = append(facts, msTeamsFact{Title: "Reason", Value: closedSentence(payload.Incident)})
}
if payload.Incident.ResolvedAt != nil {
diff --git a/server/internal/notifications/msteamsbot.go b/server/internal/notifications/msteamsbot.go
index 64273be68..cacb17160 100644
--- a/server/internal/notifications/msteamsbot.go
+++ b/server/internal/notifications/msteamsbot.go
@@ -422,6 +422,10 @@ func (s *MSTeamsBotSender) titleAndColor(payload *Payload, checkName string) (st
return "⚪ " + checkName + " was DELETED, incident closed", msteams.CardColorGood
}
+ if ResolvedByCheckDisable(payload.Incident) {
+ return "⚪ " + checkName + " was DISABLED, incident closed", msteams.CardColorGood
+ }
+
return "🟢 " + checkName + " has RECOVERED", msteams.CardColorGood
case eventTypeIncidentEscalated:
return "⚠️ " + checkName + " ESCALATED", msteams.CardColorWarning
diff --git a/server/internal/notifications/ntfy.go b/server/internal/notifications/ntfy.go
index 152c598f2..43b06753f 100644
--- a/server/internal/notifications/ntfy.go
+++ b/server/internal/notifications/ntfy.go
@@ -230,8 +230,8 @@ func (s *NtfySender) buildDownBody(payload *Payload, checkName string) string {
func (s *NtfySender) buildResolvedBody(payload *Payload, checkName string) string {
var builder strings.Builder
- if ResolvedByCheckDeletion(payload.Incident) {
- builder.WriteString(checkDeletedSentence + "\n")
+ if closedWithoutRecovery(payload.Incident) {
+ builder.WriteString(closedSentence(payload.Incident) + "\n")
}
fmt.Fprintf(&builder, "Check: %s (%s)\n", checkName, payload.Check.Type)
diff --git a/server/internal/notifications/pushover.go b/server/internal/notifications/pushover.go
index 12d8c8460..945d32f9a 100644
--- a/server/internal/notifications/pushover.go
+++ b/server/internal/notifications/pushover.go
@@ -281,8 +281,8 @@ func (s *PushoverSender) buildDownBody(payload *Payload, checkName string) strin
func (s *PushoverSender) buildResolvedBody(payload *Payload, checkName string) string {
var builder strings.Builder
- if ResolvedByCheckDeletion(payload.Incident) {
- builder.WriteString(checkDeletedSentence + "\n")
+ if closedWithoutRecovery(payload.Incident) {
+ builder.WriteString(closedSentence(payload.Incident) + "\n")
}
fmt.Fprintf(&builder, "Check: %s (%s)\n", checkName, payload.Check.Type)
diff --git a/server/internal/notifications/slack.go b/server/internal/notifications/slack.go
index d8f0038ab..0b422c770 100644
--- a/server/internal/notifications/slack.go
+++ b/server/internal/notifications/slack.go
@@ -864,6 +864,14 @@ func (s *SlackSender) buildIncidentResolvedThreadReply(payload *Payload) *slack.
)}
}
+ if ResolvedByCheckDisable(payload.Incident) {
+ return &slack.MessageResponse{Text: fmt.Sprintf(
+ ":white_circle: %s%s was disabled after %s, so its incident was closed. "+
+ "The check did not necessarily recover.",
+ refLink, nameLink, duration,
+ )}
+ }
+
text := fmt.Sprintf(
":large_green_circle: %s%s — incident resolved after %s.",
refLink, nameLink, duration,
@@ -1081,6 +1089,9 @@ func (s *SlackSender) buildResolvedUpdateMessage(payload *Payload) *slack.Messag
if ResolvedByCheckDeletion(payload.Incident) {
headline = fmt.Sprintf("Closed %s incident: the check was deleted", checkName)
}
+ if ResolvedByCheckDisable(payload.Incident) {
+ headline = fmt.Sprintf("Closed %s incident: the check was disabled", checkName)
+ }
fallbackText := headline
diff --git a/server/internal/notifications/twilio.go b/server/internal/notifications/twilio.go
index 653638fb7..067dbb5b3 100644
--- a/server/internal/notifications/twilio.go
+++ b/server/internal/notifications/twilio.go
@@ -116,6 +116,11 @@ func (s *TwilioSender) buildBody(
twilio.OptOutFooter
}
+ if ResolvedByCheckDisable(payload.Incident) {
+ return fmt.Sprintf("[%s] %s: %s was disabled, incident closed.", productName, org, checkName) +
+ twilio.OptOutFooter
+ }
+
return fmt.Sprintf("[%s] %s: %s RECOVERED.", productName, org, checkName) +
twilioRegionSentence(payload) + twilio.OptOutFooter
case eventTypeIncidentEscalated:
diff --git a/server/internal/notifications/webhook.go b/server/internal/notifications/webhook.go
index e0fcb9da1..479ab2a66 100644
--- a/server/internal/notifications/webhook.go
+++ b/server/internal/notifications/webhook.go
@@ -84,7 +84,7 @@ type WebhookIncident struct {
RelapseCount int `json:"relapseCount"`
RecoveryPeriodSeconds *int `json:"recoveryPeriodSeconds"`
// ResolutionType says how a resolved incident closed (auto | manual |
- // expired | escalated | disabled | check_deleted). Omitted while active.
+ // expired | escalated | disabled | check_deleted | check_disabled). Omitted while active.
ResolutionType *string `json:"resolutionType,omitempty"`
// Regions says which regions saw the failure. A snapshot taken when the
// incident opened (or reopened) and refreshed on escalation. Omitted when
diff --git a/server/internal/notifications/webpush.go b/server/internal/notifications/webpush.go
index c8add8deb..e30fffbdb 100644
--- a/server/internal/notifications/webpush.go
+++ b/server/internal/notifications/webpush.go
@@ -185,8 +185,8 @@ func buildWebPushEventContent(payload *Payload, checkName string) (string, strin
return ref + "[DOWN] " + checkName,
fmt.Sprintf("%s is down. Cause: %s", checkName, getFailureReason(payload.Incident))
case eventTypeIncidentResolved:
- if ResolvedByCheckDeletion(payload.Incident) {
- return ref + checkDeletedTag + checkName, checkDeletedSentence
+ if closedWithoutRecovery(payload.Incident) {
+ return ref + closedTag(payload.Incident) + checkName, closedSentence(payload.Incident)
}
return ref + recoveredTag + checkName,
diff --git a/specs/done/2026/10/2026-10-23-01-resolve-incidents-when-check-disabled.md b/specs/done/2026/10/2026-10-23-01-resolve-incidents-when-check-disabled.md
new file mode 100644
index 000000000..d699cd104
--- /dev/null
+++ b/specs/done/2026/10/2026-10-23-01-resolve-incidents-when-check-disabled.md
@@ -0,0 +1,134 @@
+---
+model: sonnet
+effort: medium
+---
+
+# Disabling a check leaves its open incidents open forever
+
+GitHub Issue: https://github.com/fclairamb/solidping/issues/647
+
+## Source
+
+Issue #647 (filed 2026-10-23): when a check with an open incident is disabled by hand, the
+incident stays open and keeps showing as ongoing on the dashboard, so the open-incident count
+stops meaning anything. The reporter suggests offering to resolve the open incidents in the
+same action: a confirmation in the UI ("This check has 1 open incident. Resolve it?", default
+yes) and the same option on the API (`PATCH` with `enabled: false` plus
+`resolveOpenIncidents: true`). The resolution must say the incident was closed because the
+check was disabled, not because it recovered. Workaround: resolve each incident by hand.
+
+The report is right. Nothing closes the incident once the check stops running.
+
+## Problem
+
+- `UpdateCheck` (`server/internal/handlers/checks/service.go:2282`) copies `req.Enabled`
+ into the update, writes it, then only calls `resolveDegradedOnDisable`
+ (`service.go:2403`), which reacts to `degradedEnabled: false`, not to `enabled: false`.
+ `enabled: false` only feeds the job reconcile (`service.go:2410`), which removes the
+ check's jobs.
+- With no jobs, no result ever arrives, so the adaptive auto-resolution in the incidents
+ service never runs for that check. An open outage or degraded incident stays
+ `state = active` indefinitely.
+- Two precedents already close incidents on a lifecycle event, and give the shape to copy:
+ - `ResolveDegradedOnDisable` (`server/internal/handlers/incidents/degraded.go:299`) closes
+ the open degraded incident with `resolution_type = "disabled"` when degraded detection
+ is turned off, through the `DegradedIncidentResolver` hook wired into the checks service
+ (`server/internal/handlers/checks/degraded.go:144-175`).
+ - Check deletion resolves every active incident with `resolution_type = "check_deleted"`
+ (`models.ResolutionTypeCheckDeleted`, `server/internal/db/models/incident.go`), then
+ `NotifyCheckDeletedIncidents` (`server/internal/handlers/incidents/check_deleted.go`)
+ cancels pending escalation steps and emits one `incident.resolved` event per incident.
+- The manual resolve path (`ResolveIncident`, `incidents/service.go:4009`) uses
+ `resolution_type = "manual"` and records `resolved_by`. Using it here would lose the
+ "closed because disabled" meaning the reporter asks for.
+- The dashboard toggle (`handleToggleEnabled`,
+ `web/dash0/src/routes/orgs/$org/checks.$checkUid.index.tsx:1147`) sends
+ `{ enabled: next }` with no confirmation.
+
+## Why a spec
+
+- Needs a changed API endpoint: a new `resolveOpenIncidents` field on the check update
+ request, in `openapi.yaml` (`make generate`), and likely on `/apply`, import and the MCP
+ `update_check` tool, which all funnel through `UpdateCheck`.
+- Needs a product decision the reporter did not settle: opt-in prompt versus always closing,
+ and which `resolution_type` to record (see Open questions).
+
+## Proposal
+
+### Backend
+
+1. Add `ResolveOpenIncidents *bool` (`resolveOpenIncidents`) to `UpdateCheckRequest`
+ (`checks/service.go:1991`) and to the OpenAPI schema of the check `PATCH`. Ignored unless
+ the same request sets `enabled: false`. A request with `resolveOpenIncidents: true` and no
+ `enabled: false` is a `VALIDATION_ERROR`.
+2. Add a new resolution type `models.ResolutionTypeCheckDisabled = "check_disabled"` (see
+ Open question 2). No migration is needed: `resolution_type` is a free text column (verify
+ there is no CHECK constraint on either dialect; if there is one, this becomes a migration
+ in the next `NNN` file).
+3. Add `ResolveOnCheckDisable(ctx, orgUID, checkUID string) ([]string, error)` to the
+ incidents service: resolve every active incident (outage and degraded) of the check with
+ `resolution_type = "check_disabled"`, `resolved_by` = the caller when known, cancel
+ pending escalation steps before emitting, emit one `incident.resolved` per incident with
+ `resolutionType` in the payload, and publish the live update. Reuse the
+ `NotifyCheckDeletedIncidents` sequence rather than duplicating it.
+4. Wire it into `UpdateCheck` next to `resolveDegradedOnDisable`, through a small interface
+ on the checks service (same pattern as `DegradedIncidentResolver`), called only when
+ `enabled` is `false` and `resolveOpenIncidents` is `true`. Like the degraded hook, fire on
+ any write that sets both, not only on a true to false transition, so a retry after a
+ failed resolve still closes them.
+5. Expose the open-incident count the UI needs: `GET` check already returns the check; add
+ `openIncidentCount` to the check response, or let the UI query
+ `GET /incidents?checkUid=&state=active&limit=1` (pick the one that needs no new
+ field if it gives the count).
+
+### Notifications and UI copy
+
+6. Resolved notifications (Slack, email, webhook, Telegram) read `resolutionType`: add the
+ `check_disabled` wording ("resolved: check disabled") wherever `check_deleted` has its own
+ wording today (`server/internal/notifications/check_deleted.go`, `webhook.go`,
+ `job_incident_resolution_notice.go`).
+7. dash0: add `"check_disabled"` to the `resolutionType` union (`web/dash0/src/api/hooks.ts:748`)
+ and a label on the incident page next to the `check_deleted` one
+ (`incidents.$incidentUid.tsx:1224`).
+8. dash0: when the user clicks Disable on a check with at least one active incident, open a
+ confirmation dialog: "This check has N open incident(s). Resolve them?", with a checkbox
+ checked by default, and send `{ enabled: false, resolveOpenIncidents: }`. With
+ no open incident, keep today's one-click toggle. `data-testid` on the dialog, checkbox and
+ buttons. i18n keys in every locale the checks namespace ships.
+
+### Docs and tests
+
+9. Docs: the check `PATCH` reference (generated) and the incidents page in `web/docs/` (how
+ incidents end, the new resolution type). CHANGELOG entry.
+10. Go tests (SQLite, `make test`): disabling with `resolveOpenIncidents: true` resolves an
+ open outage and an open degraded incident with `check_disabled`; disabling without it
+ leaves them active (positive control for the absence); `resolveOpenIncidents: true`
+ without `enabled: false` is rejected; the resolved event is emitted once per incident.
+11. Playwright: disable a check with an open incident, keep the checkbox, see the incident
+ resolved with the "check disabled" label.
+
+## Open questions
+
+1. **Opt-in or automatic?** Recommended: opt-in on the API (`resolveOpenIncidents`, default
+ false) and default-checked in the UI, as the reporter suggests. Trade-off: an API or
+ `/apply` user who disables a check still gets the stale incident unless they pass the flag;
+ always closing would be simpler but silently changes the meaning of `enabled: false` for
+ every existing script, and some users disable a check to stop the noise while the outage
+ is still being worked on.
+2. **Which resolution type?** Recommended: a new `check_disabled`. Trade-off: `disabled`
+ already exists but means "degraded detection turned off" (`incident.go`), and reusing it
+ would make the history ambiguous; a new value means one more case in every notification
+ template and in the dash0 union.
+
+## Closing the issue
+
+This spec closes #647. The implementing PR body **must** carry one `Closes #647` line so the
+squash-merge closes it. When the spec is archived to `specs/done/`, verify the issue is
+closed and, if the merge did not close it, close it by hand:
+
+ gh issue close 647 --comment "Implemented by ; spec: "
+
+## Resolved open questions
+
+1. Opt-in on the API (`resolveOpenIncidents`, default false), default-checked in the UI. (autopilot: recommended answer)
+2. New resolution type `check_disabled`. (autopilot: recommended answer)
diff --git a/web/dash0/e2e/check-disable-resolve-incidents.spec.ts b/web/dash0/e2e/check-disable-resolve-incidents.spec.ts
new file mode 100644
index 000000000..7220d9a89
--- /dev/null
+++ b/web/dash0/e2e/check-disable-resolve-incidents.spec.ts
@@ -0,0 +1,105 @@
+import {
+ test,
+ expect,
+ API_BASE,
+ createHeartbeatCheck,
+ getAuthToken,
+ uniqueStamp,
+} from "./fixtures";
+
+// Spec 2026-10-23-01: disabling a check with an open incident offers to resolve
+// it in the same action; the incident then ends as "check_disabled".
+
+test.describe("Disable a check with open incidents", () => {
+ test("keeping the checkbox resolves the incident with check_disabled", async ({
+ authenticatedPage: page,
+ }) => {
+ const token = await getAuthToken(page);
+ const auth = { Authorization: `Bearer ${token}` };
+ const check = await createHeartbeatCheck(page, token, `E2E Disable ${uniqueStamp()}`);
+
+ try {
+ const beat = await page.request.get(
+ `${API_BASE}/api/v1/heartbeat/test/${check.uid}?token=${check.hbToken}&status=down`,
+ );
+ expect(beat.status()).toBe(200);
+
+ const listActive = async () => {
+ const resp = await page.request.get(
+ `${API_BASE}/api/v1/orgs/test/incidents?checkUid=${check.uid}&state=active`,
+ { headers: auth },
+ );
+ return (await resp.json()).data ?? [];
+ };
+ await expect.poll(async () => (await listActive()).length, { timeout: 15_000 }).toBe(1);
+
+ await page.goto(`/d/orgs/test/checks/${check.uid}`);
+ await page.getByTestId("check-toggle-enabled").click();
+
+ await expect(page.getByTestId("disable-check-dialog")).toBeVisible();
+ await expect(page.getByTestId("disable-check-resolve-checkbox")).toBeChecked();
+ await page.getByTestId("disable-check-confirm").click();
+
+ await expect.poll(async () => (await listActive()).length, { timeout: 10_000 }).toBe(0);
+
+ const resolved = await page.request.get(
+ `${API_BASE}/api/v1/orgs/test/incidents?checkUid=${check.uid}&state=resolved`,
+ { headers: auth },
+ );
+ const rows = (await resolved.json()).data;
+ expect(rows).toHaveLength(1);
+ expect(rows[0].resolutionType).toBe("check_disabled");
+
+ await page.goto(`/d/orgs/test/incidents/${rows[0].uid}`);
+ await expect(page.getByTestId("incident-timeline-resolved-reason")).toContainText(
+ /disabled/i,
+ );
+ } finally {
+ await page.request.delete(`${API_BASE}/api/v1/orgs/test/checks/${check.uid}`, {
+ headers: auth,
+ });
+ }
+ });
+
+ test("unchecking the box disables the check and leaves the incident open", async ({
+ authenticatedPage: page,
+ }) => {
+ const token = await getAuthToken(page);
+ const auth = { Authorization: `Bearer ${token}` };
+ const check = await createHeartbeatCheck(page, token, `E2E Disable Keep ${uniqueStamp()}`);
+
+ try {
+ await page.request.get(
+ `${API_BASE}/api/v1/heartbeat/test/${check.uid}?token=${check.hbToken}&status=down`,
+ );
+ const listActive = async () => {
+ const resp = await page.request.get(
+ `${API_BASE}/api/v1/orgs/test/incidents?checkUid=${check.uid}&state=active`,
+ { headers: auth },
+ );
+ return (await resp.json()).data ?? [];
+ };
+ await expect.poll(async () => (await listActive()).length, { timeout: 15_000 }).toBe(1);
+
+ await page.goto(`/d/orgs/test/checks/${check.uid}`);
+ await page.getByTestId("check-toggle-enabled").click();
+ await page.getByTestId("disable-check-resolve-checkbox").click();
+ await page.getByTestId("disable-check-confirm").click();
+
+ await expect
+ .poll(async () => {
+ const resp = await page.request.get(
+ `${API_BASE}/api/v1/orgs/test/checks/${check.uid}`,
+ { headers: auth },
+ );
+ return (await resp.json()).enabled;
+ })
+ .toBe(false);
+ expect(await listActive()).toHaveLength(1);
+ } finally {
+ await page.request.delete(`${API_BASE}/api/v1/orgs/test/checks/${check.uid}`, {
+ headers: auth,
+ });
+ }
+ });
+});
diff --git a/web/dash0/src/api/hooks.ts b/web/dash0/src/api/hooks.ts
index 17102bd4e..4491d05e3 100644
--- a/web/dash0/src/api/hooks.ts
+++ b/web/dash0/src/api/hooks.ts
@@ -455,6 +455,9 @@ export interface CreateCheckRequest {
}
export interface UpdateCheckRequest {
+ /** With `enabled: false`, also close the check's open incidents (spec
+ * 2026-10-23-01). */
+ resolveOpenIncidents?: boolean;
/** Per-check path-trace policy: `inherit`, `on` or `off` (spec
* 2026-08-21-10). `inherit` is what puts a check back under the org default;
* omitting the field leaves it unchanged. */
@@ -745,7 +748,8 @@ export interface IncidentDetail {
| "expired"
| "escalated"
| "disabled"
- | "check_deleted";
+ | "check_deleted"
+ | "check_disabled";
failureCount?: number;
relapseCount?: number;
/**
@@ -1030,6 +1034,7 @@ export function useUpdateCheck(org: string, uid: string) {
queryClient.invalidateQueries({ queryKey: ["checks", org] });
queryClient.invalidateQueries({ queryKey: ["checks", "infinite", org] });
queryClient.invalidateQueries({ queryKey: ["check", org, uid] });
+ queryClient.invalidateQueries({ queryKey: ["incidents", org] });
},
});
}
diff --git a/web/dash0/src/locales/de/checks.json b/web/dash0/src/locales/de/checks.json
index 07aa23852..484010a1d 100644
--- a/web/dash0/src/locales/de/checks.json
+++ b/web/dash0/src/locales/de/checks.json
@@ -755,6 +755,13 @@
"disable": "Deaktivieren",
"enableToast": "Prüfung aktiviert",
"disableToast": "Prüfung deaktiviert",
+ "disableDialog": {
+ "title": "Diese Prüfung deaktivieren?",
+ "description_one": "Diese Prüfung hat {{count}} offenen Vorfall.",
+ "description_other": "Diese Prüfung hat {{count}} offene Vorfälle.",
+ "resolve_one": "Offenen Vorfall ebenfalls lösen",
+ "resolve_other": "Offene Vorfälle ebenfalls lösen"
+ },
"toggleFailed": "Aktualisieren der Prüfung fehlgeschlagen",
"descriptionLabel": "Beschreibung",
"typeLabel": "Typ",
diff --git a/web/dash0/src/locales/de/incidents.json b/web/dash0/src/locales/de/incidents.json
index 2a59d0fbc..518329cae 100644
--- a/web/dash0/src/locales/de/incidents.json
+++ b/web/dash0/src/locales/de/incidents.json
@@ -193,6 +193,7 @@
"reopenedRelapse": "Wieder geöffnet (Rückfall Nr. {{count}})",
"resolved": "Aufgelöst",
"resolvedCheckDeleted": "Geschlossen, weil die Prüfung gelöscht wurde",
+ "resolvedCheckDisabled": "Geschlossen, weil die Prüfung deaktiviert wurde",
"monitoringInterrupted": "Überwachung unterbrochen",
"monitoringInterruptedSince": "Überwachung unterbrochen seit {{time}}",
"monitoringResumed": "Überwachung fortgesetzt",
diff --git a/web/dash0/src/locales/en/checks.json b/web/dash0/src/locales/en/checks.json
index ec45eb074..e31db950c 100644
--- a/web/dash0/src/locales/en/checks.json
+++ b/web/dash0/src/locales/en/checks.json
@@ -799,6 +799,13 @@
"disable": "Disable",
"enableToast": "Check enabled",
"disableToast": "Check disabled",
+ "disableDialog": {
+ "title": "Disable this check?",
+ "description_one": "This check has {{count}} open incident.",
+ "description_other": "This check has {{count}} open incidents.",
+ "resolve_one": "Also resolve the open incident",
+ "resolve_other": "Also resolve the open incidents"
+ },
"toggleFailed": "Failed to update check",
"descriptionLabel": "Description",
"typeLabel": "Type",
diff --git a/web/dash0/src/locales/en/incidents.json b/web/dash0/src/locales/en/incidents.json
index afaccf02b..ca35c6dc1 100644
--- a/web/dash0/src/locales/en/incidents.json
+++ b/web/dash0/src/locales/en/incidents.json
@@ -193,6 +193,7 @@
"reopenedRelapse": "Reopened (relapse #{{count}})",
"resolved": "Resolved",
"resolvedCheckDeleted": "Closed because the check was deleted",
+ "resolvedCheckDisabled": "Closed because the check was disabled",
"monitoringInterrupted": "Monitoring interrupted",
"monitoringInterruptedSince": "Monitoring interrupted since {{time}}",
"monitoringResumed": "Monitoring resumed",
diff --git a/web/dash0/src/locales/es/checks.json b/web/dash0/src/locales/es/checks.json
index 1a8bb82f5..d8e5f34ab 100644
--- a/web/dash0/src/locales/es/checks.json
+++ b/web/dash0/src/locales/es/checks.json
@@ -755,6 +755,13 @@
"disable": "Desactivar",
"enableToast": "Comprobación activada",
"disableToast": "Comprobación desactivada",
+ "disableDialog": {
+ "title": "¿Desactivar esta comprobación?",
+ "description_one": "Esta comprobación tiene {{count}} incidente abierto.",
+ "description_other": "Esta comprobación tiene {{count}} incidentes abiertos.",
+ "resolve_one": "Resolver también el incidente abierto",
+ "resolve_other": "Resolver también los incidentes abiertos"
+ },
"toggleFailed": "No se pudo actualizar la comprobación",
"descriptionLabel": "Descripción",
"typeLabel": "Tipo",
diff --git a/web/dash0/src/locales/es/incidents.json b/web/dash0/src/locales/es/incidents.json
index 8063cac4e..4c336bf8d 100644
--- a/web/dash0/src/locales/es/incidents.json
+++ b/web/dash0/src/locales/es/incidents.json
@@ -193,6 +193,7 @@
"reopenedRelapse": "Reabierto (recaída n.º {{count}})",
"resolved": "Resuelto",
"resolvedCheckDeleted": "Cerrado porque se eliminó la comprobación",
+ "resolvedCheckDisabled": "Cerrado porque se desactivó la comprobación",
"monitoringInterrupted": "Monitoreo interrumpido",
"monitoringInterruptedSince": "Monitoreo interrumpido desde las {{time}}",
"monitoringResumed": "Monitoreo reanudado",
diff --git a/web/dash0/src/locales/fr/checks.json b/web/dash0/src/locales/fr/checks.json
index b8e95fcd1..ee576e8d8 100644
--- a/web/dash0/src/locales/fr/checks.json
+++ b/web/dash0/src/locales/fr/checks.json
@@ -799,6 +799,13 @@
"disable": "Désactiver",
"enableToast": "Contrôle activé",
"disableToast": "Contrôle désactivé",
+ "disableDialog": {
+ "title": "Désactiver ce contrôle ?",
+ "description_one": "Ce contrôle a {{count}} incident ouvert.",
+ "description_other": "Ce contrôle a {{count}} incidents ouverts.",
+ "resolve_one": "Résoudre aussi l'incident ouvert",
+ "resolve_other": "Résoudre aussi les incidents ouverts"
+ },
"toggleFailed": "Échec de la mise à jour du contrôle",
"descriptionLabel": "Description",
"typeLabel": "Type",
diff --git a/web/dash0/src/locales/fr/incidents.json b/web/dash0/src/locales/fr/incidents.json
index f3c79d512..8a8d1a630 100644
--- a/web/dash0/src/locales/fr/incidents.json
+++ b/web/dash0/src/locales/fr/incidents.json
@@ -193,6 +193,7 @@
"reopenedRelapse": "Rouvert (rechute n°{{count}})",
"resolved": "Résolu",
"resolvedCheckDeleted": "Clôturé car le contrôle a été supprimé",
+ "resolvedCheckDisabled": "Clôturé car le contrôle a été désactivé",
"monitoringInterrupted": "Surveillance interrompue",
"monitoringInterruptedSince": "Surveillance interrompue depuis {{time}}",
"monitoringResumed": "Surveillance reprise",
diff --git a/web/dash0/src/routes/orgs/$org/checks.$checkUid.index.tsx b/web/dash0/src/routes/orgs/$org/checks.$checkUid.index.tsx
index d98317b4b..0964af5ce 100644
--- a/web/dash0/src/routes/orgs/$org/checks.$checkUid.index.tsx
+++ b/web/dash0/src/routes/orgs/$org/checks.$checkUid.index.tsx
@@ -78,6 +78,7 @@ import { Switch } from "@/components/ui/switch";
import { CollapsibleCode } from "@/components/shared/copyable-code";
import { CheckRegionOutageBanner } from "@/components/shared/region-outage-banner";
import { CollapsibleSection } from "@/components/ui/collapsible-section";
+import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
Card,
@@ -727,6 +728,8 @@ function CheckDetailPage() {
const navigate = useNavigate();
const router = useRouter();
const { user: authUser } = useAuth();
+ const [disableDialogOpen, setDisableDialogOpen] = useState(false);
+ const [resolveOnDisable, setResolveOnDisable] = useState(true);
const [deleteOpen, setDeleteOpen] = useState(false);
const [publishOpen, setPublishOpen] = useState(publish === true);
const [editingSlug, setEditingSlug] = useState(false);
@@ -1144,11 +1147,33 @@ function CheckDetailPage() {
}
};
- const handleToggleEnabled = async () => {
+ const { data: activeIncidents } = useIncidents(org, {
+ checkUid: check?.uid,
+ state: "active",
+ size: 100,
+ enabled: !!check?.uid,
+ });
+ const openIncidentCount = activeIncidents?.data?.length ?? 0;
+
+ // Disabling a check with open incidents asks whether to close them too:
+ // a disabled check has no jobs, so nothing else ever would.
+ const handleDisableClick = () => {
+ if (check?.enabled && openIncidentCount > 0) {
+ setResolveOnDisable(true);
+ setDisableDialogOpen(true);
+ return;
+ }
+ handleToggleEnabled();
+ };
+
+ const handleToggleEnabled = async (resolveOpenIncidents?: boolean) => {
if (!check) return;
const next = !check.enabled;
try {
- await updateCheck.mutateAsync({ enabled: next });
+ await updateCheck.mutateAsync({
+ enabled: next,
+ ...(resolveOpenIncidents ? { resolveOpenIncidents: true } : {}),
+ });
toast.success(
next
? (t("checks:detail.enableToast") ?? "Check enabled")
@@ -1432,7 +1457,8 @@ function CheckDetailPage() {
: (t("checks:detail.enable") ?? "Enable")
}
disabled={updateCheck.isPending}
- onClick={handleToggleEnabled}
+ onClick={handleDisableClick}
+ data-testid="check-toggle-enabled"
>
@@ -1441,6 +1467,47 @@ function CheckDetailPage() {
: t("checks:detail.enable")}
+
+
+
+
+ {t("checks:detail.disableDialog.title")}
+
+
+ {t("checks:detail.disableDialog.description", {
+ count: openIncidentCount,
+ })}
+
+
+
+ setResolveOnDisable(v === true)}
+ data-testid="disable-check-resolve-checkbox"
+ />
+
+ {t("checks:detail.disableDialog.resolve", {
+ count: openIncidentCount,
+ })}
+
+
+
+
+ {t("common:cancel")}
+
+ handleToggleEnabled(resolveOnDisable)}
+ data-testid="disable-check-confirm"
+ >
+ {t("checks:detail.disable")}
+
+
+
+
diff --git a/web/docs/docs/configuration/notifications.md b/web/docs/docs/configuration/notifications.md
index a841826cc..99876dd3a 100644
--- a/web/docs/docs/configuration/notifications.md
+++ b/web/docs/docs/configuration/notifications.md
@@ -968,7 +968,7 @@ specification, so receivers can verify it came from SolidPing.
- `resolvedAt` and `durationSeconds` are `null` while the incident is active.
- `resolutionType` (`auto`, `manual`, `expired`, `escalated`, `disabled`,
- `check_deleted`) is only present once the incident is resolved.
+ `check_deleted`, `check_disabled`) is only present once the incident is resolved.
- `acknowledgedAt` is only present once the incident has been acknowledged.
- `recoveryPeriodSeconds` is `null` until the incident has relapsed at least once.
- `regions` holds the slugs (not labels) of the regions that saw the failure,
diff --git a/web/docs/docs/features/incidents.md b/web/docs/docs/features/incidents.md
index 48d3123ee..d0ff54672 100644
--- a/web/docs/docs/features/incidents.md
+++ b/web/docs/docs/features/incidents.md
@@ -101,6 +101,15 @@ organization). Their pending escalation steps stop. A single-check delete sends
one resolved notification per incident, worded "check deleted" rather than
"recovered". Deleting an organization sends none.
+Disabling a check does not touch its open incidents by default: a disabled check
+runs no more probes, so nothing would ever auto-resolve them. To close them in
+the same action, send `resolveOpenIncidents: true` with `enabled: false` on the
+check `PATCH` (the dashboard asks, checked by default, when the check has an open
+incident). They resolve with a `check_disabled` resolution, record who disabled
+the check, stop their pending escalation steps, and send one resolved
+notification each, worded "check disabled" rather than "recovered".
+`resolveOpenIncidents` without `enabled: false` is a `VALIDATION_ERROR`.
+
Acknowledging is not dashboard-only: Slack and Telegram alerts both carry an
**Acknowledge** button, and Telegram additionally accepts `/ack #42`. Every path
goes through the same service call, so all of them are idempotent and all of