Skip to content

feat(cloudformation): add templateTextPolicy, an opt-in guard for template text - #352

Open
laazyj wants to merge 1 commit into
mainfrom
claude/cfn-template-text-policy
Open

feat(cloudformation): add templateTextPolicy, an opt-in guard for template text#352
laazyj wants to merge 1 commit into
mainfrom
claude/cfn-template-text-policy

Conversation

@laazyj

@laazyj laazyj commented Aug 1, 2026

Copy link
Copy Markdown
Owner

What & why

CloudFormation stores template text as ASCII and silently transliterates anything else to ? at deploy time. No error, no CREATE_FAILED — the deployed template just stops matching the synthesised one, so cdk diff reports a change on every run afterwards, forever, on a stack nobody touched.

This is the underlying-problem half of #336. The shipped-defaults half is #351.

import { templateTextPolicy } from "@composurecdk/cloudformation";

templateTextPolicy(app); // fail synth on anything CloudFormation would rewrite
onViolation Behaviour Use it when
"throw" (default) Fails synth, naming the construct path, field and offending character. You want the text fixed at the source.
"sanitize" Rewrites the value so the emitted template matches what CloudFormation stores. A large existing estate you can't chase string by string.
"warn" Lists every violation in one pass. Adoption: run it, fix the list, switch to "throw".

Design

Opt-in, per the plan. Enforcing this at every builder would turn a working — if diff-noisy — deployment into a synth failure for anyone living with the transliteration. Constraints whose violation fails the deploy rather than the diff (an EC2 GroupDescription, which the service rejects outright) stay enforced at the builder regardless.

An Aspect, because a builder validator structurally can't do this. It only covers fields someone remembered to wire one into — which is precisely how the stack-level Description slipped through: StackBuilder passes description straight to StackProps and no validator ever sees it. The Aspect walks the tree, so for registered types it also catches constructs this library never built.

Keyed by resource-type string, so it imports no service package — the registry names AWS::Lambda::Function without importing @composurecdk/lambda. That's what ADR-0010's consequences called for and why the policy can live in cloudformation without inverting the dependency graph.

Three node kinds, because only one is a CfnResource: the stack's Description lives on templateOptions, and CfnOutput/CfnParameter are CfnElements with their own accessor. A registry keyed by resource type alone would miss both — including the field that caused the bug.

Per-character sanitisation, not sanitizeString's run-collapse, which would turn a quoted phrase into a single -. Unmapped characters become ? — CloudFormation's own substitution — so the template byte-matches the deployed value either way.

Notable implementation points

  • warn degrades to Annotations.addWarning where addWarningV2 is missing. This package's floor is aws-cdk-lib 2.1.0 — the lowest in the repo, because every other package peer-depends on it — and addWarningV2 only arrived in 2.93.0 (see cdk-floors.json). Raising the floor for one optional mode would drag the whole library up with it. Covered by a test that deletes the method from the prototype.
  • throw throws rather than Annotations.addError. Only the CDK CLI acts on error metadata, so app.synth() and Template.fromStack() would sail past an annotation — a guard that doesn't fire in unit tests. Cost: only the first violation is reported, which is what warn mode exists for.
  • A Lazy resolving to a plain string is checked, not skipped — that string is what deploys. Only values resolving to a CloudFormation intrinsic are skipped.
  • TEMPLATE_TEXT_FIELDS is not exported. Exporting it would lock the registry's shape in as public API and foreclose a future switch to name-based detection. config.fields is the extension point; the default list is documented as a README table.

Scope and honest limits

The registry seeds 15 resource types; aws-cdk-lib declares a free-text property on roughly 550. That is a starting point sized to what has been reasoned about, not a claim of completeness — the README says so, and ADR-0017 records the name-predicate alternative (/[Dd]escription$/, displayName, comment) as the likeliest successor, with why it's deferred.

Also outside coverage, and documented: values resolving to intrinsics, values written via addPropertyOverride or a bare CfnResource's properties, and nested paths like DistributionConfig.Comment (deferred — they need a resolve on read and a property override on write).

Not yet verified against live AWS. The plan's Phase 0 called for confirming which fields CloudFormation actually coerces; only the template-level Description has direct evidence. The rest are listed because they carry the same author-written prose through the same document. The cdk diff idempotence check that would settle it is Phase 3 and depends on #351 landing first.

Docs

  • packages/cloudformation/README.md — new templateTextPolicy section: failure mode, mode table, what it checks, what it doesn't.
  • docs/adr/0017-template-text-policy.md — new.
  • docs/adr/0002-policies.md — §5 marked amended; a resource-type-keyed policy has no peer-dependency surface, so the "pan-domain policies wait in examples/" rule doesn't apply to it.
  • docs/adr/0010-aws-property-constraints.md — its named follow-up now points here.
  • docs/architecture.md §Policies and docs/constraints.md (regenerated) updated.

Follow-ups

  1. Nested property paths (Phase 2).
  2. Post-deploy cdk diff idempotence check in deploy-test (Phase 3) — the empirical proof of acceptance criterion Deployable examples #2, blocked on fix: strip non-ASCII from descriptions emitted into templates #351.
  3. Name-predicate detection, if the seed registry proves too narrow in practice.

Checklist

  • Linked to an issue (or it's a small, obvious fix)
  • npm run verify passes locally
  • Tests added/updated for the change
  • If it adds an example stack: registered, listed in the examples README, and covered by a smoke test — n/a, no new example

Generated by Claude Code

…plate text

CloudFormation stores template text as ASCII and silently transliterates
anything else to `?` at deploy time. There is no error: the deployed
template stops matching the synthesised one, so `cdk diff` reports a
change on every run afterwards, forever, on a stack nobody touched.

`templateTextPolicy(scope, config?)` installs an Aspect that finds those
characters at synth instead. Three modes:

- `throw` (default) fails synth, naming the construct path, the field and
  the offending character.
- `sanitize` rewrites the value so the emitted template matches what
  CloudFormation will store.
- `warn` lists every violation in one pass — the adoption path onto
  `throw` for an existing estate.

The policy is opt-in because enforcing this at every builder would turn a
working, if diff-noisy, deployment into a synth failure. Constraints whose
violation fails the deploy rather than the diff — an EC2 GroupDescription
— stay enforced at the builder.

Keyed by CloudFormation resource-type string, so the registry names
AWS::Lambda::Function without importing the lambda package. This is what
ADR-0010 called for and it is why the policy can live centrally without
inverting the dependency graph. Consumers extend it via `config.fields`;
the registry const is deliberately not exported so its shape stays
changeable.

Sanitisation replaces per character rather than per run, since collapsing
a run turns a quoted phrase into a single `-`. Unmapped characters become
`?`, which is what CloudFormation would have stored anyway.

`warn` degrades to the 2.0-era Annotations.addWarning where addWarningV2
is absent: this package's floor is aws-cdk-lib 2.1.0, the lowest in the
repo because every other package peer-depends on it, and addWarningV2
only arrived in 2.93.0.

Adds ADR-0017 and amends ADR-0002 §5, which routed pan-domain policies to
packages/examples until a policies package is justified — that rule exists
to contain peer-dependency surface, and a resource-type-keyed policy has
none.

Refs #336
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Coverage

Overall line coverage: 99.36% across 23 package(s).

Package Statements Branches Functions Lines
acm 🟢 97.36% 🟢 94.73% 🟢 100.00% 🟢 97.22%
apigateway 🟢 100.00% 🟢 100.00% 🟢 100.00% 🟢 100.00%
budgets 🟢 99.20% 🟢 95.55% 🟢 100.00% 🟢 100.00%
cloudformation 🟢 98.57% 🟢 95.20% 🟢 100.00% 🟢 99.46%
cloudfront 🟢 99.40% 🟢 95.14% 🟢 100.00% 🟢 100.00%
cloudwatch 🟢 95.56% 🟡 89.55% 🟢 100.00% 🟢 98.23%
core 🟢 100.00% 🟢 97.36% 🟢 100.00% 🟢 100.00%
custom-resources 🟢 100.00% 🟢 100.00% 🟢 100.00% 🟢 100.00%
dynamodb 🟢 100.00% 🟢 92.00% 🟢 100.00% 🟢 100.00%
ec2 🟢 98.81% 🟢 97.72% 🟢 100.00% 🟢 99.58%
eslint-plugin 🟡 89.88% 🟡 83.33% 🟢 100.00% 🟢 97.90%
events 🟢 98.66% 🟢 94.59% 🟢 100.00% 🟢 100.00%
examples 🟢 95.86% 🟡 78.12% 🟢 100.00% 🟢 99.27%
iam 🟢 100.00% 🟢 100.00% 🟢 100.00% 🟢 100.00%
lambda 🟢 98.00% 🟢 96.77% 🟢 100.00% 🟢 98.41%
logs 🟢 100.00% 🟢 100.00% 🟢 100.00% 🟢 100.00%
module-compat 🟢 100.00% 🟢 100.00% 🟢 100.00% 🟢 100.00%
neptune 🟢 97.14% 🟡 86.36% 🟢 100.00% 🟢 98.50%
route53 🟢 98.24% 🟢 96.04% 🟢 100.00% 🟢 99.04%
s3 🟢 100.00% 🟢 100.00% 🟢 100.00% 🟢 100.00%
ses 🟢 100.00% 🟢 100.00% 🟢 100.00% 🟢 100.00%
sns 🟢 100.00% 🟢 100.00% 🟢 100.00% 🟢 100.00%
sqs 🟢 100.00% 🟢 98.55% 🟢 100.00% 🟢 100.00%
Total 🟢 98.13% 🟢 94.70% 🟢 100.00% 🟢 99.36%

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants