Design a class
A class is the schema for one kind of shared record. You write it once, as YAML, before any agent runs. This page designs one from scratch for a problem that is not an incident: a fleet of agents reviewing pull requests.
Start from the agents
Write down the agents you have and what each one produces.
| Agent | Produces |
|---|---|
| summariser | A short description of what the PR changes |
| tester | Results of running the test suite |
| reviewer (several copies) | Findings on the code, and a final verdict |
Each thing an agent produces becomes a field. Each agent becomes a role.
Pick a mode for each field
Ask one question per field: could two agents both be right about this at the same time?
| Field | Both right at once? | Mode | Why |
|---|---|---|---|
summary | No. One current description. | versioned | If two summarisers run, the second must know the first wrote. |
findings | Yes. Two reviewers can each find something. | append | Every finding stays, with who found it and what evidence. |
tests | Yes. Each run is a result. | append | Test runs accumulate. A later run does not erase an earlier one. |
verdict | No. One decision. | versioned | And it must cite evidence: what finding or test run it rests on. |
review | Only one reviewer should work a PR at a time. | task | A reviewer claims it. Other copies skip. |
When in doubt, choose append. It is the mode that loses nothing.
Set the rules on each field
verdictgetsenum: [approve, request_changes]. The gateway rejects anything else.verdictgetsevidence: required. A verdict with no evidence is refused.reviewgets a lease. A review takes a reviewer a few minutes, so600sleaves room to renew.- The PR number is the record id, so
idPattern: "[0-9]+". An id that is not a number is refused.
Roles
Each agent is a role. Give it the fields it writes and nothing more. Every role here may read everything, which is usual.
Reviewers run as several copies with different identities, so the role
uses a pattern. A * stands for one whole segment and never crosses a
/, so apikey:reviewer/* matches reviewer/r1 and reviewer/r2. A
Kubernetes identity is always sa:<namespace>/<name>, two segments, so
the pattern has to be sa:<namespace>/*. That matches every
ServiceAccount in the namespace, which is why the reviewers get a
namespace of their own.
Add an operator role for the CLI, with no write access.
The class
apiVersion: kslate.dev/v1alpha1
kind: SharedContext
metadata: { name: pr-reviews }
spec:
idPattern: "[0-9]+"
fields:
summary: { type: string, mode: versioned }
findings: { type: append }
tests: { type: append }
review: { type: task, lease: 600s }
verdict: { type: string, mode: versioned, enum: [approve, request_changes], evidence: required }
roles:
summariser: { identities: [sa:ci/summariser, apikey:summariser], read: ["*"], write: [summary] }
reviewer: { identities: ["sa:ci-reviewers/*", "apikey:reviewer/*"], read: ["*"], write: [findings, verdict], claim: [review] }
tester: { identities: [sa:ci/tester, apikey:tester], read: ["*"], write: [tests] }
operator: { identities: [sa:ci/kslate-cli], read: ["*"], write: [] }
ttl: { after: 720h, onExpire: archive }
checkpoint:
required: [reviewer]
sections: [goal, decisions, progress, open_questions]Two more lines explained:
ttlsays records expire 30 days after creation and are archived with their history rather than deleted.checkpoint.required: [reviewer]means a reviewer must have saved a checkpoint before anyone can setstatustoresolved. This class has nostatusfield, so the gate never fires here. It is kept to show the shape.
Load it
Locally, add the document to your policy file and start the gateway with
--policy. On Kubernetes, kubectl apply it. Then check it is there:
KSLATE_APIKEY=rev-token bin/kslate classes --server http://127.0.0.1:8080pr-reviewsGET /v1/classes/pr-reviews returns the class as the gateway loaded it.
The verdict field, for example:
"verdict": {
"mode": "versioned",
"type": "string",
"evidence": "required",
"enum": ["approve", "request_changes"]
}Try the rules
A reviewer claims the review on PR 4812. The record did not exist, so the claim creates it:
curl -s -X PUT http://127.0.0.1:8080/v1/records/pr-reviews/4812/fields/review/claim \
-H 'Authorization: Bearer rev-token' -d '{}'{"record":"pr-reviews/4812","field":"review","owner":"apikey:reviewer/r1","lease_expires_at":"2026-10-02T18:52:39.648026-07:00","version":1,"record_version":1}An id that is not a PR number:
{"error":"MALFORMED","message":"id does not match the class idPattern","record":"pr-reviews/abc"}A verdict without evidence:
{"error":"EVIDENCE_REQUIRED","field":"verdict","record":"pr-reviews/4812"}A verdict outside the enum:
{"error":"MALFORMED","field":"verdict","message":"value must be one of the field's enum: approve, request_changes","record":"pr-reviews/4812"}How the agents would use it
- The summariser runs once per PR:
run(agent, client, record=f"pr-reviews/{pr}")with adecidethat returns oneputtosummary. - The tester appends to
testsafter each run, with the CI job URL as evidence. - Reviewers run in class-scoped mode with
claim_field="review". Each copy claims a PR, readssummaryandtests, appendsfindings, and putsverdictwithbased_onset to thetestsversion it read. If the tester appends a new run while a reviewer is deciding, the verdict is rejected and the reviewer decides again.
Checklist
- One field per thing an agent produces.
appendunless exactly one value can be true at a time.evidence: requiredon any field another agent acts on.enumon any field with a fixed set of values.- A
taskfield for anything one agent should work at a time, with a lease two to three times your step time. - A role per agent, writing only its own fields. A pattern for roles that run as several copies.
- An
operatorrole for the CLI. idPatternif the id has a shape.- A TTL. Add
onExpire: archiveif you want the history kept.