Incident Manager: Open, List, Update, Close
- 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.