Skip to content

CRDs

kslate has one custom resource, SharedContext. Each one defines a class. Records are not Kubernetes objects. They live in Postgres, and the API server never sees them.

Group / versionkslate.dev/v1alpha1
KindSharedContext (sharedcontexts, short name sc)
ScopeNamespaced
Subresourcesstatus
Served / storagetrue / true

kubectl get sharedcontexts columns:

ColumnTypeJSON path
Readystring.status.conditions[?(@.type=="Ready")].status
Recordsinteger.status.records
Agedate.metadata.creationTimestamp

status fields the controller writes:

FieldType
observedGenerationinteger
recordsinteger
conditionsarray of object

The CRD does not validate spec beyond “it is an object”. kslate validates the class itself when the controller loads it. It applies the same rules to a class loaded from a file with --policy contexts.yaml. If the class is invalid, the resource gets Ready=False and the condition message tells you what is wrong.

Because the CRD schema is open, there is nothing for a schema tool to render. The shape below is what kslate accepts.

spec

apiVersion: kslate.dev/v1alpha1
kind: SharedContext
metadata: { name: <class name>, namespace: <ns> }
spec:
  idPattern: "[a-z0-9-]+"            # optional; anchored; default: any non-empty id without "/"
  minIdentityStrength: sa-bound      # optional; the weakest credential that may write any field
  fields:
    <name>:
      type: string | number | integer | boolean | object | array | append | task
      mode: versioned | soft           # for the six value types; append and task imply their mode
      evidence: required               # optional
      enum: [a, b, c]                  # optional; versioned fields; checked at write time
      lease: 300s                      # task fields; the default lease_seconds
      minIdentityStrength: pod-bound   # optional; overrides the class floor for this field
  roles:
    <role>:
      identities: [sa:<ns>/<name>, apikey:<name>, "sa:<ns>/*"]
      read:  ["*"] | [field, ...]
      write: [field, ...]
      claim: [field, ...]
  ttl:
    after: 168h                        # Go duration; records expire this long after creation
    onExpire: archive                  # optional; only "archive" is allowed; absent means delete, with history
  checkpoint:
    required: [<role>, ...]            # roles that must checkpoint before status may be set to resolved
    sections: [goal, decisions, progress, open_questions]

What kslate checks when it loads a class:

  • metadata.name must be set. spec.fields must have at least one field.
  • Every key must be one kslate knows. A misspelled key such as minIdentityStrenght or evidance is an error, not a silently dropped rule. This applies to a class loaded from a file too.
  • A field with type: append or type: task gets that mode. mode: task or mode: append with no type is the same field. A type that disagrees with the mode is an error. Every other type needs mode: versioned or mode: soft.
  • evidence, if set, must be required.
  • enum is only allowed on a versioned field, and lease only on a task field. Either on another mode is an error, because it would look like a rule that is not enforced.
  • lease and ttl.after are Go durations, such as 300s or 168h, and must be positive.
  • Every name in a role’s read, write or claim list is a field of the class, or "*".
  • A role that claims a task field must also be able to read it. A claimer names the task field’s version as a fence on its writes, and naming a premise counts as reading the field. Without read scope every write is refused after the agent has already run, which costs a call per cycle and produces nothing. Granting the read discloses nothing: a task field has no value, and claim writes only the owner, the expiry and the version, which is what claim and HELD already return.
  • minIdentityStrength, on the class or on a field, must be pod-bound, sa-bound, issuer-declared or shared-secret. See Identity strength.
  • In an identity pattern, * stands for one whole final segment, as in sa:kagent/*. It may only appear at the end.
  • An oidc: identity pattern has exactly two segments, oidc:<issuer>/<subject>, because a / in the subject is percent-escaped as %2F. Write oidc:ci/repo:org%2Fname, not oidc:ci/repo:org/name.
  • ttl.onExpire, if set, must be archive. Any other value is an error, because it would otherwise delete records you meant to keep.
  • Every role in checkpoint.required must be defined in roles.
  • idPattern is matched against the whole id. kslate wraps it as ^(?:pattern)$ for you.

Class names are shared across the whole cluster. If another namespace already has a SharedContext with the same name, yours gets Ready=False with reason DuplicateClassName, and the message names the namespace that owns it.

The older object wins, by creationTimestamp. That timestamp has one-second resolution, so two objects created in the same second are tied, and the one in the lexically lower namespace wins. The tie-break is there because the initial list from the API server arrives in no particular order, and “first” has to mean the same thing after a restart.

Deleting the owner does not promote a refused duplicate. The class disappears for everyone, and callers get NOT_FOUND. The refused objects stay refused, with the owner name cleared from their status to say the owner is gone. Delete and re-create the one you want. A class changing hands without anyone asking is the thing this avoids.

Set watchNamespace where more than one team can create a SharedContext. That removes the possibility rather than reporting it.

For each class, the controller creates a Role and RoleBinding that mirror the class’s roles. That lets kubectl auth can-i answer questions about kslate access. The gateway still does the real enforcement.

Each derived object is named kslate-<class>-<role>-<hash>, where the hash covers the class and role together, so two pairs never collide on one object. The controller ignores an object of that name that does not carry its own labels, so it will not adopt something you made.

The mirror uses get for read scope and the verbs write and claim for the other two. Those two are kslate’s words, not Kubernetes API verbs: kubectl auth can-i write sharedcontexts/incidents --as=... answers, and nothing can change the SharedContext object through them. A role in a class never gets update or patch on the class itself. It used to, and that let any role with write scope edit the class that granted it.

The controller reconciles on every change, runs a full pass every ten minutes anyway, and retries a failed pass with a backoff that grows to five minutes. A watch that breaks — because someone deleted the CRD, say — shows up on /readyz on both the gateway and the worker.

If you delete a SharedContext, its records stay readable until they expire. Writes to them are rejected with CLASS_GONE.

status

status:
  observedGeneration: 3
  records: 12
  conditions:
    - type: Ready
      status: "True"
      reason: Reconciled
      message: ""
      lastTransitionTime: "2026-10-01T22:00:00Z"

records is how many live records the class has. The Ready condition takes one of these reasons:

ReasonStatusMeaning
ReconciledTrueThe class is loaded and its RBAC is in place
InvalidSpecFalseThe spec failed validation. message has the error
DuplicateClassNameFalseAnother namespace already owns this class name
CacheFailedFalseWriting the class to Postgres failed. message has the error
RBACFailedFalseCreating the Role or RoleBinding failed. The class is still loaded