Skip to content

Organizational Policy

Lexicon lint answers “is this a coherent resource for its domain.” It does not express organizational policy — “no public load balancers in prod,” “every workload carries a cost-center tag,” “instance types from this allowlist.” Those are cross-cutting, org-specific, and often need to branch on environment.

chant does not add a separate policy language for this. Organizational policy is project-authored post-synth checks, run by the existing engine, with the current environment in context — the same PostSynthCheck shape lexicons ship as domain rules, authored by your org.

A policy reads the resolved resources and returns diagnostics. A diagnostic with severity: "error" fails chant build — 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) — rather than hand-splitting ctx.outputs YAML. It is exactly what a lexicon’s own post-synth checks use, with no lexicon-internal import required: an org policy gets the same shared, parse-once manifest list.

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. This is distinct from lint.plugins (declarative lint rules) by authorship and phase — policies reason about the resolved resources during build — but it is the same engine.

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

Policy evaluates the synthesized artifact, offline — it never touches the cloud. Live checks are chant lifecycle, not this.

Turn a check off (or down to warning) with lint.rules, keyed by the check’s own id — 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. Nothing about how you write one changes.

What does change 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”

Nothing above is k8s-specific — 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 compose, they don’t compete.)

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.

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 authors — nothing more.

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 — non-retryable and single-attempt (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.

One combination is refused rather than half-honoured. 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.

Signed override (deferred). Pausing for a human, signed, audited override to proceed despite a violation requires conditional gating in the generated workflow — it is tracked as a follow-on, not in this version. Today policyGate blocks; loosen the policy or fix the violation to proceed.

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