Correctness Rules
Correctness rules catch structural errors that would cause evaluation or deployment failures.
COR003: String-Based Reference
Section titled “COR003: String-Based Reference”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 COR003export const fn = new _.Function({ role: GetAtt("functionRole", "Arn"), bucket: Ref("dataBucket"),});// ✅ Fixed — use typed AttrRef propertiesimport { functionRole } from "./role";import { dataBucket } from "./storage";
export const fn = new _.Function({ role: functionRole.arn, bucket: dataBucket.ref,});COR004: Unused Declarable
Section titled “COR004: Unused Declarable”Exported declarables that are never referenced by another resource in the same file may be dead infrastructure code.
Severity: warning
// ❌ Triggers COR004export const logsBucket = new _.Bucket({ bucketName: "logs",});
export const dataBucket = new _.Bucket({ bucketName: "data",});// logsBucket is never used — flagged// ✅ Fixed — reference it or remove itexport 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.
COR008: Export Required
Section titled “COR008: Export Required”Export every declarable instance so chant can find it during synthesis. The build sees only exported resources.
Severity: error
// ❌ Triggers COR008const bucket = new _.Bucket({ bucketName: "my-data",});// ✅ Fixedexport 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",});// ✅ Fixedimport { 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.
COR022: Effect Receipt Is a Leaf
Section titled “COR022: Effect Receipt Is a Leaf”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 COR022const 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) directlyexport 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 SecureStringMigratedReceipt: Type: AWS::SSM::Parameter Properties: Name: /receipts/migrated Type: SecureString# ✅ Fixed — plain String parameterMigratedReceipt: Type: AWS::SSM::Parameter Properties: Name: /receipts/migrated Type: StringCOR024: 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 hashconst dbSecret = new SecretManagerSecret({ name: "db-password" });export const seeded = EffectReceipt("seeded", { effect: "db-seed", flavor: "hash", inputs: { password: dbSecret.data.password },});// ✅ Fixed — name+version pointerconst 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.toStringexport const policy = new BucketPolicy({ bucket, statement: [{ effect: "Allow", resource: `${bucket.arn}/*` }],});// ✅ Fixed — join the reference instead of interpolating itimport { Join } from "@intentius/chant-lexicon-aws";
export const policy = new BucketPolicy({ bucket, statement: [{ effect: "Allow", resource: Join("", [bucket.arn, "/*"]) }],});COR026: Stale Knowledge Binding
Section titled “COR026: Stale Knowledge Binding”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: decisiontitle: Use a managed queuebinds: ghostQueue---
No entity named `ghostQueue` exists in this project anymore.<!-- ✅ Fixed — binds the current logical name -->---type: decisiontitle: Use a managed queuebinds: 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.frompath missing from the declared return type
Severity: error
// ❌ Triggers OPS012: "env" typo'd as "environment"activity("lifecycleDiff", { environment: "prod" });// ✅ Fixed: matches lifecycleDiff's registered contractactivity("lifecycleDiff", { env: "prod" });OPS013: Dangling Step-Output Reference
Section titled “OPS013: Dangling Step-Output Reference”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 Opactivity("httpCheck", { url, contains: stepOutput("diff", "output") });// ✅ Fixed: "diff" names a preceding step in the same phasesconst diff = activity("lifecycleDiff", { env: "prod" }, "diff");activity("httpCheck", { url, contains: diff.out.output });OPS014: ConvergeOp Rule Table Refusal
Section titled “OPS014: ConvergeOp Rule Table Refusal”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/anyOfover a known symptom field, nothing else) - a
run()action naming an Op that no declared Op has as itsname - a
run()dispatching amutatingOp under any dial butapply(the table’s answer forreconcileis “open PR,” which v1 doesn’t implement) - a
run()dispatching adestructiveOp under any dial, unconditionally - a rule whose predicate reads
adoptCountwhile dispatching amutatingOp (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 dialwhen(eq("status", "drifted"), run("prune-staging"), { id: "drift-prune", why: "Prune drifted resources.",});// ✅ Fixed: report instead, and remediate the destructive op manuallywhen(eq("status", "drifted"), report("staging has drifted resources to prune"), { id: "drift-prune", why: "Destructive remediation stays a manual, gated ApplyOp/ReconcileOp run.",});OPS015: Gate Approval Block
Section titled “OPS015: Gate Approval Block”chant build refuses a gate whose approval block (#2508) it cannot honour. The build fails on any of these:
quorum.countbelow 1, or not an integerquorum.rolespresent but empty- a
modeother thanlog-onlyorenforce mode: "enforce"with nopolicy- a
policythat is not a gate policy set built by a lexicon helper such asgatePolicy() - a
policywhoseversionno longer matches the digest of its text - a
contextvalue that is not a string, number, boolean, list of strings or step output reference, or acontexton 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 setgate("ship", { approval: { policy: "dist/ship.cedar" as never, mode: "enforce" } });// ✅ Fixed: render the policy set with the lexicon's helpergate("ship", { approval: { policy: gatePolicy("ship", shipPolicies), mode: "enforce" } });OPS016: Gate That Can’t Resume
Section titled “OPS016: Gate That Can’t Resume”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
onFailurephase or inside an effect - the gate asks a decision point
- the Op declares a
workLease - the gate’s
plannames 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" secretconst 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 pathphase("Apply", [activity("applyIt", { tokenPath: token.out.path })]),OPS017: Unsafe Gate Alias
Section titled “OPS017: Unsafe Gate Alias”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.gatesnames 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 gatephase("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 hereOPS018: Shell Step Image Not Pinned
Section titled “OPS018: Shell Step Image Not Pinned”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 imagephase("Test", [shell("npm ci && npm test", { image: "node:24", outputs: ["coverage"] })]),// ✅ Fixed: pin the image by digestphase("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.