The incident fleet
This guide shows four agents working one incident through a single kslate record. Each agent owns a few fields and reads the rest. No agent calls another. An agent acts when a field it reads changes. You will see the class that defines the record, what each field is for, and the writes from one real run. One of those writes is the one kslate rejected.
The class
The class defines the record’s fields, who may write each one, and how long records live.
apiVersion: kslate.dev/v1alpha1
kind: SharedContext
metadata: { name: incidents, namespace: kagent }
spec:
fields:
status: { type: string, mode: versioned, enum: [open, mitigating, resolved] }
affected_service: { type: string, mode: soft }
symptoms: { type: append }
hypotheses: { type: append }
planned_action: { type: string, mode: versioned, evidence: required }
remediation: { type: task, lease: 300s }
actions_taken: { type: append }
customer_updates: { type: append }
roles:
detector: { identities: [sa:kagent/detector], read: ["*"], write: [status, affected_service, symptoms] }
diagnoser: { identities: [sa:kagent/diagnoser], read: ["*"], write: [hypotheses, planned_action] }
remediator: { identities: [sa:kagent/remediator], read: ["*"], write: [status, actions_taken], claim: [remediation] }
communicator: { identities: [sa:kagent/communicator], read: ["*"], write: [customer_updates] }
operator: { identities: ["sa:kagent/kslate-cli", "user:*"], read: ["*"], write: [] }
ttl: { after: 168h, onExpire: archive }
checkpoint:
required: [remediator]
sections: [goal, decisions, progress, open_questions]This class is the incidents document in testdata/contexts.yaml, which
also holds two smaller classes used by the tests. The Helm chart installs
a near-identical copy when you set example.enabled. Both add API-key
identities alongside the ServiceAccount ones, so the suite can run
without Kubernetes.
The fields
Four agents share the record. Each field has one job.
| Field | Mode | Written by | Read by | What it holds |
|---|---|---|---|---|
symptoms | append | detector | diagnoser | One entry per observation. Each entry cites the alert or query that produced it. Nothing is summarised or overwritten. |
hypotheses | append | diagnoser | all | One entry per hypothesis. A revised hypothesis is a new entry that names the symptoms version it is based on. Older entries stay. |
planned_action | versioned, evidence required | diagnoser | remediator | The one current plan. To revise it, the diagnoser writes a new value with the version it last saw and cites the hypothesis it follows from. |
remediation | task, lease 300 s | remediator (claim) | all | Which agent is carrying out the plan right now. Claimed, renewed, released, or taken over when its lease runs out. |
actions_taken | append | remediator | communicator | What was done. Each entry names the planned_action version it carried out in based_on. |
customer_updates | append | communicator | all | Messages to customers. Written when actions_taken changes. |
status | versioned, enum | detector, remediator | all | open, mitigating or resolved. Two roles write it, so the version check stops them overwriting each other. Setting resolved requires a remediator checkpoint first. |
affected_service | soft | detector | all | Scratch context. Last write wins. |
The communicator cannot set status. It reports on the incident. It does
not declare the incident over. The operator role reads everything and
writes nothing. The CLI runs as the operator.
One run
These are the writes from one run against this class. Versions are per field.
- The detector appends
symptomsentry 1. It then writesstatus = openwithexpected_version: 0, which creates the record. - The diagnoser appends
hypothesesentry 1 withbased_on: symptoms@1. It then writesplanned_actionv1 (“restart payments-api pods”) withevidence: hypotheses@1. - The remediator claims
remediation. The lease is 300 seconds, from the class. It starts carrying out plan v1. - The detector appends
symptomsentry 2: replica lag on payments-db. - The diagnoser appends
hypothesesentry 2 withbased_on: symptoms@2. It then writesplanned_actionv2 (“fail over payments-db to replica 2”) withexpected_version: 1. - The remediator appends to
actions_takenwithbased_on: planned_action@1. kslate rejects it:
{
"error": "STALE_PREMISE",
"record": "incidents/inc-4471",
"field": "actions_taken",
"current_version": 0,
"current_author": null,
"moved": [
{ "field": "planned_action", "your_version": 1, "current_version": 2,
"current_author": "apikey:diagnoser", "current_evidence": "hypotheses@2" }
]
}moved tells the remediator that planned_action went from version 1
to version 2, who changed it, and what they cited.
- The remediator reads
planned_actionagain. It carries out v2 and appends toactions_takenwithbased_on: planned_action@2. kslate accepts the write. - The remediator saves a checkpoint with
progress: failover done; awaiting rollout verification. It then releases the claim. - The communicator appends to
customer_updates. - The detector writes
status = resolved. This works because the remediator checkpointed in step 8. Without that checkpoint kslate would reject it withINVALID_CHECKPOINT.
Step 6 is the reason this guide exists. The remediator was about to record an action based on a plan the diagnoser had already replaced. kslate rejected the write and said what had changed. Nothing told the remediator to wait. The diagnoser did not know the remediator existed.
Writing an agent for this class
the pages before this one covers writing an agent from scratch. For this class, the remediator is the interesting one. It runs as several copies, works every open incident, and must not act on a plan that has changed. With the Python client that is one call:
from kslate import Client, Agent, Write, run
def execute(fields):
plan = fields["planned_action"]
# ... carry out plan["value"] ...
return [Write(op="append", field="actions_taken",
entry=f"executed: {plan['value']}", evidence="kubectl output")]
run(
Agent(decide=execute, reads=["planned_action"], writes=["actions_taken"]),
Client("http://kslate-gateway.kslate.svc", api_key=token),
class_name="incidents",
filters={"status": "open"},
claim_field="remediation",
lease_seconds=300,
concurrency=4,
)Each copy polls for open incidents and claims remediation on one. It
reads the record, calls execute, and writes the result with based_on
set to the planned_action version it read. Then it releases the claim.
A copy that finds the task already claimed skips that incident. That is how the copies share work without talking to each other. The agent loop explains each step.
The claim is on the remediation field, not the whole record, so the
diagnoser keeps revising planned_action while a remediator holds it.
If the plan changes while execute is running, the write is rejected and
the loop calls execute again with the new plan.
The other three agents only read and write their own fields. The
detector and communicator can run as single-record cycles triggered by
an alert or a change. The diagnoser watches symptoms the same way the
remediator watches planned_action.
If you cannot change the agent’s code at all, the kagent guide shows how to run the same loop as a sidecar.
Testing the scenario
You can test this scenario four ways. Each one asks a question about the record afterwards.
| Test | What goes wrong | What the record shows |
|---|---|---|
| Concurrency | The plan changes while the remediator is deciding. | A STALE_PREMISE row for the write that would have carried out the old plan. The accepted write names the new version. |
| Crash | The remediator pod is deleted mid-action. | The replacement’s claim response has took_over_from. When it loads the checkpoint, the response returns progress and changed_fields. It verifies the earlier action instead of repeating it. |
| Spoof | A write includes author: sa:kagent/diagnoser in the body. | IDENTITY_MISMATCH. The attempt is recorded in history under the real identity. |
| Forensics | An operator asks who changed the plan and what it was based on. | kslate blame incidents/inc-4471 planned_action lists every version with author, evidence and premises, plus every rejected write that depended on it. |
kslate verify incidents/inc-4471 reads the same history. It flags an
agent that wrote without based_on, resubmitted a rejected write
unchanged, misused the record, or never wrote at all. Add --move planned_action=<value> --watch 60s to
change the plan while an agent is running and see what it does next.
A runnable version of this scenario, with a stub agent on kagent, is in a companion repository.