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 anFn::IforConditionreference 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, anFn::Subplaceholder left unsubstituted, or a list or object holding one of those or a missing value; - an output's
Export.Namecould 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-getattaborts instead). - the resolver returned nothing — a malformed value, such as an
Fn::Ifwith no third argument. No per-output warning (--strict-getattaborts here too). AnFn::GetAttcdkd 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::Splitpiece 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 shortNoEchovalue (a1, atrue) prints as***too. - A piece is a needle for the whole node, like the value it came from. A short
piece (the
1ofdbuser,1) masks every leaf EQUAL to it, and a piece of 4 or more characters (a port such as5432) 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 noNoEcho, 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::Splitby an EMPTY delimiter over aNoEchovalue prints its pieces as***on its own line, but anFn::JoinorFn::Subputting 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 aNoEchoparameter in that deploy. The stored previous plaintext prints as itsold:side. An export alias published under a previous value is the exception: while any declaredExport.Namereads aNoEchoparameter or custom-resource attribute (in anyFn::Ifbranch, a nested child's parent row included), every stale alias's REMOVE row name is withheld, an unrelated one included. Likewise a storedFn::Splitpiece of the current value prints once noFn::Splitover the value by that delimiter is left in the template. - A
NoEchoparameter fed a plain{{resolve:ssm:...}}reference to aStringparameter prints its resolved value where anFn::Sub/Fn::Joinembeds the reference, and the expression where a bareRefserves it: aStringparameter 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 itsDefaultlive, 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
Refto a resource the deploy creates or replaces, anFn::Ifon a condition the parent cannot evaluate, aFn::GetAttwith 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::NotandFn::Equalsover string literals, declared scalar parameters and other such conditions; - a parameter in it holds a
NoEchoplain 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.