> ## Documentation Index
> Fetch the complete documentation index at: https://docs.reliantlabs.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Lint rules: deploy, secrets and the composition graph

> The rules that read your deploy/kcl environments, the secrets they declare, the composition graph and commit policy: what each prevents, with examples, severity and rollout

These rules read what you deploy, not what you compile. They run over `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](https://docs.reliantlabs.io/forge/linting/overview.md) 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](https://docs.reliantlabs.io/forge/linting/rollout.md).

## 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.

| Rule | Severity | Rollout | What it catches |
| - | - | - | - |
| `flux-job-ttl` | error | v0.1.46 | a Job with `ttlSecondsAfterFinished` |
| `flux-job-unhashed` | error | v0.1.46 | a Job whose name carries no hash of its spec |
| `flux-force-on-data` | error | v0.1.46 | the Flux `force` annotation on an object that holds data |
| `pdb-blocks-drain` | warning | v0.1.44 | a PodDisruptionBudget that can never allow a disruption |
| `hosted-image-base/host-bearing-image` | warning | v0.1.44 | a registry host on an `OnHosted` workload's image |
| `identity-share-with` | error | v0.1.46 | a `share_with` identity chain forge cannot honor |
| `shellbuild-tokens/retired-token` | error | v0.1.40 | a retired `${TOKEN}` left in a `ShellBuild` command |

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.

```yaml theme={null}
# bad
kind: Job
spec:
  ttlSecondsAfterFinished: 600
```

Remove `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.

```yaml theme={null}
# bad: one replica, minAvailable 1
spec:
  minAvailable: 1
# fix: allow one disruption
spec:
  maxUnavailable: 1
```

### `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.

```python theme={null}
# bad
image = "registry.example.com/other-org/api"
# fix
image = "api"
```

When forge knows the admitted base it says the image is outside it; when it does not, it only says a host was declared. This is the cheap offline check; `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.

```python theme={null}
# bad: ${TARGETARCH} is a shell variable nothing sets
build = forge.ShellBuild {
    cmd = "GOARCH=${TARGETARCH} go build -o bin/api ./cmd/api"
}

# fix: compute the value in KCL, which interpolates ${_arch} before the shell runs
_arch = forge.target_arch()
build = forge.ShellBuild {
    cmd = "GOARCH=${_arch} go build -o bin/api ./cmd/api"
}
```

The finding lists a replacement for each retired token (`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

| Rule | Severity | Rollout | What it catches |
| - | - | - | - |
| `no-dotenv` | error | v0.1.40 | a `.env` file in the project |
| `generated-code-ignored` | error | v0.1.40 | generated code a compiler needs, excluded from git |
| `machine-local-state-tracked` | error | v0.1.40 | `.forge-kcl/` or a dev `public/config.js` committed |
| `tool-derived-file-tracked` | error | v1.0.10 -> v1.0.11 | `gen/forge_descriptor/` committed |

### `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 `.gitignore` rule is hiding a generated file. Remove the rule and `git add` the file.
* `machine-local-state-tracked`: `git rm --cached` it 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.

| Rule | Severity | Rollout | What it catches |
| - | - | - | - |
| `secrets-cluster-kind` | error | v1.0.0 -> v1.0.1 | a `ClusterSecretStore`, `ClusterExternalSecret`, `PushSecret` or generator |
| `secrets-es-policy` | error | v1.0.0 -> v1.0.1 | an `ExternalSecret` with an unsafe creation/deletion policy, store kind, `dataFrom`, name or refresh |
| `secrets-key-coverage` | error | v1.0.0 -> v1.0.1 | a workload reads a Secret or key nothing produces |
| `secrets-single-owner` | error | v1.0.0 -> v1.0.1 | one Secret declared by more than one source |
| `secrets-immutable-pin` | error | v1.0.0 -> v1.0.1 | an immutable store name read without a version pin |
| `secrets-out-of-band-reason` | error | v1.0.0 -> v1.0.1 | an `out_of_band_reason` that does not explain |
| `openbao-wildcard-read` | error | v1.0.0 -> v1.0.1 | an OpenBao grant that reads a glob without a reason |
| `secrets-escrow-in-cluster` | warning | v1.0.0 -> v1.0.1 | an OpenBao whose seal key is escrowed inside its own cluster |
| `secrets-unreferenced-key` | info | v1.0.0 -> v1.0.1 | a declared Secret or key no workload reads |
| `secrets-env-not-rendered` | warning | v1.0.0 -> v1.0.1 | an env did not render, so its secrets were not judged |

### `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.

```python theme={null}
# bad: the api reads key "url" of Secret app-db, and the declaration lists only "password"
forge.ExternalSecret {
    name = "app-db"
    namespace = "acme-prod"
    keys = ["password"]
    source = forge.StoreSource {
        store = forge.OpenBaoStore {}
        data = {"password" = "APP_DB_PASSWORD"}
    }
}

# fix: list the key and say where its value comes from
forge.ExternalSecret {
    name = "app-db"
    namespace = "acme-prod"
    keys = ["password", "url"]
    source = forge.StoreSource {
        store = forge.OpenBaoStore {}
        data = {"password" = "APP_DB_PASSWORD", "url" = "APP_DB_URL"}
    }
}
```

### `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.

```python theme={null}
# bad: APP_DB_PASSWORD is immutable and read without a pin
source = forge.StoreSource {
    store = forge.OpenBaoStore {}
    data = {"password" = "APP_DB_PASSWORD"}
    immutable = ["APP_DB_PASSWORD"]
}
# fix
source = forge.StoreSource {
    store = forge.OpenBaoStore {}
    data = {"password" = "APP_DB_PASSWORD"}
    immutable = ["APP_DB_PASSWORD"]
    pin = {"APP_DB_PASSWORD" = 1}
}
```

### `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.

```python theme={null}
# bad
forge.ExternalSecret { name = "cloudflare-api-token", namespace = "cert-manager", keys = ["api-token"], out_of_band_reason = "manual" }
# fix
forge.ExternalSecret { name = "cloudflare-api-token", namespace = "cert-manager", keys = ["api-token"], out_of_band_reason = "created by the DNS team in the cert-manager namespace when the zone is delegated" }
```

### `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`.

| Rule | Severity | Rollout | What it catches |
| - | - | - | - |
| `forge-graph-unreachable` | error (warning while a component has no consumer yet) | v1.0.0 -> v1.0.1 | a component or provider no root reaches |
| `forge-graph-audience` | error | v1.0.0 -> v1.0.1 | a listener serving a service of a different trust domain than its auth chain |
| `forge-graph-assemble` | error | v1.0.0 -> v1.0.1 | the graph does not assemble (`forge generate` fails with the same error and names the node, field and type) |

### `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.

```go theme={null}
// bad: nothing takes the Notifier in its Deps, and no root holds it
func NewNotifier(...) notifier.Service { ... }

// fix: take it in the Deps of a node a root reaches, delete New<…>, or keep it ready on purpose
//forge:standby
func NewNotifier(...) notifier.Service { ... }
```

Right after you scaffold a component nothing consumes it yet, so while it has no consumer the finding is a warning that reads `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.

```text theme={null}
listener public serves AdminService (audience "operator") through a chain authenticated by userAuth (audience "user"): a user caller would reach an operator service
```

Serve the service from a listener whose chain authenticates the right audience, or change `(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.

| Rule | Severity | Rollout | What it catches |
| - | - | - | - |
| `component-drift/undeclared-component` | warning | v0.1.40 | a service or worker in code with no entry in `workloads.k` |
| `component-drift/orphan-entry` | warning | v0.1.40 | an entry that names no Go component |
| `component-drift/no-such-command` | warning | v1.0.3 -> v1.0.4 | a workload whose args select no command of the binary |

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.

| Rule | Severity | Rollout | What it catches |
| - | - | - | - |
| `static-export-not-exported` | error | v0.1.44 | no `next.config`, or `output: "export"` is not set |
| `static-export-dynamic-segment` | error | v0.1.44 | a dynamic route (`[id]`) with no `generateStaticParams` |
| `static-export-route-handler` | error for a GET not marked static, warning for other methods | v0.1.44 | a route handler |
| `static-export-dynamic-config` | error for `force-dynamic` and `revalidate = 0`, warning for other `revalidate` | v0.1.44 | `export const dynamic` / `revalidate` |
| `static-export-server-action` | error | v0.1.44 | a `"use server"` action |
| `static-export-next-headers` | error | v0.1.44 | `cookies()`, `headers()` or `draftMode()` |
| `static-export-next-image` | error | v0.1.44 | `next/image` without `images.unoptimized` |
| `static-export-middleware` | warning | v0.1.44 | `middleware.ts`, which runs under `next dev` and never in production |
| `static-export-config-routes` | warning | v0.1.44 | `rewrites`, `redirects` or `headers` not gated to development |
| `static-export-trailing-slash` | warning | v0.1.44 | a bucket-bound export without `trailingSlash: true` |

```tsx theme={null}
// bad: app/orders/[id]/page.tsx has no generateStaticParams, so next build refuses it
// fix: the id moves from the path into the query string, which a static host can serve
//   app/orders/view/page.tsx   →   /orders/view?id=…   (read with useSearchParams)
```

Forge's scaffold already uses `/<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.

> [!NOTE]
>
> `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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.