deploy/kcl/<env>/ and, for the secrets and PodDisruptionBudget checks, over each environment’s real render, so they judge the objects that would be applied. Each exists because the failure it prevents shows up late and far from the cause: a pod stuck in CreateContainerConfigError, a Job that re-runs every reconcile, a node drain that never finishes.
The overview covers flags and silencing. Escape hatches here are written in KCL as # forge:lint-disable-next-line <rule>: <why> above the declaration, or # forge:lint-disable-file <rule>: <why> in the file. Rollout cells follow the rollout page.
Delivery safety
Forge applies some objects once and a reconciler (Flux, on hosted delivery) applies others every interval, pruning what a bundle stops carrying. An object that is wrong only under a reconciler passes a direct apply and then fails every reconcile after the first change.
Severity for the three Flux rules follows who applies the object’s delivery unit. An object in a unit a reconciler applies gets an error, because its bundle would be refused at build; an object forge applies itself gets a warning, because nothing re-applies it today but moving it into a reconciled unit would break it.
flux-job-ttl
A reconciler re-applies the same bytes on every interval. A Job with ttlSecondsAfterFinished is deleted by the TTL controller once finished, and the next reconcile creates it again, so a migration Job re-runs every period.
ttlSecondsAfterFinished. A superseded Job is removed by its spec-hash name plus prune, never by a timer.
flux-job-unhashed
A Job’s spec is immutable. If its name does not change when its spec does, every reconcile after the first change fails instead of creating a new Job, and the old one is never pruned. Forge’s standalone Jobs are already named <name>-<10 hex of a hash of the spec>; a hand-written one in forge.Manifests must follow the same shape.
flux-force-on-data
The Flux force annotation deletes and re-creates an object on an immutable-field change. On a StatefulSet, PVC or anything that owns data, that loses its state. Remove the annotation and change the immutable field through a migration.
pdb-blocks-drain
A PodDisruptionBudget with minAvailable >= replicas, maxUnavailable: 0 or minAvailable: 100% can never allow a disruption, so it blocks every node drain that touches its pods, forever. The check runs over each environment’s render, so it covers forge-rendered PDBs and hand-written forge.Manifests alike. It is a warning because a render that fails is skipped: lint judges the project, and forge env render owns render errors.
hosted-image-base/host-bearing-image
On Reliant hosting the platform admits images from exactly one subtree, <registry>/<org>/<project>. A bare image = "api" is the correct shape; forge composes the rest from the org your credential acts for. Writing the host yourself is transcribing a value forge already composes, which is fine when you get it right and a publish-time refusal when you do not.
forge env render <env> runs the authoritative one.
identity-share-with
forge.ManagedIdentity.share_with lets one environment use another’s identity. KCL already refuses a share_with that names another project, the env itself or a host-run IdP. This rule judges the part one render cannot see: whether the owner environment exists, declares a hosted identity of its own, and is not itself a sharer (a chain).
shellbuild-tokens/retired-token
Forge used to substitute a ${TAG} / ${TARGETARCH} vocabulary into a ShellBuild command. It no longer does; the command is plain KCL and runs byte for byte. A leftover token is now a shell variable nothing sets. This gates because one of them fails silently: GOARCH=${TARGETARCH} becomes GOARCH=, which Go reads as unset and builds for the host, so an amd64 cluster gets an arm64 binary and the only symptom is exec format error in a crash loop.
forge.image_tag("<env>"), forge.target_arch(), forge.env(), file.workdir()), or you can declare the name in the build’s env map so the shell resolves it.
Files that should not exist
no-dotenv
A dotenv was handed to every host service wholesale, so a value added to it went live in every process without being declared anywhere. Appending to an untracked file became cheaper than declaring a value, and non-secret config drifted out of version control into a file nobody could review. The replacement is forge.FileSecrets and forge secret set, injected only into the services that declare each key. The finding names the fix for the file it found: forge secret migrate --env <env> for secrets, deploy/kcl/<env>/config.k for config, and forge secret ensure --env <env> for an .env.example.
The commit-policy rules
Generated code the compiler needs must be committed so a fresh clone builds as cloned; tool-derived output and machine-local files must not be.forge ci verify-generated cannot see drift in an ignored file, which is why this is a lint and not a template default.
generated-code-ignored: a.gitignorerule is hiding a generated file. Remove the rule andgit addthe file.machine-local-state-tracked:git rm --cachedit and add it to.gitignore.tool-derived-file-tracked:forge generate && git rm -r --cached gen/forge_descriptor. The descriptor is only read by tooling and is regenerated.
Secrets
forge lint --secrets judges the platform secrets model per environment, from the real render, with file and line. Every finding names its escape. They all warn until v1.0.1.
secrets-cluster-kind
The External Secrets Operator forge installs processes namespaced SecretStore and ExternalSecret only; its cluster kinds, PushSecret and generators are disabled and their CRDs absent. A rendered object of one of those kinds would be accepted by nothing. Use a namespaced store and a forge.ExternalSecret with a source.
secrets-es-policy
An ExternalSecret must be an Orphan-or-Merge, Retain, namespaced-store, explicit-key declaration. Each part closes a way to lose a live credential: creationPolicy unset means ESO’s default Owner, which deletes the Secret with the ExternalSecret (a Flux prune would take a live credential with it); deletionPolicy other than Retain lets a store outage delete the Secret; dataFrom lets a value added to the store reach the cluster unreviewed; a target.name that differs writes a Secret another declaration owns; a refreshInterval under five minutes is a load on the store. A forge.ExternalSecret with a source renders exactly the safe shape.
secrets-key-coverage
A workload that reads a Secret nothing produces sits in CreateContainerConfigError. A workload that reads a key the producing declaration does not list loses it on the next sync: with creationPolicy: Orphan, ESO rebuilds the Secret from the declared keys only.
secrets-single-owner
Two declarations for one Secret fight over its keys on every sync, and the loser’s keys disappear without an error. Keep one declaration, or give each source its own Secret name and have the workload read both.
secrets-immutable-pin
A store name marked immutable is never rotated in place, so it has exactly one version. An ExternalSecret that reads it without a pin follows the store’s latest version, so a write to the store changes the value the pod gets; one that pins a version other than 1 is refused too. A new value is a new name, migrated by a reviewed change.
secrets-out-of-band-reason
An ExternalSecret with no source declares that someone else provisions the Secret, and must say who. A reason under twelve characters, or one that restates that the Secret is provisioned by hand without saying why, is refused.
openbao-wildcard-read
A grant that reads a glob under a mount exposes every value under it to that principal. Grant exact paths, or set wildcard_read_reason on the forge.OpenBaoGrant to say why they cannot be named and when the glob goes.
secrets-escrow-in-cluster
A store whose seal key and bao operator init output are escrowed inside the cluster it runs on loses both with the cluster, and the unseal key is also what makes its backups readable. It is a warning, and only a real environment is judged: a local or ephemeral cluster is not.
secrets-unreferenced-key and secrets-env-not-rendered
The first is informational: a declared Secret or key no workload this environment renders reads. If a controller, a Job or another environment reads it, ignore it; otherwise it is an orphan. The second says an environment did not render at all, so it was not judged. Run forge env render <env> to see why.
Composition graph
forge lint --graph checks internal/app as a whole: that every component and provider is reached by some root, and that every listener serves only services of its chain’s audience. It only applies to a project composed with the graph. All three rules are v1.0.0 -> v1.0.1.
forge-graph-unreachable
A component that no root reaches is built by no process. It compiles, its tests pass, and it never runs in any environment.
pending: nothing takes … in its Deps yet; it becomes an error once a consumer that no root reaches takes it.
forge-graph-audience
One listener is one trust domain. A listener whose chain authenticates one audience and serves a service declared for another lets, for example, an end-user caller reach an operator service. The finding names the listener, the service, both audiences and the authenticator.
(forge.v1.service).audience. An authenticator provider is marked //forge:audience <name> so the lint can read it. If the audience of a chain cannot be read statically, the finding says so.
Component drift
deploy/kcl/workloads.k is scaffolded once and yours afterwards, because deploy is where per-environment customization lives. The cost is drift between what your code declares and what that file declares. These rules report it and never write.
Both directions can be deliberate: a component no environment deploys, or infrastructure forge did not generate (NATS, a cache, a sidecar). Forge notices and a human decides. For
undeclared-component the finding prints the literal stanza to paste. For no-such-command, a project built on the composition graph has commands that are the roots of its graph (cmd/<bin>/cmd/roots_gen.go), not one per service or worker, so a workload that runs <bin> orders exits “unknown command” wherever it is deployed. A service the server root runs needs no workload of its own; delete the declaration, or declare a //forge:root struct for a process of its own.
Static export
forge lint --static-export applies to a Next.js frontend that must build to a static export (output: static in forge.yaml, or bound to forge.OnHosted, OnBucket or OnFirebase in any env). Hosted static hosting serves only static files, so anything a server would do has to go. The lane says first, with file and line, what next build would refuse or what the export would silently drop.
/<entity>/view?id=… and /<entity>/edit?id=…. A hand-written [id] page is what trips the rule, and for a scaffold-once page from an older forge the finding prints the rm-and-rescaffold commands that replace it. The trailing-slash rule matters on a bucket: a bucket serves objects by exact name, so without trailingSlash: true the export writes orders.html and every route but / returns 404, while the export builds fine.
static-export-route-handler is an error for a GET handler not marked force-static (the export would render it once at build time and next build refuses a GET that might read the request), and a warning for POST and other methods (the export drops them and a static host answers 404). static-export-dynamic-config is a warning for revalidate = 3600 style values, because the page is built once and frozen.