Skip to content
cdkd

cdkd diff internals

The rules behind the edge cases cdkd diff summarises: how the Outputs preview decides what to show, when a stored value is withheld, what the diff does with a state record it cannot read, and how secret references are compared across nested stacks.

For how to run the command and read its output, see cdkd diff.

Adopted rollback orphans: the verification

cdkd diff runs the same verification the deploy runs before it draws this row, in the stack's own region: the resource must still exist, still answer to the recorded physical id, and be claimed by no other cdkd stack. A record that fails any of those is not adopted, and the row stays a create — which is what the deploy will attempt. A record that fails the ownership check is reported under Blocking instead; see Exit codes.

The annotation is not cosmetic. Without it the row is indistinguishable from an ordinary update, and the last thing you saw this resource do was fail and drop out of state — so an unannotated [~] reads as cdkd having quietly kept managing it.

This check is one of the two places cdkd diff calls an AWS resource provider (the other is a state record missing an attribute), and it runs only for a stack whose state holds orphan records. A stack that has never had a rollback orphan anything pays nothing for it.

Outputs previewed instead of omitted

One case is previewed instead of omitted: no resource changes, and every output that failed did so with a signal the deploy also records (the resolver threw, or returned nothing for the whole value). The deploy then persists the outputs that did resolve, as described below, so the diff shows that — a row for an added or changed sibling, no row for the failed output, plus a warning naming the failed outputs: those with a stored value are compared at it, those with no stored value under their own name are listed as such, and the deploy may still resolve one the diff could not — a lookup keyed by a secret the diff never fetches, for example. Unless every value carried from state for a failed output, an alias included, is itself one whole secret reference, the rows withhold their previous values, because a value the diff did not resolve cannot show whether the stored outputs predate secret redaction; when a row actually withholds one, the warning says so. That reason no longer applies once every failed output resolves, though the diff's other legacy-plaintext checks can still withhold the values. The section is still omitted when:

  • a resource change is pending;
  • a condition verdict can reach an output's value — an output carries its own Condition, or an Fn::If or Condition reference appears anywhere outside the template's resources and conditions, such as in a mapping. The diff evaluates conditions best-effort and can reach a different verdict from the deploy, so an output that fails here may resolve at deploy. A condition that gates only resources, like the one CDK adds to its metadata resource, does not count: an output never reads a resource's template properties. Should the diff and the deploy disagree about such a resource, the warning above already says the deploy may write a different value for each failed output;
  • a template parameter could not be bound for the diff, or a condition could not be evaluated because it depends on a parameter holding a secret reference (see Conditions over a secret-fed parameter);
  • an output's value came back in a shape that does not tell the diff what the deploy will do with it — a function the diff could not evaluate, an AWS::NoValue, an Fn::Sub placeholder left unsubstituted, or a list or object holding one of those or a missing value;
  • an output's Export.Name could not be resolved or decided;
  • the deploy would keep all of the previous outputs (the two cases below).

One of those keep-whole checks the deploy repeats after it has captured observed state, where a secret it records late can refuse a merge the diff previewed, so a row shown here can still be kept back by the deploy.

Outputs the last deploy skipped

An output the last deploy could not resolve and skipped is not previewed as an ADD either. Two things get skipped, and only the first is announced:

  • the resolver threw — a lookup failed inside it, such as a {{resolve:secretsmanager:...}} naming a JSON key the secret does not hold. The deploy warns per output (--strict-getatt aborts instead).
  • the resolver returned nothing — a malformed value, such as an Fn::If with no third argument. No per-output warning (--strict-getatt aborts here too). An Fn::GetAtt cdkd cannot build is the first kind: it is refused, and warned about.

Either way the deploy still persists every other output that did resolve, so an output you add beside a broken one lands on the next deploy. A broken output keeps the value it had from an earlier deploy, if it had one. Two cases keep all of the previous outputs instead, with a warning naming why: a broken output that had a value before and declares its Export.Name with an intrinsic function, and a save that would put the first secret reference into the stored outputs beside a value it cannot vouch for — checked on the outputs exactly as they will be saved. A kept value is not repositioned onto a reference from today's template; the ordinary secret scan still redacts it.

The diff never fetches secrets, so it cannot reproduce the first failure at all. The second it does reproduce — an attribute it cannot build is unresolved for the diff too — but it cannot tell that case from an output simply waiting on a resource this deploy will create, and before this field either one made it drop the whole Outputs section. So the deploy records the skipped key with a digest of its template inputs (skippedOutputs in state), and the diff previews the key as absent — no row, while its sibling outputs are still compared as usual — as long as it is still absent from state and that digest is unchanged.

Repair the output's Value, its Export.Name, or a parameter / condition / mapping it reads, and the output is back under the ordinary preview rules — usually an ADD row (an intrinsic Export.Name the diff still cannot resolve keeps omitting the section, as before); the next deploy then publishes it if the repair took, or records it again.

Repairing the RESOURCE an output reads is the third way, and the digest cannot see it: Resources is deliberately not digested, or every unrelated resource edit would discard the record. cdkd diff handles it from the other side — an output whose Value or Export.Name references a logical id this run's resource diff reports as changing does not use the record, because the deploy that follows re-resolves every output and may publish that key. Such an output falls back to how it behaved before this field existed: the diff usually still cannot compute it from today's state, so the Outputs section is omitted, with the "could not be resolved" warning when some other output also differs. What you do not get is the record's silent "nothing to do" over a key the deploy is about to publish.

How much of that you SEE depends on whether the output can be resolved at diff time at all. An output reading an attribute of a resource that does not exist yet stays unresolvable in both readings, so with no sibling output differing the two print the same thing — an empty Outputs section — and the rule moves the VERDICT, not the row; where a sibling does differ, it trades the record's silent "nothing to do" for the "could not be resolved" warning over the whole section, the sibling's row included. An output the diff CAN resolve is the other case, and there the row is the difference: an output whose Fn::Sub reads an SSM parameter's name, say, renders its ADD as soon as the record stops binding, with no sibling involved.

That rule is deliberately coarse: it cannot tell a resource edit that repairs the output from one that does not, so an unrelated edit to a referenced resource — a tag, a description — also stops the record binding, and the output can show as an ADD again. The cost is bounded to a diff that is already reporting that resource's change, so --fail was going to exit 1 either way; the alternative is hiding a row the deploy will publish.

One template value is excluded from the digest on purpose: a NoEcho: true parameter's Default is hashed as a constant, so the record cannot become a confirm oracle for a low-entropy one. That is not a claim the value is otherwise absent from state — a record written before state schema version: 11 can still hold it in a resource's properties (a current deploy stores *** there) — only that this field does not add an oracle where there was none. Changing only such a default therefore does not un-bind the record.

A repair the digest does not cover leaves the record binding. Four are repairs outside its reach, and a fifth is the NoEcho default it declines to hash on purpose, above. Two are outside the template and outside anything cdkd diff looks up: the secret gained the JSON key, or the SSM parameter was created. One is a nested stack's input VALUE changing on the parent's side, which the diff does resolve but the digest does not hash, since hashing supplied values would tie the record to a caller's arguments rather than to the template. The last is cdkd itself being upgraded so that a provider now builds the attribute an output reads. In all four the record clears on the next deploy, and until then the deploy's own warning, where there is one, is the signal for the broken output.

A repair on the RESOURCE side is deliberately not on that list: the rule above declines the record whenever this run's resource diff reports the resource as changing. That covers a repair the template carries, and only that. A state rebuild OUTSIDE a deploy — cdkd import, cdkd drift in either direction, cdkd rollback, cdkd scrub, cdkd orphan and cdkd state refresh-observed — DROPS the record instead of carrying it forward. All but the last can change the values an output's resolution reads while every resource still reports NO_CHANGE; refresh-observed touches only observedProperties, which the resolver does not read, and drops anyway on any run that refreshed at least one resource, because the rule is flat (a run that refreshed nothing keeps the record). The one writer that carries it is the partial snapshot a failed cdkd destroy leaves: every resource it removed returns as a CREATE on the next diff, which un-binds any record that references it.

That is the safe direction, not a free one, and it is worth being blunt about what it costs. The key is resolved like any other output again, so for the shape this section is about — a failure inside a secret lookup — the phantom ADD comes back, and cdkd diff --fail exits 1 on the unchanged stack until the next deploy rewrites the record. For a key the diff cannot resolve either, it is handled like any other output that fails: the Outputs section is omitted, or — when no resource change is pending and the failure is one the deploy repeats — previewed through the merge described under "Outputs previewed instead of omitted". Bounded on both counts: one deploy clears it, and the alternative is the diff asserting that nothing is coming while the next deploy publishes the key.

Withheld previous values

cdkd diff is the only command that prints a stored output value, so two safeguards apply to that side of the output.

A previous value that may be legacy secret plaintext is withheld from both the text and the --json output rather than printed into CI logs. The change itself is still reported; only the old value is replaced with a placeholder pointing at cdkd scrub. These refusal gates decide this:

Gate Trigger Scope
Redacted-expression mismatch The template side is still a {{resolve:...}} expression while state is not — exactly what cdkd scrub repairs. A stored value counts as the expression only when it is one whole reference (and the template side is one too, or absent) or matches the template side's text around each reference, so a reference stored beside other text does not. Record-wide
Template-declared dynamic reference The template declares the output's value as a dynamic reference. Also covers an output that was condition-skipped, which has no template side left to compare. Record-wide
Unaccountable stored key A stored key today's template cannot account for — no declared output name, no literal Export.Name, not in the resolved bag — i.e. an output deleted from the template. Skipped when the stored record holds a plain ssm reference, which only a cdkd that redacts every secret writes (or a cdkd scrub that kept an undeclared key beside it — one it rewrote only in part, one that may be a live export alias, one another stack still reads, every one when the other stacks' state could not be read, or one whose name holds a secret — the caveat below); a secretsmanager reference alone does not prove that. Per-key
Stored secret beside other text The stored value holds a {{resolve:...}} secret reference beside other text in a shape the expression rule above rejects (in any string leaf of an array or object) — what an older deploy wrote around a plaintext. One whole reference is never withheld here. Checked for every key, whatever the record's other evidence or the template says. Per-key
Carried value in the merge preview The no-change merge preview carried a value from state for a failed output, an alias included, that is not one whole secret reference. Record-wide

The first two are record-wide because a record holding any such key was written by a pre-redaction binary, so every previous value in it is suspect. The merge preview's gate is record-wide for the same reason from the other side: the diff never learns what a failed output would have resolved to, so a carried value that is not a secret reference cannot rule that out.

The per-key gate is narrower on purpose, since deleting an output is an ordinary refactor. It fires only when the template still proves a secret reference somewhere, and not when any stored value is itself a plain ssm secret expression (a secretsmanager one does not count: an older cdkd stored it correctly beside a SecureString plaintext) — the latter is read as evidence that the last write already redacted the whole bag, which holds for a full deploy and is not guaranteed for every earlier write. Those two conditions are what keep the refusal off stacks that handle no secrets at all.

For a nested child removed from its parent's template there is no template left to account for anything, so the refusal applies to that child's whole stored bag whenever the parent's template proves a secret reference. That population is repairable by cdkd scrub; the refusal here is unchanged, because diff still cannot decide from a stored string alone whether a value is plaintext.

Second, rendered values are stripped of control and bidi characters before display, and Outputs row names are shown only after a secret test. An Export.Name is a value cdkd resolved (from an Fn::Sub, a parameter, an SSM lookup), so unlike a CloudFormation logical ID it never passed a validator, and a cdkd older than the export-name refusal could store one holding a resolved secret as a state key. Each name is printed with invisible and control characters removed, masked (app-*** (name masked: it contains a secret)) when it holds a secret, or replaced by <name withheld: contains a secret> when masking cannot hide it. diff fetches no secret, so it finds one only in the part of a stored key that a secret-bearing Export.Name's {{resolve:...}} reference covers, or where a key holds the stored value of a secret output in a pre-redaction record or of a key the template no longer declares. A name embedding a NoEcho parameter's value is masked too (see the third point). A stale alias sharing that Export.Name's literal text is masked too.

Because that search cannot see every secret, a removed export alias is withheld in a stack whose template references a secret: a REMOVE row for a key the template no longer declares that contains a character an Output logical ID cannot (anything outside A-Z, a-z, 0-9) and, on a record that lists exportNames, is listed there. A removed ordinary Output keeps its name, even one exported under its own name, except while a declared Export.Name reads a NoEcho parameter or custom-resource attribute: then a self-exported one, and every removed key of a record that lists no exportNames, is withheld too. Two gaps remain, and each prints unless the search above finds its secret: an alias made only of letters and digits, which reads as an Output logical ID; and any alias in a stack whose template no longer references a secret through {{resolve:secretsmanager: or {{resolve:ssm-secure: — including one whose only secret is a plain {{resolve:ssm:...}} to a SecureString parameter. Both are withheld anyway while a declared Export.Name reads a NoEcho parameter or custom-resource attribute (see the third point's limits).

Third, a NoEcho: true parameter's value is printed as ***, as a CloudFormation change set prints ****. This covers a property's old: / new: lines, an Outputs value, an export row name, --json's propertyChanges and outputChanges, and the --verbose lines (the requires replacement line and the resolver's own). It also covers an encoding the diff derives from the value (an Fn::Base64), each piece of an Fn::Split over it, and a nested child that receives the parent's value through a parameter it does not itself declare NoEcho, a list parameter split out of it included. A NoEcho parameter fed a SECRET {{resolve:...}} reference (secretsmanager, ssm-secure, or ssm to a SecureString) prints as that expression, like every secret reference here.

Since state schema version: 11, state stores *** wherever a NoEcho parameter supplied a value, and cdkd diff compares the desired side masked the same way, so a changed NoEcho value is NOT shown as a change: cdkd diff cannot read AWS. Instead it prints one line per stack, N unchanged resource(s) read a NoEcho parameter, whose value state holds only as ***: the deploy compares it with AWS, and updates a resource whose value changed. That line is informational and never counts as a change for --fail; the deploy reads each such resource back and updates the ones whose value moved (see version: 11 stores NoEcho values as ***).

A record written before version: 11 still holds the value it last sent in the clear. cdkd diff compares that value with the current one: an equal value is no change, and a different one is reported with the old side shown as (previous NoEcho value) and the new side as ***, so neither value prints.

Limits:

  • A value, Fn::Split piece or split-out list element of 1-3 characters is masked only where it is a whole value, not where it is embedded in a longer string. The whole-value match can also over-mask: an unrelated value that happens to EQUAL a short NoEcho value (a 1, a true) prints as *** too.
  • A piece is a needle for the whole node, like the value it came from. A short piece (the 1 of dbuser,1) masks every leaf EQUAL to it, and a piece of 4 or more characters (a port such as 5432) masks it inside any text. A URL split by : makes its scheme name, port and words needles. So a row can print *** for an unrelated value, and when its new side is masked the old side is withheld too, hiding a real non-secret change behind ***.
  • The up-front record of a stored piece (above) sees only the Fn::Splits in the NEW template. A CDK nested child declares no NoEcho, so it records nothing up front and relies on the pieces its own resolution records. And a split that reads the value indirectly (Fn::GetAtt, Fn::FindInMap) leaves its pieces unrecorded when its diff resolution fails.
  • An Fn::Split by an EMPTY delimiter over a NoEcho value prints its pieces as *** on its own line, but an Fn::Join or Fn::Sub putting them back together with a separator prints them character by character.
  • Only the CURRENT value is known. On a record written before state schema version: 11, a previous value still in state prints where the new side no longer carries the current one: a property or output REMOVED in the same deploy that rotated the value, or a property that switched away from a NoEcho parameter in that deploy. The stored previous plaintext prints as its old: side. An export alias published under a previous value is the exception: while any declared Export.Name reads a NoEcho parameter or custom-resource attribute (in any Fn::If branch, a nested child's parent row included), every stale alias's REMOVE row name is withheld, an unrelated one included. Likewise a stored Fn::Split piece of the current value prints once no Fn::Split over the value by that delimiter is left in the template.
  • A NoEcho parameter fed a plain {{resolve:ssm:...}} reference to a String parameter prints its resolved value where an Fn::Sub / Fn::Join embeds the reference, and the expression where a bare Ref serves it: a String parameter is public configuration, and the diff resolves it as the deploy does.

The --json payload is deliberately not stripped — it is a machine interface, and mutating a name a consumer matches on would be worse than the display concern it would avoid — so a name is its stored key unless it holds a secret, in which case it is the masked or withheld text with nameRedacted: true (a redacted name is not unique: two withheld rows share it). The payload is escaped instead: every control, format, line-separator and paragraph-separator character (DEL, the C1 range, U+2028 / U+2029, the bidi controls and the zero-width characters, as well as the C0 range) is written as a \uXXXX escape, so the payload parses back to exactly the same values and printing it cannot run a control sequence.

Malformed state records

A state record is read as JSON and used as typed data without a field-by-field shape check, so a hand-edited or truncated one can hold anything where a map belongs. cdkd diff never writes state, so it repairs each container it walks rather than refusing, and warns about each one it repaired — once per stack, except for the properties case noted below, which can warn twice. A single unreadable ENTRY is dropped rather than repaired, for the reason below the table:

Container Read as What the preview then shows
resources empty Every resource the template declares previews as a CREATE, (resources map) is named with the dropped rows, and --json lists resources in unreadableContainers; on the TOP-LEVEL stack it also exits 3
outputs empty Every output this diff resolves previews as an ADD, and no stored key previews as a REMOVE
A resource's properties empty Every property that resource declares previews as an addition, and a create-only one previews as a replacement
orphans empty No rollback-orphan record previews as an adoption, (orphans container) is named in the preview, --json lists orphans in unreadableContainers, and --fail counts it; on the TOP-LEVEL stack it also exits 3
One orphans record whose properties or attributes map is not an object KEPT The record is still previewed, and the preview WARNS naming the row at every node the run REACHES with an adoption preview — a plain cdkd diff visits only the top-level stack, --recursive (or --fail-on=destructive) visits its template-present children, and a state-only child being DELETED runs no preview at all, saying that cdkd deploy refuses the record over it. On the TOP-LEVEL stack it also exits 3; a nested child warns without the exit code, for the reason exit 3 gives
One resources entry, or one orphans record (not an object, no resource type, and for an orphan record no string logicalId or one another record also carries, or a state with no non-empty string physicalId) DROPPED The row is named in the preview, in --json's unreadable (an entry) or unreadableOrphans (an orphan record), and in the --fail count; a row the template still declares previews as a CREATE, one it no longer declares gets no row at all. On the TOP-LEVEL stack it also exits 3

"Unreadable" is decided per container against the shape that container holds. For the three MAPS it is anything that is not a JSON object: a string, a list, a number, a boolean or null. orphans is a list, so there it is the mirror image — a string, a number, an object, a boolean or null — and a list is the healthy shape. A healthy container is untouched and nothing is said about it, and an empty {} (or an empty []) is a healthy container — a stack can legitimately hold no resources, publish no outputs, or carry no orphans.

An absent outputs or orphans field is the exception: it reads as empty and says nothing, because a record with no outputs is one cdkd writes and cdkd scrub preserves, and a stack that has never had a failed deploy has no orphan list at all. An absent resources map is a defect and does warn — a stack always has a resource map, even an empty one.

Reading it as empty is the safe answer for a preview, and the warning is what keeps it honest. Without the repair the walk over each container takes a string or a list as readily as a map: a planted "abcdef" in resources renders six resources that do not exist, and in outputs it produces one REMOVE row per character, each printing a character of the record as its previous value — rows --fail would exit 1 on.

Every one of these warnings points at cdkd state show '<stack>' --stack-region '<region>' --json, which emits the record as stored, so the evidence survives the repair. The resources warning additionally says that cdkd deploy and cdkd destroy refuse such a record: they read the same map, an unreadable one is indistinguishable from an empty stack, and acting on that reading would make a deploy re-create every resource and a destroy delete none of them. So a resources preview that renders is followed by a refusal from either of those commands. On the stack you named, the preview says so itself: the refusal is reported under Blocking and the command exits 3. See when resources is not an object. The outputs warning costs this preview's Outputs section, and it costs more than the preview: cdkd deploy and cdkd destroy refuse a record whose outputs map is unreadable rather than deciding from it. On the stack you named, the preview says so itself: the refusal is reported under Blocking and the command exits 3. On a nested child it is the warning alone. See when outputs is not an object.

The orphans warning costs this preview's rollback-orphan adoption, and it costs the same thing beyond the preview: cdkd deploy, cdkd destroy, cdkd rollback, cdkd import and a real cdkd scrub all refuse a record whose orphans field is present and not a list, because such a field either counts as no orphans — leaving the resources an earlier failed deploy left live in AWS unreported, or rewritten into character-shaped records by a rollback — or aborts the command outright with no cause named. On the stack you named, the refusal is reported under Blocking and the command exits 3. See when orphans is not a list, and when one record cannot be read for the row level below it.

The properties warning names the individual resource records it emptied — up to five of them, then a count. It costs more than the section or the preview each container-level warning costs: the rows for those resources are still printed, and they are wrong. Where the template still declares the resource, its every declared property reads as an addition against the empty map and a create-only one renders as a replacement; where the template no longer declares it — a removed nested child under --recursive is diffed against an empty template — the DELETE row shows an empty previous side instead of what the record holds. So that warning says explicitly not to act on the preview, and that cdkd deploy refuses the record rather than performing those replacements. On the stack you named, that refusal is also reported under Blocking and exits 3. See when a resource properties map is not an object.

The same repair runs a second time on a stack that adopts a rollback orphan: those records come from a different part of the file and are spliced in after the load, so a torn one is emptied and named there too. An orphan record that is not readable as a resource at all — not an object, or carrying no resource type — is dropped before adoption is previewed, named with the dropped rows below, and counted the same way — cdkd deploy refuses the record over it, so on the stack you named it also exits 3.

A single resources entry that is not an object, or carries no resource type, is not repaired but dropped from the record the diff reads, with or without --recursive, and a warning names its logical id. Nothing says what AWS resource such a row names, so there is no honest empty version of it. Dropping happens before the properties repair, so a typeless row whose properties map is also unreadable is reported once, as dropped. A dropped row the template still declares previews as a CREATE; one it no longer declares gets no row at all. So the dropped rows, (resources map) for an unreadable map and (orphans container) for an unreadable orphan list, are also named together on one line after the counts — up to ten names, then how many more — listed in full in --json (see --json for which field holds which), and counted by --fail. A container is printed from what the diff found, never from a key, so a dropped row's own id is printed the way every id in the preview is: in double quotes when making it safe to print changed it (padding, a control character) or when the part shown holds a space, a parenthesis or any other character outside letters, digits and :_@./+=,~-. An entry keyed (resources map) therefore prints as "(resources map)". An id that is still longer than 255 characters after that is cut, with a marker naming how much was withheld, and one with nothing printable left prints as <unrenderable>. cdkd deploy refuses a record holding any of them, so on the stack you named each is also reported under Blocking and the command exits 3, which takes precedence over --fail.

With --recursive each node of the tree carries its own record, so the warning names the stack it came from and a healthy parent can sit above a malformed child.

exportNames, which is a list rather than a map

The record also carries exportNames — the outputs keys that are Export.Name aliases (state schema v9+). That one is a list, not a map, so it is none of the containers repaired above and takes its own rule.

A non-array exportNames reads as an empty export set: the diff runs, and no stored key is reported as an export. It is deliberately not read as unknown — an absent exportNames means "not known" and falls back to the pre-v9 rule where every output key is importable, so taking that branch for a corrupt one would report every plain output name as an export.

cdkd diff warns when it takes that branch, naming the stack and region. The rule itself lives in a predicate shared with the exports index, the deploy-time resolver and the cdkd local commands, and that predicate stays silent — it holds no stack name to put in a message. cdkd diff does hold one, so it says so rather than letting a loud failure become a quiet wrong answer. The warning is suppressed when the record's outputs bag is itself unreadable, since that is reported on its own and the exportNames line would just blame the wrong field.

How a secret-fed nested parameter is compared

The token is cast by the child parameter's declared Type only where every part of it stays a string, so the comparison matches what the child's state actually holds:

Declared Type Compared as
String The token, uncast.
The AWS-specific scalar types The token, uncast.
The whole AWS::SSM::Parameter::Value<...> family The token, uncast — the value is a Parameter Store key, not the resolved list.
CommaDelimitedList and every other List<...> type Split on , into an array of expressions, mirroring what the deploy split before redacting.
Number / List<Number> The token, uncast — casting yields NaN (an array of them for List<Number>), which matches neither side and would diff forever.

The population of splitting types is the family — any List<...> type, plus CommaDelimitedList itself — rather than a fixed list of names; List<AWS::EC2::Subnet::Id>, List<AWS::EC2::SecurityGroup::Id> and List<String> are examples of it. The line falls where it does because cdkd's secret redaction is string-keyed end to end: a shape whose parts are all strings stays inside that model, and a number does not.

A split reference survives as one element as long as it carries no comma of its own. That holds for the secret id, the parameter name and the version stage, and not for the JSON-key slot — {{resolve:secretsmanager:sec:SecretString:a,b::}} splits into two. A parameter fed such a reference reports a phantom change on every cdkd diff --recursive. Nothing is written and no plaintext is exposed by it.

cdkd diff additionally warns for any parameter whose declared Type could lose the plaintext under coercion, because cdkd deploy refuses that parameter when the coercion actually destroys it.

When a recorded condition verdict is reused

A condition that is not evaluated takes the verdict the last deploy recorded for it, when there is one. cdkd deploy evaluates the condition against the real secret, and records the verdict in the nested child's state together with a fingerprint. The fingerprint covers the condition's definition, every condition it names, and the inputs of the parameters they read. A secret-fed parameter contributes its {{resolve:...}} reference, never its value. The diff recomputes the fingerprint and reuses the verdict only when the two are equal. The reused verdict then selects Fn::If branches and prunes exactly as an evaluated one does, so a swapped branch or an edited property is still reported.

Without a matching record, an Fn::If on the condition resolves to its FALSE branch, and a resource gated on it is kept rather than pruned. That can show a change the deploy will not make, and it can also HIDE one: after a deploy that took FALSE, an edit that makes the condition TRUE still shows as no change. When a record exists but no longer matches, the diff warns and names the condition. There is no matching record when:

  • the condition, a condition it names, or a parameter input changed since the last deploy;
  • the state was written by an older cdkd, or by a deploy that failed or was interrupted. A parent skips an unchanged nested stack, so a child an older cdkd deployed gains its record only on the next deploy that changes it;
  • a parameter in the condition is SSM-typed (AWS::SSM::Parameter::Value<...>): the diff resolves its Default live, so the value is not one it can vouch for, and a value the parent supplies is withheld the same way;
  • a parameter in the condition could not be bound for the diff, or its value in the parent's nested-stack row is not one the diff can vouch for. Only literals, the parent's own trusted parameters and conditions, and parent resources that are unchanged and read only such inputs count. A Ref to a resource the deploy creates or replaces, an Fn::If on a condition the parent cannot evaluate, a Fn::GetAtt with no recorded attribute and a cross-stack read do not;
  • the parent passes a parameter in the condition a plain value other than its Default. Such a value is never fingerprinted, because it can carry an ancestor's secret;
  • the condition uses anything other than Fn::And, Fn::Or, Fn::Not and Fn::Equals over string literals, declared scalar parameters and other such conditions;
  • a parameter in it holds a NoEcho plain value, or any other value carrying a secret that the deploy cannot spell back as its {{resolve:...}} reference alone. Such a value is never fingerprinted.

The diff assumes the value behind an unchanged {{resolve:...}} reference is unchanged, as it does for every secret-bearing property.

Why a secret parameter cannot be a number

cdkd keeps a resolved secret out of persisted state by rewriting string leaves back to their {{resolve:...}} expression. Casting the value to a number takes it out of that model, so the child stack's state.json would keep the decrypted secret with nothing to redact it back to — the very disclosure this refusal exists to prevent. Refusing names the problem; silently persisting the plaintext does not.

CDK synthesizes every nested-stack cross-reference parameter as Type: String, so a CDK app never hits this.

A list-shaped type is allowed only while the secret itself carries no comma, and the refusal is decided by measuring the actual value rather than by the declared type alone. Splitting on , shreds a comma-bearing secret into fragments that no longer match the plaintext, so the same refusal fires and names the declared type. That is the dominant Secrets Manager shape — a JSON blob is nothing but commas — so a list-typed secret parameter is usable only for a bare token value. This applies to every list-shaped type alike, because the refusal asks the coercion what it destroyed rather than consulting a list of type names.

Exit 3: the five conditions and the nodes that raise them

The preview is complete when this fires: every resource row, every Outputs row and every nested stack is printed first, and the reasons follow under a Blocking (cdkd deploy will refuse): heading. cdkd deploy stops at the same condition — but it stops because there is nothing left for it to do, while a preview that died before printing would be a preview you could not use to decide anything.

Five conditions raise it.

A rollback-orphan adoption cdkd refuses. A rollback left a DeletionPolicy: Retain resource behind, cdkd recorded it so the next deploy could re-adopt it, and the physical name in that record is one ANOTHER cdkd stack's state already claims. Adopting it would put one physical id in two state files, and either stack's cdkd destroy would then delete the other's live resource, so cdkd refuses. Resolve the ownership conflict — usually by removing the resource from whichever stack should not own it — and the next cdkd diff exits normally.

A container this command repaired or dropped and cdkd deploy refuses. A resource's unreadable properties map, or an unreadable outputs bag, is repaired to empty for the preview (see when the state record is malformed) while cdkd deploy refuses the record outright. A rollback-orphan record this diff adopted counts the same way, since its properties map is repaired after it is spliced in. The preview is therefore honest about the rows and wrong about what happens next, and this is the exit code that says so — including when the template declares nothing in the damaged container, where the repaired {} produces no change rows at all and --fail alone would exit 0. The containers this command DROPS count the same way — an unreadable resources map, a resources entry that is not a resource record, an orphans field that is not a list, and an orphans record the preview cannot read. Those are also listed in --json and counted by --fail; this exit code takes precedence. Repair the record, or let the deploy's own refusal name it.

A rollback-orphan record this preview KEEPS that the deploy refuses. A row whose properties or attributes map is not an object stays in the preview, because the properties repair above names it again over the records an adoption takes — but cdkd deploy refuses the whole record over such a row. The preview warns naming the rows and raises this exit code, so a clean run never precedes a deploy that cannot start.

A resource whose Type changes into or out of AWS::CloudFormation::Stack. cdkd does not replace a single resource by a nested stack, or a nested stack by a single resource, and cdkd deploy refuses before touching anything. The reason names the row and both types. Give the new resource a different logical id (in CDK, rename the construct), or remove the resource in one deploy and add its replacement in the next.

A custom resource whose ServiceToken changes. CloudFormation refuses it (Modifying service token is not allowed), and so does cdkd deploy, before any handler is invoked: sent as an update, the change would reach only the new handler and orphan what the old one created. The reason names the row and the new token; where the recorded token is missing, the redaction mask *** or a {{resolve:...}} reference and the template moves it, it says cdkd cannot compare it, which the deploy refuses too. Give the custom resource a new logical id (in CDK, a new construct id, or overrideLogicalId), which creates a new resource through the new handler and deletes the old one through its old handler. A token that reads a resource this deploy replaces or creates (a renamed backing Lambda), or an attribute an update may move (a nested stack's output, another custom resource's Data), is known only once that resource exists, so the preview WARNS instead: the deploy refuses it then, before invoking any handler, and rolls back (a replaced backing Lambda is re-created by that rollback, see a changed custom-resource ServiceToken).

The first, fourth and fifth conditions are raised at every node the preview diffs. Only the TOP-LEVEL stack raises the second and third, as a conservative choice: a deploy skips an unchanged nested-stack row, and an UPDATE that moves only DeletionPolicy / UpdateReplacePolicy never diffs the child either, so a reason on a nested node would report a refusal over a deploy that succeeds. Every node this run REACHES still WARNS, which is what makes that carve-out safe — under --recursive, that is each template-present child. Two paths reach no ORPHAN warning: a plain run (without --recursive or --fail-on=destructive) visits no child at all, and a state-only child being DELETED runs no adoption preview, so neither orphan warning fires for it — that child still gets its container, properties and outputs warnings, and its own cdkd destroy refuses the row. For a resource properties map, an outputs bag and a kept rollback-orphan row, the warning also tells you the deploy refuses the record. For the outputs bag and the orphan row, that refusal comes when the deploy loads the record; for a properties map, when it computes its diff, before it provisions anything. A nested child the deploy skips as unchanged is neither loaded nor diffed, so it is never refused.

It is deliberately NOT 1. --fail uses 1 to mean "something changed", and a refusal is not a change: a CI job gating on drift must be able to tell "there is work to do" from "the work cannot begin". It is not 2 either — that code means re-running typically resolves it, and this one does not change until a person acts.

Distinguish the two meanings of 1 by whether the diff report was printed first: --fail prints the full report and then exits 1, while a command failure prints an error.

Last updated: