Skip to content
Handling rejections

Handling rejections

When the gateway refuses a write it returns an error that says why and what the record looks like now. The clients turn these into typed exceptions in Python and typed errors in Go. There are five.

ErrorWhenWhat to do
StaleErrorYou wrote a versioned field with a version that is no longer currentRead the field, decide again, write with the new version
StalePremiseErrorA field in based_on changed since you read itRead the moved fields, decide again
HeldErrorYou claimed a task another agent holdsSkip this record. If it came from a renew, stop working and drop your result
WeakIdentityErrorYour credential is below the identity-strength floor of the class or field (required and presented say which)Present a stronger credential. Usually a deployment change, not a policy one
APIErrorAnything else: wrong role, missing evidence, bad inputFix your class or your code. Retrying will not help

What run() already does

Most of the time you do not catch these. run() reads the record, calls decide, and writes. If the gateway refuses the write because a field decide read has moved, run() reads again and calls decide again with the new fields, up to max_attempts (default 5).

Here is that happening. The plan changes between the read and the write, so decide runs twice, on two different versions:

decide: planned_action v1 'restart payments-api pods'
decide: planned_action v2 'roll back api deployment'
written

Both attempts are in the record’s history, the refused one included:

v1   12:51:25  actions_taken        apikey:remediator      append APPLIED
      evidence: kubectl output
      based_on: planned_action@2
--   12:51:25  actions_taken        apikey:remediator      append STALE_PREMISE
      evidence: kubectl output
      based_on: planned_action@1
v2   12:51:25  planned_action       apikey:diagnoser       put    APPLIED
      evidence: api logs show config parse error at 14:02:11
v1   12:51:25  planned_action       apikey:diagnoser       put    APPLIED
      evidence: api pods in CrashLoopBackOff

Your function decided twice, on what was true each time. It never saw the rejection.

When your agent acts as it decides

Re-running decide is safe because decide returns writes rather than performing them: a second call costs one more decision. If your function also acts — restarts something, calls an API, spends money — a second call acts again.

Pass max_attempts=1. Your function runs once and the rejection reaches you:

try:
    run(Agent(decide=decide), client, record=RECORD, max_attempts=1)
except StalePremiseError as e:
    for m in e.moved:
        print(f"{m.field} moved v{m.your_version} -> v{m.current_version}")

Your agent has already acted, so what follows is yours to choose: call run() again when acting twice is harmless, stop and log when it is not, or hand it to a person. Either way the attempt is in history with what it was based on, so the record never claims an action against a plan that had already moved.

The rest of this page

You need the detail below when you write your own loop, when decide returns several writes, or when you want to log what happened.

StalePremiseError

from kslate import StalePremiseError

try:
    session.append(record, "actions_taken", action, evidence="kubectl output")
except StalePremiseError as e:
    for m in e.moved:
        print(f"{m.field} moved v{m.your_version} -> v{m.current_version} by {m.current_author}")
        print(f"  their evidence: {m.current_evidence}")
    # read again, decide again

e.moved has one entry per field that changed: the version you relied on, the current version, who wrote it, and what evidence they gave. e.field and e.target_current_version describe the field you were writing. The write did not happen.

Do not resubmit the same write with the new version. The point of the error is that the input to your decision changed. Decide again.

StaleError

from kslate import StaleError

try:
    session.put(record, "status", "resolved", expected_version=2)
except StaleError as e:
    print(f"{e.field} is at v{e.current_version}, you had v{e.your_version}")
    print(f"current value {e.current_value!r} by {e.current_author}")
    print(e.diff)

You tried to write a field someone else changed first. e.current_value is what is there now, e.current_author wrote it, e.diff is a unified diff of their value against yours. Read it, decide whether your write still makes sense, and if so write with expected_version=e.current_version.

If your role may write the field but not read it, current_value, current_author and current_evidence are None and diff is empty. You still get current_version.

HeldError

from kslate import HeldError

try:
    session.claim(record, "remediation", lease_seconds=300)
except HeldError as e:
    print(f"held by {e.owner} until {e.lease_expires_at}")
    # skip this record

Another agent has the task. Move on to the next record. Do not wait for the lease to end and do not poll the claim. The next pass of your loop will try again.

If HeldError comes from a renew (renew=True), your lease ran out and someone else took the task. Stop. Do not write the result you were about to write. Another agent owns the task now.

APIError

from kslate import APIError

try:
    session.put(record, "planned_action", "x", expected_version=2, evidence="y")
except APIError as e:
    print(e.status, e.code, e.body)

e.code is the gateway’s error code: SCOPE_DENIED, WEAK_IDENTITY, EVIDENCE_REQUIRED, NO_ROLE, UNAUTHENTICATED, NOT_FOUND, MALFORMED, and the rest. These are not races. The same request will fail the same way next time. See Troubleshooting for the common ones and their fixes.

In Go

The same five, as error types you test with errors.As:

var stale *kslate.StalePremiseError
var held *kslate.HeldError
var weak *kslate.WeakIdentityError
var api *kslate.APIError

_, err := session.Append(ctx, record, "actions_taken", action, "kubectl output", nil)
switch {
case errors.As(err, &stale):
    for _, m := range stale.Moved {
        fmt.Printf("%s moved v%d -> v%d by %s\n", m.Field, m.YourVersion, m.CurrentVersion, m.CurrentAuthor)
    }
    // read again, decide again
case errors.As(err, &held):
    // skip this record
case errors.As(err, &weak):
    log.Fatalf("%s requires %s, credential is %s", weak.Field, weak.Required, weak.Presented)
case errors.As(err, &api):
    log.Fatalf("%s: %v", api.Code, api.Body)
}

kslate.StaleError has Field, YourVersion, CurrentVersion, CurrentValue, CurrentAuthor, CurrentEvidence, CurrentUpdatedAt and Diff. kslate.WeakIdentityError has Record, Field, Required and Presented.

Three rules

  1. Never resubmit unchanged. A rejection means your decision’s input changed. Decide again with the new input. kslate verify flags agents that resubmit the same value after a rejection.
  2. Drop the result when you lose the lease. A HELD on renew means another agent is now doing this task. Writing your result anyway puts two actions on the record.
  3. One write per decision, if you can. When decide returns one write, run() can safely retry the whole cycle. When it returns several, the first may land and the second be rejected, and the loop cannot know whether to redo the first. For a multi-field decision, write them in separate cycles or check the record yourself.