The same exchange on the wire
This page is optional. Your first agent does the same job in Python in less time; read this one if you are not using a client library, or if you want to see the protocol itself.
You act as two agents sharing one incident record. The diagnoser writes a plan and the remediator acts on it. In between, the plan changes, so kslate rejects the remediator’s write and tells it what moved.
Before you start
You need Go 1.26 and Docker.
Clone the repository and start a gateway.
git clone https://github.com/kslate-dev/kslate && cd kslate make runThe gateway is on
127.0.0.1:8080, with one API key per role. Each token maps to a name, sodiag-tokenbecomes the identityapikey:diagnoserandrem-tokenbecomesapikey:remediator.In a second shell, set up the two identities.
export B=http://127.0.0.1:8080 export DIAG='Authorization: Bearer diag-token' export REM='Authorization: Bearer rem-token'
The class you are writing to
make run loads examples/contexts.yaml, the starter class, which is
called incidents. A class declares the fields a record may have, how each may
be written, and which identities may write which. Nothing in this guide
creates it, and you cannot write a field the class does not declare.
These are the parts of incidents this guide uses, with the other
fields and roles cut out:
kind: SharedContext
metadata: { name: incidents } # the class name, and the first path segment below
spec:
fields:
planned_action: { type: string, mode: versioned, evidence: required }
actions_taken: { type: append }
roles:
diagnoser: { identities: [apikey:diagnoser], read: ["*"], write: [planned_action] }
remediator: { identities: [apikey:remediator], read: ["*"], write: [actions_taken] }Every name and rule in the steps below comes from that block.
Which line of the class causes what
| In the commands below | Because the class says |
|---|---|
/v1/records/incidents/inc-1 | the class is named incidents; inc-1 is a record id you pick |
planned_action takes expected_version | mode: versioned |
planned_action needs evidence | evidence: required |
actions_taken is written with POST, not PUT | type: append adds entries instead of replacing a value |
diag-token may write planned_action, rem-token may not | the write list on each role |
The real class has eight fields and six roles. Design a class covers writing your own.
The same class works on Kubernetes
kind: SharedContext is not a file format. It is the Kubernetes custom
resource, and the same spec either way: off Kubernetes you put the
document in a file and pass --policy, on Kubernetes you kubectl apply
it. One decoder and one set of rules read both, so nothing you learn here
is thrown away when you move to a cluster. Only metadata.namespace and
status behave differently, and neither appears above.
The diagnoser writes a plan
The first write to a record creates it. planned_action is a versioned
field that requires evidence. expected_version: 0 means “this field must
not exist yet”.
curl -s -X PUT $B/v1/records/incidents/inc-1/fields/planned_action -H "$DIAG" \
-d '{"value":"restart payments-api pods","expected_version":0,"evidence":"api pods in CrashLoopBackOff"}'{
"record": "incidents/inc-1",
"field": "planned_action",
"version": 1,
"record_version": 1
}The response is 201: the write created the record.
The remediator reads the plan
The remediator reads the field it is about to act on. Note the version it gets back.
curl -s "$B/v1/records/incidents/inc-1?fields=planned_action" -H "$REM"{
"record": "incidents/inc-1",
"record_version": 1,
"fields": {
"planned_action": {
"value": "restart payments-api pods",
"version": 1,
"author": "apikey:diagnoser",
"evidence": "api pods in CrashLoopBackOff",
"updated_at": "2026-10-01T22:34:15.401558-07:00"
}
}
}Note "version": 1. The remediator will use that.
The diagnoser changes the plan
The diagnoser sends the version it holds, 1, and a new value.
curl -s -X PUT $B/v1/records/incidents/inc-1/fields/planned_action -H "$DIAG" \
-d '{"value":"roll back api deployment","expected_version":1,"evidence":"api logs show config parse error at 14:02:11"}'{
"record": "incidents/inc-1",
"field": "planned_action",
"version": 2,
"record_version": 2
}planned_action is now at version 2.
The remediator acts on the plan it read
The remediator did not see the change. It records its action and says what
it was based on: planned_action at version 1.
curl -s -X POST $B/v1/records/incidents/inc-1/fields/actions_taken/entries -H "$REM" \
-d '{"entry":"restarted payments-api pods","evidence":"kubectl rollout restart deploy/payments-api","based_on":[{"field":"planned_action","version":1}]}'{
"current_author": null,
"current_version": 0,
"error": "STALE_PREMISE",
"field": "actions_taken",
"moved": [
{
"current_author": "apikey:diagnoser",
"current_evidence": "api logs show config parse error at 14:02:11",
"current_version": 2,
"field": "planned_action",
"your_version": 1
}
],
"record": "incidents/inc-1"
}kslate rejected the write. Look at moved. The remediator read
planned_action at version 1. It is now at version 2, changed by the
diagnoser with new evidence. The action was not recorded. The attempt is
in history.
The remediator reads again and acts on the new plan
curl -s "$B/v1/records/incidents/inc-1?fields=planned_action" -H "$REM"
curl -s -X POST $B/v1/records/incidents/inc-1/fields/actions_taken/entries -H "$REM" \
-d '{"entry":"rolled back api deployment","evidence":"kubectl rollout undo deploy/payments-api","based_on":[{"field":"planned_action","version":2}]}'{
"record": "incidents/inc-1",
"field": "actions_taken",
"seq": 1,
"record_version": 3
}This write names version 2, which is current, so it is accepted.
Look at the history
kslate blame shows every version of one field and every write that was
refused because of it.
KSLATE_APIKEY=diag-token bin/kslate blame --server $B incidents/inc-1 planned_actionplanned_action (versioned, evidence required)
v2 22:34:15 apikey:diagnoser "roll back api deployment"
evidence: api logs show config parse error at 14:02:11
v1 22:34:15 apikey:diagnoser "restart payments-api pods"
evidence: api pods in CrashLoopBackOff
superseded by v2 after 0s
-- 22:34:15 apikey:remediator REJECTED STALE_PREMISE
write: actions_taken based_on: planned_action@1 (moved to v2 at 22:34:15)Each version of planned_action is listed with who wrote it and what
evidence they gave. The last two lines are the write that was refused and
the reason: it relied on version 1, which had moved to version 2.
kslate history shows the same for the whole record, every field at
once, and kslate verify reports which rejections fired and which roles
never wrote. Both are on the blame page.
What happened
- The diagnoser created the record and wrote
planned_actionversion 1. - The diagnoser changed it to version 2, passing version 1 to prove it had seen the current value.
- The remediator wrote an action based on version 1. kslate compared that with the current version inside the write and rejected it.
- The remediator read the new plan and wrote an action based on version 2. kslate accepted it.
Nothing sequenced those two agents. There was no leader and no queue: each acted when you told it to, and the gateway refused the one write whose premise had moved. kslate did not retry, merge or wait on anyone’s behalf. It rejected one write and told the writer what to look at.
Next
Your first agent is this scenario as a
thirty-line Python program: you write one function that receives the
record and returns what to write, and the client fills in based_on and
re-asks your function when a write is rejected. There is a Go
version too.
- The shared record for how records, fields and versions fit together.
- based_on for the check that rejected the remediator’s first write.
- The incident fleet for the four-agent version of this scenario.
Every output on this page comes from a real run against the current build. The JSON is re-indented and nothing else is changed.