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.
Writing a policy
Section titled “Writing a policy”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.
// 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.
Registering it
Section titled “Registering it”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.
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;Running it
Section titled “Running it”Policy runs during chant build. Pass the environment with --env (or set
ownership.env in the config):
chant build src # cost-center enforced; prod-only rules dormantchant build src --env dev # same — ORG-PROD-TLS skippedchant build src --env prod # ORG-PROD-TLS now enforcedFor the example above, the prod build fails:
error: [policy:ORG-PROD-TLS] [storefront] Ingress "storefront" must terminate TLS in prodPolicy evaluates the synthesized artifact, offline — it never touches the
cloud. Live checks are chant lifecycle, not this.
Suppressing a check
Section titled “Suppressing a check”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.
Under --sandbox
Section titled “Under --sandbox”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.
Why not a separate engine
Section titled “Why not a separate engine”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.
Gating an apply
Section titled “Gating an apply”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
policyGateblocks; loosen the policy or fix the violation to proceed.
Try it
Section titled “Try it”The full example is
lexicons/k8s/examples/org-policy
— it is exercised in CI (clean in dev, blocked in prod).