Skip to content
based_on

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.

request
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:

HTTP 409
{
  "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.

HTTP 422
{ "error": "UNKNOWN_FIELD", "field": "plan",
  "message": "based_on names a field the class does not have", "record": "incidents/inc-4471" }
HTTP 403
{ "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:

request
GET /v1/records/incidents/inc-4471?fields=planned_action
X-Kslate-Scope: task-7f3a

It 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:

request
POST /v1/records/incidents/inc-4471/fields/actions_taken/entries
X-Kslate-Scope: task-7f3a
{"entry":"scaled payments-api to 6","evidence":"kubectl scale"}
HTTP 409
{
  "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_on at 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 blame shows (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.