Skip to content

Correctness Rules

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 },
});

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 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 — 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",
});
// ✅ 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 — 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.


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 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 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 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 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 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 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.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 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: 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. 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.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 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 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 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/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 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 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.",
});