Skip to content

Correctness Rules

llms.txt lists every page for an agent

Correctness rules catch structural errors that would cause evaluation or deployment failures.


Use typed property access instead of string-based GetAtt() or Ref() calls. String references bypass type checking and break if a resource is renamed.

Severity: warning

// ❌ Triggers COR003
export const fn = new _.Function({
role: GetAtt("functionRole", "Arn"),
bucket: Ref("dataBucket"),
});
// ✅ Fixed — use typed AttrRef properties
import { functionRole } from "./role";
import { dataBucket } from "./storage";
export const fn = new _.Function({
role: functionRole.arn,
bucket: dataBucket.ref,
});

Exported declarables that are never referenced by another resource in the same file may be dead infrastructure code.

Severity: warning

// ❌ Triggers COR004
export const logsBucket = new _.Bucket({
bucketName: "logs",
});
export const dataBucket = new _.Bucket({
bucketName: "data",
});
// logsBucket is never used — flagged
// ✅ Fixed — reference it or remove it
export const logsBucket = new _.Bucket({
bucketName: "logs",
});
export const dataBucket = new _.Bucket({
bucketName: "data",
loggingConfiguration: { destinationBucketName: logsBucket.ref },
});

Property-kind declarables are the classes a lexicon lists in its plugin’s propertyClassNames(). COR004 never flags one, since it only means something inside a resource and is often exported so that another file can assemble it. A resource that holds one, inline or through a const in the same file, is the root that emits it. Nothing is expected to reference that root, so COR004 does not flag it either. A Grafana dashboard holding its panels is the usual case. A resource whose property-kind children are all imported from other files has nothing in its own file that marks it as a root, so it is still flagged. Export it through an export { … } list or add a chant-disable-next-line COR004 comment.


Export every declarable instance so chant can find it during synthesis. The build sees only exported resources.

Severity: error

// ❌ Triggers COR008
const bucket = new _.Bucket({
bucketName: "my-data",
});
// ✅ Fixed
export const bucket = new _.Bucket({
bucketName: "my-data",
});

COR021: Literal Name in a Multi-Environment Project

Section titled “COR021: Literal Name in a Multi-Environment Project”

When a project declares two or more environments and binds its ownership marker to a build parameter (ownership.env: { param: "env" }), it is built once per environment. A name-bearing property holding a bare string literal then produces the same physical name in every build. Nothing reports the collision until apply time, when the second environment’s deploy overwrites the first’s resources. Interpolate the env parameter into the name instead; the template folds to a per-environment literal because build-time parameters resolve before any file is imported.

Severity: warning

// ❌ Triggers COR021 (config declares environments: ["dev", "prod"]
// and ownership: { env: { param: "env" } })
export const uploads = new _.Bucket({
bucketName: "billing-uploads",
});
// ✅ Fixed
import { params } from "@intentius/chant/params";
export const uploads = new _.Bucket({
bucketName: `billing-${params.env}-uploads`,
});

The rule stays silent when ownership.env is a literal or absent. Such a project names its instances another way (the layered configuration all-in-one pattern hand-names each instance). See Resource Naming for both patterns.


Nothing may reference an effect receipt’s attributes. A receipt is the declared witness that an out-of-band effect ran. The effect() step is its only writer, and writes it last, on success. A resource that derives a property from a receipt couples itself to a value only the effect controls, and the receipt’s late write (or its absence after a crash) ripples into resources that were supposed to be independent of whether the effect has fired. Const indirection fires too: an alias of a receipt is still the receipt.

Severity: error

// ❌ Triggers COR022
const seeded = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" });
export const app = new _.Service({
marker: seeded.effect,
});
// ✅ Fixed — reference the effect's inputs (or their sources) directly
export const seeded = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" });
export const app = new _.Service({
marker: "db-seed",
});

Passing the receipt value itself around (registering it, handing it to a materialization row) is fine. The leaf rule covers attributes only.


COR023: Receipt Materializes Into a Plain Store

Section titled “COR023: Receipt Materializes Into a Plain Store”

A receipt’s materialization target must be a plain store — never a Secret, a SecureString, or any secret-capable kind. The receipt value is a witness (an existence marker or a sha256: digest), not a secret; a secret-capable store adds masking, rotation, and access-control semantics that defeat the observe-and-compare loop the receipt exists for.

This is a post-synth check run by chant build over the whole build result. Core recognizes receipts through their marker, lexicon-independently, and checks the two signals it has: an entityType naming a secret-capable kind, and a Type/Kind prop selecting a secret-capable variant of an otherwise plain kind (SSM’s Type: "SecureString"). Each lexicon’s materialization row enforces its own kind concretely at the source.

Severity: error

# ❌ Triggers COR023 — receipt landed in a SecureString
MigratedReceipt:
Type: AWS::SSM::Parameter
Properties:
Name: /receipts/migrated
Type: SecureString
# ✅ Fixed — plain String parameter
MigratedReceipt:
Type: AWS::SSM::Parameter
Properties:
Name: /receipts/migrated
Type: String

COR024: Receipt Inputs Reference Secrets by Pointer

Section titled “COR024: Receipt Inputs Reference Secrets by Pointer”

A receipt’s inputs feed its expectation hash. Whatever goes there is canonicalized, digested, and compared against a stored value for the rest of the receipt’s life. chant’s constitutional line on secrets is that no code path may hold, log, hash, or compare a secret value, so a receipt input reading a Secret-kind entity’s material (.data, .value, .stringData, …) is refused. Reference the secret by name+version pointer instead: a rotation is then an explicit version bump that re-proposes the effect, and the hash only ever sees the pointer. Const indirection fires too, so moving the material into a variable first still counts.

Severity: error

// ❌ Triggers COR024 — secret material would enter the expectation hash
const dbSecret = new SecretManagerSecret({ name: "db-password" });
export const seeded = EffectReceipt("seeded", {
effect: "db-seed",
flavor: "hash",
inputs: { password: dbSecret.data.password },
});
// ✅ Fixed — name+version pointer
const dbSecret = new SecretManagerSecret({ name: "db-password" });
export const seeded = EffectReceipt("seeded", {
effect: "db-seed",
flavor: "hash",
inputs: { secretName: dbSecret.name, secretVersion: 3 },
});

COR025: Stringified Reference in Serialized Output

Section titled “COR025: Stringified Reference in Serialized Output”

A serialized output (a CloudFormation template, a Kubernetes manifest, …) containing the literal substring [object Object] is always an authoring error. A reference was coerced to a string, typically by a template literal or string concatenation over an object like an AttrRef, instead of resolved to its value. No serializer emits it on purpose and no provider schema expects it, so the check is cheap and has no meaningful false positives in any lexicon.

This is a post-synth check run by chant build over the whole build result’s emitted text, scanning every lexicon’s primary output plus any nested files (e.g. nested stack templates). It flags each distinct offending line once, quoting it in the diagnostic so the emitting declaration is easy to spot.

Severity: error

// ❌ Triggers COR025 — bucket.arn is an AttrRef, not a string;
// the template literal stringifies it via Object.prototype.toString
export const policy = new BucketPolicy({
bucket,
statement: [{ effect: "Allow", resource: `${bucket.arn}/*` }],
});
// ✅ Fixed — join the reference instead of interpolating it
import { Join } from "@intentius/chant-lexicon-aws";
export const policy = new BucketPolicy({
bucket,
statement: [{ effect: "Allow", resource: Join("", [bucket.arn, "/*"]) }],
});

A knowledge concept’s binds frontmatter key (an OKF v0.2 bundle read from the project’s knowledge/ directory, or wherever knowledge.dir points) must name a discovered entity’s logical name. A name that resolves to nothing is a stale binding. The entity was renamed or removed, or the concept named something that never existed.

This is a post-synth check run by chant build over the whole build result, alongside COR023/COR024. Per the OKF binding design (#1059), the spec permits broken links, so a stale binding is a warning. A concept with no binds never triggers this check, and neither does a project with no knowledge bundle.

Severity: warning

<!-- ❌ Triggers COR026 — knowledge/decisions/example.md -->
---
type: decision
title: Use a managed queue
binds: ghostQueue
---
No entity named `ghostQueue` exists in this project anymore.
<!-- ✅ Fixed — binds the current logical name -->
---
type: decision
title: Use a managed queue
binds: jobQueue
---
`jobQueue` is a declared entity in this project.

OPS012: Activity Step Args Don’t Match the Activity’s Contract

Section titled “OPS012: Activity Step Args Don’t Match the Activity’s Contract”

An activity step’s args and outcomeAttribute.from must match the activity’s registered ActivityContract: its name and arg schema, plus its return schema when one is declared. The check skips a step whose fn has no registered contract, since contracts are opt-in per activity.

This runs over every declared Op, whatever lexicons are configured, since an Op is recognized by its entity type. It catches four failures an untyped args: Record<string, unknown> would let through:

  • an unrecognized profile
  • an args key the schema doesn’t recognize
  • an args value of the wrong type
  • an outcomeAttribute.from path missing from the declared return type

Severity: error

// ❌ Triggers OPS012: "env" typo'd as "environment"
activity("lifecycleDiff", { environment: "prod" });
// ✅ Fixed: matches lifecycleDiff's registered contract
activity("lifecycleDiff", { env: "prod" });

A step-output reference (stepOutput() / step.out.field) must name a top-level activity step of the main phases that precedes it. An onFailure step may name any main-phase step, and one nested inside an effect may name a step that runs before the effect. Nothing may name a step nested inside an effect, or a step in onFailure (#3948). The named step also needs a registered contract with a returns schema the referenced path resolves against. It also compares the producer’s declared return type at that path against the consumer’s declared arg type at the same position, and flags a mismatch such as a boolean-returning path feeding a string-typed arg.

This is what lets a lexicon’s serializer compile every reference it finds unconditionally: chant build blocks output while an error-severity finding stands, so a workflow referencing an unresolved step never reaches disk. The structural half of this check (an unknown step id, or a reference into a later phase) needs no activity contracts, so it catches a dangling reference even on a project with no lexicons configured.

Severity: error

// ❌ Triggers OPS013: "diff" is never a step id in this Op
activity("httpCheck", { url, contains: stepOutput("diff", "output") });
// ✅ Fixed: "diff" names a preceding step in the same phases
const diff = activity("lifecycleDiff", { env: "prod" }, "diff");
activity("httpCheck", { url, contains: diff.out.output });

chant build fails on a ConvergeOp rule table with any of these:

  • a rule with no (or blank) why
  • a predicate outside the evaluable subset (eq/neq/gt/gte/lt/lte/truthy/falsy/allOf/anyOf over a known symptom field, nothing else)
  • a run() action naming an Op that no declared Op has as its name
  • a run() dispatching a mutating Op under any dial but apply (the table’s answer for reconcile is “open PR,” which v1 doesn’t implement)
  • a run() dispatching a destructive Op under any dial, unconditionally
  • a rule whose predicate reads adoptCount while dispatching a mutating Op (an unowned resource is reported, never auto-claimed)

The destructive refusal holds even when the target carries a gate. A converge tick runs unattended on a schedule. A destructive dispatch needs a human’s approval before it starts, a gate is consulted only after the tick has committed to run the op, and run() cannot ask first. See the Converging Lifecycle guide’s “Build-time refusals” section for the full dial × verb-class matrix.

Severity: error

// ❌ Triggers OPS014: "prune-staging" is destructive; refused under every dial
when(eq("status", "drifted"), run("prune-staging"), {
id: "drift-prune",
why: "Prune drifted resources.",
});
// ✅ Fixed: report instead, and remediate the destructive op manually
when(eq("status", "drifted"), report("staging has drifted resources to prune"), {
id: "drift-prune",
why: "Destructive remediation stays a manual, gated ApplyOp/ReconcileOp run.",
});

chant build refuses a gate whose approval block (#2508) it cannot honour. The build fails on any of these:

  • quorum.count below 1, or not an integer
  • quorum.roles present but empty
  • a mode other than log-only or enforce
  • mode: "enforce" with no policy
  • a policy that is not a gate policy set built by a lexicon helper such as gatePolicy()
  • a policy whose version no longer matches the digest of its text
  • a context value that is not a string, number, boolean, list of strings or step output reference, or a context on a gate with no policy

gate() throws on the first of these. This rule reports all of them, including on a step written without the builder. The guide explains the block itself in its approval policy section.

Severity: error

// ❌ Triggers OPS015: a path is not a gate policy set
gate("ship", { approval: { policy: "dist/ship.cedar" as never, mode: "enforce" } });
// ✅ Fixed: render the policy set with the lexicon's helper
gate("ship", { approval: { policy: gatePolicy("ship", shipPolicies), mode: "enforce" } });

chant build refuses a gate with resume: true (#3947) that a run could not resume at, since the run would otherwise restart at the first phase without saying so. Resuming at the approved gate in the guide explains how a run resumes. OPS016 reports each gate that breaks one of these rules:

  • the gate is in an onFailure phase or inside an effect
  • the gate asks a decision point
  • the Op declares a workLease
  • the gate’s plan names a step that does not run before it at the top of a main phase, so a resumed run can’t plan again
  • a step after the gate reads an output that its producer’s activity contract marks secret, which a checkpoint never stores

Severity: error

// ❌ Triggers OPS016: mintToken's contract marks "value" secret
const token = activity("mintToken", {}, { id: "token" });
phase("Gate", [gate("approve", { plan: plan.out.planDigest, resume: true })]),
phase("Apply", [activity("applyIt", { token: token.out.value })]),
// ✅ Fixed: pass a reference to the secret, such as its store path
phase("Apply", [activity("applyIt", { tokenPath: token.out.path })]),

chant build refuses a gate’s aliases (#3949) when one approval could answer two gates, or when an approval that needed a seal would count without one. Renaming a gate in the guide explains what an alias admits. It reports a gate when:

  • it has aliases but no plan, or asks a decision point
  • an alias names a gate that still exists in an Op in the build
  • two gates list the same alias
  • the workspace’s identity.gates names an alias, and the new gate has no entry, or lacks the class the alias’s entry names

Severity: error

// ❌ Triggers OPS017: upgrade-tier-1 still has its approve gate
phase("Wave 1", [gate("approve-tier-1", { plan: plan.out.planDigest, aliases: [{ op: "upgrade-tier-1", gate: "approve" }] })]),
// ✅ Fixed: delete the upgrade-tier-1 Op once its gate moves here

A shell step that runs in a container (#4015) has to name its image as a literal reference pinned by digest, <ref>@sha256:<digest>, or chant build refuses it. The run ledger keeps the step’s image on its record, and a tag could name a different image by the next run, so the record would no longer say what ran. The check covers the main phases, the onFailure phases and the steps inside an effect.

Severity: error

// ❌ Triggers OPS018, because node:24 is a tag that can move to another image
phase("Test", [shell("npm ci && npm test", { image: "node:24", outputs: ["coverage"] })]),
// ✅ Fixed: pin the image by digest
phase("Test", [shell("npm ci && npm test", { image: "node:24@sha256:<digest>", outputs: ["coverage"] })]),

docker buildx imagetools inspect node:24 prints the digest a tag points at now, which is the one to pin.

chant