Astrolabe / ajxpublic /korvus
Repository

Incident Report Schema

reference scope internal/report Verification passed
  • internal/report/schema.go
  • internal/report/renderer.go
  • ReportData
  • IncidentMeta
  • Signal
  • Hypothesis
  • AuditSummary
Relevant source files · 7

The internal/report package is the contract between Korvus's agent loop and everything downstream that consumes an incident's analysis. It holds a single file of substance — schema.go — which defines the JSON shape of a root-cause report: the narrative summary, the signals the investigation observed, the ranked hypotheses about root cause, and the audit trail of the run that produced them. There is no logic here, and that is the point: this package is the seam where the model's free-form output becomes structured, versioned, persistable data.

The repo keeps this schema in its own tiny package rather than beside the agent code, and the reason shows up in the type comments themselves. The JSON tags are not Go-flavored conveniences; they are literally "the contract with the model" ReportData. The finalization prompt asks the LLM for exactly those snake_case keys, the agent unmarshals the reply into report.ReportData, and the same struct is then serialized into the data column of the reports table Report. One struct, two consumers, one set of key names.

How it works

The split between model-authored and programmatic fields

ReportData is a bag of nine fields, but they are not of the same kind. Four of them — incident, generated_at, model_id, and audit_summary — are filled programmatically by Korvus itself and never by the model ReportData. The other five (summary, key_signals, blast_radius, hypotheses, next_steps) are exactly what the finalize prompt requests from the model, in that order, as a JSON object with no fences and no surrounding prose finalizePrompt.

The prompt and the schema are kept in lockstep by hand: the prompt template spells out key_signals with source/metric/observation, hypotheses with title/confidence/evidence/action, and next_steps as an array of strings — the same names the struct declares Signal, Hypothesis. If you add a field to the struct you must add it to the prompt; if you rename one you must change both, and the schema's own doc comment warns that renaming or removing a field is a breaking change for already-deployed templates ReportData.

Programmatic population

Agent::buildReport is where the programmatic half is stamped onto whatever the model produced buildReport. It computes the incident metadata — ID, alert name, namespace, the full label map, and the elapsed duration since the incident started — and assigns it to data.Incident as an IncidentMeta IncidentMeta. It sets generated_at to the current UTC instant and model_id to whichever provider the session ended on.

The audit_summary block is the accountability record of the run itself: how many tool calls were made, how many tokens were spent counting input and output across every call including retries, which skills were used, which skills were merely loaded, and the exit reason that ended the session AuditSummary.

The two skill lists deserve a note, because they answer different questions. skills_loaded is every skill injected into the system prompt; skills_used is narrower — only MCP-attributable usage, meaning the skill declared an mcp_server in its frontmatter and a tool from that server was actually invoked skillsUsed. A skill with no mcp_server can never be attributed to a call and so is always absent from skills_used; that asymmetry is deliberate and documented on the struct. The two lists are also never null: the code allocates an empty slice rather than leaving a nil, because a report where one pair member is [] and the other is null forces every consumer to handle both spellings skillsUsed.

Validation and the fallback path

The model's JSON is not trusted blindly. parseReportJSON extracts the first JSON object from the response — tolerating markdown fences and surrounding prose, the two universal model sins — and then unmarshals it into a report.ReportData parseReportJSON. Three validation rules then apply: summary must be present and non-blank, hypotheses must contain at least one entry, and every hypothesis's confidence must fall in the closed interval 0.0–1.0.

A failure at any of these steps is not fatal. The finalize loop retries up to maxFinalizeAttempts times, and after each parse failure it appends the error text back into the transcript as a user message so the model can fix its own JSON finalize. If every attempt fails, or if the provider is dead, fallbackReportData produces a programmatic report instead: a summary stating that automated analysis failed and why, plus next steps pointing the operator at the agent transcript and at manual investigation fallbackReportData. The degradation ladder's promise — every exit path lands a report — is enforced here.

Persistence and transport

Once built, the ReportData is marshaled to JSON and stored as the Data column of a report row buildReport. The same row carries the session and incident IDs, the burst number, the session turn range the iteration covered, the model ID, and the exit reason; the markdown document, if authoring produced one, lives separately in blob storage and is referenced by key Report.

When the report is served over the Connect API, the stored JSON is decoded into a generic map and re-encoded as a structpb.Struct, so the schema's field names reach clients unchanged as structured JSON rather than as an opaque blob reportToProto.

The empty renderer

internal/report/renderer.go currently contains nothing but a package declaration renderer.go. It is a placeholder for template rendering of ReportData into a human-facing document. Today that job belongs to the agent's author phase, which writes a markdown report into the workspace and verifies it by reading it back AuthoredReport — the JSON schema is the index of that document, not the document itself.

Relationships

graph TD
  subgraph report["internal/report"]
    schema["schema.go"]
    renderer["renderer.go"]
    ReportData["ReportData"]
    IncidentMeta["IncidentMeta"]
    Signal["Signal"]
    Hypothesis["Hypothesis"]
    AuditSummary["AuditSummary"]
  end

  schema --> ReportData
  schema --> IncidentMeta
  schema --> Signal
  schema --> Hypothesis
  schema --> AuditSummary
  ReportData --> IncidentMeta
  ReportData --> Signal
  ReportData --> Hypothesis
  ReportData --> AuditSummary
  renderer -.-> ReportData

  prompt["finalizePrompt"] -->|"demands these keys"| ReportData
  finalize["Agent::finalize"] --> ReportData
  fallback["fallbackReportData"] --> ReportData
  build["buildReport"] -->|"stamps programmatic fields"| ReportData
  build --> Report
  Report --> storage["PostgreSQL reports table"]
  storage --> proto["reportToProto"]
  proto --> api["Connect API"]

Reference

Symbol Kind What it does Location
schema.go file The package's only substantive file: the JSON contract for incident reports internal/report/schema.go
renderer.go file Package stub reserved for template rendering internal/report/renderer.go
ReportData struct The report root: narrative, signals, blast radius, hypotheses, next steps, audit ReportData
IncidentMeta struct Identifies the incident a report belongs to (ID, alert, namespace, labels, duration) IncidentMeta
Signal struct One piece of observed evidence: source, metric/selector, observation Signal
Hypothesis struct One candidate root cause with confidence, evidence, and remediation action Hypothesis
AuditSummary struct Run accountability: tool calls, tokens, skills used/loaded, exit reason AuditSummary

For how these fields are produced and validated, see Agent Prompts, Report Authoring and Finalization. For where the marshaled JSON lands, see Storage Domain Model and Store Interfaces and PostgreSQL Persistence, Migrations and Vector Search. For the API surface that exposes it, see Connect API Server and Service Handlers and Watch Streams, Read APIs, Webhooks and Proto Conversion.


Generated by astrolabe from commit 0c2dca73 on branch main.