Destroy data guards: non-empty S3 buckets and image-carrying ECR repositories
cdkd destroy (and cdkd state destroy) matches CloudFormation's
fail-and-protect behavior for the two resource types whose delete API
refuses by default while they still hold data:
| Resource type | Without an opt-in | With the opt-in |
|---|---|---|
AWS::S3::Bucket |
A non-empty bucket fails the destroy with an actionable "bucket is not empty" error (CloudFormation: DELETE_FAILED; Terraform: requires force_destroy). The bucket and every object survive. |
CDK autoDeleteObjects: true (the aws-cdk:auto-delete-objects tag) — cdkd auto-empties all object versions + delete markers before DeleteBucket, which also absorbs the race where objects (e.g. ALB access logs) land between the auto-delete custom resource's cleanup and the bucket deletion. |
AWS::ECR::Repository |
A repository that still contains images fails the destroy with an actionable "still contains images" error (CloudFormation: DELETE_FAILED unless EmptyOnDelete: true; Terraform: requires force_delete). The repository and images survive. |
CDK emptyOnDelete: true (EmptyOnDelete: true in the template) or the legacy autoDeleteImages (aws-cdk:auto-delete-images tag) — cdkd deletes with force: true. |
AWS::S3Express::DirectoryBucket |
A non-empty directory bucket fails the destroy with an actionable "is not empty" error (CloudFormation: DELETE_FAILED, live-A/B-verified 2026-08-03). The bucket and objects survive. |
Add the aws-cdk:auto-delete-objects tag with value true to the bucket's Tags (a handled property; CDK has no autoDeleteObjects sugar for directory buckets, so declare the tag explicitly on the L1). The opted-in auto-empty absorbs concurrent-write races with the same bounded empty-retry loop as the standard bucket. Alternatively empty the bucket manually (aws s3 rm s3://<bucket> --recursive — directory buckets have no versioning) and destroy again. --force-stateful-recreation replacement deletes are authorized as for the other types. |
To destroy anyway without redeploying, empty the data first (aws s3 rm s3://<bucket> --recursive — for versioned buckets delete all object
versions and delete markers; aws ecr batch-delete-image) and re-run the
destroy.
Replacement deletes during cdkd deploy are governed by the existing
stateful-recreation consent instead: passing --force-stateful-recreation
(the flag whose documented meaning is "I accept a data-losing recreation")
also authorizes the force-cleanup on the replaced resource's delete.
Related parity notes, verified against real CloudFormation (2026-08-02): CFn
itself hard-deletes AWS::SecretsManager::Secret (no recovery window) and
force-detaches out-of-band IAM role policy attachments on delete — cdkd's
identical behavior for those types is parity, not a divergence.
DeletionPolicy: Snapshot: final snapshots on delete (--skip-final-snapshot)
CloudFormation creates a final snapshot before deleting a resource whose
DeletionPolicy is Snapshot — and the CDK RDS L2 (DatabaseInstance /
DatabaseCluster) defaults removalPolicy to SNAPSHOT, so plain CDK
database stacks rely on it. cdkd matches this on every delete
path: cdkd destroy,
cdkd state destroy, the cdkd deploy DELETE of a resource removed from
the template, the rollback of a CREATE (automatic after a failed deploy, or
cdkd rollback), and — for UpdateReplacePolicy: Snapshot — the deploy
engine's replacement / recreate deletes of the OLD resource.
| Resource type | How the final snapshot is created |
|---|---|
AWS::RDS::DBInstance |
DeleteDBInstance(SkipFinalSnapshot=false, FinalDBSnapshotIdentifier=<generated>). CFn nuance matched: an instance that is a cluster member (DBClusterIdentifier set) is deleted without an instance-level snapshot — cluster-level snapshots cover it. |
AWS::RDS::DBCluster |
DeleteDBCluster(SkipFinalSnapshot=false, FinalDBSnapshotIdentifier=<generated>) |
AWS::Neptune::DBCluster |
DeleteDBCluster(...FinalDBSnapshotIdentifier) (Neptune SDK) |
AWS::DocDB::DBCluster |
DeleteDBCluster(...FinalDBSnapshotIdentifier) (DocDB SDK) |
AWS::ElastiCache::CacheCluster |
DeleteCacheCluster(FinalSnapshotIdentifier=<generated>) — Redis engine only; a Memcached cluster under Snapshot surfaces AWS's rejection, matching CFn's DELETE_FAILED |
AWS::EC2::Volume |
Pre-delete CreateSnapshot (tagged cdkd:final-snapshot-of: <volumeId>), waited to completed, then the normal delete — the type is Cloud-Control-routed and DeleteVolume has no snapshot parameter. Idempotent: a destroy re-run reuses the tagged snapshot instead of creating a second one. |
AWS::Redshift::Cluster |
Pre-delete CreateClusterSnapshot (<clusterId>-final-<ts>), waited to available, then a bounded wait for the CLUSTER itself to settle (the fresh snapshot leaves it busy and the delete would otherwise 400 with "There is an operation running on the Cluster"), then the CC-routed delete. |
AWS::ElastiCache::ReplicationGroup |
Pre-delete ElastiCache CreateSnapshot, waited to available, then the CC-routed delete. The snapshot source depends on cluster mode: a cluster-mode-ENABLED (sharded) group is snapshotted by ReplicationGroupId, while the cluster-mode-DISABLED default must name its PRIMARY member cache cluster instead (AWS rejects the group form with "Please specify a cache cluster instead") — cdkd resolves this automatically. Redis only; Memcached / snapshot-incapable node types surface AWS's rejection, matching CFn's DELETE_FAILED. |
Generated snapshot identifiers are deterministic and logged:
<physicalId>-final-<utcTimestamp> (sanitized to the snapshot-identifier
character rules).
--skip-final-snapshot (on cdkd deploy, cdkd destroy, cdkd state destroy, and cdkd rollback) is the explicit opt-out: delete WITHOUT the
final snapshot (data loss — useful for dev/test stacks where the snapshot
cost/latency is unwanted, and the escape hatch for the cc-api-routed refusal
below).
Notes:
- The recorded
state.deletionPolicy(schema v5+) is what the destroy paths consult; pre-v5 state keeps the legacy plain-delete behavior until a redeploy records the attribute (cdkd destroyalso falls back to the synth template'sDeletionPolicyfor pre-v5 state). - A Cloud-Control-routed resource of an atomic-parameter type (state
records
provisionedBy: cc-api— the silent-drop auto-routing) is refused: Cloud Control'sDeleteResourcehas no final-snapshot parameter, so cdkd cannot honor the policy on that route. Snapshot manually, then re-run with--skip-final-snapshot. UpdateReplacePolicy: Snapshotis honored on the deploy engine's replacement / recreate delete sites with the same mechanism matrix; the--force-stateful-recreationstateful guard still applies first where the replacement is data-losing. Failure handling differs by site, deliberately: the delete-first / recreate paths surface a snapshot failure as a resource failure (their delete is load-bearing for the re-create), while the post-replacement CLEANUP delete keeps its warn-and-continue policy for a TRANSIENT snapshot failure — it skips the delete, so the old resource is leaked with a warning rather than deleted un-snapshotted. A REFUSAL (a type / route cdkd cannot snapshot) always fails the resource, matching CloudFormation failing the update.- Rolling back a COMPLETED CREATE (the automatic rollback after a failed
deploy, or
cdkd rollback) IS a delete, soDeletionPolicy: Snapshotapplies to it: cdkd creates the final snapshot and then deletes, using the same mechanism matrix as the table above, and REFUSES (counting the op as a rollback failure, so the journal is kept for a re-run) any shape it cannot snapshot — pass--skip-final-snapshottocdkd rollbackto delete without it. Previously such a resource was ORPHANED — dropped from state and left running in AWS — which silently handed you an untracked, billing resource.DeletionPolicy: Retainstill orphans (that is what the policy asks for). cdkd rollback --revert-failed's delete of a resource whose CREATE FAILED mid-flight applies the SAME policy matrix:Retainleaves it in AWS,Snapshotsnapshots then deletes (refusing what it cannot snapshot),RetainExceptOnCreate/Delete/ absent delete plainly. That branch previously read no policy at all. It only engages when AWS actually provisioned the resource — the action requires a recorded physical id AND a matching state record — so the policy is never applied to a resource that never existed. A refusal here is recoverable rather than final: the op stays in the journal, so once a half-created resource settles into a snapshot-capable state (an RDS instance rejects a final-snapshot delete whilecreating) a re-run completes it, and--skip-final-snapshotis the opt-out if you would rather drop the data.- On a ROLLBACK's delete-of-the-NEW-resource (reversing a replacement, i.e.
UpdateReplacePolicy), only the atomic SDK-routed types get a final snapshot — the other shapes deliberately keep the plain delete (that delete is load-bearing for same-name re-creation). - Snapshot reuse across a re-run resumes only an IN-FLIGHT snapshot for the
name-keyed APIs (Redshift / ElastiCache): their identifiers are
user-chosen and reusable, so adopting an already-
availablesnapshot could hand you a PREVIOUS generation's data. A re-run after the snapshot completed therefore creates a second (timestamped, non-colliding) one. EC2 Volume reuses a completed snapshot safely — its tag is keyed on an AWS-generated volume id that is never reused. - Final snapshots are billed AWS resources that survive the destroy by design — delete them manually when no longer needed.
--remove-protection: bypass deletion protection on destroy
cdkd destroy --remove-protection and cdkd state destroy --remove-protection flip every protection flag off in-place
before each provider's delete API call so the destroy proceeds
without an intermediate edit / redeploy / console click. Covers
stack-level terminationProtection (the bypass logs a WARN
line naming the stack — cdkd state destroy already ignores
terminationProtection because the flag is a CDK property
surfaced via synth, so the flag is effectively a no-op there for
that part) AND resource-level protection on the following
types:
| Resource type | Protection field | Bypass call |
|---|---|---|
AWS::Logs::LogGroup |
DeletionProtectionEnabled |
PutLogGroupDeletionProtection(deletionProtectionEnabled=false) |
AWS::RDS::DBInstance |
DeletionProtection |
ModifyDBInstance(DeletionProtection=false, ApplyImmediately=true) |
AWS::RDS::DBCluster |
DeletionProtection |
ModifyDBCluster(DeletionProtection=false, ApplyImmediately=true) |
AWS::DocDB::DBCluster |
DeletionProtection |
ModifyDBCluster(DeletionProtection=false, ApplyImmediately=true) (DocDB SDK) — DocDB DBInstance has no DeletionProtection field, so no per-instance bypass; cluster-level covers the common case |
AWS::Neptune::DBCluster |
DeletionProtection |
ModifyDBCluster(DeletionProtection=false, ApplyImmediately=true) (Neptune SDK) |
AWS::Neptune::DBInstance |
DeletionProtection |
ModifyDBInstance(DeletionProtection=false, ApplyImmediately=true) (Neptune SDK) |
AWS::DynamoDB::Table |
DeletionProtectionEnabled |
UpdateTable(DeletionProtectionEnabled=false) then DescribeTable poll until ACTIVE |
AWS::EC2::Instance |
DisableApiTermination |
ModifyInstanceAttribute(DisableApiTermination={Value:false}) |
AWS::ElasticLoadBalancingV2::LoadBalancer |
attribute deletion_protection.enabled |
ModifyLoadBalancerAttributes([{Key: 'deletion_protection.enabled', Value: 'false'}]) |
AWS::Cognito::UserPool |
DeletionProtection (ACTIVE / INACTIVE) |
UpdateUserPool(DeletionProtection='INACTIVE') |
AWS::AutoScaling::AutoScalingGroup |
DeletionProtection (none / prevent-force-deletion / prevent-all-deletion) |
UpdateAutoScalingGroup(DeletionProtection='none') followed by DeleteAutoScalingGroup(ForceDelete=true) so AWS terminates running instances as part of the delete |
AWS::DSQL::Cluster |
DeletionProtectionEnabled |
Cloud Control UpdateResource patch ([{op: add, path: /DeletionProtectionEnabled, value: false}]), waited to completion, then DeleteResource — the generic CC-routed protection flip; more CC-routed types with a top-level protection property can join the registry in src/provisioning/cc-protection-properties.ts once live-verified |
AWS::NeptuneGraph::Graph |
DeletionProtection |
Same generic CC patch flip (value: false) then DeleteResource |
AWS::SMSVOICE::ProtectConfiguration |
DeletionProtectionEnabled |
Same generic CC patch flip (value: false) then DeleteResource |
AWS::VerifiedPermissions::PolicyStore |
DeletionProtection ({Mode: ENABLED|DISABLED}) |
Same generic CC patch flip with value: {Mode: DISABLED} then DeleteResource |
AWS::EKS::Cluster |
DeletionProtection |
Same generic CC patch flip (value: false) then DeleteResource |
AWS::RDS::GlobalCluster |
DeletionProtection |
Same generic CC patch flip (value: false) then DeleteResource |
AWS::DocDB::GlobalCluster |
DeletionProtection |
Same generic CC patch flip (value: false) then DeleteResource |
Behavior:
- The flip-off call is idempotent — providers always issue it when the flag is set, regardless of whether the resource currently has protection on. AWS accepts the no-op (already- disabled) case without error.
- A failure of the flip-off itself (NotFound / similar) is logged at debug; the actual delete API call still runs and surfaces its own error message.
- On the two DynamoDB types (
AWS::DynamoDB::TableandAWS::DynamoDB::GlobalTable), a flip that is followed by a terminal delete failure — or by a Ctrl-C landing after the flip — is compensated: cdkd re-enablesDeletionProtectionEnabledbefore reporting the failure, so a destroy that did not happen does not leave a live table with its guard stripped. Four limits are deliberate: it only ever restores a guard cdkd itself turned off in this run (a table whose protection was already disabled beforehand, or one whose pre-flip read failed, is left alone); it does not fire BETWEEN retries — the destroy loop re-entersdelete()for a retryable failure and flips the guard off again, so compensating there would only toggle it back and forth, and what counts is how the delete ENDS (a throttle followed by a terminal refusal IS compensated); it does not fire once AWS has ACCEPTED theDeleteTable, because a failure after that point is a wait giving up on a table that is already being deleted; and it is best-effort — the delete failure stays the reported outcome, and a re-enable that itself fails is reported as a separate ERROR line naming the table plus theaws dynamodb update-table --deletion-protection-enabledcommand that restores it by hand. A re-enable that fails withResourceNotFoundExceptionis reported at warn instead, because cdkd cannot tell which case it is: DynamoDB returns that error both for a table that is gone and for one whose status is merely notACTIVE. So the line drops the claim that the table is LIVE — which would be false in the first case — while staying visible, and names adescribe-tablecheck before the restore command. Every other re-enable failure keeps the ERROR line. - The compensation survives a long retry sequence.
It latches per table for the duration of the destroy, and that
latch's reuse window is sliding — every re-entry restarts
it — so what is bounded is idle time since the last attempt
rather than the total lifetime of the sequence. A
--resource-timeoutset past the window used to let an accumulation of attempts age out its own latch mid-flight, after which the next attempt read the guard as already off (cdkd having turned it off itself) and compensated nothing. This is not unconditional.acquireruns once per attempt, so what the window measures is the previous attempt's own duration plus the loop backoff. That is normally comfortable — the 26-minute delete allowance covers the WHOLE retry sequence rather than one attempt, and the window adds a 4-minute margin sized for that backoff — so what can still age out a successor is an attempt that OVERRUNS the allowance (a floor poll granted at zero remaining, an unpriced teardown), not one that merely spends it. - A terminal delete failure releases the latch once the
compensation above has run — UNLESS that compensating re-enable
itself failed, in which case the latch is kept so a later delete
can retry the re-enable cdkd still owes. Retention
exists so a re-entered delete keeps the same record, and only a
retryable failure is re-entered; holding it past a terminal one
left
flippedOffByThisRuntrue for a full sliding window after the last attempt, during which a second destroy of the same table in the same process could inherit it and issue anUpdateTable(DeletionProtectionEnabled: true) nobody asked for. The release runs after the compensation reads the record, so the re-enable above is unaffected. Two failures still RETAIN the latch. A retryable one — the attempt-cap case below included — does, because a re-entry is exactly what the record is for. So does a terminal one whose compensating re-enable itself FAILED: there the guard really is off and cdkd is the one that turned it off, so the latch is kept and a later destroy of the same table in the same process retries the re-enable cdkd still owes rather than finding the guard already down and leaving it there. - Two cases it deliberately does NOT reach, both leaving the
guard off: a retryable failure that exhausts the destroy loop's
own attempt cap (cdkd cannot see which attempt is the last),
and a per-resource
--resource-timeoutfiring, which rejects without cancelling the delete, so the provider never observes a failure to compensate. - This is per-PR-level: a single
--remove-protectioncovers every protection-bearing type listed above. There is no per- type variant. If you need finer control, run a stack-only destroy and clean up the rest manually. - The interactive confirmation prompt is updated when the flag is
set:
About to destroy N resources from stack "X", REMOVING DELETION PROTECTION on K of them. Continue? (y/N). The default flips fromY/ntoy/N.--yes/-y/-fskips the prompt. - RDS / Cognito gating change: prior to this flag, the RDS
DBInstance / DBCluster providers always issued
ModifyDB{Instance,Cluster}withDeletionProtection: falsebefore destroy, and the Cognito UserPool provider always issuedDescribeUserPool+ (ifACTIVE)UpdateUserPool (DeletionProtection='INACTIVE')before destroy. Both implicit behaviors are now gated on--remove-protectionto match the other types — destroying an RDS or Cognito UserPool resource whose deletion protection was set externally (console / AWS CLI) without--remove-protectionwill surface AWS'sInvalidParameterCombination/InvalidParameterExceptionerror rather than silently succeed. - Protection types not in the table above (CloudFront Distributions, S3 bucket retention, etc.) are out of scope — the list is curated to the cases where AWS exposes a synchronous "flip protection off" API call.
# Stack with terminationProtection: true OR a protected DynamoDB / RDS / Logs / EC2 / LB
cdkd destroy MyStack --remove-protection
cdkd destroy --all --remove-protection -y
# CDK-app-free counterpart — the resource-level flip applies the same way;
# stack-level terminationProtection is already ignored by `state destroy`.
cdkd state destroy MyStack --remove-protection -y
Interrupting a destroy (Ctrl-C / SIGTERM)
cdkd destroy and cdkd state destroy shut down gracefully. The first
Ctrl-C stops scheduling new deletes; the deletes already in flight are awaited,
the state file is flushed to its minimal preserved form, and the stack lock is
released. The command then exits non-zero
(Destroy interrupted by Ctrl-C. State preserved) so CI sees that the teardown
did not complete — re-running cdkd destroy picks up the remaining resources.
A second Ctrl-C force-quits without waiting for the in-flight call. Which
recovery command it prints depends on whether a per-stack teardown had armed
its own handler yet, which is not the same as whether a stack is "running":
- the exact region-qualified
cdkd force-unlock <stack> --stack-region ...once that stack's teardown owns the signal, i.e. it can name the lock; - a hedged
cdkd force-unlock <stack-name>otherwise — both between two stacks (where the finished stack has already released its lock, unless that release itself failed) and on the FIRST signal inside a stack that has not yet armed its teardown, the window between the loop dispatching the stack and the runner registering its handler. Nothing at either point knows which lock to name.
CI cancellation delivers SIGTERM rather than Ctrl-C, and it is routed through the identical path.
Under --all, the interrupt also stops the run before the next stack
starts — including a signal that lands between two stacks, or while the
interrupted stack was finishing its own teardown, which are windows the
per-stack teardown cannot observe at all. Previously a Ctrl-C
there was either swallowed, letting --all delete the next stack, or taken by
a last-resort force-quit that exited 130 with the lock stranded.
The non-zero exit means work was left undone, not merely "a signal arrived". A Ctrl-C that lands in the tail of the run — after the last (or only) stack has already been destroyed, while cdkd is finalizing its event record — leaves nothing to re-run, so that case exits 0.
cdkd state destroy --all prompts once for the whole batch before it touches
anything. A signal at that prompt cancels it and exits 130 without reading,
locking or deleting anything.
The prompt is interactive-only: when stdin is not a TTY — a piped,
redirected or CI run — the command refuses before creating the prompt and
exits non-zero, naming --yes as the way through. Nothing is read, locked or
deleted. This is the same non-interactive rule cdkd gc and
cdkd bootstrap --destroy follow. Use --yes / -y to confirm the batch
non-interactively; that flag short-circuits above the check, so it never
consults stdin at all.
The PER-STACK destroy prompt is interactive-only too
Upgrade note — this is a behaviour change, and it adds an exit-code
contract. The
per-stack confirmation — the Are you sure you want to destroy stack "X" ...
prompt that cdkd destroy <stack>, cdkd destroy --all and
cdkd state destroy <stack> all share — now follows the same rule as the batch
prompt above: on a non-TTY stdin it refuses before creating the prompt,
throwing CdkdError with the code NON_INTERACTIVE_CONFIRM and exiting
exit code 1. Note this is NOT the same guarantee the batch prompt above carries. That one
promises nothing is read, locked or deleted; here the refusal is preceded by the
strong-reference scan, which READS other stacks' state records. Nothing is locked and
nothing is deleted on the path that refuses.
A stack whose state record holds ZERO resources never reaches the refusal at all: that branch returns earlier, having taken the lock and deleted the record, and a non-interactive run of it still succeeds. The two are mutually exclusive, so this is not a case of the refusal deleting something first.
Before this change that prompt had no non-TTY guard at all, and
rl.question never settles once stdin is at EOF. A CI job running
cdkd destroy MyStack without --yes therefore hung until its own
timeout rather than failing — no signal is delivered on EOF, so nothing woke
it. Measured on Node 24.15.0, the version .node-version pins (and 24.19 before it): echo y | resolved, while printf 'y' | (no
trailing newline) and < /dev/null both hung indefinitely.
What breaks: a pipeline that answered the prompt with printf 'y\n' | cdkd destroy MyStack succeeded before (piped stdin does settle rl.question when
the input ends in a newline) and now exits 1. Pass --yes / -y — or
-f / --force on cdkd destroy — which short-circuits above the check and
never consults stdin at all. Refusing rather than auto-confirming is the
deliberate choice for a destroy: cdkd deploy assumes "yes" on a non-TTY
because a deploy is recoverable, whereas silently answering "yes" for an absent
operator here would delete every resource in the stack.
Not affected: nested-stack children, which are destroyed as part of their
parent's cascade and never prompt separately, and cdkd state destroy --all,
whose per-stack prompts are already skipped once the batch prompt above is
answered.
A stack whose deletes were interrupted keeps its state.json and its
deployment-event history — the events are the post-mortem for the retry, which
is why --purge-events below is skipped on an interrupted run.
Every other mutating confirmation prompt is interactive-only too
Upgrade note — this is a behaviour change on nine more prompts. rl.question never
settles once stdin is at EOF, and EOF delivers no signal, so a command that
prompted without a non-TTY guard hung until its own timeout in CI rather
than failing. That was closed first for the destroy prompts above; these nine
were the rest of the class:
| Command | Prompt | Flag that avoids it |
|---|---|---|
cdkd rollback |
Roll back '<stack>' (<region>)? |
--force (or -y / --yes) |
cdkd state orphan |
Remove state for <refs> from s3://...? |
-y / --yes, or -f / --force |
cdkd state refresh-observed |
Refresh observedProperties for N stack(s)...? |
-y / --yes |
cdkd orphan |
Orphan N resource(s) from cdkd state...? |
-y / --yes, or -f / --force |
cdkd import |
Write state for <stack> with N resource(s)? |
-y / --yes |
cdkd export |
the rollback-journal override, the migration confirm, and the nested-stack tree-wide confirm | -y / --yes |
cdkd drift --accept / --revert |
Update cdkd state...? / Push cdkd state values back into AWS...? |
-y / --yes |
cdkd import --migrate-from-cloudformation, cdkd migrate --retire-cfn-stack |
Set DeletionPolicy=Retain ... then delete the stack? |
-y / --yes |
cdkd state migrate |
Copy N object(s) from <bucket> -> <bucket>...? |
-y / --yes |
cdkd events prune (the tenth) |
Prune deployment-event history for <stack> (<region>): <scope>? |
-y / --yes |
On a non-TTY stdin each now refuses before creating the prompt, throwing
CdkdError with the code NON_INTERACTIVE_CONFIRM and exiting 1. The
message names the command and the flag that avoids the prompt.
No partial mutation survives the refusal. That is the guarantee, and it is worth stating precisely rather than as "nothing has happened yet", because for two of the nine something already has:
- A state lock IS held at the prompt on four of them.
cdkd orphan,cdkd import,cdkd export(its migration and nested-tree prompts) andcdkd rollbackacquire the stack's lock before building the plan they are about to ask you to confirm. Every one of them releases it in afinally, so the refusal releases it on the way out and no lock is leaked — a re-run with the flag is not blocked by the run that refused. No lock is held atcdkd drift's two prompts or atcdkd export's rollback-journal override (all three acquire after the prompt), nor at eithercdkd stateprompt (they only READ lock state),cdkd state migrate, or the CloudFormation retirement. - The CloudFormation retirement has already written, in two senses. cdkd
state is written before it is reached at all — it is the last step of
cdkd import --migrate-from-cloudformation/cdkd migrate --retire-cfn-stack— so a refusal leaves the resources recorded in cdkd state while the CloudFormation stack is still live. Its refusal message says so, and names both commands. Separately, for a nested stack whose child templates exceed CloudFormation's 51,200-byte inline limit, those child bodies have already been uploaded tocdkd-migrate-tmp/in the state bucket by the time the prompt fires; they are deleted on the refusing path, exactly as they are when you answern.
Every other prompt refuses after read-only work only — the plan it was about to ask you to confirm, and nothing else.
What breaks: a pipeline that answered one of these with
printf 'y\n' | cdkd import ... succeeded before (piped stdin does settle
rl.question when the input ends in a newline) and now exits 1. Pass the
flag from the table, which short-circuits above the check and never consults
stdin at all.
Refusing rather than auto-confirming is uniform here because every one of the
nine guards a mutation — a rollback replay, a state-record removal, an
observed-property refresh, an orphan, an import, an export-then-delete-state, a
drift accept/revert, a CloudFormation stack retirement, a state-bucket
migration. There is no read-only command in the set, so there was no case for
the cdkd deploy treatment. cdkd deploy's asset-storage auto-create prompt
remains the one deliberate exception (it assumes "yes" on a non-TTY), because a
deploy is recoverable.
All TEN now share ONE implementation — confirmOrRefuse in
src/cli/commands/confirm-prompt.ts — nine folded here and cdkd events prune folded last (see below), so the guard
cannot be missed by the next prompt added. Each site keeps its own prompt wording and its own refusal
message; nothing about the interactive experience changed, including the two
prompts that render (y/N): where the other seven render [y/N].
cdkd events prune was the tenth, and it now follows the same
contract. It had carried a
non-TTY guard since it shipped, so it never hung and it pruned nothing without
a TTY — but the guard sat at its CALLER rather than in a helper, and it refused
by logging a line and returning, which exits 0. A CI job branching on exit
1 to detect "cdkd wanted a confirmation" could not see it, and could not tell
"cdkd refused" from "cdkd pruned nothing". It now throws
NON_INTERACTIVE_CONFIRM and exits 1 like the other nine, and its own copy of
the byte-identical helper — the last one — is gone.
BREAKING for cdkd events prune specifically: a non-interactive run
without --yes used to exit 0 and is now exit 1. Nothing was pruned in either
case, so the remedy is the same as for the other nine — pass -y / --yes.
--purge-events: also delete deployment-event history on destroy
By default cdkd destroy removes state.json / lock.json but keeps the
stack's deployment-event history (the deployments/ store) as
post-mortem context — so the state bucket does not return fully empty after a
teardown. cdkd destroy <stack> --purge-events opts into purging that
history too, so the bucket returns to empty:
cdkd destroy MyStack --purge-events -y
- The purge runs only after a clean, non-interrupted destroy of that stack. On a failed / interrupted destroy the events are kept — they are exactly the post-mortem you want when retrying.
- "Interrupted" here is the PER-STACK question — was this stack left with work to re-run — matching what the exit code asks of the run as a whole. A Ctrl-C that lands after this stack was fully destroyed does not keep its events: there is no retry to post-mortem, and the run reports success, so suppressing the purge there would report a clean teardown while silently leaving the history behind.
- Best-effort: a purge failure logs a warning but never fails the already-successful destroy.
state destroydoes NOT take this flag; for an already-destroyed stack (or the CDK-app-free path) use the equivalentcdkd events prune <stack> --all.- Per-stack: when destroying multiple stacks, each clean stack's history is purged independently.