+ Your next 3am page can end in a pull request.
YALSO keeps an eye on your cluster. When a workload starts failing, it works out why with a language model and opens a pull request against your GitOps repository. You get a small patch, the evidence behind every claim, and a section that says plainly what it is not claiming.
It cannot touch your cluster. It can only open a pull request. The ClusterRole grants no write verb outside its own API group, and the operator's own client refuses a write anyway. That makes two separate locks, and neither one relies on the model behaving.
the evidence each statement cites
the rules that produced it
which gates ran
hashes of the evidence and the rules
a heading reading "Not claimed"
Give an agent write access and its worst day becomes your worst day. A bad edit lands in a live cluster whenever the reconcile loop happens to fire, and you learn about it from the pager.
You know the human version of that night, because you have lived it. The page goes off. You tail logs, build a theory, bump a limit with kubectl and crawl back to bed. The pod recovers. Then your GitOps sync quietly reverts the change before standup, and the repository has no idea you were ever there.
A pull request goes where the cluster actually reads from. The evidence travels with it, a colleague can push back before it merges, and git keeps the reasoning next to the change. You already trust that machinery: a diff, an approver, a history, a revert. Every proposal arrives there and nowhere else, so a wrong answer costs you one diff read and one click on close.
--patch '{"spec":…}'
→ reverted by reconcile
→ absent from git history
Nine steps, and each one hands its result to the next. The last is the interesting one, because it reads how you responded. Nine learning signals, among them PRMergedSymptomResolved, PRAmendedByHuman and VerifierDisagreed, become YalsoLesson records. Nothing takes effect until you approve it.
Citation
Every statement in the analysis must cite a piece of evidence that was actually collected, and every hunk must cite evidence that reaches the field it changes. A finding that cites nothing is not a finding.
Verification
A second model call is shown the analysis and the same evidence and asked whether it holds. Agreement is required, and it is unanimous across every judgement in the reply: one disagreement refuses the analysis.
Minimality
The patch is bounded in hunks and in changed lines, and it may not repeat a change to a field it has already touched. A fix that rewrites a file is not a fix.
A language model sounds equally sure whether or not it has grounds. So three gates sit between the model and your pull request, and each one refuses outright instead of warning. When YALSO cannot make an honest case, it stops and writes down why, as an enum value you can query.
UnparseableAnalysis
RulesOverCeiling
PromptOverCeiling
WriteAccessRefused
HelmSourceUnsupported
SourceNotFound
You can check each boundary below in the source. None of them depends on a setting you have to take on trust.
| Boundary | How it is enforced |
|---|---|
| Change any object in the cluster except its own records | The ClusterRole grants no write verb outside its own API group, and the operator's own client refuses one regardless. A bug that tried would be stopped twice. |
| Read a credential it was not pointed at | Secrets are read from the operator's own namespace, by name and key, named in a custom resource. The Helm chart never creates a Secret and refuses a values file that looks like it carries one. |
| Send a credential to the model | Manifests, pod status, events and container logs are redacted before they
leave: credential-shaped values, the data of every Secret, and
credential-shaped environment variables. Redaction is pattern based and is
not a guarantee, which is why the model endpoint should be one you control. |
| Open a pull request against a repository you did not list | A resolved repository URL outside the configured list is refused outright. |
helm install yalso ./charts/yalso \ --namespace yalso-system --create-namespace \ --set api.enabled=true
Next, apply a YalsoConfig named default. It names your
model endpoint and the repositories YALSO may propose changes to. Then read the
status conditions to see how it took it. The chart pushes back on a values file that
oversteps: a key shaped like a credential, a key that belongs in a custom resource,
or a replica count each fail at render time, and the error says why.
helm test yalso -n yalso-system --logs
the read API over the cluster network
A passing chart test proves the
operator answers. It says nothing
about whether it has anything to
tell you yet.
The model endpoint
kubectl -n yalso-system create secret generic llm-credentials \ --from-literal=apiKey='<your-key>'
If your gateway wants no key, leave llm.credentialRef out entirely. An
absent reference resolves to an empty token instead of an error. That is how the
author runs it: an in-cluster gateway with nothing to rotate.
llm.baseURL takes plain http only for a Kubernetes
Service address, because traffic to a Service never leaves the cluster. Every
other host must be https.
A reference carries a name and a
key and no namespace, so a custom
resource cannot point the operator
at a Secret somewhere else.
A repository, as a GitHub App
Use this one if you can. The operator mints an installation token from the three values below whenever it needs one. The token lasts an hour, covers only the repositories the App is installed on, and carries the App's permissions instead of a person's. Nothing long-lived sits in the cluster, and nobody's departure breaks it.
kubectl -n yalso-system create secret generic yalso-github-app \ --from-literal=githubAppID='<app id>' \ --from-literal=githubAppInstallationID='<installation id>' \ --from-file=githubAppPrivateKey=<app>.private-key.pem
Those key names are the ones ArgoCD writes, so a cluster already running ArgoCD
names namespace: argocd and the Secret it already has, rather than
duplicating a credential that drifts the moment the original rotates. That is the
one reference taking a namespace, and a Secret outside the operator's own is read
straight from the API server and never cached.
→ no expiry
→ one person's permissions
→ dies when they leave
Which wins, and what fails when
Set both and the App wins: the token is never read. Set neither and the API server still accepts it, because the schema cannot say "one of these two" without CEL. That is not the same as it working: the operator pushes a branch and opens a pull request, so a repository needs a credential even when anyone may read it. The configuration reports Ready and the clone is what fails.
Readiness proves only that the Secret is readable. It never mints a token, because that would call GitHub on every reconcile, so a wrong installation ID passes readiness and fails at the first pull request.
What the App has to be allowed to do
These are the calls the operator makes against a repository. There are no others.
| What it needs | The call |
|---|---|
| Clone over HTTPS | git, the token as the password |
| Push one branch, never the default branch, never forced | git |
| Find the branch to target | GET /repos/{owner}/{repo} |
| Read a file at a revision | GET /repos/{owner}/{repo}/contents/{path} |
| Read commits since a time | GET /repos/{owner}/{repo}/commits |
| Find the pull request it opened | GET /repos/{owner}/{repo}/pulls |
| Read one pull request, including whether it merged | GET /repos/{owner}/{repo}/pulls/{number} |
| Open a pull request | POST /repos/{owner}/{repo}/pulls |
Label one, only when prLabels is set | POST /repos/{owner}/{repo}/issues/{number}/labels |
| Mint the installation token | POST /app/installations/{id}/access_tokens |
GitHub decides which fine-grained permission each call needs, and the source does not say. The table shows what the code does, nothing more. Check the calls against GitHub's REST reference and grant what it names. A permission list guessed from an endpoint list would be a guess dressed up as a fact, and you would only find out it was wrong at the first pull request.
Two values decide whether anything outside the cluster can read what the operator
found, and both are off. api.enabled serves the panel and the read API
without a credential, and ingress.enabled publishes them.
Turn either on deliberately, and keep ingress.allowedIPs set.
ingress.type picks the object — a Traefik IngressRoute by default,
an Ingress or an HTTPRoute otherwise — and any type that cannot carry
allowedIPs fails the render instead of publishing the panel open.
Choosing between them: traefik if you run Traefik, ingress
if your controller is one of the four the table names, and gateway only
when something else already restricts who reaches that Gateway. There is no fourth
option where the list is written and quietly ignored.
| Key | Default | Description |
|---|---|---|
| Image | ||
| image.repository | ghcr.io/lukaszraczylo/yalso | Image repository. |
| image.tag | "" | Image tag. Empty uses the chart's appVersion. |
| image.digest | "" | Pin the image by sha256 digest instead of tag, e.g. sha256:<64 hex characters>. Wins over image.tag when both are set: the rendered reference becomes repository@digest and the tag is ignored. Empty by default, so the reference stays repository:tag until pinned deliberately. |
| image.pullPolicy | IfNotPresent | Image pull policy. |
| image.pullSecrets | [] | Pull Secrets that already exist in the release namespace. A private image needs one. The chart creates none. |
| imagePullSecrets | [] | Same, at the top level, for a values file written against the common chart convention. Both are honoured. |
| tests.image | busybox:1.37 | Image helm test runs. It needs an HTTP client and nothing else. Point it at a mirror on an air-gapped cluster. |
| Naming | ||
| nameOverride | "" | Override the chart name. |
| fullnameOverride | "" | Override the generated resource name. |
| Permissions | ||
| serviceAccount.create | true | Create the ServiceAccount. |
| serviceAccount.name | "" | Name to use. Empty uses the release fullname. |
| serviceAccount.annotations | {} | ServiceAccount annotations, for workload identity. |
| rbac.create | true | Install the ClusterRole and its binding. Turn it off only when the permissions are managed elsewhere. |
| rbac.allowClusterMutation | false | Add write verbs on foreign workloads to the ClusterRole. The operator's own client refuses them regardless, so this grants permission the code still declines to use. |
| leaderElect | false | Enable leader election. Also renders a Role granting the lease permission, which the ClusterRole deliberately omits. |
| The read API and panel | ||
| api.enabled | false | Serve the read API and the panel. Nothing served is authenticated, so what can reach the port is the whole of its protection. Turn it on together with a NetworkPolicy. |
| api.port | 8082 | Port the API and panel listen on. |
| api.mcp.enabled | false | Serve the Model Context Protocol tools at /mcp, on the API's own port. Requires api.enabled. Every tool reads and there is no tool that writes, so an agent cannot change the cluster through it. It carries no credential either, so whoever can reach the panel can drive it. |
| api.service.enabled | true | Create a Service. This is what a browser or another tool connects through. |
| api.service.type | ClusterIP | Service type. |
| api.service.port | 8082 | Service port. |
| api.service.annotations | {} | Service annotations. |
| NetworkPolicy | ||
| networkPolicy.enabled | false | Render a NetworkPolicy restricting who can reach the operator's own ports. Off by default: with the API off, and on a cluster whose pod network is already private, the status quo is fine. It exists for the two ports that carry no credential -- the read API and panel are unauthenticated, so this is the boundary that makes turning them on a reviewed change rather than a promise about who can reach them. |
| networkPolicy.kubeletCIDRs | [] | Node networks the kubelet health probes come from, in addition to the RFC1918 blocks already allowed to the health and metrics ports. Kubelet probes come from the node network, so if your node range is outside the private blocks, add it here or the probes stop being answered and the operator is taken down by its own control plane. |
| networkPolicy.apiAllowFrom | {'podSelector': {}} | The whole source list for the read API port, as NetworkPolicyPeer entries. The default allows only pods in this namespace. Publishing the panel through an ingress? Add the ingress controller's namespace to this list or the route answers nothing. |
| networkPolicy.metricsAllowFrom | [] | Extra source peers for the metrics port, as NetworkPolicyPeer entries. The RFC1918 blocks stay allowed. Some network plugins do not match an ipBlock against pod IPs, so name the scraper's namespace here when a scrape from another namespace times out. |
| Ingress | ||
| ingress.enabled | false | Publish the panel and API. Requires api.enabled, because there is nothing to route to otherwise. |
| ingress.type | traefik | Which object to render: traefik for a Traefik IngressRoute, ingress for a networking.k8s.io/v1 Ingress, gateway for a gateway.networking.k8s.io/v1 HTTPRoute. traefik is the default because the IngressRoute match expression carries allowedIPs itself, so no controller can drop it. |
| ingress.host | "" | Hostname to route. The only value with no sensible default. |
| ingress.allowedIPs | 10.0.0.0/22 172.16.0.0/22 | Restrict the route by client address. The panel is unauthenticated, so this is the only thing between it and whoever resolves the host. An empty list removes the restriction. Every type matches the address it is given, which behind a second proxy or an L4 load balancer is that proxy's. Type traefik renders it as ClientIP, type ingress as the annotation className selects, and type gateway refuses the render: an HTTPRoute has no filter for the client address, so the list could only be dropped. |
| ingress.tls.secretName | "" | An existing certificate Secret in the release namespace. Empty falls back to the controller's default certificate. Type gateway refuses it, because an HTTPRoute carries no certificate and its Gateway listener does. |
| ingress.annotations | {} | Annotations on whichever object is rendered. The allowlist annotation type ingress derives from allowedIPs wins over the same key set here. |
| ingress.entryPoints | websecure | Traefik entry points. Type traefik only. |
| ingress.middlewares | [] | Middlewares, applied in order. Each needs the namespace it lives in, because a Traefik middleware is namespaced. Type traefik only, and the other types refuse a non-empty list rather than drop it. |
| ingress.className | "" | Type ingress only. Becomes spec.ingressClassName, and selects the annotation allowedIPs is written as. Recognised: nginx, haproxy for haproxytech/kubernetes-ingress, haproxy-ingress for jcmoraisjr/haproxy-ingress, alb for the AWS Load Balancer Controller. Any other class refuses the render while allowedIPs is set — Helm cannot ask the cluster which controller answers for a class, and an annotation the controller does not read is ignored in silence. |
| ingress.gateway.name | "" | Type gateway only, and required by it. The Gateway this route attaches to. |
| ingress.gateway.namespace | "" | Namespace of that Gateway. Empty uses the release namespace. A Gateway elsewhere must also accept this route through its listener's allowedRoutes, which is its owner's to grant. |
| ingress.gateway.sectionName | "" | Attach to one named listener. Empty attaches to every listener that accepts the hostname. |
| Metrics and health | ||
| metrics.enabled | true | Serve Prometheus metrics. |
| metrics.port | 8080 | Metrics port. |
| metrics.service.enabled | true | Create a Service, which a scraper such as a ServiceMonitor needs. |
| metrics.service.type | ClusterIP | Service type. |
| metrics.service.port | 8080 | Service port. |
| metrics.service.annotations | {} | Service annotations. |
| metrics.serviceMonitor.enabled | false | Render a monitoring.coreos.com ServiceMonitor that scrapes the operator's metrics. Off by default: without one the metrics are computed but never read. |
| metrics.serviceMonitor.interval | 30s | How often the operator is scraped. |
| metrics.serviceMonitor.labels | {} | Labels written on the ServiceMonitor, which is how a Prometheus whose selector matches on labels discovers it. |
| metrics.prometheusRule.enabled | false | Render a monitoring.coreos.com PrometheusRule with alerts about the operator's own health: down, model fallbacks, and evidence truncation. |
| metrics.prometheusRule.labels | {} | Labels written on the PrometheusRule. |
| healthProbe.port | 8081 | Health and readiness port. |
| Runtime | ||
| workspace.sizeLimit | 1Gi | Size of the clone directory. The root filesystem is read only, so this is the one writable mount. Exceeding it evicts the pod, so raise it for a large monorepo, and raise it again if you raise analysis.concurrency in the YalsoConfig: that many clones can be on this mount at once, and an eviction names the mount rather than the setting that filled it. The render sidecar mounts the same volume at the same path and puts its helm and git caches there too, so raise it again when you switch sidecar.enabled on. |
| resources | see values.yaml | Requests and limits. No CPU limit by default: analysis is bursty, and throttling it lengthens an incident rather than protecting the node. |
| podSecurityContext | see values.yaml | Runs as user 65532, non-root, with the RuntimeDefault seccomp profile. |
| securityContext | see values.yaml | Read-only root filesystem, no privilege escalation, all capabilities dropped. Rendered onto both containers from this one value, so the render sidecar cannot end up hardened less than the operator. |
| terminationGracePeriodSeconds | 30 | Time an in-flight analysis has to stop cleanly. |
| logLevel | "" | Raise the operator's own verbosity, passed to controller-runtime as --zap-log-level. Empty leaves the info-level production encoding. debug renders the per-incident V(1) outcome lines -- why a given analysis was deferred, what an incident's analysis returned -- which are otherwise invisible short of rebuilding. |
| The render sidecar | ||
| sidecar.enabled | false | Run the GitOps repository's render in a second container. The operator image carries no make, no helm, no git and no shell, so with this off a configured render cannot start at all. Two things come with turning it on, and neither can be fenced off inside a pod. The sidecar runs code from your GitOps repository — rendering means running that repository's own build, so whoever can merge to it chooses what this container executes. It shares a network namespace with the operator — it reaches every port the operator's container reaches on 127.0.0.1, including the read API and MCP surface if you enabled them, and it mounts the same workspace, so it reads and writes every checked-out worktree. It is given no service-account token, no model-gateway key and no forge credential: the pod sets automountServiceAccountToken: false and projects the token into the operator container alone. |
| sidecar.image.repository | ghcr.io/lukaszraczylo/yalso-render | Render toolchain image. It carries make, helm and git, all pinned. |
| sidecar.image.tag | "" | Image tag. Empty uses the chart's appVersion, so the toolchain and the operator that drives it move together. A toolchain that moves on its own changes what your repository renders to, which the operator reports as drift in a workload nobody touched. |
| sidecar.image.digest | "" | Pin the sidecar image by sha256 digest instead of tag, the same convention as image.digest: wins over sidecar.image.tag when both are set, and the rendered reference becomes repository@digest. Empty by default. |
| sidecar.image.pullPolicy | IfNotPresent | Image pull policy. |
| sidecar.command | [] | Replaces the image's entrypoint, and is empty because the entrypoint is what you want. The image runs /render-sidecar, which serves the protocol on loopback, runs one render at a time, and takes the command it runs from its own arguments so that nothing reaching the port can choose what executes. Naming something else here means this container stops answering the operator. |
| sidecar.args | [] | Appended to the listen address the chart already derives from sidecar.port. The render command is set here, as everything after the flags, and it defaults to make -- for example ["make", "render"]. In sidecar mode the per-repository render.command is deliberately not sent, so this one command serves every repository. The flags are --root (default /tmp, the containment root every render must resolve inside), --timeout (2m), --max-output-bytes (65536), --max-concurrent (1) and a repeatable --env NAME=VALUE, which is the only thing a render is given beyond PATH and a HOME of /tmp. |
| sidecar.port | 8090 | Port it listens on. The operator is given http://127.0.0.1:<port> and refuses any endpoint that is not loopback. Nothing publishes this port and nothing should: a Service in front of it lets whatever reaches the Service drive the render toolchain against the operator's worktrees. |
| sidecar.resources | see values.yaml | Requests and limits for the sidecar. No CPU limit, as for the operator. The memory limit is what stops a repository whose build allocates without bound from evicting the operator beside it. |
| Scheduling | ||
| podAnnotations | {} | Pod annotations. |
| podLabels | {} | Pod labels. |
| nodeSelector | {} | Node selector. |
| tolerations | [] | Tolerations. |
| affinity | {} | Affinity. |
| priorityClassName | "" | Priority class. |
These fail the render with the reason rather than being ignored.
| Kind | What it holds |
|---|---|
| YalsoConfig | The whole configuration: model endpoints, detectors, repositories, gates, retention, notifications. One object named default. |
| YalsoIssue | One symptom, with its fingerprint, first and last sighting, and how many times it has been seen. |
| YalsoIncident | One correlated group and every decision made about it: the analysis, the gates, the patch, the pull request and its outcome. |
| YalsoRuleSet | Instructions you write that shape the analysis. They are quoted in the pull request, so a reader can tell which rules produced a proposal. |
| YalsoLesson | What the operator concluded from what humans did with its pull requests. Applied only when you approve it. |
A rule is knowledge the operator cannot infer from the cluster or the manifests: a constraint somebody decided, a convention nobody wrote down, a thing that broke once. Rules are injected verbatim into the analyst prompt and quoted in the pull request, so a reviewer can tell which rule produced a proposal.
Documents compose from least to most specific. A rule that applies everywhere lands
first, a rule scoped to one namespace lands after it, and the later document carries
more weight. priority orders documents that share a specificity tier.
a set that applies everywhere no selector
The smallest useful set. One document, no selector, so it applies to every object the operator analyses.
apiVersion: yalso.raczylo.com/v1alpha1
kind: YalsoRuleSet
metadata:
name: default
spec:
rules:
- name: never-touch-replicas
description: Replica counts are owned by the autoscaler
body: |
Never propose a change to spec.replicas. The horizontal pod autoscaler owns
that field, and a patch to it is reverted within a minute and teaches nobody
anything.
name must be unique in the set and appears in the composed output and
in status. description is for the human reading the object; the
operator does not put it in the prompt.
narrowing a rule to some objects selector
A selector takes namespaces, resources, and a label selector. An absent field matches anything, and a rule matches if any resource entry matches.
- name: payments-memory-floor
description: The payments services need headroom the profiler does not show
priority: 10
selector:
namespaces:
- prod
resources:
- group: apps
kind: Deployment
body: |
The payments services hold a large in-memory rate table that is populated
lazily, so a container that looks idle at 200Mi will need 512Mi within an
hour of a cold start. Never propose a memory limit below 512Mi for them.
detail: |
This was learned from an incident where the limit was tuned down to the
observed working set and every pod was killed at the next traffic peak.
detail is appended only when the document matches. It lets a rule stay
short in the common case and expand when it is actually relevant, which keeps the
prompt inside its token ceiling.
scoping by label instead of name labelSelector
Namespaces and kinds are the blunt instruments. A label selector follows the workloads rather than where they happen to live.
- name: stateful-no-rollout-surgery
description: Anything holding data is patched by a human
selector:
labelSelector:
matchLabels:
yalso.raczylo.com/data: persistent
body: |
These workloads own data on disk. Do not propose a change to the update
strategy, the volume claim template, or any probe timing. Report the finding
and let a human decide the sequence.
A set holds between 1 and 200 documents, and each body is capped at 20 480 characters. The whole rule set is hashed into the incident record, so a proposal can always be traced to the exact rules that shaped it.
Every description below is the field's own doc comment and every default is the one the API server applies, both read back out of the generated schemas. The build fails when a field ships with no doc comment, and when this reference and the schemas disagree in either direction.
The Default column has four readings. A value is what the API server writes when you
write nothing. required means it refuses the object without one.
— means the field is simply absent. unset means the
field carries a default that never applies, because the object holding it carries
none: the API server creates nothing and the Go zero value stands. Three detectors
work that way, and they have a group to themselves.
This is every field a human writes. YalsoIssue and
YalsoIncident declare no spec at all, because the operator writes them
and you read them.
| Field | Default | What it does |
|---|---|---|
| YalsoConfig · The model endpoint | ||
| llm.provider | openai-compatible | Provider selects the client implementation. Only openai-compatible exists today. One of openai-compatible. |
| llm.baseURL | required | BaseURL is the OpenAI-compatible API root, for example https://gateway.example.com/v1. There is no default. Plain http is permitted only for a Kubernetes Service address, meaning a host ending in .svc or .svc.cluster.local, because traffic to a Service never leaves the cluster. Every other host must be https. Matches ^(https://[^/\s]+(/.*)?|http://[a-zA-Z0-9.-]+\.svc(\.cluster\.local)?(:[0-9]+)?(/.*)?)$. |
| llm.model | required | Model is the model identifier to request. There is no default. |
| llm.credentialRef.name | required | Name of the Secret. It must live in the operator's own namespace. Omit the whole credentialRef for a keyless gateway: an absent reference resolves to an empty token rather than to an error. |
| llm.credentialRef.key | required | Key inside the Secret's data map. |
| llm.temperature | 0 | Temperature controls sampling. Analysis wants determinism, so the default is zero. 0 to 2. |
| llm.timeout | 120s | Timeout bounds one model call. |
| llm.maxInputTokens | 60000 | MaxInputTokens caps the prompt. It must exceed the rule and evidence ceilings combined, so the prompt template always fits. At least 1000. |
| llm.maxOutputTokens | 8000 | MaxOutputTokens caps the response. At least 256. |
| YalsoConfig · One endpoint per difficulty tier | ||
| llm.models.simple.provider | — | Provider selects the client implementation for this tier. One of openai-compatible. |
| llm.models.simple.baseURL | — | BaseURL is this tier's API root. Set it to route the tier to a different provider. The same scheme rule as LLMSpec.BaseURL applies. Matches ^(https://[^/\s]+(/.*)?|http://[a-zA-Z0-9.-]+\.svc(\.cluster\.local)?(:[0-9]+)?(/.*)?)$. |
| llm.models.simple.model | — | Model is the model identifier to request for this tier. Omit the tier, or llm.models entirely, to use the endpoint above for everything. |
| llm.models.simple.credentialRef.name | required | Name of the Secret. It must live in the operator's own namespace. |
| llm.models.simple.credentialRef.key | required | Key inside the Secret's data map. |
| llm.models.standard.provider | — | Provider selects the client implementation for this tier. One of openai-compatible. |
| llm.models.standard.baseURL | — | BaseURL is this tier's API root. Set it to route the tier to a different provider. The same scheme rule as LLMSpec.BaseURL applies. Matches ^(https://[^/\s]+(/.*)?|http://[a-zA-Z0-9.-]+\.svc(\.cluster\.local)?(:[0-9]+)?(/.*)?)$. |
| llm.models.standard.model | — | Model is the model identifier to request for this tier. Omit the tier, or llm.models entirely, to use the endpoint above for everything. |
| llm.models.standard.credentialRef.name | required | Name of the Secret. It must live in the operator's own namespace. |
| llm.models.standard.credentialRef.key | required | Key inside the Secret's data map. |
| llm.models.complex.provider | — | Provider selects the client implementation for this tier. One of openai-compatible. |
| llm.models.complex.baseURL | — | BaseURL is this tier's API root. Set it to route the tier to a different provider. The same scheme rule as LLMSpec.BaseURL applies. Matches ^(https://[^/\s]+(/.*)?|http://[a-zA-Z0-9.-]+\.svc(\.cluster\.local)?(:[0-9]+)?(/.*)?)$. |
| llm.models.complex.model | — | Model is the model identifier to request for this tier. Omit the tier, or llm.models entirely, to use the endpoint above for everything. Verification always runs on the complex tier. |
| llm.models.complex.credentialRef.name | required | Name of the Secret. It must live in the operator's own namespace. |
| llm.models.complex.credentialRef.key | required | Key inside the Secret's data map. |
| YalsoConfig · Reaching an endpoint | ||
| llm.probe.enabled | true | Enabled turns the reachability check on. |
| llm.probe.interval | 10m | Interval is how often a configuration that has not changed is probed again. A probe stamps the moment it ran, which changes the status, which is itself a change the operator observes. Without this floor that loop runs at write speed and spends a model call every time round it. |
| llm.probe.timeout | 20s | Timeout bounds one probe. A probe is a trivial request, so a slow answer is itself a reachability problem. |
| llm.probe.attempts | 2 | Attempts is how many times a probe retries before the endpoint is reported unreachable. A single network blip must not fail a rollout. At least 1. |
| llm.fallback.enabled | true | Enabled allows a failed call to retry against another configured endpoint. |
| llm.fallback.failureThreshold | 3 | FailureThreshold is how many consecutive failures mark an endpoint unhealthy. One timeout is noise; three in a row is an outage. At least 1. |
| llm.fallback.recheckInterval | 5m | RecheckInterval is how long an endpoint stays marked unhealthy before the operator tries it again. |
| llm.fallback.allowTierDowngrade | — | AllowTierDowngrade permits falling back to an endpoint configured for an easier tier. Off by default, and that default matters: a weaker model agreeing with a stronger one is not the independent check verification exists to provide. With it off, a tier that cannot be reached leaves the incident unanalysed for the next sweep rather than answering it with an easier endpoint. |
| YalsoConfig · Repositories it may propose against | ||
| repositories[].name | required | Name identifies the repository inside this configuration. It appears in incident status. |
| repositories[].url | required | URL is the https clone URL. Matches ^https://[a-zA-Z0-9.-]+(/.*)?$. |
| repositories[].provider | required | Provider selects the hosting provider implementation. One of github, gitlab. |
| repositories[].credentialRef.name | required | Name of the Secret. It must live in the operator's own namespace. A token that can push a branch and open a pull request, and nothing else. Prefer appCredentialRef, which stores nothing long-lived in the cluster. |
| repositories[].credentialRef.key | required | Key inside the Secret's data map. |
| repositories[].appCredentialRef.namespace | — | Namespace holding the Secret. Empty means the operator's own namespace. A Secret outside the operator's namespace is read straight from the API server and never cached, so pointing at another controller's credential does not put every Secret in that namespace into the operator's memory. |
| repositories[].appCredentialRef.name | required | Name of the Secret. Naming an App is what selects App authentication, and it wins over credentialRef when both are set. Three values live in this one Secret. |
| repositories[].appCredentialRef.appIDKey | githubAppID | AppIDKey holds the App's numeric identifier. |
| repositories[].appCredentialRef.installationIDKey | githubAppInstallationID | InstallationIDKey holds the installation the token is minted for. An App installed on several organisations has one per organisation, and a token is valid only for its own. |
| repositories[].appCredentialRef.privateKeyKey | githubAppPrivateKey | PrivateKeyKey holds the PEM-encoded RSA key the App was issued. |
| repositories[].branchPrefix | yalso | BranchPrefix prefixes every branch the operator creates. The pattern requires lowercase letters and digits, optionally joined by single . or - separators, and forbids leading, trailing and consecutive separators. Matches ^[a-z0-9]+([.-][a-z0-9]+)*$. |
| repositories[].baseBranchOverride | — | BaseBranchOverride replaces the branch the operator targets. When empty, the operator uses the revision the GitOps controller tracks. |
| repositories[].prLabels | — | PRLabels are applied to every pull request the operator opens. |
| repositories[].prDraft | — | PRDraft opens pull requests as drafts. |
| YalsoConfig · Charts, values and the render that connects them | ||
| repositories[].render.enabled | — | Enabled turns rendered-to-values resolution on for this repository. |
| repositories[].render.chartFile | unset | ChartFile names the file whose presence proves a directory is a chart. |
| repositories[].render.renderRoot | unset | RenderRoot is the path segment that separates the directory a render runs in from the output that render writes. |
| repositories[].render.valuesPath | unset | ValuesPath is a Go text/template naming the values file for a chart, evaluated against the chart the resolver identified. |
| repositories[].render.command | unset | Command is the render step, as an argv vector rather than a shell line. |
| repositories[].render.workingDir | unset | WorkingDir is a Go text/template naming the directory Command runs in, evaluated against the same fields as ValuesPath. |
| YalsoConfig · What it watches | ||
| observe.interval | 60s | Interval is how often the operator sweeps the cluster for symptoms. |
| observe.resolveAfter | 15m | ResolveAfter is how long a fingerprint must go unobserved before its issue is marked resolved. |
| observe.namespaces.include | — | Include lists namespaces to observe. Each entry is an exact name or a glob such as "app-*"; an invalid glob matches literally and is logged. An empty list observes every namespace that Exclude does not remove. |
| observe.namespaces.exclude | — | Exclude lists namespaces to ignore, as exact names or globs (an invalid glob matches literally). Exclude wins over Include. |
| observe.excludeWorkloads | — | ExcludeWorkloads lists "namespace/name" globs, for example "yalso-system/canary-*", matched against the top-owning workload of a pod. An invalid glob matches literally. A workload, pod or namespace annotated yalso.raczylo.com/ignore: "true" is skipped as well. |
| gitops.controllers | argocd flux | Controllers lists the GitOps controllers to resolve ownership against. |
| gitops.declineHelmSources | true | DeclineHelmSources refuses to patch Helm-rendered resources. One of True. |
| YalsoConfig · Detectors that ship on | ||
| observe.crashLoopBackOff.enabled | true | Enabled turns the detector on. |
| observe.crashLoopBackOff.threshold | 3 | Threshold is the minimum number of occurrences inside Window before the detector raises a candidate. Its exact meaning depends on the detector: restarts for crashLoopBackOff, kills for oomKilled, and consecutive failures for probeFailure. At least 1. |
| observe.crashLoopBackOff.window | 10m | Window is the period the detector looks back over. |
| observe.oomKilled.enabled | true | Enabled turns the detector on. |
| observe.oomKilled.threshold | 1 | Threshold is the minimum number of occurrences inside Window before the detector raises a candidate. Its exact meaning depends on the detector: restarts for crashLoopBackOff, kills for oomKilled, and consecutive failures for probeFailure. At least 1. |
| observe.oomKilled.window | 10m | Window is the period the detector looks back over. |
| observe.probeFailure.enabled | true | Enabled turns the detector on. |
| observe.probeFailure.threshold | 3 | Threshold is the minimum number of occurrences inside Window before the detector raises a candidate. Its exact meaning depends on the detector: restarts for crashLoopBackOff, kills for oomKilled, and consecutive failures for probeFailure. At least 1. |
| observe.probeFailure.window | 10m | Window is the period the detector looks back over. |
| observe.jobFailure.enabled | true | Enabled turns the detector on. |
| observe.jobFailure.threshold | 1 | Threshold is the minimum number of occurrences inside Window before the detector raises a candidate. Its exact meaning depends on the detector: restarts for crashLoopBackOff, kills for oomKilled, and consecutive failures for probeFailure. At least 1. |
| observe.jobFailure.window | 24h | Window is the period the detector looks back over. |
| YalsoConfig · Detectors you have to switch on | ||
| observe.notStarting.enabled | unset | Enabled turns the detector on. This detector ships off. The object carries no default, so the API server creates nothing and this stays false until you write the block. An upgrade must change nothing on its own. |
| observe.notStarting.threshold | unset | Threshold is the minimum number of occurrences inside Window before the detector raises a candidate. Its exact meaning depends on the detector: restarts for crashLoopBackOff, kills for oomKilled, and consecutive failures for probeFailure. It counts pods, so the shared default of three waits for three stuck replicas. Set it to one: a Deployment with a bad image tag blocks its rollout on the first pod, and a CronJob only ever creates one. At least 1. |
| observe.notStarting.window | unset | Window is the period the detector looks back over. |
| observe.podFailed.enabled | unset | Enabled turns the detector on. This detector ships off. The object carries no default, so the API server creates nothing and this stays false until you write the block. |
| observe.podFailed.threshold | unset | Threshold is the minimum number of occurrences inside Window before the detector raises a candidate. Its exact meaning depends on the detector: restarts for crashLoopBackOff, kills for oomKilled, and consecutive failures for probeFailure. It counts occurrences of one reason on one workload, so three waits for a pattern rather than reacting to a single eviction, which a busy node does routinely. At least 1. |
| observe.podFailed.window | unset | Window is the period the detector looks back over. |
| observe.controllerFailure.enabled | true | Enabled turns the detector on. This detector ships off. The object carries no default, so the API server creates nothing and this stays false until you write the block. |
| observe.controllerFailure.threshold | 1 | Threshold is the minimum number of occurrences inside Window before the detector raises a candidate. Its exact meaning depends on the detector: restarts for crashLoopBackOff, kills for oomKilled, and consecutive failures for probeFailure. It counts one condition on one workload, so set it to one. A controller that cannot create a pod does not do so intermittently. At least 1. |
| observe.controllerFailure.window | 10m | Window is the period the detector looks back over. |
| observe.mountFailure.enabled | unset | Enabled turns the detector on. This detector ships off. The object carries no default, so the API server creates nothing and this stays false until you write the block. A StatefulSet rolling a ReadWriteOnce volume emits FailedAttachVolume while the old node releases it, so a default of on would open incidents during ordinary rollouts. |
| observe.mountFailure.threshold | unset | Threshold is the minimum number of occurrences inside Window before the detector raises a candidate. Its exact meaning depends on the detector: restarts for crashLoopBackOff, kills for oomKilled, and consecutive failures for probeFailure. It counts mount failures on one workload, and three is right where four other detectors override the shared default to one. Their evidence has a ceiling and this evidence does not: the kubelet aggregates repeats into an event count, and the pod worker retries a stalled mount every 2m3s, so three means the mount has been failing for about six minutes. One would open an incident for every snapshot window on every workload that mounts the appliance. At least 1. |
| observe.mountFailure.window | unset | Window is the period the detector looks back over. |
| YalsoConfig · Grouping symptoms into one incident | ||
| correlate.causalSkew | 30s | CausalSkew is the tolerance applied when ordering symptoms by first occurrence. It stops clock jitter from deciding the root. |
| correlate.flapWindow | 1h | FlapWindow is how long a resolved fingerprint stays remembered. A reappearance inside this period reports as a regression. |
| correlate.maxIncidentMembers | 25 | MaxIncidentMembers caps how many symptoms one incident may hold. The operator reports a larger group and does not analyse it, because a cluster wide failure needs a human before it needs a patch. At least 1. |
| correlate.window | — | Window is reserved and no code reads it. |
| YalsoConfig · Analysis and what bounds it | ||
| analysis.maxPerSweep | 10 | MaxPerSweep caps how many incidents one sweep analyses. Two model calls each, the analyst and the verifier, so this is the only bound on what one bad minute costs. Nothing is lost by it: an incident that does not fit waits for the next sweep, oldest first. Zero means no bound. |
| analysis.concurrency | 1 | Concurrency is how many incidents are analysed at the same time. Detection never waits for an analysis, so this bounds only how fast the backlog drains and how much the model endpoint is asked for at once. One is what the operator did before, so an upgrade that changes nothing spends nothing more. Raise it to what the endpoint's rate limit and the workspace volume can carry: each analysis holds its own clone on disk. 1 to 8. |
| analysis.maxHunks | 5 | MaxHunks caps how many hunks one patch may contain. At least 1. |
| analysis.maxChangedLines | 25 | MaxChangedLines caps how many lines one patch may change. At least 1. |
| analysis.adviseUnmanaged | true | AdviseUnmanaged explains a failing object no GitOps controller claims. There is no manifest to patch, so no pull request opens and nothing changes: the operator writes what is wrong onto the incident. Turn it off to spend no model call on anything it cannot fix. |
| analysis.evidenceTokenCeiling | 40000 | EvidenceTokenCeiling caps the evidence bundle. At least 500. |
| analysis.rulesTokenCeiling | 8000 | RulesTokenCeiling caps the composed rules document. The operator drops whole documents rather than truncating one, and it records every drop. At least 100. |
| analysis.verifier.enabled | true | Enabled runs a second model call that must agree before a pull request opens. |
| analysis.verifier.mustAgree | true | MustAgree blocks the pull request when the verifier disagrees. Setting it to false records the disagreement and continues, which weakens the guarantee described in the design. |
| YalsoConfig · What became of the pull request | ||
| outcome.enabled | true | Enabled turns outcome tracking on. |
| outcome.pollInterval | 5m | PollInterval is how often the operator checks open pull requests. Provider APIs are rate limited and a pull request decision takes minutes at best, so polling faster buys nothing. |
| outcome.staleAfter | 168h | StaleAfter is how long an untouched pull request waits before the operator records it as stale. A pull request nobody reviewed is itself a signal. |
| outcome.recurrenceWindow | 24h | RecurrenceWindow is how long after a merge the operator watches for the same fingerprint. A reappearance inside this window means the patch did not work. |
| outcome.trackAmendments | true | TrackAmendments diffs the merged tree against what the operator proposed. That diff is the only signal showing how the operator should have been right rather than merely that it was wrong. Turn it off to avoid storing repository content on the incident record. That diff is the only signal showing how the operator should have been right rather than merely that it was wrong. Turn it off to avoid storing repository content on the incident record. |
| outcome.maxAmendmentDiffBytes | 8192 | MaxAmendmentDiffBytes caps the stored diff. A larger diff is recorded by hash only. |
| YalsoConfig · Learning from its own record | ||
| learning.enabled | true | Enabled turns the learning loop on. It defaults to on because proposing costs nothing: every lesson is inert until a human approves it. |
| learning.interval | 1h | Interval is how often the operator looks over its own record. Lessons are drawn from weeks of history, so looking more often than this finds the same patterns and spends reads doing it. |
| learning.minOccurrences | 3 | MinOccurrences is how many times something must happen before it is proposed as a lesson. Two is a coincidence and three is a pattern. At least 2. |
| learning.minConfidence | 50 | MinConfidence is the share of comparable outcomes a lesson must account for before it is worth proposing, as a percentage. A pattern that explains one outcome in twenty is noise a reviewer should not have to read. 0 to 100. |
| learning.autoApply | false | AutoApply approves every lesson the operator proposes, without a human. It should stay off. The approval is the entire difference between an analyst that learns from its mistakes and one that talks itself into them. The operator still cannot write spec.approval, so the record continues to say truthfully that no human decided. |
| YalsoConfig · Notifications | ||
| notifications.slack.enabled | — | Enabled turns Slack delivery on. |
| notifications.slack.events | PROpened IssueRegressed ValidationRejected VerifierDisagreed | Events lists which pipeline events reach Slack. This list is what leaves the cluster to a third party. |
| notifications.slack.credentialRef.name | required | Name of the Secret. It must live in the operator's own namespace. |
| notifications.slack.credentialRef.key | required | Key inside the Secret's data map. |
| notifications.slack.cooldown | — | Cooldown suppresses a repeat of the same event for the same target on this channel within the window. Absent or zero disables it. |
| notifications.slack.digest.enabled | — | Enabled replaces per-event IncidentOpened, AnalysisDeclined and Advised messages with one summary. PROpened, IssueRegressed, ValidationRejected and VerifierDisagreed stay immediate. Pending counts are held in memory, so a restart drops an unsent digest. |
| notifications.slack.digest.interval | unset | Interval is how long events are collected before the summary is sent. Absent or zero means 24h. |
| notifications.telegram.enabled | — | Enabled turns Telegram delivery on. |
| notifications.telegram.events | PROpened IssueRegressed ValidationRejected VerifierDisagreed | Events lists which pipeline events reach Telegram. This list is what leaves the cluster to a third party. |
| notifications.telegram.credentialRef.name | required | Name of the Secret. It must live in the operator's own namespace. |
| notifications.telegram.credentialRef.key | required | Key inside the Secret's data map. |
| notifications.telegram.chatIDRef.name | required | Name of the Secret. It must live in the operator's own namespace. |
| notifications.telegram.chatIDRef.key | required | Key inside the Secret's data map. |
| notifications.telegram.cooldown | — | Cooldown suppresses a repeat of the same event for the same target on this channel within the window. Absent or zero disables it. |
| notifications.telegram.digest.enabled | — | Enabled replaces per-event IncidentOpened, AnalysisDeclined and Advised messages with one summary. PROpened, IssueRegressed, ValidationRejected and VerifierDisagreed stay immediate. Pending counts are held in memory, so a restart drops an unsent digest. |
| notifications.telegram.digest.interval | unset | Interval is how long events are collected before the summary is sent. Absent or zero means 24h. |
| notifications.webhook.enabled | — | Enabled turns webhook delivery on. |
| notifications.webhook.kind | — | Kind is the wire format the endpoint expects, and it has no default on purpose. A default would pick a body shape on your behalf, and the wrong shape is accepted by nothing: PagerDuty rejects the request, and a plain receiver stores JSON it cannot read. An enabled webhook that names no kind is reported as unusable on the YalsoConfig instead. One of pagerduty, ntfy, url. |
| notifications.webhook.events | PROpened IssueRegressed ValidationRejected VerifierDisagreed | Events lists which pipeline events reach the webhook. This list is what leaves the cluster to a third party. IncidentOpened is deliberately not among the defaults: one correlated failure opens many incidents, and a pager woken by each of them is a pager that gets silenced. |
| notifications.webhook.credentialRef.name | required | Name of the Secret. It must live in the operator's own namespace. What the value is depends on kind. For pagerduty it is the Events v2 routing key. For ntfy and url it is the whole endpoint URL, which is a credential in its entirety. pagerduty posts to PagerDuty's US service region endpoint, which this operator cannot change. |
| notifications.webhook.credentialRef.key | required | Key inside the Secret's data map. |
| notifications.webhook.cooldown | — | Cooldown suppresses a repeat of the same event for the same target on this channel within the window. Absent or zero disables it. |
| notifications.webhook.digest.enabled | — | Enabled replaces per-event IncidentOpened, AnalysisDeclined and Advised messages with one summary. PROpened, IssueRegressed, ValidationRejected and VerifierDisagreed stay immediate. Pending counts are held in memory, so a restart drops an unsent digest. |
| notifications.webhook.digest.interval | unset | Interval is how long events are collected before the summary is sent. Absent or zero means 24h. |
| notifications.suppress.namespaces | — | Namespaces lists namespaces, as exact names or globs, whose notifications are dropped. An invalid glob matches literally. |
| notifications.suppress.workloads | — | Workloads lists "namespace/name" globs whose notifications are dropped. An invalid glob matches literally. |
| YalsoConfig · How long records are kept | ||
| retention.resolvedIssues | 168h | ResolvedIssues is how long a resolved issue is kept after it closed. Zero keeps resolved issues forever, and nothing else bounds how much etcd they take. |
| retention.declinedIssues | — | DeclinedIssues is how long a declined issue is kept after its symptom was last seen. Measured from the last sighting, so a symptom that keeps firing keeps its declined issue. Zero keeps declined issues forever. |
| retention.finishedIncidents | 720h | FinishedIncidents is how long an incident is kept after it was decided. Much longer than the issue retention, deliberately: a lesson needs three comparable outcomes, and a pattern that takes two months to occur three times is exactly the one worth learning. Zero keeps incidents forever. |
| retention.lessons | 720h | Lessons is how long a dead lesson is kept after it was decided. |
| YalsoRuleSet · Rules you write | ||
| rules[].name | required | Name identifies the document. It must be unique within the set and appears in the composed output and in status. Matches ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$. |
| rules[].description | — | Description explains the document's purpose to a human reader. The operator does not inject it into the prompt. |
| rules[].body | required | Body is the markdown rule text, injected verbatim. |
| rules[].detail | — | Detail holds extra context appended only when this document matches. It lets a document stay short in the common case and expand when relevant. |
| rules[].priority | 0 | Priority orders documents within the same specificity tier. Higher values compose later, closest to the end of the composed document. 0 to 1000. |
| rules[].selector.namespaces | — | Namespaces the rule applies to. Absent matches every namespace. |
| rules[].selector.resources[].group | — | Group to match, for example "apps". Absent matches any group. |
| rules[].selector.resources[].kind | — | Kind to match, for example "Deployment". Absent matches any kind. |
| rules[].selector.resources[].name | — | Name to match. Absent matches any name. |
| rules[].selector.labelSelector | — | LabelSelector further narrows by the target object's labels. |
| YalsoLesson · A rule the operator proposes and you decide on | ||
| approval | Pending | Approval is the human decision. It defaults to Pending, so a lesson that reaches the cluster before anybody looks at it is inert. One of Pending, Approved, Rejected. |
| rule.name | required | Name identifies the document. It must be unique within the set and appears in the composed output and in status. Matches ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$. |
| rule.description | — | Description explains the document's purpose to a human reader. The operator does not inject it into the prompt. |
| rule.body | required | Body is the markdown rule text, injected verbatim. |
| rule.detail | — | Detail holds extra context appended only when this document matches. It lets a document stay short in the common case and expand when relevant. |
| rule.priority | 0 | Priority orders documents within the same specificity tier. Higher values compose later, closest to the end of the composed document. 0 to 1000. |
| rule.selector.namespaces | — | Namespaces the rule applies to. Absent matches every namespace. |
| rule.selector.resources[].group | — | Group to match, for example "apps". Absent matches any group. |
| rule.selector.resources[].kind | — | Kind to match, for example "Deployment". Absent matches any kind. |
| rule.selector.resources[].name | — | Name to match. Absent matches any name. |
| rule.selector.labelSelector | — | LabelSelector further narrows by the target object's labels. |
The Kubernetes API already serves the records themselves to anything holding a kubeconfig. This serves the layer above them, so every consumer is not deriving it again and drifting apart.
| Path | Answers |
|---|---|
| / | the panel: a timeline of every incident, its analysis, and how incidents relate |
| /api/v1/snapshot | every record in one consistent response |
| /api/v1/summary | the counts |
| /api/v1/issues | symptoms, filterable by namespace, detector and phase |
| /api/v1/incidents | correlated groups; the filters match any member |
| /api/v1/groups | how incidents relate to one another |
| /openapi.json | the specification, held to the routes by a test |
It stays off unless you give it an address. Put a NetworkPolicy in front of it.
One value adds a Model Context Protocol endpoint to the port the panel already uses, so an agent can ask what broke rather than you reading the panel and pasting it. Same process, same Service, nothing new to route.
| Tool | Answers |
|---|---|
| yalso_summary | the counts, to size the problem before listing anything |
| yalso_list_issues | symptoms, narrowed by namespace, detector or phase |
| yalso_list_incidents | correlated groups; the filters match any member |
| yalso_get_incident | one incident: the analysis, both gates, the pull request and what became of it |
| yalso_incident_issues | the symptoms one incident grouped, in full |
| yalso_list_groups | how incidents relate to one another |
| Both listings take a limit and return 50 by default. A tool result lands in a model's context and stays there, where an HTTP body does not, so a listing is bounded rather than complete. A truncated one says how many it left: a quiet cap reads as the whole cluster. | |
An incident's signal comes back as a bare enum value, so the server also serves one document saying what each value records. It is generated from the constants that define them, and the build fails when the two disagree.
It requires the read API and inherits its protection, which is none. If you published the panel, you published this.
Two limits, stated up front so you do not stumble on them in a FAQ after you install.
It keeps a retention window, not a history
Resolved issues and finished incidents are pruned on the schedule your configuration sets. Once a record expires, it can no longer answer questions.
It patches only what a manifest declares
A rendered Helm chart committed to your repository is fair game, because the object
is a file. A chart rendered at deploy time is not. In that case YALSO declines with
HelmSourceUnsupported and tells you why, rather than guessing at a file
that does not exist.
→ rendered at deploy time
SourceNotFound
→ no manifest declares it
A short pull request would ask you to trust it, so these run long on purpose. Each
proposal names the problem and the change, quotes the evidence behind every
statement, and quotes the YalsoRuleSet rules that produced it, so you
can see which of your own rules is talking. It also records which gates ran, with
hashes of the evidence and of the rules.
It ends under a heading called Not claimed, where the operator says plainly what it is not asserting.
that anything was applied
that either gate can tell whether
the fix is the right one
MIT licensed, written in Go on controller-runtime, and shipped as a multi-architecture image and a Helm chart. The code lives at github.com/lukaszraczylo/yalso . Read it before you believe a word on this page.