Skip to content

Composition Rules

llms.txt lists every page for an agent

Composition rules check the Component graph. They are the long-term entropy guards described in Orchestration: “publishes-but-never-applies, an imageRef to a phase that does not exist, a mutating capability with no rollback and no opt-out, a gate in a local-executor component, a capability named after a component.”

COR/EVL rules check one file’s AST. COMP* rules see the whole discovered component graph: every *.component.ts file under the project, cross-referenced. They run in chant lint and block the build at error severity, like a COR* error.


COMP* rules run against real, imported Component values (see Component Discovery). chant imports each *.component.ts file as well as parsing it, so a rule can answer questions a single-file AST check cannot: “does any component consume this one’s published artifact?”, “does this wiring reference point at a phase that actually exists?”, “do two components compose an identical shape?”

These checks report per component, so a diagnostic sits at the top of the file the component was declared in (1:1), the same convention COR009 uses for its own whole-file diagnostic. Only the file-level disable directive form is honored for COMP* rules:

// chant-disable COMP004 -- the release manager approves this rollout

chant-disable-line / chant-disable-next-line are not supported for COMP* rules, since a whole-component diagnostic has no single line to anchor to.


Flags a component that publishes an image or artifact (publish-image, publish-artifact, load-image-on-host) that nothing consumes. A consumer is a later step in the same component (@Publish.digest) or another one (@<name>.publish.uri|digest|key).

SeverityError
CategoryCorrectness
// ❌ Triggers COMP001 — nothing references "@orphan-lib.publish.uri"
export const orphanLib: Component = {
name: "orphan-lib",
dependsOn: [],
build: { kind: "jvm-build" },
deploy: [phase("Publish", [{ kind: "publish-artifact", from: "archive", to: "$env.s3" }])],
};
// ✅ Fixed — a consumer component references the publish output
export const jarLib: Component = {
name: "jar-lib",
dependsOn: [],
build: { kind: "jvm-build" },
deploy: [phase("Publish", [{ kind: "publish-artifact", from: "archive", to: "$env.s3" }])],
};
export const emrJob: Component = {
name: "emr-job",
dependsOn: ["jar-lib"],
deploy: [phase("Submit", [{ kind: "emr-start-job-run", jar: "@jar-lib.publish.uri" }])],
};

Fix: wire the publish output into a consuming apply/submit step, or add a downstream component that depends on this one and references @<name>.publish.*.


Flags an @Phase.field or @<component>.publish.* reference that points at a phase or component that does not exist: @Phase.field naming a phase absent from the component’s own deploy, or @<component>.publish.* naming an undiscovered component, or one missing from dependsOn.

SeverityError
CategoryCorrectness
// ❌ Triggers COMP002 — no "Build" phase exists (it's named "Publish"),
// and "missing-lib" was never discovered.
deploy: [
phase("Publish", [{ kind: "publish-image", from: "archive", to: "$env.registry" }]),
phase("Apply", [
{ kind: "cfn-deploy", template: "t.json", imageRef: "@Build.digest", inputs: { jar: "@missing-lib.publish.uri" } },
]),
],
// ✅ Fixed — the reference matches the real phase name, and the referenced
// component is discovered and listed in dependsOn.
dependsOn: ["jar-lib"],
deploy: [
phase("Publish", [{ kind: "publish-image", from: "archive", to: "$env.registry" }]),
phase("Apply", [
{ kind: "cfn-deploy", template: "t.json", imageRef: "@Publish.digest", inputs: { jar: "@jar-lib.publish.uri" } },
]),
],

Fix: correct the phase name, add the missing component to dependsOn, or check for a typo in the component name.


COMP003: Mutating Capability With No Rollback

Section titled “COMP003: Mutating Capability With No Rollback”

Flags a mutating capability step (s3-sync, cdn-invalidate, run-migration, emr-start-job-run, emr-submit-step, copy-to-host, remote-exec, or any non-starter kind) with no known native rollback and no explicit opt-out. cfn-deploy/ecs-update-service/lambda-deploy/code-deploy (native rollback) and the whole publish/build family (an already-built or published artifact is immutable and content-addressed, so there is nothing to roll back) are never flagged.

SeverityError
CategoryCorrectness
// ❌ Triggers COMP003 — no rollback, no opt-out
deploy: [phase("Apply", [{ kind: "run-migration", tool: "flyway", target: "ecs-task:migrate" }])],
// ✅ Fixed — explicit noRollback reason
deploy: [
phase("Apply", [
{
kind: "run-migration",
tool: "flyway",
target: "ecs-task:migrate",
noRollback: "forward-only migration; rolling back would drop backfilled columns",
},
]),
],

Fix: add a noRollback: "<reason>" property directly on the step, add a component-level rollback phase, or add a sibling rollback-previous/snapshot-before step in the same phase.


COMP004: Gate Ends The Run Pending Approval

Section titled “COMP004: Gate Ends The Run Pending Approval”

The rule id stays COMP004, and so does its file name (comp004-gate-needs-durable-runtime.ts). Renaming either would touch every chant-disable comment and every import, so the heading above is the rule’s current description and the file name is historical.

Flags a gate step anywhere in a component’s composition. A gate ends the run pending chant approve <component> <gate>: the driver decides it against the gate ledger, and one nobody has approved stops the run there, with no later phase running. Which runtime hosts the run is chosen per run via chant run --on <lexicon>, never declared in the component itself.

SeverityError
CategoryCorrectness
// ❌ Triggers COMP004 — no acknowledgment that this component stops for a person
deploy: [
phase("Node 1", [
gate("approve-node-1", { description: "Confirm the seed node is healthy" }),
{ kind: "cfn-deploy", template: "t.json" },
]),
],
// ✅ Fixed — the gate is acknowledged with a file-level disable directive
// chant-disable COMP004 -- the release manager approves this rollout
deploy: [
phase("Node 1", [
gate("approve-node-1", { description: "Confirm the seed node is healthy" }),
{ kind: "cfn-deploy", template: "t.json" },
]),
],

Fix: if the component is meant to stop for a person, suppress with // chant-disable COMP004 -- <reason> to document who approves it and why. The declaration names no runtime and runs wherever it is invoked, so this rule flags every gate that has no suppression comment.


Flags a step kind named after a component rather than an operation, the “capability-per-component” failure mode (epic #551, “renamed sprawl”). A capability kind must be a verb (cfn-deploy, publish-image, wait-steady-state); a kind equal to or built from a discovered component’s own name is the sprawl the capability model is meant to remove.

SeverityError
CategoryStyle
// ❌ Triggers COMP005 — "deploy-search-service" is named after this component
export const searchService: Component = {
name: "search-service",
dependsOn: [],
deploy: [phase("Apply", [{ kind: "deploy-search-service", imageRef: "@Publish.digest" }])],
};
// ✅ Fixed — a verb-named capability
export const searchService: Component = {
name: "search-service",
dependsOn: [],
deploy: [
phase("Apply", [
{ kind: "cfn-deploy", template: "search.template.json", imageRef: "@Publish.digest" },
{ kind: "ecs-update-service", cluster: "$env.cluster", service: "search" },
]),
],
};

Fix: name the capability after the operation it performs. See Capabilities.


Flags a raw shell step (the deliberate escape hatch) with a missing, empty or blank reason.

SeverityError
CategoryCorrectness
// ❌ Triggers COMP006 — no reason declared
deploy: [phase("Apply", [{ kind: "shell", cmd: "./legacy-deploy.sh" }])],
// ✅ Fixed
deploy: [
phase("Apply", [
{ kind: "shell", cmd: "./legacy-deploy.sh", reason: "no capability wraps this vendor's proprietary CLI yet" },
]),
],

Fix: add a non-empty reason explaining why no capability covers this case. ShellInput.reason is already required on the typed createShellCapability input; this rule applies the same requirement to hand-authored and JSON-only components.


Flags two or more components whose deploy composition is structurally identical — same phases, same parallel/nesting shape, same step kinds in the same order (ignoring name, dependsOn, and literal wiring values/params). This is a hint, reported as a warning, to extract a preset.

SeverityWarning
CategoryStyle
// ⚠ Triggers COMP007 — identical shape to inventory-table
export const ordersTable: Component = {
name: "orders-table",
dependsOn: [],
deploy: [
phase("Apply", [{ kind: "cfn-deploy", template: "orders-table.template.json" }]),
phase("Verify", [{ kind: "wait-for-stack", stack: "orders-table" }]),
],
};
export const inventoryTable: Component = {
name: "inventory-table",
dependsOn: [],
deploy: [
phase("Apply", [{ kind: "cfn-deploy", template: "inventory-table.template.json" }]),
phase("Verify", [{ kind: "wait-for-stack", stack: "inventory-table" }]),
],
};

Fix: extract a named preset (e.g. DynamoTableComponent({ name, template })) that both components call, the same way ApplyOp composites the common shape today. Structurally distinct components (a different archetype, a different apply family, fan-out vs. no fan-out) are never flagged. The four pilots were chosen to be structurally distinct and pass this rule: ALB/ECS, DynamoDB, Neo4j fan-out and an image-processor Lambda.


No COMP* rule has an automatic fix yet (see #562). COMP007 is the likeliest first one. A fix could scaffold the shared preset call, but the author still picks the preset’s name and parameter shape.

chant