Astrolabe / ajxpublic /korvus
Repository

Incident Manager: Open, List, Update, Close

reference scope internal/incident Verification failed
  • internal/incident/manager.go
  • OpenRequest
  • ListFilter
  • UpdateRequest
  • Manager
  • Store
Relevant source files · 5

An incident in Korvus is the long-lived context space an investigation happens inside: it is opened by an engineer, its descriptive fields are edited while the investigation runs, and it is closed exactly once with a terminal status. The internal/incident package owns that lifecycle, and Manager in internal/incident/manager.go is the manual path through it — the one an engineer drives by hand.

The package deliberately splits the work in two. Manager handles incidents a person opens; Ingestor handles incidents the Alertmanager webhook opens, with a deduplication fingerprint and its own debounce window (see Alert Ingestion, Fingerprinting and Debounce). Keeping the two apart is not cosmetic: the manager's request types cannot even express a fingerprint, so a manually opened incident cannot be made to look like an alert one, and the two paths cannot race over the same row.

The manager sits between the transport and the database. It does not own persistence — PostgreSQL Persistence, Migrations and Vector Search does — but it owns the rules that need a read before a write: a closed incident may not be edited, a terminal status may only be reached through Close, and a second close is refused. The Storage Domain Model and Store Interfaces page describes the row shape and the wider persistence contract; this page is about the rules layered on top of it.

How it works

The manager's only dependency is a narrow Store interface declared in the consuming package rather than imported from the storage layer: CreateIncident, GetIncident, ListIncidents, UpdateIncident and CloseIncident Store. It is the incident half of the full store.Store contract Store, and keeping it narrow means a test can substitute a fake without dragging in sessions, reports and artifacts.

Manager holds that store plus an injected clock. The clock is unexported and defaults to the wall clock, so tests can pin timestamps without sleeping Manager NewManager.

Open

Open takes an OpenRequest — title, description, severity, opener, labels, annotations and an optional start time OpenRequest. The type has no Origin, Fingerprint, Status or ID field on purpose: those are the manager's to set, and a request type that cannot spell them makes "manual incidents never carry a fingerprint" structural instead of a rule someone has to remember.

The method trims the title and refuses an empty one with ErrTitleRequired, because every other field has a defensible default but an untitled incident is unfindable in a list. Severity falls back to defaultSeverity ("unknown") when the caller says nothing, so the value is stored rather than left empty and a severity filter sees every incident. The start time defaults to the open time when the operator did not say otherwise. The manager then fills in a UUID v7 identifier, the open status, the manual origin and a nil fingerprint, and hands the row to CreateIncident Open. The row it produces is the store.Incident shape the rest of the system reads.

Get and List

Get is a thin read: it asks the store for one incident and returns it, or an error wrapping the store's not-found sentinel Get.

List takes a ListFilter — status, origin, limit and cursor, each empty meaning "no narrowing" ListFilter. The filter mirrors the storage-level IncidentFilter instead of re-exporting it, so the transport depends on the manager's vocabulary IncidentFilter. The manager translates its filter into the store's, calls ListIncidents, and computes the cursor for the next page with store.NextIncidentCursor List. That helper lives on the storage side because the cursor's encoding is a storage detail: the value is handed back to the caller and returned verbatim on the next call, never parsed by anyone above NextIncidentCursor. The cursor is empty when the page was the last one, which is how a caller knows to stop.

Update

Update carries the descriptive fields an operator may change while an incident is open — UpdateRequest UpdateRequest. The lifecycle columns are absent by construction, and every field replaces the stored value, including the empty string and a nil map UpdateRequest.

The method first reads the current row. If that row already carries a close time, the update is refused with ErrAlreadyClosed Update. The status is then resolved: an empty status keeps the current one, a terminal status is refused with ErrTerminalStatus because closing is Close's alone, and any status other than open or investigating is refused with the store's invalid-status sentinel Update. An empty title is refused just as it is on Open.

The write itself copies the stored row and overwrites only the mutable fields; origin, fingerprint, creation time and the close columns are taken from the stored row rather than echoed back from the request, so the returned incident cannot disagree with the database Update. Underneath, the store's update statement only touches the mutable columns and matches closed_at IS NULL UpdateIncident; when it matches no row, the store disambiguates the outcome into either not-found or already-closed rather than collapsing the two classifyMissingIncident. The manager translates the store's already-closed sentinel onto its own, so a caller sees the same error — and the transport the same code — whichever path produced it Update.

Close

Close is the only path that stamps a terminal status. It defaults an empty status to resolved and refuses anything that is not resolved or cancelled Close. The store's close statement is likewise conditional on closed_at IS NULL, which makes "close once" a property of the statement rather than of a read-then-write in the caller; a close that matches no row is disambiguated by a follow-up read CloseIncident. The manager translates the store's already-closed sentinel and then reads the incident back, so the caller receives the stored row including its close time and reason Close.

Error handling

The manager exposes its failures as sentinels, and callers match on those rather than on message text errors:

Sentinel Meaning
ErrTitleRequired an open or update carried no title
ErrNotFound no incident has the requested ID; it wraps the store's not-found sentinel
ErrAlreadyClosed a second lifecycle transition on an already-closed incident
ErrTerminalStatus an update tried to set a terminal status
ErrFingerprintNotManual a manual incident was asked to carry a fingerprint

The transport turns those into Connect codes in one place incidentError: not-found becomes NotFound; the title, fingerprint, terminal-status and invalid-status failures become InvalidArgument; already-closed becomes FailedPrecondition, because the incident exists and the request is well-formed but the resource's state refuses the transition. Anything unrecognised becomes Internal.

The Connect API Server and Service Handlers page describes the RPCs themselves, and Generated Korvus v1 Protobuf and Connect API covers the wire types these handlers serve.

Relationships

The transport consumes the manager through its own narrow interface, IncidentManager, which is exactly the five lifecycle methods IncidentManager. When a process is built without an incident manager, the transport mounts the unimplemented implementation instead, whose lifecycle RPCs answer with a Connect Unimplemented error stating that no incident RPC is served errNoIncidentManager NewMux.

flowchart TD
  Client["Connect client"] --> RPC["`incidentService` in internal/transport/incident.go"]
  RPC --> IM["`IncidentManager`"]
  IM --> M["`Manager`"]
  M --> O["`OpenRequest`"]
  M --> LF["`ListFilter`"]
  M --> UR["`UpdateRequest`"]
  M --> ST["`Store`"]
  ST --> PG["internal/store/postgres_incident.go"]
  M -.-> SESS["internal/session/manager.go"]

Sessions hang off an incident rather than off the manager: an incident is the context space that holds N sessions, and the session side of the system is described in Session Manager and Lifecycle. The alert path is the other way into the same table, described in Alert Ingestion, Fingerprinting and Debounce. For the whole repository in one page, start at Korvus Overview.

Reference

Symbol Kind What it does Location
Manager struct Owns the incident lifecycle rules over a store and an injected clock src/internal/incident/manager.go#L67-L78
NewManager function Builds a Manager backed by a store, using the wall clock src/internal/incident/manager.go#L80-L82
Store interface The manager's narrow view of incident persistence src/internal/incident/manager.go#L33-L39
OpenRequest struct What an engineer supplies when opening an incident src/internal/incident/manager.go#L90-L102
ListFilter struct Status, origin, limit and cursor narrowing a list src/internal/incident/manager.go#L157-L162
UpdateRequest struct The descriptive fields an operator may change src/internal/incident/manager.go#L187-L196
Open method Validates and stores a new manual incident src/internal/incident/manager.go#L105-L142
Get method Reads one incident src/internal/incident/manager.go#L142-L155
List method Returns a page of incidents and the next cursor src/internal/incident/manager.go#L165-L183
Update method Replaces an open incident's descriptive fields src/internal/incident/manager.go#L211-L267
Close method Stamps the terminal status, close time and reason src/internal/incident/manager.go#L272-L297
ErrTitleRequired variable Open or update carried no title src/internal/incident/manager.go#L42-L60
ErrNotFound variable No incident has the requested ID src/internal/incident/manager.go#L42-L60
ErrAlreadyClosed variable Second lifecycle transition on a closed incident src/internal/incident/manager.go#L42-L60
ErrTerminalStatus variable An update tried to set a terminal status src/internal/incident/manager.go#L42-L60
ErrFingerprintNotManual variable A manual incident was given a fingerprint src/internal/incident/manager.go#L42-L60

Generated by astrolabe from commit 0c2dca73 on branch main.