Skip to content

Organizational Policy

llms.txt lists every page for an agent
Optional: hand this page to your coding agentThe steps work by hand too.
Show the whole prompt
Read https://intentius.io/chant/guide/organizational-policy/.
Write the policy I describe as a post-synth check in `policies/`, register it under `lint.policies` in `chant.config.ts`, and add a `policyGate()` step before the apply phase of any Op that deploys.
Run `npx chant build src --env <env>` for each environment I name and `npx chant lint`, and show me what each policy reports.
Open a pull request with the policy and the config. Do not suppress a finding to make a build pass.
Never deploy to a real cloud account or shared environment (with `chant run`, `chant components` or a platform's own CLI), approve (a pull request review, `chant approve` or `chant run approve`), override a policy denial (`chant approve policy-override`), or merge; never cut or publish a release (`chant release cut --yes` or `chant release publish`), push or delete a `ci/*` tag (`chant ci tick` pushes them), or change the value of an existing flag; never touch `.chant/allowed_signers` or `chant/lifecycle`.

Lexicon lint checks that a resource is valid for its domain. Organizational policy is your org’s own rules: “no public load balancers in prod,” “every workload carries a cost-center tag,” “instance types from this allowlist.” Such rules span lexicons, belong to one org, and often branch on environment.

In chant, organizational policy is project-authored post-synth checks, run by the existing engine, with the current environment in context. They use the PostSynthCheck shape lexicons ship as domain rules, with no separate policy language.

A policy reads the resolved resources and returns diagnostics. A diagnostic with severity: "error" fails chant build, and that is the gate. The check receives ctx.env, so a rule can branch on the environment.

Both checks below read ctx.docs, the parsed-output view (chant #975, Post-Synth Check Guide), instead of splitting ctx.outputs YAML by hand. A lexicon’s own post-synth checks read the same view, so an org policy gets the same parse-once manifest list without a lexicon-internal import.

policies/org.ts
// Organizational policy — project-authored post-synth checks.
//
// These are the same `PostSynthCheck` shape lexicons ship as domain rules, but
// authored by *your org* and registered via `lint.policies` in chant.config.ts.
// They run during `chant build` over the resolved resources, with the current
// `--env` in context, so a policy can branch on environment. A check returning
// `severity: "error"` fails the build — the gate.
import type {
PostSynthCheck,
PostSynthContext,
PostSynthDiagnostic,
} from "@intentius/chant/lint/post-synth";
interface Manifest {
kind?: string;
metadata?: { name?: string; labels?: Record<string, string> };
spec?: { tls?: unknown[] };
}
/**
* Every parsed Kubernetes manifest across all lexicon outputs, via
* `ctx.docs` (chant #975) — the shared, parse-once view every check and
* policy gets, in place of hand-splitting `ctx.outputs` YAML/JSON. `d.error`
* marks a document `parseOutputDocs` could not make sense of; skip it
* rather than pass `undefined` along as a "manifest".
*/
function manifests(ctx: PostSynthContext): Manifest[] {
return (ctx.docs ?? [])
.filter((d) => !d.error)
.map((d) => d.value as Manifest);
}
const COST_CENTER = "acme.io/cost-center";
/** Every workload must be attributable to a cost center — in every environment. */
export const costCenterRequired: PostSynthCheck = {
id: "ORG-COST-CENTER",
description: "every Deployment must carry an acme.io/cost-center label",
check(ctx): PostSynthDiagnostic[] {
const diags: PostSynthDiagnostic[] = [];
for (const m of manifests(ctx)) {
if (m.kind !== "Deployment") continue;
// Literal key rather than `[COST_CENTER]` — EVL003 requires computed
// keys to be statically evaluable literals.
if (!m.metadata?.labels?.["acme.io/cost-center"]) {
diags.push({
checkId: "ORG-COST-CENTER",
severity: "error",
message: `Deployment "${m.metadata?.name}" is missing the ${COST_CENTER} label`,
entity: m.metadata?.name,
});
}
}
return diags;
},
};
/** Production ingress must terminate TLS. Lower environments may skip it. */
export const tlsRequiredInProd: PostSynthCheck = {
id: "ORG-PROD-TLS",
description: "in prod, every Ingress must terminate TLS",
check(ctx): PostSynthDiagnostic[] {
if (ctx.env !== "prod") return []; // the environment-aware branch
const diags: PostSynthDiagnostic[] = [];
for (const m of manifests(ctx)) {
if (m.kind !== "Ingress") continue;
const tls = m.spec?.tls;
if (!Array.isArray(tls) || tls.length === 0) {
diags.push({
checkId: "ORG-PROD-TLS",
severity: "error",
message: `Ingress "${m.metadata?.name}" must terminate TLS in prod`,
entity: m.metadata?.name,
});
}
}
return diags;
},
};

ORG-COST-CENTER runs in every environment; ORG-PROD-TLS only fires when the build is for prod.

Add the policy file(s) to lint.policies in chant.config.ts. Policies differ from lint.plugins (declarative lint rules) in who writes them and when they run: they read the resolved resources during build. The engine is the same.

chant.config.ts
import type { ChantConfig } from "@intentius/chant";
// `lint.policies` registers project-authored organizational policy checks. They
// run during `chant build` over the resolved resources, with `--env` in
// context, and fail the build on violation. Distinct from `plugins` (declarative
// lint rules) by authorship and phase — same engine.
export default {
lexicons: ["k8s"],
ownership: { stack: "storefront" },
lint: { policies: ["policies/org.ts"] },
} satisfies ChantConfig;

Policy runs during chant build. Pass the environment with --env (or set ownership.env in the config):

Terminal window
chant build src # cost-center enforced; prod-only rules dormant
chant build src --env dev # same — ORG-PROD-TLS skipped
chant build src --env prod # ORG-PROD-TLS now enforced

For the example above, the prod build fails:

error: [policy:ORG-PROD-TLS] [storefront] Ingress "storefront" must terminate TLS in prod

chant lint runs the same policies over the same in-memory build, after each lexicon’s own post-synth checks, and reports a finding under the policy’s id (ORG-PROD-TLS) at the file that declared the entity it names.

Policy evaluates the synthesized artifact offline and never touches the cloud. For live checks, use chant lifecycle.

Turn a check off (or down to warning) with lint.rules, keyed by the check’s own id, the same mechanism as any lexicon lint rule:

export default {
lexicons: ["k8s"],
lint: {
policies: ["policies/org.ts"],
rules: { "ORG-PROD-TLS": "off" },
},
} satisfies ChantConfig;

chant-disable source comments do not apply to a policy finding; see Post- Synth Checks and Policies for why. A suppressed finding is counted, not dropped: chant build and policyGate both print how many lint.rules suppressed.

A policy is your code, and chant build --sandbox runs it in an isolated child process rather than in the CLI’s own, after the build is merged and serialized, over the same resources, with the same env, producing the same diagnostics. You write it the same way.

What changes is what it may do while running: it may read the project directory and nothing else, it may not write files or spawn processes, and it sees only PATH and CHANT_ENV in its environment. A policy that writes a report or shells out to a scanner fails under --sandbox, naming the file and the operation. See Sandboxed Execution for the full contract, including the handful of ways the resource view is narrower.

Cookbook: the same shape against CloudFormation

Section titled “Cookbook: the same shape against CloudFormation”

Everything above works for any lexicon: a policy reads ctx.docs, whatever lexicon produced it. Two more examples, in the style CDK’s cdk-nag / CfnGuardValidator compliance packs cover for CloudFormation: a bucket must not allow public access in prod, and every bucket must declare encryption. (For reusing an existing CloudFormation Guard/OPA ruleset instead of writing one of these by hand, see the aws lexicon’s Policy Validation guide; the two work together.)

import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
interface CfnResource {
Type?: string;
Properties?: Record<string, unknown>;
}
/** Every `AWS::S3::Bucket` across every parsed CloudFormation template, via `ctx.docs`. */
function s3Buckets(ctx: PostSynthContext): Array<[string, CfnResource]> {
const buckets: Array<[string, CfnResource]> = [];
for (const doc of ctx.docs ?? []) {
if (doc.error) continue;
const resources = (doc.value as { Resources?: Record<string, CfnResource> })?.Resources ?? {};
for (const [logicalId, resource] of Object.entries(resources)) {
if (resource.Type === "AWS::S3::Bucket") buckets.push([logicalId, resource]);
}
}
return buckets;
}
const BLOCK_FLAGS = ["BlockPublicAcls", "BlockPublicPolicy", "IgnorePublicAcls", "RestrictPublicBuckets"] as const;
/** In prod, every bucket must fully block public access. Lower environments may skip it. */
export const noPublicBucketsInProd: PostSynthCheck = {
id: "ORG-NO-PUBLIC-BUCKETS-PROD",
description: "in prod, every S3 bucket must block all public access",
check(ctx): PostSynthDiagnostic[] {
if (ctx.env !== "prod") return []; // the environment-aware branch
const diags: PostSynthDiagnostic[] = [];
for (const [logicalId, resource] of s3Buckets(ctx)) {
const pab = resource.Properties?.PublicAccessBlockConfiguration as Record<string, unknown> | undefined;
const blocked = !!pab && BLOCK_FLAGS.every((flag) => pab[flag] === true);
if (!blocked) {
diags.push({
checkId: "ORG-NO-PUBLIC-BUCKETS-PROD",
severity: "error",
message: `S3 bucket "${logicalId}" must block all public access in prod`,
entity: logicalId,
});
}
}
return diags;
},
};
/** Every bucket, in every environment, must declare server-side encryption. */
export const bucketEncryptionRequired: PostSynthCheck = {
id: "ORG-BUCKET-ENCRYPTION",
description: "every S3 bucket must declare BucketEncryption",
check(ctx): PostSynthDiagnostic[] {
const diags: PostSynthDiagnostic[] = [];
for (const [logicalId, resource] of s3Buckets(ctx)) {
if (!resource.Properties?.BucketEncryption) {
diags.push({
checkId: "ORG-BUCKET-ENCRYPTION",
severity: "error",
message: `S3 bucket "${logicalId}" has no BucketEncryption configured`,
entity: logicalId,
});
}
}
return diags;
},
};

Register both the same way: add the file to lint.policies in chant.config.ts (lexicons: ["aws"] instead of ["k8s"]). ORG-BUCKET- ENCRYPTION runs in every environment; ORG-NO-PUBLIC-BUCKETS-PROD only fires in prod, the same environment-aware branch ORG-PROD-TLS uses above.

The sql lexicon exports a typed read API over the parsed schema, @intentius/chant-lexicon-sql/read. A policy can use it to check columns, keys and engines rather than SQL text. Two such rules are “every table has created_at timestamptz NOT NULL” and “no Nullable column in a ClickHouse sort key”. See the sql lexicon’s Your Own Schema Rules for both dialects.

chant already has the machinery: a post-synth phase that reasons about resolved resources, project-authored rule loading, and the Op gate model for the apply side. A separate policy language (Rego/OPA-style) would duplicate the lint engine and break “TypeScript is the one language.” Org policy is post-synth checks the org writes.

chant build already fails on a policy violation, so any pipeline that builds before applying is gated. To gate an apply inside an Op, add a policyGate() step before the apply phase:

import { Op, phase, build, policyGate } from "@intentius/chant/op";
import { kubectlApply } from "@intentius/chant-lexicon-k8s/op/builders";
export default Op({
name: "deploy",
phases: [
phase("Build", [build(".")]),
// Re-runs lint.policies over the resolved resources; a violation fails the
// workflow here, so nothing is applied.
phase("Policy", [policyGate({ env: "prod" })]),
phase("Apply", [kubectlApply("k8s.yaml")]),
],
});

policyGate builds the project, runs lint.policies with the given env (or ownership.env), and blocks on any violation. It runs once, with no retry (a deterministic violation is not worth retrying). It is a plain activity, so it gates the same wherever the run is hosted. A clean evaluation passes through to the apply.

The build behind the gate is assembled from the same resolution chant build uses, so it is the same project: folded by default, with the project’s config reaching the serializers and its buildRoots contributing their entities. A gate that passes is a gate over a build chant build would also accept.

chant refuses one combination outright. chant run <op> --sandbox on an Op containing a policyGate step exits before any phase runs: the gate builds the project and imports its lint.policies modules in the chant process, which is what --sandbox promises does not happen. Run the Op without --sandbox, or build separately with chant build --sandbox. A project whose chant.config.ts sets build.sandbox: true still gets a gate that builds in-process, and the gate says so on stderr.

When the gated apply runs as an Op waves job whose spec has notify, a blocked wave fails its job, and the job posts a failed event to Slack, Teams or a signed webhook (Notify).

policyGate blocks until the policy is loosened or the violation is fixed. A tool that lets a person apply a denied plan anyway records the decision on the override gate. The override is bound to the plan digest and the rules that denied it, and it counts once.

The full example is lexicons/k8s/examples/org-policy and CI runs it (clean in dev, blocked in prod).

chant