based_on
An agent usually writes one field because of what it read in another. The
remediator executes a plan because of what planned_action said. If the
plan changed after the remediator read it, the action is wrong even though
nobody touched actions_taken.
based_on is how a write says what it relied on. You list the fields and
the versions you read. kslate checks them when the write arrives. If any
of them has moved, the write is rejected and you are told what moved, to
what, and who moved it.
This is different from expected_version. expected_version protects
the field you are writing. based_on protects the decision behind the
write.
Declaring what you relied on
Add based_on to a put or append. Each item names a field and the
version you read. You can name any field your role may read, including
append fields, where the version is the entry count.
POST /v1/records/incidents/inc-4471/fields/actions_taken/entries
{"entry":"restarted payments-api pods",
"evidence":"kubectl rollout restart deploy/payments-api",
"based_on":[{"field":"planned_action","version":1}]}In this run the diagnoser had already moved planned_action to version 2.
So:
{
"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"
}
]
}Look at moved. It lists each premise that changed: the version you
named, the current version, and who wrote it with what evidence. Read
planned_action again, decide again, and write with
based_on: planned_action@2. The same write with version 2 was accepted.
The top of the body (field, current_version, current_author)
describes the field you tried to write. Here actions_taken had no
entries yet, so its version is 0. You get this so you can retry without a
second read.
kslate stores based_on with the value. You see it when you get the
field, and every history row carries it.
A premise always names a field and that field’s version. It never names
record_version. A record version rises on every write to any field, so
a premise on it would be stale the moment any agent wrote anything, which
tells you nothing about whether your own input moved.
Premises on fields you cannot read
A premise returns the field’s version, author and evidence in the rejection. So kslate treats naming a premise like reading the field. You cannot name a field your role may not read, and you cannot name a field the class does not have.
{ "error": "UNKNOWN_FIELD", "field": "plan",
"message": "based_on names a field the class does not have", "record": "incidents/inc-4471" }{ "error": "SCOPE_DENIED", "field": "secret_plan",
"message": "based_on names a field this role may not read", "record": "scope-test/s-1" }A misspelt field name fails loudly instead of passing as “version 0”.
Without that rule any role could read any field’s state by naming it as a premise. The rejection hands back the version, the author and the evidence, so a caller with no read scope would learn all three from a write it expected to fail.
The rule holds for a fence too. You set kind yourself, so a fence that
skipped the read check would be the same hole with a label on it. That is
why a role that claims a task field has to be able to read it, rather than
the gateway making an exception for the fence a claimer has to declare.
See CRDs.
Premises kslate works out for you
Some callers cannot add based_on. A model calling kslate through MCP
tools is one. For these, kslate can infer the premises from what the
caller read.
It works through a header, X-Kslate-Scope. Put the same value on the
read and on the write. kslate then treats every field the read returned as
a premise of the write, at the version it returned. The header’s name
reuses the word scope. It has nothing to do with a role’s read and write
scope on Scope and identity.
The remediator reads with a scope header:
GET /v1/records/incidents/inc-4471?fields=planned_action
X-Kslate-Scope: task-7f3aIt sees planned_action at version 2. Meanwhile the diagnoser moves it to
version 3. The remediator writes with the same header and no based_on:
POST /v1/records/incidents/inc-4471/fields/actions_taken/entries
X-Kslate-Scope: task-7f3a
{"entry":"scaled payments-api to 6","evidence":"kubectl scale"}{
"error": "STALE_PREMISE",
"record": "incidents/inc-4471",
"field": "actions_taken",
"current_version": 1,
"current_author": "apikey:remediator",
"moved": [
{ "field": "planned_action", "your_version": 2, "current_version": 3,
"current_author": "apikey:diagnoser", "current_evidence": "hypotheses@2" }
]
}kslate rejected it as if the remediator had declared
based_on: planned_action@2.
The header is an opaque string. kslate only compares it for equality. Use one value per task or conversation, such as a task id. Do not use the MCP session id: one session spans unrelated conversations.
How inference behaves:
- Reads count for 15 minutes. After that they are not used.
- Reads are keyed by scope header value and identity together. Two identities with the same header value do not share reads.
- Fields the read did not find are recorded at version 0. If one appears later, that counts as moved.
- The field you are writing is never a premise of its own write.
- If you send
based_onat all, kslate uses only what you sent.based_on: []means “no premises for this write”. The same write above with"based_on": []was accepted. - No header on the write means nothing is inferred.
- History marks these rows with
inferred: true.kslate blameshows(inferred).
Not every premise is evidence
A premise answers “was this still current when I decided”. A writer can also use one to ask “do I still hold the claim I took before I started”. That works because a claim, a renewal and a takeover all bump a task field’s version, and kslate checks premises inside the write’s own transaction.
So declare the task field at the version your last claim returned. “I still hold the lease” and “the write applies” then become one decision instead of two hopeful ones.
That is a different kind of statement from evidence, and history says so.
Set kind: "fence" on such a premise:
{"based_on": [
{"field": "planned_action", "version": 3},
{"field": "remediation", "version": 12, "kind": "fence"}
]}kslate blame renders the second as remediation@12 (fence). The check
is identical either way, because kslate does not interpret kind. The
mark is for the auditor: it separates a value the decision relied on from
a lease the writer was proving it still held. Without it, the task field
looks like a reasoning input the agent never read.
The kslate-reconciler does this on every write it makes. You only need it
if you are writing a loop that claims a task before acting.
What this does and does not do
kslate guarantees the stale write did not land and that the attempt is in
history. It does not make the caller act on the rejection. A model that
gets a 409 from a tool call may ignore it or send the same write again.
kslate verify flags that after the fact.
A premise is about a field’s version, not about who holds a task. If the agent that wrote a field dies and another takes over its task, premises on that field are unaffected. Declaring the task field itself as a fence is what changes that, and is the reason to do it.
kslate checks that the premise is still current. It does not check that the decision follows from it or that the premise was true.