Skip to content

blame

kslate keeps a history of every write to a record, including the ones it rejected. Each row says who made the write, what they sent, what they cited as evidence, what they based it on, and whether it was applied. kslate blame shows that history for one field. kslate history shows it for the whole record.

Both read through the gateway, so you only see fields your role may read.

Every CLI example below omits the connection flags for readability. The full form is:

KSLATE_APIKEY=$TOKEN kslate blame --server http://127.0.0.1:8080 incidents/inc-4471 planned_action

Who changed this field

$ kslate blame incidents/inc-4471 planned_action
planned_action  (versioned, evidence required)
v4  22:00:39  apikey:diagnoser      "fail over done; scale payments-api to 8"
      evidence: hypotheses@2
v3  22:00:36  apikey:diagnoser      "fail over, then scale payments-api to 6"
      evidence: hypotheses@2
      based_on: hypotheses@2
      superseded by v4 after 2s
v2  22:00:36  apikey:diagnoser      "fail over payments-db to replica 2"
      evidence: hypotheses@2
      based_on: hypotheses@2
      superseded by v3 after 0s
v1  22:00:36  apikey:diagnoser      "restart payments-api pods"
      evidence: hypotheses@1
      based_on: hypotheses@1
      superseded by v2 after 0s
--  22:00:36  apikey:remediator     REJECTED STALE_PREMISE
      write: actions_taken  based_on: planned_action@2  (moved to v3 at 22:00:36)  (inferred)
--  22:00:36  apikey:remediator     REJECTED STALE_PREMISE
      write: actions_taken  based_on: planned_action@1  (moved to v2 at 22:00:36)

Read it top to bottom. Each version of the field, newest first, with who wrote it, what evidence they gave, what they based it on, and how long it stood. Then the rejected writes that involved this field. The last two rows are writes to actions_taken that were refused because they relied on a version of planned_action that had already changed. (inferred) means kslate worked out the based_on from the caller’s own reads. See based_on.

Other rejections appear the same way:

$ kslate blame incidents/inc-4471 status
status  (versioned)
v3  22:00:39  apikey:detector       "resolved"
v2  22:00:36  apikey:detector       "mitigating"
      superseded by v3 after 3s
v1  22:00:36  apikey:detector       "open"
      superseded by v2 after 0s
--  22:00:39  apikey:communicator   REJECTED SCOPE_DENIED
      expected_version: 3
--  22:00:36  apikey:remediator     REJECTED STALE
      expected_version: 1

The communicator tried to write a field its role may not write. The remediator tried to write with a version that was already old.

Everything that happened to a record

kslate history lists every write to the record, newest first. This excerpt shows a task being claimed, released, re-claimed, and taken over, plus one rejected write:

$ kslate history incidents/inc-4471
v5   22:00:39  remediation          apikey:remediator      takeover APPLIED
v4   22:00:36  remediation          apikey:remediator2     claim  APPLIED
v3   22:00:36  remediation          apikey:remediator      release APPLIED
v2   22:00:36  remediation          apikey:remediator      claim  APPLIED
--   22:00:36  remediation          apikey:remediator2     claim  HELD
v1   22:00:36  remediation          apikey:remediator      claim  APPLIED
v3   22:00:36  actions_taken        apikey:remediator      append APPLIED
      evidence: kubectl rollout status
--   22:00:36  actions_taken        apikey:remediator      append STALE_PREMISE
      evidence: kubectl scale
      based_on: planned_action@2  (inferred)

Check the agents’ behaviour

kslate verify reads the same history and reports which rejections fired and which roles behaved oddly or never wrote at all. A class whose roles did not all write produces one finding per silent role, which is normal for a record only two of them touched:

incidents/inc-1
guarantees fired:
  STALE_PREMISE        1
findings: 4
  ROLE_WROTE_NOTHING           communicator
      no identity of this role has written to the record
  ROLE_WROTE_NOTHING           detector
      no identity of this role has written to the record
  ROLE_WROTE_NOTHING           dev
      no identity of this role has written to the record
  ROLE_WROTE_NOTHING           operator
      no identity of this role has written to the record

verify exits non-zero whenever it has findings, so a script that calls it should expect that.

What a history row contains

ColumnWhat it holds
seqA number that increases with every write, across every record
field, opThe field, and put, append, claim, release or takeover
versionThe field’s version after the write, when it was applied
record_versionThe record’s version. After the write when applied, at the moment of refusal when rejected
valueThe value written, or the value the caller tried to write. A scope, evidence or identity rejection keeps at most 64 KiB of it
authorThe identity kslate verified on the connection
identity_strengthHow well that credential was bound: pod-bound, sa-bound, issuer-declared or shared-secret. Absent on rows written before the gateway recorded it
invocationThe writer’s own statement of which call produced the write, when it sent one. kslate does not verify it
evidenceWhat the caller cited
expected_version, based_onWhat the caller sent. inferred: true if kslate derived based_on
outcomeapplied, stale, stale_premise, held, scope_denied, weak_identity, evidence_required or identity_mismatch
created_atWhen

Only failures the gateway reached the record to decide leave a row: stale, stale_premise, held, scope_denied, weak_identity, evidence_required and identity_mismatch.

A request rejected before that point writes nothing. These never reach history:

  • a bad credential
  • an identity with no role
  • an unknown field
  • the wrong operation for the field’s mode
  • a malformed body
  • a failed checkpoint gate

The endpoint

GET /v1/records/{class}/{id}/history?field=planned_action&limit=1000&before=

Rows come newest first, 1000 per page by default. When there are more, the response includes next_before. Pass it back as before for the next page. Each row is the history record above as JSON.

History has no MCP tool and no gRPC method. That is a decision, not a gap. An agent acts on the record’s current fields and versions, which get returns. The audit trail is there so a person can check afterwards what the agents did. It stays a gateway route rather than a table you read directly. A direct read would bypass scope.

What history tells you, and what it cannot

History answers four questions:

  • who wrote this value, and when
  • what evidence they cited
  • which versions of which other fields they relied on
  • which writes were refused, and for what reason

It does not tell you whether the evidence was true. If an agent cites a log line that does not exist, history records that it cited it. Checking the world is your job.

History is kept as long as the record lives. When the record expires, the history is deleted with it, or archived with it if the class says onExpire: archive.

(fence) and (inferred)

Two marks can appear on a based_on line.

(inferred) means kslate derived the premise from the reads the caller made rather than the caller declaring it (based_on).

(fence) means the writer declared that premise to prove it still held a claim, not because its decision relied on the value. Read it as a lease check, not as provenance — the writer never looked at that field’s contents. kslate-reconciler marks its pre-write claim check this way.