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 },});COR008: Export Required
Section titled “COR008: Export Required”All declarable instances must be exported so chant can discover them during synthesis. Non-exported resources are invisible to the build pipeline.
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 — and a name-bearing property holding a bare string literal produces the same physical name in every build. The collision is silent until apply time, when the second environment’s deploy walks over 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 — a project not doing per-environment builds from one source tree names its instances another way (the layered configuration all-in-one pattern hand-names each instance), and warning there would be noise. 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 sole writer, on success, last. 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 constraint is about attributes.
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 lands 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 — extracting the material into a variable first does not change what the input reads.
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 got coerced to a string — typically by a template literal or string concatenation over an object like an AttrRef — instead of resolved to its actual value. No lexicon’s serializer emits this on purpose, and no provider’s schema ever expects it, so this check is cheap, universal across lexicons, and free of meaningful false positives.
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 was authored against a name 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 warns rather than fails the build — knowledge is deliberately softer than the typed graph it describes. A concept with no binds at all is orphaned knowledge, not an omission, and never triggers this check; a project with no knowledge bundle triggers nothing either.
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. A step whose fn has no registered contract is skipped, not flagged, since contract coverage is opt-in per activity.
This runs over every declared Op regardless of which lexicons are configured; an Op is recognized by its entity type, not by which lexicon declared it. An untyped args: Record<string, unknown> otherwise lets four failure classes through, and this check catches all of them:
- 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 step that precedes it, in scope. “In scope” means the same main phases; not onFailure, and not nested inside an effect step. The named step also needs a registered contract with a returns schema the referenced path resolves against. This check additionally 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 purely structural half of this check (an unknown step id, or a reference into a later phase) fires with no activity contracts registered at all, so it still catches a dangling reference 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 refuses a ConvergeOp rule table that isn’t honest about what it will do. Each of these fails the build:
- 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 no matter whether the target carries a gate. A converge tick runs unattended on a schedule, with no one watching it fire. A destructive dispatch needs a human’s approval before it is attempted, not a gate the tick consults only after already committing to run the op, and run() has no way to 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.",});