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 / version | kslate.dev/v1alpha1 |
| Kind | SharedContext (sharedcontexts, short name sc) |
| Scope | Namespaced |
| Subresources | status |
| Served / storage | true / true |
kubectl get sharedcontexts columns:
| Column | Type | JSON path |
|---|---|---|
| Ready | string | .status.conditions[?(@.type=="Ready")].status |
| Records | integer | .status.records |
| Age | date | .metadata.creationTimestamp |
status fields the controller writes:
| Field | Type |
|---|---|
observedGeneration | integer |
records | integer |
conditions | array 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.namemust be set.spec.fieldsmust have at least one field.- Every key must be one kslate knows. A misspelled key such as
minIdentityStrenghtorevidanceis an error, not a silently dropped rule. This applies to a class loaded from a file too. - A field with
type: appendortype: taskgets that mode.mode: taskormode: appendwith notypeis the same field. Atypethat disagrees with themodeis an error. Every other type needsmode: versionedormode: soft. evidence, if set, must berequired.enumis only allowed on aversionedfield, andleaseonly on ataskfield. Either on another mode is an error, because it would look like a rule that is not enforced.leaseandttl.afterare Go durations, such as300sor168h, and must be positive.- Every name in a role’s
read,writeorclaimlist 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
claimwrites only the owner, the expiry and the version, which is whatclaimandHELDalready return. minIdentityStrength, on the class or on a field, must bepod-bound,sa-bound,issuer-declaredorshared-secret. See Identity strength.- In an identity pattern,
*stands for one whole final segment, as insa: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. Writeoidc:ci/repo:org%2Fname, notoidc:ci/repo:org/name. ttl.onExpire, if set, must bearchive. Any other value is an error, because it would otherwise delete records you meant to keep.- Every role in
checkpoint.requiredmust be defined inroles. idPatternis 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:
| Reason | Status | Meaning |
|---|---|---|
Reconciled | True | The class is loaded and its RBAC is in place |
InvalidSpec | False | The spec failed validation. message has the error |
DuplicateClassName | False | Another namespace already owns this class name |
CacheFailed | False | Writing the class to Postgres failed. message has the error |
RBACFailed | False | Creating the Role or RoleBinding failed. The class is still loaded |