cdkd changelog (extracted from AGENTS.md)
Detailed per-PR notes split out from the project's main AGENTS.md so that file fits within Claude Code's recommended ≤200-line instruction-file size (official memory docs).
Each entry below describes a shipped change — the file at the top of the entry, the public-facing surface that changed, the user-visible behavior delta, the tests added, and (where present) the issue / PR number that drove it. Pre-PR behavior is described in the past tense and post-PR behavior in the present tense, so a reader reconstructing the history of any subsystem can read top-to-bottom by date and see when each capability landed.
AGENTS.md retains the load-bearing summary ("for dev/test workflows only — early in development, not yet production-ready"); the per-PR detail moved here.
What ONE entry may carry (issue #2552)
An entry is capped at 2000 characters, counted over the bullet and every
continuation line under it, and enforced by
tests/unit/scripts/changelog-entry-size.test.ts. In a fragment the count is
the whole file as it reads, trailing whitespace stripped — so a blank line
between two prose lines counts, where the assembled document's own check
skips it, and a --- at column 0 counts along with everything below it, where
that check stops at the rule. The two agree unless you pad or park a ---
mid-entry. The entry keeps the
user-visible behavior delta, the changed files, the issue / PR numbers, and the
residual's issue number — roughly the headline plus the file list plus a few
sentences of mechanism.
Everything past that moves rather than disappears: a design decision to
docs/design/<issue>-<slug>.md (the directory already holds documents of that
shape, and .claude/rules/ bullets already close with
Design: docs/design/<n>-<slug>.md), and a mechanism to the doc comment on the
module or test that implements it — where it sits next to the thing it
describes and moves when that thing does. The entry links to it.
The cap exists because prose here asserts implementation detail that no fence
can verify, and it drifts: a sentence describing an integration fixture's
premise guard has nothing checking it still matches that fixture, and one such
sentence needed its own follow-up PR (#2549).
Editing this file also costs a full unit-suite run, since docs/** is a
check gate input — and moving the rationale does not reduce that: both
redirect targets, docs/** and tests/**, are in the same check scope. What
shrinks is the number of implementation facts one entry asserts with nothing
verifying them. Narrowing .markgate.yml's scope is a separate question.
There is deliberately no per-entry opt-out.
Every fragment you write is capped, whatever date its filename carries. A
fragment under changelog.d/entries/ is new by construction, so the date in
its name is a sort key for the assembler and never evidence about when the
entry was written. There is no allowlist of exempted entries and no date that
buys an exemption.
The cap is still forward-only, but the forward-only part now applies only
to the ARCHIVE: everything in changelog.d/_archive.md was written under no
cap, is already correct, and rewriting it buys nothing, so entries under a
heading dated before 2026-09-05 stay exempt there.
That exemption is reachable, and saying otherwise would be papering over it:
_archive.md is an ordinary tracked file the assembler reads on its own path,
so a bullet appended to it under a pre-cutoff heading bypasses the fragment cap
entirely. Nothing fences that. "No lane edits the archive again" is a
convention, not a mechanism — what the fragment cap buys is that the ordinary
way to write an entry is capped.
Until issue #2859 the cap keyed on the heading date alone, which meant a fragment named with a pre-cutoff date shipped uncapped. That was survivable only because issue #2813 happened to refuse any fragment dated inside the archive's span; fixing #2813 would have widened the uncapped window by about three months, so the cap moved to the fragment itself.
The cutoff is the day after the cap landed, not the day of. A same-day cutoff
races every other lane merging that day: while this was in review, two
unrelated PRs merged the same afternoon and added 3,001- and 1,854-character
entries under the same heading. A same-day cutoff would have turned main red
for entries written before the rule existed, or forced a grandfathering list
that grows by whoever merges next — an opt-out reachable by being late.
WHEN an entry is required (issue #2779)
The section above caps what one entry may carry. This one decides whether a change writes one at all, and it NARROWS the population: a change with no user-visible behavior delta needs no bullet.
The test is what the SHIPPED ARTIFACT does — the binary a user runs. A change
that alters it writes an entry; a change to how this repository is developed,
reviewed, or checked does not. In practice that means src/**, plus anything
that feeds data the runtime reads. Agent instructions under .claude/**, the
unit and integration suites, the CI workflows, the hooks, and documentation
edits that describe behavior rather than change it are all out.
One case sits on the line and is IN, deliberately: a scripts/** generator
whose output the deploy path consumes. The refreshed CloudFormation schemas
decide SDK-versus-Cloud-Control routing at deploy time, so a change there can
silently drop a property from a user's stack — a behavior delta that happens
to be produced by a build-time script.
That clause is a BACKSTOP rather than a common case, and saying so is better
than letting the next reader over-estimate it: such a generator commits its
output under src/** and CI fails on drift, so a PR that really changes what
the deploy path reads is already IN by the ordinary trigger. What the clause
catches is the split landing — a generator edit whose regenerated output
arrives in a later PR.
On the src/** side this is codification; on the other side it is a real
narrowing. Measured over the 40 merged PRs ending at
#2787, ordered by merge time: 20
wrote an entry, and 9 of the 10 touching src/** did — the one exception
being a comment-only edit. That half the rule only writes down.
The other 30 are where it bites. Eleven of them wrote an entry and nineteen
did not, along no line anyone could name: retiring nine PreToolUse gates wrote
none while pinning one environment flag wrote one, and both change how every
future session behaves. Under this rule all eleven of those flip to no
entry. Six touch scripts/** and none of the six feeds the deploy path —
they are CI checkers, coverage-matrix generators and a schema-refresh
REPORTER, not the schema refresh itself — so the exception above does not
rescue them; the remaining five are hooks, tests and docs. That is over a
quarter of the window changing behavior, not a formality.
The window is named by its BOUNDARY PR rather than by a date on purpose: these figures were first published as "the 40 most recently merged as of 2026-09-08" and were invalidated within the hour by two same-day merges, which a date stamp cannot express. Re-derive by walking merged PRs from that boundary.
The rate is also window-dependent, so treat any of it as a reading rather than
a constant: over 120 merged PRs from the same boundary the src/** side holds
at 87.8% while the non-src entry rate falls from 36.7% to 19.0%.
Two consequences worth stating, since neither is obvious from the rule. An
entry is not a record of effort — a large agent-tooling PR correctly writes
nothing, and that is not a demotion. And the entry a change does not write
still has somewhere to go: the reasoning belongs in the commit message, a
design note under docs/design/, or the doc comment of the module it
describes, all of which outlive a bullet.
Enforced only by review. A CI check could require an entry from a src/**
diff, and deliberately is not added: a missing bullet harms nobody at the
moment of the merge and is repaired by an edit, which is below this repo's bar
for a blocking gate. This file is the contract; AGENTS.md carries one line
pointing here, and /check-docs restates the cap — kept in step by hand.
Note for anyone editing THIS section: a line starting with - at column 0
reads as a changelog ENTRY to the uniqueness fence, which would key it and
count it toward that fence's population floors. (The size cap does not see it —
it resolves each entry's date and so exempts everything above the first dated
heading.) The contract sections are written as prose on purpose.
WHERE an entry goes (issue #2779)
You are reading an ASSEMBLED document. It is built by vp run gen:changelog
from changelog.d/ and is gitignored — never edit it, and never commit it.
An entry is one file, changelog.d/entries/<YYYY-MM-DD>-<issue>-<slug>.md,
containing the bullet and nothing else. The issue number in the path is what
makes two lanes structurally unable to choose the same file, which is the
whole point: every lane used to append to one anchor at the top of one list,
and that anchor is what conflicted on nearly every parallel-lane rebase.
Do not write a dated heading into a fragment. The assembler emits one per
DATE, grouping the entries that share it, and splices a date the archive
already heads underneath that heading rather than opening a second one. That
is what makes the duplicate-heading defect above structurally impossible
rather than merely fenced: no lane writes a heading, so no merge resolution
can duplicate one. assemble-changelog.test.ts refuses a fragment carrying
one.
Everything written before the migration lives in changelog.d/_archive.md as
a single file. Splitting settled history into hundreds of fragments would have
produced a diff nobody could review, in which a mis-split is indistinguishable
from a correct one, to make uniform something no lane will edit again.
What a SECTION HEADING must be (issue #1837)
Entries are grouped under **Recently Implemented** (YYYY-MM-DD): headings.
tests/unit/scripts/changelog-entry-uniqueness.test.ts requires that every
line which READS as one is spelled exactly that way — at column 0, in that
case, with a YYYY-MM-DD date — that no heading text repeats, and that the
dates never rise as you read down the file.
They exist because a rebase resolving "keep both sides" duplicates a heading as
readily as it duplicates an entry, and the entry verdicts in that file cannot
see it — they key - bullets, and a heading is not one. Measured on main at
2026-09-08, before the fence: the 2026-08-18 and 2026-08-25 headings each
appeared twice among 52, and the 08-25 pair had an 08-24 section wedged between
its halves, so the dates ran 25 → 24 → 25 → 23.
Splitting one day deliberately is fine and needs no exemption — distinguish the
headings the way (2026-07-02, second batch): does. Uniqueness keys the whole
heading text, and the ordering verdict rejects only a rising date, never an
equal one.
Recently Implemented (2026-10-24):
- A redeploy that changes an
AWS::ApiGatewayV2::Api'sBodyS3Location, or itsBasePath/FailOnWarningswhile the definition comes fromBodyS3Location, now updates the API in place instead of being refused (issue #5264) --src/provisioning/apigwv2-body-import-patch.ts,src/provisioning/cloud-control-provider.ts, unit cases, and a phase in thecc-api-fallbackinteg. cdkd reads the definition object as the deploy identity, in the bucket's own region. It reads a pinnedVersionas that version, and follows a301redirect once to the region S3 names. A template declaring bothBodyandBodyS3Locationis refused. Then it reimports the text throughReimportApi, as it does for an inlineBody, and re-sends a declaredDescription. A refused read names the failure by its class and the next step: anAccessDeniednames thes3:GetObjectgrant (pluss3:GetObjectVersionfor a pinned version andkms:Decryptfor an SSE-KMS object), and AWS's own text goes to--verboseonly. A throttled or failed read is retried as any update is. CloudFormation reads the object with the stack's execution role; cdkd reads it with the deploy identity. - A redeploy that edits an
AWS::ApiGatewayV2::Api's inline OpenAPIBodynow updates the API in place instead of failing with "Redundant fields [...] provided when either Body or BodyS3Location is not empty" (issue #5260) --src/provisioning/apigwv2-body-import-patch.ts,src/provisioning/cloud-control-provider.ts, unit cases, and a two-edit phase in thecc-api-fallbackinteg. Such an API is routed through Cloud Control, which applies an update to the model it reads back. That model carriesName,ProtocolTypeand four more keys that API Gateway refuses besideBody, and no Cloud Control patch can leave them out: removing the create-onlyProtocolTypeis itself refused. So cdkd now sends a change toBody,BasePathorFailOnWarningsthroughReimportApi, as the import path does, and sends any other changed property through Cloud Control without the import keys. ATagsorDescriptionchange on such an API goes through Cloud Control with the import keys left out, including the write-onlyBodyevery Cloud Control update re-adds. A declaredDescriptionis re-sent after the reimport, since a definition holdinginfo.descriptionoverwrites it. A change to aBodyS3Locationdefinition, or toBasePath/FailOnWarningson an API defined by one, is refused with a message naming the two ways out (inlineBody, or a replacement), sinceReimportApitakes the definition's text only; CloudFormation accepts that change. - A malformed template,
--env-varsfile, event payload, Cloud Control model, lock, manifest or config file no longer prints a window of itself in its error (issue #5254) --src/utils/json-parse-failure.ts,src/cli/yaml-cfn.ts,src/cli/commands/{import,state,local-invoke,local-invoke-agentcore,local-run-task,local-start-api}.ts,src/cli/config-loader.ts,src/state/lock-manager.ts,src/synthesis/{assembly-reader,context-store,macro-expander}.ts,src/synthesis/context-providers/cc-api-provider.ts,src/provisioning/{create-only,write-only}-properties.ts,src/assets/{asset-manifest-loader,asset-publisher,asset-redirect,asset-storage}.ts. These are the siblings of #5247. Each site put Node'sJSON.parseerror text into a thrown message, aCaused by:line or a warning, and that text quotes about 30 characters of the body, which can be a plaintext secret. Each now readsinvalid JSON[ at code-unit position N]; <bytes> byte(s) read.manifest.json's cause, which a Stage reader classifies, and the bootstrap marker's, whose message has no detail of its own, are aSyntaxErrorcarrying that text; the others attach none. A YAML template's error names theyamllibrary's error code, line and column (or cdkd's own!GetAttrefusal) instead of a reason and code frame that quoted the source, and a YAML warning such as an unknown tag is awarnline in that form instead of a code frame on stderr.cdkd state refresh-observedprints onlySyntaxErrorfor a readback that failed to parse. Node's full text, ASCII-sanitized, is logged at--verboseonly.src/provisioning/cloud-control-provider.ts'sreadCurrentStateis not changed here. - A corrupt
state.jsonor rollback journal no longer prints a window of the file in its error (issue #5247) --src/utils/json-parse-failure.ts(new:describeJsonParseFailure,utf8ByteLength),src/state/s3-state-backend.ts,src/types/rollback-journal.ts,src/state/export-index-store.ts,src/cli/commands/gc.ts,docs/troubleshooting-locks-state.md. Before,State file for stack <S> is not valid JSON: ...andRollback journal for '<S>' is not valid JSON: ...carried Node'sJSON.parseerror text, which quotes about 30 characters of the body. Beforecdkd scrub, that can be a plaintext property or output value, and it reached the terminal and CI logs at default verbosity -- twice for the state file, whoseCaused by:line repeated it. Both messages now readinvalid JSON[ at code-unit position N]; <bytes> byte(s) read, the form the exports index already used, and neither the state refusal norcdkd gc's corrupt-state abort (which printed the same window on itsCaused by:line) attaches the parse error as its cause. Node's full text, ASCII-sanitized, is logged at--verboseonly. cdkd deploy --concurrencydefaults to 20 instead of 10, andCDKD_PHASE_TIMINGS=1counts retries by error class (issue #5242) --src/cli/options.ts,src/deployment/deploy-engine.ts,src/deployment/retry.ts,docs/cli-deploy.md,docs/troubleshooting-slow-deploys.md,docs/architecture-deploy-pipeline.md,docs/provider-rules.md,tests/integration/wide-independent(a Cloud Control mode), the newtests/integration/wide-web-functions, and a new unit file. A stack with many independent resources provisions up to 2x faster: 24 Lambda web functions took 40 s at 10 and 20 s at 20; 48 independent Cloud Control resources 103 s at 10, 75 s at 15, 66 s at 20 and 72 s at 30, where Cloud Control began to throttle. A stack whose resources depend on each other rarely has more than 10 ready at once, so it deploys as before. A nested stack's child deploys at its parent's limit, so a nested level now allows up to 20 x 20 operations in flight. Pass--concurrency 10to keep the previous limit. The phase report now carries aretry after <error class>line per class, naming the AWS error a provider wrapped, with the scheduled backoff and the longest; an error's message is never shown.cdkd state destroyrefuses a stack deployed withterminationProtection: true(issue #5238) --src/types/state.ts(terminationProtection, carried bystackInstanceIdCarriedFrom),src/deployment/deploy-engine/deploy-flow.ts,src/deployment/deploy-engine/options.ts,src/cli/commands/deploy.ts,src/cli/commands/state.ts,src/cli/commands/destroy.ts,src/utils/error-handler.ts,docs/cli-destroy.md,docs/cli-state-writing.md, a new unit file. Before,cdkd state destroy(no CDK app) could not see the setting and deleted a protected stack after the ordinary prompt. Each deploy now records the synthesized stack's setting in its state (root stacks only; a deploy that changes nothing else still saves a toggle), andcdkd state destroyrefuses such a stack withSTACK_TERMINATION_PROTECTION(exit 2) unless--remove-protectionis given.cdkd destroyalso honors the recorded setting, so code that dropped the protection protects nothing only once it is redeployed, as in CloudFormation. The field is additive, so no schema bump; a record an earlier version wrote is unprotected until its next deploy.- A delete failure that is not proof the resource is gone keeps its retries (issue #5228) --
src/provisioning/not-gone.ts(new), the three already-deleted classifiers (src/deployment/deploy-engine/delete.ts,src/deployment/deploy-engine/update-in-place.ts,src/cli/commands/destroy-runner.ts), and the delete guards in the Cloud Map, ECS, CodeCommit, CloudFront, API Gateway Account, Scheduler and Cloud Control providers, with their unit suites and a new unit file. Before, the only way a provider could stop those classifiers reading a needle such asNotFoundin a delete failure's message as "already deleted" was to mark the failure non-retryable. So a guarded failure lost every retry, and a transient failure that carried a needle (a throttle on a logical id likePageNotFound) could not be guarded: once its retries ran out, the classifiers dropped a live resource's state record. Now a dedicated not-gone mark, which the classifiers refuse, carries that verdict on its own. Each guard sets it instead, so a retry the failure qualifies for comes back, and Cloud Control marks a needle-bearing failure whether or not it is transient. - A failed Cloud Control delete is read as "already deleted" only on a typed not-found (issue #5225) --
src/provisioning/cloud-control-provider.ts,tests/unit/provisioning/cloud-control-provider.test.ts, a new unit file. Before, the delete's already-deleted check also matcheddoes not exist,not foundorNotFoundanywhere in the error message. So a live resource's state record was dropped whenDeleteResourcefailed withTypeNotFoundException, when a FAILED delete's handler message named a missing dependency ("subnet ... not found"), or when the logical id itself spelledNotFound. Now only the handler'sErrorCode: NotFound,DeleteResource'sResourceNotFoundException, andInvalidVolume.NotFoundon the EBS volume path count. A failure that is kept and whose message still holds one of those phrases is now marked non-retryable. The engine and the destroy runner check that mark before their own message match, so they no longer drop the record either. A failure that is transient for another reason, such as a throttle, is left unmarked and keeps its retry. - A failed CloudFront Distribution, API Gateway Account or Scheduler Schedule delete whose AWS failure is a
*NotFoundabout something else no longer reads the resource as already deleted (issue #5222) --src/provisioning/providers/cloudfront-distribution-provider.ts,apigateway-provider.ts,scheduler-schedule-provider.ts, and unit cases. Since thrown messages name an AWS failure by its class (issue #2319), a class such as CloudFront'sEntityNotFound,NoSuch*or*DoesNotExist(the disable step's referenced policy, function or key group is gone), API Gateway'sNotFoundExceptionfromUpdateAccount, or Scheduler'sResourceNotFoundExceptionfrom the redacted-GroupNameListSchedulessearch satisfied the "already deleted" substring checks, so a live resource's state record could be dropped, or a replacement created beside it. These wraps are now marked non-retryable, which those checks read first. Cloud Control'sRequestTokenNotFoundExceptionwas already safe: it surfaces as an abandoned wait, which every such check refuses. cdkd destroyre-reads the stack's state under its lock, so a write during the confirmation prompt cannot make it delete another stack's resource (issue #5220) --src/cli/commands/destroy-runner.ts(holdsSameResourcesAs),docs/cli-destroy.md, a new unit file and updated destroy test harnesses. Before, a destroy deleted the resources of the record read BEFORE its confirmation prompt: acdkd refactorthat moved a resource to another stack, or a deploy that replaced one, while the prompt waited left the destroy deleting a resource the record no longer owned, and a resource a deploy added in that time was dropped with the record while it stayed live. Now, under the stack lock, the record is read again (beside the refactor marker, in one concurrent round trip) and the destroy refuses withSTATE_CHANGED, deleting nothing, unless the record still holds exactly the resources it held before.CDKD_SDK_CALL_LOGnow counts S3 calls (issue #5219) --src/utils/sdk-call-log.ts(quietS3Logger),src/utils/aws-clients.ts,src/utils/bucket-region-client.ts,src/deployment/intrinsic-resolver/stack-state.ts, two new unit files. The S3 clients replaced the call log's counting logger with a silent one (to hide the SDK's "Stream of unknown length" warning), so every state, lock, exports-index and refactor-marker call was missing from the log. They now keep the counting logger while the log is on; the warning stays hidden either way.- The Provider Coverage Report and the Tier 3 pre-flight refusal list are current again (issue #5214) --
scripts/audit-provider-coverage.ts(listProvisioningTypes),docs/_generated/provider-coverage.{json,md},src/provisioning/unsupported-types.generated.ts,src/provisioning/stateful-types.ts,docs/_generated/stateful-candidates.{json,md},docs/cli-deploy-safety.md. The report had not been regenerated since 2026-09-04: 220 types AWS publishes were in no tier (among themAWS::Lambda::WebFunction, now Tier 2), and 27 types AWS has since made provisionable (e.g.AWS::AppStream::Fleet,AWS::Config::ConfigurationRecorder) were still refused at pre-flight; they now deploy through Cloud Control. 88 newly published NON_PROVISIONABLE types are now refused up front. The regeneration reads each type's provisioning type from three filteredListTypeswalks instead of oneDescribeTypecall per type. Of the newly covered types, 19 that hold data (snapshots and backups of DocDB Elastic, ElastiCache, FSx, MemoryDB, Redshift and Timestream InfluxDB, Storage Gateway volumes, FSx file caches, Managed Microsoft AD directories, SageMaker notebook instances, Cloud9 EC2 environments, and seven types whose emptiness cannot be proven) join the stateful guard, so a deploy that would replace one asks for--force-stateful-recreationas for the other data-bearing types. - A
cdkd refactorinterrupted mid-move is now safe and finishes itself on the next run (issue #5212) --src/cli/commands/refactor-move.ts,src/cli/commands/refactor.ts,src/state/pending-moves.tsandsrc/state/refactor-marker.ts(new),src/state/s3-state-backend.ts(getRefactorMarker/saveRefactorMarker),src/utils/deferred-interrupts.ts(new),src/types/state.ts(pendingMovesOut),src/deployment/deploy-engine/deploy-flow.ts,src/cli/commands/{deploy,destroy,destroy-runner,diff-recursive}.ts,docs/cli-refactor.md,docs/state-management.md, a new Guides pagedocs/refactor.md, new unit files, and new phases in therefactor-moveinteg. Before, a refactor killed between the state writes of a move left the resource in no cdkd state, and the destination's next deploy created a second one; Ctrl+C also skipped everyfinally, stranding the stack locks. Now SIGINT / SIGTERM is held until the writes finish, and a move is first listed in{prefix}/_refactor/pending.json, then marked in the source record (pendingMovesOut, ids only) while the source keeps the resource, then added to the destination, dropped from the source, and unlisted. While a move is listed,cdkd deployandcdkd destroyof either stack refuse andcdkd diffexits 3; the nextcdkd refactorof either stack finishes it (--dry-runonly lists it). A deploy reads the list per stack beside that stack's state read, so it adds no wait; a destroy reads it under each stack's lock; a denied read counts as no move. The record field is additive, so no schema bump. CDKD_PHASE_TIMINGS=1reports how long resources waited for a--concurrencyslot (issue #5209) --src/deployment/dag-executor.ts,src/deployment/deploy-engine/execute.ts,docs/troubleshooting-slow-deploys.md, the newtests/integration/wide-independentfixture, and unit tests intests/unit/deployment/dag-executor.test.ts. Each stack's report gains aready, waiting for a --concurrency slotline: how long each resource sat with every dependency done before a slot freed, added up, with the longest wait. A stack whose resources depend on each other rarely waits (17 resources: 8 ms in all); a stack of 48 independent resources waited 75 s in all at the default of 10, and its resource DAG took 3.78 s, against 2.09 s at--concurrency 30. Unset, nothing is recorded and nothing changes.cdkd diffand the deploy approval prompt name a removalcdkd refactorwould keep (issue #5203) --src/analyzer/refactor-hints.ts(new),src/analyzer/destructive-changes.ts,src/cli/commands/diff.ts,src/cli/commands/diff-recursive.ts,src/deployment/deployment-approval.ts,docs/cli-diff.md,docs/cli-diff-json.md,docs/refactor.md, a new unit file, and new assertions in therefactor-commandandrefactor-moveinteg fixtures. A DELETE row whose resource reappears under another logical ID, in the same stack or in another selected stack of the same account and region, with the same type and properties and no other candidate, now reads(moved to <Stack>.<LogicalId>, run 'cdkd refactor' to keep it), as the AWS CDK CLI words it. It is on the diff line, the--fail-on=destructivelist,--json(movedTo), and thecdkd deploy --require-approvalprompt (renames within the deployed stack). Rows the diff already holds; no AWS call.cdkd refactormoves a resource between stacks instead of letting the next deploy replace it (issue #5202) --src/cli/commands/refactor-move.ts(new),src/cli/commands/refactor.ts,src/state/logical-id-rename.ts(removeMovedRecords,insertMovedRecords),docs/cli-refactor.md, a new unit file, and the newrefactor-moveinteg. Before, a construct moved to another stack made the source's deploy delete the AWS resource and the destination's deploy create a new one, andcdkd refactorrefused a mapping between two stacks. When a run selects two or more stacks of one account and region, a resource the source no longer declares now moves into the destination stack whose template newly declares one of the same type, when the destination's diff then shows it unchanged; look-alikes are named with--override-file. Both stacks are locked; the source is written first, and a failed write puts the destinations back before any source, so a resource is never recorded in two stacks. A named move to a stack with no state yet, and a stack that would both lose and gain a resource in one run, are refused; a stack with no state is never a detected destination. No AWS resource is touched.- New
cdkd refactorkeeps a resource whose logical ID a construct rename changed, instead of letting the next deploy replace it (issue #5201) --src/cli/commands/refactor.ts,src/analyzer/refactor-pairing.ts,src/state/logical-id-rename.ts,src/cli/program.ts,src/cli/index.ts,docs/cli-refactor.md, three new unit files, and the newrefactor-commandinteg. Before, renaming or moving a construct within a stack madecdkd deploydelete the old logical ID and create the new one, so the AWS resource was replaced and a stateful one lost its data.cdkd refactor [stacks...]pairs each removed logical ID with an added one of the same type and renames the state record when the new resource then shows no change in the same diffcdkd deploycomputes; look-alikes it cannot tell apart are reported as ambiguous and named with--override-file(thecdk refactormapping format,--revertapplies it in reverse). A pair whose properties also changed is refused, as bycdk refactor. Only cdkd state is rewritten, under the stack lock and conditional on the state it planned from; no AWS resource is touched.--dry-runprints the plan. Moving a resource between stacks is not supported yet (#5202). CDKD_PHASE_TIMINGS=1prints where acdkd deployspent its time, and a deploy that updates many resources writes fewer interim state saves (issue #5200) --src/utils/phase-timings.ts(new),src/cli/commands/deploy.ts,src/deployment/deploy-engine/deploy-flow.ts,src/deployment/deploy-engine/execute.ts,src/deployment/deploy-engine.ts,src/state/s3-state-backend.ts,src/provisioning/cloud-control-provider.ts,src/provisioning/providers/dynamodb-table-provider.ts,docs/troubleshooting-slow-deploys.md,docs/state-schema-internals.md, and two new unit files. With the variable set, the deploy ends with a per-phase report: Node startup, synthesis beside the state-bucket preparation, asset builds and publishes, and per stack the lock, the state load, the diff, the resource DAG, each resource's provider call, the interim and final state saves, and the teardown. It makes no AWS call of its own, and unset it costs nothing. Interim state saves after each resource now coalesce: at most one save waits behind the one in flight, and every resource that completes meanwhile joins it, since that save reads the live records when it starts. The saved state after each completion is the same as before; fewer identical writes are queued, so the end of the resource DAG no longer waits for a backlog of them. Three serial round trips now overlap other work: the account lookup that names the asset destinations runs beside synthesis, the rollback journal is read beside the state record, and a first deploy reads itsstack-instance.jsonsibling beside the diff. A first deploy's final save no longer waits for that sibling's removal, which now takes one call (the version it wrote) and runs beside the post-save writes, before the lock is released.cdkd state browse: pick a stack with the keyboard and view its resources or record (issue #5199) --src/cli/commands/state-browse.tsandsrc/cli/interactive-select.ts(new),src/cli/commands/state.ts(printStackResources/printStackShow, the bodies ofstate resources/state show, now shared),docs/cli-state.md, new unit files. Lists the stacks in the state bucket; arrow keys move, typing filters, Enter picks. A picked stack offers its resources, its resources with dependencies and attributes, and its full record, each printed by the same code ascdkd state resources,cdkd state resources --longandcdkd state show. Without a terminal it exits 1 before any AWS call.- A teardown failure that cannot be converted to text no longer stops the cleanup of
cdkd local invoke,cdkd local invoke-agentcoreorcdkd local run-task(src/cli/commands/{local-invoke,local-invoke-agentcore,local-run-task}.ts, go-to-k/cdkd#3361). The cleanup handlers built their message withx instanceof Error ? x.message : String(x), andString()throws for a null-prototype object, so the handler itself threw: oninvokeandinvoke-agentcorea failed container-log drain skipped the response write and stopped the cleanup beforedocker rm -f, and a faileddocker rm -fskipped the remaining tmpdir and credentials removals and the stdio flush; onrun-taska failedcleanupEcsRunskipped the credentials-file dispose that covers it, leaving the file behind. Each now goes throughdescribeAwsFailure(x).detail, byte-identical whenever the value converts and a placeholder only when it cannot; the same rendering in those commands'--event/--env-varsread and parse errors and incdkd local start-api's WebSocketerrorlog is converted too. Tests:tests/unit/cli/{local-invoke-stdout-stream,local-invoke-agentcore-stdout-stream,local-run-task-command-body}.test.ts. - A failure value that cannot be converted to text no longer replaces the Cloud Map partial-create failure or the Lambda create failure, nor drops the Cognito
--remove-protection"left on" warn (src/provisioning/providers/{servicediscovery,lambda-function,cognito}-provider.ts, go-to-k/cdkd#3361). These helpers took the caught value as a PARAMETER, where the catch-scoped sweep did not look, and rendered it withString(), which throws for a null-prototype object. So aTypeError: Cannot convert object to primitive valuereplaced the ServiceAttributes failure a Cloud Map create rethrows when its cleanupDeleteServicerejected with such a value, replaced the Lambda create failure, and, when Cognito refused the settings echo with such aname, threw out of the best-effort flip so the warn naming why DeletionProtection stayed on was never printed; the Cognito MFA-configuration read and restore did the same with the failure'sname, losing the read's fail-open answer and the restore warn. That--remove-protectionwarn now also masks the failure text it prints when AWS gave no answer, which is the raw message.describeAwsFailure(src/utils/aws-failure-text.ts) itself threw the same way for an AWS-authored error whosenamecannot be converted, since its summary interpolated thename; it now renders a placeholder there (unchanged for a stringname). ACM's in-use classifier (src/provisioning/providers/acm-certificate-provider.ts) is converted as defence in depth:delete()always hands it anErrortoday. Each now goes throughdescribeAwsFailure(x).detailorsafeStringify(x), byte-identical for every valueString()converts and a placeholder otherwise. - A corrupt exports index no longer prints its contents, and the last state / utils / types catches stop out-throwing an unconvertible value (issue #3361) --
src/state/export-index-store.ts,src/state/s3-state-backend.ts,src/state/s3-replication-purge-gap.ts,src/utils/role-arn.ts,src/utils/assembly-path.tsandsrc/types/rollback-journal.ts, with a new unit file and the replication-check suite. When the exports index failed to parse, theExports index corrupt (...)warning quoted the JavaScript parser's message, which repeats part of the index body, and the index holds resolved Output values. It now reports only the size, plus the position when the parser states one, as the read-only path already did. Separately, these catches and the helpers they call built their message withx instanceof Error ? x.message : String(x), which throws for a value that cannot be converted to text (a null-prototype object), so the failure being reported was replaced by an unrelatedTypeError. That cost the corrupt-index rebuild, and the replication check's own report on a failed probe. These now print a fixed placeholder for such a value. Text for every other value is unchanged. - A failure value that cannot be converted to text no longer replaces the failure being reported in the macro-expansion, asset-publishing and retry-mark handlers (
src/synthesis/{macro-expander,failed-stages,assembly-reader}.ts,src/assets/{asset-manifest-loader,file-asset-publisher}.ts,src/analyzer/security-diff.ts,src/cli/{yaml-cfn,options}.ts,src/cli/commands/retire-cfn-stack.ts,src/deployment/retryable-errors/marks.ts, go-to-k/cdkd#3361).String()throws for a null-prototype object, and these handlers built their message with it, so aTypeError: Cannot convert object to primitive valuetook the place of the handler's own error when an SDK call rejected with such a value: a rejected macro-expansionCreateChangeSet, a failed S3HeadObjectasset check (which also threw on anullrejection), and a provider failure thatwrapMaskedAwsErrorwraps. The Stage-scoped synthesis error, the cloud-assembly and asset-manifest read failures, the security-diff failure (SECURITY_DIFF_FAILED), the template-parse failures and the--resource-timeoutper-type parse failure are converted as defence in depth: nothing known today throws them a non-Error. Each now goes throughdescribeAwsFailure(x).detailorsafeStringify(x), byte-identical for every valueString()converts and a placeholder otherwise. Test:tests/unit/string-outthrow-3361.test.ts. - The SDK provider attaches a VPN gateway to a VPC itself:
AWS::EC2::VPCGatewayAttachment.VpnGatewayId(issue #2762) --src/provisioning/providers/ec2-provider.ts, the regenerated property-coverage map,tests/fixtures/cfn-schemas/_todo-backfill.json, a new unit file, the physical-id rows indocs/import-resource-types.md/docs/state-management-physical-ids.md/docs/state-schema-internals.md, and a new VPN gateway arm in theec2-route-targetsinteg. Before, an attachment namingVpnGatewayIdrouted through Cloud Control. It now sendsAttachVpnGatewayand waits for the attachment to beattached, so a route through the gateway can be created next. The record is<vgwId>|<vpcId>. Destroy sendsDetachVpnGatewayand waits fordetached, because AWS refuses to delete the VPN gateway or the VPC until then. Drift reads the attachment withDescribeVpnGateways.--migrate-from-cloudformationandcdkd importnow adopt a CloudFormationVGW|<vpcId>attachment. A template naming bothInternetGatewayIdandVpnGatewayId, or neither, is refused before any AWS call: CloudFormation's documentation allows exactly one of the two. ChangingInternetGatewayId/VpnGatewayIdnow swaps the attached gateway in place, as CloudFormation does (issue #5216). Before, it was refused, and--replacecould not help because AWS allows one gateway of each kind per VPC. A record naming only the attachment type (VGW|<vpcId>) resolves its gateway from the VPC. An attachment Cloud Control already created stays on Cloud Control. - A failed Cloud Control create, update or delete keeps the handler's message but no longer writes the caller's principal ARN or account id into the persisted deployment events (issue #2319) --
src/provisioning/cloud-control-provider.tsandtests/unit/provisioning/cloud-control-statusmessage-identity-2319.test.ts. The handler'sStatusMessage, and the text of a rejectedCreateResource/UpdateResource/DeleteResource, is what a user fixes the template from (Property validation failure: ...), so it stays in the message. A denial in it quoted the caller (User: arn:aws:sts::<account>:assumed-role/<role>/<session> is not authorized ...), anddeployments/{runId}.jsonloutlives the run. Now an STS session ARN, an IAM user / role / root ARN in the denied-principal position, and a principal's unique id read<caller-principal>, and an account id in an ARN, a name, a path, a host, or after a word such asaccountreads<account-id>; a resource ARN keeps its service, region and name, and a free-standing 12-digit size or template value is kept. The unmasked text is printed at--verbose. Name-collision, retry, already-deleted and downstream-403 hint classification is unchanged. - A failed ECR repository, SQS queue, SNS topic, Step Functions state machine, WAFv2 web ACL or CodeBuild project create, update or delete no longer writes the caller's account id, assumed-role ARN and session name into the persisted deployment events (issue #2319) --
src/provisioning/providers/ecr-provider.ts,sqs-queue-provider.ts,sns-topic-provider.ts,stepfunctions-provider.ts,wafv2-provider.ts,codebuild-provider.ts, and unit cases. The thrown message quoted AWS's raw text, which names the caller on an access denial, anddeployments/{runId}.jsonloutlives the run. The message now carries the error class (for exampleAccessDeniedException. Re-run with --verbose for AWS's own message.), and AWS's text goes to the debug log, through the operation's secret masker. A cdkd-authored or transport error keeps its text, masked. The warning for a failed cleanup of an SNS topic left behind by a failed create names the class too. The AWS error is still the wrap'scause, so an IAM grant that is still propagating and an SQS queue name still in its 60-second cooldown are retried as before, and an "already exists" from the create call is still stated as such. A delete failure whose class names aNotFoundother than the resource's own (a missing KMS key, for example) is marked as not proving the resource gone, so the class now in the message is not read as "already deleted". - A failed resource whose AWS error reaches the deploy engine unwrapped, and three engine replacement refusals, no longer write the caller's account id, assumed-role ARN and session name into the persisted deployment events (issue #2319) --
src/types/deployment-events.ts,src/deployment/deploy-engine/update-replace.ts,update-in-place.ts, and unit cases. When a provider lets AWS's own error escape,deployments/{runId}.jsonlrecorded AWS's text as the event message, and AWS names the caller on an access denial. The recorder now writes the error class instead (for exampleAccessDenied. Re-run with --verbose for AWS's own message.), next to theawsErrorCodeandrequestIdit already kept. The same applies to the--recreate-via-*destroy failure, the create-first collision refusals of a replacement, and the create failure after the update-not-supported fallback: each names an AWS-authored failure by its class, with AWS's text in the debug log through the same secret masker and on the error'scause. A cdkd-authored or transport error keeps its text. Two refusals get the same treatment: the--strict-getattoutput refusal, and the refusal of an AWS CDK-generated cross-accountFn::GetStackOutputwhose lookup role was denied. The lookup role's ARN stays in that message.cdkd eventsalso stops printing the class twice on such a row. - A failed Kinesis stream, EventBridge rule or CloudFront origin access identity create, update or delete no longer writes the caller's account id, assumed-role ARN and session name into the persisted deployment events (issue #2319) --
src/provisioning/providers/kinesis-provider.ts,eventbridge-rule-provider.ts,cloudfront-oai-provider.ts, unit cases, and arollback-commandinteg check. The thrown message quoted AWS's raw text, which names the caller on an access denial, anddeployments/{runId}.jsonloutlives the run. The message now carries the error class (for exampleAccessDenied. Re-run with --verbose for AWS's own message.), and AWS's text goes to the debug log, through the operation's secret masker. This includes a failure afterCreateStreamreturned (a denied status read while waiting forACTIVE) and an EventBridge rule whose targets step failed afterPutRule, which still name the stream or rule left behind forcdkd rollback --revert-failed; the warning for a failed cleanup of that rule names the class too. A cdkd-authored or transport error keeps its text, masked. The AWS error is still the wrap'scause, so an IAM grant that is still propagating is retried as before, and an "already exists" is still stated as such. - A refused name check before a create or a replacement no longer writes the caller's account id, assumed-role ARN and session name into the persisted deployment events (issue #2319) --
src/deployment/deploy-engine/replacement.ts,create.ts, and unit cases. For a type whose create API hands back or overwrites an existing resource of the same name, cdkd looks the name up first and refuses when the lookup fails; that refusal ("cdkd could not check whether another resource already holds it") quoted AWS's raw text, which names the caller on an access denial, anddeployments/{runId}.jsonloutlives the run. The message now carries the error class (for exampleAccessDenied. Re-run with --verbose for AWS's own message.), and AWS's text goes to the debug log, through the same secret masker. A cdkd-authored or transport error keeps its text, masked. The same holds for the two--replacedelete-first fallback failures ("Failed to delete old resource ..." and "Failed to re-create ... Cause: ...") when the provider let an AWS SDK error through. The AWS error is still thecause. - A failed ELBv2 load balancer, target group or listener, Firehose delivery stream, CloudWatch alarm or anomaly detector, CloudFront origin access control, S3 Express directory bucket, S3 vector bucket, Kinesis stream consumer, or Bedrock AgentCore runtime, evaluator, default browser or default code interpreter no longer writes the caller's account id, assumed-role ARN and session name into the persisted deployment events (issue #2319) --
src/provisioning/providers/{elbv2,firehose,cloudwatch-alarm,cloudwatch-anomaly-detector,cloudfront-oac,s3-directory-bucket,s3-vectors,kinesis-streamconsumer,agentcore-runtime,agentcore-evaluator,agentcore-browser,agentcore-code-interpreter}-provider.tsunit cases, andremove-protection/created-before-failure-revertinteg checks. The create, update and delete failure messages quoted AWS's raw text, which names the caller on an access denial, anddeployments/{runId}.jsonloutlives the run. The message now carries the error class (for exampleAccessDenied. Re-run with --verbose for AWS's own message.), and AWS's text goes to the debug log, through the operation's secret masker. A cdkd-authored or transport error keeps its text, masked. The AWS error is still the wrap'scause, so an IAM grant that is still propagating, or a throttle, is retried as before, a--remove-protectionload balancer delete still decides between retrying and restoring the guard on AWS's text, and an "already exists" is still stated as such (but not for a load balancer, target group or listener whose post-create wiring step failed). A delete failure whose class is another resource's not-found (for exampleKMSNotFoundException) no longer reads as "already deleted": the state record is kept. The ELBv2 partial-create cleanup warning names an AWS failure by its class too, and no longer fails on a rejection value that cannot be converted to text. - A failed Lambda function, permission, layer version or event invoke config no longer writes the caller's account id, assumed-role ARN and session name into the persisted deployment events (issue #2319) --
src/provisioning/providers/{lambda-function,lambda-permission,lambda-layer,lambda-event-invoke-config,s3-bucket-policy,appsync,glue,s3-tables,s3-bucket,servicediscovery}-provider.tsunit cases. The create, update and delete failure messages quoted AWS's raw text, which names the caller on an access denial, anddeployments/{runId}.jsonloutlives the run. The message now carries the error class (for exampleAccessDenied. Re-run with --verbose for AWS's own message.), and AWS's text goes to the debug log, through the operation's secret masker. A cdkd-authored or transport error keeps its text, masked. The S3 bucket policy wrap, which already named the class, now masks it and the cdkd-authored text too, and restates an "already exists". The AWS error is still the wrap'scause, so an IAM grant that is still propagating, a throttle or a Lambda function still updating is retried as before, and an "already exists" is still stated as such, except from a post-create step of a Lambda function, AppSync GraphQL API or S3 bucket. A delete failure whose class is another resource's not-found (for exampleKMSNotFoundException) no longer reads as "already deleted" for these providers or for the S3 bucket policy, AppSync, Glue, S3 Tables and S3 bucket deletes: the state record is kept. A failed S3 bucket delete now masks a secret spelled in the error class too. The Lambda function post-create cleanup lines, the AppSync GraphQL API rollback warning and the Cloud Map service cleanup warning name an AWS failure by its class too. - A failed Cloud Map namespace or service, ECS cluster, task definition or service, or CodeCommit repository create, update or delete no longer writes the caller's account id, assumed-role ARN and session name into the persisted deployment events (issue #2319) --
src/provisioning/providers/servicediscovery-provider.ts,ecs-provider.ts,codecommit-repository-provider.ts, and unit cases. The thrown message quoted AWS's raw text, which names the caller on an access denial, anddeployments/{runId}.jsonloutlives the run. The message now carries the error class (AccessDeniedException. Re-run with --verbose for AWS's own message.), and AWS's text goes to the debug log, masked through the operation's secret masker. This also covers a Cloud Map operation that AWS reports as FAIL (the message names itsErrorCodewhen AWS reports one), the ECS service--full-waitsteady-state failure (the SDK waiter's result, which lists every DescribeServices answer and failure text, is reduced to its state), the CodeCommit trigger put beside kept live triggers, and the free-text detail of a DescribeClusters failure during an ECS cluster name lookup. A cdkd-authored or transport error keeps its text. The AWS error stays as thecause, so an IAM grant that is still propagating is retried as before, a name that is taken still reads as a name collision, and a CodeCommit delete refused for a missing KMS key (EncryptionKeyNotFoundException) is never read as an already-deleted repository. - A failed SNS Subscription create, update or delete, a rollback's reverse replacement, or an
Fn::GetAZslookup no longer writes the caller's account id, assumed-role ARN and session name into the persisted deployment events (issue #2319) --src/provisioning/providers/sns-subscription-provider.ts,src/deployment/rollback-executor/replay-reverse-replacement.ts,src/deployment/intrinsic-resolver/functions.ts,src/deployment/work-graph.ts, and unit cases. These messages quoted AWS's raw text, which names the caller on an access denial, anddeployments/{runId}.jsonloutlives the run. They now carry the error class (AuthorizationErrorException. Re-run with --verbose for AWS's own message.), and AWS's text goes to the debug log, masked. In a rollback this covers the re-create of the old resource after the new one was deleted, theUnderlying collision:line of a refused reverse replacement, and the reason recorded for a new resource that could not be deleted. TheN node(s) failedsummary at the end of a deploy names an AWS-authored failure (an asset upload'sAccessDenied) the same way; itsFailed:line above still prints AWS's text. A cdkd-authored or transport error keeps its text, and the AWS error stays as thecause, so a throttle or a still-propagating IAM grant is retried as before and a taken subscription still reads as a name collision. - A failed SSM parameter create, update or delete no longer writes the caller's account id, assumed-role ARN and session name into the persisted deployment events, and the SSM parameter's Arn-attribute warning, the CloudTrail trail's skipped event-selector reset warning, and the partial-create / best-effort cleanup warnings of the IAM user and group, FSx file system and EMR cluster / instance group / instance fleet providers name an AWS failure by its class (issue #2319) --
src/provisioning/providers/{ssm-parameter,kinesis,cloudtrail,iam-user-group,fsx-filesystem,emr-cluster,emr-instance-group-config,emr-instance-fleet-config}-provider.tsunit cases. The SSM failure message now carries the error class (for exampleAccessDenied. Re-run with --verbose for AWS's own message.), and AWS's text goes to the debug log through the operation's secret masker; the AWS error is still the wrap'scause, so a propagating grant or a throttle is retried as before, and an "already exists" is still stated as such. A cleanup warning shows the class at warn and AWS's text at debug, masked; an EMR cluster that terminated with errors shows the state-change reason's code at warn. An SSM parameter, Kinesis stream, CloudTrail trail or FSx file system delete failure whose class is another resource's not-found no longer reads as "already deleted": the state record is kept. A CloudTrail update whose event-selector reset is denied on a trail whose name contains "advanced event selector" now fails instead of being skipped. A Kinesis stream, CloudTrail trail, IAM user or IAM group whose post-create step fails with "already exists" no longer adds "AWS reports that it already exists", which read as the resource's own name collision. AWS::FSx::FileSystem: the warning for a removedStorageCapacity,StorageTypeorNetworkTypenow says CloudFormation keeps the value too (issue #1160) --src/provisioning/providers/fsx-filesystem-provider.ts. A CloudFormation A/B on the issue (us-east-1, 2026-10-23) removed each property in its own update. Every update completed in place, with the same file system and no FSx administrative action. An OpenZFSSINGLE_AZ_1file system keptNetworkTypeDUAL,StorageCapacityandStorageTypeSSD. A LustrePERSISTENT_1file system keptStorageTypeHDDandStorageCapacity. cdkd already left these values in place, so only the warning changes: it now reads... and keeps its current AWS value, as CloudFormation leaves it; .... The claim is made only for the file system types measured (Lustre and OpenZFS, andNetworkTypeon OpenZFS only), and never for anINTELLIGENT_TIERINGfile system. A warning that names an unmeasured property, such as a Windows or ONTAP removal or a LustreNetworkType, keeps its old wording. Tests:tests/unit/provisioning/providers/fsx-filesystem-provider.test.ts; thefsx-openzfsinteg gains a phase that removes all three.AWS::FSx::FileSystem: the warning for a removed OpenZFSThroughputCapacity,ReadCacheConfigurationorEndpointIpv6AddressRangenow says CloudFormation keeps the value too, on the shapes measured (issue #1160) --src/provisioning/providers/fsx-filesystem-provider.ts. A CloudFormation A/B on the issue (us-east-1, 2026-10-24) removed each property from an OpenZFSMULTI_AZ_1file system. Every update completed in place, with the same file system, the value unchanged and no FSx administrative action:ThroughputCapacityonSSDand onINTELLIGENT_TIERING,ReadCacheConfigurationonINTELLIGENT_TIERING, andEndpointIpv6AddressRangeon a dual-stackSSDfile system in the same update that removedNetworkType. cdkd already sent nothing for these removals, so only the warning changes: it now reads... and keeps its current AWS value, as CloudFormation leaves it; ...on those shapes, forThroughputCapacityandReadCacheConfigurationonly as the deploy's single removal withNetworkTypenot declaredDUAL(CloudFormation refused every update while it was). Any other shape keeps the line that claims nothing about CloudFormation: aSINGLE_AZ_*file system, a deploy that also changesStorageType, anEndpointIpv6AddressRangeremoval whileNetworkTypestays declared, and, onMULTI_AZ_1, aNetworkTyperemoval whileEndpointIpv6AddressRangestays declared (CloudFormation refused both). Tests:tests/unit/provisioning/providers/fsx-filesystem-provider.test.ts; thefsx-openzfsinteg gains a phase that removesThroughputCapacityon itsSINGLE_AZ_1file system and asserts the value stays and the warning makes no CloudFormation claim.
Recently Implemented (2026-10-23):
- An RDS, Neptune or DocDB DB instance update that AWS refuses because the instance is not
availableis retried once it readsavailableagain (issue #5194) --src/provisioning/providers/db-instance-modify-retry.ts(new),src/provisioning/providers/neptune-provider.ts,src/provisioning/providers/docdb-provider.ts,src/provisioning/providers/rds-provider.ts, and a new unit file. A deploy that changed a DB cluster (for example by removing itsVpcSecurityGroupIds) and a member instance together could fail withDatabase instance is not in available state.: the cluster change moves the member tomodifyinga moment after the cluster update returns, and the instance'sModifyDBInstancewas sent before that showed up. CloudFormation lets the cluster change settle first, so the same template succeeded there. Now only that refusal (InvalidDBInstanceStateFault/InvalidDBClusterStateFaultwith the not-available wording) is retried. cdkd waits out any transitional status and resends, for up to 30 minutes or what--resource-timeoutleaves, whichever is less. A terminal status (failed,incompatible-*, ...) fails at once and names the status. An update AWS accepts makes no call beyond the oneModifyDBInstance. A modify AWS answered with success is never resent; a request the SDK replayed after a lost response can be resent once with the same values. cdkd driftcomparesAWS::Budgets::Budgetagain, through the provider's own read-back (issue #5183) --src/provisioning/providers/budgets-budget-provider.ts(readCurrentState,getDriftUnorderedPaths,getDriftUnknownPaths,canonicalizeDriftProperties),src/analyzer/drift-cc-api-deny-list.ts,docs/cli-drift.md,docs/cli-drift-internals.md, a new unit file, and thebudgetsinteg. Since #5179 every budget was reported as drift unknown (? CostBudget), because the Cloud Control READ response diffed on an unchanged budget. The provider now reads the budget withDescribeBudget,DescribeNotificationsForBudget, oneDescribeSubscribersForNotificationper notification andListTagsForResource, mapped to the template shape: amounts as numbers, dates as ISO strings, and members AWS changes on its own (CalculatedSpend,LastUpdatedTime,HealthStatus) left out. Notifications and their subscribers are compared as sets. An absentThresholdTypereads as AWS's defaultPERCENTAGEon both sides.Budget.BudgetLimitis not compared on a budget declaringAutoAdjustDataorPlannedBudgetLimits, where AWS sets it. The deny-list entry is removed. Like every provider with a read-back, a deploy that creates or updates a budget now captures itsobservedPropertiesoff the critical path;--no-capture-observed-stateturns that off.cdkd local start-apiserves several stacks that reuse a construct id, each with its own Lambdas, Stage and CORS (issue #5181) --src/cli/commands/local-start-api.ts,tests/integration/local-invoke-stage/. Take two stacks with their own physical names that each declarenew HttpApi(this, 'Api')backed by aHandlerLambda, served together with--stack 'Svc*'. Before #5177, one stack's Lambda, Stage variables or CORS settings answered the other stack's routes. #5177 made start-api refuse that set instead. Now the container specs and the Stage and CORS maps are keyed by stack and logical ID, so each stack's API is served from its own handler with its own Stage and CORS. Two refusals remain. start-api still refuses two served stacks that share one physical stack name (#5175). It also refuses a bare logical ID in--assume-role <LogicalId>=<arn>or an--env-varskey when more than one served stack declares that Lambda. The error names the stacks, and for--env-varsit suggests the stack-qualified construct-path key, such as"SvcA/Handler".- A multi-region app that deploys one CDK Stage per region under a shared
stackNameis now deployed, diffed, listed and destroyed in every region, as the AWS CDK CLI does (issue #5176) --src/cli/stack-matcher.ts,src/analyzer/cross-stack-deps.ts,src/cli/commands/{deploy,destroy,diff,list,synth,publish-assets,scrub,local-run-task}.ts,src/deployment/intrinsic-resolver/cross-stack.ts,docs/cross-stack-internals.md, unit cases and theshared-stackname-multi-regioninteg fixture. Before,cdkd deploy '**'(anddiff,list,synth,publish-assets,scrub) kept one stack per physical name and silently deployed only the first region's.cdkd destroy UsWest/Apidestroyed the FIRST Stage's stack (us-east-1). Now each selected stack is handled under its own name and region: deploy work-graph nodes, asset nodes, dependency edges and destroy targets. A dependency resolves to the producer in the dependent's own Stage, or to the stack that exports an imported name. A name shared by two app stacks is destroyed only where its selected stack lives. Two selected stacks with the same name AND region (or a selected one and an unselected one pinning that region in a possibly-equal account) would be one CloudFormation stack, so they are refused before any lock or engine runs, with both CDK paths named. destroy skips a shared-name stack pinned to another account than the run's.Fn::ImportValuenow prefers a producer in the consumer's own region. A cross-region import with no same-region producer resolves as before.cdkd listwithout a pattern no longer drops a same-named stack.cdkd synth --verbosewrites<name>.<region>.template.jsonfor a shared name.scrub --allreads each shared-name stack in its own region.cdkd local run-taskno longer takes the first Stage's state for a shared name. - A failed Neptune DB subnet group, DB cluster or DB instance, or DocumentDB DB subnet group, DB cluster or DB instance create, update or delete no longer writes the caller's account id, assumed-role ARN and session name into the persisted deployment events (issue #2319) --
src/provisioning/providers/neptune-provider.ts,docdb-provider.ts,docdb-subnet-group-provider.ts,docdb-shared.ts, and unit cases. The thrown message quoted AWS's raw text, which names the caller on an access denial, anddeployments/{runId}.jsonloutlives the run. The message now carries the error class (for exampleAccessDenied. Re-run with --verbose for AWS's own message.), and AWS's text goes to the debug log, through the operation's secret masker. This includes a failure afterCreateDBCluster/CreateDBInstancereturned (a denied status read while waiting foravailable), which still names the cluster or instance left behind forcdkd rollback --revert-failed. A cdkd-authored or transport error keeps its text, masked. The AWS error is still the wrap'scause, so an IAM grant that is still propagating is retried as before, and an "already exists" is still stated as such.
Recently Implemented (2026-10-22):
cdkd destroy --remove-protectionno longer fails when AWS refuses the final-snapshot delete right after the guard change on an RDS / DocDB / Neptune DB cluster or a standalone RDS DB instance (issue #5184) --src/provisioning/providers/deletion-protection-compensation.ts(deleteAfterProtectionFlipWithFinalSnapshot),rds-provider.ts,docdb-provider.ts,neptune-provider.ts,tests/unit/provisioning/providers/deletion-protection-compensation.test.ts. Before this change, the wait added by #5155 could end on its first read. AfterDeletionProtectionwas turned off, AWS could keep reading the resourceavailable, then move it tomodifyingand refuse the delete ("Can't create a snapshot because the database cluster ... isn't currently in the available state"). The destroy failed and the guard was put back. Now a delete AWS refuses that way is retried once the resource readsavailableagain, within the same 15-minute cap and resource timeout. Both before the delete and between retries, cdkd waits through any status that is neitheravailablenor terminal (modifying,configuring-enhanced-monitoring,backing-up, ...). A terminal status (stopped,failed,inaccessible-*, ...) is not waited on, and neither isstorage-optimizationorstorage-initialization, which AWS documents as still available. Any other refusal still fails at once, so a cluster that still has members is not retried. A read of protection off andavailablebefore any such status has shown is held for up to 30 seconds from the first read showing protection off. As before, all of this runs only when this destroy turned the guard off and a final snapshot is requested.--remove-protection --verbose(oncdkd destroy,cdkd state destroyand rollback) now masks secret values in the debug line printed when cdkd cannot read a deletion guard before turning it off (issue #5180) --src/provisioning/providers/deletion-protection-compensation.ts, its callers insrc/provisioning/cloud-control-provider.tsandsrc/provisioning/providers/{asg,cognito,docdb,elbv2,emr-cluster,logs-loggroup,neptune,rds}-provider.ts,src/provisioning/providers/dynamodb-table-provider.ts, and unit cases. The lineCould not read <guard> on <physical id> before disabling it: <AWS's text>was printed without the delete's secret masker. A physical id can come from a dynamic reference ({{resolve:secretsmanager:...}}), and AWS's text quotes it, so--verboseoutput could show that plaintext. The same applied to the debug line printed when the guard could not be turned off, for an Auto Scaling group, load balancer, log group, Cognito user pool (whose AWS text can quote an echoed template value; also its failed write-back when the compensation restores the guard), DocDB cluster, Neptune cluster or instance, and DynamoDB table (also its wait after the flip). These lines now go through the masker. They are printed only with--verboseand are never written to the state bucket. The EC2 instance path is unchanged: its id is assigned by EC2 and cannot hold a template value.cdkd driftno longer reports false drift on an unchangedAWS::Budgets::Budget, or a permanent read failure on anAWS::EMR::InstanceFleetConfig/InstanceGroupConfig(issue #5179) --src/analyzer/drift-cc-api-deny-list.ts,docs/cli-drift.md,tests/unit/cli/drift-per-resource-failure.test.ts,tests/integration/budgets/verify.sh. cdkd has no read-back of its own for these three types, so drift asks Cloud Control. Cloud Control used to have no READ handler for them, and drift reported each one as drift unknown. AWS has since added handlers. For a budget, the response lists notification subscribers in a different order and adds the defaultThresholdType: PERCENTAGE, soNotificationsWithSubscribersshowed as drift on every run. For the EMR types, Cloud Control needs the cluster id together with the fleet or group id, and cdkd records only the latter. It refused every read, and drift exited 2. Drift now reports all three as drift unknown again and exits 0 for them.cdkd local invoke/run-task/start-apirefuse a target naming a physical stack name that two CDK Stage stacks share, instead of running the first Stage's code (issue #5175) --src/cli/stack-matcher.ts,src/local/lambda-resolver.ts,src/local/ecs-task-resolver.ts,src/cli/commands/local-start-api.ts,tests/integration/local-invoke-stage/. Take an app with two Stages that each declarenew Stack(stage, 'Api', { stackName: 'SharedApiStack' }). Before this change,cdkd local invoke SharedApiStack:Fn(orSharedApiStack/...) silently ran the first Stage's function, andcdkd local start-api --stack SharedApiStackserved only the first Stage. Now invoke and run-task refuse withmatched 2 stacks, naming both stacks by their CDK paths, and say to address one by its path (Beta/Api/...). start-api refuses to serve two stacks that share a physical name, because it reads deployed state by that name. It also refuses any served set in which two stacks declare the same routed Lambda or API logical id. Its container and stage / CORS maps are keyed by the bare logical id, so one stack's Lambda used to answer the other's routes. That case includes--stack '**'over a Stage deployed twice. Each error names the stacks and tells you to pass--stack '<CDK path>'. Separately,deploy,diff,list,synth,publish-assetsandscrubgiven a CDK path such asProd/Apinow select that stack. They used to select the first stack carrying the same physical name,Beta/Api.cdkd local start-service/start-alb/start-cloudfront/invoke-agentcore/start-agentcorenow resolve a stack inside acdk.Stageby its display path, and a bad--max-tasks/--restart-policyvalue is reported like cdkd's own option errors (issues #5162, #5163) --package.json(cdk-local^0.153.2->^0.154.0),tests/unit/cli/local-start-service.test.ts,tests/unit/cli/local-embed-logger-stdout.test.ts,tests/integration/local-invoke-container/verify.sh,tests/integration/local-invoke-from-state/verify.sh. Before this change, those commands took a path-form target's first/-segment as the stack, matched a slash-free stack pattern as a glob over the physical name only, and kept one of two stacks sharing a physical name. Now the longest display-path prefix wins, a pattern matches the hierarchical id first and the exact physical name second (ascdkd deploydoes), and two stacks sharing a physical name are both kept, so a command needing one stack reports the ambiguity instead of taking the first. TheirAvailable stackslists showPhysical (Display/Path)when the two differ. A value such as--max-tasks 84used to be rethrown as a cdkd error; it now printserror: option '--max-tasks <n>' argument '84' is invalid. --max-tasks 84 exceeds ...and exits 1, with the same message text. The local engine's log lines also change: a continuation line of a multi-line message starts with two spaces, a control character renders as\uXXXXwhere cdkd showed a space, a plain identifier loses the quotes it used to carry (Lambda 'Fn'->Lambda Fn), and an argv element on the--verbosedocker build and docker run lines that carries a*is quoted (--build-arg "KEY=***",-e "KEY=***").cdkd local start-service/start-alb--image-override <service>=<dockerfile>now matches a service by itsServiceName(or its construct id when it sets none), logical id orStack:LogicalId, and--strict-overridesrefuses before any service starts (issues #5156, #5157) --package.json(cdk-local^0.153.1->^0.153.2),docs/local-start-service-image-overrides.md,tests/integration/local-start-alb-from-state/. Before this change, only the exact target string matched as<service>. Understart-alb, whose services are keyedStack:LogicalId, a key such asWebService=./Dockerfilematched nothing: cdkd warned, then pulled and ran the registry image, so local edits never reached the container. The same held for the per-service--image-build-arg <svc>:K=V,--image-build-secret <svc>:id=srcand--image-target <svc>=stageforms. Now each of those names matches. A name shared by two registry-image services, or two keys for one service, is refused with an error.--strict-overridesused to refuse only after the replicas had booted; it now refuses first. The bump also changes one proxy detail inside the local engine: when every proxy variable set names a scheme it cannot use (for example onlyALL_PROXY=socks5://...), its AWS clients take the SDK's default handler and the one-timeUnsupported proxy schemewarning is printed when the client is built. Requests still go direct, as before.cdkd destroy --remove-protectionno longer fails on a protected RDS / DocDB / Neptune DB cluster or a standalone RDS DB instance that takes a final snapshot (issue #5155) --src/provisioning/providers/rds-provider.ts,docdb-provider.ts,neptune-provider.ts,tests/unit/provisioning/remove-protection-final-snapshot-wait-5155.test.ts. Before this change, cdkd turnedDeletionProtectionoff and sent the delete at once. When the delete took a final snapshot (DeletionPolicy: Snapshot, or noDeletionPolicy), AWS could refuse it because the resource was stillmodifyingfrom that change ("Can't create a snapshot because the database cluster ... isn't currently in the available state"). The destroy then ended partial and the guard was put back. cdkd now waits until the resource reads protection off andavailablebefore such a delete. The wait runs only when this destroy turned the guard off and a final snapshot is requested, so no other delete takes longer. If the resource is notavailablewithin 15 minutes, the delete is not sent, the guard is put back, and the error says to re-run the destroy once it is.- Updating a DocDB / RDS DB cluster or an RDS DB instance no longer resets its master password when the password did not change (issue #5153) --
src/provisioning/providers/docdb-provider.ts,rds-provider.ts,tests/unit/provisioning/db-master-password-resend-5153.test.ts,tests/integration/docdb-neptune/verify.sh. Before this change, every update of anAWS::DocDB::DBCluster,AWS::RDS::DBClusterorAWS::RDS::DBInstancethat declaresMasterUserPasswordsent the password again, even when only another property changed. AWS treats a supplied password as a credential reset, so a DocDB cluster enteredresetting-master-credentialsafter, for example, aPreferredMaintenanceWindow-only change. cdkd now sends the password only when the template changed it, as CloudFormation does. The check compares the templates as written, so a secret is compared by its{{resolve:...}}reference, never by its value: a secret rotated under a reference the template spells literally is not sent again, as in CloudFormation. On RDS,ManageMasterUserPasswordand theMasterUserSecretKMS key are likewise sent only with a master-credential change. A password fed by aNoEchoparameter is still sent on every update, because cdkd keeps no value to compare it with. - A failed RDS DB proxy target group create, update or delete no longer writes the caller's account id, assumed-role ARN and session name into the persisted deployment events (issue #2319) --
src/provisioning/providers/rds-dbproxy-targetgroup-provider.ts, and unit cases. The thrown message quoted AWS's raw text, which names the caller on an access denial, anddeployments/{runId}.jsonloutlives the run. The message now carries the error class (for exampleAccessDenied. Re-run with --verbose for AWS's own message.), and AWS's text goes to the debug log, through the operation's secret masker. The same holds for the failed cleanup a create appends when it cannot deregister the targets it registered; the manualaws rds deregister-db-proxy-targetscommand is still printed. A cdkd-authored or transport error keeps its text. The AWS error is still the wrap'scause, so an IAM grant that is still propagating is retried as before, and an "already exists" is still stated as such. - A failed IAM user, IAM group, IAM UserToGroupAddition, IAM inline policy or IAM instance profile create, update or delete no longer writes the caller's account id, assumed-role ARN and session name into the persisted deployment events (issue #2319) --
src/provisioning/providers/iam-user-group-provider.ts,iam-policy-provider.ts,iam-instance-profile-provider.ts, and unit cases. The thrown message quoted AWS's raw text, which names the caller on an access denial, anddeployments/{runId}.jsonloutlives the run. The message now carries the error class (AccessDenied. Re-run with --verbose for AWS's own message.), and AWS's text goes to the debug log, masked through the operation's secret masker; the instance profile, inline policy and UserToGroupAddition delete messages now go through that masker too. A cdkd-authored or transport error keeps its text. The AWS error stays as thecause, so an IAM grant that is still propagating is retried as before and a taken user, group or instance profile name still reads as a name collision. ADeleteUserthat finds the user already gone (NoSuchEntity) now reads as already deleted, as CloudFormation treats it, instead of failing the delete. - A failed CloudWatch Logs log group, EventBridge Scheduler schedule, DLM lifecycle policy or EventBridge Pipes pipe operation, and a failed detach of an RDS cluster from its global cluster, no longer write the caller's account id, assumed-role ARN and session name into the persisted deployment events (issue #2319) --
src/provisioning/providers/{logs-loggroup,scheduler-schedule,dlm-lifecycle-policy,pipes,rds}-provider.ts, and unit cases. The thrown message quoted AWS's raw text, which names the caller on an access denial, anddeployments/{runId}.jsonloutlives the run. The message now carries the error class (for exampleAccessDeniedException. Re-run with --verbose for AWS's own message.), and AWS's text goes to the debug log, through the operation's secret masker. This covers the create, update and delete failures of the four types, theFn::GetAttlookup failure of a schedule, lifecycle policy or pipe, a pipe's*_FAILEDstate reason, and the RDSRemoveFromGlobalCluster/DescribeGlobalClustersfailures that stop a cluster delete. A cdkd-authored or transport error keeps its text, masked through the operation's secret masker on every create, update and delete wrap. Each wrap that carried the AWS error as itscausestill does, so an IAM grant that is still propagating is retried as before, and an "already exists" is still stated as such (except for a log group's create-time wiring call, where "it" would read as the log group). - A failed RDS DB subnet group, DB cluster or DB instance create, update or delete no longer writes the caller's account id, assumed-role ARN and session name into the persisted deployment events (issue #2319) --
src/provisioning/providers/rds-provider.ts, and unit cases. The thrown message quoted AWS's raw text, which names the caller on an access denial, anddeployments/{runId}.jsonloutlives the run. The message now carries the error class (for exampleAccessDenied. Re-run with --verbose for AWS's own message.), and AWS's text goes to the debug log, through the operation's secret masker. This includes a failure afterCreateDBCluster/CreateDBInstancereturned (a denied status read while waiting foravailable), which still names the cluster or instance left behind forcdkd rollback --revert-failed. A cdkd-authored or transport error keeps its text, masked. The AWS error is still the wrap'scause, so an IAM grant that is still propagating is retried as before, and an "already exists" is still stated as such. - A failed IAM role, IAM managed policy, Budgets budget or Lambda network connector create, update or delete no longer writes the caller's account id, assumed-role ARN and session name into the persisted deployment events (issue #2319) --
src/provisioning/providers/iam-role-provider.ts,iam-managed-policy-provider.ts,budgets-budget-provider.ts,lambda-network-connector-provider.ts, and unit cases. The thrown message quoted AWS's raw text, which names the caller on an access denial, anddeployments/{runId}.jsonloutlives the run. The message now carries the error class (AccessDenied. Re-run with --verbose for AWS's own message.), and AWS's text goes to the debug log, masked through the operation's secret masker where the provider has one. This also covers thepartialreason a renamed IAM role or managed policy records when the old one could not be deleted, the budgetArnattribute read, and a network connector that AWS reports as FAILED, update Failed or DELETE_FAILED: its status reason stays out of the message, which names the reason code instead. A cdkd-authored or transport error keeps its text. The AWS error stays as thecause, so an IAM grant that is still propagating is retried as before, a role or policy name that is taken still reads as a name collision, and a role already gone at delete time still reads as not found. - Removing
AvailabilityZoneImpairmentPolicyfrom anAWS::AutoScaling::AutoScalingGroupnow turns zonal shift off, as CloudFormation does (issue #1160) --src/provisioning/providers/asg-provider.ts,tests/unit/provisioning/asg-provider-update-removal.test.ts,tests/integration/launchtemplate-asg-inplace/. Before this change, cdkd sent nothing for a removed policy, so the group kept zonal shift enabled andcdkd diffreported no changes afterwards. A real CloudFormation A/B showed that CloudFormation sendsZonalShiftEnabled: falsewith the previousImpairedZoneHealthCheckBehavior, after which AWS reports no policy; cdkd now sends the same. The same A/B confirmed that CloudFormation keeps a removedDesiredCapacityandServiceLinkedRoleARN, and sends only the newly declared member on aLaunchTemplate/MixedInstancesPolicyorVPCZoneIdentifier/AvailabilityZonesswitch, which is what cdkd already did. - Removing
Monitoringfrom anAWS::ECS::Servicenow resets the service's metric resolution, as CloudFormation does (issue #1160) --src/provisioning/providers/ecs-provider.ts,tests/unit/provisioning/ecs-service-removal-1160d.test.ts,tests/integration/ecs-service-update-props/. Before this change, cdkd sent nothing toUpdateServicewhen a template droppedMonitoring, so a service kept its old metric configuration (for example a 20-second resolution) indefinitely. A CloudFormation A/B showed CloudFormation resets the service revision to{metricConfigurations: []}, which cdkd now sends. The same A/B confirmed the existing behavior of three siblings: a removedVolumeConfigurationsorVpcLatticeConfigurationsis cleared, and a removedAvailabilityZoneRebalancingkeeps its live value. AWS::RDS::DBProxy: removingAuthfrom the template is now refused before any AWS call, as CloudFormation's update fails on it; the FSx and DBProxy removal warnings now say when CloudFormation keeps the value too (issue #1160) --src/provisioning/providers/rds-dbproxy-provider.ts,src/provisioning/providers/fsx-filesystem-provider.ts. A CloudFormation A/B on the issue (us-east-1) measured each removal. CloudFormation sends anAuthremoval, and AWS refuses it whileDefaultAuthSchemeisNONEor unset. cdkd used to keepAuthand warn; it now stops the deploy with an error that says to declareAuthagain. A rollback replay still keeps it and warns;drift --revertkeeps it without a warning, as before. CloudFormation keeps a removed DBProxyRequireTLS/IdleClientTimeout/DebugLogging/VpcSecurityGroupIds, an FSxFileSystemTypeVersion, a LustreDataCompressionType/AutoImportPolicy/WeeklyMaintenanceStartTime, and an OpenZFSAutomaticBackupRetentionDays/CopyTagsToBackups/CopyTagsToVolumes/DailyAutomaticBackupStartTime/WeeklyMaintenanceStartTime/DiskIopsConfiguration. cdkd already kept them, so only the warning changes: it now reads... and keeps its current AWS value, as CloudFormation leaves it; .... A warning that names an unmeasured property (FSxStorageType,NetworkType, a Windows or ONTAP sub-property) keeps its old wording. Tests:tests/unit/provisioning/rds-dbproxy-provider.test.ts,tests/unit/provisioning/providers/fsx-filesystem-provider.test.ts; thefsx-lustreandrds-aurorainteg needles now require the new wording.
Recently Implemented (2026-10-21):
- A name collision with this stack's own rollback-retained copy now says so (issue #5143) --
src/deployment/deploy-engine/replacement.ts,src/deployment/kept-in-history.ts,src/deployment/deploy-engine.ts,docs/cli-rollback-limitations.md. When a rolled-back deploy keeps a replacement's new copy because ofUpdateReplacePolicy: Retain(as CloudFormation does), that copy keeps its fixed name. The next deploy that asks for the name is refused, and that is still the outcome. The refusal used to say only "another existing resource already holds the name ... delete it if it is yours". It now reads the stack's event history. When a rolled-back deploy of this stack kept this resource's replacement under that name, it says so, and says to delete the holder only if it is that copy and no longer needed. The read happens only on that refusal, so a successful deploy reads nothing more. - Changing an
AWS::ApiGateway::Method'sIntegrationorMethodResponsesnow reaches AWS (issue #5140) --src/provisioning/providers/apigateway-provider.ts. An update patched only the Method's own fields: a changed integration (for example itsUri, after the Lambda function behind aLambdaIntegrationwas replaced) was recorded in state but never sent, so the API kept invoking the old target, or answered500once it was deleted, after a green deploy. An update now puts the integration and its integration responses, and adds, changes or removes method and integration responses, in the order API Gateway accepts:PutIntegrationreplaces the integration and drops its integration responses, so they are put again, and a changed method response is deleted before it is put. Nothing extra is called when neither changed. - Replacing an un-named
AWS::Lambda::Permissionno longer fails the name-idempotent guard, with or without--replace(issue #5137) --src/provisioning/providers/lambda-permission-provider.ts,src/deployment/type-change-guard.ts,docs/provisioning-layers.md. The SDK provider sent the bare logical id as the statement id. When a permission was replaced (for example because its function was replaced behind an API Gateway method), the new statement came back under the old id, and the deploy refused it as "the create returned the existing resource". Under--replaceit deleted the old statement and then failed to re-create the new one, because the new one already existed. On a stack with a stack instance id, the statement id is now generated as<StackName>-<LogicalId>-<suffix>, as CloudFormation does, with a new suffix for each replacement, so the replacement creates first. A legacy stack keeps sending the bare logical id. On either kind of stack, two records with the same id whoseFunctionNames name different functions now count as two statements, so the old one is deleted from the old function. Existing records keep their ids. - A declined
--require-approvalprompt now prints as one yellow line, as the AWS CDK CLI prints it, instead of a red failure reported twice (issue #5136) --src/utils/error-handler.ts,src/deployment/deployment-approval.ts,src/deployment/work-graph.ts. Answeringnused to printFailed: stack:<name>: ...and thenError: 1 node(s) failed: ...in red. The deploy now prints onlyDeployment of stack <name> was not approved (...). Nothing was changed.in yellow, and adds a line counting any dependent stacks it skipped. It still exits 1, because the template was not applied (the AWS CDK CLI'sAbortErrorexits 1 too). A decline that happens alongside a genuine failure keeps the red failure report. - A Cloud Map Service whose
Descriptioncomes from a secret dynamic reference or aNoEchoparameter now keeps a removedHealthCheckConfigwhen theDescriptiondid not change, as CloudFormation does (issue #5112) --src/types/resource.ts,src/deployment/deploy-engine/{update,update-in-place}.ts,src/provisioning/providers/servicediscovery-provider.ts. State records such aDescriptionas its{{resolve:...}}token or***, which the provider could not compare with the resolved value, so it treated it as changed: every update of that Service sentUpdateService(aTags- orServiceAttributes-only change included), clearing a removedHealthCheckConfigand, with a removedDnsConfig, failing. The deploy now hands providers the properties that changed template to template (UpdateContext.changedProperties), counting a masked or secret-derived value as changed when its template, inputs or secret version moved, or when AWS did not confirm aNoEchovalue (for a Service, cdkd reads it back to check); the Service sendsUpdateServiceonly for aDescription/HealthCheckConfigchange among them. A rollback andcdkd drift --revertcompare as before. - A failed Cognito User Pool or Auto Scaling group create, update or delete no longer writes the caller's account id, assumed-role ARN and session name into the persisted deployment events (issue #2319) --
src/provisioning/providers/{cognito,asg}-provider.ts, and unit cases. The thrown message quoted AWS's raw text, which names the caller on an access denial, anddeployments/{runId}.jsonloutlives the run. The message now carries the error class (for exampleAccessDeniedException. Re-run with --verbose for AWS's own message.), and AWS's text goes to the debug log, through the operation's secret masker. This also covers the Auto Scaling group's note about a group whose create-time cleanup could not finish (the group and its delete command are still named), and the warning about a user pool whose partial-create cleanup failed. A cdkd-authored or transport error keeps its text, masked through the operation's secret masker on both providers (the Cognito wraps and the Auto Scaling group delete now apply it too). The AWS error stays as thecause, so an IAM grant that is still propagating is retried as before, and an "already exists" is still stated as such. - A failed ElastiCache subnet group or cache cluster, CloudTrail trail, CloudFront distribution or IAM access key create, update or delete no longer writes the caller's account id, assumed-role ARN and session name into the persisted deployment events (issue #2319) --
src/provisioning/providers/elasticache-provider.ts,cloudtrail-provider.ts,cloudfront-distribution-provider.ts,iam-access-key-provider.ts, and unit cases. The thrown message quoted AWS's raw text, which names the caller on an access denial, anddeployments/{runId}.jsonloutlives the run. The message now carries the error class (AccessDenied. Re-run with --verbose for AWS's own message.), and AWS's text goes to the debug log, masked through the operation's secret masker. This also covers a CloudFront distribution update whose tag change failed, and the IAM access key warnings about a denied key listing or a key cleanup that failed: they keep their remedy and command, with the class in place of AWS's text. A cdkd-authored or transport error keeps its text. The AWS error stays as thecause, so an IAM grant that is still propagating is retried as before. A name that is already taken still says it already exists, so a create-first replacement still recognises the name collision. - Removing
VpcSecurityGroupIdsfrom an RDS or Neptune DB cluster now moves the cluster to its VPC's default security group, as CloudFormation does (issue #1160) --src/provisioning/providers/rds-provider.ts,neptune-provider.ts,docdb-provider.ts,tests/unit/provisioning/db-cluster-sg-removal-1160.test.ts. Before this change, cdkd sent no security groups when the property was removed, andModifyDBClusterthen kept the removed groups attached while the deploy reported success. A real CloudFormation A/B showed CloudFormation attaches the VPC'sdefaultsecurity group in place of the removed ones onAWS::RDS::DBClusterandAWS::Neptune::DBCluster. cdkd now finds that group (the cluster's DB subnet group gives the VPC;ec2:DescribeSecurityGroupsgives the group nameddefault) and sends it. If the group cannot be found, the update fails before any change and says to declareVpcSecurityGroupIds. The lookup runs only on this removal, and the deploy credentials needec2:DescribeSecurityGroupsfor it. A warning names the group attached. An update that changes a cluster's security groups (this reset, or a changed declared list, DocDB included) now waits until the cluster reports the new groupsactiveand each member instance isavailable, as CloudFormation does, so a member instance's own change in the same deploy is no longer refused with "Database instance is not in available state";--no-waitskips the wait, as it does on create. An empty list is still never sent, andcdkd drift --revertdoes not reset.AWS::DocDB::DBClusteris unchanged: CloudFormation keeps a DocDB cluster's groups on removal, and so does cdkd. - A Firehose delivery stream now applies every destination sub-field its template declares, on create and on an ExtendedS3 update (issue #1160) --
src/provisioning/providers/firehose-provider.ts,tests/unit/provisioning/firehose-provider-removal-1160.test.ts,firehose-provider-readcurrentstate.test.ts, andtests/integration/stream-delete-redeploy/(Phase 1 and Phase 3c). Before this change,FileExtension,CustomTimeZoneandDynamicPartitioningConfigurationwere dropped from ExtendedS3 creates and updates. A stream therefore came up without them, a change to them was never applied, and a dynamic-partitioning stream could be neither created nor updated:UpdateDestinationrefuses a call that omits that block. Creates also droppedSecretsManagerConfigurationon HttpEndpoint, Redshift and Splunk destinations,RetryOptionsand the S3 backup settings on Redshift,BufferingHintson Splunk, andVpcConfiguration/DocumentIdOptionson the OpenSearch and Elasticsearch destinations.cdkd driftnow readsFileExtension/CustomTimeZonetoo. A stream deployed earlier still lacks these fields after the upgrade, because an unchanged template sends nothing. To apply them, change the destination or redeploy with--replace.VpcConfigurationis create-only on the OpenSearch and Elasticsearch destinations, so it takes effect only through--replace. A destination sub-field REMOVED from the template still stays at its live value. A live CloudFormation A/B showed CloudFormation keeps it as well (UpdateDestinationmerges), so the unit suite pins that no reset value is sent. AWS::Glue::Crawler: a deploy now names an optional property removed from the template, and says CloudFormation keeps the value too (issue #1160) --src/provisioning/providers/glue-provider.ts. A CloudFormation A/B on the issue (us-east-1) covered all ten optional schema properties:DatabaseName,Description,Classifiers,TablePrefix,Schedule,SchemaChangePolicy,RecrawlPolicy,LakeFormationConfiguration,ConfigurationandCrawlerSecurityConfiguration. They were removed all at once, first with no other change and then beside aTargetschange. CloudFormation kept every live value, because itsUpdateCrawlercarries onlyName,RoleandTargets. cdkd already left them out, so what reaches AWS does not change. Before, the removal passed silently. Now the deploy prints one line,... were removed from the template and keep their current AWS value, as CloudFormation leaves them; UpdateCrawler keeps a setting it is not sent., and a rollback revert prints its own wording.LineageConfiguration, which cdkd accepts but the CloudFormation schema lacks, is kept and named too, without the CloudFormation clause. Tests:tests/unit/provisioning/glue-crawler-removal-1160.test.ts. Theglue-update-hardeninginteg fixture's phase 2g now removes four crawler properties and asserts that the values are kept and that the line is printed.
Recently Implemented (2026-10-20):
- A DynamoDB table replaced under the same name is no longer left deleted with nothing in its place when its re-create comes too soon (issue #5131) --
src/provisioning/providers/dynamodb-table-provider.ts,src/types/resource.ts,src/deployment/deploy-engine/{replacement,update-replace,update-in-place}.ts,src/deployment/rollback-executor/replay-reverse-replacement.ts. A replacement that deletes the old table first and re-creates it under the same name (--replace's delete-first fallback,--recreate-via-*, an update the provisioning layer rejects, and a rollback reversing one) could fail the re-create withResourceInUseException:DeleteTablereturns while the table is stillDELETING, and DynamoDB keeps the name until it is gone, so the deploy ended with the old table deleted and no new one. These re-creates now wait (bounded, and Ctrl+C stops it) untilDescribeTablereports the table gone.cdkd destroyand every other deploy path make no extra call. - A replacement that deleted the old resource first is no longer abandoned half-way when another resource of the same deploy fails (issue #5118) --
src/deployment/deploy-engine.ts,src/deployment/deploy-engine/{replacement,update-replace,update-in-place,deploy-flow}.ts.cdkd deploy --replace(and--recreate-via-*, an update the provisioning layer rejects, a resource that went with its re-created parent) can delete a resource first and re-create it under the same name. When the re-create met the service's late release of that name (DynamoDB'sTable already existswhile the old table is still deleting) or a throttle, it retries -- but a failure of any other resource in the same deploy stopped that retry, leaving the stack with neither the old nor the new resource while state still recorded it. Only the user's interrupt (Ctrl+C) stops that retry now, including a Ctrl+C pressed after another resource has already failed; a sibling's failure lets it finish. Ctrl+C now also stops the automatic rollback, which keeps its journal socdkd rollbackfinishes it. cdkd destroy: a stack recorded in a region other than the CLI's deletes its Cloud Control-routed resources again (issue #5115) --src/cli/commands/destroy-runner.ts. Since #4941 the cross-region destroy built its provider registry before entering the stack's AWS scope, and the registry's constructor builds its Cloud Control and custom-resource fallbacks there, so both captured the CLI region's clients. Every Cloud Control-routed resource (e.g.AWS::ApiGateway::RestApi) was then refused withAWS client region us-east-1 does not match stack state region ...and the stack was left half-destroyed (the refusal touched nothing in the wrong region). The registry is now built inside the scope, as deploy, diff, drift, rollback and orphan already do. Workaround on older versions: run destroy withAWS_REGION=<stack region>. Tests:tests/unit/cli/destroy-nested-cross-region-scope-4941.test.ts.AWS::ServiceDiscovery::Service: a create right after its namespace no longer fails on Cloud Map'sNamespaceNotFoundlag (issue #5113) --src/provisioning/providers/servicediscovery-provider.ts. Cloud Map can answerCreateServicewithNamespaceNotFoundjust after the namespace's create operation reported SUCCESS (seen live: one of four parallel creates against a fresh namespace), and cdkd sent the call once, so the deploy failed and rolled back. cdkd now retries that error only, for about 30 seconds (27.5s of backoff); every other error still fails at once, and a create that gives up says it retried. The rejection creates no service, so a retry cannot duplicate one. Tests:tests/unit/provisioning/servicediscovery-provider-create-namespace-retry-5113.test.ts; theservicediscoveryinteg fixture exercises the path.- A replaced resource whose delete was refused "in use" is no longer lost when the deploy stops before its cleanup (issue #5111) --
src/deployment/superseded-resources.ts,src/deployment/deploy-engine/{replacement,superseded-retry,execute,deploy-flow}.ts,src/cli/commands/destroy-runner.ts,src/types/state.ts. A replacement whose old resource was still used by something the deploy had yet to update (an ELBv2 target group behind a listener) deferred that delete to the end of the deploy, but only in memory, while state already recorded the new resource: a deploy killed, crashed or interrupted before the end left the old resource in AWS with no record, and nothing ever deleted it -- it survivedcdkd destroyand kept billing. The old resource is now recorded in state (supersededResources, additive, no schema bump) by the same save that records the replacement. Every later deploy, an unchanged one included, retries its delete after its own changes and drops the record once it is gone;cdkd destroydeletes it after the stack's own resources and keeps the state while it fails. The end-of-deploy retry also waits out an "in use" refusal for up to a minute (a deleted load balancer's listeners go asynchronously; an endpoint moving off a Lambda web function revision), and a delete still refused keeps the record with a warning naming the next deploy andcdkd destroyas the remedy. - A rollback re-creates a replaced resource's old copy before reverting what references it (issue #5064) --
src/deployment/rollback-executor.ts,src/deployment/rollback-executor/plan.ts,replay-reverse-replacement.ts,replay-scope.ts,src/deployment/superseded-resources.ts,src/cli/commands/rollback.ts,src/deployment/nested-child-journal.ts. When a replacement gave the resource a new physical id (a generated name at its next generation, or a renamed resource),cdkd rollbackand the automatic rollback reverted the resources that moved onto it (an IAM managed policy'sRoles, an inline policy's role) BEFORE re-creating the old resource, so they named one AWS no longer had ("The role with name ... cannot be found") and the rollback exited with a partial failure. The old resource is now re-created before the first revert that may reference it (and before a replaced resource its own old properties reference), and the new one is deleted after those reverts, as CloudFormation does. Until that delete runs,cdkd rollbackkeeps the new resource insupersededResources, so a killed run leaves it for the next deploy to delete; there it also stays, for both rollbacks, when its delete fails or a resource still using it did not revert, and whencdkd rollbackis interrupted. A same-physical-id re-create, a delete-first reversal, and one whose old id another record still holds keep their order. - A failed custom resource create or update no longer writes the caller's account id, assumed-role ARN and session name into the persisted deployment events (issue #2319) --
src/provisioning/providers/custom-resource-provider.ts, and unit cases. When the backing LambdaInvoke, the SNSPublishor the response placeholderPutObjectwas denied, the thrown message quoted AWS's raw text, which names the caller, anddeployments/{runId}.jsonloutlives the run. The message now carries the error class (AccessDeniedException. Re-run with --verbose for AWS's own message.), and AWS's text goes to the debug log, masked through the operation's secret masker. A backing Lambda that never became ready keeps itsStateReasonCode/LastUpdateStatusReasonCodein the message, but the reason sentence, which AWS words with ARNs, goes to the debug log too. The retry warning for a denial before the request was delivered, and the warning for a delete whose handler could not be reached, name the class too. The handler's own FAILED reason and other cdkd-authored errors keep their text. An IAM grant that is still propagating is retried as before: that retry runs on AWS's own error inside the invoke loop, before the message is built. The AWS error stays as thecause. AWS::ApiGatewayV2::*: the remaining properties removed from the template now follow CloudFormation (issue #1160) --src/provisioning/providers/apigatewayv2-provider.ts. A removed AuthorizerIdentitySourceis now cleared (UpdateAuthorizergets[]); before, cdkd left it in place. As in CloudFormation, AWS refuses that clear for a JWT authorizer or a REQUEST authorizer with caching on. A forward deploy that removes an IntegrationIntegrationSubtypewithout declaring anIntegrationUriis now refused before any call, because CloudFormation fails that update. Before, cdkd reported success and kept the subtype. Declared beside anIntegrationUri, it is sent without the subtype, as CloudFormation does. A rollback replay keeps a subtype the live integration still has, and warns. Every other removable property (ApiRouteSelectionExpression; StageDescription/DefaultRouteSettings; IntegrationIntegrationUri/IntegrationMethod/PayloadFormatVersion/TimeoutInMillisand the other #609 fields; RouteTarget/ModelSelectionExpression/RouteResponseSelectionExpression; AuthorizerAuthorizerUri/EnableSimpleResponses/JwtConfiguration) is kept, as before. Also fixed: an update to anEventBridge-PutEventsservice integration that did not change itsRequestParameters(aDescription-only change, for example) failed withrequires enabling passthrough, or defining all of the following parameters. AWS re-validates the parameter mapping on every update, so cdkd now re-sends the declared map beside the subtype; a declared map missing a required key still fails, as it does in CloudFormation, and a redacted secret stand-in in it refuses instead of being sent. Tests:apigatewayv2-provider-roundtrip.test.ts; integapigatewayv2-update-removalphases 2, 2b, 2c.AWS::EC2::Instanceproperty removals now match CloudFormation: a removedMonitoringis turned off, and a removedDisableApiTerminationis refused (issue #1160) -- Two rounds of real CloudFormation A/B decide each case. WhenMonitoringis removed, CloudFormation turns detailed monitoring off. cdkd used to send nothing, so monitoring stayed on; it now callsUnmonitorInstances. A removedMetadataOptionsorCreditSpecificationkeeps its live value, in both. WhenDisableApiTerminationis removed (fromtrueorfalse), CloudFormation fails the whole update and keeps the value, but it applies an explicittrue/false. cdkd used to drop the removal silently and leave protection on. A deploy that removes it now fails before any AWS call, other changes in the same update included. The error names the instance and says to keep the property declared and set it tofalseto turn protection off. A rollback that restores a state without the property does not refuse: the user cannot edit that state, so the rollback leaves protection as it is and warns when it was on.cdkd drift --revertis unchanged. Changed:src/provisioning/providers/ec2-provider.ts,tests/unit/provisioning/ec2-provider-instance-removal-1160.test.ts,tests/integration/ec2-instance/.AWS::ElasticLoadBalancingV2::Listener: the warning for a removedAlpnPolicy,MutualAuthentication,Certificates,SslPolicy,PortorProtocolnow says CloudFormation keeps the value too (issue #1160) --src/provisioning/providers/elbv2-provider.ts. A CloudFormation A/B on the issue (us-east-1, each property removed alone and beside aDefaultActionschange) showed CloudFormation keeps all six live values and no update fails. cdkd already kept them, becauseModifyListenerkeeps a field it is not sent, so the behaviour does not change. The deploy warning used to make no claim about CloudFormation; it now reads... were removed from the template and keep their current AWS value, as CloudFormation leaves them; .... The rollback wording is unchanged. Tests:tests/unit/provisioning/elbv2-listener-removal-1160.test.ts; thealbinteg fixture gains a phase that drops the listener'sPortandProtocol.AWS::EMR::Cluster/AWS::EMR::InstanceFleetConfig: the warning for a removedStepConcurrencyLevel,ResizeSpecificationsorInstanceTypeConfigsnow says CloudFormation keeps the value too (issue #1160) --src/provisioning/providers/emr-cluster-provider.ts,src/provisioning/providers/emr-instance-fleet-config-provider.ts. A CloudFormation A/B on the issue (us-east-1, a fleet cluster with a running TASK fleet; each property removed alone, andStepConcurrencyLevel/ResizeSpecificationsalso beside a companion change) showed CloudFormation keeps all three live values and no update fails, although its documentedStepConcurrencyLeveldefault is 1. cdkd already kept them, becauseModifyCluster/ModifyInstanceFleetkeep a field they are not sent, so the behaviour does not change. The deploy warning used to make no claim about CloudFormation; it now reads... was removed from the template and keeps its current AWS value, as CloudFormation leaves it; .... The rollback wording is unchanged. Tests:tests/unit/provisioning/providers/emr-cluster-provider-removal-1160.test.ts,tests/unit/provisioning/providers/emr-instance-fleet-config-provider-removal-1160.test.ts; theemr-clusterandemr-instance-fleetsinteg fixtures' Phase 2b needles now require the new wording.AWS::KMS::Key: a removedRotationPeriodInDaysorKeyPolicyis reset, as CloudFormation does (issue #1160) --src/provisioning/providers/kms-provider.ts. A CloudFormation A/B showed that a Key update resets a removedRotationPeriodInDaysto 365 and a removedKeyPolicyto the KMS default key policy (Id: key-default-1,kms:*for the account root), alone and beside another change. cdkd sent nothing for either, so the old period and policy stayed. It now re-sends the period withEnableKeyRotationwhenever it changes while rotation stays on (a period change was dropped too), and puts the default policy, named from the key ARN's partition and account.cdkd drift --revertstill never puts a policy it did not read. The update follows CloudFormation's order:EnableKey, the rotation calls,DisableKey, then tags and the key policy, so a policy denyingkms:DisableKeydeploys. A rotation change on a key that stays disabled fails, as it does in CloudFormation, and the error now says to enable the key first (declareEnabled: true, or enable it in AWS) or drop the rotation change. A string'false'forEnabledorEnableKeyRotation(a String parameter's Ref) now reads as false on create and update, as in CloudFormation; it used to turn rotation on and leave the key enabled. The existing resets of a removedDescription,EnabledandEnableKeyRotationalready matched CloudFormation. Tests:tests/unit/provisioning/kms-provider-removal-1160.test.ts; thekms-encryptioninteg fixture gains averify.shwith a removal phase.AWS::Lambda::EventSourceMapping: aBatchSizeremoved from the template is reset to the source's default, and a removedLoggingConfigorProvisionedPollerConfigon a Kafka mapping is cleared, as CloudFormation does (issue #1160) --src/provisioning/providers/lambda-eventsource-provider.ts.UpdateEventSourceMappingkeeps a field it is not sent, so cdkd used to leave all three at their old AWS value while the deploy succeeded andcdkd diffreported no changes. A CloudFormation A/B (us-east-1; CloudTrail read of the update CloudFormation sent) measured the reset on SQS, DynamoDB-stream and self-managed Kafka mappings:BatchSize10 for SQS and 100 for DynamoDB and self-managed Kafka, and{}for a self-managed Kafka mapping'sLoggingConfig/ProvisionedPollerConfig. Kinesis, MQ and DocumentDB take the Lambda API reference default of 100, MSK is assumed to match self-managed Kafka, and an unclassifiable source gets nothing. CloudFormation keeps an SQS mapping'sProvisionedPollerConfigand a Kafka mapping'sMaximumBatchingWindowInSeconds, and cdkd still does too. Tests:tests/unit/provisioning/lambda-eventsource-provider.test.ts; thesqs-esm-max-concurrencyinteg fixture now dropsbatchSizein its update phase, andlambda-esm-self-managed-kafkagains a Phase 2c droppingBatchSize, the poller config and aLoggingConfig.AWS::Route53::HostedZone: removingVPCsfrom a private hosted zone now prints a warning that the zone keeps its VPC associations, as CloudFormation does (issue #1160) --src/provisioning/providers/route53-provider.ts. A CloudFormation A/B on the issue (us-east-1) showed CloudFormation keeps every association whenVPCsis removed whole, from a zone with one VPC or two, or set to[], and no update fails. cdkd already kept them, so no AWS call changes. The deploy used to say nothing; it now warns<LogicalId> (AWS::Route53::HostedZone): VPCs was removed from the template, and the zone keeps its current VPC associations, as CloudFormation leaves them. .... A rollback revert gets its own wording, andcdkd drift --revertdoes not warn. Dropping some VPCs while keeping others still disassociates the dropped ones. Tests:tests/unit/provisioning/route53-hostedzone-vpcs-removal-1160.test.ts; theec2-vpcinteg fixture gains a private hosted zone whoseVPCsits removal phase drops.AWS::ServiceDiscovery::Service: aDescription,DnsConfigorHealthCheckConfigremoved from the template now follows CloudFormation (issue #1160) --src/provisioning/providers/servicediscovery-provider.ts.UpdateServicereplaces its body, and cdkd sent it whenever any of the three was declared, so a removal on its own cleared a removedDescription/HealthCheckConfigand failed the deploy for a removedDnsConfig(Service dns config cannot be null). CloudFormation A/Bs on the issue showed it sends the update only when a declaredDescriptionorHealthCheckConfigchanged, with the declared values as they are; cdkd now does the same: a removal on its own keeps the value, and beside such a change a removed field is cleared (a removedDnsConfigis refused by AWS, as for CloudFormation). Deliberate divergence: CloudFormation drops aDnsConfig.DnsRecords-only change (a TTL); cdkd applies it and keeps any removedDescription/HealthCheckConfigfrom the live service. Known divergence: an unchangedDescriptionrecorded as any dynamic reference ({{resolve:...}}) or a NoEcho***counts as changed, so aHealthCheckConfigremoved beside it is cleared (#5112). The type is audited intoremovalHandledInUpdate. Tests:tests/unit/provisioning/providers/servicediscovery-provider.test.ts; theservicediscoveryinteg fixture gains three removal phases.
Recently Implemented (2026-10-19):
cdkd rollbacknow reverses a replacement that re-created a resource under the same physical id, instead of reporting success over it (issue #5096) --src/deployment/rollback-executor/plan.ts. A replacement that deleted the old resource first and re-created it under the same name (cdkd deploy --replace's delete-first fallback, a fixed-name--recreate-via-*, an update the provisioning layer rejects) was rolled back in place: the provider could not change the create-only property, so the re-created resource stayed while the rollback wrote the old record into state and reported success. Such a replacement is now reversed: the re-created resource is deleted and the old one re-created, as for any delete-first replacement; a re-run after a completed reversal does nothing. When the stack records a resource AWS stores inside it (a function's permission, a topic's subscription, a role's inline policy), which deleting it again would take too, the rollback refuses that resource before deleting anything and namescdkd deploy --replaceof the previous template as the fix. It refuses the same way when every property the deploy changed is a secret redacted identically in state before and after, since state then cannot tell whether an earlier rollback already reversed it.- An un-named resource gets the same generated name on the SDK provider and Cloud Control routes (issue #5095) --
src/provisioning/resource-name.ts, every SDK provider that generates a name,src/deployment/deploy-engine/routing.ts,src/deployment/deploy-engine/update-in-place.ts,src/deployment/replacement-name-holder/. Each type now has one name rule that both routes read. A Cloud Control create of an un-namedAWS::CodeCommit::Repository,AWS::RDS::DBProxyorAWS::RDS::DBProxyEndpointused to fail, because their schemas require the name; it now gets the name the SDK provider gives. An un-namedAWS::S3Express::DirectoryBucketon Cloud Control used to be sent a name without the required--<zone id>--x-s3suffix; it now gets the suffix fromLocationName, and a create whoseLocationNameis not a zone id is refused with the fix named. On a stack with a stack instance id, an un-named CodeBuild project, EC2 security group, CloudTrail trail, Glue job, crawler, connection or trigger, or Firehose delivery stream is now named{stackName}-{logicalId}-{suffix}instead of its bare logical id, which collided across stacks sharing a VPC or account and region and on every replacement, and Cloud Control names log groups, SSM parameters, Auto Scaling groups, DynamoDB global tables, ECS task definitions, Lambda network connectors, EventBridge pipes and Scheduler schedules as their SDK providers do. Neither route generates anAWS::Events::EventBusname any more: CloudFormation requires it. Stacks without an instance id keep every name either route generated before. - A redacted AWS failure whose class name contains
AlreadyExistsis no longer retried as a name collision when AWS's own message describes something else (issue #5088) --src/deployment/retryable-errors/name-collision.ts. A provider wrap that withholds AWS's text reduces the failure to its class, such asHostedZoneAlreadyExists. Re-run with --verbose for AWS's own message.The delete-then-re-create retry filter read that class as an "already exists" collision. Route 53'sHostedZoneAlreadyExistsreports a reused caller reference, not a taken name, and S3'sBucketAlreadyExistsreports a name held by another account. Both were retried for about 64 seconds before failing. For a wrap marked as withholding text, the filter now ignores the class in that reduced message and reads AWS's own message on thecausechain, plus any "AWS reports that it already exists." sentence the provider restated. Genuine collisions and name cooldowns are still retried. Tests:tests/unit/deployment/redacted-already-exists-class-5088.test.ts. - A 1-3 character secret embedded in a literal no longer reaches
state.jsonin plaintext when a failed deploy trims a lost attachment's record (issue #5076) --src/deployment/deploy-engine/execute.ts,src/deployment/deploy-engine/record-shape.ts,src/deployment/secret-redaction/pairs.ts. When a deploy fails after re-creating an IAM principal under the same name, cdkd keeps the record of a resource that lost its attachment, with the re-created principal dropped from its lists. The rebuilt record was a plain copy that lost the marks the original record carried. A resource whose record this deploy had already installed was then saved without the claim that lets a secret below the value scan's 4-character floor be stored as its reference. Examples are a Cloud Control update that sent nothing, and an update that failed after recording. The copy now carries the original's redaction marks, and adds none it lacked. Tests:tests/unit/deployment/deploy-engine-lost-attachment-marks-5076.test.ts. - Changing an
AWS::EC2::NetworkAclEntry's rule (for exampleRuleActionfromallowtodeny, or itsCidrBlock,Protocol,PortRangeorIcmp) now updates the entry in place with oneReplaceNetworkAclEntrycall, as CloudFormation does, instead of failing the deploy as not updatable (issue #5070) --src/provisioning/providers/ec2-provider.ts,tests/unit/provisioning/ec2-nacl-entry-replace-5070.test.ts, and theec2-vpcinteg's Phase 2. The refusal's suggestedcdkd deploy --replacedeleted the entry and created it again, leaving the rule number absent between the two calls so traffic fell through to the next rule. A rollback of the change, andcdkd drift --revertof a console-side rule edit, now replace the rule back in place too. A change toNetworkAclId,RuleNumberorEgressis createOnly and still replaces the entry. - Changing an
AWS::EC2::Route's target (for exampleGatewayIdtoTransitGatewayId) now updates the route in place with oneReplaceRoutecall, as CloudFormation does, instead of deleting and re-creating it (issue #5068) --src/provisioning/providers/ec2-provider.ts,tests/unit/provisioning/ec2-route-replace-5068.test.ts, and theec2-route-targetsinteg's Phase 2. The oldDeleteRoute+CreateRouteleft the route table without the route between the two calls, so traffic to that destination dropped for the window, and a refused create (a target not yet attached, a permission, a throttle) left the route deleted. The update now reports the route as updated in place rather than replaced, and a rollback of a target change replaces the target back. A change to the route table or destination is createOnly and still replaces the route. - New stacks get CloudFormation-style generated names:
{stackName}-{logicalId}-{suffix}(issue #5064, part 2) --src/provisioning/resource-name.ts,src/deployment/deploy-engine/deploy-flow.ts,src/deployment/deploy-engine.ts, the IAM role / managed policy / inline policy and EventBridge rule providers, and the commands that replay provider calls (cdkd rollback,drift --revert,state refresh, destroy). A resource the template does not name, in a stack whose record carries a stack instance id (every stack first deployed by this version), is named{stackName}-{logicalId}-plus 12 hex characters derived from that id and the logical id; the{stackName}-{logicalId}part is cut so the whole fits the type's limit. The name stays the same until the resource is replaced (part 3). Aftercdkd destroy, a redeploy gets a new id and new names, so it no longer collides with, or takes back, a resource aDeletionPolicy: Retain(orcdkd state orphan) left behind; the first deploy warns once and namescdkd importfor adopting such a resource. Existing stacks keep their names: a stack whose record predates the id keeps{stackName}-{logicalId}, and nothing is renamed or replaced. Declared names never change. Anupdate()that re-derives a name keeps the recorded one of an un-named resource rather than replace it;cdkd drift --revertof an un-named EventBridge rule now updates the rule its ARN names, where it used to sendPutRuleunder the bare logical id and create a second rule on a legacy stack. Generated names now stay within each service's limit for long stack names, where they used to fail: Lambda capacity provider (64), CloudWatch alarm (255), CodeBuild project (150) and S3 directory bucket (63 with its zone suffix). - A replaced auto-named resource gets a new name and is created before the old one is deleted, as in CloudFormation (issue #5064, part 3) --
src/state/name-generation.ts,src/provisioning/resource-name.ts,src/deployment/deploy-engine/update*.ts,replacement.ts,create.ts,execute.ts,deploy-flow.ts,src/deployment/generated-name-guard.ts,orphan-adoption.ts, the rollback executor,cdkd import,cdkd orphan. On a stack with a stack instance id, a resource the template does not name records its name generation (state v13,nameGeneration). A replacement (an immutable property,--recreate-via-*, an update the provisioning layer rejects) creates the new resource under the next generation's name first and then deletes the old one, with no--replace; a collision on that name refuses and namescdkd import, and--replacedeletes nothing. A crash re-run asks for the same name (the generated-name guard takes back what the killed run made); a rollback restores the old resource and remembers the generation reached, so the retry gets a new name. A resource removed from the template (deleted,Retain,cdkd orphan) and later re-added gets a new name, and what a rollback left in AWS underRetainis no longer taken back: the deploy creates a new resource and namescdkd import. A CREATE a rollback took back (the automatic rollback,cdkd rollback, a nested child's) is created under a new name next time, as in CloudFormation, so a type that refuses a just-deleted name for a while (an SQS queue, a Secrets Manager secret) does not fail the retry. Legacy stacks keep their names and today's replacement behavior. - State schema v13: a stack records the id of its current lifetime (issue #5064, part 1) --
src/types/state.ts,src/state/stack-instance-id.ts,src/state/s3-state-backend.ts,src/deployment/deploy-engine/deploy-flow.ts,src/cli/commands/import.ts,src/cli/commands/state.ts. A stack's first state write (its first deploy, the partial save of a failed first deploy, orcdkd importcreating the record) recordsstackInstanceId, a random id kept until the record is deleted; a recreated stack gets a new one. Once a first deploy is going ahead (past--require-approval), it writes the id tostack-instance.jsonbesidestate.jsonbefore any resource is created, so a re-run after a crash reuses it; the object is deleted oncestate.jsoncarries the id, and with the record on destroy,state orphan, export and a rolled-back first deploy. A stack whose record an older cdkd wrote never gets one (legacy), and a deploy of a stack with a record makes no extra AWS call; a first deploy adds one read and one write,--dry-runnone. cdkd never prints the id (cdkd state show --jsonshows<withheld>). Generated resource names derive from it (part 2). Older binaries fail on v13 with "Upgrade cdkd". - A 1-3 character secret embedded in a literal no longer reaches
state.jsonin plaintext when a provider records its owneffectiveProperties(issue #5063) --src/deployment/secret-redaction/pairs.ts,src/deployment/deploy-engine/record-shape.ts,src/deployment/deploy-engine.ts,src/deployment/deploy-engine/rollback.ts,src/deployment/rollback-executor/replay-retry.ts,src/deployment/rollback-executor/replay-secrets.ts. Nine providers record a spread of the resolved properties they were handed (S3, Kinesis, AppSync, Cognito, the DynamoDB global table, the nested stack, the RDS proxy target group, the Secrets Manager secret, and the EC2 security group ingress), for example aTags[].Valueor aDescription. Such a bag was never given the claim that lets a secret below the value scan's 4-character floor be written as its reference, because a provider may carry a previous value into it. It is now LEAF-certified: the redaction claim holds per leaf, since every arm the claim relaxes accepts a leaf only where it equals this deploy's own rendering of the template, so a carried value keeps its old answer. This applies on the deploy, in the rollback journal, and when a rollback replay restores the record. The v12 reference provenance of such a record is unchanged. Tests:tests/unit/deployment/deploy-engine-leaf-certified-5063.test.ts,tests/unit/deployment/deploy-engine-short-embedded-secret.test.ts,tests/unit/deployment/secret-redaction-context-1940.test.ts; integtests/integration/secrets-dynamic-ref(a Kinesis stream tag). - A 1-3 character secret embedded in a literal beside a public
ssmreference no longer reachesstate.jsonin plaintext (issue #5056) --src/deployment/secret-redaction/positions.ts,src/deployment/deploy-engine/update.ts. Such a secret is below the value scan's 4-character floor, so only its position can redact it. Before, a literal such aspostgres://{{resolve:ssm:/db/host}}:5432/?pin={{resolve:secretsmanager:...}}was persisted with the pin in plaintext: the position arm refused the whole leaf because the public token has no secret pair. A deploy now persists the secret span as its reference and the public span as its resolved value, where this deploy's own lookup proved the parameter public and the leaf is exactly that rendering; any leaf the value scan would answer differently keeps its old form. A record an older cdkd wrote with the plaintext spelling is rewritten on the next deploy without an UPDATE when the template is unchanged. A rollback replay can now state the same generation claim as the deploy for a provider bag the provider marked; no provider marks one on a replay today, so a provider'seffectivePropertiesechoing such a secret keeps the residual (issue #5063). Tests:tests/unit/deployment/secret-redaction-subfloor-5056.test.ts,tests/unit/deployment/deploy-engine-mixed-literal-legacy-repair-5056.test.ts,tests/unit/deployment/secret-redaction-context-1940.test.ts. - A failed ACM certificate, Lambda web function revision or endpoint, Lambda MicroVM image, event source mapping or function URL create, update or delete no longer writes the caller's account id, assumed-role ARN and session name into the persisted deployment events (issue #2319) --
src/provisioning/providers/acm-certificate-provider.ts,lambda-web-function-revision-provider.ts,lambda-web-function-endpoint-provider.ts,lambda-microvm-image-provider.ts,lambda-eventsource-provider.ts,lambda-url-provider.ts, and unit cases. The thrown message quoted AWS's raw text, which names the caller on an access denial, anddeployments/{runId}.jsonloutlives the run. The message now carries the error class (AccessDeniedException. Re-run with --verbose for AWS's own message.), and AWS's text goes to the debug log, masked through the operation's secret masker. This also covers a web function revision or endpoint that AWS reports as Failed: its status reason stays out of the message (a revision keeps its build error's code). A cdkd-authored or transport error keeps its text. The AWS error stays as thecause, so an IAM grant that is still propagating is retried as before. An occupied name (an event source mapping or MicroVM image that already exists) still says it already exists, so a create-first replacement still recognises the name collision. An ACM create whose cleanup could not delete the requested certificate still names it. - A failed AppSync (GraphQLApi, GraphQLSchema, DataSource, Resolver, ApiKey) create, update or delete no longer writes the caller's account id, assumed-role ARN and session name into the persisted deployment events (issue #2319) --
src/provisioning/providers/appsync-provider.ts, and unit cases. The thrown message quoted AWS's raw text, which names the caller on anAccessDeniedException(or S3'sAccessDeniedwhen a*S3Locationobject cannot be fetched), anddeployments/{runId}.jsonloutlives the run. The message now carries the error class (AccessDeniedException. Re-run with --verbose for AWS's own message.), and AWS's text goes to the debug log, masked where the operation has a secret masker. A cdkd-authored or transport error keeps its text. The AWS error stays as thecause, so an IAM grant that is still propagating is retried as before, and an occupied name still says it already exists, so a create-first replacement still recognises the name collision. - A failed DynamoDB Table or GlobalTable create, update or delete no longer writes the caller's account id, assumed-role ARN and session name into the persisted deployment events (issue #2319) --
src/provisioning/providers/dynamodb-table-provider.ts,dynamodb-globaltable-provider.ts, and unit cases. The thrown message quoted AWS's raw text, which names the caller on anAccessDeniedException, anddeployments/{runId}.jsonloutlives the run. The message now carries the error class (AccessDeniedException. Re-run with --verbose for AWS's own message.), and AWS's text goes to the debug log, masked with the operation's secret masker. A cdkd-authored or transport error keeps its text. The AWS error stays as thecause, so an IAM grant that is still propagating is retried as before. An occupied name (Table already exists) still says it already exists, so a create-first replacement still recognises the name collision, and a delete that gives up on a busy global secondary index still says so and that re-running the destroy succeeds once the index is ACTIVE. The GlobalTable TimeToLive rate-limit refusal closes the class as one sentence before its remedy. - A failed EMR (Cluster, InstanceGroupConfig, InstanceFleetConfig), FSx FileSystem or EFS (FileSystem, MountTarget, AccessPoint) create, update or delete no longer writes the caller's account id, assumed-role ARN and session name into the persisted deployment events (issue #2319) --
src/provisioning/providers/{emr-cluster,emr-instance-group-config,emr-instance-fleet-config,fsx-filesystem,efs}-provider.ts,src/utils/aws-failure-text.ts, and unit cases. The thrown message quoted AWS's raw text, which names the caller on an access denial, anddeployments/{runId}.jsonloutlives the run. The message now carries the error class (AccessDeniedException. Re-run with --verbose for AWS's own message.), and AWS's text goes to the debug log, through the operation's secret masker. This also covers the EMR cluster's describe-before-delete and termination-poll failures and the FSx file system's deletion-poll failure. A failed or terminal state that EMR or FSx reports (EMR's state-change reason, which can name an IAM role ARN, and FSx's failure details) is named the same way, by EMR's reason code where there is one. A cdkd-authored or transport error keeps its text (on EFS still masked, as before). The AWS error stays as thecause, so an IAM grant that is still propagating is retried as before, and an EMR or FSx create that left a resource behind still names it forcdkd rollback --revert-failed. An "already exists" is still stated as such. - A failed Route 53 (HostedZone, RecordSet) or KMS (Key, Alias) create, update or delete no longer writes the caller's account id, assumed-role ARN and session name into the persisted deployment events (issue #2319) --
src/provisioning/providers/route53-provider.ts,kms-provider.ts, and unit cases. The thrown message quoted AWS's raw text, which names the caller on an access denial (and the account id in a KMS alias ARN), anddeployments/{runId}.jsonloutlives the run. The message now carries the error class (AccessDenied. Re-run with --verbose for AWS's own message.), and AWS's text goes to the debug log, masked where the operation has a secret masker. This also covers the hosted zone's Accelerated Recovery and tag-removal failures and the record set'sHostedZoneNamelookup failure. A cdkd-authored or transport error keeps its text. The AWS error stays as thecause, so an IAM grant that is still propagating, or a zone locked during an Accelerated Recovery transition, is retried as before. An occupied name (a duplicate record, a KMS alias that already exists) still says it already exists, so a create-first replacement still recognises the name collision. - A failed S3 Tables (TableBucket, Namespace, Table), API Gateway (Account, Authorizer, Resource, Deployment, Stage, Method) or API Gateway v2 (Api, Stage, Integration, Route, Authorizer) create, update or delete no longer writes the caller's account id, assumed-role ARN and session name into the persisted deployment events (issue #2319) --
src/provisioning/providers/s3-tables-provider.ts,apigateway-provider.ts,apigatewayv2-provider.ts, and unit cases. The thrown message quoted AWS's raw text, which names the caller on anAccessDeniedException, anddeployments/{runId}.jsonloutlives the run. The message now carries the error class (AccessDeniedException. Re-run with --verbose for AWS's own message.), and AWS's text goes to the debug log, masked where the operation has a secret masker. A cdkd-authored or transport error keeps its text. The AWS error stays as thecause, so an IAM grant that is still propagating is retried as before, and an occupied name (ConflictException ... already exists) still says it already exists, so a create-first replacement still recognises the name collision. - Removing
CloudWatchRoleArnfrom anAWS::ApiGateway::Accountnow clears the account's CloudWatch role, matching CloudFormation (issue #1160) -- A real CloudFormation A/B showed CloudFormation clears the region's API Gateway CloudWatch role ARN when the template stops declaring it.UpdateAccountis a patch call, and cdkd sent it with no operations for the removed property, so the old role ARN stayed set. cdkd now sendsreplace /cloudwatchRoleArn ''for a removedCloudWatchRoleArn, the same clear its delete already sends. Changed:src/provisioning/providers/apigateway-provider.ts,tests/unit/provisioning/apigateway-provider.test.ts,tests/integration/apigateway/. - Removing
EnableDnsHostnamesorEnableDnsSupportfrom anAWS::EC2::VPCnow resets it to the VPC default, matching CloudFormation (issue #1160) -- A real CloudFormation A/B showed CloudFormation resets a removed attribute: DNS hostnames back to off and DNS support back to on.ModifyVpcAttributechanges only the attribute it is sent, so cdkd used to leave the old value live (for example, DNS support kept off after the template stopped declaring it). cdkd now sends the default for each removed attribute, and sends nothing when the removed value already was the default.cdkd drift --revertwrites no such reset. Changed:src/provisioning/providers/ec2-provider.ts,tests/unit/provisioning/ec2-vpc-dns-removal-1160.test.ts,tests/integration/ec2-vpc/.
Recently Implemented (2026-10-18):
AWS::Events::EventBus: a template-declaredPolicyis now applied to the bus (issue #5044) --src/provisioning/providers/eventbridge-bus-provider.ts. Before, the provider declaredPolicyhandled but never sent it, so the deploy succeeded and the bus had no resource policy: a cross-accountPutEventsthe policy granted was refused. The bus is now created and then given the whole document throughPutPermission(an object or a JSON string). An update with a changedPolicyreplaces the whole bus policy, statements a separateAWS::Events::EventBusPolicyadded included, as CloudFormation does; an update that leavesPolicyunchanged sends nothing for it, and removingPolicyfrom the template leaves the live policy in place, as CloudFormation leaves it, with a warning saying so. A create or update refused by AWS now names the error class and points to--verboseinstead of relaying AWS's text, which can carry the caller's assumed-role ARN into the persisted deployment journal (issue #2319). Tests:tests/unit/provisioning/eventbridge-bus-policy-5044.test.ts, integtests/integration/eventbus-policy.- A failure that cannot be converted to text no longer turns a best-effort handler in
cdkd deployorcdkd diffinto a hard failure, nor replaces a provider's rejection with aTypeError(src/deployment/deploy-engine/{create,delete,deploy-flow,execute,observed-capture,outputs,provision,replacement,rollback,update-in-place,update-replace}.ts,src/cli/commands/diff-recursive.ts, go-to-k/cdkd#3361). These handlers built their message withx instanceof Error ? x.message : String(x)(or itsx.message || x.namevariant), andString()throws for a null-prototype object, so the handler itself threw: a failed per-resource, post-rollback or post-output-failure state save, a failed lock release, and a failed rollback-journal write, pop or delete stopped the deploy instead of warning; a failed observed-properties readback failed the deploy before its final state save; a failed output resolution off--strict-getattfailed instead of skipping the output; a failed final snapshot or old-resource delete during a replacement lost its handling; a failed DELETE was replaced by aTypeErrorbefore the not-found check and the per-resource failure report could read it; andcdkd diff's partial parameter binding and condition evaluation fallbacks failed the diff. Each now goes throughdescribeAwsFailure(x).detail(safeStringify(x)on the output handler'sStringarm), byte-identical whenever the value converts and a placeholder only when it cannot. Tests:tests/unit/deployment/deploy-engine-string-outthrow-3361.test.ts. - A failure that cannot be converted to text no longer turns a best-effort fallback of the intrinsic-function resolver into a hard failure (
src/deployment/intrinsic-resolver/*, go-to-k/cdkd#3361). These handlers built their message withx instanceof Error ? x.message : String(x), andString()throws for a null-prototype object, so the handler itself threw: a failed CloudFormationListExports/DescribeStacksfallback stoppedFn::ImportValue/Fn::GetStackOutputwith aTypeErrorinstead of the not-found error; an exports-index lookup failure skipped the state scan, a failed index write-through became an unhandled rejection, and one unreadable producer state stopped the scan instead of being skipped; a condition whose evaluation failed rejected the deploy instead of being assumed false; a failedIpv6CidrBlocks/ launch-template version read lost its[]/$Latestfallback; anFn::Subvariable that failed to resolve stopped the deploy instead of keeping its placeholder, an abandoned dynamic reference stopped it instead of being recorded and reported, and a failed pinned recorded-reference read threw instead of answeringfailed; andFn::GetAZs, theFn::GetAttrefusal for a constructed attribute whose live read failed (DescribeSecurityGroups,DescribeInstances,GetNamespaceor the provider read, viadescribeFailureObserved) and the lookup-role denial message replaced the failure they describe with the converter'sTypeError. Each now goes throughdescribeAwsFailure(x).detail(orsafeStringify(x)for the bare form), byte-identical whenever the value converts (so every message's secret masking reads exactly the text it read before) and a placeholder only when it cannot. Tests:tests/unit/deployment/intrinsic-resolver-string-outthrow-3361.test.ts. - A failure that cannot be converted to text no longer turns a rollback or destroy handler into a
TypeError(src/deployment/rollback-executor/{replay-reverse-replacement,journaled-orphans,messages}.ts,src/cli/commands/{destroy,rollback}.ts, go-to-k/cdkd#3361). These handlers built their message withx instanceof Error ? x.message : String(x)(or a bareString(x)), andString()throws for a null-prototype object, so the handler itself threw. In a reverse replacement, a failed delete of the new resource after the old one was re-created failed the op instead of warning that the new resource is left untracked, and a failed re-create, or a failed delete of what it made, failed the op withCannot convert object to primitive valueinstead of a placeholder naming the failure. The per-op failure line (rollbackFailureText) andcdkd rollback's journal-backend error lines (backendErrorText) rendered such a value with the sameString()and are converted too. An unreadable rollback journal, or a failed journal rewrite after a successful deploy, threw instead of keeping the journal with a warning;cdkd destroy --purge-eventsthrew instead of warning that the event history was not purged; and a synthesis failure incdkd destroythrew instead of falling back to the state's stack list. Each now goes throughdescribeAwsFailure(x).detail(orsafeStringify(x)for the bare form), byte-identical whenever the value converts and a placeholder only when it cannot. Tests:tests/unit/deployment/rollback-executor-string-outthrow-3361.test.ts,tests/unit/cli/destroy-string-outthrow-3361.test.ts. - A failed EC2 create, update or delete (VPC, Subnet, InternetGateway, EIP, VPCGatewayAttachment, NatGateway, RouteTable, Route, SubnetRouteTableAssociation, SecurityGroup, SecurityGroupIngress, Instance, NetworkAcl, NetworkAclEntry or SubnetNetworkAclAssociation, for each operation the type supports) no longer writes the caller's account id, assumed-role ARN and session name into the persisted deployment events (issue #2319) --
src/provisioning/providers/ec2-provider.ts, and unit cases. The thrown message quoted AWS's raw text, which names the caller on anUnauthorizedOperation, anddeployments/{runId}.jsonloutlives the run. The message now carries the error class (UnauthorizedOperation. Re-run with --verbose for AWS's own message.), and AWS's text goes to the debug log. A cdkd-authored or transport error keeps its text. The AWS error stays as thecause, so an IAM grant that is still propagating is retried as before; a security group whose name is taken still says it already exists, so a custom-named group's replacement is still handled as a name collision; and a create denied both with and without tags still says so. - Removing a key from an
AWS::ElasticLoadBalancingV2::Listener'sListenerAttributesnow keeps its live value, matching CloudFormation (issue #1160) -- A real CloudFormation A/B showed CloudFormation KEEPS a removed listener attribute key, whether one key or the whole list is dropped. cdkd used to reset it (to the documented default forrouting.http.response.server.enabled, to an empty value for other keys) and now sends nothing for it, as it already does forLoadBalancerAttributesandTargetGroupAttributes. A removed CORS key (routing.http.response.access_control_*, which CloudFormation also keeps) or request-header rename (routing.http.request.<header>.header_name) prints one warning naming the keys, since a kept one can keep a cross-origin grant live or leave the backend reading a header the ALB no longer writes. A listener recorded on Cloud Control still returns to the SDK provider on its next change, now for the SDK path's speed; both routes keep the key. Changed:src/provisioning/providers/elbv2-provider.ts,src/provisioning/provider-registry.ts(rationale only), the ELBv2 listener unit suites,tests/integration/alb/,docs/cli-deploy-safety-routing.md,docs/provider-rules.md,docs/state-schema-internals.md,docs/troubleshooting-internals.md. - Removing
Description,KmsKeyIdentifier,LogConfigorDeadLetterConfigfrom anAWS::Events::EventBusnow clears it, matching CloudFormation (issue #1160) -- A real CloudFormation A/B showed CloudFormation clears each removed property: no description, the AWS owned key instead of the customer managed key,LogConfigatOFF/NONE, and no dead-letter queue.UpdateEventBuskeeps any field it is not given, so cdkd used to leave all four live, including encryption with a key the template no longer names. cdkd now sends the clearing value for each one ('','',OFF/NONE, an empty queue ARN).cdkd drift --revertwrites no such clear. Changed:src/provisioning/providers/eventbridge-bus-provider.ts,tests/unit/provisioning/eventbridge-bus-removal-1160.test.ts,tests/integration/eventbridge/.
Recently Implemented (2026-10-17):
- A failure that cannot be converted to text no longer turns a best-effort handler in
cdkd export,cdkd import,cdkd local start-api,cdkd orphan, asset publishing or config loading into a hard failure (src/cli/commands/{export,import,local-start-api}.ts,src/analyzer/orphan-rewriter.ts,src/assets/docker-asset-publisher.ts,src/cli/{config-loader,upload-cfn-template}.ts,src/synthesis/context-store.ts, go-to-k/cdkd#3361). These handlers built their message withx instanceof Error ? x.message : String(x)(or a bareString(x)), andString()throws for a null-prototype object, so the handler itself threw: an unreadablecdk.json/cdk.context.jsonfailed the command instead of warning; a failed DescribeType skippedcdkd export's fallback identifier table; a provider lookup failure aborted the orphan reference rewrite instead of reporting the reference as unresolvable; a failed watcher close or tmpdir removal inlocal start-api's shutdown skipped the cleanup after it, the profile-credentials tmpdir removal among them, and a failed--warmpre-warm stopped the server from starting;cdkd export's lock release, template-upload cleanup and per-stack state removal stopped the run; and a docker push rejection was replaced by aTypeErrorbefore the ECR auth retry could classify it. The transient-template version purge (upload-cfn-template.ts), which nothing known today makes reject, is converted as defence in depth. Each now goes throughdescribeAwsFailure(x).detail(orsafeStringify(x)for the bare form), byte-identical whenever the value converts and a placeholder only when it cannot. Tests:tests/unit/cli/singletons-string-outthrow-3361.test.ts. - A failed create, update or delete of a Glue Database, Table, SecurityConfiguration, Workflow, Job, Crawler, Connection or Trigger no longer writes the caller's account id, assumed-role ARN and session name into the persisted deployment events, and no longer replaces the failure with a
TypeErrorwhen the thrown value cannot be converted to text (issues #2319, #3341) --src/provisioning/providers/glue-provider.ts, and unit cases. The thrown message quoted AWS's raw text, which names the caller on an access-denied error, anddeployments/{runId}.jsonloutlives the run. The message now carries the error class (AccessDeniedException. Re-run with --verbose for AWS's own message.), and AWS's text goes to the debug log through the operation's secret masker. A cdkd-authored or transport error keeps its text. The AWS error stays as thecause, so an IAM grant that is still propagating is retried as before, and an occupied table name is still classified as a name collision. - Removing a property from an
AWS::ElasticLoadBalancingV2::LoadBalancerorTargetGroupnow does what a CloudFormation update does (issue #1160) -- A real CloudFormation A/B decided each case. A removed load balancerIpAddressTypenow resets toipv4; before, it stayeddualstack. A removedHealthCheckPorton a GENEVE target group now resets totraffic-portlike every other protocol; before, it kept its value. A key removed fromLoadBalancerAttributesorTargetGroupAttributesnow keeps its live value, because CloudFormation leaves it in place; before, cdkd reset it to a documented default or an empty value, and warned for a target group key it had no default for. A keptaccess_logs.s3.*/connection_logs.s3.*/health_check_logs.s3.*key prints one warning naming the keys, since the load balancer may keep delivering logs to the bucket they name.ListenerAttributesstill resets.drift --revertwrites noIpAddressTypereset for a value its recorded baseline lacks. Changed:src/provisioning/providers/elbv2-provider.ts,tests/unit/provisioning/elbv2-tg-lb-removal-1160.test.tsand the existing ELBv2 suites,tests/integration/alb/.
Recently Implemented (2026-10-16):
- AWS published 1 writable property the 2026-10-16 schema refresh now carries, so cdkd classifies it instead of treating it as unrecognized (PR #5005) --
AWS::CloudWatch::Alarm:ActionConfiguration. Changed:tests/fixtures/cfn-schemas/,src/provisioning/property-coverage.generated.ts(regenerated). OnAWS::CloudWatch::Alarm, no SDK provider writes it yet (not yet implemented by cdkd), so pre-flight classifies each as a silent drop and the issue #614 auto-route sends a resource whose template carries one through Cloud Control, which forwards the full property map. Since issue #3713 a key absent from the snapshot already routed so, unless the state record held it with the declared value; such a resource moves on its next deploy.AWS::CloudWatch::Alarmis not inSTICKY_CC_MIGRATION_EXEMPT, so an existing SDK-provisioned resource of that type whose template gains one moves to Cloud Control and pinsprovisionedBy: 'cc-api'ONE-WAY. The snapshot marks none of them create-only, socreateOnlyDropsand the record narrowing are unchanged; the capture is top-level-only, so it says nothing about a nested create-only path. - Removing an ApiGatewayV2 property from a template no longer resets properties CloudFormation keeps (issue #5004) -- A real CloudFormation A/B showed CloudFormation leaves the live value of these when they are dropped from the template, so cdkd now sends nothing for them either (the live value stays): Api
Description,Version,DisableExecuteApiEndpoint,IpAddressType,ApiKeySelectionExpression; StageStageVariables(per key),ClientCertificateId; IntegrationDescription,RequestParameters(per key); RouteAuthorizationType,AuthorizerId,OperationName,RequestModelsandRequestParameters(per key); AuthorizerAuthorizerCredentialsArn,AuthorizerPayloadFormatVersion,AuthorizerResultTtlInSeconds,IdentityValidationExpression. ApiCorsConfiguration, StageAutoDeploy/AccessLogSettings/RouteSettingsand RouteAuthorizationScopesare reset by CloudFormation too and keep their resets. Changed:src/provisioning/providers/apigatewayv2-provider.ts,docs/provider-rules.md, theapigatewayv2-update-removalinteg fixture and the ApiGatewayV2 unit tests. RouteApiKeyRequiredwas not measured and is still reset, andcdkd drift --revertstill removes a console-added map key. - Removing nine
AWS::AutoScaling::AutoScalingGroupproperties from a template no longer resets them, matching CloudFormation (issue #5004) -- A real CloudFormation A/B showed CloudFormation keeps the live value of a removedHealthCheckType,HealthCheckGracePeriod,Cooldown,TerminationPolicies,NewInstancesProtectedFromScaleIn,CapacityRebalance,DesiredCapacityType,DefaultInstanceWarmuporDeletionProtection, while cdkd reset each to a default (for exampleHealthCheckTypeback toEC2). cdkd now sends nothing for them, so the live value stays.InstanceMaintenancePolicy,MaxInstanceLifetime,CapacityReservationSpecificationandAvailabilityZoneDistributionstill reset, as CloudFormation does. Changed:src/provisioning/providers/asg-provider.ts,docs/provider-rules.md,tests/unit/provisioning/asg-provider-update-removal.test.ts,tests/integration/launchtemplate-asg-inplace/verify.sh. - Removing a property from the template no longer resets it for 7 properties CloudFormation keeps (issue #5004) -- A real CloudFormation A/B showed CloudFormation leaves the live value in place when these are dropped from a template, where cdkd used to reset them.
AWS::SQS::Queue:SqsManagedSseEnabled,DeduplicationScope,FifoThroughputLimit;AWS::SecretsManager::Secret:Description,KmsKeyId;AWS::IAM::Role:MaxSessionDuration;AWS::Lambda::Url:InvokeMode. cdkd now sends nothing for them on removal. The other SQS attributes, IAM RoleDescriptionand Lambda UrlCorsare reset by CloudFormation and keep cdkd's reset. Changed:src/provisioning/providers/{sqs-queue,secretsmanager-secret,iam-role,lambda-url}-provider.ts,docs/provider-rules.md, the matching unit tests and thefifo-sqs-event-source,iam-role-prefixed-name-update,sns-sqs-event,secrets-dynamic-refandcloudfront-function-urlinteg fixtures, which now assert the value is retained. AWS::DLM::LifecyclePolicy: removing a default-policy shorthand property no longer resets it (#5004):CreateInterval,RetainInterval,CopyTags,ExtendDeletion,CrossRegionCopyTargetsandExclusionskeep their live value when dropped from the template, matching a real CloudFormation A/B (cdkd previously reset them to 1 / 7 / false / false / [] / empty). Changed:src/provisioning/providers/dlm-lifecycle-policy-provider.ts,docs/provider-rules.md.- Removing
DeletionProtectionorBackupRetentionPeriodfrom anAWS::DocDB::DBCluster,DeletionProtection,IamAuthEnabledorBackupRetentionPeriodfrom anAWS::Neptune::DBCluster, orAutoMinorVersionUpgradefrom anAWS::Neptune::DBInstanceno longer resets the live value: cdkd now leaves it untouched, as CloudFormation does (measured in a real CloudFormation A/B). A removedDeletionProtectionpreviously switched protection off. Changeddocdb-provider.ts,neptune-provider.ts. Issue #5004. - Removing
PlatformVersion,HealthCheckGracePeriodSeconds,PropagateTags,EnableECSManagedTags,EnableExecuteCommand,CapacityProviderStrategy,PlacementConstraintsorPlacementStrategiesfrom anAWS::ECS::Servicetemplate no longer resets them (issue #5004) --src/provisioning/providers/ecs-provider.ts(removalDefaultsis now empty;updateServicesends nothing for these members),tests/unit/provisioning/ecs-provider.test.ts, and theecs-service-update-propsinteg fixture (now asserts the live value is retained). A real CloudFormation A/B showed CloudFormation KEEPS the live value of all eight on removal, while cdkd (issue #1160) reset them to a default, so a removal changed a running service where CloudFormation would not.ServiceConnectConfigurationremoval still resets to disabled, which the same A/B confirmed CloudFormation does;VolumeConfigurationsandVpcLatticeConfigurationswere not measured and are unchanged. - Removing
SnapshotRetentionLimit,AutoMinorVersionUpgradeorNotificationTopicArnfrom anAWS::ElastiCache::CacheClusterno longer resets it, matching CloudFormation (issue #5004) --src/provisioning/providers/elasticache-provider.ts,tests/unit/provisioning/elasticache-provider-update-removal.test.ts,docs/provider-rules.md. A real CloudFormation A/B showed CloudFormation keeps the live value of all three when they are removed from the template, while cdkd reset them (retention to 0, auto minor version upgrade to true, notifications set inactive). cdkd now sends nothing for a removed one and leaves the live value.LogDeliveryConfigurationskeeps its reset (CloudFormation disables delivery). - Removing
DeletionProtection,EnableIAMDatabaseAuthentication(RDS DBCluster / DBInstance) orMonitoringInterval(RDS DBInstance) from the template no longer resets it, matching CloudFormation (issue #5004) -- A real CloudFormation A/B showed CloudFormation KEEPS the live value of these properties when they are removed, while cdkd reset them (DeletionProtectionto false,EnableIAMDatabaseAuthenticationto false,MonitoringIntervalto 0). cdkd now sends no value for them, so the live setting is left as it is; a protected cluster or instance stays protected after the property is removed, andcdkd destroy --remove-protectionstill lifts it. DBClusterBackupRetentionPeriod(CloudFormation resets it to 1) is unchanged, and DBClusterMonitoringIntervalis unchanged because it could not be measured. Changed:src/provisioning/providers/rds-provider.ts,tests/unit/provisioning/rds-provider-update-removal.test.ts,tests/integration/rds-aurora/,tests/integration/rds-dbinstance-backfill/. - Deploying an
AWS::ElasticLoadBalancingV2::TargetGroupwithTargetType: lambdano longer fails intermittently with "Lambda function ... is not in Active state" (issue #5002). The function's create returns while it is stillPending, and ELBv2 rejects registering aPendingfunction, so the first deploy sometimes rolled back and a re-run passed. The target group now waits for each Lambda target to reachState=Activeimmediately beforeRegisterTargets(create and update); no other target group and no Lambda create pays for it. A function that endsFailedor never turns Active fails the target group with an error naming the function, without AWS's message text. Source:src/provisioning/providers/elbv2-provider.ts,src/provisioning/providers/custom-resource-provider.ts. cdkd local *with a cross-account--role-arnnow resolves${AWS::AccountId}to the deploy account, where the stack lives, except under--from-cfn-stack(issue #5001) --src/local/local-role-arn.ts,src/cli/commands/local-invoke.ts,local-start-api.ts,local-run-task.ts,local-invoke-agentcore.ts,cdk-localbumped to 0.153.1.--from-statealready resolved the role's account (#3230); what changes is the case with NO state source (alocal run-taskimageFn::Subor a bare--assume-task-role), which was the caller's account and is now the deploy account, so a pinned ECR image URI and account-derived ARNs point at the deployed resources.--from-cfn-stackkeeps the caller's account (it reads as you; CloudFormation sees only your account). The engine commands (start-service,start-alb,start-cloudfront,start-agentcore) follow the same rule through cdk-local 0.153.1. Container credentials, assumed roles and secrets still resolve as you. Same-account use and runs without--role-arnare unchanged.Fn::GetAtton anAWS::Lambda::WebFunctionEndpoint's wholeRegionalEndpointsmap (attrRegionalEndpoints) and on a path through one of its object members (RegionalEndpoints.<region>.ScalingConfig.MaxEnvironments,.ThrottleConfig.RateLimit,.RevisionWeights) resolves again instead of returning the endpoint ARN (issue #4992). Since the SDK provider landed in 0.300.0 these loggedUnknown attribute ... returning physical ID; 0.301.1 (#4978) restored only the scalar members. The provider now also recordsRegionalEndpointsas the nested region -> entry object in CloudFormation casing, as the Cloud Control path did on 0.299.x, leaving out every member AWS did not assign. A record written by 0.300.x or 0.301.x gains it on the next deploy that reaches theFn::GetAtt, through the stale-attribute heal. Source:src/provisioning/providers/lambda-web-function-endpoint-provider.ts.- Two secret values that overlap inside one property value no longer leave part of a secret in state (issue #4986) --
src/deployment/secret-redaction/redact-state.ts. When two recorded secret plaintexts overlapped in one string (us-east-1pw12andpw12-str4ddle-4973insideus-east-1pw12-str4ddle-4973), the redaction replaced the first and persisted the rest of the second in plaintext. Overlapping matches now take one rule: where their{{resolve:...}}references render the overlapping run exactly (one match spanning it, or matches meeting end to start) those references are persisted; otherwise, or where a match cuts a freshNoEchovalue or theFn::Base64encoding of a secret, the whole value is persisted as the redaction mask (***). A record holding the old partial spelling is repaired by the resource's next deploy, whichcdkd scrubdoes not do: where it equals what the old walk stored for today's value, it is rewritten to the mask with nothing sent (src/deployment/deploy-engine/update.ts), and a real template change is sent as usual. Earlier versions ofstate.jsonin the versioned state bucket keep the partial value:cdkd scrub --purge-historydeletes them, and the exposed secret should be rotated. A rollback to a baseline holding that mask refuses as it does for the other masks, and the refusals (rollback,Fn::GetAttof a masked attribute,cdkd export) now name this case and its remedy. The same union-of-overlaps masking now applies to a Cloud Control call's withheld-identifier scrub (src/provisioning/cloud-control-provider.ts) and to the masked display of a secret-bearing export name or state key (src/deployment/outputs-export-alias/secret-scan.ts), which could print the tail of one of two overlapping values. Fn::GetAttinto anAWS::Lambda::WebFunctionEndpoint'sRegionalEndpoints.<region>.<Member>resolves again instead of returning the endpoint ARN (issue #4978). Since the dedicated SDK provider landed in 0.300.0,Fn::GetAttonRegionalEndpoints.us-west-2.DomainName(and the other members) loggedUnknown attribute ... returning physical IDand served the endpoint ARN, so a CloudFront origin built from it failed withorigin name cannot contain a colon. The provider now recordsRegionalEndpoints.<region>.{DomainName,AuthType,State,StateReason,UpdateStatus,UpdateStatusReason}fromGetWebFunctionEndpointResponse.regionalEndpoints; a member AWS did not assign (aMultiRegionendpoint reportsdomainName: nullper region) is not recorded. A record written by 0.300.x gains the keys on the next deploy that reaches theFn::GetAtt, through the stale-attribute heal. Source:src/provisioning/providers/lambda-web-function-endpoint-provider.ts.cdkd diff --recursive --failno longer reports a change on an unchanged nested stack in which one child resource reads a parameter the parent spelled as a literal embedding{{resolve:...}}tokens beside a sibling parameter spelling the same secret differently, through anFn::Select/Fn::Split/Fn::Ifover anFn::Sub(orFn::Join) namingAWS::AccountId(issue #4975). The #4962 fix leftAWS::AccountIdunbound in the nested-stack parameter re-read; it is now bound too. After upgrading, the first deploy of such a stack shows a change on the child resources reading the literal parameter, because their state now records the parameter's own{{resolve:...}}spelling instead of the sibling's; the deployed VALUE is unchanged, and a create-only property is replaced once with an identical value. The account comes from the same lookup and cache asAWS::AccountIditself, so every pass binds the same value whether or not the account was already known. That can add one STSGetCallerIdentitycall per process, made only by a nested child that inherits parent secrets and has a resource namingAWS::AccountIdbeside anFn::Select/Fn::Split/Fn::If, and only when the account is not yet known; any other stack makes no added call. If the lookup fails, the account stays unbound (the earlier behavior) and no new error is raised. Source:src/deployment/intrinsic-resolver/{account-drain,parameter-secrets,params-conditions}.ts,src/deployment/secret-redaction/nested-stack.ts.cdkd diff --recursive --failno longer reports a change on an unchanged nested stack in which a child'sOutputsread a parameter the parent spelled as a literal embedding{{resolve:...}}tokens, beside another output reading a sibling parameter that spells the same secret differently through anFn::Join/Fn::Subnaming two or more pseudo parameters, e.g.`${Aws.REGION}.${Aws.ACCOUNT_ID}.${param.valueAsString}`(issue #4973). The redaction of a child's outputs could hold at most one pseudo parameter unknown in such an output, so it fell back to the sibling's spelling for the literal parameter across the whole child. It now readsAWS::Region,AWS::Partition,AWS::URLSuffixand (where the child already looks it up, #4975)AWS::AccountIdfrom the values the child's parameter resolution records (#4962); no AWS call is added. After upgrading, the first deploy of such a stack shows a change on the child resources reading the literal parameter, because their state (and the child's outputs) now record the parameter's own{{resolve:...}}spelling instead of the sibling's; the deployed VALUE is unchanged, and a create-only property is replaced once with an identical value.AWS::StackName,AWS::StackId, and anAWS::AccountIdthe child does not look up, stay unknown. Source:src/deployment/secret-redaction/{placeholder-positions,nested-stack}.ts,src/deployment/intrinsic-resolver/parameter-secrets.ts.- An
AWS::IAM::Role,AWS::SSM::Parameter,AWS::Kinesis::Streamand the EC2VPC,Subnet,InternetGateway,EIP,NatGateway,RouteTable,SecurityGroup,InstanceandNetworkAclnow send their tags on the create call itself (issue #4971) --src/provisioning/providers/{iam-role-provider,ssm-parameter-provider,kinesis-provider,ec2-provider}.ts. cdkd created these resources untagged and tagged them afterwards (TagRole,AddTagsToResource,AddTagsToStream,CreateTags), so an organization SCP requiring a tag on the create action (aws:RequestTag/...) denied the create, wherecdk deploysucceeds. The tags now go inCreateRole.Tags,PutParameter.Tags,CreateStream.Tagsand each EC2 create'sTagSpecifications, and the follow-up tagging call is gone: a tagged create makes one AWS call fewer. A tagged create needs the tagging permission the follow-up call already needed (iam:TagRole,ssm:AddTagsToResource,kinesis:AddTagsToStream,ec2:CreateTags). The old EC2 path only warned whenCreateTagsfailed, so a role withoutec2:CreateTagscould still create: such a role's tagged create is re-sent once untagged and then tagged withCreateTagsas before. Updates still tag with the separate calls (SSM refusesTagswithOverwrite). An EC2 tag the create call refuses now fails the create instead of warning. A possible orphan of a lost EC2 create response is now matched by carrying no tags or exactly the template's tags, and the deploy's post-create EC2 read re-reads a not-found answer briefly before dropping the drift baseline. - An
AWS::S3::Bucketnow sends its tags onCreateBucketitself, and an access denial from an explicit deny fails fast instead of being retried (issue #4969) --src/provisioning/providers/s3-bucket-provider.ts,src/deployment/retryable-errors/transient.ts. cdkd created a bucket untagged and applied its tags afterwards withPutBucketTagging, so an organization SCP requiring a tag ons3:CreateBucket(aws:RequestTag/...) denied the create, wherecdk deploysucceeds. The template's tags now go inCreateBucketConfiguration.Tags, and a create that carried them no longer repeats them withPutBucketTagging; an adopted bucket still gets thePutBucketTagging. Tags onCreateBucketneeds3:TagResource: a role that lacks it but holdss3:PutBucketTagginggets the old path back automatically (one untagged retry of each bucket's create, thenPutBucketTagging); when that untagged create is denied too, the error names the missings3:TagResource(and fails at once when the untagged denial is an explicit deny); an endpoint that refuses theTagsmember itself takes the same untagged path. Separately, anAccessDeniedthat says the call was refusedwith an explicit deny(a service control policy, resource control policy, identity- or resource-based policy, permissions boundary, session policy, VPC endpoint policy or role trust policy) was matched as IAM propagation and retried for about 48 seconds; it now fails on its first answer, and an S3 bucket error names the policy type that denied it. A role holdings3:TagResourcemakes no extra AWS call, and its tagged bucket's create makes one call fewer. cdkd diff --recursive --failno longer reports a change on an unchanged nested stack in which one child resource reads a parameter the parent spelled as a literal embedding{{resolve:...}}tokens beside a sibling parameter spelling the same secret differently, through anFn::Select/Fn::Splitover anFn::Sub(orFn::Join) namingAWS::Region,AWS::PartitionorAWS::URLSuffix(issue #4962). After upgrading, the first deploy of such a stack shows a change on the child resources reading the literal parameter, because their state now records the parameter's own{{resolve:...}}spelling instead of the sibling's; the deployed VALUE is unchanged, and a create-only property is replaced once with an identical value. The child's parameter resolution records those three values, each a function of the stack's region, and the redaction re-reads such a leaf with them, ascdkd deploy,cdkd scruband the diff side all resolve them; no AWS call is added.AWS::AccountIdis bound since #4975;AWS::StackName,AWS::StackIdandAWS::NotificationARNsare not bound, so a leaf naming one still falls back to the sibling's spelling. Source:src/deployment/secret-redaction/nested-stack.ts,src/deployment/intrinsic-resolver/{parameter-secrets,params-conditions}.ts.cdkd deploy,cdkd diffandcdkd destroynow mask two more resource names taken from a secret (issue #3869). (1) With--verbose, a parent resource reading a nested stack's output withFn::GetAtt Child.Outputs.<Key>printed the name of a child resource taken from a secret, when the child's provider had rewritten or case-folded it (an IAM name with the stack prefix, an RDS identifier), in theResolved Fn::GetAtt ... resolved toline and the reader's own lines. A nested-stack row created or updated by this version records which outputs hold such a name (ResourceState.secretNamedAttributes, attribute keys only; no state schema bump), so a later deploy where the row is unchanged masks them too. An existing nested stack is covered after its next update: there is no backfill, which would read the child's state on every no-change deploy. (2) A name taken from aNoEchoparameter whose value has since changed (a record written before schema v11 still holds the old value), or from a short value, printed on a replacement'sDeleting old/was replacedlines (shown without--verbose) and oncdkd destroy's delete lines. Each record's own value at a position the template fills from aNoEchoparameter is now judged as secret. Beyond that key list, print-only: nothing else persisted, and nothing compared or sent, changes. Source:src/deployment/secret-name-needles.ts,src/deployment/secret-redaction/noecho-leaves.ts,src/provisioning/providers/nested-stack-provider.ts,src/deployment/deploy-engine/masking.ts,src/deployment/deploy-engine/attribute-healer.ts,src/cli/commands/destroy-runner.ts,src/cli/commands/diff-recursive.ts.- A provider failure whose thrown value cannot be converted to text now reports that failure instead of
TypeError: Cannot convert object to primitive value(issue #3341). Catch blocks undersrc/provisioning/providers/that rethrow read the caught value with a bareString(err), which throws for a null-prototype object or a hostiletoStringand replaced the real AWS failure (and the text persisted todeployments/{runId}.jsonl). They now read it throughdescribeAwsFailure(err).detailorsafeStringify(err), byte-identical for every value that converted before. Enforced by an exact, shrink-only ratchet intests/unit/provisioning/provider-swallowing-catch-out-throw.test.ts. - The four engine
cdkd local start-service/start-alb/start-cloudfront/start-agentcorecommands no longer hand the--role-arnrole to the emulated workload (issue #3240) --src/cli/commands/local-invoke.ts,src/local/local-role-arn.ts,src/cli/commands/local-state-source.ts,src/cli/commands/local-start-{service,alb,cloudfront,agentcore}.ts,local-start-api.ts,local-run-task.ts,local-invoke-agentcore.ts. With--role-arnand no profile, these commands copied the role's credentials into containers, injected ECS task secrets and{{resolve:...}}values fetched with the role, resolved${AWS::AccountId}to the role's account, read--from-cfn-stackas the role and made the--assume-rolehop as the role. They now resolve all of it as the caller (pass--profileto choose the identity), via cdk-local 0.152.0'sworkloadIdentity: 'caller'. A literal layer ARN onlocal invoke/start-apiis likewise fetched as the caller now, and those four cdkd-owned commands record their assume with cdk-local throughapplyRoleArnForLocal. The startup warning the engine commands printed is gone. Under--role-arnthe--assume-role/--ecr-role-arn/--layer-role-arnhops are signed by your own identity, so the target role's trust policy must trust you, andlocal start-cloudfront --from-state's deployed S3-origin / KeyValueStore reads now run as you. Synthesis, the ECR login without--ecr-role-arn, and cdkd's--from-statestate reads still use the role. Tests:tests/unit/local/local-role-arn.test.ts,tests/unit/cli/local-embed-config.test.ts,tests/unit/local/ecr-puller.test.ts,tests/unit/local/local-role-arn-from-cfn-stack.test.ts. cdkd local start-cloudfrontaccepts--from-stateagain,start-alb's--from-statereaches Lambda target groups, andcdkd local invoke <image-fn> | jqgets a clean payload (issues #2600, #2429, #2525, #3393; #2602) --package.json(cdk-local 0.151.1),src/cli/commands/local-start-cloudfront.ts,local-start-alb.ts,local-invoke.ts,local-state-source.ts,src/utils/local-aws-failure.ts. With cdk-local 0.151.0 consulting a host's state provider on every state-backed path,start-cloudfrontno longer refuses--from-state/--state-bucket/--state-prefix: they resolve a deployed S3 origin's bucket,cf.kvs()stores and the Function URL / Lambda@Edge environment. Under--role-arnwith no--profile, the startup warning now also names the engine's origin, KeyValueStore and${AWS::AccountId}reads under either state flag.start-albdrops its boot warning that--from-statemisses a Lambda target, since it no longer does. cdk-local's own log lines now go through cdkd's logger, solocal invokekeepsBuilding container image ...off stdout,--verbosereaches cdk-local's debug lines, and its warnings print in cdkd's format (noWARN:prefix) unlessCDKL_LOG_LEVEL/CDKL_LOG_STREAMis set. Bundled-engine fixes picked up by the bump: thestart-cloudfront --helptext says Lambda@Edge runs locally, a--profilename is rendered safely in cdk-local's messages, and a--verbosebuild line masks--build-argand cache-credential values.
Recently Implemented (2026-10-15):
cdkd diff --recursive --failno longer reports a change on an unchanged nested stack in which one child resource reads a parameter the parent spelled as a literal embedding{{resolve:...}}tokens beside a sibling parameter spelling the same secret differently, through anFn::Ifwhose branches resolve to that secret spelled two ways, anFn::Ifwhose other branch is an attribute, a resource or a pseudo parameter, or an array of which only someFn::Ifelements resolve toAWS::NoValueor which holds another intrinsic beside one (issue #4954). After upgrading, the first deploy of such a stack shows a change on the child resources reading the literal parameter, because their state now records the parameter's own{{resolve:...}}spelling instead of the sibling's; the deployed VALUE is unchanged, and a create-only property is replaced once with an identical value. The resolver now records, per resource, which branch eachFn::Iftook and which array elements dropped out asAWS::NoValue, and the state redaction reads those records instead of guessing from the resolved value, so an array element that drops out keeps the rest of the array paired with its template in any stack. AnFn::Subnaming a pseudo parameter insideFn::Select(#4962), and a taken branch reading the parameter through an intrinsic the redaction cannot evaluate, still fall back to the sibling's spelling. Source:src/deployment/secret-redaction/{nested-stack,redact-path,resolver-outcomes}.ts,src/deployment/intrinsic-resolver/{functions,parameter-secrets}.ts,src/deployment/intrinsic-function-resolver.ts.- A rollback no longer re-adopts the old resource of a replacement when the deploy's delete had already stripped it before AWS refused the delete (issue #4946) --
src/deployment/rollback-executor/replaced-survivor.ts,src/provisioning/delete-started.ts. Several providers change a resource before the call that deletes it: an IAM role, user or group loses its policies, memberships and instance profiles, a managed policy is detached from every principal, an EventBridge rule loses its targets, a CloudFront distribution is disabled, an ECS service is scaled to 0, a CloudTrail trail stops logging, a VPC Lambda function is detached from its VPC, and so on. When AWS then refused the delete by name (an SCP denyingiam:DeletePolicybut allowing the detach, for example) and a later resource failed the deploy, the rollback re-adopted the stripped resource in place of the working new copy, and the nextcdkd deploysaw no difference to repair. Such a delete now marks its error, and the replacement journalsoldResourceMaySurvive: unconfirmed: the rollback refuses that operation with the journal kept and offerscdkd rollback --orphan, as it already did for the same providers' own in-update replacement. A refusal before the delete changed anything still re-adopts the old resource. Providers changed: IAM role, managed policy, policy, user / group /UserToGroupAddition, instance profile, EventBridge rule and event bus, CloudFront distribution, CloudTrail trail, ECS service, Glue trigger, Lambda function, Route 53 hosted zone, RDS DB cluster (a global-cluster detach), DynamoDB global table, SQS queue policy and SNS topic policy. Thecdkd rollbackplan line for such an operation now also names a delete that only partly ran. No extra AWS call is made. - A failed create that left a resource behind is no longer retried into making another one --
src/deployment/retry.ts,src/provisioning/auxiliary-failure.ts, and theiam-access-key,appsync,cognito,ec2,emr-cluster,emr-instance-fleet-config,emr-instance-group-configandlambda-web-function-revisionproviders. A create whose attempt made its resource and then failed (marked for the failed-CREATE journal) was retried, and for a type whose create sends no name and no idempotency token, each retry made another resource while the journal recorded only the last one. AnAWS::IAM::AccessKeywithStatus: Inactive, deployed by a principal deniediam:UpdateAccessKeyandiam:DeleteAccessKey, minted a second key on the retry (the deny reads like IAM propagation, which is retried), filled the user's 2-key quota, and failed onLimitExceededwith the first key unrecorded. That create now fails on its own error once it leaves its resource behind, for the access key, AppSync GraphQL API, Cognito user pool, EC2 VPC and EIP, EMR cluster, instance fleet and instance group, and Lambda web function revision, socdkd rollback --revert-failedand a fix-forward find the one resource. An EIP, a web function revision and an EMR TASK instance group that a later step failed after are now released, deleted or scaled to 0 first, so a transient failure there is still retried. For every other type, a retry whose create made a resource other than the one an earlier attempt left behind stops there with a warning; a create that resumes its earlier resource (KMS, S3, an idempotency token, an SNS or SQS policy over several targets) is retried as before. Issue #4945. cdkd destroyandcdkd state destroynow delete a cross-region stack's nested children in the stack's region, not the CLI's (issue #4941) --src/cli/commands/destroy-runner.ts,src/cli/commands/state.ts, newtests/unit/cli/destroy-nested-cross-region-scope-4941.test.tsand integ fixturedestroy-nested-cross-region. Destroying a stack recorded in a region other than the CLI's switched region for the parent's own resources, but its nested children ran with the provider registry and AWS clients the command had built in the CLI's region. A provider that builds its own SDK client (Kinesis, CodeBuild, CloudTrail, Glue, WAFv2, S3 Tables, ...) captures its region when it is constructed, so a child's resource was deleted by name in the CLI's region: a same-named resource there that cdkd does not manage was deleted irreversibly, and the real one was left behind. A child'sDeletionPolicy: Snapshotfinal snapshot also went through the CLI-region clients. Such a stack is now destroyed inside a stack AWS scope for its region, as deploy and rollback run theirs, with a provider registry built in that scope, and its nested children are handed that registry and the stack's clients.cdkd state destroy --region Xrun from a shell in another region also built its registry in the shell's region, so it deleted by name there for a stack recorded in X; it now points the SDK at X first, ascdkd destroy --regiondoes. A stack in the CLI's region is otherwise unchanged.cdkd orphannow reads liveFn::GetAttvalues in the orphaned stack's region, not the CLI's--region/AWS_REGION(issue #4937) --src/cli/commands/orphan.ts, newtests/unit/cli/orphan-stack-aws-scope-4937.test.ts, and a new phase 4b intests/integration/orphan-getatt-takeover/.cdkd orphanpicked the stack's region from its synthesizedenv.region, its state record or--stack-region, but built its providers on the CLI-region clients and never entered that stack's AWS scope. So when the rewrite read an orphan's attribute live (the record could not serve it), the read went to the CLI's region: a NotFound aborted the orphan, or a same-named resource's value (or an ARN naming the wrong region) was written into a sibling's state. The provider registry and the rewrite now run insiderunWithStackAwsClientsfor the stack's region, as deploy does. The state backend and lock stay on the CLI's clients, andprocess.env.AWS_REGIONis left unchanged.cdkd rollbacknow runs its providers in the rolled-back stack's region, not the CLI's ambientAWS_REGION(issue #4932) --src/cli/commands/rollback.ts, newtests/unit/cli/rollback-stack-aws-scope-4932.test.ts. A standalonecdkd rollbackresolved the stack's region from its journal or state key and pinned the globalgetAwsClients()set to it, but never entered the stack AWS scope that deploy uses. Providers that build their own SDK client read the region fromambientRegion(), which outside a scope isprocess.env.AWS_REGION. So a rollback of a stack recorded in a region other than the shell's could delete, re-create or update a same-named resource in the ambient region, which cdkd does not manage. The provider registry and the whole replay now run insiderunWithStackAwsClientsfor the stack's region, so every provider captures that region when it is constructed and when it builds a client. A nested child's revert is also handed the stack's clients, which its destroy takes final snapshots through, as deploy does.process.env.AWS_REGIONis left unchanged.cdkd diff --recursiveno longer reports an unchanged nested child that splits a parent-built parameter on a delimiter inside a secret{{resolve:...}}reference's text, such as:(issue #4929). A child readingFn::Select [1, Fn::Split [':', {Ref: Conn}]]over a parent rowConn: postgres://{{resolve:...}}:{{resolve:...}}@hostshowed a change on every run, and--failexited 1, because the diff cut each reference into fragments (//{{resolve) that no deploy persists. The child now keeps whole each reference its parent's deploy replaces with the secret before handing the value down (one in the row's literal,Fn::JoinorFn::Sub, or one the parent itself received that way), as the deploy's plan does since #4914. Reference text the parent passes down as plain text (aRefto its own parameter, anFn::FindInMapvalue) is still split as text, as the deploy does. No secret is fetched and no AWS call is added. Source:src/cli/commands/diff-recursive.ts,src/deployment/intrinsic-resolver/string-functions.ts,src/deployment/intrinsic-resolver/dynamic-refs.ts,src/deployment/intrinsic-resolver/context.ts.AWS::Lambda::NetworkConnectornow deploys through an SDK provider; deleting a connector takes about 3 s instead of about 13 s -- newsrc/provisioning/providers/lambda-network-connector-provider.ts,src/provisioning/provider-registry.ts,src/utils/aws-clients.ts, new dependency@aws-sdk/client-lambda-core. A connector is the VPC egress path Lambda Web Functions and MicroVM images share. Its create still waits until the connector isACTIVE(about 4 minutes, AWS's own provisioning), which cdkd now notices within a second rather than through Cloud Control's report and poll backoff; its delete settles in about 3 s, where Cloud Control took 12.6 s (us-east-1). A configuration change goes throughUpdateNetworkConnectorand waits for the new version, a tags-only change through Lambda's tagging API. RemovingOperatorRolefrom a deployed connector's template, or declaring a non-string one, now fails the deploy before any change, naming the cause: the update would send no role, leaving the old one attached. A connector created by an earlier cdkd, recorded on Cloud Control, moves to the SDK provider on the next deploy that changes it.cdkd driftandcdkd import(by ARN or name) are supported. Issue #4928.AWS::Lambda::WebFunctionRevisionandAWS::Lambda::WebFunctionEndpointnow deploy through SDK providers, about 5 s faster per Web Function code change -- newsrc/provisioning/providers/lambda-web-function-revision-provider.ts,lambda-web-function-endpoint-provider.tsandlambda-web-function-arns.ts,src/provisioning/provider-registry.ts,src/utils/aws-clients.ts, new dependency@aws-sdk/client-lambda-web. A code change creates a new revision, moves the endpoint'sRevisionWeightsto it and deletes the old revision; through Cloud Control those three steps took about 12 s, and through the Lambda Web API about 7 s (median of three, us-east-1). A revision created by an earlier cdkd, recorded on Cloud Control, moves to the SDK provider on the next deploy that changes it, and so does an endpoint. Both providers supportcdkd driftandcdkd import.AWS::Lambda::WebFunctionstays on Cloud Control: the publicCreateWebFunctionrefuses a function without a first revision and endpoint, which only CloudFormation's handler may create. Issue #4926.cdkd drift --revertcan now revert drift on anAWS::GuardDuty::Detector(issue #4921) --src/provisioning/guardduty-detector-patch.ts,src/provisioning/cloud-control-provider.ts, unit cases and a new phase in theguardduty-detector-updateinteg fixture. The revert hands Cloud Control the AWS snapshot with the drifted keys overlaid. That snapshot carried bothDataSourcesand the fullFeatureslist on every read measured, whichUpdateDetectorrefuses together, so reverting a drifted scalar or feature failed withEKS_RUNTIME_MONITORING and RUNTIME_MONITORING cannot be provided in the same request. Now an undriftedFeaturesorDataSources(one the patch has no op on) is removed from the request instead of sent as read back, unless the live read lacks it; GuardDuty leaves an omitted setting as it is. A driftedFeatureslist is narrowed to the entries that differ from the live entry of the sameName, so a toggled feature is reverted alone, and a driftedDataSourcesbeside it (GuardDuty's legacy view of features such as S3 and EKS audit logs) is left out. When no entry differs, the drift is only in entry order or in entries GuardDuty added, whichUpdateDetectorcannot undo: the revert fails with an error saying so, rather than reporting a revert that changed nothing, and warns thatcdkd drift --acceptrecords every drifted property of the stack, so other drift on the detector should be reverted first. A desired entry without a stringName, an unreadable live list or an op without a list keeps the op as it was. Still refused: a drift that switched the runtime monitoring mode, since reverting it changes both runtime features.cdkd diff --recursive --failno longer reports a change on an unchanged nested stack in which one child resource reads a parameter the parent spelled as a literal embedding{{resolve:...}}tokens beside a sibling parameter spelling the same secret differently, either of the two throughFn::If, throughFn::SubinsideFn::Select, or inside an array holding anFn::IforAWS::NoValueelement (issue #4919). After upgrading, the first deploy of such a stack shows a change on the child resources reading the literal parameter, because their state now records the parameter's own{{resolve:...}}spelling instead of the sibling's; the deployed VALUE is unchanged, and a create-only property is replaced once with an identical value. Before, cdkd fell back to the sibling's spelling for that parameter in the whole child whilecdkd diff --recursiverendered its literal. The child leaf re-read (#4731) now evaluatesFn::SubandFn::If: anFn::Ifis re-read only when every branch is and no two branches resolve to one value spelled two ways, so its branch is read off the persisted value with no condition outcome needed. A bag array shorter than its source pairs the remaining elements in order when the count alone says whichAWS::NoValueelements dropped; this applies to any stack's resource arrays, so a{{resolve:...}}element there keeps its own spelling where two references share one secret value. Anything else keeps the old fallback. Source:src/deployment/secret-redaction/{nested-stack,redact-path}.ts,src/deployment/intrinsic-resolver/parameter-secrets.ts.- A deploy's plan and
cdkd diffno longer report an unchanged resource whoseFn::Splitdelimiter occurs inside a secret{{resolve:...}}reference's text, such as:(issue #4914). A nested child splitting a parameter the parent built aspostgres://{{resolve:...}}:{{resolve:...}}@hoston:showed an UPDATE row for that resource in every child redeploy's plan, and on a create-only property a replacement that--require-approval=destructiveasked about each time (refused without a terminal); the engine's post-resolution check then skipped the call, so AWS was never touched.cdkd diffreported a change on every run, and--failexited 1, for a stack splitting a literal,Fn::JoinorFn::Subholding such references. The comparison side cut each reference into fragments (//{{resolve) that no deploy persists. It now keeps a reference whole where it splits whenever the real deploy replaces it with its secret before splitting (one in the split's own literal,Fn::JoinorFn::Sub, or one a nested child's parent resolved), which matches what the deploy persists unless the secret value itself holds the delimiter. Reference text reaching the split as plain text (aRefto a parameter, anFn::FindInMapvalue) is still split as text, as the deploy does.cdkd diff --recursivestill reports the nested-child case (#4929). After upgrading, a nested child in which one resource reads such a split beside a sibling parameter spelling the same secret differently may show a one-time change (a replacement on a create-only property) on the resources reading the split parameter: their state now records that parameter's own spelling instead of the sibling's. The deployed value is unchanged. No AWS call is added. Source:src/deployment/intrinsic-resolver/string-functions.ts,src/deployment/secret-redaction/nested-stack.ts. cdkd diff --recursive --failno longer reports a change on an unchanged nested stack whose childOutputsread a parameter the parent spelled as a literal embedding{{resolve:...}}tokens beside a sibling parameter spelling the same secret differently (issue #4913). Before, no output was positioned per parameter, so cdkd fell back to the sibling's spelling for that parameter in the whole child (its resources included) whilecdkd diff --recursiverendered the literal. Now the outputs redaction carries the outputs pass's parameter reads and the parent's per-parameter associations, so an output whoseValueis a bareRef, anFn::Select/Fn::Splitre-read, or anFn::Join/Fn::Subover the parameters (with at most one other non-literal part) persists each parameter's own spelling, and the collision check reads each outputValueas it reads a resource. After upgrading, the first deploy of such a stack shows a change on those outputs, and on child resources reading the literal parameter that had taken the sibling's spelling; the deployed VALUE is unchanged, and a create-only property is replaced once with an identical value. A read the outputs redaction cannot position (underFn::If, throughFn::SubinsideFn::Select, inside a list-valued output, in anFn::Join/Fn::Subwith two or more other non-literal parts, or in anExport.Name) keeps the old behaviour.cdkd scrubwrites the same outputs. Source:src/deployment/intrinsic-resolver/parameter-secrets.ts,src/deployment/deploy-engine/outputs.ts.- A rollback no longer re-creates the old resource of a replacement a provider made inside its own update when that provider could not delete it (issue #4911) --
src/deployment/deploy-engine/update-in-place.ts. A provider that replaces a resource insideupdate()(anAWS::CertificateManager::CertificatewhoseValidationMethodchanges; also the IAM role, IAM managed policy and API Gateway resource providers) and whose delete of the old one failed or was skipped reports a partial update. The deploy journaled that operation without the #2631 survivor mark, so when a later resource of the same deploy failed, the rollback re-created the old resource while it was still there: a duplicate certificate, for example, when ACM refused the delete because a load balancer or CloudFront distribution still used it. The operation is now journaled witholdResourceMaySurvive, from the provider's own verdict on the old resource:keptwhen the ACM or API Gateway provider proves AWS refused the delete outright (by AWS error name, ACM's in-use refusal included) or never sent it,unconfirmedotherwise, and always for the IAM role and managed policy, whose delete strips them before the refused call. The rollback reads AWS for the old resource first: re-created when AWS reports it gone, re-adopted when it is still there andkept(the replacement is deleted, as CloudFormation would), otherwise refused with the journal kept andcdkd rollback --orphanoffered (the refusal now also names a delete that only partly ran). Providers also changed:acm-certificate-provider.ts,apigateway-provider.ts, andsrc/types/resource.ts(oldResourceSurvivoron a partial update). The deliberateUpdateReplacePolicy: Retainsurvivor of the update-unsupported fallback is still recorded as retained, and an ordinary deploy makes no extra AWS call. - A fix-forward deploy now deletes the IAM access key or Cloud Map namespace or service an earlier failed CREATE left behind (issue #4606, access key and Cloud Map slice) --
src/provisioning/providers/{iam-access-key,servicediscovery}-provider.ts. When acdkd deploy --no-rollbackmade anAWS::IAM::AccessKeyor anAWS::ServiceDiscovery::Service(or a private DNS, public DNS or HTTP namespace), failed on a later call and could not delete it again, and a later deploy created a new one under the same logical id, the successful deploy warned about the earlier one, left it in AWS and exited 2;cdkd rollbackandcdkd destroykept it the same way. The providers now compare the two: an access key id and a Cloud Mapns-/srv-id are generated by AWS and never reused, so the earlier resource is a different one once the record's key, namespace (of the record's kind) or service reads back in the stack's region, and it is deleted. An id of another form, a read that fails, or a record that does not read back keeps the warning. No ordinary deploy makes an extra call. Tests:tests/unit/provisioning/{iam-access-key,servicediscovery}-provider-same-resource-4606.test.ts; integ:access-key-cloudmap-fix-forward-orphan. - A fix-forward deploy now deletes the EventBridge pipe an earlier failed CREATE left behind (issue #4606, Pipes slice) --
src/provisioning/providers/pipes-provider.ts. When acdkd deploy --no-rollbackmade anAWS::Pipes::Pipe, the pipe never settled (a*_FAILEDstate, the wait's cap, an interrupt, or a state read that failed) and cdkd could not delete it again, and a later deploy kept the logical id under another name, the successful deploy warned about the earlier pipe, left it in AWS and exited 2;cdkd rollbackandcdkd destroykept it the same way. The provider now answers both questions: a pipe name is unique per account and region and cannot be changed, so the earlier pipe is a different one once the record's reads back (two names equal ignoring case are never compared); and the failed deploy records the pipe's ARN and creation time (fromCreatePipe's answer, else read when the failure is journaled), so the earlier pipe is deleted only while its name still reads back with that creation time. One deleted by hand and re-created under the name has another creation time and is kept, with a warning, and so is one AWS reportsDELETING. Such a failure is no longer retried by re-sendingCreatePipe(which could only collide with the pipe it left), so its message keeps theaws pipes delete-pipeadvice instead of ending in "already exists". No ordinary deploy makes an extra call. Tests:tests/unit/provisioning/pipes-provider-same-resource-4606.test.ts; integ:pipes-fix-forward-orphan. destroy --remove-protectionon a Cognito User Pool with aDEVELOPER(SES)EmailConfigurationnow says plainly when a write reset it to Cognito's default sender (issue #4149) --src/provisioning/providers/cognito-provider.tsandtests/unit/provisioning/providers/cognito-remove-protection-rmw.test.ts. Measured in us-east-1: anUpdateUserPoolthat omits aDEVELOPEREmailConfigurationresets it toEmailSendingAccount: COGNITO_DEFAULT, dropping its SESSourceArn; echoed back unchanged it is accepted and kept. TheDeletionProtectionflip already echoes it; when AWS refuses that echo and cdkd re-sends without it, the warn used to say a reset was "not measured" -- it now says the email configuration WAS reset, and if the delete then fails and the live pool reads back on the default sender, cdkd reports that at ERROR with how to put it back (a redeploy will not). A re-send that fails with no clear answer (a timeout or 5xx, which may have landed) now warns of the reset on the throttle/5xx retry path and on the re-enable after a failed delete, where it used to say nothing. The ERROR report after a bare flip now lists theDEVELOPERsending account among what the pool held. Turning the guard back on after a failed delete, cdkd no longer sends a write without the SES settings to a pool that already reads it on, unless the flip-off's own write without them is known to have landed.- A failure value that cannot be converted to text no longer turns a best-effort step of
cdkd diff, the--recreate-via-*consumer listing,cdkd bootstrap --destroyorcdkd publish-assetsinto a hard failure (src/analyzer/outputs-diff.ts,src/cli/commands/{recreate-downstream-consumers,bootstrap-destroy,publish-assets}.ts, go-to-k/cdkd#3361).String()throws for a null-prototype object, and these handlers built their message with it, so the handler itself threw: an output value orExport.Namethatcdkd diffcould not resolve failed the diff instead of being skipped; one unreadable state file, or a failed stack listing, failed the downstream-consumer enumeration instead of being skipped or falling back to an empty list; a failed bootstrap-marker listing failedbootstrap --destroyinstead of warning and continuing; and, with more than one stack selected, one stack's failure incdkd publish-assetsstopped the run instead of being recorded while the remaining stacks were published. Each now goes throughdescribeAwsFailure(x).detailorsafeStringify(x), byte-identical for every valueString()converts and a placeholder otherwise. Tests:tests/unit/{analyzer/outputs-diff,cli/recreate-downstream-consumers,cli/bootstrap-destroy,cli/publish-assets}-string-outthrow-3361.test.ts. - A failure that cannot be converted to text no longer stops the teardown or dispatch of
cdkd local start-api,cdkd local run-taskorcdkd local invoke-agentcore --watch(src/local/{container-pool,websocket-server,ecs-task-runner,invoke-agentcore-watch-loop}.ts, go-to-k/cdkd#3361). Handlers that log a failure and carry on built their message withx instanceof Error ? x.message : String(x), andString()throws for a null-prototype object, so the handler itself threw: the container pool skippeddocker rmafter a failed log-stream stop, and a failed in-flight start madedispose()reject before tearing down any warm container; a WebSocket dispatch failure left the 1011 close or the error frame unsent and broke the connection's dispatch chain, so every later frame and its$disconnectwere skipped;run-task's cleanup stopped at a failed hook, in-flight removal, log-stream stop or drain, leaving the network and volumes behind (and, without--keep-running, the containers); and the--watchloop lost its rebuild fallback, kept pinging a torn-down container after a failed rebuild, stopped running later reloads, or rejected when closing its watcher failed. Each now goes throughdescribeAwsFailure(x).detail, orsafeStringify(x)beside a kepterr.stackarm, byte-identical whenever the value converts and a placeholder only when it cannot. Tests:tests/unit/local/*-string-outthrow-3361.test.ts. - A value that cannot be converted to text no longer defeats
cdkd local start-api's graceful shutdown or hides a failed--watchreload (src/cli/commands/local-start-api.ts, go-to-k/cdkd#3361). TheuncaughtExceptionandunhandledRejectionlisteners, and the reload's synth catch, rendered the value they received with a bareString(), which throws for a null-prototype object. A throw inside theuncaughtExceptionlistener ended the process without the shutdown that removes the warm containers and the profile credentials tmpdir; theunhandledRejectionlistener reported the converter'sTypeErrorinstead of the rejection; and a failed re-synth printed nothing, because the reload chain swallowed theTypeErrorin place of the "keeping previous version" warning. Each now renders throughsafeStringify/describeAwsFailure(x).detail, byte-identical whenever the value converts and a placeholder only when it cannot. Tests:tests/unit/cli/local-start-api-string-outthrow-3361.test.ts. - A state read rejecting with a value that cannot be converted to text no longer defeats a
--from-statefallback ofcdkd local(src/cli/commands/local-state-loader.ts, go-to-k/cdkd#3361). Six handlers that log a failed S3 read at debug and carry on built their message withx instanceof Error ? x.message : String(x), andString()throws for a null-prototype object, so the handler itself threw: a failed state-bucket resolution or bootstrap-marker read failedcdkd local run-taskinstead of falling back to the conventional asset-repo names; a failed exports-index lookup skipped the per-stack scan, and one unreadable stack during that scan stopped it, so anFn::ImportValuea later stack could answer was dropped from the env, and failedcdkd local invoke-agentcorewhen it named afromS3code bucket; and a failed stack listing orFn::GetStackOutputstate read dropped its entry with the converter'sTypeErroras the reason. Each now goes throughdescribeAwsFailure(x).detail, byte-identical whenever the value converts and a placeholder only when it cannot. Tests:tests/unit/cli/local-state-loader-string-outthrow-3361.test.ts. - A Cloud Control create cdkd stopped waiting for is now resumed by the next
cdkd deploy, and settled bycdkd destroyandcdkd rollback(issue #3252) --src/deployment/cc-pending-requests.ts,src/deployment/deploy-engine/create.ts,src/deployment/deploy-engine/deploy-flow.ts,src/deployment/deploy-engine/execute.ts,src/deployment/deploy-engine/rollback.ts,src/provisioning/cloud-control-provider.ts,src/provisioning/wait-abandoned.ts,src/deployment/rollback-executor/journaled-orphans.ts,src/cli/commands/destroy-runner.ts,src/cli/commands/rollback.ts,src/cli/commands/rollback-drop-failed.ts,src/state/s3-state-backend.ts,src/types/rollback-journal.ts,src/deployment/rollback-executor/orphan-identity.ts. When a network outage made cdkd give up on a Cloud Control create, the request token was printed and lost, and the user had to check it and import or delete the resource by hand. The rollback journal now records it (pendingCloudControlRequest, additive, nojournalVersionbump). The next deploy, destroy or rollback reads the request's status under the stack lock before anything else. Whether its id is unique is read from the type's registry schema: a read-only primary identifier field AWS minted, not a name the create sent. A deploy whose template still declares the resource with the same properties takes a unique one over instead of creating another; otherwise it becomes a journaled resource the failed deploy created, deleted when its id is unique and named in a warning, with thecdkd importremedy, when it is a name. A create that made nothing, or that Cloud Control no longer has (requests expire after seven days), is forgotten. One still unknown refuses the command, which names the status command andcdkd rollback --drop-failed. - An update of a CloudFront distribution, origin access control or origin access identity is refused when the AWS client's region differs from the region recorded in cdkd state (issue #2613) --
src/provisioning/providers/cloudfront-{distribution,oac,oai}-provider.ts(assertRegionMatchnow runs before any AWS call in theseupdate()s; theirdelete()s compare the regions only on a NotFound),scripts/check-provider-update-context.ts(OMITS_UPDATE_CONTEXTis now empty, so every SDK provider's update takes the region the deploy engine passes), and unit cases. CloudFront's client is built in the stack's region rather than pinned to us-east-1, so a stack in any region updates as before; only a client whose region disagrees with the record now stops before the first call, names both regions, and says that the global endpoint would have reached the same resource through a client not built for that stack. A state record with no region is unchanged. - An update or delete of an S3 Express directory bucket, an S3 Tables table bucket, namespace or table, or a WAFv2 web ACL, an update of a Glue security configuration, and a delete of any Glue resource cdkd's SDK providers handle (database, table, workflow, security configuration, job, crawler, connection, trigger) is refused when the AWS client's region differs from the region recorded in cdkd state (issue #2613) --
src/provisioning/providers/{s3-directory-bucket,s3-tables,wafv2,glue}-provider.ts(assertRegionMatchnow runs before any AWS call in theseupdate()s anddelete()s; the directory bucket's update compares both the S3 client its ARN comes from and the S3 Control client its tag writes use),scripts/check-provider-update-context.ts(four names leaveOMITS_UPDATE_CONTEXT, leaving the three CloudFront providers), and unit cases. A run with a client in another region sent its writes there; a delete checked the region only when AWS answered NotFound, so for a name-shaped physical id it deleted a same-named resource cdkd does not manage. A Glue security configuration update failed with a "cannot be updated in place" error pointing at--replace, whose replacement would then run in the wrong region. The call now stops first and names both regions. A state record with no region is unchanged. - A failed create, update or delete of an S3 bucket policy, SNS topic policy or SQS queue policy no longer writes the caller's account id, assumed-role ARN and session name into the persisted deployment events, and no longer replaces the failure with a
TypeErrorwhen the thrown value cannot be converted to text (issues #2319, #3341) --src/provisioning/providers/{s3-bucket-policy,sns-topic-policy,sqs-queue-policy}-provider.ts, and unit cases. The thrown message interpolated AWS's raw text, which on an access-denied error names the caller, anddeployments/{runId}.jsonloutlives the run. The message now carries the error class (AccessDenied. Re-run with --verbose for AWS's own message.) and AWS's text goes to the debug log, through the operation's secret masker. A cdkd-authored or transport error keeps its text. The AWS error stays as thecause, so an IAM grant still propagating is retried as before. - On Windows, a container secret whose name differs only in letter case from a variable already in your shell now replaces it, and Windows system variables are no longer forwarded as secret names (issue #2190) --
src/utils/docker-cmd.ts. Windows treatsMY_TOKENandmy_tokenas one variable, but both spellings reached the container client's environment, so a secret namedmy_tokenreached the container as the host'sMY_TOKENvalue. cdkd now removes the other spelling before it starts the client, so the secret wins as it does when spelled the same, and it warns when two secrets differ only in letter case (the container then receives the last one's value under both names). A secret matching another name only through a letter outside A-Z (ßagainstSS) is dropped with a warning instead, so it can never remove the operator's variable. Secrets namedComSpec,SystemRoot,windir,SystemDrive,PSModulePathorPSExecutionPolicyPreference, in any letter case and on every platform, are now dropped with the existing rename warning, because they choose which programs and libraries the client and its credential helpers load or trust.TEMPandTMPare still forwarded. A newwindows-env-precedenceCI job runs this on a real Windows host. Linux and macOS behaviour is otherwise unchanged.
Recently Implemented (2026-10-14):
- An
AWS::GuardDuty::Detectorcan now be updated in place (issue #4910) --src/provisioning/cloud-control-provider.ts, a newsrc/provisioning/guardduty-detector-patch.ts, unit cases and a newguardduty-detector-updateinteg fixture. cdkd updates the detector through Cloud Control, which applies the patch on top of the model it reads back. For a detector that model carried bothDataSourcesand the fullFeatureslist on every read measured (us-east-1 and us-west-2), andUpdateDetectorrefused them together, whichever of the two the template declared: a scalar-only change failed withEKS_RUNTIME_MONITORING and RUNTIME_MONITORING cannot be provided in the same request, and aFeatureschange withboth data sources and features were provided. So the detector deployed once and then refused those updates. Now, for each of the two keys a non-empty update patch has no op on, the patch also getsadd /<key>with the declared value, orremove /<key>when the template does not declare it, so the request carries what the template declares, as a CloudFormation update does. Features the template omits keep their AWS status, andRDS_DATA_RISK, which GuardDuty enables underRDS_LOGIN_EVENTS, stays enabled. Every other type is unchanged. - A failed update that cdkd moved from an SDK provider to Cloud Control API now names the move and the two ways through --
src/provisioning/cc-autoroute-update-remedy.ts,src/deployment/deploy-engine/update-in-place.ts. When a property the SDK provider does not handle is added to a resource recorded on that provider, the deploy routes its update through Cloud Control, which addresses the resource by the physical id the SDK provider recorded; for some types Cloud Control does not accept that id. Such a failure printed only Cloud Control's error. It now also says the update moved the resource to Cloud Control because of the named properties, and gives--recreate-via-cc-api <LogicalId>(re-create it on Cloud Control) and--prefer-sdk-route <Type>:<Property>(stay on the SDK provider without sending them). A successful deploy runs nothing new, and other failures keep their message. Issue #4904. - AWS published 1 writable property the 2026-10-14 schema refresh now carries, so cdkd classifies it instead of treating it as unrecognized (PR #4902) --
AWS::Route53::HostedZone:DelegationSetId. Changed:tests/fixtures/cfn-schemas/,src/provisioning/property-coverage.generated.ts(regenerated). OnAWS::Route53::HostedZone, no SDK provider writes it yet (not yet implemented by cdkd), so pre-flight classifies each as a silent drop and the issue #614 auto-route sends a resource whose template carries one through Cloud Control, which forwards the full property map. Since issue #3713 a key absent from the snapshot already routed so, unless the state record held it with the declared value; such a resource moves on its next deploy.AWS::Route53::HostedZoneis not inSTICKY_CC_MIGRATION_EXEMPT, so an existing SDK-provisioned resource of that type whose template gains one moves to Cloud Control and pinsprovisionedBy: 'cc-api'ONE-WAY. The snapshot marksAWS::Route53::HostedZone.DelegationSetIdcreate-only, so it joinscreateOnlyDropsand the record narrowing keeps it in the record; the capture is top-level-only, so it says nothing about a nested create-only path. - A replacement whose old resource is still in use by a resource the same deploy has not updated yet now deletes it after the rest of the deploy, instead of leaving it behind (issue #4900) --
src/deployment/deploy-engine/replacement.ts(deleteReplacedAfterCreatedefers its first failure; newretryDeferredReplacedDeletes/warnUnretriedReplacedDeletes),src/deployment/deploy-engine/execute.ts,src/deployment/deploy-engine.ts, and a new unit file. A create-first replacement deleted the old resource right after creating the new one, before the dependents had moved to it. When a dependent still used the old resource, AWS refused the delete. For example, anAWS::Lambda::WebFunctionEndpointrefuses the delete of itsAWS::Lambda::WebFunctionRevisionwith a 409ConflictException. cdkd warnedFailed to delete old resourceand the old resource stayed in AWS, untracked, on every replacing deploy. Now the first failure inside a deploy is retried once after its creates, updates and deletes have all completed, as CloudFormation deletes old resources in its cleanup phase. The fix is not specific to any resource type. A retry that fails warns as before, and a retry the provider skips makes the row a partial update. A deploy that fails or is interrupted first does not retry, and it warnsFailed to delete old resource ... (not retried: ...). The retry runs under the row's own masking, stack-records and delete-guard scopes, and records its skip and guard events as the row's. A delete that succeeds inline is unchanged, so an ordinary deploy makes no extra call. cdkd state destroy '<parent>~<child>'resolves a secret principal reference the child's state record places in a region (issue #4887) --src/cli/commands/destroy-runner.ts,src/provisioning/secret-principal-resolution.ts,src/provisioning/providers/nested-stack-provider.ts,iam-policy-provider.ts,iam-user-group-provider.ts,src/provisioning/region-check.ts. A nested child destroyed on its own skipped everyAWS::IAM::Policy/AWS::IAM::UserToGroupAdditionwhose principal list holds a{{resolve:...}}reference (record kept, exit 2), because the regions its parent reads from are unknown there. It now resolves a region-less reference its state schema v12 record places in a region, in that region, and removes the policy or memberships. A record holding any other region-less reference keeps the skip and the hint to destroy it through the parent, and the child's own nested children inherit that limit instead of reading the child's regions as complete. No new AWS call.cdkd drift --revertnow removes an out-of-bandAWS::SSM::Parameterpolicy instead of reporting a revert that left it in place --src/provisioning/providers/ssm-parameter-provider.ts. When the template declaresPoliciesand the recorded baseline holds none, a policy added out of band is drift. The revert sent noPolicies, andPutParameterkeeps a parameter's policies until it is sent new ones or an empty one, so the policy stayed and the nextcdkd driftreported it again. A revert to no policies against a parameter that has some now sends the empty policy the Systems Manager guide documents for removal ([{}]), when the template declares no policies either; a baseline whose empty list stands for a failed read never removes a policy the template declares. A deploy still sends noPolicieswhen the template has none, as CloudFormation does. Issue #4884.cdkd destroyresolves a region-less secret reference in an IAM principal list in the region its state record names (issue #4879) --src/provisioning/secret-principal-resolution.ts,src/cli/commands/destroy-runner.ts,src/provisioning/providers/iam-policy-provider.ts,iam-user-group-provider.ts. AnAWS::IAM::PolicyorAWS::IAM::UserToGroupAdditionwhoseRoles/Groups/Usersnames a principal through a{{resolve:...}}reference with no region was skipped (record kept, exit 2) when the stack also reads a value from another region, because the reference could have been resolved there. A state schema v12 record says which region resolved it, socdkd destroyandcdkd state destroynow resolve it in that region, as the rollback replay,cdkd driftandcdkd scrubalready do, and remove the policy or memberships. A record without that field keeps the skip. The destroy makes no new AWS call to find the region. A nested stack destroyed on its own still skips such a record.cdkd import --migrate-from-cloudformationrecords the resource CloudFormation holds after the Retain update, not one it replaced (issue #4873) --src/cli/commands/import.ts,src/cli/commands/retire-cfn-stack.ts. The command wrote cdkd state from the physical ids it read before theUpdateStackthat adds the Retain policies. When that update replaced a resource (an auto-namedAWS::SSM::Documentis a known case), state named the superseded copy, andcdkd destroyleft the live replacement unmanaged. The Retain update now runs before the state write; when it completes, cdkd re-reads the ids (nested stacks included), adopts every resource by the new ids, and warns with both ids; the superseded copy is kept byUpdateReplacePolicy: Retainfor you to delete. A failed update or re-read leaves no state from the run, and its error names the manual route (Retain update by hand,cdkd importwithout the flag, thenDeleteStack) and says a replacement CloudFormation cannot perform fails by hand too. When the update may have completed, the error also says how to find retained copies in the stack events. A failedDeleteStacksays the state is written and every resource is retained, and to delete the stack by hand once its cause is resolved. After a replacement,--record-resource-mappingis rewritten so it no longer names a superseded id. A resource replaced at any nesting depth that can no longer be imported by its new id refuses beforeDeleteStackrather than orphan it. A re-import that adopts a different number of resources than were confirmed warns with both counts. Declining the retirement prompt still writes the confirmed state and leaves the stack unchanged; that prompt and its non-interactive refusal now say nothing was written yet. Integ:migrate-from-cfnnow fails when the replacement Document survives the destroy.- A reader left on the value of a resource replaced in an earlier deploy is updated (issue #4852, part 3 of 3) --
src/analyzer/read-provenance-promotion.ts,src/analyzer/diff-calculator.ts,src/deployment/deploy-engine/read-provenance-moved.ts,src/deployment/intrinsic-resolver/cross-stack.ts,src/deployment/rollback-executor/replay-props.ts,src/cli/commands/diff-recursive.ts. A queue named from a secret and replaced after the secret rotated stores itsArnasarn:...:{{resolve:...}}, so a reader of it compared equal to its record and kept the deleted queue's ARN whenever the replacement and the reader's update did not happen in the same deploy: an interrupted deploy, a nested stack's child reading it through a parameter, a cross-stack consumer, or a producer updated in place earlier. With the state schema v12 record,cdkd deployandcdkd diffnow compare the secret versions a reader recorded with the ones its upstream records, promote it (cdkd difflabels it[read value moved upstream]), and send it, naming why. A rotation alone still sends nothing. A cross-stack consumer resolves a reference at the version the producer's output records (one read of the producer's state per deploy, only where needed), and a rollback restores a reference at its record's version. Records without the v12 field behave as before. Integ:secret-derived-immutable-names(5h-5j),cross-stack-secret-import. - A stored
{{resolve:...}}reference is re-resolved in the region and at the secret version its record names (issue #4847, part 2 of 3) --src/deployment/intrinsic-resolver/recorded-reference-routes.ts,getatt.ts,dynamic-refs.ts,dynamic-ref-lookups.ts,src/deployment/rollback-executor/replay-secrets.ts,src/cli/commands/drift.ts,src/cli/commands/scrub.ts. A resource named from a secret that a stack in another region publishes (Fn::GetStackOutputwithRegion) stores itsArnasarn:...:{{resolve:...}}; a reader of thatArnadded while the resource was unchanged was sent the literal reference (SSM refused it; a queue policy matched nothing), because cdkd could not tell which region resolved it. It now uses the state schema v12 record: the recorded region answers, and a single recorded version is read by a pinned lookup, so a reader also gets the value the resource was named with after the secret rotated. A pinned version that is gone warns and falls back to the previous check. The rollback replay,cdkd driftandcdkd scrubresolve such a reference in its recorded region instead of refusing it. A record without the v12 field behaves as before; an unchanged deploy makes the same AWS calls, and a pin equal to the current version adds none. Integ:getstackoutput-crossregion(two-region arm),rollback-cross-region-secret,rollback-nested-cross-region-secret. cdkd drift --revertof anAWS::SSM::Parameterno longer fails with "Start of list found where not expected" --src/provisioning/providers/ssm-parameter-provider.ts. The read-back baseline drift reverts from holds the parameter's policies as a parsed list (an empty one when it has none), and the update sent that list toPutParameter, which takes a JSON string, so AWS refused every revert of an SSM parameter. A list is now sent as its JSON string, and the empty placeholder as noPolicies, as an absent template property is. Found by therollback-cross-region-secretinteg of issue #4847.cdkd diff --recursive --failno longer reports a change on an unchanged nested stack in which one child resource reads a parameter the parent spelled as a literal embedding{{resolve:...}}tokens beside a sibling parameter spelling the same secret differently, either of the two throughFn::Select/Fn::Split(issue #4731). After upgrading, the first deploy of such a stack shows a change on the child resources reading the literal parameter, because their state now records the parameter's own{{resolve:...}}spelling instead of the sibling's. The other direction happens too: where one child resource reads such a parameter as a bareRefinside an array that also holds anFn::IforAWS::NoValueelement, beside the sibling, every resource in that child reading the parameter now records the sibling's spelling. Either way the deployed VALUE is unchanged, and a create-only property is replaced once with an identical value. Before, cdkd fell back to the sibling's spelling for that parameter in the whole child whilecdkd diff --recursiverendered its literal. A child leaf whose source holdsFn::Select/Fn::Split(possibly withFn::Joinbeneath or around it) over the child's parameters is now persisted as the deploy's diff side computes it, re-read with the same parameter bindings, when that answer renders back to the resolved value through the parent's own secret pairs. Three cases keep the old behaviour: a read underFn::If, throughFn::SubinsideFn::Select, or inside an array holding anFn::IforAWS::NoValueelement (#4919); a split that cuts a token (#4914); and two reads in the child'sOutputs(#4913). Source:src/deployment/secret-redaction/{nested-stack,redact-path}.ts,src/deployment/intrinsic-resolver/parameter-secrets.ts.- A fix-forward deploy now deletes the IAM instance profile or managed policy an earlier failed CREATE left behind (issue #4606, IAM instance profile and managed policy slice) --
src/provisioning/providers/iam-instance-profile-provider.ts,src/provisioning/providers/iam-managed-policy-provider.ts. When acdkd deploy --no-rollbackmade anAWS::IAM::InstanceProfileorAWS::IAM::ManagedPolicy, failed wiring it (adding its role, or attaching the policy to a group, role or user) and could not delete it again, and a later deploy kept the logical id under another name, the successful deploy warned about the earlier profile or policy, left it in AWS and exited 2;cdkd rollbackandcdkd destroykept it the same way. The providers now answer both questions: an instance profile or policy name is unique per account, so the earlier one is a different one once the record's reads back (two whose names are equal ignoring case are never compared); and the failed CREATE records theInstanceProfileId/PolicyIditsCreateInstanceProfile/CreatePolicyreturned, so the earlier one is deleted (a policy detached first) only while its name still reads back under that id. One deleted by hand and re-created under the name has another id and is kept, with a warning. A managed policy that reads back but is gone by itsDeletePolicy(a concurrent delete) is now treated as already deleted, after the region check, instead of failing the delete. Tests:tests/unit/provisioning/iam-profile-policy-provider-same-resource-4606.test.ts; integ:iam-profile-policy-fix-forward-orphan. - A fix-forward deploy now deletes the IAM role an earlier failed CREATE left behind (issue #4606, IAM role slice) --
src/provisioning/providers/iam-role-provider.ts. When acdkd deploy --no-rollbackmade anAWS::IAM::Role, failed wiring it (a managed policy, an inline policy or its tags) and could not delete it again, and a later deploy kept the logical id under another role name, the successful deploy warned about the earlier role, left it in AWS and exited 2;cdkd rollbackandcdkd destroykept it the same way. The IAM role provider now answers both questions: a role name is unique per account and cannot be changed, so the earlier role is a different one once the record's role reads back (a record holding the same name in another case holds that role, which is never deleted); and the failed CREATE records theRoleIditsCreateRolereturned, so the earlier role is deleted only while its name still reads back under thatRoleId. A role deleted by hand and re-created under the name has anotherRoleIdand is kept, with a warning. Tests:tests/unit/provisioning/iam-role-provider-same-resource-4606.test.ts; integ:iam-role-fix-forward-orphan. - A fix-forward deploy now deletes the IAM user or group an earlier failed CREATE left behind (issue #4606, IAM user and group slice) --
src/provisioning/providers/iam-user-group-provider.ts. When acdkd deploy --no-rollbackmade anAWS::IAM::UserorAWS::IAM::Group, failed wiring it (a permissions boundary, a login profile, a managed or inline policy, or a group membership) and could not delete it again, and a later deploy kept the logical id under another name, the successful deploy warned about the earlier user or group, left it in AWS and exited 2;cdkd rollbackandcdkd destroykept it the same way. The provider now answers both questions: a user or group name is unique per account, so the earlier one is a different one once the record's reads back (a record holding the same name in another case holds that very user or group, which is never deleted); and the failed CREATE records theUserId/GroupIditsCreateUser/CreateGroupreturned, so the earlier one is deleted only while its name still reads back under that id. One deleted by hand and re-created under the name has another id and is kept, with a warning. Tests:tests/unit/provisioning/iam-user-group-provider-same-resource-4606.test.ts; integ:iam-user-group-fix-forward-orphan. - A fix-forward deploy now deletes the CloudWatch Logs log group an earlier failed CREATE left behind (issue #4606, log group slice) --
src/provisioning/providers/logs-loggroup-provider.ts. When acdkd deploy --no-rollbackmade anAWS::Logs::LogGroup, failed configuring it (its retention, data protection or index policy, or bearer-token setting) and could not delete it again, and a later deploy kept the logical id under another name, the successful deploy warned about the earlier log group, left it in AWS and exited 2;cdkd rollbackandcdkd destroykept it the same way. The provider now answers both questions: a log group name is unique per account and region and cannot be changed, so the earlier one is a different one once the record's reads back (two names equal ignoring case are never compared); and the failed deploy records the log group's ARN and thecreationTimeCloudWatch Logs reports for it, so the earlier one is deleted only while its name still reads back with that creation time. One deleted by hand and re-created under the name has another creation time and is kept, with a warning. No ordinary deploy makes an extra call: the creation time is read only when a failed CREATE is journaled. Tests:tests/unit/provisioning/logs-loggroup-provider-same-resource-4606.test.ts; integ:logs-loggroup-fix-forward-orphan. cdkd drift,state refresh-observed,orphan,importand thecdkd diff/cdkd deployattribute heal mask a physical name derived from a secret (issue #3869) --src/cli/commands/{drift,state,orphan,import,diff-recursive}.ts,src/deployment/deploy-engine/heal.ts. These hand a record to a provider with no printing bag bound. For a resource named from a secret, such as an IAM role whoseRoleNameis a{{resolve:secretsmanager:...}}reference, the provider's--verboselines printed its physical id. So did a failed read's warning quoting AWS's error, and a resource reading the name (an instance profile'sRoles). Each read now runs under the per-resource printing bagcdkd drift --revertandcdkd destroybind: the record's own name spellings and those of each secret-named record it holds.cdkd drift's report,--jsonand accept / revert plans mask such a name in a drifted row's path and its value's leaves and keys, keeping the value's shape;--acceptstill writes the raw value.cdkd orphanandcdkd state orphan --resourcemask it in the rewrite, audit and unresolvable report.cdkd importmasks a name its template takes from a secret in the read (an id from--resource,--resource-mapping[-inline]or--migrate-from-cloudformation) and theImport plan:listing. Display only. Judged from the records (forimport, the template), so aNoEcho-derived name a pre-v11 record holds in plaintext is not recognised. Tests:tests/unit/cli/*-secret-name-needles-3869.test.ts,tests/unit/deployment/deploy-engine-stale-attribute-heal.test.ts. Integsecret-derived-immutable-namesstep 5k runscdkd orphan --dry-runover a hand-added reference to the secret-named queue and fails when the log names it.cdkd deploy's own messages flatten each value they interpolate to one line, so a logical id, physical id, type or name cannot forge an output line (issue #2676) --src/deployment/deploy-engine/*.ts(exceptupdate.ts, which follows after #4852). The logger sink strips terminal control characters but keeps newlines, so a newline in a value from the template or the state file started a line cdkd never wrote. The deploy engine's log lines and thrown errors now build their text withsafeMsg, which keeps cdkd's own newlines and colours and turns each interpolated value's newline or tab into a space. The rule: only the values the deploy engine interpolates are flattened; an error message embedded in a wrapping message (the per-resourceFailed to <op> <id>:line,Failed to resolve output <key>:, the replacement failures, the state-save warnings) prints as before, masked where it was masked before but not flattened (asIsMessageinsrc/utils/display-safe.ts), so a remedy command it ends in keeps its own last line. Under--strict-getatt, the output failure now readsFailed to resolve output <key> (--strict-getatt ...): <message>, with the message last. Where a message is masked for secrets, each value is masked before it is flattened, and the printing maskers (src/deployment/secret-redaction/) also match a secret's flattened spelling. The CodeCommit, S3 Tables, Glue, Cloud Control and provider-registry refusals now flatten the logical id, physical id or property they name.tests/raw-log-interpolation-baseline.jsondrops these files, so a new raw interpolation in them failsaudit:raw-log-interpolation:check.cdkd deploy's UPDATE path and its ServiceToken refusal flatten each value they interpolate to one line (issue #2676) --src/deployment/deploy-engine/update.ts,src/deployment/custom-resource-service-token.ts. The follow-up to the deploy-engine sweep, for the file that sweep left out. A newline in a logical id, resource type or property name from the template no longer starts a line cdkd never wrote in three messages: theCannot update <id>: resource not found in stateerror, theSkipping <id>: no actual changesdebug line, and the unwritten create-only refusal. The custom-resource ServiceToken refusal (Refusing to deploy <stack>: ...) renders the stack name throughdisplayStackName, and its remedies keep their own line breaks.tests/raw-log-interpolation-baseline.jsondropsupdate.ts, so a new raw logger interpolation there failsaudit:raw-log-interpolation:check.- A rollback no longer re-creates the old resource of a replacement whose delete the deploy could not confirm while that resource is still there (issue #2631) --
src/deployment/deploy-engine/replacement.ts,src/deployment/deploy-engine/update-replace.ts,src/deployment/deploy-engine/execute.ts,src/deployment/deploy-engine.ts,src/deployment/rollback-executor/replay-reverse-replacement.ts, newsrc/deployment/rollback-executor/replaced-survivor.ts,src/types/rollback-journal.ts,src/cli/commands/rollback.ts. When a replacement's delete of the old resource failed (including the post-deploy retry from #4900, or a deploy that failed before it), was skipped by the provider, or was blocked by a failedUpdateReplacePolicy: Snapshotsnapshot, a later failure in the same deploy rolled the replacement back by re-creating the old resource. When the old one was still alive, that made a duplicate (auto-named types) or failed withAlreadyExists(named types). The rollback journal now recordsoldResourceMaySurviveon such an operation (keptwhen AWS refused the delete outright or it was never sent,unconfirmedotherwise; an additive field, no journal version change), and the rollback reads AWS for the old resource through the provider's drift read before re-creating it. If AWS reports it gone, it is re-created as before. If it is still there andkept, it is re-adopted, with a warning to check it withcdkd drift(exit 2). Otherwise (a delete that may still be running, or no answer) the operation is refused with the journal kept, and the message offerscdkd rollback --orphan. Thecdkd rollbackplan preview names all three outcomes. An ordinary deploy, and the rollback of any other replacement, makes no extra AWS call. - An update of a Bedrock AgentCore evaluator or runtime, a DLM lifecycle policy, a DocumentDB subnet group, a Kinesis Data Firehose delivery stream or a Lambda layer version is refused when the AWS client's region differs from the region recorded in cdkd state (issue #2613) --
src/provisioning/providers/{agentcore-evaluator,agentcore-runtime,dlm-lifecycle-policy,docdb-subnet-group,firehose,lambda-layer}-provider.ts(update()now takes theUpdateContextand callsassertRegionMatchbefore any AWS call),src/provisioning/providers/{agentcore-browser,agentcore-code-interpreter,wait-condition-handle}-provider.ts(they take theUpdateContexttoo, with no guard: theirupdate()sends nothing),scripts/check-provider-update-context.ts(nine names leaveOMITS_UPDATE_CONTEXT), and unit cases in each provider's test file. These providers ignored the recorded region on update, so a deploy, rollback orcdkd drift --revertrun with a client in another region sent its writes there -- for a subnet group or a delivery stream, to whatever carried the same name in that region, a resource cdkd does not manage. A DLMDefaultPolicychange and any Lambda layer version update instead failed with a "cannot be updated in place" error pointing at a replacement, the wrong next step when it is the region that disagrees. The update now stops before the first call and names both regions. An update whose state record has no region is unchanged. - An update of an SQS queue, an S3 bucket policy, an EventBridge event bus or rule, or a Step Functions state machine is refused when the AWS client's region differs from the region recorded in cdkd state (issue #2613) --
src/provisioning/providers/{sqs-queue,s3-bucket-policy,eventbridge-bus,eventbridge-rule,stepfunctions}-provider.ts(update()now takes theUpdateContextand callsassertRegionMatchbefore any AWS call),scripts/check-provider-update-context.ts(five names leaveOMITS_UPDATE_CONTEXT), and unit cases in each provider's test file. These providers ignored the recorded region on update, so a deploy, rollback orcdkd drift --revertrun with a client in another region sent its writes there -- for an event bus or rule, to whatever carried the same name in that region, a resource cdkd does not manage (an EventBridgePutRulecreates the rule when none exists). An SQS queue update instead failed at AWS with a request-signing error naming neither region, because the SDK sends the call to the queue URL's host but signs it for the client's region. The update now stops before the first call and names both regions. An update whose state record has no region is unchanged. cdkd state destroynow removes the earlier versions of the state records it deletes, so their bodies stop being readable by version id (issue #2346 sites 1-3) --src/state/s3-state-backend.ts(purgeDeletedStateHistory),src/cli/commands/destroy-runner.ts(purgeStateHistory),src/cli/commands/state.ts,src/provisioning/nested-stack-context.tsandsrc/provisioning/providers/nested-stack-provider.ts, newtests/unit/state/state-destroy-version-purge.test.ts. The state bucket is versioned, so the record delete used to write only a delete marker, and every earlierstate.jsonbody (every recorded property and attribute) stayed readable throughGetObjectwith aVersionId. After the delete and the kept-resource record's write, the command now purges the region-scoped key's noncurrent versions, plus those of a cascading nested child. It also purges the legacy<prefix>/<stack>/state.jsonkey's history, but only when no other region of the stack hasstate.jsonversions, current or earlier, because that history may belong to that region. Otherwise, or when the record's absence could not be confirmed or the other regions could not be listed, the history is kept and a warning says so. Every other delete keeps the history as the recovery path:cdkd destroy,cdkd state orphan, rollback, export and the v1 -> v2 migration tail. That split is the maintainer's decision on the issue. The purge needss3:ListBucketVersionsands3:DeleteObjectVersion. Without them the destroy still succeeds, and a warning names both grants.- Removing
AlpnPolicy,MutualAuthentication,Certificates,SslPolicy,PortorProtocolfrom anAWS::ElasticLoadBalancingV2::Listenernow warns that the live value stays --src/provisioning/providers/elbv2-provider.ts.ModifyListenerkeeps any field it is not sent, so a removed one stayed live while the deploy reported success and the nextcdkd diffshowed no change. cdkd still sends no reset, since CloudFormation's behavior on these removals is unmeasured, and now names each removed property in one warning after the update succeeds.SslPolicyandCertificatesare not named on an HTTPS to HTTP or TLS to TCP change, where AWS drops them itself.drift --revertprints no such warning. Issue #1160.
Recently Implemented (2026-10-13):
- More
cdkd local *AWS-failure relays follow the #2300 render policy (issue #4857, completing #2300's enumerated items) --src/local/ecr-puller.ts,src/local/ecs-secrets-resolver.ts,src/cli/commands/local-state-loader.ts,local-run-task.ts,local-start-api.ts,src/cli/config-loader.ts,src/utils/local-aws-failure.ts(moved fromsrc/cli/commands/),src/utils/docker-cmd.ts. The ECR-pull refusals (STS GetCallerIdentity,--ecr-role-arn'sAssumeRole-- printed twice, the second time asCaused by:-- andECR GetAuthorizationToken), the ECS secret refusals (Secrets Manager, SSM), the--assume-task-role/local start-api --assume-role/--profilecredential refusals, the--from-statestate-bucket warns and the uncaught state-bucket access failure printed the SDK error's raw message. They now keep a modeled service exception's message (AccessDenied: User ... is not authorized ...) and print anything else as<Class>[ <code>]; N-character message withheld, logged at debug level under --verbose. Through the default credential chain on the pinned SDK acredential_processfailure already arrives as the genericCould not load credentials from any providers, so the main effect is sanitizing forged names and control characters. The ECR errors keep the SDK error's classification fields as theircausethrough a redacted copy (redactedAwsCause);No cdkd state bucket found ... Run 'cdkd bootstrap'is now aStateErrorand still prints in full; a withheld--profilefailure names theaws sso loginremedy. cdkd local start-agentcore/start-cloudfront/start-service/start-albsynthesize with cdkd's own synthesizer (issue #4856) --src/cli/commands/local-host-synthesizer.ts(new),src/cli/commands/local-start-{agentcore,cloudfront,service,alb}.ts,package.json(cdk-local 0.150.0, which takes asynthesizer, go-to-k/cdk-local#787). These four commands synthesized the CDK app through cdk-local's@aws-cdk/toolkit-libsynthesizer, the onlycdkdcommands that did. They now use cdkd's synthesizer, aslocal invoke/start-api/run-taskdo, on boot and on every--watchreload. The stacks these commands pick a target from now include acdk.Stage's stacks, which cdk-local's synthesizer did not enumerate (go-to-k/cdk-local#746); a stack carrying a CloudFormation macro is expanded, using--state-bucketfor a template too large to expand inline, and context lookups andcdk.json/cdk.context.jsoncontext resolve as in every othercdkdcommand. A macro-bearing stack is expanded on every--watchreload too, which needs credentials and a region.--verboseshows the synthesis debug output again, and--role-arnis the identity of the synthesis's own AWS calls (macro change set, upload) under--profileas well. Tests:tests/unit/cli/local-host-synthesizer.test.ts,tests/unit/cli/local-start-own-synth-wiring.test.ts(new). Integ:local-start-agentcore,local-start-cloudfront,local-start-service,local-start-alb,local-start-service-watch-fast,local-start-alb-from-state.- Editing an SNS subscription's attributes updates it in place instead of replacing it (#4853) --
src/provisioning/providers/sns-subscription-provider.ts. A change toRawMessageDelivery,FilterPolicy,FilterPolicyScope,RedrivePolicy,DeliveryPolicy,ReplayPolicyorSubscriptionRoleArnon anAWS::SNS::Subscriptionmadecdkd deployunsubscribe and subscribe again. The subscription got a new ARN, messages published in between were not delivered, and an email or http(s) endpoint went back to pending confirmation. cdkd now reads the subscription and applies only the attributes that differ withSetSubscriptionAttributes, keeping the ARN, as CloudFormation does. An attribute removed from the template is reset to its default (RawMessageDeliveryoff, noFilterPolicyorRedrivePolicy,FilterPolicyScopeback toMessageAttributes). A change to the topic, protocol or endpoint, the removal ofDeliveryPolicy,ReplayPolicyorSubscriptionRoleArn, and a newFilterPolicytogether with a change of itsFilterPolicyScope(declared or dropped) still replace the subscription. - A resource named from a
NoEchoparameter keeps its ARN in state, so its readers redeploy (#4849) --src/deployment/secret-redaction/redact-state.ts,src/deployment/deploy-engine/masking.ts,src/provisioning/providers/nested-stack-provider.ts. When a resource's name came from aNoEchotemplate parameter (an SNS topic withTopicName: !Ref Name),state.jsonstored its own ARN attribute (TopicArn) as***, and so did every stack output and export holding that ARN. The next deploy that re-resolved a reader (editing a subscription or a grant usingFn::GetAtt Topic.TopicArn) failed withCannot resolve Topic.TopicArn ... redaction maskand rolled back. A consumer stack importing the exported ARN in a later run was refused the same way. AWS publishes a resource's name inside its ARN, and cdkd already keeps the physical id itself as AWS returns it; the ARN attribute, the outputs, the exports and a nested stack's outputs holding a child's id are now kept the same way. An attribute or output that equals theNoEchovalue itself (TopicName) still stays***, unless the resource's physical id is that value (then it is stored as the physical id is). A record an earlier cdkd wrote with***there is repaired by the next deploy that reads the attribute: cdkd re-reads the resource and replaces the mask only with a value that is the physical id, or an ARN that ends in it with nothing but the ARN's partition, service, region, account and resource type before it (a function's or role's ARN). - State schema v12: each stored
{{resolve:...}}reference records where it was resolved and which secret version it stood for (issues #4847, #4852, part 1 of 3) --src/types/state.ts,src/deployment/secret-redaction/reference-provenance.ts,src/deployment/reference-provenance-record.ts,src/deployment/deploy-engine/provenance-capture.ts,src/state/token-free-state.ts. A resource record gainsreferenceProvenanceand the stackoutputReferenceProvenance: per token, the region that resolved it and the secret's own version ids (a Secrets ManagerVersionId, an SSMVersion), read off the lookups cdkd already makes, never a value or a hash. Nothing reads them yet: every command behaves as before, and the fixes that use them follow. The first deploy after upgrading adds them to unchanged records through a one-time capture that sends, updates and replaces nothing (a record it cannot attribute gets an empty entry and one warning; a throttled lookup is retried next deploy); later deploys make no extra AWS call, and a stack holding no reference takes no extra work.CDKD_SDK_CALL_LOG=<file>appends, per cdkd process, a line counting every AWS SDK command it sent. Older binaries fail on v12 with "Upgrade cdkd". Integ:schema-v11-to-v12-migration(new). cdkd importthrough Cloud Control keepsProposedSegmentChangeon NetworkManager attachments (PR #4840) --src/provisioning/getatt-attributes.generated.ts, regenerated from@aws-cdk/aws-service-spec0.1.219. OnAWS::NetworkManager::ConnectAttachment,SiteToSiteVpnAttachment,TransitGatewayRouteTableAttachmentandVpcAttachment,ProposedSegmentChangeis both a writable property and aFn::GetAttattribute. An import recorded it as***, so a later deploy readingFn::GetAtt <attachment>.ProposedSegmentChangefrom the imported resource was refused. It is now kept, like the other writable attributes (#3735).--remove-protectionkeeps a user pool's custom ACR level names (PR #4839) --src/provisioning/providers/cognito-provider.ts. The AWS SDK bump addsAcrConfigurationtoUpdateUserPool, and the API reference says a level a write does not name gets its default name, so theDeletionProtectionflip could reset the custom names of an Essentials or Plus pool. The flip now echoes the custom-named levels with the other read-back members (#4066). Levels carrying their defaulturn:cognito:loa:<n>are not sent, so a pool with no custom name -- every Lite pool -- gets noAcrConfigurationin the write. Tests:tests/unit/provisioning/providers/cognito-remove-protection-rmw.test.ts.- A reader that spells a secret is no longer skipped, left on a deleted resource's ID, when the secret-named resource it reads is replaced under the rotated secret (issue #4838) --
src/deployment/deploy-engine/update.ts. When a secret rotated and a create-only change replaced a resource named from it (an SQS queue renamedsdin-q-1->sdin-q-2), a reader that also spells the same{{resolve:...}}reference (a tag besideValue: Fn::GetAtt Queue.Arn) recorded the new name against that reference, so the new ARN redacted to the samearn:...:{{resolve:...}}it had stored for the old queue. The no-change re-check compared equal, the reader was not updated, and it kept the deleted queue's ARN in AWS. A resource that reads a resource whose physical ID changed in this deploy is now sent, rather than skipped, whenever the redaction rewrote any of its values. A create-only property the redaction rewrote counts as changed, so a reader such as an event source mapping is replaced rather than updated in place. A reader whose values no secret rewrote, or of a resource replaced under the same physical ID, is still skipped. A reader left stale by an interrupted earlier deploy, a nested child fed by a parameter, and a cross-stack consumer are not covered (issue #4852). - A deploy finishes sooner after its last resource (issue #4837) --
src/provisioning/providers/dynamodb-table-provider.ts,src/state/{lock-manager,s3-state-backend,export-index-store}.ts,src/provisioning/providers/create-token-ledger.ts,src/deployment/deploy-engine/{deploy-flow,rollback}.ts,src/cli/commands/deploy.ts. The bookkeeping after the last resource took ~5s on a 5-resource stack. A DynamoDB table's follow-up reads for its drift baseline (tags, point-in-time recovery, TTL, resource policy, Kinesis destination, contributor insights, stream members) now run concurrently, in the same key order; per-index contributor insights stay one after another. A deploy that finds no rollback journal at its start (under the stack lock) purges the journal key's leftover versions then, beside the deploy (on a first deploy, once its pre-provisioning gate passes), and waits for that purge before releasing the lock on every path; and its success then no longer reads, deletes and purges the absent journal again; any journal write through the same backend voids that. The create-token ledger, and the exports index when the stack's record publishes no export, are read at the start too, so an unchanged success needs no read of either. The state backend opens its connections beside the final drain, as S3 drops a keep-alive socket idle about five seconds.cdkd deployno longer waits for the lock's old-version purge before its events flush: the lock is still deleted before the deploy returns, and the purge finishes beside the flush, before the command exits. Tests:tests/unit/provisioning/{dynamodb-table-provider-readcurrentstate,create-token-ledger}.test.ts,tests/unit/state/{lock-noncurrent-version-purge,rollback-journal-version-purge,export-index-store}.test.ts,tests/unit/deployment/deploy-engine-tail-ordering.test.ts. - A copy of a string-declared
NoEchovalue is now stored masked, as a boolean one is (issue #4831) --src/deployment/intrinsic-resolver/parameter-secrets.ts. For a parameter declaredNoEcho: "true"(the string form CloudFormation accepts), a leaf that held the value without reading the parameter by position (a literal, or a copy in another property) was persisted in plaintext instate.json. The value was recorded only as a log-only needle, unlikeNoEcho: true, because masking that copy would have replaced a create-only resource on the next unchanged deploy. Now that the migration witness also reads a record anotherNoEchosource already marked (#4836), the value is a mask-only needle too. A record written before is masked on its next deploy without being replaced, and the leaf is named innoEchoLeaves. The--verboselog lines that print a resolved value holding it now mask the WHOLE value, as forNoEcho: true(Resolved Fn::Sub: ***rather thanResolved Fn::Sub: str-***). Tests:tests/unit/deployment/noecho-string-value-arm-4831.test.ts. Integ:noecho-parameter-maskingPhase 4v deploys an isolated stack with @go-to-k/cdkd@0.297.0, then redeploys it unchanged with this binary, and fails if the topic is replaced, its name prints, or the copy stays in plaintext. - A reader of an unchanged resource whose secret-derived name is assembled by an intrinsic or read from another stack in the same region is no longer sent the unresolved
{{resolve:...}}spelling (issue #4805) --src/deployment/intrinsic-resolver/getatt.ts(referencesRegionProven),src/deployment/intrinsic-resolver/context.ts,src/deployment/deploy-engine/resolver-context.ts,src/deployment/deploy-engine.ts,src/deployment/deploy-engine/deploy-flow.ts. Since go-to-k/cdkd#4800 a reference in a stored record (an SQSArnaround a queue named from a secret) is resolved before a reader'sFn::GetAtt/Refis served, but only when the resource's template spelled the token literally and read no other stack. A name built byFn::Join/Fn::Sub(CDK'ssecretValueFromJsonon a secret in the stack), a literal name beside an imported dead-letter queue, a name read byFn::ImportValue, or one copied byFn::GetAttfrom another resource was still served as stored: anAWS::SSM::Parameterreader refused it and the deploy failed.cdkd deploynow also proves the region from the stack's cross-stack reads (its state'simports/outputReads, this run's reads and a nested child's ancestors'): when they are complete and name no producer in another region, the reference is resolved here. A stack that reads a stack in another region, or a nested child whose parent's reads are unknown, is served as before. The physical-id check of go-to-k/cdkd#4800 still applies, so a rotated secret or a value that is not the name is still served as stored, with a warning. - A fix-forward deploy now deletes the Lambda function an earlier failed CREATE left behind (issue #4606, Lambda function slice) --
src/provisioning/providers/lambda-function-provider.ts. When acdkd deploy --no-rollbackmade anAWS::Lambda::Function, failed configuring it (RecursiveLoop,ReservedConcurrentExecutionsorRuntimeManagementConfig) and could not delete it again, and a later deploy kept the logical id under another function name, the successful deploy warned about the earlier function, left it in AWS and exited 2;cdkd rollbackandcdkd destroykept it the same way. The Lambda function provider now answers both questions: a function name is unique per account and region and cannot be changed, so the earlier function is a different one once the record's function reads back; and the failed CREATE records the function's ARN and theLastModifieditsCreateFunctionreturned, so the earlier function is deleted only while its name still reads back under both. A function deleted by hand and re-created under the name has anotherLastModifiedand is kept, with a warning; so is one a later code or configuration update changed. A successfulPutRuntimeManagementConfigmovesLastModifiedtoo, so a failure after it journals a live read instead of the create's token. Tests:tests/unit/provisioning/lambda-function-provider-same-resource-4606.test.ts; integ:lambda-fix-forward-orphan. broadeningno longer depends on a CDK library (issue #4601) --src/analyzer/security-changes.ts(new),src/analyzer/security-diff.ts,package.json.cdkd diff --fail-on=broadeningandcdkd deploy --require-approval=broadeningread their verdict and security table from@aws-cdk/cloudformation-diff, a runtime dependency since 0.298.0. They now use cdkd's own classifier, which follows the AWS CDK CLI's rules for IAM statements, managed-policy attachments,AWS::Lambda::Permission, IAM Identity Center and security-group rules. The verdict and the printed table (text and colors) are unchanged, except that text the CDK CLI's table could not lay out (a combining mark, a control character, a ZWJ emoji) is now printed rather than dropped or replaced by a note, and@aws-cdk/cloudformation-diffis gone from cdkd's dependencies (it remains a devDependency for tests). Tests:tests/unit/analyzer/security-changes-parity.test.ts(new) runs both classifiers over 84 template pairs, with and without colors, and requires the same verdict and table, and requires cdkd's table of scrutinized resource types and properties to match the AWS service-spec database.cdkd state show/state resourcesno longer printU+2028/U+2029in a record key, logical id or value, andcdkd diffmasks a secret that one of them split in a blocking reason (issue #2888) --src/utils/regexp.ts,src/cli/commands/state.ts,src/cli/commands/diff-recursive.ts,src/analyzer/outputs-diff.ts. The state views write to stdout directly and stripped control characters withstripControlCharsalone, which keeps both separators, and a JSON or web log viewer breaks a line on either, so a hand-edited record could still forge a row there. They now go throughstripForDisplay, which deletes the same class, replaces the two separators with a space and trims; a legitimate value renders as before apart from leading and trailing whitespace, which is now trimmed. Thecdkd diffheaders, blocking reasons, Outputs preview values and warnings and the Outputs debug lines take the same helper; the logger already printed both separators as a space there, so only the trim is new. A blocking reason is now masked after that substitution, so aNoEchovalue holding a space, carried ascorrect<U+2028>horse, prints as***rather thancorrect horse, including in--json'sblocking. Tests:tests/unit/utils/regexp.test.ts,tests/unit/cli/state-show.test.ts,tests/unit/cli/state-resources.test.ts,tests/unit/cli/diff-recursive.test.ts,tests/unit/cli/diff-noecho-render-4049.test.ts,tests/unit/analyzer/outputs-diff.test.ts.cdkd local *STS failure warnings follow cdk-local's render policy (issue #2300) --src/utils/local-aws-failure.ts(new),local-invoke.ts,local-run-task.ts,local-start-api.ts,local-invoke-agentcore.ts. The default-level warn printed when STSGetCallerIdentity(for${AWS::AccountId}) or--assume-role'sAssumeRolefailed interpolated the SDK error's raw message, which for acredential_processfailure carries the failed command line. Each of the eight sites now renders through cdk-local'sdescribeAwsFailureForWarn: a modeled STS exception keeps its message (AccessDenied: User ... is not authorized ..., one line, capped), and any other failure prints<Class>[ <code>]; N-character message withheld, logged at debug level under --verbose, with the full text atdebugthrough cdkd's own logger. ACdkdError(cdkd's ownAssumeRole(<arn>) returned no usable credentials.) still prints in full. The role ARN still prints. A matcher on the old warn text needs updating. The--from-statestate-bucket warns and thesrc/local/**relays: #4857.- A bare cdkd command uses your AWS profile's region, and keeps finding stacks the old
us-east-1default created (issue #2100) --src/cli/region-options.ts,src/cli/config-loader.ts,src/state/s3-state-backend.ts,src/synthesis/app-executor.ts,src/assets/docker-build.ts,src/cli/commands/*.ts. With no--region/AWS_REGION, these commands keyed an env-agnostic stack's state, resources andAWS::Regionbyus-east-1while their pre-flight clients followed the profile. They now resolve--region,AWS_REGION,AWS_DEFAULT_REGION, the profile's region, thenus-east-1, ascdkd bootstrap/gcalready did. Compatibility: when the region came fromAWS_DEFAULT_REGIONor the profile, an env-agnostic stack that has state only inus-east-1stays there, and cdkd printsContinuing to use us-east-1 so the existing stack is not duplicated; a new stack joins it when the app's other stacks are all held, so the app stays in one region. The same hold applies to a force-unlock lock, the exports index (one file per command, theus-east-1one while it exists) and a region-less legacy state record (S3StateBackend.stateExistsOwnedBy), and the default state bucket also readscdkd-state-<account>-us-east-1, preferred while it holds state;cdkd state migratewith no--regionpicks its source the same way. A stack with state in both regions stays inus-east-1, and aus-east-1legacy bucket that answers 403 never wins over a working one. A region taken fromAWS_DEFAULT_REGION, which the AWS SDK does not read, is copied intoAWS_REGIONfor cdkd's clients but not for the CDK app or an asset's build command. A fresh bare deploy into a profile region without cdkd asset storage creates it or uses the CDK bootstrap assets there. Tests:tests/unit/cli/*-2100.test.ts(new),region-effective,config-loader,state-migrate,s3-state-backend. Integ:profile-region-default(new). - A provider's delete is handed the secret masker, so it can tell a name is secret-derived (issue #2007) --
src/provisioning/region-check.ts,src/types/resource.ts,src/deployment/deploy-engine/{delete,replacement,update-replace,update-in-place}.ts,src/cli/commands/destroy-runner.ts,src/deployment/rollback-executor.ts,src/deployment/rollback-executor/{names,replay-scope,replay-revert,replay-reverse-replacement,replay-orphan-delete}.ts,src/provisioning/cloud-control-provider.tsand the custom-resource, S3 Express directory bucket, IAM role / managed policy / user / group, DynamoDB global table, Glue, RDS DB proxy target group, SNS subscription and ACM certificate providers.DeleteContexthad nomaskSecrets, so a provider could not tell that a name it printed was derived from a secret. The S3 Express "bucket is not empty" refusal therefore printedaws s3 rm s3://*** --recursiveonce the log masked the name: a command that names no bucket. It now says the name is secret-derived and prints no command. A deploy's replacement delete also ran with no resolved value bound for the logger, so a provider's own delete lines could print one; they are masked now. Every cdkd delete site passes a masker: a replacement over the deploy's resolved secrets and its secret-named records, a rollback over both the re-resolved previous generation and the failed deploy's values (its own failure lines and events included), andcdkd destroyor a template-removal delete over the record's secret-derived names. A provider's internal delete during an update or a failed create's cleanup is handed the operation's masker too.
Recently Implemented (2026-10-12):
- Declaring an existing template parameter
NoEchono longer replaces a create-only resource that reads it, and the value is then stored masked (issue #4831) --src/deployment/secret-redaction/noecho-leaves.ts,src/deployment/deploy-engine/{update,noecho}.ts,src/deployment/deploy-engine.ts. A record written while a parameter was ordinary stores its value in plaintext. Once the template declares the parameterNoEcho, the deploy compares what it would store (***) with that plaintext. The migration witness reconciles the two only when the record carries nonoEchoLeavesat all. A record anotherNoEchosource had already marked read the leaf as moved:cdkd diffandcdkd deployreported a create-only change and replaced the resource (a fixed-name topic, bucket or table), andcdkd diffprinted the stored plaintext as the old value. The witness now covers every record. A leaf whose stored plaintext equals today's value reads as unchanged. The save stores***there and adds the leaf tonoEchoLeaves. A different value is still a change, shown as(previous NoEcho value). When the same deploy updates another property in place, the provider is now handed that leaf's value as the previous one rather than***, so it no longer reads a create-only change there (an ACM certificate was re-created inside its update). Tests:tests/unit/deployment/noecho-witness-marked-4831.test.ts,tests/unit/cli/diff-noecho-witness-marked-4831.test.ts. Integ:noecho-parameter-maskingPhase 4w declares the parameter behind a fixed-name SNS topicNoEchowith its value unchanged, and fails if the topic is replaced or the name prints. cdkd diff --security-only(issue #4827) --src/cli/commands/diff.ts. The flag was refused as an unknown option, whilecdk diff --security-onlyis valid. It now prints, instead of the diff, the AWS CDK CLI's warning and the security table (IAM and security-group changes, secret values shown as***) of every stack in each top-level tree that broadens permissions, and one line for a tree that does not. It walks nested stacks, refuses--json, and for--fail-oncounts only the stacks it prints:any-changefails on a security change in a broadening tree,broadeningfails without printing the tables twice,destructivestill reads the whole diff. The security diff (#4601) is computed only when the flag or--fail-on=broadeningasks. Tests:tests/unit/cli/{diff-fail-on,diff-deploy-refusal-exit}.test.ts; integrequire-approval-broadening.- A template parameter declared
NoEcho: "true"(the string form) is masked likeNoEcho: true(issue #4824) --src/deployment/noecho-declared.ts(new),src/deployment/intrinsic-resolver/parameter-secrets.ts,src/deployment/deploy-engine/{masking,noecho}.ts,src/deployment/outputs-export-alias/secret-scan.ts,src/deployment/condition-verdicts.ts,src/deployment/masked-property-fingerprints.ts,src/deployment/noecho-delete-reresolution.ts,src/deployment/intrinsic-resolver/{support,refs}.ts,src/analyzer/skipped-outputs.ts,src/cli/commands/diff-recursive.ts. CloudFormation accepts the string as well as the boolean (a hand-written orCfnIncludetemplate). Several readers tested only the boolean. With the string form, the value printed oncdkd deploy's log lines (the resolver's--verboselines and its diff lines). The export-name secret scan did not see it, and it fed a recorded condition verdict and the skipped-output digest. These readers now ask one predicate,isNoEchoDeclared(the boolean or exactly"true").cdkd diffalso masks a property's stored copy. A string-declared value copied into a leaf that does not read the parameter still persists in plaintext until the migration witness covers such records (issue #4831): masking it now would replace a create-only resource. A stack whoseExport.Namespells a string-declared value loses that export on its next deploy, as a boolean one always did. Tests:tests/unit/deployment/noecho-string-form-4824.test.ts,tests/unit/cli/diff-noecho-string-form-4824.test.ts,tests/unit/deployment/noecho-string-form-replacement-4824.test.ts. Integ:noecho-parameter-maskingdeclares a parameter with the string form and fails when its value reaches the deploy logs, events or state. - A deploy that swaps an inline policy name between an
AWS::IAM::Policyand anAWS::IAM::RolePolicy/UserPolicy/GroupPolicyon one principal now keeps both policies, and so does its rollback (issue #4821) --src/deployment/inline-policy-claims.ts,deploy-engine.ts,deploy-engine/execute.ts,deploy-engine/replacement.ts,rollback-executor.ts,src/provisioning/cloud-control-provider.ts. Such a swap used to run both writers at once, and the replaced standalone policy's delete of its old name removed the policy that had just taken it, so the principal could end up with neither. CloudFormation finishes the same swap holding only the standalone policy's new name: its cleanup-phase delete removes the other policy. cdkd now runs the writer releasing a name before the standalone policy that takes it (Cloud Control refuses to create a name the principal still holds). A standalone policy's delete then leaves a name another resource of the same deploy has already written there. A rollback reverses that writer before the standalone policy. Writers that share no (principal, name) pair are not ordered, so a deploy with no such collision runs as before. cdkd driftno longer reports anAWS::IAM::RolePolicy/UserPolicy/GroupPolicy's policy asPoliciesdrift on its role, user or group (issue #4821) --src/provisioning/providers/iam-role-provider.ts(collectInlinePolicyNamesManagedBySiblings),src/deployment/deploy-engine/observed-capture.ts. Only anAWS::IAM::Policysibling's inline policy was excluded from the principal'sPolicies, so aCfnRolePolicyon a role that declares inline policies showed its name as drift on the role. A standalone policy naming the principal inRoleName/UserName/GroupNameis now excluded the same way, in the drift read and in the deploy-time baseline.- A reader's copy of a secret-named resource's physical id read from another stack or a nested stack's parameter, or named from a
NoEchoparameter, is no longer stored as{{resolve:...}}or***(issue #4819) --src/deployment/intrinsic-resolver/cross-stack.ts,src/deployment/intrinsic-resolver/parameter-secrets.ts,src/deployment/secret-redaction/fresh-noecho.ts. Since go-to-k/cdkd#4810 a copy read withRef/Fn::GetAttin the same stack stays as the id is. A queue policy whoseQueuesimports another stack's secret-named queue URL withFn::ImportValue/Fn::GetStackOutput, and whose document spells the secret reference literally, still had the URL rewritten intohttps://sqs.../{{resolve:...}}by the save, socdkd destroyof that stack failed withInvalidAddress. A value that is one of a same-account producer's physical ids (read from its state once per resolution) is now recorded as a copy where it is read; any other output value, and one read across an account boundary, is not. A nested child reading a parameter the parent filled with an id it read keeps that id's copy the same way, incdkd deployandcdkd scrub, whether or not the parent's row holds a secret. A queue named from aNoEchoparameter, read by a reader that also reads the parameter, had its URL copy flattened to***; theNoEchocontainment arm now keeps a copy's spans too, and still masks the value anywhere else. cdkd destroyno longer fails on a reader of a resource named from a secret because a deploy stored the reader's copy of that resource's physical id as a{{resolve:...}}reference (issue #4810) --src/deployment/secret-redaction/physical-name-copies.ts(new),secret-redaction/redact-state.ts,intrinsic-resolver/refs.ts,deploy-engine/update.ts,src/cli/commands/drift.ts,src/provisioning/providers/scheduler-schedule-provider.ts. AnAWS::SQS::QueuePolicywhoseQueuesreads a queue named from a secret, and whose policy document spells the secret reference literally, had its plaintextQueuesURL rewritten intohttps://sqs.../{{resolve:secretsmanager:...}}by the save after a deploy (the final save after an automatic rollback included), because the policy's own secrets bag held the queue name;cdkd drift --accept/--revertdid the same. The provider deletes by that URL, socdkd destroyfailed withInvalidAddress. Each physical id aRef/Fn::GetAttreads (for drift, every other record's) is now recorded beside the reader's secrets, and those writes keep a secret's text inside such an id, as the id is stored in plaintext. Elsewhere in a value, and in a literal reference, it is still the reference. The automatic rollback's own restore is go-to-k/cdkd#4801's half; that shape is fixed once both are released. A record an earlier deploy rewrote is repaired by the nextcdkd deploy, which sends and replaces nothing for it, though its plan,cdkd diffand--require-approvalshow the reader as changed once; acdkd destroyrun first still fails. A copy read from another stack, through a nested stack's parameter, or of aNoEchovalue is not covered (issue #4819).- A cross-account
Fn::GetStackOutputthat the AWS CDK generates is now read through the producer account's CDK lookup role, or refused before the deploy with both remedies (issue #4808) -- the newsrc/deployment/intrinsic-resolver/cdk-cross-account-ref.ts,stack-output.ts,src/synthesis/assembly-reader.ts(StackInfo.lookupRoleArn),src/analyzer/cross-stack-deps.ts,src/cli/commands/deploy.ts,diff.ts,diff-recursive.ts. Such a reference used to fail mid-deploy with "RoleArn must be a literal string" (or, under@aws-cdk/core:enablePartitionLiterals, with anAssumeRoledenial).cdkd deployandcdkd diffnow recognize it, only for a reference carrying aRoleArn: theRoleArnis a literal ARN or CDK's exactFn::JoinoverAWS::Partition, and the producer stack in the same assembly declares a role of that name trusting only CloudFormation execution roles. cdkd then assumes the producer artifact's lookup role and reads the producer's state as for anyRoleArn; the secret refusal still applies. When the producer is not in the assembly, names no lookup role, or the lookup role or state read is denied, cdkd refuses and namescdk bootstrap --trust-for-lookup <consumer-account>and a user-writtencdk.Fn.getStackOutput();cdkd deployrefuses the first two before anything is provisioned when the reference sits in a top-level stack's resource that every deploy resolves; one in a nested stack or a conditional branch is refused when it is resolved. A role you wrote keeps today's behavior, and a reference withoutRoleArnruns none of this. - A reader of a resource named from a secret is no longer updated again on the deploy after that resource is updated (issue #4802) --
src/deployment/intrinsic-resolver/getatt.ts(recordProducerSecretPairs),src/deployment/intrinsic-resolver/context.ts,src/deployment/deploy-engine/resolver-context.ts. When a deploy updated an SQS queue named from a secret, a reader of itsFn::GetAtt Queue.Arnwas served the provider's fresh ARN in plaintext and stored it that way, while a reader of an unchanged queue stores the reference form (go-to-k/cdkd#4800). The next deploy's diff reads the queue's stored, reference-form ARN, so every such reader was updated once more. A reader served an attribute of a resource resolved in the same deploy now records that resource's own secret pairs its value carries, and stores the reference form too, when the resource keeps its physical ID. A deploy that creates or replaces it still stores the plaintext in its readers and in a stack output reading it (an export alias carrying it is published on that deploy and withheld on the next), as before. - A rollback that restores a resource holding a copy of a secret-derived physical name now records that copy as AWS knows it, so the next
cdkd destroycan delete it (issue #4801) --src/deployment/rollback-executor/replay-secrets.ts(redactRollbackRecord,keepPersistedPlainLeaves), with the previous record's attributes passed in bysrc/deployment/rollback-executor.ts,src/deployment/rollback-executor/replay-revert.tsandsrc/deployment/rollback-executor/replay-reverse-replacement.ts, and the rollback journal's copy of each previous record insrc/deployment/deploy-engine/rollback.ts. When an automatic rollback (orcdkd rollback) reverted a resource such as anAWS::SQS::QueuePolicywhose queue is named from a{{resolve:secretsmanager:...}}reference, the restored record had the queue name inside itsQueuesURL replaced by the reference, socdkd destroyfailed with "The address https://sqs.../{{resolve:secretsmanager:...". The restore, and the journalcdkd rollbackrestores from, now keep a plaintext value exactly as the previous record stored it, in properties and attributes, as a normal deploy stores it. A value the previous record stored as a reference is still stored as the reference, and the rollback writes no value the previous record did not already hold. A record an older cdkd wrote with a secret value in plaintext is restored as it was;cdkd scrubrepairs it. - A reader of a resource named from a secret is no longer sent the unresolved
{{resolve:...}}spelling when that resource is unchanged (issue #4800) --src/deployment/intrinsic-resolver/getatt.ts(reresolveRecordedReferences),refs.ts. State stores a secret-derived attribute with the secret's expression in place of its value, so an SQS queue named from a secret records itsArnasarn:aws:sqs:...:{{resolve:secretsmanager:...}}. A deploy that left the queue unchanged served that record to a reader'sFn::GetAtt Queue.Arnand sent the spelling to AWS: anAWS::SSM::Parameterrefused it and the deploy rolled back, while anAWS::SQS::QueuePolicyaccepted it and itsResourcematched nothing. A value anFn::GetAttorRefserves from a stored record's attributes now resolves the references it carries first, when the resource's own template spells each one literally and reads no other stack's value anywhere in its properties, and when the resource's physical id holds the value each resolves to. Otherwise it is served as stored: a name assembled by an intrinsic or read from another stack (issue #4805), and, with a warning, any value the physical id does not hold: a secret rotated since the resource was deployed (it still carries the old name), but also a value that is not part of the name (a custom resource'sData, anAWS::S3Tables::TableTableNameon the Cloud Control route) and a name shorter than four characters. The reader's record stores the expression back. A reader that stored the plaintext ARN after an update of the queue is updated once more, with the resolved ARN; that extra update is issue #4802. cdkd diff --fail-on=broadeningandcdkd deploy --require-approval=broadening(issue #4601) --src/analyzer/security-diff.ts(new),src/deployment/deployment-approval.ts,src/deployment/deploy-engine/options.ts,src/deployment/deploy-engine/deploy-flow.ts,src/cli/commands/{diff,diff-recursive,deploy,require-approval}.ts,package.json. Both options were refused as invalid choices, and"requireApproval": "broadening"incdk.json(the AWS CDK CLI's default) was ignored with a warning, so a deploy thatcdkwould stop for approval went ahead. Both now takebroadening, decided by the AWS CDK CLI's own security diff (@aws-cdk/cloudformation-diff'spermissionsBroadened, now a direct dependency, loaded only at this level): an added IAM statement, managed-policy attachment,AWS::Lambda::Permissionor security-group rule.cdk.json'sbroadeningis honoured, so such a deploy now asks, and without a terminal it fails unless--yes. The approval prompt and the--fail-onreport print the CDK CLI's security table. A secret value is shown as***in the table; a masked policy or rule that changed counts as broadening, since it cannot be judged. With no deployed template, cdkd compares its state with the change, resolved as far ascdkd diffresolves it; a reference to a replaced resource is not a change.--fail-on=broadeningwalks nested stacks; under--require-approvaleach nested stack asks for its own changes. The default level staysnever. Integ:require-approval-broadening. Tests:tests/unit/analyzer/security-diff.test.ts,tests/unit/deployment/deployment-approval-broadening-cost.test.ts,tests/unit/cli/diff-broadening-secrets-4601.test.ts(new),tests/unit/cli/{diff-fail-on,require-approval}.test.ts,tests/unit/deployment/{deployment-approval,deploy-engine-require-approval}.test.ts.- Two
AWS::IAM::Policyresources with no dependency between them that swap an inline policy name on one principal, or hand one off (a rename away from a name a new policy takes), no longer both lose it (issue #4227) --src/deployment/inline-policy-claims.ts(orderInlinePolicyWriters),src/deployment/deploy-engine/execute.ts,src/deployment/deploy-engine.ts. Such resources used to run at once, so each one's removal of its old name could miss the other's put, leaving the role with neither policy until a later deploy changed one of them. Before dispatch, the deploy now makes each create / update that writes the same inline policy name on the same role, group or user wait for the one before it; a pair the template already orders, and every other resource, run exactly as before, so an ordinary deploy is unchanged. A name or principal cdkd cannot read before resolving counts as any name or principal of that kind; a principal of the same stack is told apart by its logical id even when its name is a token, so policies on different such roles stay concurrent. AnAWS::IAM::Role/Group/Userupdate that drops a name from its ownPoliciesnow also keeps it when another resource of the same deploy has already put that name there, as a rollback already did. Covered bytests/unit/deployment/inline-policy-write-order.test.ts,tests/unit/deployment/deploy-engine-inline-policy-claims.test.ts, and an unordered arm in theiam-role-policies-drift-cleaninteg fixture. cdkd destroymasks a resource name taken from aNoEchoparameter in state written before schema v11 (issue #3869) --src/deployment/secret-name-needles.ts,src/deployment/noecho-delete-reresolution.ts,src/deployment/rollback-executor/journaled-orphans.ts,src/deployment/intrinsic-resolver/support.ts,src/cli/commands/destroy-runner.ts. Since v11, aNoEchovalue persists as***, and nothing migrates an older record when it is read. So a pre-v11 record still holds the value in plaintext, with no marker saying it is secret. Its delete lines printed the whole id (Deleting Bucket <value>), and so did a reader holding the name and the id spellings a provider folds.cdkd destroynow binds the stack'sNoEchovalues once from the synthesized template, falling back to each literalDefaultwhen the binding fails, and hands them to a nested child. It binds nothing when noNoEchoparameter is declared, and waits at most 10 seconds, warning on expiry. Every delete's printing bag, journaled failed-CREATE orphans and both pre-prompt orphan listings included, is judged with these values and also masks the values themselves. Without a usable template (cdkd state destroy,cdkd drift --revert, the state-only fallback, a stack no longer in the app, a macro template, another region's synth), or for a value changed since the record was written, the judgement stays record-only until the nextcdkd deployrewrites the record. The prompt's list of resources an earlier rollback kept (state.orphans) now masks an id named from any secret, ascdkd diffdoes. ANoEcho: "true"string now redacts the parameter's--verboseline too. Masking only. Tests:tests/unit/cli/destroy-noecho-plaintext-name-3869.test.ts,tests/unit/deployment/secret-name-needles-3869.test.ts,tests/unit/deployment/noecho-delete-reresolution.test.ts,tests/unit/deployment/noecho-string-true-parameter-log-3869.test.ts.cdkd importthrough Cloud Control no longer masks aFn::GetAttattribute that is also a writable property (issue #3735) --src/provisioning/read-only-properties.ts(newgetTopLevelAttributeNames),src/provisioning/cloud-control-provider.ts, the new generatedsrc/provisioning/getatt-attributes.generated.tsand its generatorscripts/gen-getatt-attributes.ts(vp run gen:getatt-attributes,vp run audit:getatt-attributes:check, chained intogen:all-matrices), andsrc/deployment/deploy-engine/masking.ts. The import kept only the type's registryreadOnlyPropertiesand recorded every other model key as***, but CloudFormation also accepts some writable properties inFn::GetAtt(AWS::EC2::SubnetVpcId/CidrBlock/AvailabilityZone,AWS::SQS::QueueQueueName, ...). A later deploy reading one from an imported resource was refused, and the printed remedy (cdkd import --force) masked the same key again. The import now also keeps the writable properties the CloudFormation resource specification lists as attributes with the same value shape, from a table generated from the pinned@aws-cdk/aws-service-specdevDependency (no new runtime dependency). Other writable properties stay masked (so doesAWS::SSM::ParameterValue), and an unresolvable schema still masks the whole model. Docs:docs/import-resource-types.md.cdkd state refresh-observed <stack> --accept-refused <LogicalId> --template <file>clears one resource's parameter baseline refusal on the user's assertion (issue #3466) -- newsrc/cli/commands/state-refresh-observed.ts,src/cli/commands/state.ts,src/cli/commands/refused-baseline-remedy.ts,docs/cli-state-writing.md,docs/cli-destroy-prompts.md. Previously anunverifiable-parameter(or reason-less) refusal cleared only on replacement or a re-import a CloudFormation stack could prove, impossible after--migrate-from-cloudformation. The new flag takes one logical id of one stack (--all, several stacks and a second id are refused) and the template the stack was last deployed from. It refuses anincomplete-resolutionrefusal, a resource the template does not define or types differently, a template it cannot judge, a resource that still reads a declared parameter (directly or through another resource's attribute), a record that does not hold exactly what the template defines (Ref/Fn::GetAtt/Fn::Join/Fn::Sub/Fn::Selectevaluated against the stack's records, anything else refused), and a record holding a parameter'sDefault. The prompt states the assertion: AWS holds no secret the template does not spell as a dynamic reference, because whatever it holds is written tostate.json.-y/--yeslogs it as a warning instead; without it a non-interactive run is refused. Only that resource is read back, through the same position-based redaction as any refresh, and bothobservedBaselineRefusedandobservedBaselineRefusalReasonare removed only after the read succeeds. The sticky remedies name the command. Tests:tests/unit/cli/state-refresh-observed-accept-refused.test.ts. Integimport-deployed-parameter-secretstep 6d refuses an undeployed template and the parameter-bound record, then accepts a function moved to a dynamic reference.AWS::DynamoDB::Tablenow refuses anOnDemandThroughputceiling member CloudFormation rejects, before any AWS call, instead of dropping it and deploying green (issue #3286) --src/provisioning/providers/dynamodb-table-provider.ts(onDemandCeilingRefusal,refusedOnDemandCeilingShape),tests/unit/provisioning/dynamodb-table-provider-cfn-integer.test.tsand thedynamodb-ondemandinteg fixture (Phase 1.8). AMaxReadRequestUnits/MaxWriteRequestUnitsspelled padded, hex, exponent, decimal,null, boolean, array or object, at the table level or in aGlobalSecondaryIndexes[]entry, used to be omitted with a warning; both members are optional, so the request succeeded with that half unapplied while state recorded the declared spelling, andcdkd driftreported it on every run.create()andupdate()now throw naming the resource, the member and the value's TYPE (never the value; an index by position), as CloudFormation refuses the same template. On an update only a pending send is checked: an unchanged block sends nothing and is not refused. Integers, quoted integers and the-1"remove the maximum" sentinel deploy as before; a blank string, an integer beyond JavaScript's safe range and a rollback replay keep the previous warn-and-drop, since CloudFormation may accept the first two and a replayed state record cannot be fixed from the template.- A Cloud Control create or update no longer records the whole resource model as the resource's
attributes, so the first deploy aftercdkd importkeeps the import's narrowing (issue #2925) --src/provisioning/cloud-control-provider.ts(maskDeployedNonAttributeKeys),src/provisioning/read-only-properties.ts(getTopLevelDeployAttributeNames), the generatedSHAPE_MISMATCHED_GETATT_ATTRIBUTES,src/cli/commands/import.ts,src/deployment/deploy-engine/masking.ts. Every model key that is not anFn::GetAttattribute of the type is now recorded as***, key kept, ascdkd importalready did. Every attribute the CloudFormation resource specification lists stays in the clear, including the writable ones whose value shape differs from the property's (AWS::DocDB::DBClusterPort, which CDK'sallowDefaultPortFromreads), and keys the enrichment computes (Endpoint.Address, ...) are never masked;AWS::SSM::ParameterValuestays clear unless the deploy resolved it from a secret. When the deploy cannot read the schema (nocloudformation:DescribeType), it still records the whole model, as before, rather than refusing everyFn::GetAtt. A re-import's "produced no value to replace the redaction mask" warning now names only keys the template reads throughFn::GetAttorFn::Sub; other kept masks go to--verbose. The masked-attribute refusal's Cloud Control cause no longer says onlycdkd importwrites the mask. Docs:docs/import-resource-types.md,docs/import-after.md,docs/import-internals.md,docs/cli-export-internals.md. - The state bucket now expires custom-resource response objects with a one-day lifecycle rule (issue #2353) -- new
src/state/state-bucket-lifecycle.ts,src/cli/commands/bootstrap.ts,src/provisioning/providers/custom-resource-provider.ts. A custom resource's handler writes its whole response,Dataincluded, undercustom-resource-responses/in the state bucket. A run killed before cdkd deleted it left that object live untilcdkd gcran.cdkd bootstrapnow adds the lifecycle rulecdkd-custom-resource-responses-expirationforcustom-resource-responses/cdkd-1: a current version expires after one day and its noncurrent version one day later, each rounded up to midnight UTC. A state bucket bootstrapped earlier gets the rule from the first deploy or destroy that runs a custom resource: oneGetBucketLifecycleConfigurationper run, started beside the response placeholder write and never awaited, plus a listing and onePutBucketLifecycleConfigurationthe first time. Stacks with no custom resource make no call. The rule is merged into the existing configuration: every existing rule is kept, and nothing is written when a rule with the same ID exists, when the configuration cannot be read, or when a stack's state is stored where the rule would reach it (--state-prefix custom-resource-responseswith a stack namedcdkd-1*). A missing permission prints a warning on each such run and never fails the bootstrap, deploy or destroy. - cdkd now reads the region of a cloud assembly's stack environment exactly as the AWS CDK CLI does, and refuses the ones it refuses, such as
aws://<account>/US-EAST-1(issue #2221) --src/types/assembly.ts(parseEnvironment),src/synthesis/assembly-reader.ts. cdkd used to take any text afteraws://<account>/as the region. ForUS-EAST-1the deploy succeeded against the lowercase region, because its AWS clients fold the region they sign with, but recorded the stack's state under the raw spelling (cdkd/<stack>/US-EAST-1/state.json), which a later deploy from the correctly spelled assembly does not read. A region carrying a line break fell back to the command's own region. The region is now the longest run of lowercase letters, digits and-after the/, the rest ignored, which is how the AWS CDK CLI reads it:us-east-1/extra,us-east-1and a region followed by a line break all read asus-east-1. Where no such run starts right after the/(US-EAST-1,us-east-1), reading the assembly fails, naming the stack and the region, as the AWS CDK CLI fails withUnable to parse environment specification. The message tells you which spelling to re-synthesize with. For a spelling that differs only in case, it also warns that state an earlier cdkd recorded under the raw spelling is not found by the corrected deploy, and thatcdkd state showandcdkd state orphanstill reach that record with--stack-regiongiven the exact raw spelling. The account part of the environment and theunknown-*sentinels are unchanged.
Recently Implemented (2026-10-11):
- cdkd's errors, warnings and
--helptext no longer cite GitHub issue numbers or probe dates -- 40 files undersrc/(the type-change and ServiceToken refusals, the Glue Iceberg refusal, the Cognito, DynamoDB, EFS, Firehose and RDS notes, thecdkd local start-alb/start-cloudfrontwarnings and help, and thecdkd events,destroy --purge-events,state show --show-nestedanddeploy --recreate-via-sdk-providerhelp text, among others). Messages such aswhich cdkd does not replace in place (issue #2668).told a user nothing they could act on; each now states the behavior without the reference, and a message that leaned on one ("written by a cdkd version older than issue #1681") names an older cdkd version instead. The measured dates in the Glue, Cognito, DynamoDB andcdkd exportmessages are gone too. Wording only: no message changes what cdkd does. cdkd destroyof a parent stack whose nested child left only resources a failed deploy created now finishes in one run (issue #4738) --src/provisioning/providers/nested-stack-provider.ts,src/cli/commands/destroy-runner.ts(journaledOrphanSkipson the result),src/provisioning/nested-stack-context.ts, and the newsrc/cli/commands/destroy-skip-message.tsused bydestroy.tsandstate.ts. A failed-CREATE resource that the rollback journal alone records, and that cdkd keeps in AWS because nothing proves it is the deploy's own (an SQS queue, for example), keeps no state record. A nested child whose only skips were such resources had already deleted its own state, yet the parent kept itsAWS::CloudFormation::Stackrow and its own state and needed a second destroy. The parent now drops that row and its state in the same run. Those resources still count as skipped, so the run exits 2, and the row's line names how many the child left. The final message of acdkd destroy/cdkd state destroywhose every skip is such a resource no longer says "State preserved (the records are kept)": it says the resources were left in AWS and no state records them. When recorded skips sit beside them, only the recorded ones are counted as entries kept in state. A deploy that removes such a nested stack, and a rollback, are unchanged: they keep the row, and their next run finds the child's state gone.- A fix-forward deploy now deletes the DynamoDB global table an earlier failed CREATE left behind (issue #4606, DynamoDB global table slice) --
src/provisioning/providers/dynamodb-globaltable-provider.ts. When acdkd deploy --no-rollbackmade anAWS::DynamoDB::GlobalTable, failed configuring it (a replica, a TTL or a replica tag) and could not delete it again, and a later deploy kept the logical id under another table name, the successful deploy warned about the earlier table, left it in AWS and exited 2;cdkd rollbackandcdkd destroykept it the same way. The global table provider now answers both questions by theTableIdof the table in the stack's region, which AWS generates and never reuses: the failed CREATE records the id itsCreateTablereturned, and the successful deploy deletes the earlier table only when the record's table reads back under anotherTableIdand the old name still reads back under the recorded one. A table deleted by hand and re-created under the name has anotherTableIdand is kept, with a warning. Tests:tests/unit/provisioning/dynamodb-globaltable-provider-same-resource-4606.test.ts; integ:globaltable-fix-forward-orphan. cdkd diffandcdkd deploymask a secret-derived name that only a stored OLD value holds (issue #3869) --src/deployment/secret-name-needles.ts,src/cli/commands/diff-recursive.ts,src/deployment/deploy-engine/{masking,deploy-flow,provision}.ts. Consider a metric filter whose create-onlyLogGroupNamemoves from a log group named from a secret to an ordinary one. The row's old side is the state record's copy of the secret group's name, and nothing in the new template reads that group, so no resolution recorded the name. Therequires replacement (from <old> to <new>)line ofcdkd diff --verboseandcdkd deploy --verboseprinted it. So didcdkd diff --json's row, an output's stored old value, a nested child's line for a name its parent's row used to pass, and a deployed replacement'sDeleting old <id> (<old id>)andwas replacedlines, logged without--verbose, whose old id embeds the name.storedSecretNameNeedlesnow takes each record named from a secret, judged from state and the stack's wholeNoEchovalues, wherever another record or a stored output holds its name. A segment the record itself stores in the clear stays public.cdkd diffmasks every line and row with these, adopted orphan records included, and hands a nested child its parent's secret-named records. The deploy masks its replacement line and a replaced row's old-id lines, and a nested-stack row's printing bag carries the parent's records, as the deploy found them, into the child's deploy. Masking only. Tests:tests/unit/cli/diff-secret-name-needles-3869.test.ts,tests/unit/deployment/deploy-engine-replacement-line-secret-name-3869.test.ts. Integsecret-derived-immutable-namessteps 5c and 5d move a metric filter off a secret-named log group undercdkd diff --verbose,cdkd deploy --dry-run --verboseand a real deploy, and fail when a log names the group.- An update of a KMS key or alias, an ECR repository, a CloudWatch alarm or a CodeBuild project is refused when the AWS client's region differs from the region recorded in cdkd state (issue #2613) --
src/provisioning/providers/{kms,ecr,cloudwatch-alarm,codebuild}-provider.ts(update()now takes theUpdateContextand callsassertRegionMatchbefore any AWS call),scripts/check-provider-update-context.ts(four names leaveOMITS_UPDATE_CONTEXT), and unit cases in each provider's test file. These providers ignored the recorded region on update, so a deploy, rollback orcdkd drift --revertrun with a client in another region sent its writes to whatever carried the same name there, a resource cdkd does not manage. The update now stops before the first call and names both regions. An update whose state record has no region is unchanged.
Recently Implemented (2026-10-10):
cdkd deployandcdkd destroyof a stack with hundreds of CloudWatch alarms no longer fail onRate exceeded(issues #4774, #4781) --src/provisioning/providers/cloudwatch-alarm-provider.ts(create(),update(),delete()through onesendWithThrottleRetry), a new unit file, and thethrottle-wide-daginteg fixture (now with 300 alarms). The provider sends onePutMetricAlarmorDeleteAlarmsper alarm, and a deploy or destroy runs them in parallel, so a large stack met CloudWatch's request quota. The engine's own per-resource retry ran out: a deploy failed and rolled back on an alarm create, and a destroy ended partially destroyed with alarms left behind. A throttled alarm write is now retried inside the provider with jittered backoff (8 retries, about 45s to 91s in total) before the engine's retry sees it. Any other failure is not retried there, and on deleteResourceNotFoundis still treated as already deleted.- A rollback reverses a delete-first replacement by deleting the new resource first (issue #4690) --
src/deployment/rollback-executor/replay-reverse-replacement.ts,src/deployment/deploy-engine/execute.ts,src/types/rollback-journal.ts,src/deployment/rollback-executor/types.ts, plus tests and thealbinteg's Phase 6. Before this change, when a--recreate-via-cc-api/--recreate-via-sdk-providerrecreate (or the update-unsupported fallback,--replace's delete-first fallback, or a child lost with its re-created parent) succeeded and a later operation in the same deploy failed, the rollback re-created the OLD resource while the NEW one still existed. For a resource only one copy of which can exist (an ELBv2 listener's port), the re-create collided, the rollback refused, and the user had to runcdkd rollback --orphan. The rollback journal now records, on every completed UPDATE, whether the deploy deleted the old resource before creating the new one (oldDeletedBeforeCreate, additive, nojournalVersionbump). When it did, the rollback deletes the new resource first and then re-creates the old one. If that delete fails or is skipped, state keeps pointing at the new resource and the journal is kept. A journal written by an older cdkd, and a new resource kept byUpdateReplacePolicy: Retain, keep the create-first order, as does a resource whose old properties name another resource the same deploy replaced or deleted, which the rollback may not be able to restore under that id; its re-create never deletes the new resource first. - A fix-forward deploy now deletes the DynamoDB table an earlier failed CREATE left behind (issue #4606, DynamoDB table slice) --
src/provisioning/providers/dynamodb-table-provider.ts. When acdkd deploy --no-rollbackmade anAWS::DynamoDB::Table, failed configuring it (a TTL, point-in-time recovery or stream setting) and could not delete it again, and a later deploy kept the logical id under another table name, the successful deploy warned about the earlier table, left it in AWS and exited 2: it could not prove the two were different tables, nor that the table under the old name was still the one the failed CREATE made. The DynamoDB provider now answers both by the table'sTableId, which AWS generates and never reuses: the failed CREATE records the id itsCreateTablereturned, and the successful deploy deletes the earlier table only when the record's table reads back under anotherTableIdand the old name still reads back under the recorded one. A table deleted by hand and re-created under the name has anotherTableIdand is kept, with a warning. The same identity servescdkd rollbackandcdkd destroyfor such a journaled table. Tests:tests/unit/provisioning/dynamodb-table-provider-same-resource-4606.test.ts; integ:dynamodb-fix-forward-orphan. cdkd diffand the deployment events mask a secret-derived name a parent passes to a nested stack (issue #3869) --src/cli/commands/diff-recursive.ts,src/deployment/deploy-engine.ts. Consider a nested stack whose parameter reads a resource named from a secret, such asParameters: { QueueUrl: Ref SecretQueue }.cdkd diff --recursivediscarded the needles of that read, so its walk into the child printed the queue name on the child'sParameter QueueUrl: using user-provided valueline, itsReflines and its changed rows. The row's reads now go into a mask-only bag. That bag is bound around the child's whole walk and masks its rows, a grandchild's included. It never joins the child's inherited secrets, so no export preview moves. A deleted nested stack's stored values are masked too, with the needles of the live stack above it and of every record named from a secret. On deploy, a nested child engine's event text indeployments/*.jsonlwas masked only with the child's own secrets. That text is an AWS error quoting such a value. Every event is now masked in one pass with the resource's own secrets and the printing bags bound where it is recorded. That masker, the log sink and every printed-text mask now mask overlapping needle matches as one span, so neither leaves a fragment; a rollback refusal still masks a needle spanning lines.src/deployment/secret-redaction/log-only.ts,mask-errors.ts,src/deployment/secret-name-needles.ts. ThephysicalIdfield is unchanged. Tests:tests/unit/cli/diff-secret-name-needles-3869.test.ts,tests/unit/deployment/secret-name-needles-3869.test.ts,tests/unit/deployment/deploy-engine-derived-name-needle-3869.test.ts. Integsecret-derived-immutable-namesstep 5b now runscdkd diff --recursiveand FAILs when the child's lines name the queue; it used to print a NOTE.
Recently Implemented (2026-10-09):
AWS::EMR::InstanceFleetConfignow recordsInstanceFleetIdas well asIdin its attributes (issue #4770) --src/provisioning/providers/emr-instance-fleet-config-provider.tsandemr-instance-group-config-provider.ts, plus tests. The CFn schema renamed the read-only attribute fromIdtoInstanceFleetId(#4769). Before this change, a template reading the new name got its value only through the physical-ID fallback, with a warning. It was refused under--strict-getatt, andcdkd orphancould not rewrite it. An aws-cdk-lib regenerated from that schema emits the new name asattrInstanceFleetId.create,updateandgetAttributenow serve both names as the instance fleet id, which is also the physical id. A fleet whose state was written before this change keeps onlyIduntil its next update or replacement. Until then,InstanceFleetIdstill resolves through the fallback. The same holds for anAWS::EMR::InstanceGroupConfigupdate that changes nothing mutable: it now recordsInstanceGroupIdtoo, where it previously kept the old attribute bag.- Messages and help that send the reader to the documentation now link to the matching cdkd.dev section, instead of a repository path or a GitHub blob URL (PR #4768) --
src/cli/commands/{local-start-api,scrub}.ts,src/cli/options.ts,src/utils/role-arn.ts,src/provisioning/{provider-registry,replacement-protection-advice}.ts,src/provisioning/providers/logs-loggroup-provider.ts. Thelocal start-apiVPC andAWS_IAMwarnings and help link tohttps://cdkd.dev/local-start-api/; theAssumeRolefailure to the trust-policy setup oncross-stack-internals; theAWS_PROFILE-with-assumed-role warning tocli-reference/#profile-vs-role-arn; the deletion-protection replacement refusals and the LogGroupClass refusal to their section ofcli-deploy-safety; the sticky Cloud Control warning and the stateful-types help text to that page; and thescrublegacy-key refusals to the version purge oncli-scrub. The distributedcdkd-skillsplugin links to cdkd.dev the same way (0.7.14). Tests:tests/unit/scripts/cdkd-dev-links.test.tsresolves every cdkd.dev link insrc/andplugins/to a page and heading. - A retried S3 bucket create with an explicit
BucketNameno longer refuses the bucket its own first attempt made (issue #4758) --src/provisioning/providers/s3-bucket-provider.ts. When a create made the bucket, then failed on a configuration call that cdkd retries (a permission or throttling error, say), and could not delete the bucket again, the retry met that bucket: S3 answeredBucketAlreadyOwnedByYou(or, in us-east-1, the pre-flight found it), and the explicit-name refusal (issue #4684) failed a deploy the retry would have finished. The failed attempt now records the bucket's identity (name, region and theCreationDatethis account'sListBucketsreports), and the retry of the same create takes the bucket as its own only while it still has that identity; any other holder of the name is refused as before. A generated name is unaffected. Tests:tests/unit/provisioning/providers/s3-bucket-retry-own-bucket-4758.test.ts. - A deploy that stops while a create waits to retry now still records the resource that create made (issue #4757) --
src/deployment/retry.ts. When a resource's create call succeeded at AWS and a later step of the same create failed with an error cdkd retries (a permission or throttling error, say), and another resource then failed and stopped the deploy while this one was waiting to retry, the failed CREATE was recorded without the resource's id, with or without--no-rollback. The automatic rollback,cdkd rollback --revert-failed,cdkd destroyand a fix-forward deploy then had nothing to act on, and the resource stayed in AWS untracked. The stop now keeps the record of what the create made, as a give-up after the last retry already did. The same holds forcdkd rollback's own re-create of a replaced resource: stopped during its retry wait, the rollback now deletes what that re-create made (unless state holds it, or itsDeletionPolicyisRetainorSnapshot) instead of leaving it untracked. Tests:tests/unit/deployment/retry-interrupt-carries-created-mark-4757.test.ts,tests/unit/deployment/deploy-engine-rollback-journal.test.ts. cdkd destroyandcdkd rollbacknow act on an orphan a fix-forward deploy kept, instead of asking for manual attention (issue #4754) --src/deployment/rollback-executor.ts,src/deployment/rollback-executor/plan.ts,src/cli/commands/rollback.ts. When a successful deploy kept a resource an earlier failed CREATE left behind (its delete failed, or it was an S3 bucket that still held objects), a latercdkd destroyorcdkd rollbackskipped that journal entry with a warning that it needed manual attention and exited 2, even after the user had emptied the bucket or deleted it by hand: only the deploy that kept it had proven it was not the resource state now tracks under the same logical id. Both commands now ask the resource's provider that question again. Proven, the resource is deleted while its creation identity still matches (an S3 bucket is still never emptied), and an entry whose resource is already gone is dropped. When the provider cannot answer (no such check, an error, or no answer within the same time limit), the entry is kept with the warning as before. Thecdkd rollbackplan shows the same outcome before you confirm. A deploy's own automatic rollback is unchanged. Tests:tests/unit/deployment/replay-recheck-kept-orphan-4754.test.ts,tests/unit/cli/rollback-preview-recheck-4754.test.ts; integ:s3-fix-forward-orphan.- A deploy that changes a custom resource's
ServiceTokenis now refused, as CloudFormation refuses it, instead of being sent as anUpdateto the new handler (issue #4749) --src/deployment/custom-resource-service-token.ts,src/deployment/deploy-engine/{deploy-flow,update}.ts,src/cli/commands/diff-recursive.ts. The old handler never received aDelete, so whatever it created was orphaned silently. Two different ARNs are refused before anything is provisioned (--dry-runtoo), with no request to either handler and state unchanged;cdkd diffreports it underBlockingand exits3. A token that reads a resource the same deploy replaces or creates (a renamed backing Lambda) is refused once it resolves, before the handler is invoked, and the deploy rolls back;cdkd diffwarns about it. A recorded token cdkd cannot compare (missing,***, a{{resolve:...}}reference) is refused when the template moves it; a token fed by aNoEchoparameter, recorded as***, is still updated as before, since cdkd keeps no value to compare. The message names the remedy: a new logical id, which creates the new resource through the new handler and deletes the old one through its old handler. A--recreate-via-cc-apitarget is not refused. Docs:docs/{cli-deploy,cli-diff,troubleshooting,state-management}.md. Tests:tests/unit/deployment/{custom-resource-service-token,deploy-engine-cr-service-token-4749}.test.ts,tests/unit/cli/diff-recursive.test.ts. - An S3 bucket create no longer adopts a bucket already holding its explicit
BucketNameon the paths the deploy engine's name lookup does not cover (issue #4684) --src/provisioning/providers/s3-bucket-provider.ts. Defense in depth behind the engine's lookup before a plain create (#4180) and a renamed replacement (#3937). A create the lookup does not cover (a replacement keeping its name, a rollback's re-create of a replaced bucket), or a bucket made between the lookup andCreateBucket, tookBucketAlreadyOwnedByYou(or, in us-east-1, the legacy 200 that resets the bucket's ACLs) as success, configured that bucket and recorded it for a later destroy to delete. The provider now refuses an explicit name a bucket already holds, in us-east-1 before the send, asking the account's bucket list when the location lookup cannot answer. The refusal names the bucket, points atcdkd importand applies nothing. It is marked a name collision: a same-name replacement fails as CloudFormation's does (or uses--replace) without reconfiguring the old bucket first, a rollback's reverse replacement refuses a bucket a stranger took, and one whose new bucket holds the name deletes that new bucket and re-creates the old one instead of adopting it. A delete-first re-create still retries it while a deleted name is released. A generated name is still adopted (#4345). The #4639 ambiguous-create refusal now points an explicit name atcdkd import. Tests:tests/unit/provisioning/providers/s3-bucket-explicit-name-held-4684.test.ts,s3-bucket-create-retry-4639.test.ts; integ:s3-lifecyclephase 2d. - A us-east-1 explicit-name create whose location lookup could not answer no longer claims the bucket on the account bucket list's word (#4684 follow-up) --
src/provisioning/providers/s3-bucket-provider.ts.ListBucketscan lag a concurrent same-account create, so a configuration failure now leaves that bucket with the "Not cleaning up" warning instead of deleting or journaling it. - A fix-forward deploy now deletes the S3 bucket an earlier failed CREATE left behind (issue #4606, S3 bucket slice) --
src/provisioning/providers/s3-bucket-provider.ts. When acdkd deploy --no-rollbackmade anAWS::S3::Bucket, failed configuring it and could not clean it up, and a later deploy kept the logical id under another bucket name, the successful deploy warned about the earlier bucket, left it in AWS and exited 2: it could not prove the two were different buckets, nor that the bucket under the old name was still the one the failed CREATE made. The S3 provider now answers both. Two different bucket names are two buckets (names are global and cannot be changed), once the record's bucket reads back in the stack's region. A bucket's identity is its name, region and theCreationDatethis account'sListBucketsreports, recorded when the CREATE fails and compared before the delete: a bucket deleted by hand and re-created under the name reports another date and is kept, with a warning. A date S3 moved after a bucket change (a bucket policy edit) also keeps the bucket. The same identity servescdkd rollbackandcdkd destroyfor such a journaled bucket. Such a bucket is never emptied on any of these paths, even withautoDeleteObjects: one that is not empty is kept with a warning. Tests:tests/unit/provisioning/providers/s3-bucket-identity-4606.test.ts; integ:s3-fix-forward-orphan. cdkd drift --acceptand--revertwrite a NoEcho parameter's position as***in every baseline they save (issue #4043, Phase C) --src/cli/commands/drift.ts,docs/cli-drift.md. Both writers clone the record's existing baseline (observedProperties, orpropertieswhen there is none) and merge into it what was accepted or what the provider reported after a revert. A baseline that still held aNoEchovalue in the clear at a position the record names innoEchoLeaves, or a provider echo of the live value a revert kept there, was written back as it came, and a value the redaction's value arm cannot match (under 4 characters, a number) stayed in the clear. Every such position is now masked in what either writer saves: by its list's identity field in an observed baseline (the whole list where none pairs it), and by index inproperties. An observed baseline is also masked at every***leaf ofproperties, as the deploy's capture is. Every writer that masks an observed baseline at aNoEchoposition (deploy,cdkd state refresh-observed, the rollback replay, drift) now masks the WHOLE node where the readback holds a non-object (a JSON string, a list) at a key the position runs through, where it used to leave it in the clear (src/deployment/secret-redaction/noecho-leaves.ts). What--acceptreports as recorded is unchanged. Tests:tests/unit/cli/drift-noecho-v11.test.ts.cdkd importandcdkd scrubstore a NoEcho parameter's position as***, andcdkd exportexports a record masked only there (issue #4043, Phase C) --src/cli/commands/{import,scrub,export}.ts,docs/cli-scrub.md,docs/cli-export.md.cdkd importnow writes a property its template fills from aNoEchotemplate parameter as***whatever the value's type or length, names it innoEchoLeaves, masks the observed baseline there, and masks and declares a same-named attribute echoing the value, as a v11 deploy does. Before, a value under 4 characters or a number stayed in the clear.cdkd scrubapplies the same positional arm without a deploy. Where a record still holds a plaintext at such a position (a pre-v11 stack, or one deployed under an olderDefault), that value also masks its copies elsewhere in the record. A stack with no other secret is scrubbed for it, and--dry-run --failcounts it. AnExport.Namethe deploy refuses for holding or reading aNoEchovalue no longer makes scrub keep every unnamed output key as a possible alias.cdkd exportno longer blocks a resource whose only masks sit at those positions: the exported template reads the parameter, and CloudFormation receives its value. It still blocks one whose import identifier embeds the mask, or whoseAWS::IAM::Policypre-delete principals or name aNoEchoparameter fills, and refuses a nested child parameter that resolves to the mask. Scrub also masks an output aNoEchoparameter serves, with its alias and exports index entry. Tests:tests/unit/cli/import-noecho-positional-4043.test.ts,tests/unit/cli/commands/scrub-noecho-positional-4043.test.ts,tests/unit/cli/export-noecho-v11.test.ts.- A rollback reverts a resource whose recorded properties hold a NoEcho parameter's value only as
***(issue #4043, Phase C) --src/deployment/rollback-executor.ts,src/deployment/rollback-executor/{replay-noecho,replay-revert,replay-reverse-replacement,replay-props}.ts,src/deployment/nested-child-journal.ts,src/deployment/deploy-engine/{rollback,outputs}.ts,docs/cli-rollback.md. Since state schema v11 a property fed by aNoEchotemplate parameter (or aNoEchoattribute read throughFn::GetAtt) is recorded as***at the coordinatesnoEchoLeavesnames, and every rollback revert of such a resource, automatic orcdkd rollback(with or without--revert-failed), failed withROLLBACK_REDACTED_BASELINE. The revert now reads the resource back from AWS, handing the provider the record masked at those coordinates, and sends the value AWS holds there, so aNoEchovalue the failed deploy changed is not reverted (the next deploy restores it). The value becomes a mask-only and a log-only needle of the operation, and the restored record keeps***by position and itsnoEchoLeaves, so it never reaches state, the events or the log in the clear (inside longer text, from 4 characters). A value that cannot be read back (write-only, absent, itself***, no readback, a failed read) and the re-create of a reversed replacement refuse with aNoEcho-specific remedy. A nested stack row's maskedParametersentry no longer refuses its revert. A nested child's journal no longer snapshots a previous export alias that today's export-name verdict refuses, so a revert of the child cannot republish an alias an older cdkd wrote. Tests:tests/unit/deployment/rollback-executor-noecho-readback.test.ts,deploy-engine-noecho-v11.test.ts,nested-child-journal.test.ts; integ arm intests/integration/noecho-parameter-masking/.
Recently Implemented (2026-10-08):
The first deploy after upgrading to state schema v11 no longer prints a NoEcho parameter's previous value at a position the template still feeds from one (issue #4741) --
src/deployment/deploy-engine/update.ts. A record an older cdkd wrote still holds the plaintext it last sent at eachNoEchoposition. Where that value differs from the one this deploy binds, it is handed to the provider as the previous properties (and to a replacement's delete of the old resource), and nothing masked it: a provider that logs the previous value while reconciling (the Budgets provider'sDeleted subscriberline under--verbose) printed it, as did an error quoting it. The value is now recorded for the duration of the deploy in the resource's print-only bag, the one bound around every line logged for the resource, so its log lines, errors and events print***in its place. Nothing that is persisted or decided reads that bag. Where a whole list was compared (its length changed), only the leaves the template no longer holds are recorded. Not covered, and still printed: a previous value at a position this deploy no longer reads from aNoEchoparameter, or in a property the template removed, or in a removed list entry when the list no longer holds anyNoEchoposition (a pre-v11 record does not say which of its positions aNoEchoparameter fed); and a previous value of exactlytrue/falseinside a list whose length changed, since recording it would mask every line containing that word. As with everyNoEchovalue, a whole printed text equal to it is masked at any length, and an occurrence inside a longer text only from 4 characters. Tests:tests/unit/deployment/deploy-engine-noecho-witness-log-4741.test.ts.Changing an IAM role's
Pathis a replacement through the engine's guards, not a deploy failing withEntityAlreadyExists(issue #4739) --src/analyzer/replacement-rules.ts,src/provisioning/providers/iam-role-provider.ts.Pathis create-only, but the replacement registry listed it as updatable, so the deploy sent apathedit on aniam.Roleto the provider's in-place update, whose re-create asked for the name the live role still holds (IAM role names are unique whatever the path) and failed withEntityAlreadyExists, with no guidance. APathchange now replaces the role: the create-first attempt's name collision is refused with the replacement-collision guidance (the name's origin, andcdkd deploy --replace), and under--replacethe role is deleted and re-created on the new path, and resources reading its ARN are updated to the new one./and an absentPathare the same path and replace nothing. The provider'supdate()refuses aPath-only change as not updatable in place instead of attempting the colliding create. APathtaken from a secret reference the template did not change keeps the role in place (a rotated secret is not applied, with a warning, as CloudFormation keeps the live create-only value), and aPathfed by a changedNoEchoparameter is planned as a replacement (src/analyzer/diff-calculator.ts). Tests:tests/unit/deployment/deploy-engine-iam-role-path-4739.test.ts,tests/unit/analyzer/replacement-rules-iam-role-path.test.ts,tests/unit/provisioning/iam-role-provider.test.ts.A deploy no longer warns that a resource "is replaced" when a changed NoEcho value on a schema create-only property is updated in place (issue #4737) --
src/deployment/deploy-engine/update.ts. Where a record held a plaintext at a position aNoEchoparameter now feeds (a pre-v11 record, or a literal turned into a{Ref}to the parameter) and the value differs, the deploy printed<id>.<prop> is a create-only property, and a NoEcho parameter's value changed since the last deploy: <id> is replaced.for every property the CloudFormation schema lists as create-only. For one cdkd updates in place (anAWS::Budgets::Budget'sNotificationsWithSubscribers, which its provider reconciles), the deploy sent an in-place update and printed that line anyway. The line is now printed only when the change replaces the resource. Tests:tests/unit/deployment/deploy-engine-noecho-v11.test.ts.cdkd diffno longer prints a rotatedNoEchoparameter's previous value inside the name of a stale export alias's REMOVE row (src/analyzer/outputs-diff.ts,src/cli/commands/diff-recursive.ts, go-to-k/cdkd#4723). An older binary that publishedExport.Name: {Fn::Sub: 'x-${Short}-y'}while theNoEchoparameterShortheldableft the aliasx-ab-yin state; after the value rotated, the diff withheld only a stored name equal to today's refused name (x-cd-y) and printedx-ab-yin the human rows and in--json. While any declared output (a condition-skipped one included) has an intrinsicExport.Namethat can read aNoEchoparameter or custom-resource attribute under anyFn::Ifverdict (a nested child's parent rows included), the name of every stored alias today's template cannot account for is now withheld: a key the record'sexportNameslists, or any such key when the record lists none. The row is still reported. The rule is stack-wide because state records no alias's owner; a stack where no declared name reads aNoEchoparameter prints its stale aliases as before. This also withholds the stored alias of a condition-false exporter, or of anFn::Ifbranch today's verdict drops, which printed even when it spelled today's short value. Tests:tests/unit/cli/diff-rotated-noecho-alias-4723.test.ts; integnoecho-parameter-masking(Phase 1c plants the legacy alias).--recreate-via-cc-apiof an imported Application Auto Scaling scalable target is now refused instead of silently losing its scaling policies, andcdkd importrecords a resource it read through Cloud Control asprovisionedBy: cc-api(src/cli/commands/import.ts,src/deployment/recreate-targets/validate.ts,src/deployment/child-of-recreated-parent.ts, go-to-k/cdkd#4706). Import recorded every resource assdk, including a type with no SDK provider, which a deploy records ascc-api. For anAWS::ApplicationAutoScaling::ScalableTargetthat let--recreate-via-cc-apipast its already-on-Cloud-Control refusal: the recreate deregistered the target, which deletes its scaling policies, and re-registered it under the sameResourceId|ScalableDimension|ServiceNamespaceid, so eachAWS::ApplicationAutoScaling::ScalingPolicyresolved exactly as recorded, was skipped as unchanged, and state kept policies AWS no longer had under a green deploy. Import now records the layer its routing chose; the pre-flight refuses--recreate-via-cc-apiof a type with no SDK provider whatever its record says, so a record an earlier import wrote is covered too; and a scaling policy whose target is re-created under the same id is re-created with it, like a Lambda permission with its function (go-to-k/cdkd#4411). Tests:tests/unit/cli/import.test.ts,tests/unit/deployment/recreate-targets.test.ts,tests/unit/deployment/child-of-recreated-parent.test.ts; integapp-autoscaling-recreate.One stack name deployed under two state backends no longer takes over, and then deletes, the other deployment's resources (issue #4705) --
src/deployment/{generated-name-guard,kept-in-history,retained-readoption}.ts,src/state/{stack-registry,s3-state-backend,cross-prefix-stack-scan,lock-manager,create-token-ledger}.ts,src/provisioning/{name-lookup,provider-registry}.ts,src/deployment/deployment-approval.ts, the name-adopting SDK providers,src/cli/commands/{deploy,cross-prefix-gate,destroy-runner,destroy,state,rollback,import,force-unlock,export,registry-release}.ts. A second deployment of the same stack under another--state-prefix,--state-bucketor account's bucket was handed the first one's SQS queue and log group (generated names derive from the stack name), and its rollback deleted the queue. Now a create of a type whose API hands back or overwrites an existing name (queue, topic, log group, alarm, rule, S3 bucket, ECS cluster, load balancer, target group, state machine) reads its generated name exactly first and refuses (GENERATED_NAME_HELD) unless this stack's state, journal, intent ledger,retained.json(what it kept under this prefix) or own history names the holder; about one round trip, none on a redeploy. In one bucket, a marker_cdkd-registry/<region>/<stack>.jsonnames the stack's prefix: a first deploy claims it, and destroy, state destroy, rollback, a destructive plan and the automatic rollback's deletes refuse a stack another prefix holds. Removing a record (destroy, orphan, export, rollback, a failed first deploy) releases it. A record or stale marker from before pays one prefix listing, then claims. A 403 warns and continues. Residual: a cross-bucket pair formed before this version keeps both records. Docs:docs/state-store.md. Tests:tests/unit/**/*4705*.test.ts; integcross-backend-same-stack,cross-backend-same-stack-migration,perf-cross-backend-check.cdkd rollback --remove-protectionnow reaches a deletion-protected resource the failed deploy created inside an existing nested stack (issue #4703) --src/deployment/nested-child-journal.ts,src/cli/commands/rollback.ts. A nested stack's row is reverted by replaying that child's own journal, and that replay never received the flag: a protected resource whose CREATE completed in the child before a later parent resource failed (a load balancer withdeletion_protection.enabled, an EC2 instance withDisableApiTermination) was deleted without it, AWS refused, andcdkd rollback --remove-protectionexited non-zero until the protection was cleared by hand. The flag now travels into the child's replay, where a completed CREATE is the child's own state record, deleted as the stack's is; a foreign-holder scan scoped to the child travels with it. A deploy's automatic rollback still never turns protection off. Test:tests/unit/deployment/nested-child-remove-protection-4703.test.ts; integremove-protection-journaled-orphan.A resource that holds a replaced resource through a create-only property the CloudFormation schema also lists write-only is now replaced with it, instead of updated in place (
src/analyzer/diff-calculator.ts,src/deployment/recreate-target-readers.ts, go-to-k/cdkd#4701). The most common case isAWS::ApplicationAutoScaling::ScalingPolicy.ScalingTargetId: a change that replaces the scalable target (a renamed DynamoDB table, a movedResourceId) planned each policy as an in-place update. AWS deletes a target's policies when it deregisters it, so the policy was updated after it was gone, or left pointing at the deleted target. cdkd did not replace on a write-only create-only property, a rule meant for a freshNoEchosecret AWS never returns (go-to-k/cdkd#3803). That rule now applies only when the property is not exactly aReforFn::GetAttof the replaced resource: a plain reference resolves to the resource's new id or attribute, which cdkd compares with the state record, not with an AWS read. AFn::GetAttof a custom resource or nested stack, and any value built from a reference (a{{resolve:...}}dynamic reference, anFn::If), keep the old rule. The--recreate-via-*pre-flight lists such a reader under "Replaced if the id changes". Other types this reaches include ElastiCacheReplicationGroup, VPC Lattice listeners and rules, DataSync locations andServiceDiscovery::PrivateDnsNamespace.Vpc. Tests:tests/unit/analyzer/diff-calculator-synthetic-create-only.test.ts,tests/unit/deployment/recreate-target-readers.test.ts; integdynamodb-autoscalingPhase 3 (a table rename replacing both scalable targets and their policies).A successful deploy and
cdkd rollbackno longer delete a journaled failed-CREATE resource that a state record holds under its identifier spelled in another case (issue #4692) --src/deployment/replacement-name-holder/name-keys.ts,src/deployment/rollback-executor/{journaled-orphans,plan,replay-reverse-replacement}.ts. RDS, DocumentDB, Neptune and ElastiCache identifiers are case-insensitive, and a provider records the identifier as the template spells it. Every check that keeps a journaled orphan because a record holds it compared physical ids exactly, so acdkd importadopting the cluster journaled asMyClusterundermycluster(in the same stack under another logical id, or in another stack under the same state prefix) left it unheld: the settle proved the identifier still named the journaled cluster and deleted it. Those checks (this stack's records, other stacks' records and rollback-orphan records, the supersede pass, the rollback classifier, a failed rollback re-create's cleanup, and the--remove-protectionownership proof) now compare under the type's case rule (physicalIdKey): case-folded for the types whose names already fold for replacement-name decisions, exact for every other type. Tests:tests/unit/deployment/journaled-orphans-case-fold-4692.test.ts,tests/unit/deployment/replacement-create-orphan.test.ts; integrds-fix-forward-orphan.Replacing an ALB listener that has rules now replaces each
AWS::ElasticLoadBalancingV2::ListenerRuleonto the new listener, instead of failing the deploy withNotFound(src/analyzer/replacement-rules.ts, go-to-k/cdkd#4689). A--recreate-via-cc-api/--recreate-via-sdk-providerof the listener, or a change that replaces it (itsLoadBalancerArn, or a replaced load balancer), planned each rule reading it as an in-place update: the rule'sListenerArnis create-only, but the CloudFormation schema also lists it write-only, and cdkd does not replace on a write-only create-only property it cannot read back (go-to-k/cdkd#3803). AWS deletes a listener's rules with it, so that update failedOne or more rules not foundafter the listener had already been replaced, and the--recreate-via-*pre-flight did not list the rule. The replacement rules now nameListenerArnfor this type, so the rule is re-created on the new listener, and the pre-flight lists it under "Replaced if the id changes". Tests:tests/unit/analyzer/diff-calculator-synthetic-create-only.test.ts,tests/unit/deployment/recreate-target-readers.test.ts; integalbPhase 5 (a--recreate-via-cc-apiof the listener with its rule).A token-less create the AWS SDK replayed inside one call after a reset connection is now checked for the duplicate its first request may have made (issue #4687) --
src/provisioning/providers/ambiguous-create.ts(replayedInSend/replayFollowedAmbiguousAttempt/replayedSendWindow/ambiguousAttemptLead),orphan-report.ts,iam-access-key-provider.ts, and theAmbiguousCreateLatchproviders (kms,cognito,appsync,apigateway,apigatewayv2,emr-*,lambda-layer,lambda-eventsource,dlm-lifecycle-policy,ecs,ec2), their unit tests anddocs/troubleshooting.md. Before, a socket reset or timeout after the request reached AWS was replayed by the SDK; when that replay SUCCEEDED, a create that is not name-unique had made a second resource -- forAWS::IAM::AccessKeya live credential no state records -- and nothing reported it. After a create the SDK retried inside its call, cdkd now checks right away: forCreateAccessKey, only when the retry followed a reset or timeout, the existing orphan reconcile deletes a key absent from the pre-create baseline, dated after the attempt started and not recorded by this process (never the key just returned; a failed delete warns with theaws iam delete-access-keycommand); for every latched create, its existing report-only lookup names candidates from the first request to the reply, never the returned resource, never adopting or deleting. A single-attempt create makes no extra call. A throttle the SDK retried also runs the report-only lookup: it finds nothing for a type with a creation date, and for an undated type it can name an unrelated resource of the same name, which the warning says may be another stack's.A custom resource that reads a
NoEchoparameter is deleted again bycdkd destroy, with the real value in itsDeleterequest (src/deployment/noecho-delete-reresolution.ts,src/provisioning/providers/custom-resource-provider.ts,src/cli/commands/destroy.ts,src/cli/commands/destroy-runner.ts,src/provisioning/providers/nested-stack-provider.ts,src/deployment/deploy-engine/{update-replace,update-in-place,replacement}.ts, go-to-k/cdkd#4682). Since schema v11 the record holds***there, so the delete was skipped (record kept, exit 2).cdkd destroywith the app now re-resolves each such position from today's template and parameters (a nested child's from its parent's row) and sends that value; the record keeps***, and the warnings, errors and handler log lines cdkd prints are masked as on a create. The delete stays skipped forcdkd state destroy, a deploy that removed the resource, a rollback, a position an attribute declaredNoEchofills, and a template that no longer reads aNoEchoparameter there, changed that property's text or resolved inputs, changed the type, carries aTransformor was synthesized for another region. Behavior change: a custom resource whose recorded properties hold***at a position the record'snoEchoLeavesdoes not name (one embedded throughFn::Join, a record an earlier cdkd wrote, onecdkd import/cdkd scrubwrote) used to receive***in itsDelete; that delete is now skipped too, keeping the record (for an import / scrub record, acdkd deployfirst records the positions). Tests:tests/unit/deployment/noecho-delete-reresolution.test.ts,tests/unit/cli/destroy-runner-noecho-reresolve-4682.test.ts; integschema-v10-to-v11-migration.An ALB listener recorded
provisionedBy: 'cc-api'now returns to the SDK provider on its next deploy that changes it, so aListenerAttributeskey removed from the template is reset instead of keeping its old value (src/provisioning/provider-registry.ts, go-to-k/cdkd#4679). A listener first deployed while tagged, before go-to-k/cdkd#4673 declared itsTagshandled, was created through Cloud Control, and the sticky routing rule kept that record there. Cloud Control's update sends noModifyListenerAttributesfor a removed key, so for examplerouting.http.response.server.enabledstayedfalseafter it was dropped.AWS::ElasticLoadBalancingV2::Listeneris now ansdk-coveragesticky exemption: the record moves to the SDK provider in place, with its listener ARN unchanged, and that deploy resets the removed key.cdkd diffmarks the listener[returning to SDK provider], and--pin-cc-api <LogicalId>declines the move for one deploy. A key already removed under Cloud Control by an earlier cdkd is not in the record any more, so nothing resets it: set it to its default value in the template and deploy. Tests:tests/unit/provisioning/elbv2-listener-sticky-cc-4679.test.ts; integalbPhase 2.5 (a listener record seeded tocc-apias for go-to-k/cdkd#4117, Cloud Control identifier parity, in-place return and attribute reset).cdkd rollback --remove-protectionnow also deletes a deletion-protected resource whose CREATE the failed deploy completed (issue #4678) --src/deployment/rollback-executor/replay-orphan-delete.ts,src/cli/commands/rollback.ts. The flag reached only a failed CREATE's resource; a protected resource the deploy had created before a later resource failed (an EC2 instance withDisableApiTermination, a load balancer withdeletion_protection.enabled) was deleted without it, AWS refused, and everycdkd rollbackexited non-zero until the protection was cleared by hand. The rollback's delete of a completed CREATE now carries the flag, as one final attempt so a failed delete puts the protection back. The resource is this stack's own state record, matched to the id the journal recorded, the same trustcdkd destroy --remove-protectiongives it. A resource reverted inside an existing nested stack, through that child's own journal, still keeps its protection, and a deploy's automatic rollback still never turns protection off. Test:tests/unit/deployment/completed-create-remove-protection-4678.test.ts; integremove-protection-journaled-orphan.--remove-protectionnow clears a deletion-protected resource that only the rollback journal records, oncdkd destroy/cdkd state destroyand, new, oncdkd rollback(issue #4678) --src/cli/commands/{destroy-runner,rollback}.ts,src/deployment/rollback-executor.ts,src/deployment/rollback-executor/types.ts. A failed CREATE whose resource carried deletion or termination protection (an EC2 instance withDisableApiTermination, a load balancer withdeletion_protection.enabled) left it journaled; destroy dropped the flag on the way to the journal's delete andcdkd rollbackhad none, so AWS refused the delete and every run exited non-zero until the user cleared the protection by hand. The flag now reaches that delete (RollbackExecutorContext.removeProtection, plus--revert-failed's delete of a state-recorded failed CREATE), as one final attempt so a failed delete puts the protection back. A journaled orphan gets it only when proven to be the resource its CREATE made (a type whose id AWS never reuses, or a live identity equal to the journaled one) and no other stack's state record holds it now; otherwise the protection stays, with a warning when the attempt had turned it on. The destroy prompt counts such a resource. A deploy's automatic rollback and a successful deploy's settle still never turn protection off. Tests:tests/unit/deployment/journaled-orphan-remove-protection-4678.test.ts,tests/unit/cli/{destroy-runner-journaled-orphan,rollback}-remove-protection-4678.test.ts; integremove-protection-journaled-orphan.cdkd rollbackandcdkd destroyno longer delete a journaled failed-CREATE orphan unless it is proven to be the resource the failed deploy made and no other stack's state record holds it (src/deployment/rollback-executor.ts,src/deployment/rollback-executor/{orphan-identity,journaled-orphans}.ts,src/cli/commands/{rollback,destroy-runner}.ts, go-to-k/cdkd#4658, go-to-k/cdkd#4696). The replay deleted such an orphan by physical id alone, so it deleted a resource that a latercdkd importhad adopted into another stack, and a resource that reused the name of an orphan deleted by hand. It now runs the success settle's checks, in its order and through its helpers (askForeignHolder,createdResourceStillThere).- Name-keyed types: a name-keyed type's live
resourceIdentitymust equal the one the CREATE journaled. Only a Kinesis stream and an RDS, DocumentDB or Neptune cluster or instance journal one today. Every other name-keyed type (an S3 bucket, a Lambda function, an IAM role, a DynamoDB table, an SQS queue, ...) has none, so its journaled orphan is now kept instead of deleted. Types whose id AWS generates and never reuses are unaffected. - Held, disproven, or no identity: the orphan is a warned skip that names its physical id and says to delete it by hand if unused.
cdkd rollbackandcdkd destroyboth exit 2. - Unreadable: if a record or the live identity cannot be read, the entry stays in the journal as a failed operation for a re-run.
--drop-failedremoves only the record. - Gone: an orphan AWS reports gone is settled with no delete.
- Automatic rollback: it runs neither check. It acts inside the failed deploy, over that attempt's own operations.
--remove-protection: strips protection only from an orphan these checks cleared.- Tests:
tests/unit/deployment/journaled-orphan-replay-guards-4658-4696.test.tsand the rollback and destroy suites; integrollback-commandsteps O10 and O11.
- Name-keyed types: a name-keyed type's live
An
Export.Namethat reads aNoEcho: trueparameter is now refused whatever the value's length, and a no-change deploy re-checks an export alias it carries forward (src/deployment/deploy-engine/outputs.ts,src/analyzer/outputs-diff.ts,src/deployment/no-change-outputs-merge.ts,src/deployment/outputs-export-alias/secret-scan.ts, go-to-k/cdkd#4657). AnExport.Nameintrinsic that reads aNoEchoparameter throughRef, anFn::Subvariable, or anFn::Join/Fn::Select/ other intrinsic over one is refused from the template. Before, such a name was refused only when the value was 4 or more characters long, so a name embedding a 1-3 character value was published tostate.json,exportNamesand the exports index. AnFn::Ifreads only the branch its condition selects. The warning names the output and the parameter, never the name. A nested child also refuses a name reading a parameter its parent fills from aNoEchosource. When a no-change deploy carries a failed output's literal export alias forward from the previous record, it now runs the export-name check on that alias again. It drops an alias the check refuses, with the same warning, so a consumer'sFn::ImportValueof it stops resolving.cdkd diffpreviews both, and shows an earlier alias under a now-refused name as a removal with its name withheld. Tests:tests/unit/deployment/export-name-noecho-refusal-4043.test.ts,tests/unit/deployment/no-change-carried-alias-noecho-4657.test.ts,tests/unit/deployment/no-change-outputs-merge.test.ts,tests/unit/cli/diff-export-name-noecho-positional-4657.test.ts.A rotated
NoEchoparameter value feeding a create-only property replaces the resource again, where AWS is proven to report that property exactly (issue #4656) --src/deployment/deploy-engine/{noecho,update,create,update-replace,update-in-place}.ts,src/types/state.ts,docs/state-management.md. Since schema v11 (#4043) such a property was never replaced on a readback that differed: it warned on every deploy and named--recreate-via-*, because a provider that normalizes what it reports would readdifferson an unchanged value. A record now carriesnoEchoExactEchoLeaves(optional, no schema bump): the coordinates at which a readback, handed the record with***there, reported exactly the string cdkd sent. It is set at the deploy that creates or replaces the resource, at the migration deploy of a pre-v11 record (against a copy with the stored plaintext pre-masked), and by any later readback that holds the value; a differing readback never changes it, and a failed or unreadable one leaves it absent. Only a whole string leaf under a create-only property, reached by object keys, is eligible. Where the flag is set and the readback reports a different string, the deploy replaces the resource through the existing create-first path and its stateful and named-collision guards; without the flag the warning now says the provider is not known to report the property exactly. Under--require-approval=destructive/any-change, whose up-front prompt saw no replacement, the deploy asks again before it (--yesapproves); a "no" keeps the resource and warns. Tests:tests/unit/deployment/deploy-engine-noecho-v11.test.ts,noecho-v11-nested-child.test.ts,tests/unit/cli/import-noecho-v11-field-carry.test.ts; integschema-v10-to-v11-migration.cdkd export's andcdkd drift's pasteablecdkd statecommands now carry the run's--profile, resolved--state-bucketand non-default--state-prefix(issue #4648). Changed files:src/cli/commands/export.ts,src/cli/commands/drift.tsandsrc/cli/commands/state.ts. Before this change these commands were printed without the flags, so on a non-default bucket or profile a pasted one acted on the default profile's bucket:cdkd export'scdkd state refresh-observedremedy, which WRITES the record, and itscdkd state showinspect line;cdkd export'scdkd state orphandrops: the failed pre-delete / phase 2, the per-stack recovery list, the tree'sRecover with:template, and a missing or out-of-sync nested child (also reached bycdkd state show --show-nested);cdkd drift --revert'sPopulate with: cdkd state refresh-observedline.
The identity check that decides whether a command is offered is unchanged. Each account value goes through the same gate as #3909's: a refused one prints as a quoted hole such as
'<profile>', with a sentence naming the flag and the reason, and the value is never repeated. When a drop names no record, itscdkd state list --jsonpointer says to run it with the same flags. This completes #4648.cdkd diff --recursive --failno longer reports a change on an unchanged nested stack whose parent passes a parameter embedding{{resolve:...}}tokens (e.g.postgres://{{resolve:...user}}:{{resolve:...password}}@host) beside a sibling parameter that spells the same secret differently and resolves later (issue #4644). After upgrading, the first deploy of such a stack shows a change on the nested-stack row'sParameters.<Name>and on the child resources reading it, because their state now records the parameter's own{{resolve:...}}spelling instead of the sibling's; the deployed VALUE is unchanged, and a create-only property (e.g. an ECS TaskDefinition environment variable) is replaced once with an identical value. For a one-token literal only those child resources change (its row already kept its own spelling), and only where such a sibling exists. Before, state held the map's survivor per plaintext whilecdkd diff --recursiverendered the parameter's literal. A literal now persists its own spelling when rendering it with the plaintexts this pass resolved each token to reproduces the resolved value exactly and its literal text holds no secret; the child's diff side, its{Ref}leaves, itsFn::Join/Fn::Subleaves and the inherited-secret carry (soFn::Select,Fn::Split, arrays andFn::Iftoo) read it. AnFn::Join/Fn::Subparent source, a literal holding a publicssmtoken, and a parameter that one child resource reads beside such a sibling, either of the two through an intrinsic other thanFn::Join/Fn::Sub(Fn::Select, ...), or that the child'sOutputsread beside it, keep the old behaviour (#4731). Source:src/deployment/secret-redaction/{positions,nested-stack,placeholder-positions}.ts,src/deployment/intrinsic-resolver/parameter-secrets.ts,src/deployment/deploy-engine/deploy-flow.ts,src/cli/commands/scrub.ts.An "already exists" that the AWS SDK's own retry hits after a dropped connection is no longer credited to another holder (issue #4639, socket-reset residual) --
src/provisioning/providers/ambiguous-create.ts,docs/troubleshooting.md. Every token-less create sent through awithoutServerErrorRetriesclient (the IAM, Lambda, EventBridge bus, ECR, data / streaming, Kinesis stream, CodeCommit, KMS, Cognito, AppSync and other sites) already refused the SDK's in-call replay of a 5xx. A socket reset, broken pipe or timeout after the request was sent was still replayed inside the same call, so when AWS had completed the first request, the replay's "already exists" reached the deploy engine as an ordinary name collision with someone else's resource. Every attempt that follows such an ambiguous one in the same call is now marked as a possible replay -- the same mark the engine sets after an ambiguous attempt of its own -- so the collision is reported as possibly this create's own, and a create that looks for an orphan after an ambiguous attempt (KMS, Cognito, AppSync, API Gateway, EMR, EC2, ECS, DLM, Lambda layers and event source mappings) now looks after such a call too. The SDK still retries the reset itself, so a flaky network does not fail the deploy; a refused connection, an unresolved host and a throttle are not marked, since the request never reached the service or the service declared it did nothing. Residual: a replay that SUCCEEDS after a reset is still a second, unreported resource for a create that is not name-unique (KMS key, Cognito user pool, AppSync API, IAM access key; #4687). The replayed-collision advice now names a dropped connection as a cause and says to confirm the holder is yours first, since a reset can precede the request. Tests:tests/unit/provisioning/ambiguous-create.test.ts,deploy-engine-orphaned-name-collision.test.ts.An S3 bucket that exists after a server error on its own
CreateBucketis refused, not adopted (issue #4639, S3 bucket residual) --src/provisioning/providers/s3-bucket-provider.ts.CreateBucketcarries no idempotency token. When a 5xx hid a create S3 had completed, the replay answeredBucketAlreadyOwnedByYou(or 200 in us-east-1) and the provider adopted the bucket as one that was already there: no created-before-failure mark, so a later configuration failure in the same create left a bucket this run made in no state record and out ofcdkd rollback --revert-failed's reach.CreateBucketnow goes through a dedicated client that refuses the SDK's 5xx retry (same region and identity as the provider's other calls), and when an earlier attempt of the SAME create ended ambiguous and the bucket now exists in this region, the create fails with a non-retryable refusal naming the bucket. It leads withaws s3api list-buckets --prefix(only this account's buckets;CreationDateis a hint) andlist-object-versions, then adelete-bucketcommand only for an empty bucket of this account that nobody else uses, or a re-run to adopt a bucket you mean the stack to own. In us-east-1 the refusal comes from the pre-flight, before any re-create (also when the pre-flight could not read the bucket, where a re-create could make an unattributed bucket). An attempt interrupted by a non-ambiguous retryable failure (anOperationAborted, aSlowDown, a throttled location read) keeps the earlier attempt's window for the next one. Nothing is configured or deleted. A bucket that existed before any ambiguous attempt is still adopted as before (tracked in #4684). Tests:tests/unit/provisioning/s3-bucket-create-retry-4639.test.ts.A successful fix-forward deploy now deletes the DocumentDB DB cluster or DB instance an earlier failed CREATE left under the same logical id, once live reads prove it is not the new one and is still the one that failed CREATE made (issue #4606) --
src/provisioning/providers/docdb-provider.ts. Aftercdkd deploy --no-rollbackfailed with anAWS::DocDB::DBClusterorAWS::DocDB::DBInstancealready created, and the fixed template created a new one under the same logical id with another identifier, the successful deploy warned, named the earlier resource, exited2and left it in AWS.DocDBProvidernow implementsisSameResourceandresourceIdentity, both resting on the immutableDbClusterResourceId/DbiResourceId; identifiers equal apart from case are the same resource. The failed CREATE journals the resource id its create call returned, carried on the failure itself. A resource someone re-created under the identifier, in any case spelling, reads back under another resource id and is kept with a warning, and one of another engine (DocumentDB shares the identifier namespace and the describe calls with RDS and Neptune) is never read as a DocumentDB resource: it too is kept with a warning. Only the not-found fault name (or an empty describe list) counts as gone. Deleting an earlier resource that is already gone prints one info line. Tests:tests/unit/provisioning/docdb-provider-same-resource-4606.test.ts; new integdocdb-fix-forward-orphan.A successful fix-forward deploy now deletes the VPC, subnet or security group an earlier failed CREATE left under the same logical id (issue #4606) --
src/provisioning/providers/ec2-provider.ts. When anAWS::EC2::VPC,AWS::EC2::SubnetorAWS::EC2::SecurityGroupCREATE failed after AWS made the resource and its cleanup delete failed too, the resource was journaled as a proven orphan. If a later deploy created a new one under the same logical id, that successful deploy warned, named the earlier one, exited2and left it in place.EC2Providernow answersisSameResourcefor these types, using the AWS-generatedvpc-,subnet-orsg-id. The client must be in the stack's region, and the record's resource must read back live (a VPC or subnetpendingoravailable; a security group whenever EC2 lists it). The earlier resource is thendifferent, whether it still exists or is not found, so the settle deletes it. Equal ids aresame. Any other id form and a failed read stayunknown, which keeps the previous warning and exit2. An earlier resource EC2 no longer lists (its ownInvalidVpcID.NotFound,InvalidSubnetID.NotFoundorInvalidGroup.NotFound, never a message that merely reads like one) is named in one info line; any other delete failure keeps the journal entry for the next deploy. Tests:tests/unit/provisioning/ec2-network-same-resource-4606.test.ts. Integec2-instancePhase 1c runs the read and the settle's delete against live resources, a throwaway VPC / subnet / security group and ids that never existed.A successful fix-forward deploy now deletes the Neptune DB cluster or DB instance an earlier failed CREATE left under the same logical id, once live reads prove it is not the new one and is still the one that failed CREATE made (issue #4606) --
src/provisioning/providers/neptune-provider.ts. Aftercdkd deploy --no-rollbackfailed with anAWS::Neptune::DBClusterorAWS::Neptune::DBInstancealready created, and the fixed template created a new one under the same logical id with another identifier, the successful deploy warned, named the earlier resource, exited2and left it in AWS.NeptuneProvidernow implementsisSameResourceandresourceIdentity, asRDSProviderdoes: both rest on the immutableDbClusterResourceId/DbiResourceId, identifiers equal apart from case are the same resource, and the failed CREATE journals the resource id its create call returned. Neptune shares its identifier namespace with RDS and DocumentDB, and its describe also answers for their resources: a resource of another engine under the identifier is never compared, so it is kept with a warning. Only the not-found fault name counts as gone. Deleting an earlier resource that is already gone prints one info line. Tests:tests/unit/provisioning/neptune-provider-same-resource-4606.test.ts; new integneptune-fix-forward-orphan.cdkd deploymasks a secret-derived name a parent passes to a nested stack (issue #3869) --src/deployment/deploy-engine/resolver-context.ts,src/deployment/deploy-engine/create.ts,src/deployment/deploy-engine/update.ts. Consider a nested stack whose parameter reads a resource named from a secret, such asParameters: { QueueArn: GetAtt SecretQueue.Arn }where the queue's name comes from Secrets Manager. That read's needles went into a print-only bag that nothing used after the row's own resolver line. The child stack inherits only the row's own secrets, which deliberately hold no read needles, since they would seed the child's export-name verdict. So the child'sResolved Ref to parameter: QueueArnandParameter QueueArn: using user-provided valuelines, its provider lines and the row's own provider lines printed the name. The row's print-only bag is now its derived-name registry, which is bound as a printing bag around the row's whole body and therefore around the child's deploy. The child's inherited secrets are unchanged. Thecdkd diffwalk into a child is not covered yet. Tests:tests/unit/deployment/deploy-engine-derived-name-needle-3869.test.ts. Integsecret-derived-immutable-namesgains the nested stackQueueReaderChild, fed SecretQueue's ARN; the deploy and update logs must not name the queue.A call rejecting with a value that cannot be converted to text no longer turns a best-effort step of
cdkd import,cdkd state,cdkd orphan,cdkd gcorcdkd force-unlockinto a hard failure (src/cli/commands/{import,state,retire-cfn-stack,orphan,gc,force-unlock}.ts, go-to-k/cdkd#3361). Handlers that log a failure and carry on built their message withx instanceof Error ? x.message : String(x), andString()throws for a null-prototype object, so the handler itself threw: a failed lock release after a successful import, state orphan, refresh orcdkd orphanfailed the command; one resource whose import orreadCurrentStatefailed aborted every other resource; a failed template-parameter, condition or intrinsic resolution aborted the import it was degrading; a failed transient-template delete failed a--migrate-from-cloudformationretire after CloudFormation had succeeded, and a mid-walk nested-template failure lost the uploads it should have carried out for deletion; a failedDescribeStackResourceslookup aborted the import instead of falling back to per-resource lookups;cdkd gc's purge warning replaced itsGC_DELETE_FAILEDerror, and its delete failure lost that code; andcdkd force-unlockstopped at the first such failure instead of attempting every remaining stack. Two throwing sites lost what they throw: the import's rollback-journal refusal and a retire's failedUpdateStacksurfaced as the converter'sTypeError. Each now goes throughdescribeAwsFailure(x).detail, byte-identical whenever the value converts and a placeholder only when it cannot. Tests:tests/unit/cli/*-string-outthrow-3361.test.ts.A failure that cannot be converted to text no longer turns a best-effort deploy step into a hard failure (
src/deployment/orphan-adoption.ts,src/deployment/nested-child-journal.ts,src/deployment/work-graph.ts,src/deployment/recreate-targets/probe.ts, go-to-k/cdkd#3361). Handlers that report a failure and carry on built their message withx instanceof Error ? x.message : String(x), andString()throws for a null-prototype object. The handler itself then threw: the orphan-adoption pre-pass rejected, and the deploy with it, instead of keeping a record it could not route or verify; the sibling-claim scan rejected instead of skipping an unreadable sibling or answering an empty set; the nested-journal sweep after a successful deploy, documented as never throwing, rejected and skipped the child's own journal delete; a failed state save after a nested child's revert, documented as a warning, failed the revert; a stack or asset node failing that way became an unhandled rejection inWorkGraph.executethat exitedcdkd deploy/cdkd publish-assetswith the converter'sTypeErrorinstead of the failure summary; and the--recreate-via-*stateful probe rejected instead of answering fail-open for a bucket or fail-closed for a log group. Each now goes throughdescribeAwsFailure(x).detail, byte-identical for anErrorand a placeholder otherwise. Tests:tests/unit/deployment/deployment-string-outthrow-3361.test.ts.A failure that cannot be converted to text no longer turns a degradation in
cdkd driftinto a failure of the command or of a revert that already landed (src/cli/commands/drift.ts, go-to-k/cdkd#3361). Thirteen handlers that log a failure and carry on built their message withx instanceof Error ? x.message : String(x), andString()throws for a null-prototype object. The handler itself then threw. In detection, a resource whose read failed or whose type has no READ handler aborted the command instead of being reported and skipped; a resource whose dynamic reference could not be resolved, or whose IAM principal lookup failed, was reported as not compared at all instead of comparing what it could; and an unreadable nested-stack ancestor rejected instead of making the producer-region evidence incomplete.--acceptand--revertrejected after their work on a failed lock release. In--revert, a failed update, re-resolution or payload build abandoned the rest of the stack and its state write; a revert that LANDED was re-reported asAWS update failedwhen the provider's effective properties or returned attributes could not be read; and a failed best-effort state write after a landed revert rejected instead of warning. Each now goes throughdescribeAwsFailure(x).detail, byte-identical for anErrorand a placeholder otherwise. Tests:tests/unit/cli/drift-string-outthrow-3361.test.ts.A failure value that cannot be converted to text no longer turns a best-effort fallback in the analyzer, synthesis, assets or utils layers into a hard failure (
src/analyzer/orphan-rewriter.ts,src/synthesis/macro-expander.ts,src/synthesis/context-providers/index.ts,src/assets/asset-storage.ts,src/utils/expected-bucket-owner.ts,src/utils/error-handler.ts, go-to-k/cdkd#3361).String()throws for a null-prototype object, and these handlers built their message with it. The handler itself then threw: a failed liveFn::GetAttread incdkd orphanrejected the rewrite instead of being reported as unresolvable (or, under--force, falling back to the recorded attribute); a failedDeleteStackof the macro-expansion transient stack failed the deploy from its cleanup; one failed context lookup lost every other value of that synth round; a failed asset-storage auto-create failed the deploy instead of falling back to the CDK bootstrap destinations; a failedExpectedBucketOwnerresolution rejected the state-bucket call instead of omitting the header; andformatError/normalizeAwsError, whose last arm exists for a non-Errorvalue, threw on it. Each now goes throughdescribeAwsFailure(x).detailorsafeStringify(x), byte-identical for every valueString()converts and a placeholder otherwise. Tests:tests/unit/{analyzer/orphan-rewriter,assets/asset-storage}-string-outthrow-3361.test.ts,tests/unit/{synthesis,utils}/string-outthrow-3361.test.ts.cdkd scrubno longer turns a failure it is built to report into a different, harder one when the failing call rejects with a value that cannot be converted to text (src/cli/commands/scrub.ts, go-to-k/cdkd#3361). Its handlers built their message withx instanceof Error ? x.message : String(x)(or a bareString(x)), andString()throws for a null-prototype object, so the handler itself threw. In--all, the per-stack failure boundary threw while reporting one stack and ended the run there, leaving every later stack unexamined; a nested-record listing failure or an unreadable exports index did the same instead of being recorded and reported after the summary. Inside one stack, a lock release failure after a successful rewrite reported the stack as failed, and a resource property, orphan record, output value,Export.Name, parameter, condition, cross-stack read on a suppressed output, producer re-read or consumer-records read that failed aborted the whole stack instead of being logged and skipped. Four refusals (SCRUB_CROSS_STACK_READ_UNRESOLVED,SCRUB_CROSS_REGION_SECRET_UNRESOLVED,SCRUB_NESTED_CHILD_UNRESOLVABLE,SCRUB_LEGACY_STATE_KEY_UNVERIFIED) were replaced by the converter'sTypeError, losing their code and remedy. Each now goes throughdescribeAwsFailure(x).detail(orsafeStringify(x)for the bare form), byte-identical for every value the old expression could render and a placeholder where it threw. Tests:tests/unit/cli/commands/scrub-string-outthrow-3361.test.ts,tests/unit/cli/commands/scrub-nested-string-outthrow-3361.test.ts.A state-bucket call rejecting with a value that cannot be converted to text no longer turns a best-effort state-layer step into a hard failure (
src/state/deployment-events-store.ts,src/state/export-index-store.ts,src/state/s3-noncurrent-version-purge.ts, go-to-k/cdkd#3361). Five handlers that log a failure and carry on, plus the noncurrent-version purge's failure recorder, built their message withx instanceof Error ? x.message : String(x), andString()throws for a null-prototype object. The handler itself then threw:DeploymentEventsStore.finalize, documented as never throwing, rejected out of the deploy and destroy runners'finally; an unreadable deployment-events index skipped the index write; one unreadable state record during an exports-index rebuild failed every lookup instead of skipping that stack; a failed or retry-exhausted exports-index write rejected instead of continuing onstate.json; a purge rejected at the first failed listing; and a failed batch delete abandoned the rest of its prefix and was reported against the prefix, with the converter'sTypeErrorin place of its own keys and reason. Each now goes throughdescribeAwsFailure(x).detail, byte-identical for anErrorand a placeholder otherwise. Tests:tests/unit/state/state-string-outthrow-3361.test.ts.A mis-cased deploy region no longer records one cross-stack producer twice, and the CloudFormation fallback client takes the canonical region (issue #2209) --
src/utils/aws-partition.ts(newsameRegion),src/deployment/intrinsic-resolver/{cross-stack,stack-output,cfn-fallback,clients}.ts,src/deployment/intrinsic-function-resolver.ts, and a new fencetests/unit/deployment/resolver-region-compare-fence.test.ts.IntrinsicFunctionResolver.resolverRegionstays RAW (it keys the stack's own state), while values derived from it are folded, so a bare===between two region values disagreed whenever the operands arrived by different paths. Under--region US-EAST-1, a producer read once without aRegionand once withRegion: us-east-1was written twice intostate.outputReads, and likewise intostate.importsacross the exports-index and state-scan arms. The CloudFormation fallback builtListExportswith the raw region andDescribeStackswith the folded one, so one region held two cached clients and a mis-cased spelling reached the SDK's case-sensitive endpoint resolution. Every region equality in the resolver family now goes throughsameRegion, which folds both operands at the comparison and moves no state key. The CFn client memo and theDescribeStackscache fold their region key. The fence refuses a bare region comparison in the resolver family,!==and either operand order included. A comparison that genuinely means "this exact string" carries// allow-raw-region-compare: <reason>.
Recently Implemented (2026-10-07):
cdkd driftreports anAWS::CloudWatch::AnomalyDetectoras drift unknown again instead of exiting 2 (issue #4668) --src/analyzer/drift-cc-api-deny-list.ts. AWS gave the type Cloud Control handlers. The SDK provider has noreadCurrentState, so drift fell back to Cloud ControlGetResourcewith cdkd's descriptor-derived physicalId, which Cloud Control refuses with a ValidationException on every run. That was reported as a read failure (NOT fully compared, exit 2) on every drift run of any stack holding a detector. The type is now on the Cloud Control fallback deny-list, so the row reports drift unknown and the run exits 0 as it did before. Tests:tests/unit/cli/drift-per-resource-failure.test.ts; integcloudwatch-anomaly-detector.cdkd exportrefuses anAWS::CloudWatch::AnomalyDetectorbefore locking the stack (issue #4668) --src/cli/commands/export.ts. AWS gave the type Cloud Control handlers and the registry identifierAnomalyDetectorId, so the import-support pre-flight no longer blocked it. The single-key path would then have sent cdkd's descriptor-derived physicalId asAnomalyDetectorId, an opaque identifier the CloudWatch API never returns, and the IMPORT changeset would have failed after the stack was locked. The type is now blocked up front with the other export refusals, including under--skip-import-support-preflight. Tests:tests/unit/cli/export-composite-identifier.test.ts.Fn::GetAtt [<detector>, AnomalyDetectorId]onAWS::CloudWatch::AnomalyDetectornow resolves (issue #4668) --src/provisioning/providers/cloudwatch-anomaly-detector-provider.ts. The registry schema renamed the type's read-only primary identifier fromIdtoAnomalyDetectorId. The SDK provider recorded and resolved onlyId, soAnomalyDetectorIdfell through to the resolver's unknown-attribute physical-id fallback with a warning, and failed under--strict-getatt. Both names are now recorded on create, update and import and accepted bygetAttribute, with the same value (cdkd's physical id);Idstays for aws-cdk-lib releases whoseattrIdstill emits it. Tests:tests/unit/provisioning/providers/cloudwatch-anomaly-detector-provider.test.ts.- A successful deploy no longer deletes a resource that reused the name of a journaled orphan the user deleted by hand (issue #4655) --
src/deployment/deploy-engine/execute.ts,src/deployment/rollback-executor/{journaled-orphans,orphan-identity,types}.ts,src/types/{resource,rollback-journal}.ts,src/provisioning/providers/kinesis-provider.ts. Aftercdkd deploy --no-rollbackfailed with a resource already created, the journal recorded it by physical id alone. If the user deleted it and something else created a resource under the same name, the next successful deploy deleted that unrelated resource. Providers gain an optionalresourceIdentity; the failed CREATE journals its token ascreatedResourceIdentity(additive, nojournalVersionbump), and the success-path settle deletes a name-keyed orphan only when a live read returns the same token, and drops one reported gone without a delete. Otherwise it warns, names the resource, clears the entry and exits2. A journal an older cdkd wrote, and a name-keyed type withoutresourceIdentity, now lands there too instead of being deleted.AWS::Kinesis::Streamimplements it (ARN plus creation time). Types whose physical id AWS generates and never reuses,AWS::SQS::QueuePolicy/AWS::SNS::TopicPolicy(whose delete compares content) and nested stacks (a cdkd-minted id) are unchanged.cdkd rollbackandcdkd destroyare not covered yet (#4658). Tests:tests/unit/deployment/journaled-orphan-identity-4655.test.ts,tests/unit/provisioning/providers/kinesis-resource-identity-4655.test.ts; integrollback-commandstep O9. - More pasteable
cdkd statecommands now carry the run's--profile, resolved--state-bucketand non-default--state-prefix(issues #4648, #4159). Changed files:src/state/malformed-resources-bag.ts(exportsaccountArgs/withheldAccountClause),src/deployment/deploy-engine/delete.ts,src/cli/commands/{destroy,destroy-runner,state,gc}.ts,src/utils/error-handler.tsandsrc/provisioning/providers/nested-stack-provider.ts. Before this change these messages printed their commands without the flags, so on a non-default bucket or profile a pasted command acted on the default profile's bucket. Some of those commands drop records or delete resources. The messages are: a deploy's delete-skip warnings (Inspect it with:/Drop the record with:),cdkd destroy's multi-regioncdkd state orphan, its nested-child refusal (cdkd destroy <parent>/cdkd state destroy <child>) and its termination-protectionRetry with:line,cdkd state orphan'sDestroy with:line, the partial-destroy hints,cdkd state orphan --resource's child drop, a nested child's destroy failure, the deploy-path refusal of a nested child's unreadable outputs (the last #4159 site), andcdkd gc's unreadable-state-filecdkd state showline, which names the prefix of the key the file was found under. Each value goes through the same gate as #3909's: a refused one prints as a quoted hole with its reason. Still without the flags: thecdkd exportandcdkd driftcommands, includingcdkd state refresh-observed, which #4648 tracks. - Data and streaming creates no longer let the AWS SDK replay a 5xx inside one call (issue #4639, data / streaming slice) -- the
glue,firehose,kinesis-streamconsumer,pipes,cloudtrail,s3-vectors,s3-tablesands3-directory-bucketproviders. GlueCreateDatabase/CreateTable/CreateWorkflow/CreateSecurityConfiguration/CreateJob/CreateCrawler/CreateConnection/CreateTrigger, FirehoseCreateDeliveryStream, KinesisRegisterStreamConsumer, PipesCreatePipe, CloudTrailCreateTrail, S3 VectorsCreateVectorBucket, S3 TablesCreateTableBucket/CreateNamespace/CreateTableand S3 ExpressCreateBucketcarry no idempotency token and were sent through a client whose SDK retry replays a 5xx even when AWS completed the request. The replay collided with the resource the first request made, the collision was credited to another holder, and the resource was left in no state record for every re-run to collide with. They now go through a dedicated client that refuses the SDK's 5xx retry, built with the same region and identity as the provider's other calls, so the 5xx reaches the deploy engine, which marks a later collision as possibly this create's own. Nothing is adopted. SchedulerCreateSchedule(its SDK-filledClientTokenis serialized once and reused by the SDK's retries) and S3CreateBucket(a replay answersBucketAlreadyOwnedByYou, or 200 in us-east-1, and both paths already record the bucket) are unchanged. A failed Pipes create or update now passes AWS's error text through the secret masker before it reaches the error message. Tests:tests/unit/provisioning/*-create-retry-4639.test.ts,scheduler-create-token-4639.test.ts. - An RDS, DocumentDB, Neptune, ElastiCache or DynamoDB create whose 5xx hid a server-side success is no longer replayed inside the AWS SDK and credited as somebody else's resource (issue #4639) --
src/provisioning/providers/{rds,rds-dbproxy,rds-dbproxy-endpoint,docdb,docdb-subnet-group,neptune,elasticache,dynamodb-table,dynamodb-globaltable}-provider.ts(getCreateClient) and new*-create-retry-4639unit files.CreateDBSubnetGroup/CreateDBCluster/CreateDBInstance(RDS, DocumentDB, Neptune),CreateDBProxy,CreateDBProxyEndpoint,CreateCacheSubnetGroup/CreateCacheClusterandCreateTable(table and global table) carry no idempotency token and were sent through a client with the SDK's default retry, so a 5xx whose request had succeeded was replayed inside onesend, and the replay's "already exists" surfaced from the deploy engine's FIRST attempt, read as a name another holder owns. Each now goes through a dedicated client wrapped bywithoutServerErrorRetries: the 5xx reaches the engine's retry, whose ambiguous-outcome latch (#3983) marks the replay's collision as possibly this create's own. Such a 5xx is now retried on the engine's logged schedule rather than the SDK's quick in-sendretries. Every other call keeps the full SDK retry, a name that existed before any ambiguous attempt is still a name collision, and nothing is adopted on that collision -- a name is not attribution. Residual: the other #4639 sites and the socket reset or timeout the SDK still replays. - IAM creates no longer let the AWS SDK replay a 5xx inside one call (issue #4639, IAM slice) --
src/provisioning/providers/iam-create-client.tsand theiam-role,iam-user-group,iam-instance-profile,iam-managed-policyandiam-access-keyproviders.CreateRole,CreateUser,CreateGroup,CreateInstanceProfile,CreatePolicyandCreateAccessKeycarry no idempotency token and were sent through the shared IAM client, whose SDK retry replays a 5xx even when IAM completed the request. For the name-unique creates the replay failedEntityAlreadyExistsagainst the entity the first request made, the collision was credited to another holder, and the entity was left in no state record for every re-run to collide with. ForCreateAccessKeythe replay minted a second key and returned it, leaving the first as a live, unrecorded credential in one of the user's two key slots. They now go through a dedicated client that refuses the SDK's 5xx retry (the CodeCommit / EC2 pattern), so the 5xx reaches the deploy engine, which marks a later collision as possibly this create's own, and, for an access key, the provider's failed-attempt reconcile, which deletes the key that attempt minted whenListAccessKeysalready shows it (the list is eventually consistent; a key it does not show yet is neither deleted nor reported, and the retry mints a second one). Tests:tests/unit/provisioning/iam-create-retry-4639.test.ts. - Lambda, EventBridge bus and ECR creates no longer let the AWS SDK replay a 5xx inside one call (issue #4639, Lambda / EventBridge bus / ECR slice) --
src/provisioning/providers/lambda-create-client.tsand thelambda-function,lambda-url,lambda-permission,eventbridge-busandecrproviders.CreateFunction,CreateFunctionUrlConfig,AddPermission,CreateEventBusandCreateRepositorycarry no idempotency token and were sent through a client whose SDK retry replays a 5xx even when AWS completed the request. Each is unique by name, so the replay failed with "already exists" against the resource the first request made. That collision was credited to another holder, and the resource was left in no state record for every re-run to collide with. They now go through a dedicated client that refuses the SDK's 5xx retry (the CodeCommit / EC2 / IAM pattern). The 5xx reaches the deploy engine, which marks a later collision as possibly this create's own. Tests:tests/unit/provisioning/lambda-ecr-create-retry-4639.test.ts. - Seven more token-less creates no longer let the AWS SDK replay a 5xx inside one call (issue #4639, remaining-sites slice) -- the
cloudfront-oac,ssm-parameter,codebuild,budgets-budget,asg,wafv2andefsproviders. CloudFrontCreateOriginAccessControl, SSMPutParameter(Overwrite: false), CodeBuildCreateProject, BudgetsCreateBudget, Auto ScalingCreateAutoScalingGroup, WAFv2CreateWebACLand EFSCreateMountTargetcarry no idempotency token and were sent through a client whose SDK retry replays a 5xx even when AWS completed the request. The replay collided with what the first request made, the deploy engine credited that "already exists" to another holder, and the create failed leaving a resource in no state record for every re-run to collide with. Each now goes through a dedicated client that refuses the SDK's 5xx retry, built in the same region and with the same identity as the provider's shared client, so the 5xx reaches the deploy engine, which marks the later collision as possibly this create's own. Nothing is adopted on that collision. The Budgets create / update and Auto Scaling group create failure messages, which a surfaced 5xx now reaches more often, quote AWS's text through the secret masker. AgentCoreCreateAgentRuntime/CreateEvaluator, the Cloud Map namespace and service creates, LambdaCreateMicrovmImageand Secrets ManagerCreateSecretare unchanged: each carries an idempotency token the SDK fills once per call, so its replay inside that call repeats the same token. Tests:tests/unit/provisioning/remaining-create-retry-4639.test.ts. cdkd rollback <stack> --drop-failed <logicalId>drops one journaled failed CREATE whose delete can never succeed (issue #4633) --src/cli/commands/rollback-drop-failed.ts,src/cli/commands/rollback.ts,src/deployment/rollback-executor/journaled-orphans.ts. A failed CREATE whose resource cdkd proved it made is kept in the rollback journal until cdkd deletes that resource; when the delete kept failing for a cause the operator could not fix (a secret or KMS key they cannot read, go-to-k/cdkd#4612), everycdkd deployexited 2 and everycdkd destroyended "State preserved", and the only way out was removing the stack'srollback-journal.json, which dropped every other entry too. The new option removes exactly that entry (and the failed replacement UPDATE recorded beside it, go-to-k/cdkd#4604) under the stack lock, after printing the resource it names (masked where the name derives from a secret, with a policy's queue or topic targets) and asking for confirmation (--force/-yskip it; a non-interactive run without them is refused). It replays nothing and deletes nothing in AWS. It refuses an unknown id, a completed operation, a failed operation of any other kind or one a newer journal entry may own (neither blocks either command), an id with several such entries, a state record that cannot be read, and combination with--orphan,--revert-failedor--skip-final-snapshot. The retry warnings ofcdkd destroy, a successfulcdkd deployandcdkd rollbacknow print the pasteable command for each such entry whose delete they tried and that failed. Tests:tests/unit/cli/rollback-drop-failed-4633.test.ts,tests/unit/cli/rollback-drop-failed-cli-4633.test.ts.cdkd rollback(plain and--revert-failed) and the automatic rollback no longer hand a provider aphysicalIdthat is absent, empty, whitespace-only or not a string (issue #4628) --src/deployment/rollback-executor.ts,src/deployment/rollback-executor/{messages,replay-revert,replay-reverse-replacement,replay-orphan-delete}.ts. Every replay arm that addresses AWS by a record's or journaled op's id -- the revert of a completed UPDATE, the--revert-failedforce-revert of a failed one, the delete of a completed or partially-created CREATE, and both replacement reversals -- now skips such an op before any provider call: aCannot ... -- its state record has no non-empty string 'physicalId'warning, aROLLBACK_RESOURCE_SKIPPEDevent, exit 2, and the state record left as it is. Before,cdkd rollback --revert-failed(the remedy a failed deploy prints) sentprovider.updatea blank id for an UPDATEcdkd deployhad refused over such a record, a delete could read the provider's*NotFoundas already deleted and drop the record, and a replacement reversal could re-create the old resource before its delete of the new one failed. Re-adopting a retained old resource (UpdateReplacePolicy: Retain) fails instead (journal kept), since only the journal names that resource. A failed CREATE no state record holds is named for manual deletion. A nested-stack record is exempt, as oncdkd deploy, unless it or the arm's route is Cloud Control. A replacement whose new copy is retained (UpdateReplacePolicy: Retain) sends nothing by that id and is unchanged. Tests:tests/unit/deployment/rollback-executor-unaddressable-physical-id-4628.test.ts.- A successful fix-forward deploy now deletes the EC2 instance an earlier failed CREATE left under the same logical id (issue #4606) --
src/provisioning/providers/ec2-provider.ts. When anAWS::EC2::InstanceCREATE failed afterRunInstancesand its cleanup terminate failed too, the instance was journaled as a proven orphan. If a later deploy created a new instance under the same logical id, that successful deploy warned, named the earlier instance, exited2and left it running.EC2Providernow answersisSameResourcefor instances, using the AWS-generatedi-instance id. The client must be in the stack's region, and the record's instance must read back live (pending,running,stoppingorstopped). The earlier instance is thendifferent, whether it still runs, isshutting-down/terminated, or is not found, so the settle terminates it. Equal ids aresame. Any other id form and a failed read stayunknown, which keeps the previous warning and exit2. An earlier instance EC2 no longer lists is named in one info line; one still listed asshutting-downorterminatedis settled quietly by the terminate. A termination-protected earlier instance is not deleted: the delete fails, the journal keeps the entry, and the next successful deploy tries again. Tests:tests/unit/provisioning/ec2-instance-same-resource-4606.test.ts. Integec2-instancePhase 1b runs the read against live instances, a terminated instance and an id that never existed. - A successful fix-forward deploy now deletes the load balancer, target group or listener an earlier failed CREATE left under the same logical id (issue #4606) --
src/provisioning/providers/elbv2-provider.ts. Aftercdkd deploy --no-rollbackfailed with anAWS::ElasticLoadBalancingV2::LoadBalancer,TargetGrouporListeneralready created and its wiring-failure cleanup unable to delete it (a journaled proven orphan) and the fixed template created a new one under the same logical id, the successful deploy warned, named the earlier one, exited2and left it in AWS.ELBv2Providernow answersisSameResourcefor the three types from the ARN, whose last segment AWS mints per create (a name alone is reusable, so it is never the identity): both ARNs must be the type's form in the stack's partition, account and region, the client must be in that region, and the record's ARN must read back as itself; the earlier one is thendifferentwhen it reads back as itself or ELBv2 reports it gone by the type's own not-found error, so the settle deletes it. Equal ARNs aresame; any other id form, a read naming another resource and a failed read stayunknown(the previous warning and exit2). An earlier resource already gone (only by the type's own not-found error name) is named in one info line. A deletion-protected earlier load balancer still needs its protection cleared before the settle can delete it. Tests:tests/unit/provisioning/elbv2-same-resource-4606.test.ts; integalbPhase 4 asserts the earlier load balancer deleted, the new one kept, exit0. - A successful fix-forward deploy now settles the EMR cluster an earlier failed CREATE left under the same logical id (issue #4606) --
src/provisioning/providers/emr-cluster-provider.ts. Aftercdkd deploy --no-rollbackfailed with anAWS::EMR::ClusterthatRunJobFlowmade and the create's own terminate could not stop (a journaled proven orphan) and the fixed template created a new cluster under the same logical id, the successful deploy warned, named the earlier cluster and exited2.EMRClusterProvidernow answersisSameResourcefrom thej-cluster id: the client must be in the stack's region and the record's cluster must read back under its own id, in any state (a transient cluster may already be terminating); the earlier cluster is thendifferent, so the settle deletes it (terminating a live one) and the deploy exits0. Equal ids aresame; any other id form and a failed read stayunknown(the previous warning and exit2). An earlier cluster already terminated or gone is named in one info line.AWS::EMR::InstanceFleetConfig/AWS::EMR::InstanceGroupConfigkeep the warning: no EMR call reads a fleet or group by id without its cluster id, and neither has a delete API. Tests:tests/unit/provisioning/emr-same-resource-4606.test.ts; integemr-clusterPhase 3b asserts the earlier cluster settled, the new one kept, exit0. - A successful fix-forward deploy now deletes the Kinesis stream an earlier failed CREATE left under the same logical id, once a live read proves it is not the new stream (issue #4606) --
src/types/resource.ts,src/deployment/rollback-executor/{journaled-orphans,plan}.ts,src/deployment/rollback-executor.ts,src/provisioning/providers/kinesis-provider.ts. Aftercdkd deploy --no-rollbackfailed with a stream already created (a journaled proven orphan) and the fixed template created a new stream under the same logical id, the successful deploy could not prove the two differ, so it warned, named the earlier stream, exited2and left it in AWS. Providers gain an optionalisSameResource, answeringsame,differentorunknownfrom a live read; the success-path settle asks it when a record of the orphan's type sits under its logical id, deletes the orphan (per its journaledDeletionPolicy, still subject to the other-stack ownership check) only ondifferent, without reading or dropping that record, and settlessamesilently as a tracked resource.AWS::Kinesis::Streamimplements it: both ids must be stream names, the client must be in the stack's region, the record's stream must read back, and the earlier stream isdifferentwhen it reads back under anotherStreamARNor is gone. Equal names are never compared and never deleted; an earlier stream already gone is named in one info line. OnlyAWS::Kinesis::Streamimplements it so far: every other type, an unreadable record, and a read that fails keep the previous warning and exit2(the other types remain #4606). Tests:tests/unit/deployment/journaled-orphans-fix-forward-4606.test.ts,tests/unit/provisioning/providers/kinesis-same-resource-4606.test.ts; integrollback-commandstep O8 now asserts the earlier stream deleted, the new one kept, exit0. - A successful fix-forward deploy now deletes the FSx file system an earlier failed CREATE left under the same logical id (issue #4606) --
src/provisioning/providers/fsx-filesystem-provider.ts. Aftercdkd deploy --no-rollbackfailed with anAWS::FSx::FileSystemalready created (a journaled proven orphan: a file system whose wait failed and whose cleanup delete failed too) and the fixed template created a new one under the same logical id, the successful deploy warned, named the earlier one, exited2and left it in AWS, billed by the hour.FSxFileSystemProvidernow answersisSameResourcefor every variant (Lustre / Windows / ONTAP / OpenZFS) from thefs-file system id FSx generates: the client must be in the stack's region and the record's file system must read back short ofDELETING/FAILED, and the earlier one is thendifferent, so the settle deletes it; equal ids aresame. An unrecognised id form and a failed read stayunknown(the previous warning and exit2). An earlier file system already gone is named in one info line. Tests:tests/unit/provisioning/fsx-same-resource-4606.test.ts; integfsx-lustrePhase 2c asserts the earlier file system deleted, the new one kept, exit0. - A successful fix-forward deploy now deletes the NAT gateway or Elastic IP an earlier failed CREATE left under the same logical id (issue #4606) --
src/provisioning/providers/ec2-provider.ts. Aftercdkd deploy --no-rollbackfailed with anAWS::EC2::NatGatewayorAWS::EC2::EIPalready created (a journaled proven orphan: a gateway whose available-state wait failed, an address whose instance association failed) and the fixed template created a new one under the same logical id, the successful deploy warned, named the earlier one, exited2and left it in AWS.EC2Providernow answersisSameResourcefor those two types when SDK-routed (a Cloud Control-routed one, e.g. a NAT gateway settingMaxDrainDurationSeconds, is unchanged) from the AWS-generated id (thenat-gateway id; theeipalloc-allocation id from the compositePublicIp|AllocationIdor the bare form, never the public IP): the client must be in the stack's region and the record's resource must read back live (a NAT gatewaypendingoravailable), and the earlier one is thendifferent, so the settle deletes it; equal ids (including the composite and bare spellings of one allocation id) aresame, and a bare journaled allocation id (the create's fallback when the composite fence refuses) is neverdifferent, since the settle's holder checks compare the id string. Every other EC2 type, an unrecognised id form and a failed read stayunknown(the previous warning and exit2). An earlier gateway or address already gone is named in one info line. Tests:tests/unit/provisioning/ec2-nat-eip-same-resource-4606.test.ts; integvpc-nat-gatewayPhases 1d / 1e assert the earlier resource deleted, the new one kept, exit0. - A successful fix-forward deploy now deletes the RDS DB cluster or DB instance an earlier failed CREATE left under the same logical id, once live reads prove it is not the new one and is still the one that failed CREATE made (issues #4606, #4655) --
src/provisioning/providers/rds-provider.ts,src/provisioning/auxiliary-failure.ts,src/deployment/deploy-engine/execute.ts. Aftercdkd deploy --no-rollbackfailed with anAWS::RDS::DBClusterorAWS::RDS::DBInstancealready created, and the fixed template created a new one under the same logical id with another identifier, the successful deploy warned, named the earlier resource, exited2and left it in AWS.RDSProvidernow implementsisSameResourceandresourceIdentity. Both rest on the immutableDbClusterResourceId/DbiResourceId. Identifiers equal apart from case are the same resource, since RDS identifiers are case-insensitive. The failed CREATE journals the resource id its create call returned, carried on the failure itself, so a describe that cannot run (often the failure) does not cost the orphan its identity; a provider may carry such an id on its created-before-failure mark, and the deploy then journals it without a live read. A resource someone re-created under the identifier, in any case spelling, reads back under another resource id and is kept with a warning. Only the not-found fault name counts as gone. Deleting an earlier resource that is already gone prints one info line. Tests:tests/unit/provisioning/rds-provider-{same-resource-4606,resource-identity-4655}.test.ts; new integrds-fix-forward-orphan. - The malformed-record refusals and warnings from
cdkd deploy,cdkd diff,cdkd drift,cdkd scrub,cdkd export,cdkd local --from-stateandcdkd state show/cdkd state resourcesnow print theircdkd state showcommands with the run's--profile, resolved--state-bucketand non-default--state-prefix(issue #4159). Changed files:src/state/malformed-resources-bag.ts,src/state/export-index-store.ts,src/deployment/deploy-engine/{options,deploy-flow,update}.ts,src/analyzer/diff-calculator.ts,src/cli/commands/{deploy,destroy,diff,diff-recursive,drift,scrub,state,export,local-state-loader}.tsandsrc/local/s3-local-state-provider.ts. Before this change onlycdkd destroy,cdkd import,cdkd orphan,cdkd rollbackandcdkd state refresh-observedcarried the flags (issue #3909). Every other command printed the inspect command bare, so on a non-default bucket or profile the pasted command read the wrong bucket. The deploy engine and the pre-lock--recreate-via-*check now print the same flags for the same record, and a nested child's engine inherits them. A value the shared paste gate refuses is printed as a quoted hole with its reason, as for #3909. A--state-prefixsuch asteam/devis now gated per/-separated part, so it prints as typed on these commands and on #3909's, where it used to be a'<prefix>'hole. One site remains: the nested-stack provider's read of a child's outputs during a deploy. - A
NoEcho: trueparameter's value, and a value an attribute was declaredNoEchofor, are no longer persisted in the clear: state schema v11 (issues #4043 Phase B, #2449) --src/types/state.ts,src/deployment/secret-redaction/noecho-leaves.ts,src/deployment/deploy-engine/{noecho,update}.ts. State,observedProperties,outputs, the exports index and the rollback journal store***where such a value was used (at a position the template names, nested children included, whatever its type or length; elsewhere from 4 characters);noEchoLeavesnames those positions,noEchoAttributeNamesthe declared attributes. A comparison reads the resource back instead: a held value is skipped, an updatable one AWS cannot report is re-sent with an info line, and a create-only property is never replaced on a readback (a warning names--recreate-via-*; a failed read fails the resource). The first deploy after upgrading compares each value with the one last stored: an unchanged one is neither updated nor replaced, a changed one is applied as before, a create-only replacement included. A custom resource'sUpdateruns every deploy with***inOldResourceProperties; itsDeleteis skipped (exit 2) where its record holds***for such a parameter or a declared attribute. A reader of a declared attribute out of an earlier run is still refused, naming it.cdkd driftreports a masked position in its own bucket;cdkd diffno longer shows a changedNoEchovalue (one per-stack note, not counted for--fail);cdkd exportblocks a resource such a value feeds. A replacement whose old resource's delete was skipped is a partial update (exit 2 unless--allow-unaddressed). Older binaries fail on v11 with "Upgrade cdkd"; earlierstate.jsonversions keep the plaintext, so rotate it. Integ:schema-v10-to-v11-migration(new). - A Kinesis stream create whose 5xx hid a server-side success is no longer replayed inside the AWS SDK and credited as somebody else's stream (issue #3978) --
src/provisioning/providers/kinesis-provider.ts(getCreateClient) and a new unit file.CreateStreamcarries no idempotency token and was sent through a client with the SDK's default retry, so a 5xx whose request had succeeded was replayed inside onesend, and the replay'sResourceInUseExceptionsurfaced from the deploy engine's FIRST attempt, read as a name another holder owns.CreateStreamnow goes through a dedicated client wrapped bywithoutServerErrorRetries: the 5xx reaches the engine's retry, whose ambiguous-outcome latch (#3983) marks the replay's collision as possibly this create's own. What the user sees changes accordingly: a plain create now prints the replay advice (the holder is most likely this create's own stream) instead of "an earlier cdkd run left it behind", and a replacement surfaces the raw create error instead ofNAMED_REPLACEMENT_COLLISION. ACreateStream5xx that did NOT hide a success is now retried on the engine's logged schedule rather than the SDK's quick in-sendretries. Every other Kinesis call keeps the full SDK retry, and a stream that existed before any ambiguous attempt is still a name collision. The stream is not adopted on that collision -- a name is not attribution. Residual: #4639, the other token-less name-unique creates, and the socket reset or timeout the SDK still replays. cdkd destroymasks a physical name derived from a secret on a journaled orphan's delete lines and in its deployment events (issue #3869) --src/deployment/secret-name-needles.ts,src/deployment/rollback-executor/journaled-orphans.ts,src/cli/commands/destroy-runner.ts. A resource a failed deploy created before failing is recorded only in the rollback journal, andcdkd destroydeletes it from there. When its name came from a secret, that delete's provider lines and final-snapshot lines printed the name: they ran under no printing bag. They now run under one judged from the journal entry itself (its type, recovered physical id and attempted properties), plus the names the entry read from a state record. The same deletes also run when a successful deploy acts on its journal. Separately, thereasonand error message of each destroy event (RESOURCE_FAILED,RESOURCE_SKIPPED,RESOURCE_GUARD_INDETERMINATE, and a journaled orphan's rollback events, on the deploy-success path too) were stored verbatim indeployments/*.jsonl, while the log line beside them was masked. They are now masked with the same needles. An event'sphysicalIdfield stays exact. Tests:tests/unit/cli/destroy-journaled-orphans-secret-name-3869.test.ts,tests/unit/deployment/secret-name-needles-3869.test.ts; integsecret-derived-immutable-namesgains step 6b, which destroys a failed-CREATE ECR repository named from the secret.cdkd diff's adoption refusal and both rollbacks' completed-op replay mask a physical name derived from a secret (issue #3869) --src/deployment/secret-name-needles.ts,src/cli/commands/diff-recursive.ts,src/cli/commands/rollback.ts,src/deployment/deploy-engine/rollback.ts,src/deployment/nested-child-journal.ts,src/cli/commands/diff.ts.cdkd diffpreviews the refusalcdkd deploythrows when a resource an earlier rollback kept is recorded by another stack. It printed that resource's physical id in plaintext, in the rendered view and under--json, while the deploy's refusal masked it. It is now masked with the same needles (orphanRecordsPrintingBag).cdkd rollbackand a failed deploy's automatic rollback revert the deploy's COMPLETED operations, for example by deleting a resource it created. That revert ran under no printing bag, so a resource named from a secret printed its name on the provider's delete lines. Both now revert under a bag judged from each op's record and the record it replaced, and so does a nested stack's child revert. The orphan-adoption planner's own lines (a vanished record, the existence check) are masked oncdkd deployandcdkd difftoo.cdkd rollbackalso judges each segment's failed ops against the state as that segment finds it, so it catches a name a newer segment's revert put back. An event'sphysicalIdfield stays exact. Tests:tests/unit/cli/diff-adoption-refusal-secret-name-3869.test.ts,tests/unit/cli/rollback-completed-ops-secret-name-3869.test.ts,tests/unit/deployment/deploy-engine-rollback-orphan-secret-name-3869.test.ts; integsecret-derived-immutable-namesadds a completed-CREATE queue named from the secret to steps 6c and 6d.cdkd deploymasks an ElastiCache final-snapshot name cut from a secret-derived id (issue #3869) --src/deployment/secret-name-needles.ts,src/provisioning/final-snapshot.ts. A final-snapshot name for an ElastiCache cache cluster or replication group keeps only the first 28 characters of the physical id. When the id was judged from plaintext, the masker had only the whole id or the secret as needles. Those plaintexts are a name resolved this deploy, aNoEchoparameter value, or a secret embedded in the id. If one of them ran past character 28, the snapshot name held only a fragment of it. No needle matched that fragment, so the fragment printed in the clear. That covers theCreating final snapshotandreadylines of a template-removal or replacement delete, and AWS'sSnapshotAlreadyExistsFaulttext quoting the name. The derived-name judge now adds the snapshot's<base>-final-prefix as a needle whenever the cut splits one of its needles, and, for any snapshot-capable type, the folded spelling (lowercased, outside characters mapped to-) of a needle the base folds but carries whole. A state record still spelling a{{resolve:reference already had this prefix. Tests:tests/unit/deployment/derived-name-needle-3869.test.ts,tests/unit/provisioning/final-snapshot.test.ts. Thenoecho-parameter-maskinginteg gains Phases 4a and 4b: a replication group namedrg-<NoEcho value>underDeletionPolicy: Snapshotis removed, and its snapshot line must print no fragment of the name.cdkd rollbackand a failed deploy's automatic rollback mask a physical name derived from a secret on a journaled orphan's delete lines and in its events (issue #3869) --src/cli/commands/rollback.ts,src/deployment/deploy-engine/rollback.ts. A resource a failed CREATE made before failing is recorded only in the rollback journal. Both rollback paths replay it, andcdkd rollback --revert-failedreplays every other failed op too. That replay ran under no printing bag, so when the resource was named from a secret, its provider's delete lines and the final-snapshot lines printed the name. ItsROLLBACK_RESOURCE_FAILEDand skip events were masked with the op's own masker only, which holds no name the resource READ from another record (an access key'sUserName).cdkd rollbacknow replays under the printing bagcdkd destroyuses (#4631), judged from each journal entry. The automatic rollback, whose entries are still resolved plaintext in memory, replays under the engine's own bags: each orphan's resolved secrets and derived-name registry, plus the names it read, judged by the read resource's own resolution. Both mask each event's text with the bags bound where it is recorded. A deploy re-adopting a resource an earlier rollback kept also masks such a name on itsAdopting ... as <physicalId>line, its notices about kept records, and its refusal of a record another stack holds. An event'sphysicalIdfield stays exact. Tests:tests/unit/cli/rollback-orphan-secret-name-3869.test.ts,tests/unit/deployment/deploy-engine-rollback-orphan-secret-name-3869.test.ts; integsecret-derived-immutable-namesgains steps 6c and 6d, a plaincdkd rollbackand an automatic rollback of a failed-CREATE ECR repository named from the secret.- A nested-stack child resource reading a secret-carrying parameter no longer reports a change on every deploy (and
cdkd scrubof a nested stack persists the same value) when the parameter's expression cannot be certified (issue #2349) --src/deployment/secret-redaction/nested-stack.ts,src/deployment/secret-redaction/redact-path.ts,src/deployment/deploy-engine/masking.ts,src/deployment/intrinsic-resolver/parameter-secrets.ts. When two child parameters resolved to one secret plaintext and a resource read both, a{Ref: <Param>}leaf that cdkd could not tie to its parameter's own{{resolve:...}}expression (a value EMBEDDING the secret, such as apostgres://u:<secret>@hostconnection string the parent built withFn::Sub, or a parameter whose association was refused) was persisted with whichever parameter's expression resolved last, while the diff side used the parent's. The two never matched, so the child reported an UPDATE that no deploy cleared (the post-resolve re-check prevented any AWS write). Both sides now answer such a leaf with one function over the parent's bag,redactInheritedParameterValue. The persisted leaf falls back to the previous redaction whenever that answer would leave a plaintext the resource's own bag holds, so nothing is persisted less redacted than before. CompositeFn::Sub/Fn::Joinleaves that every positioning arm refuses keep the old behaviour.cdkd diff --recursivestill renders such a parameter with its own spelling and can disagree in one parent resolution order (issue #4644). Tests:tests/unit/deployment/secret-redaction-nested-parameter-list.test.ts(the two pinned residual cases now assert agreement),tests/unit/deployment/secret-redaction-nested-parameter-fallthrough.test.ts. - A tagged ALB / NLB listener now deploys through cdkd's ELBv2 provider, so removing a
ListenerAttributeskey resets it in AWS (issue #2085) --src/provisioning/providers/elbv2-provider.ts,src/provisioning/property-coverage.generated.ts. AnAWS::ElasticLoadBalancingV2::ListenercarryingTags(any CDK app that tags its stack, e.g.cdk.Tags.of(stack).add(...)) was routed through Cloud Control with "cdkd's SDK Provider does not yet wire Tags", although the provider already sent the tags on create, diffed them on update and read them back. On that route, dropping a key fromListenerAttributes(e.g.routing.http.response.server.enabled) left the old value live in AWS.ELBv2Providernow declaresTagshandled for the listener, so a new tagged listener stays on the SDK route, where a removed key is reset to its documented default. A listener already recorded as provisioned through Cloud Control stays there for now. Tests:tests/unit/provisioning/elbv2-listener-tags-route-2085.test.ts; integalb(the listener attribute removal assertion). - When the delete of the old resource fails with an AWS API error during
cdkd deploy --replace's delete-first fallback or--recreate-via-cc-api/--recreate-via-sdk-provider, theRESOURCE_FAILEDevent now records the AWS error code and request id (issue #1889) --src/deployment/deploy-engine/replacement.ts,src/deployment/deploy-engine/update-replace.ts. Both paths re-threw the delete's failure as a new error with nocause, so the event'sawsErrorCode/requestIdwere absent. The delete error is now chained as thecause, masked with the resource's resolved secrets like the message. A Cloud Control handler failure or a refusal cdkd raises itself still records neither, since neither carries AWS response metadata. The--replacere-create wrap's existingcauseis masked the same way, and the--recreate-via-*destroy message is now masked where it is built. Tests:tests/unit/deployment/deploy-engine-rewrap-masked-cause-1889.test.ts; integrecreate-via-cc-apigains Sub-3e, a recreate whose bucket delete a bucket policy denies. - A nested stack whose child failed to destroy keeps its state row whatever its logical id spells, and terminal intrinsic-resolution failures are declared non-retryable (issue #1889) --
src/provisioning/providers/nested-stack-provider.ts,src/deployment/intrinsic-function-resolver.ts,src/deployment/intrinsic-resolver/{getatt,params-conditions,functions,cross-stack,dynamic-ref-lookups,stack-output,stack-state,string-functions}.ts. The resolver's plain-Errorrefusals that no retry can change (an unsupported intrinsic, a malformedFn::GetAtt/Fn::Join/Fn::Select/Fn::Base64/Fn::Cidr/Fn::And/Fn::Or/Fn::Notargument, a missingFn::FindInMapmapping or key, an invalidFn::GetAZsregion or an empty zone list, everyFn::GetStackOutputargument check and a missing output of a producer it read, a dynamic reference whose secret or parameter has no usable value, a required parameter with no value) are now marked non-retryable. Several quote a template value, so a name containing a retryable pattern such asDependencyViolationread as transient to cdkd's retry classifiers; no retry loop wraps resolution today, so this is a declaration rather than a change a deploy shows. A relayed AWS failure stays retryable, and so do the cross-stack "export / stack not found" errors, which can follow a swallowed transient lookup failure.AWS::CloudFormation::Stack's child-failure error is now marked on both arms, not only when interrupted: previously a child logical id containingNotFoundExceptionmadecdkd destroyand a deploy that removes the nested stack read the failure as "already deleted" and drop the parent's row while the child's state survived. Tests:tests/unit/deployment/intrinsic-terminal-bare-throws-non-retryable.test.ts,tests/unit/provisioning/nested-stack-provider.test.ts,tests/unit/provisioning/nested-stack-provider-cycle.test.ts.
Recently Implemented (2026-10-06):
- The
NAMED_CREATE_COLLISIONrefusal ends on a pasteablecdkd importcommand (PR #4622) --src/deployment/deploy-engine/create.ts. When a plain create of an explicitly named resource meets an existing holder of that name, the error printed only "adopt it withcdkd import" and named a deploy interrupted earlier as the only own-resource case. It now names a resource acdkd destroykept underDeletionPolicy: Retaintoo, and ends onAdopt with: cdkd import <stack> --resource <logicalId>=<physicalId>for the holder it found. It is preceded by a line asking the reader to confirm the resource is theirs, and withheld for a nested-stack child, forAWS::S3::Bucket(whose lookup also finds a listable bucket of another account), and when the secret mask would rewrite any part of the command. Tests:tests/unit/deployment/deploy-engine-create-name-takeover-4180.test.ts. - Deleting a failed
AWS::SQS::QueuePolicyorAWS::SNS::TopicPolicyjournal entry no longer clears a queue or topic whose policy was replaced by a different one (issue #4612) --src/provisioning/providers/{sqs-queue-policy,sns-topic-policy}-provider.ts,src/provisioning/{region-check,policy-document-content}.ts,src/types/resource.ts,src/deployment/{rollback-executor,delete-outcome}.ts,src/deployment/rollback-executor/{types,journaled-orphans}.ts. A policy that wrote some targets and then failed is journaled under exactly those (#4583). The automatic rollback,cdkd rollback,cdkd destroyand a later successful deploy cleared (or reset) each by name, so a target another policy or an outside write had since replaced lost that policy. The failed-CREATE delete now marks the entry a proven orphan and hands over a lazy re-resolution of its redacted attempted bag; the provider clears a queue, or resets a topic to its default, only while its live policy matches the attempted document by content, IAM-equivalent spellings folded (SQS stores a bare account id as its root ARN). Anything else is left with a warning and reported (leftInPlace), so the rollback and the deploy exit2(cdkd destroywarns only); so is a target the settling deploy itself just wrote. A missing or masked document, or a secret reference known to fail (not found, or refused for good: a refusal cdkd marks non-retryable) settles the entry, every target named, nothing cleared; any other failure (an unreadable policy, credentials, throttling) keeps it as a failed delete. A gone target is region-checked first; a one-target entry no longer widens; a record's own delete and update are as before. Tests:tests/unit/provisioning/{sqs-queue-policy,sns-topic-policy}-provider-orphan-content-4612.test.ts,tests/unit/deployment/failed-create-orphan-context-4612.test.ts; integ Phase 1d ofsns-subscription-filter, Phase 2b ofsns-sqs-event. - An
AWS::SNS::TopicPolicyis now actually removed from a topic: on destroy, and from a topic an update drops fromTopics(issue #4610) --src/provisioning/providers/sns-topic-policy-provider.ts.delete()sent an emptyPolicy, which SNS would reject: CloudFormation's own TopicPolicy handler source says SNS requires a policy on a topic.delete()read anInvalid parameterrejection as "already removed", so a destroy could report the policy deleted and drop its record while every topic kept the statement. It now writes each topic its default policy, as that handler does, and anInvalidParameterfails the delete. A record whose id is not a list of topic ARNs (the policy name an old--migrate-from-cloudformationrecorded) falls back to the topic ARNs itsTopicslists. When neither names one, the destroy now refuses, naming the by-hand reset and thecdkd state orphan --resourceremedy.update()wrote only the newTopics, so a dropped topic kept the old statement; this is also what an in-place rollback ofTopics [T1] -> [T1,T2]left onT2. It now resets each topic the record's id names that the new list drops, after writing the new list. A topic only the previous properties list (the attempted list a revert of a failed update passes) is reset only while its live policy matches that bag'sPolicyDocumentby content. Tests:tests/unit/provisioning/sns-topic-policy-provider-dropped-topics-4610.test.ts; integ arm intests/integration/sns-sqs-event/. - A removed or orphaned resource is listed by its construct path, and
--require-approval=any-changeasks for an Outputs-only change (issue #4607) --src/types/state.ts,src/analyzer/destructive-changes.ts,src/deployment/deploy-engine.ts,src/deployment/deploy-engine/{deploy-flow,options}.ts,src/deployment/deployment-approval.ts,src/cli/commands/{import,require-approval}.ts. State records gain an optionalconstructPath(the template'saws:cdk:path, no schema bump), stamped on every deploy save and bycdkd import.cdkd diff --fail-on=destructiveand the--require-approval=destructivelist name a resource the template no longer declares by that recorded path (MyStack: AWS::DynamoDB::Table Table MyTable794EDED1 will be orphaned), where they showed only the logical ID. No save or S3 request is added: a stack written by an older release gains paths on its next deploy that writes state.--require-approval=any-changenow asks before a deploy with no resource change writes changed Outputs (or a changed export set), matching the AWS CDK CLI; it deployed those without asking. Tests:tests/unit/deployment/deploy-engine-construct-path.test.ts,tests/unit/deployment/deploy-engine-require-approval.test.ts,tests/unit/analyzer/destructive-changes.test.ts,tests/unit/cli/{import-build-state-policies,require-approval}.test.ts; integdiff-fail-on-destructive. - A replacement's new resource that was created and then failed is now deleted by every rollback path, and an in-place update that changed a physical id is now rolled back in place (issues #4604, #4615) --
src/deployment/deploy-engine{,/execute,/update-in-place,/rollback}.ts,src/deployment/rollback-executor{,/plan,/journaled-orphans,/replay-reverse-replacement}.ts,src/cli/commands/rollback.ts,src/types/rollback-journal.ts. A resource a create proved it made before failing (#1710) was journaled only on a first-time CREATE; a replacement's new one stayed in AWS, untracked. It is now journaled beside the replacement's UPDATE, naming the record it was replacing, and the automatic rollback,cdkd rollback,cdkd destroyand a later successful deploy that did not touch the logical id delete it per itsDeletionPolicywithout touching that record, and the failed UPDATE is never force-reverted (a rollback or destroy warns when a delete-first replacement already removed the old resource); a record that names anything else still skips it with a warning. A rollback's own re-create that leaves such a resource deletes it (kept and named underRetain/Snapshotor a failed delete). Separately, the rollback treated every completed UPDATE whose physical id changed as a replacement, so an in-place SQSQueuePolicyor SNSTopicPolicyupdate that moved its id was undone by re-creating the old policy and then deleting the "new" one, which removed the policy just written; the journal now records the provider'swasReplaced, and an in-place update is reverted in place (a journal from an older cdkd keeps the old reading). Tests:tests/unit/deployment/{replacement-create-orphan,deploy-engine-rollback-journal}.test.ts,tests/unit/types/rollback-journal.test.ts; integ Phase P intests/integration/rollback-command/. - A skipped DELETE during
cdkd deploynames a drop command for that one record, not the whole stack (issue #4602) --src/deployment/deploy-engine/delete.ts,docs/cli-deploy-safety.md. The deploy engine's warning for a delete a provider skipped ended onDrop the record with: cdkd state orphan <stack> --stack-region <region>, which drops the record of every resource in the still-deployed stack, so the next deploy re-creates or collides with all of them; it printed this beside the provider's own warning, which since #4609 names the--resourceform. The line now ends in--resource <logicalId>(a logical id that is not a plain identifier prints as a'<logicalId>'hole), and the warning says to keep--resourceon the drop. For a nested stack'sAWS::CloudFormation::Stackrow, where--resourcerefuses while the child has a record, theInspect it with:andDrop the record with:lines name the child's own record (<stack>~<logicalId>) and the warning says to delete by hand what the child still holds first, after which the next deploy removes the row (an interrupted child is resumed by re-running the deploy instead); a withheld child name prints as'<child-stack>'. Tests:tests/unit/deployment/deploy-engine-delete-skipped-journal.test.ts; integ:nested-stack-3level(Step 4c). cdkd state orphan <stack> --resource <logicalId>drops one resource's record without a CDK app, and provider skip warnings name it instead of the whole-stack form outside a destroy (issue #4602) --src/cli/commands/{state,orphan}.ts,src/analyzer/orphan-rewriter.ts,src/provisioning/{state-orphan-remedy,redacted-delete-address,composite-id}.ts,src/provisioning/providers/{custom-resource,cloudwatch-anomaly-detector,lambda-permission,iam-policy,iam-user-group,s3-bucket,ec2}-provider.ts, docs. No command removed one record of a deployed stack whose construct had left the template, so a skipped delete duringcdkd deployprintedcdkd state orphan <stack>, which drops the record of every resource in the stack. The new repeatable--resourceremoves only the named entries, rewrites theRef/Fn::GetAtt/Fn::Sub/dependencies/ outputs that other records hold to them ascdkd orphandoes (an unresolvable one refuses unless--force), takes the stack's lock (a held lock refuses,--forceincluded) and saves conditionally; it needs one stack and refuses an ambiguous region, an unknown id, and a row whose nested-stack child still has a record. The provider delete-skip warnings and refusals now name--stack-region <region> --resource <logicalId>outside a stack destroy, and the whole-stack form (with the--resourcealternative) only oncdkd destroy/cdkd state destroy; the SecurityGroupIngress update refusal always names--resource.cdkd orphan --forceandstate orphan --resource --forceno longer copy a cached value cdkd recognises as a credential (by name, a custom resource's attribute, a known secret-valued attribute), the redaction mask or an unresolved{{resolve:}}into another record or print it. Tests:tests/unit/cli/state-orphan-resource.test.ts,tests/unit/provisioning/state-orphan-remedy-4602.test.ts; integ arm intests/integration/retain-orphan-redeploy/.- A successful deploy now acts on a resource a failed CREATE made before it failed, instead of silently dropping the rollback journal that is its only record (issue #4600) --
src/deployment/deploy-engine/{rollback,deploy-flow}.ts,src/deployment/rollback-executor/journaled-orphans.ts,src/deployment/nested-child-journal.ts,src/state/s3-state-backend.ts. After #4584 the automatic rollback,cdkd rollbackandcdkd destroyacted on such a journaled resource, but a later successful deploy deleted the journal unconditionally, and a top-level deploy deleted each nested stack's journal the same way, leaving the resource in AWS untracked. Now the deploy deletes it per its journaledDeletionPolicy(Retainkeeps it,Snapshotsnapshots it) when, among the existing #1710 ownership checks, after the deploy no state record sits under its logical id, the deploy completed no operation under it, and no resource record of this stack, nor any resource or rollback-orphan record of another stack under the same state prefix, holds its type and physical id; a record of this stack holding that very resource tracks it, and the entry is dropped silently. Otherwise it warns, naming the physical id, drops the entry with the journal, and counts it with the skipped deletes, so the deploy exits2(--allow-unaddressedexits0); a fix-forward that keeps the logical id under another name lands here, and deleting the earlier resource there is follow-up #4606. An entry the deploy could not act on (a failed delete, an interrupt, an unreadable record) keeps the journal, reduced to it where it can, for the next successful deploy. Tests:tests/unit/deployment/{deploy-engine-rollback-journal,deploy-engine-nested-journal,nested-child-journal,journaled-orphans-success}.test.ts,tests/unit/state/{s3-state-backend,malformed-resources-bag}.test.ts; integ Phase O steps O6-O8 intests/integration/rollback-command/. - A custom-resource delete skipped during
cdkd deployno longer tells the user to runcdkd state orphan(issue #4596) --src/provisioning/providers/custom-resource-provider.ts,docs/cli-deploy.md. When a custom resource's delete was skipped (the handler answeredFAILED, the invoke did not complete, or the recordedServiceTokenwas the redaction mask or a{{resolve:...}}reference), the warning told the user to tear the resource down by hand and clear the stack's records withcdkd state orphan <stack> --stack-region <region>. That is safe oncdkd destroy, but on a deploy the stack is still deployed, so the command dropped the record of every live resource in it and the next deploy re-created or collided with all of them. The warning keeps that advice only oncdkd destroy/cdkd state destroy. Everywhere else it says to fix the handler (or restoreServiceTokeninstate.json) and re-deploy, which re-attempts the delete, that--allow-unaddressedlets a deploy exit 0 meanwhile, that a replacement or rollback whose other copy already exists leaves the surviving one untracked to tear down by hand, and not to runcdkd state orphanon a stack that is still deployed. The error for a recordedServiceTokenthat is not a string no longer offerscdkd state orphanon create, update or a deploy-side delete; on create and update it points at the template, which a deploy reads and a rollback re-applies by re-deploying, and on a delete it names restoring the ARN. The skip outcome and reason are unchanged, and a destroy's warning no longer carries the deploy-only text. Tests:tests/unit/provisioning/custom-resource-skip-remedy-4596.test.ts. AWS::SQS::QueuePolicynaming several queues is now cleared from every queue it wrote on destroy, and from a queue an update drops fromQueues(issue #4594) --src/provisioning/providers/sqs-queue-policy-provider.ts.cdkd destroycleared only the first queue (the physical id) and an update ignored the previous list, so the other queues kept granting what the policy granted. The physical id stays the first queue URL; create and update now also record every queue they wrote in acdkd:WrittenQueuesattribute, whichdelete()clears andupdate()uses to clear the queues the new list drops (a gone queue is tolerated). A record without the attribute (written before this change, or imported) needs no migration: its other listed queues are cleared only while they carry the recorded policy document, compared by content, because a failed create's revert (issue #4583) or a failed update's revert lists queues it may never have written; one that cannot be read is named in a warning. Tests:tests/unit/provisioning/sqs-queue-policy-provider-multi-queue-4594.test.ts; integ:sns-subscription-filter(two-queue policy shrink, grow, destroy).- The automatic rollback, a plain
cdkd rollbackandcdkd destroynow delete a resource a failed CREATE made before it failed, instead of dropping its only record (issue #4584) --src/deployment/deploy-engine/{rollback,execute}.ts,src/deployment/rollback-executor.ts,src/deployment/rollback-executor/{plan,journaled-orphans}.ts,src/cli/commands/{rollback,destroy-runner}.ts,src/state/{s3-state-backend,malformed-resources-bag}.ts. Since #1710 such a CREATE (Kinesis so far) is journaled with its physical id, but onlycdkd rollback --revert-failedacted on it: the automatic rollback left the resource in AWS, a plaincdkd rollbackpopped the entry, andcdkd destroyswept the journal with the state, after which nothing in cdkd named it. Now, as CloudFormation's rollback deletes a failed CREATE, the automatic rollback deletes it before reverting the completed operations; a plaincdkd rollbackreplays it as--revert-faileddoes (other failed operations still need the flag); andcdkd destroylists and counts it before the prompt, re-reads the journal under the lock, deletes it before the stack's resources and strips the entry, and never takes the unconfirmed empty-stack path while one is journaled (a journal it cannot read is still warned about and removed with the state). A later successful deploy still drops the journal without deleting it (#4600). Each honours the journaledDeletionPolicy(Retainkeeps it,Snapshotsnapshots it) and the #1710 ownership checks; a failed delete keeps the record (the full segment, or the state and journal on destroy).--no-rollbackdeletes nothing. Tests:tests/unit/deployment/deploy-engine-rollback-journal.test.ts,tests/unit/cli/commands/rollback.test.ts,tests/unit/cli/rollback-divergent-region.test.ts,tests/unit/cli/destroy-runner-journaled-orphans.test.ts; integ Phase O intests/integration/rollback-command/. cdkd rollback --revert-failednow deletes the resource SDK providers made before their create failed, not only Kinesis's (issue #4583, follow-up of #1710) --src/provisioning/providers/*.ts(among them EC2, ELBv2, Auto Scaling, IAM, Lambda, ECS, RDS, DynamoDB, S3, SNS, EFS, EMR, ACM, FSx, Pipes),docs/provider-rules.md,docs/cli-rollback.md. A follow-up call (tags, policies, a wait) failing after the create returned left the resource unrecorded. Providers now mark the thrown error with the iddelete()takes (markCreatedBeforeFailure), so the failed CREATE is journaled with it and--revert-faileddeletes it. A provider that already deletes its own resource in the catch marks only when that cleanup failed; a resource the create adopted or found held, the create call's own failure and a pre-flight refusal are never marked.AWS::IAM::Policyinstead removes the policy from the principals it had already written and warns with the commands when that fails;AWS::SNS::TopicPolicyandAWS::SQS::QueuePolicymark only the topics or queues they had written; anAWS::ElasticLoadBalancingV2::Listenercreate, and anAWS::ECS::Servicecreate under--full-wait, first read whether the port or service name was already taken, and such a resource is neither cleaned up nor marked;AWS::S3Express::DirectoryBucketresolves its ARN beforeCreateBucket. Limits: a failed CREATE journals one id, so a retried create with an AWS-generated id names only its last resource; no id returned (a Cloud Map operation that never finished, CreateTrail / CreatePolicy / RegisterStreamConsumer with none) means no mark. A nested stack is marked when reading its deployed child back fails (issue #4595). Tests:tests/unit/provisioning/*-created-before-failure-4583.test.ts, plus cases in the S3 bucket and SNS topic cleanup tests.cdkd diff --fail-onandcdkd deploy --require-approval, ported from the AWS CDK CLI (issue #4429) --src/analyzer/destructive-changes.ts(new),src/deployment/deployment-approval.ts(new),src/cli/commands/{diff,diff-recursive,deploy,require-approval,confirm-prompt,prefix-migration-check}.ts,src/cli/config-loader.ts,src/deployment/deploy-engine/{options,deploy-flow}.ts,src/deployment/resource-deadline.ts,src/utils/live-renderer.ts. Port of aws/aws-cdk-cli#2020, #2011 and #2021.cdkd diff --fail-on=never|any-change|destructivepicks which change exits 1;--fail/--no-failaliasany-change/never, combining either with--fail-onor repeating it is refused, defaultnever.destructivefails only on a replacement (will/may be replaced), deletion or orphaning (Retain/RetainExceptOnCreateremoval), always walks nested stacks, and lists them;--jsoncarriesdestructiveChangesper stack.cdkd deploy --require-approval=never|any-change|destructive(alsorequireApprovalin cdk.json, defaultnever) asks after the diff and before any provider call, counts a--recreate-via-*target as a replacement, fails without a terminal unless--yes, and serializes prompts across parallel stacks (the prefix-migration prompt too). A nested stack asks when its row is reached, with the row's--resource-timeoutpaused meanwhile. CDK'sbroadeningis not offered (no security diff); cdk.json'sbroadeningis ignored with a warning. Tests:tests/unit/analyzer/destructive-changes.test.ts,tests/unit/cli/{diff-fail-on,require-approval}.test.ts,tests/unit/deployment/{deployment-approval,deploy-engine-require-approval,resource-deadline}.test.ts; integdiff-fail-on-destructive.cdkd diff,import,scrub,export,destroyanddrift --revertmask a physical name derived from a secret on their resolver, delete, revert and diff-row lines (issue #3869) --src/deployment/secret-name-needles.ts(new, shared withdeploy),src/deployment/deploy-engine/masking.ts,src/deployment/intrinsic-resolver/{context,getatt,masking,functions,string-functions}.ts,src/cli/commands/{diff-recursive,import,scrub,export,destroy-runner,drift}.ts.cdkd deployalready masked a resource NAMED from a secret (its record spells the name as a{{resolve:reference) wherever another resource read it. The commands that resolve against state did not. Under--verbose,cdkd diff,import,scrubandexportprinted that name, or an ARN or URL embedding it, on the resolver'sresolved toline for aRef/Fn::GetAttto it, or as anFn::Base64encoding orFn::Splitpiece of one;cdkd diffalso printed it in a reader's rendered rows.cdkd destroyprinted it on the resource's own delete lines, on a deleted reader's lines (an access key'sUserName, an instance profile'sRoles, a role detaching a policy whose path came from a secret) and on the final-snapshot lines;cdkd drift --revertprinted it on a reverted reader's provider, retry and failure lines. These commands now record those reads into a print-only bag, so nothing they persist or decide changes, and each destroy delete and drift revert runs under a printing bag of its own needles and those it read.docs/deployment-events.mdnow says an event'sphysicalIdfield is stored as AWS returned it. Tests:tests/unit/deployment/secret-name-needles-3869.test.ts,tests/unit/cli/{destroy,diff,import,export,drift-revert}-secret-name-needles-3869.test.ts,tests/unit/cli/commands/scrub-secret-name-needles-3869.test.ts; integsecret-derived-immutable-namesgains acdkd diff --verbosestep and destroy assertions.- A physical name derived from a secret no longer prints in the lines of the resources that read it, or in the engine's own lines about it (issue #3869) --
src/deployment/deploy-engine.ts,src/deployment/deploy-engine/{masking,resolver-context,provision,create,update,outputs,update-in-place}.ts,src/deployment/intrinsic-resolver/{context,getatt,refs}.ts,src/deployment/resource-secrets-scope.ts,src/deployment/rollback-executor/names.ts,src/deployment/secret-redaction/mask-errors.ts. When a resource's name came from a secret or aNoEchoparameter (resolved this deploy, or still recorded as a{{resolve:reference after a rotation), aRef/Fn::GetAttto it resolves to a name or ARN no masker recognised.cdkd deploy --verboseprinted it on the resolver'sresolved toline, and the reading resource's provider lines, errors and events printed it too, a deleted reader's included. The read value and the name's spellings (the rollback'ssecretDerivedNamePairs) are now masked in those lines. The resource's own lines mask them too, from its create onward, includingDeleting old <id>andwas replaced: <old> -> <new>. No persisted property value, sent value or diff changes: the needles are print-only (a masked property's fingerprint can be refused more often, which fails closed). A nested-stack row, the outputs pass and the masked-input fingerprint pass record them into a print-only bag, so their decisions do not change. An event'sphysicalIdfield stays exact, and so does the id a failed CREATE journals for--revert-failed. Tests:tests/unit/deployment/{derived-name-needle-3869,deploy-engine-derived-name-needle-3869,deploy-engine-delete-guard-events,deploy-engine-rollback-journal,secret-redaction-error-masking}.test.ts. Integ:secret-derived-immutable-namesgains a queue policy and a role that read the secret-named queue and policy. cdkd deployno longer hands a resource record's tornpropertiesmap or missingphysicalIdto a provider (issue #3211, deploy half) --src/deployment/deploy-engine/{deploy-flow,update,delete,observed-capture}.ts,src/deployment/read-only-attribute-healer.ts,src/state/malformed-resources-bag.ts. A record whosepropertiesis absent,nullor not an object is now refused at the state load, naming the stack, before any provider call. Before, the refusal came from the diff, after the observed-state auto-refresh had already handed the map toprovider.readCurrentState. For a record whosephysicalIdis absent, empty, whitespace-only or not a string (other than a nested stack's not recorded on Cloud Control): a template removal skips the delete withskipped (state record has no physical id)and keeps the record, ascdkd destroydoes; before, the provider was called with that value, and a*NotFoundanswer read as already deleted dropped the record of a resource still live. An update of such a record now fails before anything is sent, instead of callingprovider.update(or a replacement's delete) with it. The auto-refresh and theFn::GetAttattribute heal (shared withcdkd diff) no longer read such a record. An unchanged record is not refused. A custom resource handler answering a whitespace-onlyPhysicalResourceIdnow takes the fallback an empty one always took (the logical id on create, the current id on update,src/provisioning/providers/custom-resource-provider.ts), so it is never recorded as an id later deploys would refuse. Tests:tests/unit/deployment/deploy-engine-unaddressable-record-3211.test.ts,tests/unit/deployment/deploy-engine-malformed-properties-refusal.test.ts,tests/unit/deployment/read-only-attribute-healer.test.ts,tests/unit/provisioning/custom-resource-unaddressable-physical-id-3211.test.ts.cdkd driftstops reading a stack after 5 reads in a row fail, instead of paying the same failure once per resource (issue #2207) --src/cli/commands/drift.ts,docs/cli-drift.md. An expired token, a revoked role or a permission every read needs used to be read, and backed off, once for every resource of every stack before the run exited 2 anyway. Once 5 reads in a row in one stack throw through the same read path (every Cloud Control read, whether the resource is managed through Cloud Control or falls back to it, or a read through one of cdkd's own SDK providers), cdkd reads nothing more that way in that stack and reports each remaining resource it would have read so under the newnotComparedcausereadAborted(cdkd never read it: ...), with one warning saying why (a burst of throttling can trip it too, and a re-run clears that); the exit code is still 2. A read that returns resets the count for both paths, so one unreadable resource among readable ones is still the per-resourcereadFailedit was, and the rest of the stack is compared; a comparison that throws after a good read, and a type with no Cloud Control READ handler, reset it too. The count is per stack, so other stacks in an--allrun are still read. Tests:tests/unit/cli/drift-per-resource-failure.test.ts,tests/unit/cli/drift-outcome.test-d.ts.cdkd localresolves CloudFormation dynamic references before a container starts (issue #2056) --package.json(cdk-local^0.149.9),src/cli/commands/{local-invoke,local-start-api,local-invoke-agentcore,local-run-task}.ts,src/local/{container-pool,ecs-task-runner,ecs-task-resolver}.ts. A container env value carrying{{resolve:secretsmanager:...}}/{{resolve:ssm:...}}/{{resolve:ssm-secure:...}}reached the container as the token text, including a--from-stateFn::ImportValue/Fn::GetStackOutputof a secret-bearing output, which cdkd persists redacted to its token (#1899).local invoke,local start-api,local invoke-agentcoreandlocal run-tasknow resolve it with the developer's credentials (--profilehonoured, never the--role-arnrole:src/local/dynamic-reference.ts) through cdk-local's resolver (go-to-k/cdk-local#784): a cross-stack value in the producer's region, any other in the owning stack's. A resolved key is a value-less-e KEY, off thedocker runargv and every log line; an--env-varsoverride skips the lookup; a failed lookup fails the command before any container starts, naming the reference and the IAM permission.local run-taskrefuses a token inCommand/EntryPoint/ a health-check command. The engine commands (start-agentcore,start-cloudfront,start-service,start-alb) get the same behaviour from thecdk-localbump. Tests:tests/unit/cli/{local-invoke,local-container-env,local-run-task}-dynamic-reference.test.ts,tests/unit/local/ecs-task-runner-dynamic-reference.test.ts,tests/unit/local/container-pool.test.ts; integlocal-invoke-from-state.
Recently Implemented (2026-10-05):
- A masked
UserData-style property that reads a nested stack's output is now redeployed when a clean output's value changes behind unchanged template text (issue #4565) --src/deployment/masked-property-fingerprints.ts,src/deployment/nested-output-templates.ts(new),src/deployment/deploy-engine/{deploy-flow,resolver-context}.ts,src/deployment/deploy-engine.ts,src/cli/commands/diff-recursive.ts. The #4543 input fingerprint kept everyFn::GetAtt [Child, Outputs.X](and${Child.Outputs.X}) as written, so replacing the resource behindXcompared***with***and the parent kept the old script under a green deploy. Each output is now classified from the cloud assembly's templates, never from child state, so the deploy andcdkd diffagree: a passed parameter takes the class the parent gives its value, an unpassed one itsDefault, and aNoEchoparameter, a{{resolve:...}}reference, a cross-stack read, an undeclared name, a custom resource's attribute or a condition over a secret keep the output as written, as does an unreadable or cyclic tree. Grandchild outputs are followed. A clean output is hashed with the value the parent recorded, through the existing secret checks. Values a parent passes from a sibling's clean output are classified clean too. A resource whose own definition reads a nested output is still treated as reading a secret. Tests:tests/unit/deployment/masked-nested-output-classes-4565.test.ts,tests/unit/deployment/deploy-engine-base64-secret-noop.test.ts,tests/unit/cli/diff-masked-input-fingerprint-4543.test.ts; integ Step 4e intests/integration/nested-stack-3level/. cdkd rollback --revert-failedwarns and exits 2 when a failed CREATE recorded a resource state no longer tracks (issue #4552) --src/deployment/rollback-executor/plan.ts,src/deployment/rollback-executor/types.ts,src/deployment/rollback-executor.ts,src/cli/commands/rollback.ts. A failed CREATE whose recorded physical id differs from the current state record, with nocdkd importmark on its segment (an import by a cdkd older than #4547), was planned asfailed CREATE left nothing to revert, logged at INFO, and the run exited 0, although the resource the op recorded may still exist, untracked. It now classifies as the newskip-failed-mismatch: the plan line names the recorded physical id (masked when secret-derived) as not the resource state tracks and needing manual attention, and the replay warns, records aROLLBACK_RESOURCE_SKIPPEDevent, and counts a warning, so the run exits 2, like a completed op'sskip-mismatch. Nothing is deleted and the state record is left as it is. The import-marked (displaced) path and the true no-op (a failed CREATE whose record is already gone, a failed DELETE) are unchanged. Tests:tests/unit/cli/rollback-imported-ops.test.ts,tests/unit/deployment/rollback-executor-skip-events.test.ts,tests/unit/deployment/rollback-executor.test.ts,tests/unit/deployment/rollback-executor-inline-policy-claims.test.ts.- A masked
UserData-style property is now redeployed when an input it reads changes behind unchanged template text: a parameter value, aRefto a resource the deploy replaced, a flipped condition, in nested stacks too (issue #4543) --src/deployment/masked-property-fingerprints.ts,src/deployment/deploy-engine/{update,create,deploy-flow,resolver-context,options}.ts,src/deployment/deploy-engine.ts,src/provisioning/providers/nested-stack-provider.ts,src/analyzer/diff-calculator.ts,src/cli/commands/diff-recursive.ts,src/types/state.ts. The #4451 fingerprint hashed template text only, so these compared***with***and the deploy kept the old script under a green run. A new field,maskedPropertyInputFingerprints, hashes the template value with each non-secret input resolved, bound to the unchangedmaskedPropertyFingerprintstext hash, which an older cdkd keeps reading. ANoEchoparameter, a{{resolve:...}}reference, what reads either, a custom resource's attribute, an input whose resolution read a secret, a nested stack's parent-passed value the parent built from any of these, and one the parent did not classify (a rollback replay, even when it equals theDefault; the next classified deploy re-sends it once) stay in template form; a newNoEchovalue is therefore still not sent through the mask. A record without the input field is re-baselined from today's inputs without sending.cdkd difflabels the row[masked input or expression changed](was[template expression changed]). Tests:tests/unit/deployment/masked-input-{fingerprints,sources}-4543.test.ts,tests/unit/deployment/deploy-engine-base64-secret-noop.test.ts,tests/unit/cli/diff-masked-input-fingerprint-4543.test.ts; integ Phase 1b2d intests/integration/secrets-dynamic-ref/and Step 4d intests/integration/nested-stack-3level/. - A Scheduler Schedule created or imported from this version on, whose
GroupNamecomes from a secret, can be updated in place and destroyed; a rotated secret can no longer make--replaceor a rollback write a schedule or DB proxy target group into another environment (issue #4275) --src/provisioning/providers/scheduler-schedule-provider.ts,src/provisioning/providers/rds-dbproxy-targetgroup-provider.ts. State keeps a secret-derived group as its{{resolve:...}}reference and the schedule's ARN embeds the group, so every update of such a schedule was refused and its destroy skipped it. cdkd now records each schedule's AWS creation date (attributecdkd:CreationDate) and checks it, with the recorded target ARN and role, against the schedule the group the secret resolves to holds: an update or rollback revert goes ahead only on a match, and a destroy finds the schedule by them in any group. A secret rotated onto another schedule, or onto no schedule under the same reference, is refused without the--replacefallback; a re-pointed reference stays a group change. A record from before this version with a secret-derived group stays refused and skipped: delete the schedule by hand, drop its record withcdkd orphan, and deploy again. A DBProxyTargetGroup whose proxy name now addresses another target group (or none), on a deploy or a rollback revert, is refused the same way: underUpdateReplacePolicy: Retainthe replacement only created, registering the targets on the proxy the rotated secret named. Tests:tests/unit/provisioning/{scheduler-schedule-provider,secret-reference-immutable-sites}.test.ts,tests/unit/deployment/deploy-engine-replace.test.ts; integ arm intests/integration/secret-derived-immutable-names/. - cdkd no longer prints a secret-derived Cloud Control identifier in a Cloud Control create's or delete's log lines, or in the error a create, update or delete throws (part of issue #3869) --
src/provisioning/cloud-control-provider.ts(IdScrubLog, installed per call so every line the call prints, its helpers' included, goes through it); unit tests intests/unit/provisioning/cloud-control-provider-create-delete-id-masking.test.ts, and thesecret-derived-immutable-namesintegration fixture now asserts its deploy and destroy logs. A Cloud Control identifier can carry a name taken from a secret (AWS::Logs::MetricFilteris<LogGroupName>|<FilterName>), and a pre-rotation or AWS-derived spelling of that name is one the deploy's masker cannot recognise. The update path already withheld it in its lines; nowcreate()withholds the identifier Cloud Control returns (or a failed create's error names) when the desired properties hold a secret-derived value,delete()withholds it when the recorded properties hold a{{resolve:reference or***, and the error leaving any of the three has the identifier and each|part replaced by***in every message of its cause chain and in itsphysicalIdfield, AWS status text included; an abandoned wait's request token and resume command are kept whole. A CC delete's skipped and indeterminate-guard reasons are scrubbed too. The identifier the call returns is unchanged, and a withheld create's name-collision advice is dropped rather than printed. Residual: a delete handed resolved plaintext (an in-process rollback's) has no evidence to withhold on, a failed create whose error names no identifier cannot be scrubbed of it, and the engine-level registry, the engine's own lines, persisted events'physicalIdfield,import()'s two unreadable-model WARNs and other providers' lines stay open under #3869. cdkd destroy/cdkd state destroyno longer hand a resource record's tornpropertiesmap or missingphysicalIdto the provider's delete (issue #3211, destroy half) --src/cli/commands/destroy-runner.ts,src/state/malformed-resources-bag.ts. A record whosepropertiesis absent,null, a string, a list, a number or a boolean is now refused before the lock, naming each such resource and ending on acdkd state showcommand; nothing is deleted or written for the stack. Before, the value reachedprovider.deleteas it stood, where it decides the RDS final snapshot and whether an ECR repository is emptied first. A record whosephysicalIdis absent, empty, whitespace-only or not a string (other than a nested stack's, whose delete never reads it) is now skipped per resource withskipped (state record has no physical id), its record kept and state preserved. Before, the provider was called with that value, and a resulting*NotFoundread as already deleted dropped the record of a resource still live in AWS. The deploy half of the issue stays open. Tests:tests/unit/cli/destroy-runner-malformed-fields.test.ts,tests/unit/cli/destroy-runner-malformed-entries.test.ts,tests/unit/state/malformed-resources-bag.test.ts.cdkd scrub's "No plaintext secrets found" lines now say what they cover, and a secret set out of band is documented as recorded by design (issue #2868) --src/cli/commands/scrub.ts,docs/{import,state-management,cli-scrub,cli-drift,getting-started}.md,SECURITY.md. The drift baseline (observedProperties) records what AWS returns. That includes a value the template never references, such as a password an operator set in the console over a placeholder literal, or any key of a Cloud Control model. Secret redaction works only where the template spells a{{resolve:...}}reference, so such a value is stored as AWS returned it. That is by design, and for a property the template sets, CloudFormation's drift detection likewise reports the actual value. Scrub learns a secret's value only by resolving a reference, so it cannot see such a value. Its per-stack and summary no-finding lines used to read as a clean bill for the whole record. They now end with(scrub checks only values the template names through a {{resolve:...}} reference), and the--failhelp text says the same.docs/import.mddocuments the population, whystate.jsonis sensitive by construction, and how to remove such a value: rotate it through a reference and deploy, or edit the record by hand, then runcdkd scrub <stack> --purge-history. Tests:tests/unit/cli/commands/scrub-all-refusal-boundary.test.ts,tests/unit/cli/commands/scrub-nested-child.test.ts.cdkd events prune --allandcdkd destroy --purge-eventsnow purge every earlier version under the stack'sdeployments/prefix, including streams an earlier delete left behind a delete marker (issue #2624, site 3 residue) --src/state/s3-noncurrent-version-purge.ts(purgeNoncurrentVersionsUnderPrefix),src/state/s3-state-backend.ts,src/state/deployment-events-store.ts,src/cli/commands/events.ts,src/cli/commands/destroy.ts,docs/deployment-events.md,docs/cli-destroy.md,docs/cli-events.md,docs/ci-per-pr.md,docs/state-management.mdand the plugin skill doc (plugin 0.7.9). Before, the purge acted only on the keys a run deleted, found by an ordinary listing, so a stream deleted by an earlier release or by a purge that warned stayed readable with aVersionIdand the output was scoped to "the deleted keys". Because--allempties the whole directory, it now walks every version under the exact<state-prefix>/<stack>/<region>/deployments/prefix, keeping each key's current version, and runs even when nothing current is listed. Region discovery reads current keys only, so for a region where none of the stack's history is current any more,cdkd events pruneneeds--stack-region, and its "No deployment-event history found" error now says so.--stack-regionis now lower-cased like every other command's, and when nothing current is left the summary claims a purge only if the sweep removed an earlier version with content (not just a delete marker), says none were found in that region if it removed nothing, and says the versions could not be purged if it failed. The trailing/keeps a stack whose name extends this one's out of the sweep, and a prefix without one is refused.--keep/--older-thanand the writer's self-bounding prune are unchanged and still purge only the keys they delete. Withouts3:ListBucketVersions/s3:DeleteObjectVersionit still succeeds and warns.cdkd scrubpurges the earlier versions of everystate.jsonand exports index it rewrites, and--purge-historypurges every examined record's (issue #2624) --src/cli/commands/scrub.ts,docs/cli-scrub.md. On a versioned state bucket, scrub'sPutObjectused to leave the pre-scrub body readable byVersionId. A real run now purges the noncurrent versions of eachstate.jsonit rewrites, including a nested child's record, a parent row repaired for a child output, and a pre-region-layout record, which is now migrated (it used to fail the rewrite) with its old key's history purged (a surviving or unverifiable old key fails the stack). It also purges{state-prefix}/_index/{region}/exports.jsononce per region whose entries it rewrote. The purge reuses the state backend's fail-softpurgeNoncurrentVersions(missing version grants or a replicated bucket only warn, above the summary). The purge also runs after an ambiguous or SDK-retried write failure, never after a definite refusal on the first attempt. TheDone: scrubbed ...line says the earlier versions were purged "unless a warning above says otherwise". A record scrub did not rewrite keeps its history unless the new--purge-historyflag is passed, which purges every examined record's earlier versions (each target and nested stack with a readable record, never a refused one) and is refused with--dry-run, which purges nothing. A plaincdkd deploynever purges, because those versions are the state-recovery history; the docs now say its implicit scrub leaves earlier versions that may hold the plaintext, removed bycdkd scrub --purge-history. Tests:tests/unit/cli/commands/scrub-purge-state-versions.test.ts,tests/unit/cli/commands/scrub-export-index.test.ts,tests/unit/cli/commands/scrub-all-refusal-boundary.test.ts,tests/unit/cli/commands/scrub-nested-child.test.ts.cdkd deployand rollbacks persist a pre-flight safety guard that could not reach a verdict (issue #2422) --src/deployment/delete-guard-scope.ts(new),src/deployment/deploy-engine/provision.ts,src/deployment/deploy-engine/{delete,replacement,update-in-place,update-replace}.ts,src/deployment/rollback-executor/messages.tsand the rollback delete arms,docs/deployment-events.md. A delete whose guard could not answer (for example anAWS::S3::Bucketrouted through Cloud Control whoses3:GetBucketLocationis denied) recorded aRESOURCE_GUARD_INDETERMINATEevent only undercdkd destroy/cdkd state destroy; the same delete on the deploy path (template removal, replacement,--recreate-via-*, replaced-resource cleanup, update-not-supported fallback) or in a rollback left only the live warning. Both now record the same event, with the destroy runner's payload andoperation: DELETEeven when the delete is a step of an UPDATE. The deploy engine emits it from one place per resource, before the row's outcome event, including when a later step of the same row fails; the rollback executor emits it from the helper every delete arm already calls, before a'skipped'outcome is turned into a failure. The physical id and reason are masked with the resource's own secrets. No summary count is added and noresultchanges. Tests:tests/unit/deployment/deploy-engine-delete-guard-events.test.ts,tests/unit/deployment/rollback-executor-delete-guard-events.test.ts.cdkd destroynow reports a custom resource whose backing Lambda is gone as skipped, keeping its state record, instead of treating it as already deleted (issue #2115) --src/provisioning/providers/custom-resource-provider.ts,src/provisioning/region-check.ts(DeleteContext.stackDestroy),src/cli/commands/{destroy,state,destroy-runner}.ts,src/provisioning/providers/nested-stack-provider.ts(a nested child inherits the flag only from a stack destroy). The issue-#804GetFunctionpre-check used to answerResourceNotFoundExceptionby dropping the record and lettingcdkd destroyexit 0. Its usual caller was the second destroy after a handler refused itsDelete(#2054): the first run kept the record but deleted the backing Lambda, and the second dropped the record over a resource that was still live. On a stack destroy (cdkd destroy/cdkd state destroy) the pre-check now returns a skip (backing Lambda function is gone — Delete handler not invoked), so the record is kept and the destroy exits 2. This matches CloudFormation, where a custom resource whose delete cannot be confirmed leaves the stackDELETE_FAILEDuntil you retry withRetainResources. Compatibility: a shared provider stack destroyed before the stacks that use it now makes their destroy exit 2. Redeploy a function at that ARN and re-run, or confirm what the handler manages is gone and drop the records withcdkd state orphan <stack> --stack-region <region>. Acdkd deploydelete (template removal, replacement, rollback) keeps the warn-and-drop, as CloudFormation ignores delete failures in an update's cleanup phase. The pre-check is still fail-fast: oneGetFunctioncall, no 10-minute waiter. Docs:docs/cli-destroy.md,docs/troubleshooting.md. Tests:tests/unit/provisioning/custom-resource-delete-failed-response.test.tsand its CR siblings; integ phase 4 oftests/integration/custom-resource-provider/.- An EC2 VPC, internet gateway or Elastic IP create that hits a 5xx now reports candidate orphans instead of duplicating silently, and a subnet or security group create surfaces its replayed collision as possibly its own (issue #2080, Plan B:
CreateVpc,CreateSubnet,CreateInternetGateway,AllocateAddress,CreateSecurityGroup; the SDK-5xx half of #3978 layer (b) for these creates) --src/provisioning/providers/ec2-provider.ts. None of the five carries an idempotency token, so a 5xx whose request had succeeded left a second VPC, gateway or address (an unassociated Elastic IP keeps billing) with no state record, unreported when the AWS SDK replayed it inside one call. All five now refuse the SDK's 5xx retry. After an ambiguousCreateVpc,CreateSubnet,CreateInternetGatewayorAllocateAddress, cdkd's retry first lists untagged candidates the failed attempt may have made -- VPCs with the same CIDR, subnets with the same CIDR in the same VPC, detached internet gateways, unassociated addresses of the same domain -- and warns with adescribe-*read command and no delete command, since EC2 reports no creation time for them. Nothing is adopted or deleted; a throttle or 4xx triggers no lookup, and a failed lookup warns and lets the create proceed. A subnet CIDR and a security group name cannot repeat in a VPC, so those replays fail withInvalidSubnet.Conflict/InvalidGroup.Duplicaterather than duplicating; the group's collision is no longer credited to another holder. A reset or timeout after the send is still retried inside the SDK. Tests:tests/unit/provisioning/ec2-create-retry-2080.test.ts. - A PUBLIC
{{resolve:ssm:...}}reference inside a longer value of a LEGACY state record now keeps the value AWS holds in the drift baseline written bycdkd state refresh-observedandcdkd drift --accept/--revert(issue #2036) --src/deployment/public-ssm-proof.ts(new),src/deployment/secret-redaction/{mask-only,redact-path,readback-certification}.ts,src/deployment/intrinsic-resolver/dynamic-refs.ts,src/cli/commands/state.ts. Only a legacy record carries such a reference in its properties: one imported before the #2944 refusal marker, written by an older binary, or edited by hand. A current deploy stores it resolved, and a current import refuses that record's baseline. With no secrets map these commands could not tell a publicStringparameter from aSecureString, so the leaf always took its expression and the baseline disagreed with AWS. A leaf now keeps the readback only when EVERY reference in it is a plainssmone that this record's own pass PROVED public, AND the readback is exactly the source with each proven CURRENT value in place, so a type read today never vouches for a value resolved earlier or in another region. The proof is filed per record, never for the whole process (the cross-region disclosure PR #2415 withdrew).cdkd driftgets it from its own resolution of the record.cdkd state refresh-observedasksssm:GetParameterwithWithDecryption: falsein the stack's region, and gives a nested child whose ancestors' reads cannot be established no proof. Any other answer, an error or a missing permission keeps the expression, as before. Tests:tests/unit/deployment/{public-ssm-proof,secret-redaction-public-ssm-proof}.test.ts,tests/unit/cli/{state-refresh-observed,drift-secret-redaction}.test.ts; integ Phases 1f3 and 1f4 intests/integration/secrets-dynamic-ref/. cdkd rollback --revert-failednow deletes a resource whose CREATE succeeded at AWS and then failed (Kinesis so far) (issue #1710) --src/provisioning/auxiliary-failure.ts,src/deployment/deploy-engine/execute.ts,src/deployment/{rollback-executor,retry,prior-attempt-scope}.ts,src/deployment/rollback-executor/{plan,types}.ts,src/provisioning/providers/kinesis-provider.ts,src/cli/commands/rollback.ts,src/types/rollback-journal.ts. A provider that failed after its create call returned (a Kinesis stream whose retention or encryption follow-up AWS rejected) left the resource in AWS with no state record, and the failed op was journaled with no physical id, so--revert-failedskipped it and a retry collided with the leftover name. A provider now marks the error once its create call has returned (markCreatedBeforeFailure); the deploy journals that id withphysicalIdRecoveredFromErrorand the template'sDeletionPolicy, and--revert-faileddeletes, retains or snapshots it accordingly, only while nothing later owns it: no state resource or rollback-orphan record holds its ids, no newer entry of its type names its id or completed a CREATE, and no later removal superseded it; otherwise it is skipped, warned where it may be untracked.ProvisioningError.physicalIdis never trusted: providers also set it on a collision with another owner's resource. A retry carries the mark forward. Kinesis is the first provider to mark; it also now refuses a non-numericRetentionPeriodHoursor a KMSStreamEncryptionwithout aKeyIdbeforeCreateStream, and sends a numeric-string retention as a number; its update refuses a non-numeric retention too. Tests:tests/unit/deployment/{failed-create-orphan,deploy-engine-rollback-journal,retry-replayed-create-collision-3972}.test.ts,tests/unit/provisioning/kinesis-provider-metrics-and-record-size.test.ts; integ Phase O intests/integration/rollback-command/.
Recently Implemented (2026-10-04):
- The startup compile cache no longer grows with every cdkd upgrade (issue #4544) --
src/cli/compile-cache.ts. Node never removes a compile-cache entry, so each release cdkd ran added its newly compiled chunks to~/.cache/cdkd/compile-cache(or$XDG_CACHE_HOME/cdkd/compile-cache) and kept the old ones forever. The cache now lives in a per-version subdirectory, and a run deletes every other entry under the cache directory (older versions' subdirectories and the version-less layout 0.294.8 wrote) synchronously, so a command that exits at once still finishes the cleanup. Steady-state startup is unchanged; the delete costs time only on the first run after an upgrade. cdkd drift <Parent>checks the parent's nested stacks too, so a resource deleted or changed inside one exits 1 (issue #4533) --src/cli/commands/drift.ts,docs/cli-drift.md,docs/design/459-nested-stacks.md. A nested stack's resources are recorded as their own<parent>~<child>stack, which drift compared only when named exactly or under--all, socdkd drift Parentprinted the parent'sAWS::CloudFormation::Stackrow asdrift unknownand exited 0 over a deleted child resource. Naming a stack (or the single-stack auto-select) now also checks every nested stack below it in the same region, after the parent, each in its own block and each record once, ascdkd diffandcdkd destroyact on nested stacks. The auto-select counts a parent with its nested stacks as one stack. The nested row is nowskippedwhen its record is checked, anddeleted(exit 1, printed asRECORD MISSINGnaming the record,nestedStackRecordMissing: truein--json) when that record is gone from state; a parent holding only nested stacks printsno drift detected hererather thanNOTHING was compared;--accept/--revertrefuse it by name and act on each nested stack's drifted resources under that record's lock.--jsonchange: the row moves fromnotSupportedtoskipped, or todeleted. Tests:tests/unit/cli/drift.test.ts; integ arm intests/integration/nested-stack/verify.sh.- A cross-stack secret read embedded beside two or more parts the template cannot state, or under an
Fn::If, now persists its OWN{{resolve:...}}expression, not a sibling's (issue #4527, the residual of #2298) --src/deployment/intrinsic-function-resolver.ts,src/deployment/intrinsic-resolver/sub.ts,src/deployment/intrinsic-resolver/string-functions.ts,src/deployment/secret-redaction/pairs.ts,src/deployment/secret-redaction/placeholder-positions.ts. A leaf such asFn::Sub 'jdbc:mysql://${AWS::Region}.${AWS::AccountId}.host/?pw=${Child.Outputs.Pw}', the same throughFn::Join, or anFn::Ifselecting such a leaf, was still redacted by the plaintext-keyed value scan: the #2298 fix aligned the template against the resolved string and allowed at most one part of unknown text. When another leaf of the same resource resolved to the same plaintext (one secret at two version-stage spellings), the embedded leaf was persisted with the OTHER reference, which a rollback orcdkd drift --revertwould re-resolve and push to AWS as the wrong secret version. No plaintext was exposed. The resolver now records where eachFn::ImportValue/Fn::GetStackOutput/Fn::GetAttread (and each${Res.Attr}placeholder) landed on anFn::Sub/Fn::Joinoutput, keyed as the association store keys that read, and the persist path answers each such span from that read's own association, as it already did for a parameterRef. A resource whose record holds the sibling's expression can take one UPDATE on the first deploy after upgrading (a replacement if the property is create-only), even though the value sent is unchanged. Tests:tests/unit/deployment/secret-redaction-cross-stack-embedded.test.ts; integ armMultiUnknownSecretPairintests/integration/nested-stack-secret/. cdkd rollbackno longer deletes a resourcecdkd importadopted after the failed deploy (issue #4523) --src/cli/commands/import.ts,src/state/s3-state-backend.ts,src/types/rollback-journal.ts,src/cli/commands/rollback.ts,src/deployment/nested-child-journal.ts. The import did not touch the rollback journal. If a kept journal segment held a completed CREATE of an explicitly named resource, and the user then re-created that name by hand and imported it under the same logical id, the replay saw a physical id equal to the imported record's and deleted the resource the user had just adopted. Before it writes state, the import now records each adopted resource (logical id, physical id and type) in a new additiveimportedResourcesfield on every segment that holds an operation of that logical id, and marks the id superseded on the newest segment.cdkd rollbackand the nested-child replay then run none of those segments' operations for the id, failed ones included. An operation that recorded the adopted resource is listed asadopted by cdkd import after this deploy. One that recorded another resource under the id (or a replacement whose old resource it may have kept, which is then the id named) is listed with its physical id as not reverted, records aROLLBACK_RESOURCE_SKIPPEDevent, warns and makes the rollback exit 2. Completed operations of an id passed to--orphanare not covered. Segments that later deploys add carry no mark and replay as before. The import now refuses, and writes no state, when it cannot read or write the journal. Tests:tests/unit/cli/rollback-imported-ops.test.ts,tests/unit/cli/commands/rollback.test.ts,tests/unit/state/s3-state-backend.test.ts,tests/unit/cli/import.test.ts,tests/unit/deployment/nested-child-journal.test.ts,tests/unit/types/rollback-journal.test.ts; integ arm R intests/integration/import-retain-destroy/.- Installing cdkd no longer pulls in
aws-cdk-lib, and every command starts faster (issue #4521) --package.json. cdk-local moves to^0.149.8, which makes itsaws-cdk-lib/constructspeers optional and loads@aws-cdk/toolkit-libon first synth instead of at module load. A clean install of the packed cdkd shrinks from 453 MB to 254 MB ofnode_modules, andcdkd --helptakes 510 ms of CPU instead of 659 ms (Node 24, median of 9, compile cache off). A CDK app still resolvesaws-cdk-libfrom its own project, as before. - Every command starts about a quarter faster from the second run: cdkd enables Node's compile cache (issue #4521) --
src/cli/index.ts,src/cli/compile-cache.ts. The entry callsmodule.enableCompileCache()before importing the command tree, so V8 reuses compiled code for cdkd's chunks and the externalised@aws-sdk/*packages instead of compiling them on every run (cdkd --help: ~890 ms to ~680 ms wall-clock with a warm cache). The cache lives in the user's own cache directory ($XDG_CACHE_HOME/cdkd/compile-cacheor~/.cache/cdkd/compile-cache), never Node's default under the shared OS temp dir, where another local user could plant entries Node validates only by checksum; with no home directory it stays off;NODE_DISABLE_COMPILE_CACHE=1turns it off andNODE_COMPILE_CACHEmoves it (CLI Reference, "Startup compile cache"). The--versionfast path is unchanged. Tests:tests/unit/cli/version.test.ts,tests/unit/cli/compile-cache.test.ts. - Every command starts ~70 ms of CPU faster:
graphqlandadm-zipload on first use (issue #4521) --src/provisioning/providers/appsync-provider.ts,src/provisioning/providers/codecommit-repository-provider.ts.registerAllProviders()evaluates every provider module on every command, and both providers imported a dependency at module scope that only one rarely taken path needs: graphql-js for the AppSyncGraphQLSchemadrift read, adm-zip for a CodeCommit repository seeded fromCode.S3. Both are now imported where they are used; a failure to load graphql is no longer reported as an unparseable SDL. Tests:tests/unit/provisioning/lazy-optional-deps.test.ts. - Commands that never touch a provider start ~270 ms of CPU faster: the SDK provider classes load when a command registers them (issue #4521) --
src/provisioning/register-providers.ts,src/provisioning/provider-classes.ts,src/cli/commands/{deploy,destroy,destroy-runner,diff,drift,import,orphan,rollback,state}.ts,src/provisioning/providers/wafv2-arn.ts,src/provisioning/providers/wafv2-provider.ts,src/deployment/intrinsic-resolver/ref-values.ts,scripts/diagnose-schema-refresh.mjs. Every command module that registers providers is reached statically from the command tree, and each provider module evaluates its@aws-sdk/client-*package, socdkd --help,synthandlistevaluated all of them. The classes now load throughloadProviderClasses()(one dynamic import, once per process), awaited at the start of each command function so registration stays synchronous inside the per-stack AWS client scope; every type is still registered. A helper the intrinsic resolver imported from the WAFv2 provider moved to a leaf module.cdkd --help: ~1140 ms to ~870 ms CPU. Tests:tests/unit/provisioning/lazy-provider-classes.test.ts. - The published package shrinks from 8.2 MB to 1.8 MB (29.8 MB to 8.5 MB unpacked) (issue #4521) --
vite.config.ts. The source maps no longer embed a copy of everysrc/*.tsfile (sourcemapExcludeSources), and JSDoc blocks are dropped from the emitted JS. The emitted code is token-for-token unchanged; line numbers indist/shift where JSDoc was removed, while the maps keep their mappings, sonode --enable-source-mapsstill reports the samesrc/*.tslines. - A
--revert-failedrollback that re-creates anAWS::EC2::SecurityGroupIngressrule no longer keeps the revoked rule'ssgr-id (issue #4484) --src/provisioning/providers/ec2-provider.ts. When the replay re-authorized a rule the refused update had already revoked, it answered an in-place update, so a response naming no single rule id left the revoked id underattributes.Idforcdkd exportandFn::GetAtt <rule>.Idto read. It now answers a replacement, so only an id found now is recorded: the one the response names, else the one rule matching it on the group, else none. A live rule the replay adopts is recorded the same way, since an earlier send of the same replay may have created it. Tests:tests/unit/provisioning/ec2-sg-ingress-duplicate-ownership.test.ts. cdkd drift --revertrecords the physical id and attributes the revert's update returned (issue #4476) --src/cli/commands/drift.ts,docs/cli-drift.md. The revert persisted only the narrowedeffectivePropertiesdelta, so a provider that re-creates on update left the record naming what it removed: reverting a driftedAWS::EC2::SecurityGroupIngressrevokes and re-authorizes the rule under a newsgr-id, while the record kept the revoked one forcdkd exportand a laterFn::GetAtt <rule>.Id. The record now takes the returned identity exactly as the rollback replay does (go-to-k/cdkd#4434): attributes replaced wholesale when the update replaced the resource, merged key-wise when it updated in place, untouched when none were returned, and the physical id followed on a replacement. ANoEchoattribute is masked before the write, and the attributes are scrubbed of the revert's resolved secrets; a recorded value the scrub would mask only because it equals a live value the revert masks elsewhere keeps its recorded form. Tests:tests/unit/cli/drift.test.ts; integ arm intests/integration/drift-revert-vpc/verify.sh.- An edit around a secret reference inside one
Fn::Base64(the EC2UserDatashape) is now deployed, andcdkd diffreports it (issue #4451) --src/deployment/masked-property-fingerprints.ts,src/deployment/deploy-engine/update.ts,src/analyzer/diff-calculator.ts,src/deployment/deploy-engine.ts,src/deployment/deploy-engine/record-shape.ts,src/deployment/deploy-engine/deploy-flow.ts,src/types/state.ts. State persists such a property as***, and the edited script resolved to***too, so the diff and the deploy's no-change skip compared equal: a change to the script around{{resolve:...}}, or a retarget of the reference, was skipped under a green deploy and the resource kept the old script. Each record now carriesmaskedPropertyFingerprints(optional, no schema bump): per property it holds as***, a sha256 over the UNRESOLVED template value, never over a resolved one. A moved fingerprint is a change; a rotated secret behind an unchanged template is not, matching CloudFormation. A record written by an older cdkd has no fingerprint and is compared as before; the first deploy under this version records one from the template it deploys, so an edit made in that same deploy is still not sent, and every later one is; for a nested child, which is deployed only when its parent row changed, that first deploy is likely to carry such an edit.cdkd difflabels the row[template expression changed]. An input that changes without the template text (a parameter value, a replaced resource) is still not seen (#4543). Tests:tests/unit/deployment/deploy-engine-base64-secret-noop.test.ts,tests/unit/deployment/masked-property-fingerprints.test.ts,tests/unit/analyzer/diff-calculator-masked-fingerprint-4451.test.ts; integ Phase 1b2c intests/integration/secrets-dynamic-ref/. cdkd driftreads an Elastic IP back, so one released outside cdkd reportsdeletedand exits 1 (issue #4447) --src/provisioning/providers/ec2-provider.ts,src/analyzer/drift-cc-api-deny-list.ts,docs/cli-drift.md.AWS::EC2::EIPhad no arm inEC2Provider.readCurrentState, so it read asdrift unknown(exit 0) whether it existed or not. It is now read withDescribeAddresses:InvalidAllocationID.NotFound/InvalidAddress.NotFoundreport the address deleted, andDomain, a declaredInstanceId,NetworkBorderGroup,PublicIpv4PoolandTagsare read back, so a change to one the baseline holds reports as drift. A deploy now also captures an EIP's drift baseline. A nestedAWS::CloudFormation::Stackrow still reads asdrift unknown: no CloudFormation stack exists to read, and its resources are compared as the<parent>~<child>stack;cdkd drift <parent>not including that stack is #4533. A standaloneAWS::EC2::SecurityGroupIngresswhoseDescriptionwas edited out of band, or a same-account SG-to-SG rule (the template omits the peer owner AWS reports), read asdrift unknownbecause the reader matched the whole rule; it now falls back to the rule's identity when the record's own rule id still names that rule and no other record declares it, so the edit reports as drift anddrift --revertcan restore it.- A rollback that removes an inline policy name another record still holds on that role, group or user now puts that record's document back (issue #4408) --
src/deployment/inline-policy-claims.ts,src/deployment/rollback-executor.ts,src/deployment/rollback-executor/replay-inline-policy-restore.ts. When a deploy dropped anAWS::IAM::Policyand created a new one under the same name on the same principal, then failed before its DELETE phase, the rollback deleted the new policy and the name went with it: the old policy's record still listed it, the principal no longer held it, and the next deploy showed no diff. The same happened when a swap's reversal deleted a copy whose name a sibling still recorded (a partial swap rolled back without--revert-failed), and when a re-adopted retained policy's name had been deleted by a sibling's reversal. Every removal the rollback lets proceed is now noted, and at the end of each completed-op replay, a removed name that another record holds on that principal is put back with that record's recorded document (Put{Role,Group,User}Policy), logged asRollback: put back the inline policy <logicalId> records on its <role|group|user>and recorded as aROLLBACK_RESOURCE_SUCCEEDEDevent for that logical id. Nothing is put back, and the rollback warns, when the records holding it disagree on the document, the recorded document is absent or redacted, or the holder's own rollback has not completed (its record may still be the failed deploy's); a failed put warns the same way. In each case the principal lacks the policy until the resource is next updated orcdkd drift <stack> --revert. Tests:tests/unit/deployment/rollback-executor-inline-policy-claims.test.ts; integ arm Phase 2c-rollback intests/integration/iam-role-policies-drift-clean/now includes the delete-beside-create pair. cdkd diffno longer reports a phantom change when two regions or accounts share an SSM parameter name with different types (issue #4105) --src/deployment/intrinsic-resolver/{dynamic-refs,cross-stack}.ts,src/deployment/secret-redaction/mask-only.ts. cdkd remembered that a{{resolve:ssm:...}}reference pointed at aSecureStringkeyed by the token text alone. So once one stack's region answeredSecureString, another stack whose region holds a plainStringunder the same name skipped its lookup on the comparison path: it compared the unresolved token against the resolved value in its state, and showed a change on every diff and deploy no-op check. A stack's comparison pass now reads only the verdict its own region and credentials pinned. The process-wide set the redaction path reads is unchanged. Thedynamic-ref-cross-regioninteg gains a reversed mixed-type arm (phase 3f).- A value cut at a display cap now names its withheld tail, so two long values that differ only past the cut in characters cdkd shows no longer print alike (issue #4002) --
src/utils/display-safe.ts,src/cli/commands/export.ts. An identifier cdkd cuts at its cap (255 characters, or 1152 for a stack name) was followed by[cut: N more characters withheld], so two planted values sharing the kept prefix and the length rendered byte-identically. The marker now reads[cut: N more characters withheld, tail sha256:<32 hex>]for an identifier (a stack name, region, logical id, or a state-record value incdkd export's plan), the digest taken over the withheld characters only, after any masking; for a stack name, region or logical id also after the same ASCII sanitizing the kept part gets, so there a tail differing only in non-ASCII characters still renders alike, as it does under the cap. Free-form AWS error text keeps the bare marker.cutMarkeris now the one spelling of the marker. Tests:tests/unit/utils/display-safe.test.tsand the suites that pin the marker. - A Glue table replacement that keeps the table's address can now be performed with
cdkd deploy --replace, and--replaceon a table whose new address another table holds is refused with nothing deleted (issue #3932) --src/provisioning/providers/glue-provider.ts,src/deployment/replacement-name-holder/name-keys.ts,src/deployment/replacement-name-holder/holder.ts. The Glue provider reworded everyCreateTableAlreadyExistsExceptionso the engine never treated it as a name collision (#3750). That kept--replacefrom deleting the old table when a third party held a renamed table's address, but it also hid the collision when the replacement kept the address (a change to the top-levelNamealone): the create-first attempt then collides with the old table itself, and no flag could perform the replacement. The provider now relays the error, and the engine's name-holder check decides:--replacedeletes the old table first only when it holds the address the create sent, reading the name as the provider does (TableInput.Name, elseName) and placing it byDatabaseNameand, newly,CatalogId. Table names compare with ASCII case folded, as Glue stores them, and a table's<database>|<table>id never proves the name it holds (either name may contain|). A rename, another database or another Data Catalog is refused before anything is deleted, and so is aCatalogIdgiven on only one side or a recorded name cdkd cannot read. Tests:tests/unit/deployment/deploy-engine-glue-table-collision-3932.test.ts,tests/unit/deployment/replacement-name-holder.test.ts,tests/unit/provisioning/providers/glue-provider.test.ts; integ phase 2f (and step 2b) intests/integration/glue-update-hardening/. - cdkd no longer prints a secret-derived Cloud Control identifier in a Cloud Control update's log lines (part of issue #3869) --
src/provisioning/cloud-control-provider.ts(updateIdLogSink, threaded through the sparse read-back merge, theGetResourcefailure line, the attribute-enrichment lines, the model-parse failure lines and the account-lookup WARN ofaccountInfoForSynthesizedArn) and a new unit file. A Cloud Control identifier can carry a name taken from a secret (AWS::Logs::MetricFilteris<LogGroupName>|<FilterName>); after a rotation under an unchanged reference that name is the pre-rotation value, which the masker never resolved, so it printed in plaintext oncdkd deploy, its rollback revert, andcdkd drift --revert. When the desired or previous properties hold a secret-derived leaf or key (a{{resolve:reference,***, or a value the masker recognises), every occurrence of the identifier and of each|part, raw or as the masker rewrote it, now prints as***in those lines, and the model-parse detail line, whose echo of the model is truncated, is dropped; every line goes through the masker first. An identifier whose name comes from ANOTHER resource's secret-derived value (neither bag holds the secret) still prints until the engine-level registry lands. Residual: that registry, the engine lines and other providers' lines naming a physical id, and this provider'screate()/delete()lines and thrown update errors stay open under #3869. - A rollback op cdkd skips is now recorded, and the automatic rollback keeps its journal over one; a create refused onto another owner's name is no longer journaled (issues #3338, #4356) --
src/deployment/rollback-executor.ts,src/deployment/rollback-executor/{messages,names,types,replay-revert,replay-reverse-replacement,replay-orphan-delete}.ts,src/deployment/deploy-engine/{execute,rollback,create}.ts,src/types/deployment-events.ts. A rollback op the replay declined (a deleted resource it cannot re-create, a missing record orpropertiesbaseline, a physical id a later attempt changed, a failed op with nothing to revert to) printed one warning and recorded no event, and the automatic rollback then dropped the rollback journal, socdkd eventsshowed a finished rollback naming nothing left unreverted. Each such op now records aROLLBACK_RESOURCE_SKIPPEDevent with its reason, and an automatic rollback that skipped any keeps the journal and says so. A reverse-replacement whose re-create adopted the live new resource now gives itsROLLBACK_RESOURCE_SUCCEEDEDevent a reason. A create refused withNAMED_CREATE_COLLISION(or any create refused before anything was applied that recorded no physical id) is no longer journaled as a failed op, socdkd rollback --revert-failedno longer advises deleting the resource holding that name. An automatic rollback whose own journal segment could not be written no longer removes an older attempt's segment. Tests:tests/unit/deployment/rollback-executor-skip-events.test.ts,deploy-engine-rollback-journal.test.ts,deploy-engine-create-name-takeover-4180.test.ts; integ arms intests/integration/{rollback-command,s3-lifecycle,sg-circular-dependency}/verify.sh. --from-cfn-stackoncdkd local invoke/start-api/run-task/invoke-agentcorenow reads the deployed stack with your own credentials instead of the--role-arnrole, and the four enginestart-*commands warn at startup when the role reaches the container (issue #3240) --src/cli/commands/local-state-source.ts,src/cli/commands/local-start-service.ts,src/cli/commands/local-start-alb.ts,src/cli/commands/local-start-cloudfront.ts,src/cli/commands/local-start-agentcore.ts. With--role-arnand no profile, the--from-cfn-stackreader resolved as the role, so a SecureString parameter the caller could not read was decrypted and baked into the container's environment, and the deployed function's environment was read the same way. cdkd now builds that reader's CloudFormation / Lambda / SSM / AgentCore clients on the caller's identity, matching what--profilealready gave; a cross-account stack the caller cannot read now falls back with the reader's own warning. If a future cdk-local release changes the members cdkd reviewed, the command refuses under a role with no profile rather than read as it.start-service/start-alb/start-cloudfront/start-agentcorerun inside the local emulation engine, which accepts no caller credentials yet (go-to-k/cdk-local#783); they now print a warning naming what reaches the role (container credentials, ECS task secrets,${AWS::AccountId},--from-cfn-stack, the STS call behind--assume-role) when--role-arnorCDKD_ROLE_ARNis set without the--profileflag. Tests:tests/unit/cli/local-state-source-caller-identity.test.ts.cdkd deployno longer reports success for a create-only property it never wrote once--prefer-sdk-routeis removed (issue #2790) --src/provisioning/property-coverage.ts,src/types/state.ts,src/analyzer/diff-calculator.ts,src/cli/commands/diff-recursive.ts,src/deployment/deploy-engine/update.ts,src/provisioning/provider-registry.ts. A create-only silent drop accepted with--prefer-sdk-route(or--allow-unsupported-properties) stays in the state record, so the deploy that dropped the flag compared the same value on both sides, called the resource unchanged, and the property never reached AWS. The record now names such a key in a new optionalacceptedCreateOnlyDropsfield (no state schema bump), and the comparison keeps a named key only while the deploy still accepts the drop; a key the record does not name (an imported resource, or a record an older cdkd wrote, including one created with the flag) keeps the previous behaviour, where--recreate-via-cc-apiapplies it. Without it the change is a replacement, and the deploy refuses it (CREATE_ONLY_DROP_NEEDS_REPLACEMENT) before touching that resource, naming the property,--recreate-via-cc-api <LogicalId>or--replaceto replace the resource and apply it, and the--prefer-sdk-routeentries that keep dropping it. A template that removes such a recorded key no longer forces a replacement.cdkd diffpreviews the flag-less deploy, so it shows the replacement and warns that the deploy refuses it, without changing its exit code. Tests:tests/unit/provisioning/property-coverage.runtime.test.ts,tests/unit/analyzer/diff-calculator-unwritten-create-only.test.ts,tests/unit/deployment/deploy-engine-unwritten-create-only.test.ts; integ arm (phases 7-10) intests/integration/sdk-to-cc-autoroute/verify.sh.cdkd destroy --purge-eventsandcdkd events prunenow purge the earlier versions of the deployment-event objects they delete (issue #2624, site 3) --src/state/deployment-events-store.ts(deleteAndPurgeEventObjects, used by all four deletes underdeployments/),src/cli/commands/events.ts,src/cli/commands/destroy.ts,docs/deployment-events.md,docs/cli-destroy.md,docs/cli-events.md,docs/ci-per-pr.mdanddocs/state-management.md. Before, every delete there sentDeleteObjectswith noVersionId. On the versioned state bucket that only wrote delete markers, and because each run's JSONL is re-PUT in full on every flush, one readable version per flush survived. The output said so instead of claiming removal. Each delete now also runs the shared fail-softpurgeNoncurrentVersionsover the deleted keys, in afinally. The writer's self-bounding prune is included, because a stream it drops is no longer listed and a later--allcould not reach it, so a deploy past 20 runs can print the purge's IAM or replication warning too. Withouts3:ListBucketVersions/s3:DeleteObjectVersionthe command still succeeds, and a warning prints before thePruned/Purgedline, which now says "unless a warning above says otherwise". On an unversioned bucket nothing extra is deleted. Not reached: streams deleted before a purge ran on them (an earlier release, or a purge that warned), the earlier versions of anindex.jsonthat a partial prune rewrites (run metadata only), and a replica.- A cross-stack secret read EMBEDDED in a longer string now persists its OWN
{{resolve:...}}expression, not a sibling's (issue #2298) --src/deployment/secret-redaction/placeholder-positions.ts. A leaf such asFn::Sub 'jdbc:...password=${Child.Outputs.Pw}', or anFn::Join/ boundFn::Subvariable over anFn::ImportValue/Fn::GetStackOutput/ nested-stackFn::GetAtt, was redacted by the plaintext-keyed value scan. When another leaf of the same resource resolved to the same plaintext (one secret at two version-stage spellings), the embedded leaf was persisted with the OTHER reference, which a rollback orcdkd drift --revertwould re-resolve and push to AWS as the wrong secret version. No plaintext was exposed. Each embedded reference is now answered from the association the resolver recorded for that reference, and only when the template's literal text plus those values rebuild the resolved string exactly; anything else falls back to the value scan as before. A resource whose record holds the sibling's expression can take one UPDATE on the first deploy after upgrading (a replacement if the property is create-only), even though the value sent is unchanged. Tests:tests/unit/deployment/secret-redaction-cross-stack-embedded.test.ts; integ armEmbedSecretPairintests/integration/nested-stack-secret/. cdkd scrubno longer reports a stack clean over a{{resolve:...}}reference anFn::Subplaceholder left unresolvable (issue #2166) --src/deployment/intrinsic-resolver/sub.ts,src/deployment/intrinsic-resolver/dynamic-refs.ts,src/deployment/intrinsic-resolver/context.ts,src/cli/commands/scrub.ts. A leaf such asFn::Sub '{{resolve:secretsmanager:${Typo}-db:SecretString:password}}', whereTyponames no resource or parameter of the template, keeps its${Typo}with akeeping placeholderwarning, so the reference is never looked up and nothing throws. Scrub recorded nothing for it and printedNo plaintext secrets found, and--dry-run --failexited 0, over a record that could still hold the plaintext an older deploy wrote.resolveSubnow remembers such a placeholder and the dynamic-reference pass reports it as aplaceholderunit in the abandoned-resolution bag once the assembled string (thatFn::Sub, or an enclosingFn::Join/Fn::Sub) puts it inside a reference; scrub counts it as an abandoned scan: the stack is not printed clean and--failexits 1. Fix the template (declare or rename the variable, or escape it as${!Typo}) to clear it. Scrub also binds every parameter that has a non-emptyDefaultwhen another parameter has none; it used to leave them all unbound, so a reference assembled from a defaulted parameter was never looked up and its plaintext never rewritten. A placeholder naming aDefault-less parameter still only warns (#4559); one naming a declared resource only warns by design. A deferred reference whose lookup FAILS was already counted the same way.cdkd deployis unchanged, since it collects no abandoned units. Tests:intrinsic-sub-kept-placeholder-report,scrub-cross-region-secret,scrub-all-refusal-boundary; integdynamic-ref-cross-regionphases 3g / 3h.cdkd drift --revertkeeps AWS's value at a{{resolve:...}}token cdkd cannot resolve when the token is EMBEDDED in a longer string (issue #2102) --src/cli/commands/drift.ts,src/deployment/secret-redaction/unresolved-token-frame.ts. A revert triggered by another drifted key preserved the live value only where a property's WHOLE value was the token; a token inside a longer string (jdbc:...password={{resolve:...}}), or beside a secret cdkd resolved, shipped the literal token and overwrote what AWS held. The live string is now kept when it equals the payload at every character outside the unresolved tokens' spans, resolved secrets included, so a secret rotated since the last deploy is never copied into the payload; otherwise the token ships as before. Both drift warnings say so. Also, a resolved secret thatcdkd driftdiscards after a later reference on the same resource fails to resolve stays masked in the resource's comparison-failure warning. Tests:tests/unit/cli/drift-unresolved-token-frame.test.ts,tests/unit/cli/drift-masked-leaf-preserve.test.ts,tests/unit/cli/drift-secret-redaction.test.ts; integ arm intests/integration/drift-revert/verify.sh(step 5b).- An AppSync API key create that hits a 5xx now reports candidate orphans instead of duplicating silently (issue #2080, Plan C: AppSync
CreateApiKey; #3978 layer (b) for this create) --src/provisioning/providers/appsync-provider.ts,docs/troubleshooting.md.CreateApiKeymints the key's id and carries no idempotency token, so a 5xx whose request had succeeded left a second key on the API with no state record -- and, when the AWS SDK replayed it inside one call, nothing reported it. The create now goes through the same 5xx-refusing client asCreateGraphqlApi; the 5xx reaches cdkd's retry, which first lists the keys on the same API with the same description that this deploy did not record and warns, with a read command (aws appsync list-api-keys --api-id ... --region ...) and no delete command: a key has no name and no creation time, so nothing attributes one to this create. A key's id is the credential clients send, so the warning names each candidate only by its last four characters and its expiry. Nothing is adopted or deleted. A throttle, a 4xx or IAM propagation triggers no lookup. The lookup needsappsync:ListApiKeys; without it cdkd warns and the create proceeds. A reset connection or timeout after the send is still retried inside the SDK and not reported. Tests:tests/unit/provisioning/appsync-api-key-create-retry-2080.test.ts. - A DLM lifecycle policy or ECS task definition create that hits a 5xx now reports candidate orphans instead of duplicating silently (issue #2080, Plan C:
CreateLifecyclePolicy,RegisterTaskDefinition, CodeCommitCreateRepository/CreateCommit; the SDK-5xx half of #3978 layer (b) for these creates) --src/provisioning/providers/dlm-lifecycle-policy-provider.ts,ecs-provider.ts,codecommit-repository-provider.ts. Neither DLM nor ECS create carries an idempotency token, so a 5xx whose request had succeeded left a second policy (still running its schedule) or an extra ACTIVE revision with no state record, unreported when the AWS SDK replayed it inside one call. Both now refuse the SDK's 5xx retry; cdkd's retry first lists what the failed attempt may have made -- policies with the same description and kind, or revisions of the same family, created in that attempt's window -- and warns with a read command, then a delete / deregister command to run only after confirming. Nothing is adopted or deleted; a throttle or 4xx triggers no lookup, and a failed lookup warns and lets the create proceed. A CodeCommit repository name cannot be duplicated, so its creates get only the 5xx-refusing client: a replayed collision is no longer credited to another holder, and a seed commit whose 5xx had succeeded no longer fails the deploy withParentCommitIdRequiredException. A reset or timeout after the send is still retried inside the SDK. Tests:tests/unit/provisioning/{dlm,ecs-task-definition,codecommit}-create-retry-2080.test.ts. - A Lambda layer version or event source mapping create that hits a 5xx now reports candidate orphans instead of duplicating silently (issue #2080, Plan C: Lambda; #3978 layer (b) for these two creates) --
src/provisioning/providers/{lambda-layer,lambda-eventsource}-provider.ts,docs/troubleshooting.md.PublishLayerVersionandCreateEventSourceMappingmint their id and carry no idempotency token, so a 5xx whose request had succeeded left a second layer version or mapping with no state record -- and, when the AWS SDK replayed it inside one call, nothing reported it. These creates no longer let the SDK retry a 5xx; the 5xx reaches cdkd's retry, which first lists what the failed attempt may have made and warns: a version of the same layer published during that attempt (a read command, thendelete-layer-versionto run only after confirming), or a mapping between the same function and source that this deploy did not record and that was not last modified before the attempt (a read command only: Lambda reports no creation time for a mapping). Nothing is adopted or deleted. Where Lambda refuses a second mapping for the same function and source (an SQS queue), the replayed create then fails naming the orphan's UUID instead of duplicating it. A throttle, a 4xx or IAM propagation triggers no lookup. The lookup needslambda:ListLayerVersions/lambda:ListEventSourceMappings; without it cdkd warns and the create proceeds. A reset connection or timeout after the send is still retried inside the SDK and not reported. Tests:tests/unit/provisioning/lambda-create-retry-2080.test.ts. cdkd deploy'sUpdated:total no longer counts an orphaned predecessor left inside a nested stack (issue #1989) --src/cli/commands/deploy.ts,src/deployment/deploy-engine.ts,src/deployment/deploy-engine/{options,execute,provision,deploy-flow}.ts. Since nested-stack counts reach the top-level stack (PR #4459), a replacement inside a nested child that left its old resource alive was added to the top-levelUpdated:total and itsof which left an orphaned predecessorrow, althoughUpdated:counts the deployed stack's own resources only. Such survivors now print on their own row,Left an orphaned predecessor in a nested stack: N, andRunCounts.updatedincdkd eventsmatches the console. The exit code andRunCounts.skippedstill count them.docs/cli-deploy-safety.mdno longer says the exit code covers top-level stacks only, and says a skipped DELETE inside a nested stack exits 2 on the first deploy and is re-attempted by the next. Tests:tests/unit/cli/deploy-unaddressed-exit.test.ts,tests/unit/deployment/deploy-engine-nested-child-unaddressed.test.ts,tests/unit/deployment/deploy-engine-nested-child-unaddressed-e2e.test.ts.
Recently Implemented (2026-10-03):
cdkd local run-taskandcdkd local start-api --watchno longer leave a container behind when ^C lands during a container start (issue #4495) --src/local/ecs-task-runner.ts,src/local/ecs-network.ts,src/cli/commands/local-run-task.ts,src/local/container-pool.ts,src/cli/commands/local-start-api.ts.run-taskrecorded a container only after itsdocker runreturned, so a ^C during that run (or during the network + metadata sidecar start, or adocker volume create) removed what it knew and exited, and the run then left its container behind.start-api --watchdisposed a reload's previous container pool in the background, and the ^C cleanup exited without waiting for it, so a container that pool was still starting survived; a pool's own dispose also stopped waiting for an in-flight start after 5s. The ^C cleanup now waits for the in-flight docker step and removes what it created, and starts nothing more;start-apiwaits for every pool a reload replaced, and a reload whose re-synth outlasts the ^C swaps in no new pool. The wait is bounded at 20s; past it cdkd removes what the command creates by name and warns, naming it and the command that removes it (docker rm -f,docker volume rm -fordocker network rm), since adockercommand still pending can create it later. Tests:tests/unit/local/ecs-task-runner-runner.test.ts,tests/unit/cli/local-run-task-command-body.test.ts,tests/unit/local/container-pool.test.ts,tests/unit/cli/local-start-api-reload-shutdown.test.ts; integ arms intests/integration/local-run-task/verify.shandtests/integration/local-start-api/verify.sh.cdkd deployno longer fails when twoAWS::EC2::SecurityGroupIngressresources of one stack resolve to the same rule (issue #4492) --src/provisioning/providers/ec2-provider.ts,src/deployment/stack-records-scope.ts,src/deployment/deploy-engine/execute.ts,src/deployment/deploy-engine/provision.ts,src/deployment/rollback-executor.ts,src/cli/commands/destroy-runner.ts,src/cli/commands/export.ts. CDK cannot dedupe a rule whose port is a token, so an Aurora cluster with aDatabaseProxyon the same security group plus an explicitaddIngressRule(sg, Port.tcp(3306))emits two rules AWS stores as one. Since #4393 the second create was refused as someone else's rule. It now records the existing rule when another record of the same stack holds it — aSecurityGroupIngressrecord with the same group, protocol, ports and source, or the group's own record declaring it inline — waiting for a twin created in the same deploy to finish first; CloudFormation accepts both. A rule recorded under a differentsgr-id than the live one is still refused, as is a rule held by nothing in the stack. A delete, a rollback, a destroy and a security group update dropping the rule from its inline list leave it in place while a record that outlives the operation still holds it (a destroy's retained records, a deploy's records it does not delete); the last holder revokes it, and a second revoke finding it gone succeeds (one with no recorded rule id still warns that nothing was revoked).cdkd exportrefuses two such records, naming each other, since an import adopts a rule into one logical id. Tests:tests/unit/provisioning/ec2-sg-ingress-sibling-holder.test.ts,tests/unit/deployment/stack-records-scope.test.ts,tests/unit/deployment/deploy-engine-stack-records-binding.test.ts,tests/unit/cli/destroy-runner-stack-records.test.ts,tests/unit/cli/export-sg-ingress-rule-id-backfill.test.ts.cdkd local invoke-agentcore --watchno longer leaves a container behind when ^C lands while a rebuild re-synthesizes or starts its new container (issue #4488) --src/cli/commands/local-invoke-agentcore.ts(bootAgentCoreContainer,rebuildAgentCoreContainer,ContainerStartGuard). A rebuild removes the old container, re-synthesizes, then runsdocker runfor the new one. A ^C after the removal reached a cleanup that knew no container id, so it exited while thedocker runwas still starting a container nothing then removed. Once the cleanup begins, a rebuild now starts no container, and a container whosedocker runwas already in flight is removed before the process exits, with a line saying the ^C is waiting for thatdocker run. The wait is bounded at 15 s; past it, cdkd removes the container by its name if it exists, exits, and warns with thedocker rm -f <name>to run in case a latedocker runstill starts it. Tests:tests/unit/cli/local-invoke-agentcore-stdout-stream.test.ts.cdkd local invoke,local invoke-agentcoreandlocal run-taskno longer drop the container's last log lines when the Docker daemon is slow to relay them (issue #4480) --src/local/docker-runner.ts(followContainerLogs,killContainer,killAndDrainContainerLogs,flushStdio),src/local/ecs-task-runner.ts,src/cli/commands/{local-invoke,local-invoke-agentcore,local-run-task}.ts. The teardown SIGTERMeddocker logs -f(after a fixed 250 ms oninvoke) and then removed the container, so any line the daemon had not yet relayed was lost: under host load the handler'sconsole.logoutput and the RIE'sEND/REPORTlines went missing while the invoke reported success.invoke/invoke-agentcorenow rundocker kill, wait (up to 5 s) fordocker logs -fto end on its own, and only then write the response to stdout and remove the container, so every log line precedes the response.run-taskwaits for the essential container's follower after the container exits, and at teardown stops every container before draining the followers and removing the containers. The teardown flushes stdout and stderr before the process exits, on ^C (130) and on a failed invoke alike;run-taskremoves its--profilecredentials file as soon as the containers are stopped, before the drain.invoke-agentcore --watchkeeps the container between iterations, so it drains when the container is rebuilt or torn down. Tests:tests/unit/local/docker-runner-log-drain.test.ts,tests/unit/local/ecs-task-runner{,-runner}.test.ts,tests/unit/cli/local-invoke{,-agentcore}-stdout-stream.test.ts.cdkd diffreuses the condition verdict the last deploy recorded, so anFn::Ifon a condition over a secret-fed parameter no longer always takes FALSE (issue #4479) --src/deployment/condition-verdicts.ts(new),src/deployment/deploy-engine/deploy-flow.ts,src/cli/commands/diff-recursive.ts,src/types/state.ts. A condition that reads a parameter fed a secret{{resolve:...}}reference has no verdict at plan time. AnFn::Ifon one took its FALSE branch, so a stack whose last deploy took the TRUE branch diffed as a perpetual UPDATE, andcdkd diff --failexited 1 on an unchanged stack. The deploy now records each such verdict in state (conditionVerdicts, additive, no schema bump) with a sha256 fingerprint of the condition's definitions, transitively through{Condition: X}, and of the parameter inputs they read: a secret-fed one as its expression, never its value. The diff reuses a verdict only when its own fingerprint is equal, for bothFn::Ifand pruning. So a branch swap, an edited condition or a different secret reference is still reported. A deploy's final save and its no-change save write the record; a child an older binary deployed gains it on the next deploy that changes it. No record (an older binary, a failed deploy), a different fingerprint (the diff warns), an unbound or SSM-typed parameter, a plain parameter the parent passes a value other than its Default, a parent row value the diff cannot vouch for, or a shape outsideFn::And/Or/Not/Equalsover string literals and declared scalar parameters keeps the FALSE branch. The value behind an unchanged secret reference is assumed unchanged, as for every secret-bearing property. Tests:tests/unit/cli/diff-recorded-verdicts-4479.test.ts,tests/unit/deployment/condition-verdicts.test.ts,tests/unit/deployment/deploy-engine-condition-verdicts-4479.test.ts.- Stack selection now matches the AWS CDK CLI: patterns use the hierarchical id with path-aware globs, and
--allselects top-level stacks only (issue #4474) --src/utils/glob-match.ts,src/cli/stack-matcher.ts,src/synthesis/assembly-reader.ts, andsrc/cli/commands/deploy.ts,destroy.tsanddiff.ts, with unit tests beside each. A pattern without/used to match only the physical CloudFormation name, socdkd deploy Apimissednew Stack(app, 'Api', { stackName: 'prod-api' }), and*crossed/. Every pattern now matches the stack's hierarchical id (its CDK display path), ascdkdoes:*stays within one/segment, so'*'selects the top-level stacks only and'MyStage/*'one Stage's stacks, and'**'selects every stack. An EXACT physical name still selects its stack as a cdkd extension when no hierarchical id matches, but a glob no longer matches physical names.--allchanged scope: ondeploy,destroyanddiffit selects the top-level stacks only, ascdk deploy --all/cdk destroy --alldo; the run names the Stage stacks it left out with the'MyStage/*'/'**'patterns, and a stage-only app is refused.publish-assets --allandscrub --allstill cover every stack. Re-audit scripts that pass physical names or globs tocdkd destroy/cdkd deploy: a pattern without/now matches CDK ids first, so it can select a different stack than before, and'*'or'MyStage-*'no longer reaches Stage stacks; both commands warn in one line naming any stack the old rule would have selected (including one'MyStage/*'used to reach deeper than one segment). - A resource whose TOP-LEVEL property is a condition-false
Fn::IfselectingAWS::NoValue(Fn.conditionIf(cond, x, Aws.NO_VALUE)on an L1 property) no longer diffs as a perpetual UPDATE (issue #4471) --src/analyzer/diff-calculator.ts(resolveBestEffort),tests/unit/analyzer/diff-calculator.test.ts, and a live arm ontests/integration/conditions-and-if/(Phase 2b). The diff resolves desired properties one top-level key at a time, so the resolver returned the bareAWS::NoValuesymbol for such a key instead of dropping it, and the symbol compared unequal to the absent recorded property:cdkd diff --failexited 1 on an unchanged stack and everycdkd deployplanned an UPDATE of the resource. The key is now omitted, as the resolver already does for nested object keys and list elements; a key the stack still records is still reported as removed. The replacement decisions read the omission too: anAWS::Lambda::FunctionwhoseDurableConfigsuch a branch now removes is planned as a REPLACEMENT (it was an in-place UPDATE that kept the live durable config), one with no recordedDurableConfigno longer plans a spurious replacement, and a removed container whose record lacks a nested createOnly leaf updates in place instead of being conservatively replaced. cdkd diffevaluatesConditionsper condition, so one secret-fed or unbindable parameter no longer turns everyFn::Ifin the stack FALSE (issue #4470) --src/cli/commands/diff-recursive.ts. The diff used to skip the WHOLEConditionssection when any template parameter failed to bind or any one condition read a parameter fed a secret{{resolve:...}}reference, so everyFn::Ifresolved to its FALSE branch, even on a condition that reads no parameter at all: a condition-true property diffed as a perpetual UPDATE andcdkd diff --failexited 1 on an unchanged stack, at the top level and in nested children alike. Parameters now bind one at a time, and only the conditions that reach an unbound or secret-fed parameter, directly or through a{Condition: X}chain, go unevaluated. They still never prune a resource, so no phantom DELETE is reported. Every other condition gets the verdict the deploy computes. AnFn::Ifon a condition that itself depends on an unbound or secret-fed parameter still takes its FALSE branch, unchanged. Residual: go-to-k/cdkd#4479. Tests:tests/unit/cli/diff-per-condition-4470.test.ts.- A same-name IAM role, user or group re-create no longer silently drops the managed policies, instance profile and group memberships attached to it (issue #4461) --
src/deployment/child-of-recreated-parent.ts,src/deployment/deploy-engine/update.ts,src/deployment/deploy-engine/update-in-place.ts,src/deployment/deploy-engine/execute.ts,src/provisioning/providers/iam-user-group-provider.ts,src/provisioning/providers/iam-instance-profile-provider.ts. When a deploy re-created a fixed-name role, user or group under the same name, IAM had detached what was attached to it: anAWS::IAM::ManagedPolicy(Roles/Users/Groups), anAWS::IAM::InstanceProfile's role, a user'sGroupsmembership, aUserToGroupAddition's members, and an inlineAWS::IAM::Policy/UserPolicy/GroupPolicyon a user or group. Their references resolved to the same names, so every deploy diffed them unchanged while state kept recording them -- a Deny guardrail silently absent from the re-created role. cdkd now attaches each of them again in place (also when the same deploy edits the list) and writes the inline policies again. A deploy that fails first keeps their records minus the re-created principals, so the next deploy attaches them. Re-adding a role an instance profile already holds now succeeds instead of failing withLimitExceeded, and an IAM user's update no longer re-sends an unchangedLoginProfile, which reset its password. Tests:tests/unit/deployment/child-of-recreated-parent.test.ts,tests/unit/deployment/deploy-engine-iam-reattach.test.ts,tests/unit/provisioning/iam-user-group-provider-roundtrip.test.ts,tests/unit/provisioning/iam-instance-profile-provider-roundtrip.test.ts. - A nested stack's skipped DELETE is re-attempted by the next
cdkd deploy, which exits 2 again until it lands (issue #4453) --src/provisioning/providers/nested-stack-provider.ts. A top-level stack's skipped DELETE keeps its record and is retried by the next deploy, but inside a nested child (or grandchild) the retry never ran: the parent'sAWS::CloudFormation::Stackrow diffed onlyTemplateURL/Parameters, so with no template change it wasNO_CHANGE, the child never deployed, and the second deploy reported nothing and exited 0 over a resource cdkd still owns. A nested row now records the record-only keycdkd:PendingChildDeletes(the child's skipped-DELETE count, descendants included) while any remain, so the next deploy re-runs the child chain, retries the DELETE and counts it again;cdkd diffshows that pending re-run as a change on the row. A clean re-run records the plain template properties and the key is gone. A skip left by an earlier cdkd version carries no key, so it is re-attempted the next time that child stack deploys for another reason. Tests:tests/unit/deployment/deploy-engine-nested-child-unaddressed-e2e.test.ts,tests/unit/provisioning/nested-stack-provider.test.ts; integ:nested-stack-3level. - A nested-stack child resource whose embedding leaf has several parts the template cannot state, or is an
Fn::Ifselecting anFn::Sub/Fn::Join, no longer reports an UPDATE on every deploy when a sibling{Ref}parameter resolves to the same plaintext (issues #4446, #4469) --src/deployment/intrinsic-resolver/string-functions.ts,src/deployment/intrinsic-function-resolver.ts,src/deployment/secret-redaction/pairs.ts(IntrinsicLeafResolution.parameterSpans),src/deployment/secret-redaction/positions.ts,tests/unit/deployment/secret-redaction-nested-parameter-spans.test.ts, and a live arm ontests/integration/nested-stack-secret/(HandoffSpans/HandoffSpansIf, their ownspansJSON key). The #2320 arm aligned the template against the resolved leaf, so it refused a leaf likepostgres://+{Ref: User}+:+{Ref: A}+@+ an attribute, and never read anFn::If; the leaf fell to the value scan and took the sibling parameter's expression, a perpetual UPDATE (a REPLACEMENT on a create-only property). The resolver now records, while it substitutes them, where each parameterReflanded on anFn::Sub/Fn::Joinoutput, through nested parts and anFn::If's selected branch, and drops that record when a later dynamic-reference pass rewrites the text; a part that places no span it can vouch for (a string-selectedFn::Ifsuch as CDK'sFn.conditionIf(c, '-prod', '')) is a gap rather than a reason to drop the others. The persist path reads those spans first: each certified span takes that parameter's own expression, every other stretch is kept verbatim only where the value scan would leave it alone, and the #2320 straddle and final re-scan guards still apply. Any refusal falls back to the template parse, then the value scan. - The update-failure fallback's same-id re-create now brings back the sub-resources stored inside the resource (issue #4444) --
src/deployment/deploy-engine/update-in-place.ts,src/deployment/deploy-engine/execute.ts,src/deployment/child-of-recreated-parent.ts. When a provider refuses an in-place update (ResourceUpdateNotSupportedErrorunder--replace, or Cloud Control'sUnsupportedAction), cdkd deletes the resource and creates it again; with a fixed name it comes back under the same physical id, and what AWS stored inside it (a log group's streams and metric or subscription filters, from the #4411 table) went with the old one. The diff never promoted those children, since the parent was an in-place update, so they stayedNO_CHANGEand were never dispatched: after a green deploy they were missing in AWS while state recorded them. The fallback now records such a re-create, and as soon as the re-created resource completes the engine dispatches eachNO_CHANGEchild of it through the #4411 arm (re-created without a delete, or re-put), before any pending resource that reads the child, so a later failure elsewhere in the deploy no longer leaves it missing. Tests:tests/unit/deployment/deploy-engine-fallback-recreate-children.test.ts,tests/unit/deployment/child-of-recreated-parent.test.ts. - A deploy that fails after re-creating a resource under the same physical id no longer leaves the sub-resources stored inside it lost for good (issue #4443) --
src/deployment/child-of-recreated-parent.ts,src/deployment/deploy-engine/execute.ts,src/deployment/deploy-engine/update.ts,src/deployment/deploy-engine.ts. Since #4411 the deploy re-creates a child that went with such a parent (a bucket'sBucketPolicy, a function'sPermission). When the deploy failed or was interrupted before reaching it, the child's record survived (the rollback keeps an equal-id parent as done) and every later deploy diffed it unchanged, so the child stayed missing in AWS while state recorded it: a bucket policy's deny of plain HTTP, or an IAM policy's Deny, silently absent. The failure path now drops the record of each such child the deploy did not restore, when its recorded value still names the re-created parent, with a warning naming it; the next deploy creates it again. Tests:tests/unit/deployment/child-of-recreated-parent.test.ts,tests/unit/deployment/deploy-engine-lost-child-after-failed-deploy.test.ts. - After a destroy that kept an EFS or FSx file system or a CloudFront origin access identity, the same stack's next deploy creates a new one; a re-run of an interrupted deploy takes the one it made (issue #4438) --
src/provisioning/providers/{create-token-ledger,efs-provider,fsx-filesystem-provider,cloudfront-oai-provider}.ts,src/state/{create-token-ledger,s3-state-backend}.ts, the deploy engine, the rollback executor,src/cli/commands/{orphan,rollback}.ts. These create tokens were deterministic per stack, so a resource that outlived cdkd's record of it (RemovalPolicy.RETAIN, CDK's default for both file systems,RetainExceptOnCreate,UpdateReplacePolicy: Retain,cdkd orphan) still held the token the next create sent: EFS and FSx refused that redeploy where the AWS CDK CLI creates a new resource, and CloudFront handed the old identity back. A per-stack ledger,create-tokens.jsonbesidestate.json, folds a nonce into those tokens. It is deleted before the state record (failing the command if it cannot be), and rotated bycdkd orphanand by any Retain or--orphan. Each send is recorded before the create and kept until that deploy succeeds, so an interrupted deploy's re-run takes the resource it made, which #4437 refused. A deploy that finds no state record but a ledger written under one (an earlier cdkd destroyed the stack) starts a fresh ledger. A ledger that cannot be read or written refuses these creates, naming the stack and logical id. A create interrupted under an earlier cdkd creates a fresh resource on its re-run; for EFS, cdkd warns naming the file system left behind. Tests:tests/unit/{provisioning/create-token-ledger,state/create-token-ledger,deployment/deploy-engine-create-token-ledger-4438}.test.tsand the provider and orphan suites; integ:stack-copy-create-tokens. - A rolled-back update now records the attributes the provider returned, so a reverted
AWS::EC2::SecurityGroupIngresskeeps its newsgr-rule id instead of the revoked one (issue #4434) --src/deployment/rollback-executor/replay-retry.ts(recordAfterRollbackUpdate),src/deployment/rollback-executor.ts,src/deployment/rollback-executor/replay-revert.ts,src/deployment/rollback-executor/replay-reverse-replacement.ts,src/deployment/secret-redaction/mask-only.ts(recordNoEchoAttributeValues),tests/unit/deployment/rollback-executor-update-attributes.test.ts. Both UPDATE arms (cdkd rollback/ the automatic rollback, and--revert-failed) kept the restored record's attributes, so after a revert that re-authorized a rule,cdkd exportand a laterFn::GetAtt <rule>.Idread a revoked id. The record now takes the returned set wholesale when the update replaced, merges it key-wise when it updated in place, and keeps the restored set when the provider returned none; an update that replaced under a new physical id is recorded under that id, without the removed resource's attributes when it returned none. ANoEchodeclaration on those attributes (a custom resource's response, a nested stack's masked outputs) is registered before the record is redacted, as the deploy engine does, so they persist as***; the reverse-replacement replay-CREATE arm, which already recorded its create's attributes, now does the same. AWS::Pipes::Pipehas an SDK provider, so a stream or broker source's batch settings change in place (issue #4423) --src/provisioning/providers/pipes-provider.ts(new),src/provisioning/{register-providers,provider-registry}.ts, new dependency@aws-sdk/client-pipes. A change to a mutableSourceParametersmember of a Kinesis, DynamoDB Streams, MSK, self-managed Kafka, ActiveMQ or RabbitMQ source (e.g.KinesisStreamParameters.BatchSize) failed with "createOnlyProperties [...StartingPosition] cannot be updated": Cloud Control cannot express that update at all. The provider callsUpdatePipe, whose source shape has no create-only member, and waits for the pipe to settle after a create or update and to disappear after a delete. A pipe recordedprovisionedBy: cc-apiby an earlier cdkd returns to the SDK provider on its next in-place update under the same name (acc-brokensticky exemption). RemovingTargetParametersorEnrichmentParametersfrom the template is reported, not applied. Tests:tests/unit/provisioning/providers/pipes-provider.test.ts; integ:cc-write-only-create-only(aBatchSizechange on a cc-api-seeded record),eventbridge-pipes.- A
SecurityGroupIngressduplicate is no longer adopted on the strength of a journal entry a rollback, a later deploy or a partial destroy already undid (issue #4402) --src/deployment/prior-attempt-scope.ts,src/state/s3-state-backend.ts,src/types/rollback-journal.ts,src/deployment/deploy-engine/rollback.ts. Since #4355, a create that AWS rejects as a duplicate adopts the existing rule when the stack's rollback journal shows the same logical id attempted it. The journal's COMPLETED operations counted too, but a segment can keep them after they were undone: an automatic rollback whose segment cleanup failed, acdkd rollbackthat failed on another operation, an unsettled nested-stack segment, or acdkd destroythat failed partway. If someone then added the identical rule, the next deploy adopted it andcdkd destroyrevoked it. Now only a FAILED attempt that no later completed operation of the logical id superseded is evidence: a completed operation's resource was recorded in state, so a later create of that id means it was reverted, destroyed or replaced. Removing a segment (or a successful deploy failing to delete the journal) now leaves its operations' logical ids on the next older segment (supersededLogicalIds, an additive journal field), so a reverted adoption no longer lets the attempt it adopted count again. Tests:tests/unit/deployment/deploy-engine-prior-attempt-binding.test.ts,tests/unit/state/s3-state-backend.test.ts,tests/unit/types/rollback-journal.test.ts,tests/unit/deployment/deploy-engine-rollback-journal.test.ts; integ arm:tests/integration/sg-circular-dependencyphase 1e. cdkd driftreports a resource deleted outside cdkd asdeletedand exits 1, instead of as an uncovered type with exit 0 (issue #4283) --src/types/resource.ts(newRESOURCE_NOT_FOUNDsentinel),src/cli/commands/{drift,state,import}.ts,src/deployment/deploy-engine/observed-capture.ts,src/provisioning/cloud-control-provider.ts, every SDK provider that reads state back undersrc/provisioning/providers/,docs/cli-{drift,state,reference}.md, thedrift-revertandecs-fargateinteg fixtures. A resource AWS reports gone read asdrift unknown("provider does not support drift detection yet") with exit 0, so a CI gate oncdkd driftstayed green over a missing cluster, bucket or role. A read now returnsRESOURCE_NOT_FOUNDon AWS's own not-found answer (a not-found error name, an empty describe list for the id, or a terminal status such as ECSINACTIVE); access-denied, throttled or message-only answers never do. Drift reports such a resource under a newdeletedoutcome: a- <id> (<type>) — DELETED outside cdkdline in the drift block and exit 1.--jsonshape change: such a resource moves fromnotSupportedto a newdeletedarray. Each stack is now read through its own region's clients, so--stack-region/--allcannot misread another region's resources as deleted.--revertand--acceptrefuse it by name, resolve the other drifted resources, and exit 2; a plain deploy does not recreate it, and the message names the remove-then-restore route.cdkd state refresh-observednames such a resource and counts it apart from read failures (exit 2), keeping its baseline; deploy and import baseline captures record nothing for it. An S3 Tables namespace is now checked withGetNamespace, sos3tables:GetNamespaceis needed for drift. Not covered: a type with no reader (a nested stack, an Elastic IP) -- #4447.- A Route 53 record whose name contains
|is deployed instead of refused (issue #3890) --src/provisioning/providers/route53-provider.ts,src/deployment/intrinsic-resolver/ref-values.ts,src/provisioning/composite-id.ts. Route 53 accepts|in a record name and CloudFormation deploys such a record, but cdkd refused it at deploy time because|separates the segments of its<hostedZoneId>|<name>|<type>physical id. The name is now recorded as is: every reader takes it from between the first and the last|, and only when it matches the recordedNameand the last segment matches the recordedType. An id that does not match is read as CloudFormation's own physical id, the record name, as before.Refreturns the whole record name. Drift reads, renames andcdkd importfind the record under Route 53's\174spelling of|. An existing state record keeps its id and still decodes. Tests:tests/unit/provisioning/{composite-id-provider-guards,route53-provider-cfn-physical-id,route53-recordset-rename}.test.ts,tests/unit/deployment/intrinsic-functions.test.ts; integ:route53. - A CDK Stage whose cloud assembly cannot be read now stops every command, as in the AWS CDK CLI (issue #3507) --
src/synthesis/assembly-reader.tsandsrc/synthesis/failed-stages.ts(stageLoadError), with the selection-time handling removed fromsrc/cli/stack-matcher.tsandsrc/cli/commands/deploy.ts,destroy.ts,diff.ts,publish-assets.ts,scrub.ts,import.ts,export.ts,orphan.ts,list.tsandsynth.ts; unit tests intests/unit/synthesis/assembly-stage-refusal.test.tsand beside each command, andtests/integration/stage-assetsPhases 2b and 2g. A Stage whose ownmanifest.jsoncould not be read was tolerated: its stacks silently left the synthesized app, and a run then acted on the stacks that did load. That tolerance needed a refusal per selection ----all, no stack named, a wildcard -- and this replaces those refusals, and the failed-Stage note appended to "no stacks matching", with one synthesis error,Stage <path> failed to load: <reason>. ..., raised before any stack is selected or any state is read, whatever was named.cdkd destroyno longer falls back to state for an exact name in that case, and its error namescdkd state destroy '<stack>', which needs no app.cdkd scrubrefuses with exit 2 (SCRUB_STAGE_LOAD_FAILED), replacing theSCRUB_ALL_PARTIAL_APP/SCRUB_AUTO_PICK_PARTIAL_APPcodes. The exportedSynthesisResult.failedStagesandAssemblyContents.failedStagesfields are removed. Separately,cdkd destroynow warns about each pattern that matched nothing, as the AWS CDK CLI's destroy does, and about a name in state that is not a stack of this app. cdkd diffno longer reports a change on every run for anFn::Base64over a secret reference, socdkd diff --failcan pass for such a stack (issue #2909) --src/deployment/intrinsic-resolver/functions.ts,src/deployment/intrinsic-resolver/context.ts,src/analyzer/outputs-diff.ts. The diff leaves a{{resolve:secretsmanager:...}}/{{resolve:ssm-secure:...}}reference (or a plainssmone to aSecureString) unresolved and encoded the reference TEXT, while the deploy encodes the secret and stores***for it, so the two never matched: a resource or output holding such a value (the CDK EC2UserDatashape) always showed as changed, and the deploy then skipped it. The diff now compares such anFn::Base64as***, the value the deploy stores, unless its input also holds aNoEchovalue supplied in the same deploy, which must still reach AWS. Bound: an edit to the text around, or the target of, a secret reference inside the sameFn::Base64is not shown by the diff either (nor is a rotated value); the deploy already did not send it (#4451). Tests:tests/unit/deployment/intrinsic-base64-diff-mask-2909.test.ts,tests/unit/deployment/deploy-engine-base64-secret-noop.test.ts,tests/integration/secrets-dynamic-ref/.- A nested-stack child resource that embeds one parameter (
Fn::Sub/Fn::Join) beside a whole-value{Ref}to a second parameter resolving to the same plaintext no longer reports an UPDATE on every deploy (issue #2320) --src/deployment/secret-redaction/positions.ts(positionByParameterPlaceholders),src/deployment/secret-redaction/redact-path.ts,tests/unit/deployment/secret-redaction-nested-parameter-placeholders.test.ts, and a live arm ontests/integration/nested-stack-secret/(HandoffMixed, its ownmixedJSON key). The resource's secret bag holds one expression per plaintext -- whicheverRefresolved last -- so the embedded leaf was persisted with the sibling parameter's expression while the diff side rendered its own: a perpetual UPDATE, and a REPLACEMENT every run on a create-only property. The persist path now answers each placeholder of anFn::Sub/Fn::Joinover the child's parameters (a placeholder a 2-argFn::Submap binds to aRef, and a nestedFn::Sub/Fn::Join, included) from that parameter's own association, the same one a bare{Ref}leaf uses, and accepts the result only when the template's literals and those plaintexts reassemble the resolved leaf exactly; at most one other part (a pseudo parameter, an intrinsic, an unassociated parameter) is allowed, kept verbatim only where the value scan would leave it alone, and a recorded secret crossing a placeholder's edge refuses. A leaf with two or more such parts, or one the reassembly refuses, keeps the old value-scan answer (residual: #4446, since closed). cdkd deploycounts what a nested stack's child left unaddressed, and exits 2 for it (issue #1989) --src/provisioning/{nested-stack-context,providers/nested-stack-provider}.ts,src/deployment/deploy-engine/{provision,options}.ts. The parent discarded the child engine's result, so a skipped DELETE or an orphaned replacement predecessor inside a nested child (or grandchild) printed its own warning and nothing else: the parent'sSkipped (not deleted)/of which left an orphaned predecessorrows stayed at 0,RunCounts.skippedwas unset, the stack reportedDeployment completed successfully, and the run exited 0 (the #1960 exit code). The child'sdeleteSkipped/updatePartialnow reach the parent's counters through a per-row slot on the nested-stack context, recursively, so those rows, the warning banner, the events record and exit 2 (--allow-unaddressedopts out) cover nested children too. Tests:tests/unit/deployment/deploy-engine-nested-child-unaddressed{,-e2e}.test.ts,tests/unit/provisioning/nested-stack-provider.test.ts; integ:nested-stack-3level.- cdkd now refuses, instead of substituting the placeholder account
123456789012, when STS cannot name the deploy's account andAWS_ACCOUNT_IDis unset (issue #1730) --src/deployment/intrinsic-resolver/{account-drain,functions,getatt-heal}.ts,src/utils/error-handler.ts(AccountIdUnavailableError), and the Cloud Control / SSM / AppSync / API Gateway V2 / custom-resource providers andcdkd import. A failedsts:GetCallerIdentityused to answer123456789012with only a warning, soRef: AWS::AccountIdandAWS::StackIdserved it silently and a custom resource'sStackIdcarried000000000000. Now they refuse withCannot determine the AWS account id: ..., withholding AWS-authored message text from the STS failure, naming theaws sts get-caller-identitycheck and theAWS_ACCOUNT_IDescape hatch, which must be 12 digits.AWS::Region,AWS::Partition,Fn::GetAZsand anyFn::GetAttwhose value does not embed the account still resolve. An ARN a provider records after the resource exists is left out with a warning rather than failing the create;cdkd importleaves an account-bearing name unresolved; a custom-resource Delete asks again for about 17 seconds before it is reported skipped. A failure is never cached, so the next lookup asks STS again; the 10-second placeholder window is gone.
Recently Implemented (2026-10-02):
- Two copies of one stack in an account and region get their own FSx and EFS file systems and CloudFront origin access identities (issue #4428) --
src/provisioning/providers/{idempotency-token,fsx-filesystem-provider,efs-provider,cloudfront-oai-provider}.ts. The FSxClientRequestToken, EFSCreationTokenand OAICallerReferencecame from the logical id (and create-only properties) only, so a Dev and a Staging copy sent the same token: FSx and CloudFront handed the second copy the FIRST copy's resource, which itscdkd destroythen deleted, and EFS refused the second deploy. All three now cover the stack name and region, stay stable across retries and runs of one stack, and fit the APIs' length limits. A create takes a handed-back or named file system only when it was created after the create's first send, judged on AWS's clock (the responseDate), so a fast host clock does not refuse ordinary creates: EFS adopts its own lost-response retry (which used to fail), and a file system an earlier destroy kept is refused with its id and an inspect command instead of being recorded, and so later deleted. If you deployed stack copies with an earlier cdkd, compare their state files for a shared FSxFileSystemIdor OAI id before destroying either. Re-running an INTERRUPTED deploy now refuses the FSx file system the interrupted run made, as EFS already did, naming it with an inspect command: import it into the stack or delete it, then deploy again (#4438 tracks adopting it). A create interrupted under an earlier cdkd sends the new token and so creates a fresh resource. Tests:tests/unit/provisioning/{stack-scoped-create-token,cloudfront-oai-provider,providers/efs-filesystem-creation-token-4428,providers/fsx-filesystem-provider}.test.ts; integ:stack-copy-create-tokens. - A Cloud Control resource whose write-only property holds a create-only value is updated in place again (issue #4416) --
src/provisioning/{cloud-control-provider,write-only-properties}.ts. The update path re-adds every write-only property to the patch (#809), so these failed every update with "createOnlyProperties [...] cannot be updated", even when only an unrelated property changed: anAWS::Cognito::ManagedLoginBranding(ClientIdis write-only and create-only), a Kinesis- / DynamoDB- / Kafka- / MQ-sourceAWS::Pipes::Pipe(write-onlySourceParametersholding a create-onlyStartingPosition/ topic / queue), anAWS::CodePipeline::CustomActionType(create-onlySettings/ConfigurationPropertiesholding write-only leaves). Cloud Control refuses any patch bringing such a value into the model its read handler returns, unchanged or not. An UNCHANGED write-only property whose value holds such a path is no longer re-added, so it sends no op; an SQS-source pipe'sSourceParametersis still re-added. A CHANGED one still fails: Cloud Control cannot express that update at all (a Kinesis-source pipe'sBatchSize, issue #4423). Tests:tests/unit/provisioning/cloud-control-provider.test.ts; integ:cc-write-only-create-only. - A resource stored inside a parent the deploy destroys and re-creates under the same physical id is now re-created with it (issue #4411) --
src/deployment/child-of-recreated-parent.ts,src/deployment/deploy-engine/update.ts,src/deployment/deploy-engine/update-replace.ts,src/deployment/deploy-engine.ts. Recreating a fixed-name Lambda function (--recreate-via-cc-api/--recreate-via-sdk-provider, or a delete-first--replace) deletes its resource-based policy with it, but theAWS::Lambda::Permissionreading the function resolved exactly as recorded, so the deploy skipped it as unchanged: after a green deploy the new function had no permission while state still recorded one, and its trigger stopped invoking it. The engine now records each resource it destroyed and re-created under the same id, and a child of one -- a LambdaPermission,Version,AliasorEventInvokeConfig, an SNSSubscription,TopicPolicyorTopicInlinePolicy, an SQSQueuePolicyorQueueInlinePolicy, an S3BucketPolicy, a LogsLogStream,MetricFilterorSubscriptionFilter, an IAMRolePolicyor role-attachedPolicy-- is created again without deleting the old one, which went with its parent (a delete would remove the fresh statement, since a permission's id is its statement id); a permission on an alias of the function is covered too, and a policy naming several parents (topic, queue, multi-role IAM policy) is written again in place.src/analyzer/diff-calculator.tsmarks such a child as replaced whatever the schema lookup answers, so its own readers are promoted. Residuals: go-to-k/cdkd#4443, go-to-k/cdkd#4444. Tests:tests/unit/deployment/child-of-recreated-parent.test.ts,tests/unit/deployment/deploy-engine-child-of-recreated-parent.test.ts. - A failed create no longer deletes a pre-existing ELBv2 load balancer or target group, SNS topic or EventBridge rule that the create handed back (issue #4403) --
src/provisioning/providers/create-ownership.ts(new),src/provisioning/providers/{elbv2,sns-topic,eventbridge-rule}-provider.ts,docs/provider-rules.md, unit cases per site. These create APIs answer success with a resource that already held the name (ELBv2 on identical settings, SNSCreateTopic, EventBridgePutRule, which overwrites). When a later step of the same create failed (a load balancer'sactivewait or attributes, a target group's attributes or targets, a topic's policies, delivery logging or subscriptions, a rule's targets), the provider's cleanup deleted whatever the create returned, so another stack's or a hand-made resource of that name was deleted, and an EventBridge rule was stripped of its targets first. Each provider now looks the name it sends up before the create, only when such a step is declared:Describe*by name for ELBv2,GetTopicAttributeson the ARN built from STS for SNS,DescribeRuleon the rule's bus for EventBridge. Only the service's own not-found answer lets the cleanup run. A name that was held is left in place with a warning that it existed before this create attempt (an earlier attempt or failed deploy, another stack, or someone else), that this deploy may have changed its configuration, and that a cdkd-generated name is adopted by the next deploy, with the delete command to use only once it is confirmed no one else's; a lookup that could not answer (an ELBv2 empty answer included) leaves the resource too, with the delete command to run if this deploy created it. The EventBridge rule's cleanup warnings now go through the deploy's secret masker. The lookup and the create are two calls, so a resource created between them is still treated as this create's. cdkd destroy --remove-protectionnow restores a Cloud Control-routed Auto Scaling group's guards after a retry that later fails terminally (issue #4400) --src/provisioning/cloud-control-provider.ts. A group recorded asprovisionedBy: cc-apiis deleted through the SDK Auto Scaling provider, which was built afresh on every attempt and so forgot what an earlier attempt had turned off. When one attempt turned the group'sDeletionProtectionand its instances'DisableApiTerminationoff and failed retryably, and the next failed terminally, neither guard was turned back on. The delegate is now kept for the Cloud Control provider's lifetime (one per region), as the SDK route already did, so the terminal failure restores both. Tests:tests/unit/provisioning/cloud-control-asg-delegate-reuse-4400.test.ts.- A
--recreate-via-cc-api/--recreate-via-sdk-providertarget's same-stack readers now follow the id the recreate mints (issue #4383) --src/analyzer/diff-calculator.ts,src/deployment/recreate-target-promotion.ts,src/deployment/recreate-target-readers.ts,src/deployment/deploy-engine/deploy-flow.ts,src/cli/commands/deploy.ts,src/cli/commands/recreate-confirm-prompt.ts. For a type whose physical id AWS assigns (anAWS::EC2::SecurityGroup, anAWS::ApiGateway::RestApi), aRef/Fn::GetAttreader the template did not touch kept the deleted resource's id in AWS and in state after a green deploy, also when the target had an in-place change. The diff now promotes the stack's own targets' readers as for a property-driven replacement (never a nested child's or another stack's), and the counts and--dry-runinclude them. A reader holding the target in a create-only property is replaced; the confirmation prompt lists those as "Replaced if the id changes" (DATA LOSS for a stateful one) instead of saying sibling resources are unaffected, and a stateful one without--force-stateful-recreationis now refused before anything is touched rather than after the target was recreated. Tests:tests/unit/analyzer/diff-calculator-recreate-target-dependents.test.ts,tests/unit/deployment/deploy-engine-recreate-target-dependents.test.ts,tests/unit/deployment/recreate-target-readers.test.ts,tests/unit/cli/deploy-recreate-replaced-readers-preflight.test.ts. cdkd deploynow refuses a list where the resource type's schema wants an object, or the reverse, before any provider runs (issue #4357) --src/provisioning/property-shape.ts+.generated.ts,src/provisioning/provider-registry.ts,scripts/gen-property-shape.ts,scripts/refresh-cfn-schemas.mjs,tests/fixtures/cfn-schemas/*.json. CloudFormation validates properties against the type schema up front (Properties validation failed ... expected type: JSONArray, found: JSONObject); cdkd forwarded such a value, which then failed inside an SDK call with an unrelated-looking error or was silently dropped. The deploy pre-flight now names each offending resource, type and property path in one error, in CloudFormation's words. Only array-versus-object contradictions the schema pins exactly are refused: scalars, type lists, disagreeingoneOf/anyOfarms, legacy types whose schema CloudFormation does not validate against (nohandlers), unknown types and paths, and every intrinsic (Ref,Fn::*) pass. The per-path table is generated offline from the committed schema fixtures (newpropertyShapessection,vp run gen:property-shape); no AWS call is added. Tests:tests/unit/provisioning/property-shape.test.ts,tests/unit/scripts/gen-property-shape.test.ts.- An
AWS::EC2::SecurityGroupIngresscreate no longer adopts an identical rule it did not create (issue #4355) --src/provisioning/providers/ec2-provider.ts,src/deployment/prior-attempt-scope.ts,src/deployment/deploy-engine/provision.ts,src/deployment/deploy-engine/execute.ts,src/utils/aws-clients.ts. WhenAuthorizeSecurityGroupIngressansweredInvalidPermission.Duplicate, the create treated it as success and recorded the existing rule as the stack's, socdkd destroylater revoked a rule added by hand or by another stack. The rule is now adopted only when this stack's rollback journal shows the same logical id attempted the same rule, with no later delete of it (a re-run after a failed or interrupted deploy, including one whose earlier attempt got no definite answer from AWS), or during a rollback replay; otherwise the deploy refuses, naming the existingsgr-id with a revoke command. The journal is read only on that duplicate path, and a refused op (or an Authorize AWS provably did not apply, judged per send: the Authorize is sent without SDK retries) is journaled without its attempted properties, so the refusal itself never reads as a prior attempt on the next deploy andcdkd rollback --revert-failednever reverts an update from the refused rule; it re-authorizes the previous rule the refused update had revoked. Deleting a rule now revokes it by its recordedsgr-id (a stale id: the one live rule matching its identity and description), and a revoke by properties no longer sends the description: a recorded description the live rule lacked made AWS answer not-found, read as already deleted. Tests:tests/unit/provisioning/ec2-sg-ingress-duplicate-ownership.test.ts,tests/unit/deployment/deploy-engine-prior-attempt-binding.test.ts,tests/unit/deployment/deploy-engine-rollback-journal.test.ts; integ arm:tests/integration/sg-circular-dependencyphase 1e. - A refusal or update log no longer prints a resource name taken from a secret that has since been rotated (issue #4339) --
src/provisioning/providers/{asg,elbv2,iam-managed-policy,logs-loggroup,s3-vectors,rds-dbproxy,rds-dbproxy-endpoint,kinesis-streamconsumer}-provider.ts,src/provisioning/replacement-protection-advice.ts. After a secret rotation under an unchanged{{resolve:...}}reference, the recorded physical id names the PRE-rotation value, which this deploy's masker never resolved. The AutoScalingGroup and ELBv2 LoadBalancer deletion-protection remedies pasted it as the command's identifier, the LogGroup class-change refusal pasted the log group name with no masker at all (so even an unrotated secret-derived name printed), and the ASG / LogGroup / TargetGroup updating lines, the IAM ManagedPolicy replacement line (its ARN carries the oldPath) and that replacement's inner delete lines printed it at debug level; the S3 Vectors create-only refusal and the DBProxy / DBProxyEndpoint / StreamConsumer update lines and failure texts named it too. Each site now pairs the physical id (or the name / path its ARN carries) with the recorded reference throughwithDerivedNameMasks, so the pasted command is withheld (the console route stays) and the lines mask it. The DynamoDB GlobalTable remedy already did this (#2177); the Cognito and EMR remedies name AWS-minted ids. Tests:tests/unit/provisioning/recorded-secret-identifier-masking-4339.test.ts. - A Cloud Control status poll that fails with
EADDRNOTAVAILis now re-polled instead of abandoning the wait (issue #4331) --src/provisioning/cloud-control-provider.ts,src/deployment/retryable-errors.ts.EADDRNOTAVAILis a local-address socket failure, seen on a machine running several deploys at once (read EADDRNOTAVAIL). The poll's transport classifier did not list it, so one such failure ended the wait with "the failure is not retryable": a CREATE that Cloud Control had accepted kept running in AWS with no cdkd state record, untracked by rollback andcdkd destroy(observed with an EKS cluster). The code now joins the other socket codes, matched on the error'scodeor, when a wrapper dropped it, in its message, and the poll -- a read of the same request token -- is retried within the existing transient grace. It also joins the socket codes cdkd treats as an ambiguous outcome (the call may have reached the service), since itsreadform can follow a request that was already written. Tests:tests/unit/provisioning/cloud-control-wait-abandoned.test.ts,tests/unit/deployment/retry-ambiguous-create-replay-3978.test.ts. AWS::ECS::Service'sVpcLatticeConfigurations[].AdvancedConfigurationnow reaches AWS instead of being silently dropped (PR #4324) -- The ECS SDK provider forwardsVpcLatticeConfigurationsthrough a generic key converter, and@aws-sdk/client-ecs3.1140.0'sVpcLatticeConfigurationhad noadvancedConfigurationmember, so the serializer discarded the block (AlternateTargetGroupArn,ProductionListenerRule,TestListenerRule) on create and update with no error. Changed:package.json/pnpm-lock.yaml(@aws-sdk/client-ecs^3.1144.0, the first release that models the member).cdkd destroy --remove-protectionputs a deletion guard back when the delete gives up at its attempt cap (issue #4318) --src/cli/commands/destroy-runner.ts,src/provisioning/providers/deletion-protection-compensation.ts. The guard cdkd turned off was restored only when the delete failed terminally, so a refusal that kept reading as retryable (an IAM deny wordednot authorized to perform) ran the destroy loop to its attempt cap and left the guard off on a live resource with no message. The loop now marks its last attempt (runDeleteAttempt), and any failure there is compensated: the guard is turned back on, or, on a Cloud Control delete that may already be running, the warning naming the check and restore commands is printed. The compensation also classifies a failure as the loop does, reading the cause chain: a Cloud Control delete wait abandoned over a withheld access denial was counted as final while the loop retried it, so a refusal on that retry left the guard off. A per-resource--resource-timeoutstill restores nothing. Tests:tests/unit/cli/destroy-runner-remove-protection-cap.test.ts,tests/unit/provisioning/providers/deletion-protection-final-attempt.test.ts,tests/unit/provisioning/{cloud-control-remove-protection-compensate,providers/logs-cognito-remove-protection-compensate}.test.ts.- A Cloud Control-routed resource whose create-only property comes from a secret reference no longer puts that property into every update's patch (issue #4275) --
src/provisioning/cloud-control-provider.ts. State records a secret-derived value as its{{resolve:...}}expression, whileupdate()receives the resolved value, so the JSON Patch built from the two always carried an op at that path, even though the template had not changed it. On a create-only path such as a Logs MetricFilter'sFilterNamethat op asks Cloud Control to update a property it cannot update, and after a secret rotation it carries a value the resource does not have. Now, on a key the engine replaces on any change, the patch is left without that key when the record holds a reference and the desired value is a secret this deploy resolved. A changed literal, a missing masker and a mutable key still produce their op, as before (a key that is also write-only: see #4416). Tests:tests/unit/provisioning/cloud-control-provider.test.ts. The fixturesecret-derived-immutable-namesgains a secret-named MetricFilter. - An ACM certificate or Lambda MicroVM image whose immutable value comes from a secret is updated in place again (issue #4275) --
src/provisioning/providers/{acm-certificate,lambda-microvm-image}-provider.ts. State keeps a secret-derived value as its{{resolve:...}}reference whileupdate()receives the resolved value, so an ACM certificate whoseDomainName,SubjectAlternativeNames,DomainValidationOptions,CertificateAuthorityArnorKeyAlgorithmcame from a secret was REPLACED on every update (a new certificate requested and the old one deleted), and a MicroVM image whoseNamedid was refused on every update. Both guards now useunchangedBehindSecretReference's masker arm, on create-only keys only: neither physical id carries the value, and both providers' writes address the resource by its ARN. A real change, a recorded***and a key the engine does not replace on (ValidationMethod) still replace or refuse as before. The MicroVM refusal now masks the resolved name it prints. Tests:tests/unit/provisioning/secret-reference-immutable-sites-acm-microvm.test.ts. - Rolling back a same-deploy inline policy swap or hand-off no longer removes the name another revert has just put back (
src/deployment/inline-policy-claims.ts,src/deployment/rollback-executor.ts,src/provisioning/providers/iam-role-provider.ts,src/provisioning/providers/iam-user-group-provider.ts, go-to-k/cdkd#4225). AnAWS::IAM::Policyrename is journaled with a new physical id, so a rollback reverses it by re-creating the old policy and deleting the new one. When two policies swapped names on one role, the second reversal's delete removed the name the first one had re-created, and the role ended up with only one of the two policies. When a policy had renamed away from a name the role's ownPoliciestook, the role's revert removed the name the policy's reversal had put back. Each rollback removal now asks, as a deploy's has since go-to-k/cdkd#4156, whether another revert of the same rollback has already put that name on that principal, reading what that revert recorded, and keeps the name when it has. This covers the rollback's deletes and updates of anAWS::IAM::Policy(a reversed rename or re-adopted replacement, an in-place revert, a rolled-back create,--revert-failed) and a role, group or user revert dropping aPoliciesentry.cdkd rollbackshares one record across failed ops, completed ops and segments. A revert that has not run, failed, came back partial or went through Cloud Control counts for nothing, and neither does one whose record a later op replaced, so the removal proceeds. Rolling back a created policy that took an old policy's name still removes the old policy's grant (go-to-k/cdkd#4408). Tests:tests/unit/deployment/rollback-executor-inline-policy-claims.test.ts,tests/unit/provisioning/iam-principal-inline-policy-claims.test.ts,tests/unit/cli/rollback-inline-policy-claims.test.ts,tests/unit/deployment/nested-child-journal.test.ts; fixture:iam-role-policies-drift-clean(forced-rollback phase). cdkd deployrefuses anExport.Nameholding anyNoEcho: trueparameter's value, whatever reads it and in any declaration order (issue #4043, Phase B) --src/deployment/deploy-engine/outputs.ts,src/deployment/outputs-export-alias.ts(noEchoParameterValueSeed),src/analyzer/outputs-diff.ts,src/cli/commands/diff-recursive.ts. Before, the refusal counted a value only when an output value, or theExport.Nameof the same or an EARLIER-declared output, read it. So a literal name spelling a value only a resource reads, a name holding a needle only a LATERExport.Namerecorded (anFn::Base64encoding), and a name reaching this stack's value through an attribute were published as keys ofstate.outputs,exportNamesand the exports index, in plaintext. Now the verdict is seeded with everyNoEchovalue the stack holds (on a nested child, each parentNoEchovalue a child parameter carries), and everyExport.Nameis resolved before any alias is decided. ANumbercounts as the operator wrote it too (0x1F2A), and a refusal caused only by aNoEchovalue says so in its warning. The floor is unchanged: a name equal to the value at any length, one containing it at 4 or more characters. An unrelated name that happens to contain a shortNoEchovalue is now refused too, the same bound secrets already take.cdkd diffpreviews the same verdict. Still published: another stack's value throughFn::ImportValue, a piece or encoding of the value that a resource computes and an attribute echoes, a nested child's parameter fed a parent value without aRef(whichcdkd diffrefuses), a 1-3 character value inside a longer name, and a failed output's alias carried forward by a deploy with no resource change.- A CREATE whose retry collides after an ambiguous earlier attempt now prints recovery advice instead of only the raw "already exists" (issue #3984) --
src/deployment/deploy-engine/name-collision.ts,src/deployment/retryable-errors.ts(newisReplayedNameCollisionFrom),src/provisioning/auxiliary-failure.ts(RETRY_AUXILIARY_OWNER,isAuxiliaryMarkOf),src/deployment/retry.ts. Since #3978, a collisionwithRetryraises after an attempt that may already have made the resource (a 5xx, a Cloud Control handler failure, an auxiliary-call failure) is withheld from the name-collision verdict so no delete-first site acts on it. The #2902 orphan advice read the same verdict and went silent, in the case it fits best: the holder is most likely this create's own resource, in no state file, so no rollback orcdkd destroyremoves it. Such a collision on a cdkd-derived name now gets its own line: the earlier attempt ended without a clear verdict, so this create most likely made the resource; confirm it is yours, thencdkd importit or delete it. Advice only: the delete-first sites still read the withholding classifier. A provider-marked auxiliary collision and a template-supplied name stay silent, as before. - A replacement or create that names an ELBv2 load balancer or target group after one that already exists is refused before anything changes, and
cdkd importresolves either by its templateName(issue #3937) --src/deployment/replacement-name-holder.ts(NAME_ADOPTING_SDK_CREATE_TYPES,replacementSentNameMoves),src/provisioning/providers/elbv2-provider.ts(import(),sentElbv2Name),docs/cli-deploy-safety.md, unit cases and target-group, load-balancer, prefix-flag and plain-create arms intests/integration/replacement-rename-onto-holder/.CreateLoadBalancerandCreateTargetGroupreturn the existing resource when one of the requested name has the same settings, so a rename onto it "succeeded", deleted the stack's own resource and recorded the other one for a latercdkd destroyto delete; a plain create with an explicitNameadopted the same way. The SDK route now looks the name up first, as for the other name-adopting types: a resource found there, or a lookup that fails, fails the deploy with nothing created or deleted. The lookup asks for the name the create sends, stack-name prefix included under--prefix-user-supplied-names, and also runs when only that flag changed the sent name; a secret-derivedNameis masked in the spelling the provider sends. Before, the provider'simport()reported every ELBv2 load balancer and target group without a--resourceoverride as not found, thoughdocs/import.mdlisted both as resolved by their template name;cdkd importlooks up the unprefixed name, and a lookup that fails is a failed row. --allis refused when a CDK Stage failed to load beside surviving stacks (issue #3507) --src/cli/stack-matcher.ts(renderAllWithFailedStages),src/cli/commands/deploy.ts,destroy.ts,diff.tsandpublish-assets.ts, with unit tests intests/unit/cli/stack-matcher.test.ts,deploy-unaddressed-exit.test.ts,destroy.test.ts,diff-failed-stage-selection.test.tsandpublish-assets.test.ts. A Stage whose manifest could not be read drops every stack under it from the synthesized app, socdkd deploy --all,cdkd destroy --all,cdkd diff --allandcdkd publish-assets --allacted on the stacks that did synthesize and exited 0, with the synthesis warning as the only sign that part of the app was skipped -- a destroy left the Stage's stacks running. Each now exits 1 before any stack is acted on, with--all would <verb> only part of this app; refusing. Synthesized: <stacks>, the failed-Stage note, and the way through: fix the Stage, or name the stacks explicitly. Named stacks and patterns are unchanged, as is--allwhen every Stage loaded.- A command run with no stack named no longer auto-selects the only stack that synthesized when a CDK Stage failed to load (issue #3507) --
src/cli/stack-matcher.ts(renderAutoPickWithFailedStages),src/cli/commands/deploy.ts,destroy.ts,diff.ts,publish-assets.ts,scrub.ts,import.ts,export.tsandorphan.ts, with unit tests beside each command's existing failed-Stage cases and the bare arm oftests/integration/stage-assetsPhase 2g. A Stage whose manifest could not be read drops every stack under it from the synthesized app, so in an app with one surviving stack a barecdkd deploy(ordestroy,diff,publish-assets,scrub,import,export) picked that stack without a prompt, as if it were the app's only one;cdkd scrub --dry-run --failthen reported the app clean and exited 0. Each now refuses before any stack's state is read and before any lock or write, withWith no stack named, cdkd would <verb> only part of this app; refusing. Synthesized: <stacks>, the failed-Stage note, and the way through -- the sentence--allgained in #4387. A baredestroywhose surviving stack had no state record printedNo stacks found in stateand exited 0; it refuses the same way, before the state bucket is listed.scrubexits 2 (SCRUB_AUTO_PICK_PARTIAL_APP), including a bare run where no stack synthesized at all (which exited 1, the code--failuses for plaintext found); the rest exit 1.cdkd orphanhas no auto-pick; its construct path that matches no stack now names a Stage that failed to load. Naming the stack, and a bare run when every Stage loaded, are unchanged. cdkd exportnames a CDK Stage that failed to load when stack selection comes back empty (issue #3507) --src/cli/commands/export.ts, with unit tests intests/unit/cli/export-stack-selection.test.ts. A stack under a Stage whose manifest could not be read is dropped from the synthesized app, socdkd export MyStage/MyStackansweredStack 'MyStage/MyStack' not found in synthesized app, and an app whose only stacks sit under such a Stage answeredMultiple stacks found: .with no argument. export now raises the messagedeploy,diff,list,publish-assets,scrub,importandsynthprint:No stacks matching <arg> found in assembly. Available: <stacks>(orThe assembly has no stacks, orNo stacks found in assemblywith no argument), plus the failed-Stage note naming the targeted Stage or listing every failed Stage behindPossibly unrelated:. TheMultiple stacks foundlist now shows each stack's display path beside its physical name. Every selection refusal still comes before any state is read or locked, and exit codes are unchanged. The argument is still matched exactly, and an argument containing*is told so, since the shared wording reads as a pattern match.cdkd scrub --allis refused when a CDK Stage failed to load beside surviving stacks (issue #3507) --src/cli/commands/scrub.ts, through the sharedrenderAllWithFailedStagesinsrc/cli/stack-matcher.ts, with unit tests intests/unit/cli/commands/scrub-stack-selection.test.tsand thescrubarm oftests/integration/stage-assetsPhase 2g. A Stage whose manifest could not be read drops every stack under it from the synthesized app, socdkd scrub --allscrubbed the stacks that did synthesize and reported the state clean, while the Stage's stacks were never examined -- the synthesis warning was the only sign. It now exits 2 (SCRUB_ALL_PARTIAL_APP, scrub's "declined to look" code, distinct from--fail's 1) before any state read, with--all would scrub only part of this app; refusing. Synthesized: <stacks>, the failed-Stage note, and the way through: fix the Stage, or name the stacks explicitly -- the same refusaldeploy,destroy,diffandpublish-assetsgained in #4387.--allin an app whose every stack sat under a failed Stage, refused since #3935 with exit 1, now exits 2 with the same code. Named stacks and patterns are unchanged, as is--allwhen every Stage loaded.AWS::DynamoDB::Tablerefuses aPointInTimeRecoverySpecification.RecoveryPeriodInDaysCloudFormation rejects, before any AWS call (issue #3255) --src/provisioning/providers/dynamodb-table-provider.ts. A period spelled" 7 ","0x9","1e1",nullor an unresolved intrinsic used to be dropped with a warning:UpdateContinuousBackupsthen succeeded at DynamoDB's default period while cdkd state kept the declared value, socdkd driftreported a difference on every run andcdkd drift --revertre-sent the same call forever.create()andupdate()now refuse such a value, and one outside the API's 1-35 range, with a message naming the property, before any call, so no table is created only to be deleted and nothing is recorded to drift against. A non-objectPointInTimeRecoverySpecification, which was read as "PITR disabled", is refused the same way. The AWS CDK CLI cannot deploy these templates either. A rollback replay ordrift --revertkeeps the warn-and-drop, since their properties come from cdkd state rather than the template; an unchanged block an older cdkd recorded is not refused, since nothing is sent for it. Tests:tests/unit/provisioning/dynamodb-table-provider-cfn-integer.test.ts.--recreate-via-cc-api/--recreate-via-sdk-providernow recreate a target whose template did not change (issue #2651) -- newsrc/deployment/recreate-target-promotion.ts,src/deployment/deploy-engine/{deploy-flow,update}.ts,docs/cli-deploy-safety.md. Moving a resource between provisioning layers is not a template edit, so the diff called the target NO_CHANGE and never dispatched it: the deploy printed the consented recreate plan, then "No changes detected", and exited 0 with the record still on the old layer. A target the diff calls NO_CHANGE is now made an UPDATE before the deploy counts its changes, and the UPDATE arm's post-resolution no-op skip no longer applies to a target, so the destroy + recreate runs and the summary counts it as an update. A target this deploy deletes instead (aConditionnow false), or one it has no row for, is not recreated and is warned about by name. Also documents (issue #2666) that--force-stateful-recreationreaches nested child stacks, where it both clears the stateful guard and empties a replaced S3 bucket or ECR repository. Tests:tests/unit/deployment/{recreate-target-promotion,deploy-engine-recreate-target-no-change}.test.ts; thecc-to-sdk-rerouteinteg gains an unchanged-template recreate phase.- The deploy engine's stateful-replacement refusals now say when
--force-stateful-recreationcannot remove the resource because AWS protects it (issue #2610, sites 9-11) --src/provisioning/recorded-protection.ts(new),src/provisioning/protection-flags.ts(new, the protection table moved out ofsrc/cli/commands/destroy-runner.ts),src/deployment/deploy-engine/update-replace.ts,src/deployment/deploy-engine/update-in-place.ts,src/deployment/recreate-targets.ts. The property-driven replacement refusal, both arms of the update-failure fallback refusal and the--recreate-via-*stateful refusal told the user to re-run with--force-stateful-recreation(plus--replaceon one arm) even when cdkd's record of the resource carries a deletion / termination protection flag.cdkd deployhas no--remove-protection, so AWS refuses that delete: the deploy then failed at it, or completed and left the old resource and its data in AWS, untracked. When the recorded properties, or the AWS read-back cdkd stored, carry the flag, the refusal now names it and the bag it came from, says the flag alone does not remove the resource, points at "Deletion protection blocks a replacement" indocs/cli-deploy-safety.md, and asks for protection to be turned off out of band first. The flag is read through the same per-type table thecdkd destroyconfirm prompt counts with, and a target underUpdateReplacePolicy: Retaingets no note. Tests:tests/unit/provisioning/recorded-protection.test.ts,tests/unit/deployment/replacement-protection-note.test.ts,tests/unit/deployment/recreate-targets.test.ts. cdkd state destroynow records deployment events (issue #2423) --src/cli/commands/state.ts,src/cli/commands/destroy-runner.ts.cdkd state destroyran the same destroy ascdkd destroybut opened no event recorder, so it wrote nodeployments/{runId}.jsonlat all: its per-resource deletes, skips and suppressed safety checks left nothing forcdkd eventsto read once the run ended. It now records one run per destroyed stack and region, undercommand: destroy, with the sameRUN_FINISHEDresult and countscdkd destroyrecords (FAILEDon a resource error or skip).cdkd state destroystill takes no--purge-events;cdkd events prune '<stack>' --alldrops the history. The warning for an incomplete safety check now names only the nested-stack child destroy as recording nothing. Tests:tests/unit/cli/state-destroy.test.ts,tests/unit/cli/destroy-runner-guard-indeterminate.test.ts; integs3-lifecyclephase 0c-OK.cdkd destroy --remove-protectionrestoresDisableApiTerminationon the EC2 instances an Auto Scaling group launched when the group's delete then fails terminally (issue #2204) --src/provisioning/providers/asg-provider.tsand the sharedsettleProtectionFlipinsrc/provisioning/providers/deletion-protection-compensation.ts, with unit tests intests/unit/provisioning/providers/asg-instance-remove-protection-compensate.test.tsand a live arm in theremove-protection-compensationinteg (the destroy runs under a role deniedUpdateAutoScalingGroupon the group, so itsprevent-all-deletionrefuses the delete after the instance flip). Before, the group delete turned each launched instance's termination protection off (issue #796) and never put it back, so a failed destroy left the instances live with the guard stripped. Now each instance whose guard a pre-flipDescribeInstanceAttributesaw on, and this run turned off, is turned back on before the failure is reported, under the same rules as every other--remove-protectionsite: only on a terminal failure, never once AWS has accepted the group delete, and a failed re-enable is its own log line with a pasteableaws ec2 modify-instance-attributecommand. The flip now also needsec2:DescribeInstanceAttribute; a refused read leaves the delete running and compensates nothing.- A provider's own log lines now mask the resource's resolved secrets at the logger itself (issue #2177) --
src/utils/logger.ts,src/utils/log-line-masker.ts,src/deployment/resource-secrets-scope.ts. A provider'sdebug/info/warn/errorline reached no engine masking, so a{{resolve:...}}secret (or aNoEchoparameter value) it interpolated printed in plaintext unless that one call site threaded the masker, and about 256 such sites had not.ConsoleLoggernow masks every line emitted during a provider create or update with that resource's secret bag, on the live terminal and in the per-stack output buffer of a multi-stack deploy alike. The message is masked before terminal sanitizing, and each string inside an extra log argument before it is JSON-escaped. Lines outside a provider call, and every command that binds no bag, are unchanged and pay one async-context read. Not covered here: names a provider derives from a secret (still masked per site), anddelete()paths, which bind no bag (#2007). Tests:tests/unit/utils/logger-sink-secret-mask.test.ts. - An EMR cluster, instance fleet or instance group create that hits a 5xx now reports candidate orphans instead of duplicating silently (issue #2080, Plan C: EMR) --
src/provisioning/providers/{emr-cluster,emr-instance-fleet-config,emr-instance-group-config}-provider.ts,src/provisioning/providers/orphan-report.ts(renamed fromapigateway-orphan-report.ts, now shared),docs/troubleshooting.md.RunJobFlow,AddInstanceFleetandAddInstanceGroupsmint their id and carry no idempotency token, so a 5xx whose request had succeeded left a second cluster (or TASK fleet / group) billing per instance-hour with no state record. These creates no longer let the AWS SDK retry a 5xx inside one call; the 5xx reaches cdkd's retry, which first lists what the failed attempt may have made and warns about each match created during that attempt: a live cluster of the same name, or a TASK fleet / group of the same name (any name when the template sets none) in the same cluster. Each match comes with a read command, then a command to run only after confirming:terminate-clusters(with a note to turn termination protection off first when the template turns it on and the candidate shows it), or a scale to zero for a fleet / group, which EMR cannot remove. Nothing is adopted, terminated or scaled. A cluster holds at most one MASTER and one CORE, so those trigger no lookup; nor does a throttle, a 4xx or IAM propagation. The lookup needselasticmapreduce:ListClusters(clusters); without it cdkd warns and the create proceeds. A reset connection or timeout after the send is still retried inside the SDK and not reported (#3978 layer (b)). Tests:tests/unit/provisioning/emr-create-retry-2080.test.ts. - The error-cause critic now scans all of
src/, and four more wrappers keep the AWS / docker error they caught (issue #2075) --scripts/check-provider-error-cause.ts,src/utils/error-handler.ts,src/assets/docker-asset-publisher.ts,src/local/ecr-puller.ts,src/local/ecs-task-runner.ts. The retry classifiers find a throttle or a 5xx by walking an error's.cause, so a wrapper that drops the caught error is classified on its message alone. The critic used to check onlysrc/provisioning/providers; it now defaults tosrc/. A cause DERIVED on purpose (redactedDockerCause,normalizeAwsError,maskSecretsInError) is accepted only when imported from its own module, and a runtime test proves each one keeps the classifier verdict. The three deliberate omissions (two local file reads, one re-branded cdk-local error) are listed by name with an exact count. Fixed along the way:normalizeAwsErrornow chains the original SDK error under the message it rewrites, so an S3UnknownHTTP 503 on the state bucket reads as transient and keeps its request id; and thecdkd localECR pull / login / AssumeRole and ECS re-tag failures now carry their cause. Tests:tests/unit/scripts/provider-error-cause.test.ts,tests/unit/scripts/cause-composers-classify.test.ts. cdkd exportimports an HttpApi'sAWS::ApiGatewayV2::Stagein place instead of deleting it and letting CloudFormation re-create it (issue #1772) --src/cli/commands/export.ts,docs/cli-export.md, theexportinteg fixture and the export unit tests. The stage sat inIMPORT_UNSUPPORTED_RECREATABLE_TYPESbecause CloudFormation once refused it in IMPORT changesets, so every export of a stack with anHttpApi(CDK auto-emits a$defaultstage) pre-deleted the stage between the two phases and left the API serving no stage for roughly ten seconds until phase 2 re-created it. The registry now reports the typeFULLY_MUTABLEwith areadhandler and a[ApiId, StageName]primary identifier, and AWS's import-support table lists it as importable, so the stage leaves that list and its pre-delete handler, and joinsCOMPOSITE_ID_SPLITTERS: the bareStageNamethe SDK provider records is paired with the recordedApiId, and Cloud Control's<apiId>|<stageName>is split. Neither field is read-only, so the whole identifier map is overlaid.--no-recreate-import-unsupportedno longer applies to the stage, and the by-hand recovery after a failed pre-delete no longer printsaws apigatewayv2 delete-stage.- A multi-replica
AWS::DynamoDB::GlobalTablewhose template declares noStreamSpecificationnow records the stream cdkd enables for it, and an update no longer re-sends a stream the table already has (issue #1723) --src/provisioning/providers/dynamodb-globaltable-provider.ts(replicationImpliedStreamSpecification,create()'sneedsStreamarm,update()'s stream arm and its end, a newcanonicalizeDesiredProperties), a new unit file and thedynamodb-globaltable-implied-streaminteg fixture.create()enables aNEW_AND_OLD_IMAGESstream where the template is silent, but state recorded none. That was not phantom drift, but declaring the same stream later diffed as an UPDATE, andupdate()sentStreamEnabled: truefor a live stream (a stream change the CloudFormation docs allow only on a single-replica table). Nowcreate()records the stream,update()re-records it when the live table confirms it, and the twin folds it into both diff sides, so older records diffNO_CHANGEtoo.update()also skips the stream when the table already holds the declared view type, which covers older records and a rollback of a two-to-one replica change. Only the region-independent case (more than one replica, noMultiRegionConsistencydeclared) is recorded. A single replica outside the deploy region still records nothing, though the live check keeps a later declaration of its stream from being re-sent. Such a record that later moves to Cloud Control (a template adding a property cdkd's provider does not handle) now fails that update, since the patch asks to remove the recorded stream; the template is one CloudFormation rejects. AWS::CertificateManager::Certificate: removingCertificateTransparencyLoggingPreferencefrom the template now resets it toENABLED(issue #1160) --src/provisioning/providers/acm-certificate-provider.ts. Before, the removal was silently dropped:UpdateCertificateOptionskeeps an absent option, so a certificate opted out of transparency logging stayed opted out whilecdkd diffreported no changes. CloudFormation documents the removal as the same as declaringENABLED, so the provider now declares that reset throughremovalDefaults.CertificateExportcannot be updated after a certificate is created; the deploy already replaced the certificate on its change or removal (a createOnly property), and the provider's ownupdate()now does the same instead of sending anExportACM rejects or dropping the removal. The type is also audited intoremovalHandledInUpdate, so a removed property cdkd does not handle is warned about. Tests:tests/unit/provisioning/acm-certificate-provider.test.ts.- Removing an optional setting from an RDS DB proxy now warns that the live value is kept, instead of passing silently (issue #1160) --
src/provisioning/providers/rds-dbproxy-provider.ts. DroppingAuth,RequireTLS,IdleClientTimeout,DebugLoggingorVpcSecurityGroupIdsfrom anAWS::RDS::DBProxyreported a successful update while the proxy kept its old value, becauseModifyDBProxykeeps any setting it is not sent. cdkd still sends no reset, since CloudFormation's published DBProxy handler forwards the same omission, but the update now prints one warning naming each removed property and how to change it (a rollback revert words it as the state being restored), and a removal alone no longer issues an emptyModifyDBProxycall.cdkd drift --revertwarns about nothing. Tests:tests/unit/provisioning/rds-dbproxy-provider.test.ts. - EMR Cluster / InstanceFleetConfig: a property removed from the template that cdkd leaves in place is now named in a warning instead of being silently dropped, and no longer issues an empty
ModifyCluster/ModifyInstanceFleet(issue #1160) --src/provisioning/providers/emr-cluster-provider.ts,src/provisioning/providers/emr-instance-fleet-config-provider.ts. Both APIs keep a field they are not sent, and cdkd sends no reset for these removals. Before, removing a cluster'sStepConcurrencyLevel, or a fleet'sResizeSpecificationsorInstanceTypeConfigs, reported a successful update while AWS kept the old value, and when the removal was the only change cdkd still sent the Modify call (for a fleet, followed by a resize wait). Now the provider sends nothing for such a removal and names it in one warning per resource after a successful update, with no claim about what CloudFormation would do (both types declareremovalHandledInUpdate, so the shared caller adds no second line). A removed fleet capacity is still sent as 0, and the cluster's other removals (VisibleToAllUsers, the scaling and auto-termination policies,Tags) are applied as before. Tests:tests/unit/provisioning/providers/emr-cluster-provider-removal-1160.test.ts,tests/unit/provisioning/providers/emr-instance-fleet-config-provider-removal-1160.test.ts. - FSx FileSystem: a property removed from the template is now named in a warning instead of being silently dropped, and no longer issues an empty
UpdateFileSystem(issue #1160) --src/provisioning/providers/fsx-filesystem-provider.ts.UpdateFileSystemkeeps a field it is not sent, and cdkd sends no reset for these removals. Before, removingStorageCapacity,StorageType,FileSystemTypeVersion,NetworkTypeor a mutable<Variant>Configurationsub-property (LustreDataCompressionType/AutoImportPolicy, a WindowsFsrmConfiguration, ...) reported a successful update while AWS kept the old value, and when the removal was the only change cdkd sent anUpdateFileSystemcarrying no update field. Now the provider names every such removal, top-level and sub-property, in one warning per resource after a successful update, sends none of them, and makes no claim about what CloudFormation would do (the type declaresremovalHandledInUpdate, so the shared caller adds no second line). A removedRouteTableIdsis still applied as aRemoveRouteTableIdsdelta, and a removedTagsstill untags. Tests:tests/unit/provisioning/providers/fsx-filesystem-provider.test.ts. - A property removed from the template is reset through a provider declaration, and a removal cdkd does not reset is now named in a warning instead of passing silently (issues #1160, #4362) --
src/provisioning/update-removal.ts,src/types/resource.ts,src/deployment/deploy-engine/update-in-place.ts,src/deployment/rollback-executor.ts,src/cli/commands/drift.ts, the Lambda / IAM Role / ASG / ECS Service / RDS / Neptune / DocDB / ElastiCache / CloudTrail / Secrets Manager / Cloud Map namespace providers,src/deployment/deploy-engine/observed-capture.ts. A provider now declaresremovalDefaults(the CloudFormation default sent when a property disappears from the template); the deploy's in-place update and both rollback revert arms compute the removed properties once, template against template, and inject those values into the bag the provider receives, while state keeps the template. The constant resets those providers already sent move into the declaration unchanged; coerced, aliased, nested and conditional resets stay in the provider. A type whose update path is fully audited (removalHandledInUpdate: Lambda Function, IAM Role, the three Cloud Map namespaces) gets one warning per resource for a removed property cdkd leaves in place, e.g. LambdaRecursiveLoop.drift --revertinjects nothing: its previous side is an AWS readback. No AWS call is added. The post-create / post-update observed-state readback now runs inside the resource's secret scope, so its log lines are masked (#4362). Tests:tests/unit/provisioning/update-removal.test.ts,tests/unit/deployment/deploy-engine-removal-defaults.test.ts,tests/unit/deployment/observed-capture-secret-scope.test.ts, plus rollback / drift / Lambda cases.
Recently Implemented (2026-10-01):
- AWS published 1 writable property the 2026-10-01 schema refresh now carries, so cdkd classifies it instead of treating it as unrecognized (PR #4324) --
AWS::Kinesis::Stream:RecordDistributionStrategy. Changed:tests/fixtures/cfn-schemas/,src/provisioning/property-coverage.generated.ts(regenerated). OnAWS::Kinesis::Stream, no SDK provider writes it yet (not yet implemented by cdkd), so pre-flight classifies each as a silent drop and the issue #614 auto-route sends a resource whose template carries one through Cloud Control, which forwards the full property map. Since issue #3713 a key absent from the snapshot already routed so, unless the state record held it with the declared value; such a resource moves on its next deploy.AWS::Kinesis::Streamis not inSTICKY_CC_MIGRATION_EXEMPT, so an existing SDK-provisioned resource of that type whose template gains one moves to Cloud Control and pinsprovisionedBy: 'cc-api'ONE-WAY. The snapshot marks none of them create-only, socreateOnlyDropsand the record narrowing are unchanged; the capture is top-level-only, so it says nothing about a nested create-only path. - The KMS, Cognito user pool and AppSync orphan warnings now paste their
awscommands with--region(issue #4307) --src/provisioning/providers/{kms,cognito,appsync}-provider.ts. After a create attempt failed without a definite answer (or a KMS follow-up call failed afterCreateKey), these warnings name the possibly-orphaned resource and givedescribe/get/deletecommands to paste. The commands carried no--region, so for a stack deployed outside the shell's default region they ran against the wrong region: a describe answered NotFound and read as "no orphan", leaving a billed duplicate in place. The Cognito warning for a failed MFA-configuration restore had the same gap in itsget-user-pool-mfa-configcommand. Each command now carries the region of the client that created or listed the resource; when that region cannot be read, the warning is still printed without the fragment. Tests:tests/unit/provisioning/{kms,cognito,appsync}-create-retry-2080.test.ts,tests/unit/provisioning/cognito-provider.test.ts. - A masked FAILED Service Discovery (Cloud Map) operation keeps the retry classification its unmasked message gets (issue #4299) --
src/provisioning/providers/servicediscovery-provider.ts. Cloud Map reports a create/update rejection as a FAILED operation, andpollOperationmasks itsErrorMessagebefore raisingOperation failed for <id>: ...(#2063). That error carried nocause, so when a recorded secret spelled part of a retryable phrase (for exampledoes not exist), the mask cut the phrase out andwithRetryread the failure as terminal, where the same failure without the secret is retried. The rawErrorMessagenow rides as the unmaskedcauseand the error goes throughwrapMaskedAwsError, so a message the mask changed is stamped and the retry classifiers read that chain. The message is still masked, and a message the mask left unchanged is not stamped. Tests:tests/unit/provisioning/servicediscovery-provider-masked-retry.test.ts. - A
<placeholder>printed beside a flag is now quoted, and a non-plain value beside it is described (issue #4295) -- the--resource-timeout/--resource-warn-afterremedies insrc/cli/options.ts,src/utils/error-handler.tsand the ACM certificate, Lambda MicroVM image and CloudFront distribution providers, plus the--stack-region,--assume-role,--assume-task-role,--bearer-token,--region,--profile,--state-bucket,--from-cfn-stack,--origin,--ecr-role-arn,--output,--api,--stack,--template,--recreate-via-cc-apiand--older-thanremedies incdkd drift,orphan,gc,export, thecdkd localcommands, synthesis and macro expansion. Bare,--resource-timeout <duration>read stdin from a file nameddurationand>could truncate the next word when the line was pasted; these now print'<duration>','<region>','<arn>'and so on, one inert argument. On those lines, and on the ECS service--full-waitlines, a logical id, ARN, distribution id, stack name, region or container name that is not a plain identifier is now described (a logical id that is not a plain identifier,(not shown: it is not a plain identifier)) instead of printed raw or JSON-quoted, where a pasted$( )still ran. A plain value prints as before. - An AppSync, Auto Scaling, ELBv2 load balancer or IAM managed policy whose immutable value comes from a secret is updated in place again (issue #4275) --
src/provisioning/providers/{appsync,asg,elbv2,iam-managed-policy}-provider.ts. State keeps a secret-derived value as its{{resolve:...}}reference whileupdate()receives the resolved value, so the GraphQLApiName, DataSourceApiId/Name, ResolverApiId/TypeName/FieldName, ApiKeyApiId, AutoScalingGroupAutoScalingGroupNameand LoadBalancerName/Type/Schemeguards refused every in-place update, and a managed policy with a secret-derivedPathorDescriptionwas REPLACED on every update (a create under the same name and path, which IAM refuses). Each guard now usesunchangedBehindSecretReference: the value the physical id carries (group name, load balancer ARN, policy ARN path, AppSync composite id), the GraphQL API's live name fromGetGraphqlApi(a failed lookup refuses as a retryableProvisioningError), or, forType/Scheme/Description, the masker arm on a create-only key. A real rename and a rotated secret are still refused. The ASG updating line and the AppSync child ARN-rebuild warning mask the name. Tests:tests/unit/provisioning/secret-reference-immutable-sites-appsync-asg-elbv2-iam.test.ts; integ:secret-derived-immutable-names. - A resource whose name comes from a secret dynamic reference can be updated in place again (issues #4263, #4264, #4275) --
src/provisioning/secret-reference-immutable.tsand the API Gateway V2 Stage, DynamoDB GlobalTable, ECS Service, EMR Cluster / InstanceFleetConfig / InstanceGroupConfig, RDS DBProxy / DBProxyEndpoint / DBProxyTargetGroup, S3 Vectors VectorBucket and Kinesis StreamConsumer providers. State keeps a secret-derived value as its{{resolve:...}}reference whileupdate()receives the resolved value, so each provider's immutable-name guard saw a change on every in-place update and refused it, although the template never changed the name. A guard now compares the resolved value with the name the physical id carries (Stage, table, ECS service ARN, proxy, endpoint, vector bucket, consumer ARN), and otherwise lets it through only when the recorded side is a{{resolve:reference whose resolved value the deploy's masker recognises, on a key the engine replaces on any change (a re-pointed reference there is a replacement, never an update). A key whose resolved value addresses the provider's writes is confirmed with AWS first (a DBProxyTargetGroup's proxy name: the resolved names must return the recorded target group ARN) or never exempted (an EMR fleet's or group's cluster id), so a rotated secret cannot redirect a write. A Scheduler Schedule's secret-derivedGroupNamestays refused: nothing non-secret in the record identifies the group. A real rename, a recorded***without a physical-id witness, and an update with no masker are still refused. Update-path debug lines those names now reach are masked. Tests:tests/unit/provisioning/secret-reference-immutable.test.ts,tests/unit/provisioning/secret-reference-immutable-sites.test.ts. - The Glue, ACM certificate and Lambda MicroVM image import refusals no longer print a template value beside a
--resourceremedy (issue #4273) --src/provisioning/providers/{glue,acm-certificate,lambda-microvm-image,ssm-parameter}-provider.ts. The Glue Table warnings for an unpairable or ambiguous physical id, and the ACM / MicroVM "must be an ARN" refusals, showed the logical id raw or inside cdkd's own'...', the supplied physical id inside'...', and (Glue) the table and database names the ambiguity listed, on the same line as a--resourcefragment. A',;or$( )in the value ran when the line was pasted. Each value is now shown only when plain and described otherwise (a logical id that is not a plain identifier,(not shown: it is not a plain identifier)), and a|inside cdkd's quotes is still shown. The fragment names the logical id only when plain. A plain value prints as before. The rest of this rule landed with #4226. cdkd driftno longer compares a deleted ECS Cluster, Service or TaskDefinition that ECS still describes asINACTIVE(issue #4272) --src/provisioning/providers/ecs-provider.ts. ECS keeps describing a deleted resource for a while under a terminal status, andreadCurrentStatereturned its properties whenever the name came back, socdkd driftcompared a resource deleted outside cdkd as if it still existed. The read now finds nothing for anINACTIVEcluster (the ruleimportClusteralready applied), anINACTIVEservice, and anINACTIVEorDELETE_IN_PROGRESStask definition revision; every other status (aDRAININGservice, aPROVISIONINGcluster, an unknown value) stays present. Drift lists such a resource as not supported rather than as deleted; a distinct "deleted" outcome is #4283. The task definitionimport()applies the same two statuses, socdkd importwith an explicit revision, orphan adoption after a rollback, and the stale-attribute heal now treat a deregistered revision as not found instead of adopting it.- A
secretsmanager/ssm-securereference whose name was assembled from a secret is refused before cdkd looks it up (issue #4266) --src/deployment/intrinsic-function-resolver.ts,docs/troubleshooting.md; cases intests/unit/deployment/intrinsic-resolver-secret-assembled-reference.test.ts. The issue #4166 refusal ran only after the lookup returned a secret, so the Secrets Manager / SSM request had already carried the assembled id, with the other secret inside it, to AWS, where CloudTrail records it inrequestParameters. Both services are secret by spelling, so the token is now refused before the lookup, on the template route only. A plainssmreference is still looked up first, to learn its parameter type. Behaviour change: such a token whose lookup would have failed now reports the maskedRefusing to resolverefusal instead of the lookup error, and one whose secret value is empty is refused too. Persisted text (cdkd drift, the rollback replay) and the diff comparison path are unchanged. - Delete-skip and final-snapshot refusals,
cdkd eventsand the name-collision diagnosis no longer put a state value beside a command (issue #4265) --src/provisioning/final-snapshot.ts,src/deployment/delete-outcome.ts,src/cli/commands/events.ts,src/deployment/replacement-name-holder.ts, and two IAM skip reasons insrc/provisioning/providers/iam-{policy,user-group}-provider.ts. The DeletionPolicy: Snapshot refusals printed a state record's logical id and type raw besideaws rds create-db-snapshotand--skip-final-snapshot. The "cdkd did not confirm ... was deleted" sentence printed the id, physical id and skip reason beside--replace,--revert-failedor acdkd deployremedy. A$( )or;in such a value ran when the line was pasted. Both builders now describe a value that is not plain (a logical id that is not a plain identifier,a reason that cannot be shown safely here), for every caller; a plain id, type, ARN and cdkd's own skip reasons print as before.cdkd eventsprinted a recorded rollback refusal folded onto one line, joining its provider text to itsTo orphan it:command; each line of the error is now printed on its own, indented under the event. The collision diagnosis words "cdkd's rule" and "this type's provider" without an apostrophe. - A masked ELBv2 or CloudWatch Logs log group create/update failure is still retried when a secret overlaps AWS's retry wording (issue #4259) --
src/provisioning/providers/{elbv2,logs-loggroup}-provider.ts. These providers mask AWS's error text before joining it into theirFailed to create/update ...message (#2050, #2063). When a recorded secret spelled part of a retryable phrase (for exampledoes not exist), the mask cut that phrase out andwithRetryread the failure as terminal, so the deploy failed where it used to retry. The six ELBv2 wraps (LoadBalancer create, theupdate()outer wrap, TargetGroup and Listener create/update) and the two log group wraps now go throughwrapMaskedAwsError(the #4244 helper), which stampsmarkRedactedCausewhen the mask changed the text, so the retry classifiers read the unmasked cause chain. The message is still masked, and a wrap the mask left unchanged is not stamped. Tests:tests/unit/provisioning/elbv2-provider-masked-retry.test.tsandtests/unit/provisioning/logs-loggroup-provider-masked-retry.test.ts. - A masked Service Discovery or API Gateway (v1) create/update failure is still retried when a secret overlaps AWS's retry wording (issue #4259) --
src/provisioning/providers/{servicediscovery,apigateway}-provider.ts. These providers mask AWS's error text before joining it into theirFailed to create/update ...message (#2050, #2063, #2177). When a recorded secret, or a secret-derived Stage name, spelled part of a retryable phrase (for exampledoes not exist), the mask cut that phrase out andwithRetryread the failure as terminal, so the deploy failed where it used to retry. Each wrap now goes throughwrapMaskedAwsError(the #4244 helper), which stampsmarkRedactedCausewhen the mask changed the text, so the retry classifiers read the unmasked cause chain. The message is still masked, and a wrap the mask left unchanged is not stamped. Tests:tests/unit/provisioning/servicediscovery-provider-masked-retry.test.tsand theissue #4259block intests/unit/provisioning/apigateway-provider-masked-log.test.ts. - A backing Lambda that never becomes ready no longer puts its
GetFunctionresponse into the custom resource's error (issue #4255) --src/provisioning/providers/custom-resource-provider.ts. Before, a readiness waiter that TIMED OUT was relayed verbatim intoLambda backing custom resource ... did not reach a ready state for Invoke: ..., and@smithy/util-waiterkeys itsobservedResponsesby the serialized response whenever one carries no HTTP status, or by the raw body on a deserialization failure: the function'sEnvironment.Variablesin plaintext and its presignedCode.Locationcould reach the default-verbosity error line and the persisteddeployments/*.jsonlstore. The caught waiter error stayed in the cause chain with the payload, even on the FAILURE arm whose message already withheld it (a chain-walking render such as the CLI's fatalutil.inspectprints it), and the IAM-propagation recycle path debug-logged a failed update-wait's whole response. Now every waiter arm is described from an allowlist -- the waiter state and fixed reason, the function'sState*/LastUpdateStatus*fields,<status>: OKkeys, and each rejected poll asdescribeAwsFailuresummarizes it (403: AccessDeniedExceptionin the error, AWS's account / role / session sentence at--verbose; a transport failure such assocket hang upkeeps its wording, a credential failure is summarized by class; a deserialization failure, whose parse message quotes the body, by status and name only) -- the caught waiter error is rewritten in place to the same text, and the recycle line uses it as well. Tests:tests/unit/provisioning/custom-resource-provider-waiter-payload.test.ts, driving the real waiter. - A masked Glue, EFS, Secrets Manager, SSM Parameter or IAM create/update failure is still retried when a secret overlaps AWS's retry wording (issue #4244) --
src/deployment/retryable-errors.ts,src/provisioning/providers/{glue,efs,secretsmanager-secret,iam-role,iam-user-group,iam-policy,iam-managed-policy,iam-instance-profile,iam-access-key,ssm-parameter}-provider.ts. These providers mask AWS's error text before joining it into theirFailed to create/update ...message (#2177). When a recorded secret, or a 3+ character name resolved from one, spelled part of a retryable phrase (is unable to assume provided role,Invalid principal in policy,scheduled for deletion,does not exist), the mask cut that phrase out andwithRetryread the failure as terminal, so the deploy failed where it used to retry. Each wrap now goes through the newwrapMaskedAwsError, which stampsmarkRedactedCausewhen the mask changed the text, so the retry classifiers read the unmasked cause chain. The message is still masked. A wrap the mask left unchanged is not stamped, and cdkd's own template-value refusals are not stamped either. Tests: theissue #4244blocks intests/unit/provisioning/{glue-providers-masked-warn,efs-provider-masked-log,secretsmanager-secret-provider-masked-log,iam-providers-masked-warn,ssm-parameter-provider-masked-warn}.test.tsandtests/unit/deployment/retryable-errors.test.ts. - Lines that put cdkd's own
->before a template-, state- or AWS-chosen value now sayfrom <a> to <b>,then,resolved toorwith value(issue #4239) --src/analyzer/{dag-builder,diff-calculator}.ts,src/cli/commands/{local-invoke-agentcore,local-start-api,prefix-migration-check,state-migrate}.ts,src/local/{ecs-task-runner,invoke-agentcore-watch-loop}.ts,src/utils/nested-template-cycle.ts,src/provisioning/providers/{acm-certificate,codecommit-repository,iam-role,kinesis,lambda-microvm-image,logs-loggroup,s3-bucket}-provider.ts. Pasted into a shell,->is-plus a>redirect onto the bare value after it, so a chosen value (a logical id, a resource name, aType, a KinesisStreamMode, aLogGroupClass) could pick a file to truncate. The DAG edge lines, the DAG and ECSDependsOncycle refusals (which also no longer join two cycles with a;), the diff'sUPDATE (Type change)andrequires replacementlines, thelocal start-apiroute table, the nested-template cycle refusal, the--no-prefix-user-supplied-namesrename list,state migrate's prompt and summary, the AgentCorefromS3bucket and soft-reload lines, ACM's DNS validation row, the Kinesis update lines, theLogGroupClassrefusal (which now prints a class that is not a plain identifier asa class value that is not a plain identifierinstead of inside cdkd's own quotes, which a'in the value closed) and the provider rename / replacement lines are reworded; the same spelling indeploy-engine.ts,rollback-executor.ts,rollback.ts,drift.ts,events.tsand the scheduler provider remains. Tests:tests/unit/utils/arrow-paste-sites-4239.test.tsandtests/unit/provisioning/arrow-paste-sites-4239.test.ts, which paste each line through bash and zsh. cdkd rollback's Type-change plan rows and the SchedulerGroupNamerefusal sayfrom <a> to <b>instead of->, andcdkd rollbackdescribes a stack name or region that is not a plain identifier in its plan header, prompt and completion lines (issues #4239, #3760) --src/cli/commands/rollback.ts,src/provisioning/providers/scheduler-schedule-provider.ts. Pasted into a shell,->is-plus a>redirect onto the type or group name after it, so the pair dragged from areverse-replace/ failed-Type-change row, or from theGroupName addresses the schedulerefusal, created or truncated a file named by that value. The refusal also masks the group names where it builds the message, as the provider's debug line already did for a secret-derived group (go-to-k/cdkd#4275), and describes a group name that is not a plain identifier, since the line namescdkd deploy --replace. The rollback plan header, the confirmation prompt, theState for ... removedandRollback of ... completelines and the nested-stack plan preview printed a journal-key stack name with its interior spaces; in a run whose failed-persist warning prints aRe-run with:row (with that name withheld), a padded name could wrap on screen into a counterfeit row beside it. They now print the name only when it is a plain identifier and describe it otherwise, with a pointer tocdkd state list --longunder the header, and at the end of each nested-stack block, that described one; a nested-child preview whose read failed withholds the backend's error text when it names a padded child.cdkd importof an SSM parameter by an id the AWS CLI itself would act on names that reason when it withholds the read command (issue #4238) --src/provisioning/providers/ssm-parameter-provider.ts. For an import id holding:that starts withfile://or-(for examplefile://xor-p:1), the refusal withholds theaws ssm get-parameter --name ...command and points to the console. Its explanation used to name only the shell reason. It now uses the shared wording: "or it holds a character or prefix a pasted shell line, or the AWS CLI itself, would act on". Tests:tests/unit/provisioning/uncached-arn-attributes-issue-1824.test.ts.cdkd import's remaining lines and the provider import refusals no longer print a template value beside a command to paste (issue #4226) --src/cli/commands/{import,retire-cfn-stack}.ts,src/provisioning/import-helpers.tsand theroute53,s3-bucket-policy,sns-topic-policyandsqs-queue-policyproviders. These lines showed a logical id raw or inside cdkd's own'...', and some also a resource type, a supplied physical id, a templateBucket/Topics/Queues/HostedZoneNamevalue (JSON-quoted, where a shell still expands$( )), a recorded hosted zone id, the cloud assembly's stack name or region, or the template ids listed after a mistyped--resource, on the same line as acdkdcommand or a--resourcefragment. A',;or$( )in the value ran when the line was pasted. Each such value is now shown only when plain and described otherwise (for examplea logical id that is not a plain identifier,(not shown: it is not a plain identifier)), a--resourcefragment names the logical id only when plain and uses the'<logicalId>'hole otherwise, and every<...>placeholder in these fragments is quoted. The unresolved-intrinsics warning prints the resolver's message on its own line, and the CloudFormation lookup warning and the Route 53 NameServers warning print AWS's message on its own line, so the remedies start the next line. A plain value prints as before, except that these three warnings now span two lines and the policy refusals and the unresolved-intrinsics warning no longer put hand quotes around a plain logical id.- The EventBridge Scheduler schedule provider's
getAttributeerrors name the logical id in theirlogicalIdfield (issue #4222) --src/provisioning/providers/scheduler-schedule-provider.ts. The last twoProvisioningErrors from #4222 (an unknown attribute, and a failedGetSchedulelookup ofArnforcdkd orphan) carried the schedule's name, which can be built from a secret, in theirlogicalIdfield. They now carry the logical id (the fourthgetAttributeargument, #4228), with the schedule name inphysicalId. The source fence intests/unit/provisioning/logical-id-slot-4222.test.tsnow admits no exception.maskSecretsInErrorstill maskslogicalId. Tests:tests/unit/provisioning/scheduler-schedule-provider.test.ts. cdkd rollbackmessages no longer show a journal or state value on a line that also names a command (issue #4214) --src/deployment/rollback-executor.tsandsrc/cli/commands/rollback.ts. The rollback refusals and notes showed a resource type, a physical id, a role ARN, a nested stack name or a name-collision diagnosis besidecdkd deploy,cdkd rollback,--orphan,--revert-failed,--role-arnor--skip-final-snapshot, and a$( )in the value ran when the line was pasted into zsh. A value that is not a plain identifier is now described on that line instead (for examplea resource type that is not a plain identifier,a physical id that is not a plain identifier); a plain ARN, URL or type prints as before, and a described physical id comes with a pointer to the rollback journal and the state record. The name-holder collision refusal prints its diagnosis on its ownCollision diagnosis:line, and both collision refusals print the provider's text inside JSON quotes on theUnderlying collision:line. The per-opRollback failed forline, the unrestorable-baseline, redacted-baseline, region-ambiguous secret and unsupported final-snapshot refusals describe the logical id too (the redacted-baseline one printed it raw). The re-create failure and the Retain survivor warning name the fix in words instead of a command.- A pasteable
aws ...command no longer names a value the AWS CLI itself acts on (issue #4199) --src/provisioning/replacement-protection-advice.ts,src/provisioning/providers/{scheduler-schedule,ec2,eventbridge-rule}-provider.ts. The gate that provider messages route a pasteableawscommand's template- or state-borne values through (renderDisableCommandand thepasteableAwsCommandtag) admitted a value starting withfile:///fileb://, which the AWS CLI replaces with a LOCAL file's contents (so a pasted command could send, e.g., the operator's credentials file to AWS),http:///https://, which aws-cli v1 fetches, a shorthand@=(Key@=file://path, the same file load mid-word), and a leading-, which after a list-valued flag parses as an option such as--endpoint-url. Such a value is now withheld and the message points to the console; the scheduler's hand-built delete hint applies the same check (AWS_CLI_ACTIVE_ARG/isAwsCliLiteral). The EC2--no-waitre-associate hint also withholds an instance profile holding,, which insideArn=<value>shorthand starts another key. The EventBridge partial-create cleanup hint passes the listed target ids to--idsas one JSON word instead of throughjq | xargs, where a target id starting with-became an option (--region,--profile), and says that aremove-targetserror on a rule with no targets left is harmless. Tests:tests/unit/provisioning/pasteable-aws-cli-active-arg.test.ts,ec2-instance-no-wait-gate.test.ts,eventbridge-rule-provider-partial-create-cleanup.test.ts. - A plain create of an explicitly named queue, topic, rule, alarm, state machine, ECS cluster, S3 bucket or CloudWatch Logs log group no longer takes over a resource that already holds the name (issue #4180) --
src/deployment/deploy-engine-create.ts(refuseTakenCreateName),src/deployment/replacement-name-holder.ts(createNameQuestion,createLookupArn),cloudwatch-alarm-provider.ts, thereplacement-rename-onto-holderinteg (phases B5 / C5), a new unit file anddocs/cli-deploy-safety.md. These SDK create APIs hand back or overwrite an existing resource of the requested name instead of failing, so the deploy reported success, recorded the resource as the stack's, and a latercdkd destroydeleted it. The create arm now looks the name up through the provider'simport()first, as the replacement path has since #3937, and fails withNAMED_CREATE_COLLISIONand nothing created when a resource holds it or the lookup cannot run — CloudFormation's create fails with "already exists". That includes the stack's own resource left by an earlier interrupted deploy, which AWS cannot tell apart: delete it or adopt it withcdkd import. A log group something else already created (a Lambda function's, on its first invocation) is refused the same way, and the alarm lookup now sees composite alarms. A name cdkd generates is not looked up (#4345), and the Cloud Control route is unchanged. - A secret substituted into the name of a
secretsmanager/ssm/ssm-securereference is no longer persisted instate.jsoninside that reference (issue #4166) --src/deployment/intrinsic-function-resolver.ts,src/deployment/secret-redaction.ts,docs/troubleshooting.md; cases intests/unit/deployment/intrinsic-resolver-secret-assembled-reference.test.ts. A token such as{{resolve:ssm:/app/${Name}}}withNamea secret resolved, and the resolver recorded the assembled token as the expression of its secret result, so every state writer (the frame, skeleton, whole-value and substring arms) wrote the other secret's plaintext into state inside the reference. Such a token, with a secret in its name or in any other field (a JSON key, a version stage), is now refused with a masked, non-retryableRefusing to resolveonce it has resolved to a secret, before anything is recorded or cached, on the template route only; the detector is the one the unsupported-service refusal uses (issue #2743), except that a four-or-more-character secret found in the token's text counts only when a parent stack passed it in (a secret this stack's deploy resolved, coinciding with a literal token, does not). A secret whose value is itself reference text, resolved again, is refused too (Refusing to resolve ***), andcdkd importwarns with the refusal. Behaviour change: a resource property holding such a token now fails the deploy, which went green before; a stack Output holding one is skipped with a warning, as any failed output is (it fails the deploy under--strict-getatt). A public or empty result, a failed lookup, and persisted text (cdkd drift, the rollback replay) are unchanged.cdkd scrubleaves text inside a resolvable reference as written, so a record written before this change keeps the other secret until the template is fixed and redeployed. - A pasted
--verboseResolved …line no longer redirects into a file the template chose, and a value or name on it no longer runs when pasted (issue #4161) --src/deployment/intrinsic-function-resolver.ts. The resolver's debug lines forRef,Fn::GetAtt,Fn::FindInMap,Fn::Select,Fn::Split,Fn::Base64,Fn::GetAZsand the condition functions printed cdkd's own->right before a bare render, so a pasted line truncated a file named by the value (Resolved Ref to resource: Thing -> buckettruncated./bucket). They now readresolved to(normalized toon the legacyFn::GetAttattribute line); a script grepping the old spelling needs the new one. The values and names on those lines, and on theFn::Join,Fn::Sub,Fn::IfandAWS::NoValuelines, were bare too, so a template or state value such asx>OWNEDstill ran or redirected on its own. A value or name now prints only when it is shell-inert with its quotes stripped (a plain identifier or a***mask) and is not a shell assignment word (PATH=.,PATH+=:.); anything else readsa value that cannot be shown safely here. A JSON render (a list, an object, aFn::Select/Fn::Splitresult) prints while every string in it is inert, and theFn::Splitdelimiter follows the same rule. cdkd's own<redacted>token is unchanged, and so is masking. Not closed: a plain name that is itself a command word at a clause start (go-to-k/cdkd#4249), and the resolver's other log lines (go-to-k/cdkd#4250). Tests:tests/unit/deployment/resolver-resolved-line-paste-4161.test.ts, plus the pins in the resolver suites and thesecrets-dynamic-ref,nested-stack-secret,nested-stack-3levelandnoecho-parameter-maskinginteg fixtures. - A replacement renaming an ECS cluster onto a name an ACTIVE cluster already holds is refused before anything changes, and
cdkd importno longer adopts a deleted cluster (issue #3937; ELBv2 remains there) --src/deployment/replacement-name-holder.ts(NAME_ADOPTING_SDK_CREATE_TYPES),src/provisioning/providers/ecs-provider.ts(the clusterimport()),docs/cli-deploy-safety.md, unit cases and an ECS arm intests/integration/replacement-rename-onto-holder/.CreateClusterreturns an existing ACTIVE cluster of the requested name instead of refusing (measured), so a rename onto a stranger's cluster "succeeded", deleted the stack's own cluster and recorded the other one for a latercdkd destroyto delete. The SDK route now looks the newClusterNameup first, as for the other name-adopting types; a cluster found there, or a lookup that fails, fails the deploy with nothing created or deleted. The lookup is the clusterimport(), which returned a clusterDescribeClustersstill lists asINACTIVEafter its delete; that one is now absent, since its name is free to create again, and any other status still counts as the holder; a listing with no cluster and a failure reason other thanMISSINGnow fails the lookup rather than reading as free. A created cluster is recorded under the name ECS answers with. cdkd scrub's producer-plaintext refusal names a stack in its commands only when the name is a plain identifier, and a resource-timeout message no longer prints a planted logical id or type (issue #3773) --src/cli/commands/scrub.ts,src/utils/error-handler.ts. TheScrub with:andThen re-run:lines of theSCRUB_CROSS_STACK_PRODUCER_PLAINTEXTrefusal named a stack whose name was not a plain identifier (one holding:,@,,,%or+, or starting with_or.) shell-quoted. These lines are next to other labelled lines, where cdkd names a target only when it is a plain identifier. Such a name is now printed as a quoted'<stack>'placeholder. A line above the commands says why: for a name shown masked, that it holds a recorded secret. It points tocdkd state list --long. TheResourceTimeoutErrormessage (Resource X (TYPE) ... timed out, printed whole bycdkd destroy's failure line) printed the logical id and type raw. A line break or padding in either, from a state record or template, could open or wrap into a line that passes for one of cdkd's own. A value without the shape of a real one is now described instead. The--resource-timeout TYPE=<DURATION>remedy names only a type that flag accepts (three segments). For any other type, includingCustom::*, it suggests the global--resource-timeout <DURATION>. Tests:tests/unit/cli/commands/scrub-refusal-plain-ident-3773.test.ts,tests/unit/cli/destroy-runner-events.test.ts.cdkd scrub's producer-plaintext refusal no longer shows a name holding whitespace in the prose above its commands (issue #3773) --src/cli/commands/scrub.ts. TheSCRUB_CROSS_STACK_PRODUCER_PLAINTEXTrefusal ends inScrub with:andThen re-run:lines, and its prose showed the consumer and producer stack names, the stacks of a re-export chain, the export key, the logical id or output name and the property path inside quotes with any padding kept. A terminal wraps a long line at its width, so a planted name such asProd<60 spaces>Then re-run: cdkd scrub --all #could print a row that passes for one of the refusal's own commands. Each of those names is now shown only when it holds no whitespace after the display sanitizer (which turns a line break, a tab or a non-ASCII character into a space); otherwise the prose says(stack name withheld: it holds whitespace or a character outside printable ASCII). A CDK-generated export key with:, a bracketed property path and a masked name are still shown. Tests:tests/unit/cli/commands/scrub-refusal-plain-ident-3773.test.ts,tests/unit/cli/commands/scrub-import-value-secret.test.ts.cdkd exportnames the remedy a refused import baseline and a masked property really have (issues #3465, #2881) --src/cli/commands/export.ts,docs/cli-export.md. The drift-baseline report told every resource whose baseline acdkd importrefused to "deploy a change to each one", which does not clear anunverifiable-parameterrefusal, nor one an older cdkd recorded without a reason when the resource reads a template parameter. The list is now grouped by remedy through the samerefusedBaselineRemedytextcdkd state showandcdkd driftprint, and a reason-less refusal is classified against the template the CDK app synthesizes, as the next deploy reads it (with--template, which the next deploy does not read, it keeps the hedged remedy). The block on a resource whose recorded properties hold the redaction mask***named aNoEchoCustom Resource value and a mask copied from another record as its only causes; it now also names theFn::Base64encoding of a secret, which every deploy masks again, so its remedy is to stop encoding the secret into the property. Tests:tests/unit/cli/export.test.ts.cdkd state show,cdkd state refresh-observedandcdkd driftname the remedy a refused import baseline really has (issue #3465) --src/cli/commands/refused-baseline-remedy.ts,src/cli/commands/{state,drift}.ts,docs/cli-state.md,docs/cli-drift.md. Every message about a resource whose observed baseline acdkd importrefused told the user to deploy a change to it. That is false for anunverifiable-parameterrefusal, which an in-place update keeps: only a replacement or a re-import against a CloudFormation stack that proves the parameter clears it. For a refusal an older cdkd recorded without a reason it holds only when the resource reads no template parameter, which these template-less commands cannot tell. Thestate showrow, thedriftreport row, the--accept/--revertrefusals and their plans now read the record's reason and name the right remedy, keeping the deploy advice for the class a deploy does clear; therefresh-observedwarning counts the refusals a deploy may not clear and points atstate show. The pre-v10 drift warning now says a deploy does not repair a resource that reads a template parameter.cdkd export's refused-baseline list is not changed here. Tests:tests/unit/cli/{state-show,state-refresh-observed,drift,drift-plan-sanitize}.test.ts.cdkd exportno longer prints a template- or CLI-derived name raw, and refuses a record whosepropertiesmap it cannot read, in its own words (issues #3371, #4181, #3315) --src/cli/commands/export.ts,src/state/malformed-resources-bag.ts. Regions, the plan summary, cross-stack rows, CLI values (--stack-region,--parameter,--cfn-child-stack-name), template Parameter names and the state bucket rendered raw or kept',$and(; each now prints quoted when it is a plain identifier inert on a command line and as "(not shown: it is not a plain identifier)" otherwise ((us-east-1)becomes('us-east-1')). A non-plain logical id or type in a list row (import plan, phase-2 CREATE rows, pre-delete progress, import-failure summary, overlay texts) and a named id in thepropertiesrefusal are described the same way. A recorded value beside a recovery command (theAWS::EC2::Routerefusal and warning, the pre-delete row and failure) is described unless inert, so a$defaultStage id reads "(not shown ...)" there; elsewhere a value holding$,|or*($LATEST) is JSON-quoted. The by-hand phase-2create-change-setand the next-stepscdk diff/cdk deploycommands go through the shared command gate, so such a value becomes a quoted hole; anaws--stack-namethe AWS CLI itself would act on (file://,@=) is a hole too. Apropertiesmap that is not an object is refused by name at the load and over every nested child, under--dry-runtoo. The unreadable-resourcesrefusal now says what export does: it deletes the record after migrating, so removing the record is no way out, and a legacy record's text no longer says it was listed.cdkd drift --revert/--acceptname every cause of a masked baseline, and a pasted plan line redirects nothing (issues #2881, #4239) --src/cli/commands/drift.ts,docs/cli-drift.md. The--revertrefusal for a position whose baseline holds only the redaction mask named two causes, aNoEchocustom-resource value and a readback position cdkd could not certify. It now also names theFn::Base64encoding of a secret value (issues #2759 / #3119), which no custom resource is behind, and gives each non-NoEchocause its own remedy: a deploy that changes the resource sends aFn::Base64encoding again, and re-captures the baseline for an uncertified position. The--acceptrefusal of the same mask now scopes its re-capture remedy to the uncertified position, and says that no deploy replaces a mask standing for aNoEchovalue or aFn::Base64encoding, where it used to suggest that a custom-resource update would. The--accept/--revertplan line now reads<path>: <from> → <to>: pasted into a shell, the old->was a>redirect onto the value after it, so a readback value could truncate a file it named. A value carrying ASCII->or a character of the Unicode arrow ranges (→,⟶,➔,⮕,🠒,→, ...) is quoted, so it cannot read as the separator. The--acceptrefusal's remedy no longer spells a backtickedcdkd deploy, which a pasted clause of the plan'sSKIPPEDrow ran as a command substitution. The export, rollback and deploy messages that omit theFn::Base64cause remain on #2881. Tests:tests/unit/cli/drift-plan-paste-4239.test.ts,drift-plan-sanitize.test.ts,drift-secret-redaction.test.ts,drift-uncertified-baseline.test.ts.cdkd rollback,cdkd deployandcdkd orphan --forcename theFn::Base64encoding of a secret as a cause of a***they cannot use (issue #2881) --src/deployment/rollback-executor.ts,src/deployment/deploy-engine-masking.ts,src/analyzer/orphan-rewriter.ts,docs/state-management.md. cdkd records***where a value is theFn::Base64encoding of a{{resolve:...}}secret (EC2UserData, for example), but the rollback refusal on such a baseline and the deploy refusal on a resource reading it back from state (anAWS::SSM::Parameter'sValueattribute, for example) named only aNoEchoCustom Resource and a maskcdkd importorcdkd orphanwrote, so the remedies they printed could not apply. Each now names it, with its own remedy: the rollback refusal says a deploy that changes the resource restores the value, and that the refusal recurs until the secret is no longer encoded into the property; the deploy refusal says to have the reader build the value itself from the secret's own reference, and scopes its re-import command to the import-written mask it can clear. Thecdkd orphan --forcewarning no longer offers a re-import for a mask a re-import cannot clear. Tests:tests/unit/deployment/rollback-executor-masked-baseline.test.ts,tests/unit/deployment/deploy-engine-base64-attribute-refusal.test.ts,tests/unit/analyzer/orphan-rewriter.test.ts.cdkd destroy --purge-eventsandcdkd events prune --allno longer report removing deployment-event history that never existed (issue #2624) --src/state/deployment-events-store.ts. A purge always reported thedeployments/index.jsonkey as deleted, because S3'sDeleteObjectsalso succeeds for a key that does not exist. As a result,cdkd destroy --purge-eventsprintedPurged deployment-event history for <stack>for a stack with no recorded events, andcdkd events prune <stack> --all --stack-region <region>printedRemoved the empty deployment-event indexwhen there was no index. A prune now reports the index as removed only when the index was present in the listing the prune already makes, so in both cases above the message no longer appears. The same fix covers a--keep/--older-thanprune that removes every run. The objects deleted are the same as before. Tests:tests/unit/state/deployment-events-store.test.ts,tests/unit/cli/destroy-purge-events.test.ts,tests/unit/cli/commands/events.test.ts.cdkd destroy --remove-protectionrestores the guard on an EC2 instance, or on a Cloud Control protection-registry type, whose delete then failed terminally (issue #2204) --src/provisioning/ec2-termination-protection.ts,src/provisioning/providers/ec2-provider.ts,src/provisioning/cloud-control-provider.ts,src/provisioning/cc-protection-properties.ts, with unit tests intests/unit/provisioning/providers/ec2-instance-remove-protection-compensate.test.tsandtests/unit/provisioning/cloud-control-remove-protection-compensate.test.ts. Before, theDisableApiTerminationflip (both delete routes) and the Cloud Control patch for DSQL, NeptuneGraph, SMSVOICE ProtectConfiguration, VerifiedPermissions PolicyStore, EKS and the RDS / DocDB global clusters were never undone, so a failed destroy left the resource live with its guard silently stripped. Now the guard is turned back on before the failure is reported, under the rules the other--remove-protectionsites follow: only a guard a pre-flip read saw ON (DescribeInstanceAttribute, or a Cloud ControlGetResourceholding the registry's newonValue) and this run turned off, only on a terminal failure, never once EC2 accepted the terminate or a Cloud Control delete attempt may already be deleting (an abandoned wait, or an ambiguous handler code such asNotStabilized; cdkd then warns with the check and restore commands), and a failed re-enable is its own log line with a pasteableaws ec2 modify-instance-attribute --disable-api-terminationoraws cloudcontrol update-resourcecommand. The Cloud Control re-enable waits for its patch to apply, a printed command names the client's region when the state records none, and a Cloud Control-routed instance's flip now refuses, before touching anything, an EC2 client aimed at another region than the vetted one.cdkd destroy --remove-protectionrestores termination protection on an EMR cluster whose terminate then failed terminally (issue #2204, partial) --src/provisioning/providers/emr-cluster-provider.ts, with unit tests intests/unit/provisioning/providers/emr-cluster-remove-protection-compensate.test.ts. Before, theSetTerminationProtection(false)flip was never undone, so a failed destroy left the cluster live with its guard silently stripped. NowTerminationProtectedis turned back on before the failure is reported, under the rules the other--remove-protectionsites follow: only a guard the delete's pre-checkDescribeClustersaw ON and this run turned off, only on a terminal failure, never once AWS has accepted theTerminateJobFlows, and a failed re-enable is its own log line with a pasteableaws emr modify-cluster-attributes --termination-protectedcommand. EC2 and the Cloud Control generic path stay open under #2204.- The API Gateway (v1) provider masks secret-resolved values in its own log lines and failure messages (issue #2177, API Gateway slice) --
src/provisioning/providers/apigateway-provider.ts; unit tests intests/unit/provisioning/apigateway-provider-masked-log.test.ts.create()andupdate()ofAWS::ApiGateway::Account,Authorizer,Resource,Deployment,StageandMethodused to write their debug and warning lines through a logger no deploy-engine mask reaches. They now route every line through the deploy's masker: the Stage name, the Method'sRestApiId/ResourceId/HttpMethodand a Resource's old and newPathPart(each masked before it joins the sentence, so a secret shorter than four characters is caught too), the post-create cleanup warnings of a Stage and a Method (including the pasteabledelete-methodcommand), the IAM propagation retry warning and a Resource replacement's orphan warning and reason. The AWS error text a create or update failure message wraps is masked before it is joined. A Stage whose previousStageNamein state is a secret reference or the redaction mask***has its recorded name masked too, including inside the stage ARN the tag lines print, which covers a rotated secret whose old plaintext is in no bag of this deploy. Apart from a secret-derived Stage name, a secret shorter than four characters embedded in AWS error text is not caught. Thedelete()path still has no masker (issue #2007). - The API Gateway V2 provider masks secret-resolved values in its own log lines and failure messages (issue #2177, API Gateway V2 slice) --
src/provisioning/providers/apigatewayv2-provider.ts; unit tests intests/unit/provisioning/apigatewayv2-provider-masked-log.test.ts.create()andupdate()ofAWS::ApiGatewayV2::Api,Stage,Integration,RouteandAuthorizerused to write their debug and warning lines through a logger no deploy-engine mask reaches, and only the IntegrationResponseParameterswarning was masked. They now route every line through the deploy's masker, including the Stage name (masked before it joins the sentence, so a secret shorter than four characters is caught too), theTlsConfigwarning and theExecuteApiArnwarnings. The AWS error text a create or update failure message wraps is masked before it is joined; when the mask changed that text, the failure is stamped so the retry classifiers still read AWS's unmasked wording (the issue #4244 class), so a secret overlapping retry wording does not turn a transient failure terminal. A Stage whose recordedStageNameis a secret reference or the redaction mask***has its name masked too, which covers a rotated secret whose old plaintext is in no bag of this deploy. Apart from a secret-derived Stage name, a secret shorter than four characters embedded in AWS error text is not caught. Thedelete()path still has no masker (issue #2007). - The DynamoDB GlobalTable provider masks a secret-derived table name in its remaining log lines and failure messages (issue #2177, DynamoDB GlobalTable slice) --
src/provisioning/providers/dynamodb-globaltable-provider.ts; unit tests intests/unit/provisioning/dynamodb-globaltable-provider-masked-log.test.ts.create()andupdate()ofAWS::DynamoDB::GlobalTablestill printed the table name unmasked on several lines no deploy-engine mask reaches: theCreateTable initiated, ACTIVE-wait status andSuccessfully createddebug lines, and the auto-scalingUpserted auto-scaling policy,Deregistered auto-scaling targetandCould not probe existing auto-scaling targetslines. Each operation now routes every line through one masked sink set, as do the table-ACTIVE, replica-ACTIVE and replica-removal waits and the failures they throw. A secret-derivedTableNameis masked wherever it occurs, including names shorter than four characters, and including a name that state recorded as a secret reference whose old plaintext is in no bag of this deploy (a rotated secret). The protected-replacement refusal withholds itsaws dynamodb update-tablecommand when the table name is secret-derived. The AWS error text a create or update failure wraps is masked before it is joined; when the mask changed that text, the failure is stamped so the retry classifiers still read AWS's unmasked wording (the issue #4244 class). Thedelete()path still has no masker (issue #2007). - The ECS provider masks secret-resolved values in its own log lines, failure messages and pasteable commands (issue #2177, ECS slice) --
src/provisioning/providers/ecs-provider.ts; unit tests intests/unit/provisioning/ecs-provider-masked-log.test.ts.create()andupdate()ofAWS::ECS::Cluster,TaskDefinitionandServiceused to write their debug, info and warning lines through a logger no deploy-engine mask reaches. They now route every line through the deploy's masker. A cluster name, task-definition family, service name orClustervalue that is secret-derived is masked wherever it occurs, including inside the ARN a success or tag line prints and the service ARN's embedded cluster name, and theaws ecs wait services-stable/list-tasks/delete-servicecommands that would carry one are withheld rather than printed. The AWS error text a create or update failure message wraps is masked before it is joined; when the mask changed that text, the failure is stamped so the retry classifiers still read AWS's unmasked wording (the issue #4244 class), so a secret overlapping retry wording does not turn a transient failure terminal. Also masked are the non-ECS deployment controller refusal and the--full-waitcleanup warnings. A Cluster or Service whose previous name in state is a secret reference or the redaction mask***has its recorded name masked too, which covers a rotated secret whose old plaintext is in no bag of this deploy. A secret shorter than four characters embedded in AWS error text is caught only when it is one of those names. Thedelete()path still has no masker (issue #2007). - The Lambda Url, LayerVersion, Permission, EventInvokeConfig and EventSourceMapping providers mask secret-resolved values in their own log lines and failure messages (issue #2177, Lambda family slice) --
src/provisioning/providers/lambda-{url,layer,permission,event-invoke-config,eventsource}-provider.ts,src/utils/lambda-function-name.ts,scripts/check-provider-update-context.ts; tests intests/unit/provisioning/lambda-family-providers-masked-log.test.ts. Theircreate()/update()logged through a logger no deploy-engine mask reaches, and wrapped AWS error text unmasked. Every line and wrapped AWS text now goes through the deploy's masker, extended so a function, layer or qualifier name taken from a secret is also masked where it sits inside a physical id or ARN or AWS quotes it back, at any length and by its bare name as well as its ARN, including a name recorded from a rotated{{resolve:...}}secret. A wrapped failure the mask changed still retries on AWS's wording (the unmasked AWS error stays its cause). The EventInvokeConfig re-spelling refusal masks the function ARNs it quotes.AWS::Lambda::PermissionandAWS::Lambda::EventInvokeConfigupdate()now take the update context, and the Permission re-add passes on the masker only. Not caught: a secret under four characters inside AWS error text when it is not a name these providers derive.delete()still has no masker (issue #2007). - The Lambda function provider masks secret-resolved values in its own log lines and failure messages (issue #2177, Lambda family slice) --
src/provisioning/providers/lambda-function-provider.ts; tests intests/unit/provisioning/lambda-function-provider-masked-log.test.ts.create()/update()ofAWS::Lambda::Functionlogged through a logger no deploy-engine mask reaches; only theRuntimeManagementConfigline was masked. Every line now goes through the deploy's masker: the function name,RecursiveLoop,ReservedConcurrentExecutionsandCodeSigningConfigArnare masked before they join the sentence, so a secret under four characters is caught too, and so is a function name recorded from a rotated{{resolve:...}}secret. Tag lines name the function, not its ARN. The post-create warning, the cleanup error and the AWS text a create, update or update-wait failure wraps are masked before they are joined, and such a failure still retries on AWS's wording. A secret's JSON-escaped spelling (the 4 KB environment refusal prints the variables as JSON) is masked in the message; the AWS error kept as its cause is not rewritten. A failed update or Active wait no longer relays the waiter's payload, the wholeGetFunctionresponse with plaintext environment variables and a presigned code URL; the message carries only the waiter state, status fields, an error acceptor's name and message, the latest observed status lines, and counts of the omitted and withheld ones. A failed wait's unmasked, payload-free status text is kept deeper in the error's cause chain, for the retry classifiers only; no deploy output prints it. Not caught: a secret under four characters inside AWS error text, and a recorded function name of one or two characters that AWS quotes back.delete()still has no masker (issue #2007). - An API Gateway create that hits a 5xx now reports candidate orphans instead of duplicating silently (issue #2080, Plan C: API Gateway) --
src/provisioning/providers/{apigateway,apigatewayv2}-provider.ts,src/provisioning/providers/orphan-report.ts(new),docs/troubleshooting.md,docs/provider-rules.md. API GatewayCreateAuthorizerandCreateDeployment, and API Gateway v2CreateApi,CreateIntegrationandCreateAuthorizer, mint their id and carry no idempotency token, so a 5xx whose request had succeeded left a second, unrecorded resource behind. These five creates no longer let the AWS SDK retry a 5xx inside one call, where the duplicate was invisible; the 5xx reaches cdkd's retry, which first lists what the failed attempt may have made and reports each match at warn with a read command first: an HTTP API of the same name and protocol, or a deployment of the same REST API and description, created during the failed attempt (with a delete command, after confirming), or an authorizer of the same name and type, or an integration of the same type and URI, that this deploy did not record (no creation time, so no delete command). Nothing is adopted and nothing is deleted. A throttle, a 4xx or IAM propagation triggers no lookup. The lookup needsapigateway:GET; without it cdkd warns and the create proceeds. A reset connection or timeout after the send is still retried inside the SDK and not reported (#3978 layer (b)). Tests:tests/unit/provisioning/{apigateway,apigatewayv2}-create-retry-2080.test.ts. - A retried create no longer duplicates a KMS key after a follow-up call fails, and a KMS / Cognito / AppSync create that hits a 5xx now reports candidate orphans instead of duplicating silently (issue #2080, Plan C: KMS / Cognito / AppSync) --
src/provisioning/providers/{kms,cognito,appsync}-provider.ts,src/provisioning/providers/ambiguous-create.ts(new),docs/provider-rules.md,docs/troubleshooting.md.CreateKey,CreateUserPoolandCreateGraphqlApihave no idempotency token, so a retry re-ran them from the top. A failedEnableKeyRotation/DisableKeyafterCreateKeyleft a live key behind and minted another; the retry now reuses the key its own response named. These three creates no longer let the AWS SDK retry a 5xx inside one call, where a duplicate was invisible; the 5xx reaches cdkd's retry, which first looks for a user pool or API of the same name, or a key with the same settings, created during the failed attempt (for AppSync, any unrecorded API of that name), and reports each at warn with a read command first. Nothing is adopted and nothing is deleted: a name does not prove which deploy made a resource. A throttle, a 4xx or IAM propagation triggers no lookup. The lookup needskms:ListKeys+kms:DescribeKey,cognito-idp:ListUserPools, orappsync:ListGraphqlApis; without them it warns and the create proceeds. A reset connection or timeout after the send is still retried inside the SDK and not reported (#3978 layer (b)). Tests:tests/unit/provisioning/{kms,cognito,appsync}-create-retry-2080.test.ts,ambiguous-create.test.ts.
Recently Implemented (2026-09-30):
- Provider errors name the resource's logical id in their
logicalIdfield, not its physical id (issue #4222) --src/provisioning/providers/{asg,budgets-budget,cloudwatch-anomaly-detector,dlm-lifecycle-policy,firehose,iam-access-key,wait-condition-handle}-provider.ts,src/types/resource.ts,src/analyzer/orphan-rewriter.ts,src/provisioning/auxiliary-failure.ts. 18ProvisioningErrors (the Firehose destination-update failures and thegetAttributefailures of the other six providers) carried the resource's physical id, which can be built from a secret, in theirlogicalIdfield, so an error dump labelled a live resource name as the logical id and the retry classifiers anchored on the logical id never matched the error to its resource. They now carry the logical id, with the physical id inphysicalId;getAttributetakes the logical id as a fourth argument, whichcdkd orphanpasses. An auxiliary-failure mark is now recognised by its read-only shape as well as its/auxiliarysuffix, so a physical id ending in/auxiliaryis no longer read as one, andwithRetry(src/deployment/retry.ts) marks a replayed failure with the fixed ownerwithRetryinstead of its label, so a label holding a table or policy name never becomes the markmaskSecretsInErrorcopies unmasked. The two sites inscheduler-schedule-provider.tsremain. Tests:tests/unit/provisioning/logical-id-slot-4222.test.ts(including a source fence over every construction undersrc/) and the per-provider suites. cdkd drifton a nested stack no longer resolves a parent-supplied secret reference in the wrong region (issue #4213) --src/cli/commands/drift.ts. A nested child receives a parent's cross-region value only as a Parameter and records it as the parent's region-less{{resolve:...}}expression, butcdkd driftclassified that expression against the child's own cross-stack reads only, so detection compared against a same-named secret in the stack's region and--revertwrote it to the live resource. Drift now unions a nested stack's producer regions with every ancestor's, read from their state records (the #4174 model), for both detection and--revert. When an ancestor record cannot be read, every region-less secret reference is refused (DRIFT_SECRET_REGION_UNKNOWN) and the resource is reported not compared. Tests:tests/unit/cli/drift-nested-producer-regions.test.ts,tests/unit/cli/drift-cross-region-secret.test.ts.- A pasteable
cdkd ...command no longer names a value that would run as shell with its quotes stripped (issue #4205) --src/utils/pasteable-command.ts,src/state/lock-contention-message.ts,src/state/malformed-resources-bag.ts. Before, a recovery command named any printable value shell-quoted (cdkd state show 'x; touch OWNED; #'). Quoting holds only while the quote parity before the value is even, and an apostrophe pasted above the command (this stack'sin cdkd's prose, a'in AWS's error text, terminal output) flips it, so a stack name or region planted in the state bucket or a cloud assembly ran on the operator's machine. NowpasteableCommandand thecdkd force-unlockrecovery flags use the same measured predicate #4198 introduced for theaws ...remedies, moved tosrc/utils/pasteable-command.ts(PASTE_ARG_UNSAFE/isInertUnquoted) as the one test: such a value is a quoted hole ('<stack>'), a mid-word#,=,,or~(Parent~Child) is still named, and a force-unlock hint carrying one is suppressed. This retires namingIt's Legacyexactly in thecdkd orphanrefusals (#3359 / #3523). The same predicate now also gates thecdkd orphanrefusals'State for ...prose and legacyObject key:line (a non-inert name is described, and the key holds'<stack>'), the Scheduler's manualdelete-schedulehint, and the orphaned-name-collision advice, whosecdkd importmoves to its ownAdopt with:line. Prose indeploy-engine.ts,asset-storage.tsandexport.tsthat printed a raw stack name or region now renders it bounded or described. - A secret-derived name no longer splits a longer secret in a Glue, S3 bucket or IAM failure or retry line (issue #4193) --
src/provisioning/masked-retry-logger.ts.withDerivedNameMasksreplaced a physical name derived from a secret with***before the deploy's masker ran, so when AWS echoed another recorded secret containing that name (prod-dbrecorded, andprod-db owner hunter2xas a description), the longer secret no longer occurred whole and its remainder printed (Description '*** owner hunter2x' is invalid.). The deploy's masker now runs first and the derived names after it, each name itself passed through that masker first (at 4 characters or longer) so it still matches where the masker rewrote part of it (stack-***-example-com); the line now readsDescription '***' is invalid.. Where another recorded secret overlaps the edge of, or contains, a derived name of 4 or more characters that is not itself a recorded secret, the whole line is withheld as***. This reaches the Glue, S3 bucket and IAM providers' failure and retry lines and the rollback's replay lines. Tests:tests/unit/provisioning/masked-retry-logger-secret-derived.test.ts. - A masked error no longer keeps a secret in an AWS SDK exception's own error-body fields (issue #4190) --
src/deployment/secret-redaction.ts.maskSecretsInErrormasked each link'smessageandstackand copied every other own field of the error unchanged, so an AWS SDK exception's own copy of its error body (an IAM / STS / SNS exception'sError: { Type, Code, Message }, or the modeledMessagemany other services' exceptions carry) kept a recorded secret that AWS echoed, and any reader that walks the error object (util.inspect,JSON.stringify, the CLI'sFatal error:dump) printed it. Every own field is now masked too: a string, and every string reachable through plain objects and arrays, into a copy (the original error is never written), including acausethat is not anError. A secret held ONLY in such a field now also produces a masked copy. The fields retry classification compares exactly (name,code,Code,__type,ccErrorCode,ccOperation, and alogicalIdthat is an auxiliary-failure mark ending in/auxiliary) are copied unchanged; any otherlogicalIdis masked, since some providers put a physical id there (#4222), and so are the retry markers and any field holding no recorded value ($metadata), so retry classification is unaffected. Not entered: class instances (the SDK's$response), accessors, and anything past 20 levels. The IAMPolicy/UserToGroupAdditiondelete path's local copy of this masking (#4150) now uses the shared one. Tests:tests/unit/deployment/secret-redaction-error-own-fields-4190.test.ts. cdkd orphansubstitutes a sibling'sFn::GetAttwith the orphan's recorded attribute over a live read's answer (issue #4186) --src/analyzer/orphan-rewriter.ts(servableRecordedAttribute), with unit cases intests/unit/analyzer/orphan-rewriter.test.tsand the real-AWS armtests/integration/orphan-getatt-takeover/. The rewrite took everyFn::GetAttvalue from a liveprovider.getAttribute(...), which most SDK providers address by the recorded name. After the resource was deleted out of band and another one took its name -- the casecdkd orphanis the documented way out of -- the dependents' state received the newcomer's attribute (another role'sRoleId, another repository'sKmsKeyId), and a later update could send it on. The rewrite still reads live first; when that read answers, it now substitutes the recorded value wherever cdkd's ownFn::GetAttresolution would serve it, so it prints nothing the live read could not. A live read that fails or answers nothing (every Cloud-Control-routed record) stays unresolvable without--force, as before. It keeps the live answer when the record lacks the attribute, or holds a value that is not the attribute itself: the redaction mask***, a{{resolve:...}}reference, a stale placeholder ARN, a VPC'sIpv6CidrBlocks. A mask is never written into a dependent on the default path; only--force's existing fallback can, with its warning. A credential-named attribute (AWS::IAM::AccessKeySecretAccessKey), an AppSync API key'sApiKey, a value holding a credential-named key, and any custom-resource attribute are never taken from the record, since the rewrite audit prints what it substitutes.- A run that assumed a role now prints the
'<role-profile>'hole in its pasteableaws ...commands even without--profile(issue #4178) --src/utils/pasteable-aws-profile.ts. Before,cdkd destroy --role-arn <arn>with no--profileprinted bare commands such asaws rds modify-db-cluster ... --deletion-protection; pasted, they ran as the operator's ambient identity, which can be another account than the role's, so they answered NotFound or acted on a same-named resource there. Now every role-assumed run (--role-arnorCDKD_ROLE_ARN) printsaws --profile '<role-profile>' ..., as a profiled role run already did since #3959: the AWS CLI refuses the unfilled placeholder before any call, so the operator names a profile that reaches the role's account. A run with neither a role nor--profileprints exactly what it did before. - Every pasteable
cdkd ...hint now carries the run's typed--profile,--state-bucketand--state-prefix(issue #4177) -- newsrc/utils/pasteable-run-context.ts,src/utils/pasteable-command.ts(pasteableCommandappends them),src/cli/program.ts(thepreActionhook records them and reads which options each subcommand parses off the Commander tree),src/cli/options.ts,src/cli/commands/{events,deploy}.ts,src/deployment/rollback-executor.tsandsrc/synthesis/macro-expander.ts. Before, a hint such ascdkd state orphan MyStack --stack-region us-east-1orRetry with: cdkd destroy MyStack, printed aftercdkd destroy MyStack --profile prod --state-bucket b, resolved the DEFAULT profile and bucket when pasted, so a destructive hint could act on a same-named record in another account or bucket. Now it readscdkd state orphan MyStack --stack-region us-east-1 --profile prod --state-bucket b. Only flags typed on the command line are rendered, each only where the hinted subcommand parses it and never twice. A value that is not inert in a shell prints as a quoted hole, and a run that passed--role-arnprints--role-arn '<role-arn>', which cdkd refuses until filled in.--state-prefixnow refuses a value holding<or>, so a pasted'<prefix>'placeholder fails instead of deploying into an empty key space;cdkd events/cdkd events prunenow apply--role-arn, which they declared and silently ignored; the Ctrl-Ccdkd force-unlockline and a stack-lesscdkd rollback --orphanline carry the flags too. The two macro-expansion cleanup hints (aws cloudformation delete-stack,aws s3 rm) moved out of prose quotes onto their own line and now carry the run's--profileand the client's--region(#3959). - A nested stack's rollback no longer re-resolves a parent-supplied secret reference in the wrong region (issue #4174) --
src/deployment/producer-regions-scope.ts,src/deployment/nested-child-journal.ts,src/deployment/rollback-executor.ts,src/deployment/deploy-engine.ts,src/provisioning/providers/nested-stack-provider.ts,src/cli/commands/rollback.ts. A nested child receives a parent's cross-region value only as a Parameter and records it as the parent's region-less{{resolve:...}}expression, but a rollback of the child classified that expression against the child's own cross-stack reads only. So a reference the parent would refuse as ambiguous (issue #2057) was resolved against a same-named secret in the stack's region and written to a live resource. The child's replay now unions its own producer regions with its parent's (and every ancestor's): in a parent rollback that reverts the nested row, and in the child's own automatic rollback during the parent's deploy. Where the parent's regions are unknown --cdkd rollback '<parent>~<child>'run on its own -- every region-less secret reference is refused, and the operation fails with the journal kept. Tests:tests/unit/deployment/rollback-nested-cross-region-secret.test.ts,tests/unit/deployment/producer-regions-scope.test.ts,tests/unit/cli/commands/rollback-nested-producer-regions-4174.test.ts. - The
--recreate-via-cc-api/--recreate-via-sdk-providerconfirmation no longer prints a non-plain downstream-consumer value or recorded resource type raw (issue #4165) --src/cli/commands/recreate-downstream-consumers.ts(renderDownstreamConsumers),src/cli/commands/recreate-confirm-prompt.tsandsrc/deployment/recreate-targets.ts(the target and refusal rows), with paste cases intests/unit/cli/commands/recreate-downstream-consumers-paste.test.tsandrecreate-resource-type-paste.test.ts. Each consumer row printed the consumer stack name, its region and the export or output name it reads as they were recorded. The name and region come from the consumer's state key and the export name from itsstate.json, so whoever can write the state bucket chose them. A value could carry a span that ran when the row was pasted into a shell, or a newline that forged an extra consumer row inside the data-loss prompt. A plain value prints exactly as before, a CDK-generated export name carrying:included. Any other value is described (a stack name that is not a plain identifier,a region that is not a plain identifier,an export whose name is not a plain identifier,an output whose name is not a plain identifier). That includes a redacted{{resolve:...}}or***name on a row whose producer cdkd cannot name. The target row of the same prompt, and each--recreate-via-*refusal row an arbitrary recorded type can reach, printed the STATE record's resource type raw; a non-plain one is nowa resource type that is not a plain identifier. No refusalFix:line carries a<...>hole any more: the oldremove … <id> for these resources, pasted, redirected into a file namedfor. cdkd diffno longer tells the user that cdkd tried to heal a stale state record when its read-only re-read of the record fails (issues #4163, #4197) --src/deployment/intrinsic-function-resolver.ts(staleRecordRemedy),src/deployment/stale-attribute-heal.ts(StaleAttributeHealer.readOnly) andsrc/deployment/read-only-attribute-healer.ts, with cases intests/unit/deployment/stale-attribute-heal.test.ts. Since #3456,cdkd diffre-reads a state record that lacks an attribute aFn::GetAttasks for, and a denied or missing read reached the deploy's remedy text: "cdkd tried to re-read the attributes from AWS to heal the record ... cdkd retries the read on every deploy until the record is healed". The diff heals nothing and writes nothing. Its healer is now markedreadOnly, and the remedy reads "This preview re-read the attributes from AWS, but ...", then says thatcdkd deployissues the same read and records the attribute once the read returns it. That covers the warn line of a non-*Arnphysical-id fallback and the refusal for an*Arn/*Urlattribute. Anot-attemptedoutcome in the preview no longer says the record "was written by this deploy". A read with no usable value no longer says there is "nothing to heal the record with". A value Cloud Control returned masked tells the user to grantcloudformation:DescribeTypeto the credentials the preview runs with and run the diff again, not to the deploy role.cdkd deploy's wording is unchanged. Documented indocs/troubleshooting.md;docs/cli-diff.mdnow says the preview shows the value its own read returns and that only a deploy's own read records it.- A CodeCommit repository update or delete now refuses a repository under the recorded name whose id is not the recorded one (issue #4157) --
src/provisioning/providers/codecommit-repository-provider.ts,src/provisioning/region-check.ts(DeleteContext.recordedAttributes),src/types/resource.ts, and everydelete()caller:src/cli/commands/destroy-runner.ts,src/deployment/deploy-engine.ts,src/deployment/rollback-executor.ts. An update that did not rename, anddelete(), addressed the repository by NAME alone, so after the recorded repository was deleted outside cdkd and another created under the same name, a deploy overwrote that repository's description, key, tags and triggers, andcdkd destroydeleted it. Both now read the repository first and compare its id with the recordedRepositoryId: a mismatch is refused, non-retryable, with nothing sent to it and the record kept, namingcdkd orphanto drop the record. A repository already gone is still a delete success, a throttled, 5xx or connection-level read is retried, and any other read that cannot confirm the holder's identity (a deniedGetRepository, an unavailable KMS key, an unexpected error) proceeds with the delete, warns and reports adelete-recorded-identityguard. A record from beforeRepositoryIdwas recorded keeps the by-name update and delete. The rename path's own check is unchanged, and its remedy, like the new ones, now says a repository created again needs aRepositoryNameno repository holds. - Handing an inline policy name from one resource to another on the same principal in one deploy no longer removes the receiving resource's grant (
src/deployment/inline-policy-claims.ts,src/deployment/deploy-engine.ts,src/provisioning/providers/iam-policy-provider.ts, go-to-k/cdkd#4156). AnAWS::IAM::Policyupdate removes its old name from the principals it recorded, and a delete removes its name from all of them. When another resource of the same deploy took that name on the same role, group or user, the removal stripped the other resource's live policy, and the next deploy saw no diff. That happened with two policies swapping names or handing one name over, with a policy dropped while a new one takes its name, and with a policy renamed away from a name the principal's ownPoliciesnow declares. Before each such removal, the deploy now asks whether another resource of the same deploy has ALREADY written that name onto that principal, reading what that resource recorded after its create or update completed, and keeps the name when it has (names compare case-insensitively, as IAM does). A resource that has not run yet, is still running, or failed counts for nothing: the removal proceeds, and its later put restores the name. Only a write through the SDK provider counts, and for a role, group or user only a create or an update that changed its ownPolicies. So a swap or hand-off between two policies is fixed when one depends on the other; with no dependency they run concurrently and the removal can still strip the name (go-to-k/cdkd#4227). A rollback revert,cdkd drift --revertandcdkd destroyremove as before (go-to-k/cdkd#4225). Tests:tests/unit/deployment/inline-policy-claims.test.ts,tests/unit/deployment/deploy-engine-inline-policy-claims.test.ts,tests/unit/provisioning/iam-policy-provider-target-shape.test.ts; fixture:iam-role-policies-drift-clean(hand-off phase). - Renaming an
AWS::IAM::Policyin place no longer leaves the old-named inline policy on the principals it stays attached to (src/provisioning/providers/iam-policy-provider.tsupdate(), go-to-k/cdkd#4152). The update put the new name on every listed role, group and user, but removed the old name only from principals that left the list. So a principal in both the old and the new list kept both policies, and the old grant was named by no cdkd record. The old name is now removed from every recorded principal that stays listed across a rename, and a principal that leaves the list loses every name it may hold. A rollback revert is covered too: it replays the update with the attempted bag as the recorded side while the physical id still names the old policy, so the name that record carries is also removed. A recordedPolicyNamethat is not an IAM policy name (a secret reference, the***mask) is never sent. A case-only rename removes nothing from a retained principal: IAM inline policy names are case-insensitive on one principal (measured: a second put in another casing updates the one policy, keeping its first spelling), so the removal would delete the live policy. A role, group or user the template respells only in case is the same principal and no longer loses the policy as a leaver. An update that must detach a leaving principal but has no usable recorded policy name now refuses before any call. TwoAWS::IAM::Policyresources swapping names on one principal in the same deploy can still remove each other's grant (go-to-k/cdkd#4156). Tests:tests/unit/provisioning/iam-policy-provider-target-shape.test.ts; fixture:iam-role-policies-drift-clean(rename phase). cdkd destroy/cdkd state destroynow delete anAWS::IAM::PolicyorAWS::IAM::UserToGroupAdditionwhose principals come from a secret, instead of skipping it on every run (src/provisioning/secret-principal-resolution.ts,iam-policy-provider.ts/iam-user-group-provider.tsdelete(),destroy-runner.ts, go-to-k/cdkd#4150). State keeps such aRoles/Groups/Usersentry as its{{resolve:...}}expression, which the delete skipped as malformed (exit 2). A destroy now resolves it to the names the secret holds now, under rollback replay's region rules, and removes the policy or membership (a membership is first read, which needsiam:ListGroupsForUser). Printed names and the thrown error chain are masked, except a name under 4 characters inside an AWS error message. An unresolvable reference keeps the skip, which says resolution was attempted; a***mask is never attempted. A deploy never resolves: its template-removal delete runs after every create, so a logical-id move would lose the new grant. After a secret change, a principal the new value names that lacks the policy or membership makes the delete skip and keep the record (also after an interrupted earlier run, never for its own retry), since one only the old value named may still hold it (remove it by hand, thencdkd orphanorcdkd state orphan); one only the new value names loses a same-named grant it held from elsewhere, undetectably. While a resolvable such record remains after a partial destroy, the stack keeps itsimports/outputReads, so a producer's destroy is refused until it is gone. A nested child destroyed alone (cdkd state destroy '<parent>~<child>') misses its parent's regions, so it skips: destroy it through the parent. Not covered: a stack whose earlier partial destroy dropped those records, where a region-less reference resolves in the stack's region. Tests:secret-principal-delete-4150.test.ts; fixture:iam-secret-derived-principals.cdkd drift --revertno longer sends an empty value for an ELBv2 attribute key the recorded baseline lacks (issue #4147) --src/provisioning/providers/elbv2-provider.ts(diffAttributeBag),docs/cli-drift.md,docs/provider-rules.md,tests/integration/drift-revert-vpc/verify.sh,tests/unit/provisioning/elbv2-attribute-appearing-key-4147.test.ts. When AWS started returning an attribute key after the observed baseline was captured (ddos_protection.syn_cookie.modeon an ALB, or an attribute AWS released later),--revertread the key as a removal and sentValue: ''or a documented default. AWS rejects''for an enum or boolean key, which failed the wholeModify*Attributescall, so no other drifted attribute of that load balancer, target group or listener was reverted either. On--revert, no attribute removal is sent now: the key keeps its live value, and a warning names it and points tocdkd drift --accept.cdkd driftstill reports such a key, because one read cannot tell a key AWS started returning from a value an operator set.cdkd deployand rollback still reset an attribute the template removed.cdkd driftno longer reports an undeclared ELBv2 attribute that AWS stopped returning (issue #4144) --src/provisioning/providers/elbv2-provider.ts(canonicalizeDriftPair,ATTRIBUTE_BAG_BY_TYPE),src/types/resource.ts,docs/provider-rules.md,docs/cli-drift.md,tests/unit/provisioning/elbv2-attribute-bag-volatile-key-4144.test.ts. The observed baseline of a load balancer, target group or listener records every attribute AWS returns. When AWS later stopped returning one the template never declared (ddos_protection.syn_cookie.modeon an ALB),cdkd driftreported the whole attribute list as drifted, and--revertsent the key back. The drift pair pass now drops, from the baseline only, an entry whose key is undeclared AND absent from the readback: no ELBv2 attribute call removes a key, so that absence is not an out-of-band change. Still reported: a declared key that changed or vanished, an undeclared key whose value changed, and a key only the readback holds.--revertsends the trimmed baseline, so it no longer writes the dropped key. The pass leaves the baseline unchanged when the readback bag is missing or empty, or the declared bag cannot be read.cdkd diffno longer previews a literalExport.Namealias the deploy refuses, in a stack whose recorded secret is not spelled in an output value (issue #4143) --src/analyzer/outputs-diff.ts,src/deployment/outputs-export-alias.ts. The preview decides a literal alias from state only when the deploy's outputs pass records a secret. That gate read only output values spellingsecretsmanager/ssm-secure, so it missed a plain-ssmSecureString (which keeps its token through the preview's resolution) and asecretsmanagerreference spelled only in an intrinsicExport.Name. The preview published the alias as a phantom[export]ADD on every run. The gate now also counts a resolved output value or intrinsic name that keeps a secret token, so literal aliases are decided after every output has resolved. Tests:tests/unit/analyzer/outputs-diff-ssm-export-name-4056.test.ts,tests/unit/analyzer/outputs-diff.test.ts,tests/unit/deployment/deploy-engine-outputs-export-name-collision.test.ts.- A rollback masks a secret-derived name wherever an ARN or a Cloud Control composite id keeps it, not only in its last segment (issue #4138) --
src/deployment/rollback-executor.ts. On a rollback arm that never resolves a record's name, an AWS message quoting that name alone is masked only through the id's name segments. Those came from a per-shape list, and each review found another shape: an MSK cluster or AppRunner service name before a UUID or hex id, an EKS nodegroup name mid-path, and a WAFv2 name first in<Name>|<Id>|<Scope>. Every segment of an ARN's resource part (after its resource-type word) and of a|composite is now a needle, except a trailing version number, a WAF composite's scope word in its last position and a Cognito composite's pool id in its first. No shape is excluded anywhere else, since a secret-derived name is often a UUID or hex token. A Lambda ARN takes the name afterfunction:/layer:and a non-numeric alias after it, since the alias may be the secret-derived name (the #4137 change had dropped it). Tests:tests/unit/deployment/rollback-executor-derived-name-mask.test.ts. - A rollback no longer prints a secret-derived ELBv2 or Lambda name that an AWS message quotes on its own (issue #4135) --
src/deployment/rollback-executor.ts. On a rollback arm that never resolves a record's name, only the id's NAME segment can mask such a message, and that segment was the ARN's last one. An ELBv2 load balancer or target group keeps its name before a/<hash>, and a Lambda ARN keeps its function name before a:<version>/:<alias>qualifier, so a message quoting the name alone printed it. Taking the qualifier also masked an alias word such asproductionin the same op's prose. Both services now take the segment where their name sits. Tests:tests/unit/deployment/rollback-executor-derived-name-mask.test.ts. - A replacement propagated from an upstream resource now honors the reading resource's own conditional replacement rule when the value it reads moves (
src/deployment/deploy-engine.ts,src/analyzer/replacement-rules.ts,src/analyzer/diff-calculator.ts, go-to-k/cdkd#4134). When an upstream resource is replaced or updated in place, the diff raises a replacement ceiling on each resource that reads it. The deploy engine lowered that ceiling only when the resolved value did not move at all. A value that moved in a way the reader's type classifies as in place still replaced it. For a LambdaEventInvokeConfigwhoseFunctionNamea custom resource re-spelled from the function's name to its ARN, the replacement Put the config and then deleted it from the same function, leaving none (the #4118 shape). The engine now asks the type's conditional rule about a moved value and lowers the ceiling when the rule answers in place. A property with no conditional rule keeps the ceiling as before. Tests:tests/unit/deployment/deploy-engine-replacement-ceiling.test.ts; integlambda-event-invoke-config-updatephases 2c/2d. AWS::RDS::DBProxyTargetGroupTagsnow follow the shared tag-list rules, so a number or boolean tagValuedeploys as it does under CloudFormation (src/provisioning/providers/rds-dbproxy-targetgroup-provider.ts, go-to-k/cdkd#4122). Until now the target group used its own reader: it refused a scalarValuethat CloudFormation coerces to a string, and on update it could sendRemoveTagsFromResourcea recorded key that holds a dynamic reference or its mask, which names nothing AWS holds. It now reads both sides throughsrc/provisioning/tag-list.ts, as the siblingDBProxy/DBProxyEndpointproviders do. A scalarValueis sent as a string,Valuemay be omitted, and a recorded secret-derived key is never untagged (a warning says so without naming it). A malformed desired list is still refused before any call. An update now refuses a client pointed at a region other than the stack's before its first call: the live target read, the pool, deregister and register calls address the proxy by name, and an already-deregistered target reads as success. Tests:tests/unit/provisioning/rds-dbproxy-targetgroup-provider.test.ts.cdkd scrubnow drops a stored output key the template no longer declares, instead of leaving its value beside the references it writes (src/cli/commands/scrub.tsplanUnnamedOutputDrop, go-to-k/cdkd#4120). Scrub rewrote only the outputs it could name. A deleted output's value, possibly a plaintext an older cdkd stored, stayed in the record next to the plain{{resolve:ssm:...}}token scrub had just written.cdkd diffreads that token as proof the record is redacted, so it printed the value on the output's removal row. On a run that records a secret for the stack, such a key is now removed, and named in aDropped/Would dropline without its value.--dry-run --failexits1until a real run or a deploy removes it. Kept instead: a key a pass rewrote, whole or in part; one holding only whole references or no string; one whose name holds a recorded secret (still reported); one that may be a live export alias whoseExport.Namethis run could not reproduce; and one another stack's recordedFn::ImportValue/Fn::GetStackOutputstill reads, or may read (a record older than schema v4 / v8). The last two are findings (--failexits1) while the rest of the record is still scrubbed. A dropped alias also leaves the record'sexportNames. An exports index entry left without a key that still holds a recorded secret is a finding too. When the other stacks' state cannot be read, nothing is dropped and the run ends withSCRUB_DROPPED_OUTPUT_READERS_UNVERIFIED(exit2). Tests:tests/unit/cli/commands/scrub-drop-unnamed-output.test.ts,tests/unit/cli/commands/scrub-export-index.test.ts.--recreate-via-cc-apinow refuses a resource whose type cdkd keeps off Cloud Control, instead of deleting and recreating it on the SDK route while announcing a migration (src/deployment/recreate-targets.ts,src/deployment/deploy-engine.ts,src/provisioning/provider-registry.ts, go-to-k/cdkd#4119). For a type whose Cloud Control handler cannot manage it (AWS::Scheduler::Schedule,AWS::RDS::DBProxyTargetGroup,AWS::Lambda::EventInvokeConfig), routing ignores the flag. The deploy deleted the resource, created it again through the SDK provider, and logged--recreate-via-cc-api flag (mid-life SDK→CC migration). It now fails before anything is touched (RECREATE_TARGETS_INVALID, exit 1), naming each such resource and the reason. The deploy engine carries the same refusal for a caller that skips the pre-flight.--recreate-via-sdk-provideris unaffected. Tests:tests/unit/deployment/recreate-targets.test.ts,tests/unit/deployment/deploy-engine-recreate-via-cc-api.test.ts.- Re-spelling a Lambda
EventInvokeConfig'sFunctionNameas the same function (a name ↔ ARN change) no longer deletes the async-invoke configuration (src/analyzer/replacement-rules.ts, go-to-k/cdkd#4118).FunctionNamewas an unconditional replacement property. For a re-spelling, the create-first half wrote the config onto the function, and the old half's delete then removed it from the same function: the deploy succeeded, and the function was left with no failure destination and default retries.FunctionNamenow replaces only when the function NAME changes. A full ARN or a partial ARN (<account>:function:<name>) of the same name is an in-place update throughPutFunctionEventInvokeConfig. Two ARNs must also agree on partition, region and account. Before a name ↔ ARN Put, the update resolves both spellings withGetFunctionand refuses, changing nothing, when they are different functions (this useslambda:GetFunction, which the Lambda function provider already needs). A qualified ARN and an unresolved value still replace,Qualifierstill always does, and a config whose function is being renamed in the same deploy still replaces. When an in-place update does land on a differently named function, the recorded physical id now names that function, spelled as the template spells it, andcdkd driftcomparesFunctionNameby name. Tests:tests/unit/analyzer/replacement-rules-lambda-event-invoke-config.test.ts; integlambda-event-invoke-config-updatephase 2b. - A
lock.jsonowner or operation that is not a plain identifier is described in every lock message (issue #4115) --src/state/lock-contention-message.ts(formatLockOwner,formatLockOperation),src/state/lock-manager.ts,src/cli/commands/state.ts,src/utils/display-safe.ts(plainIdentOr). The lock-contention refusal, theLocked by:error, the retry line and the force-release and expired-takeover warnings printed the owner and operation raw, and sanitizing keeps spaces, so an owner such asx, expired 3h agomade a live lock read as expired and invited aforce-unlock. They now go through the same rulecdkd state showuses: the value when it is a plain identifier, a description otherwise. A comma is refused too, and an empty owner readsan unnamed holdereverywhere, as the contention refusal already said. A genuineuser@host:pidowner reads as before. Tests:tests/unit/state/lock-contention-message.test.ts,tests/unit/state/lock-acquire-retry-remedy.test.ts,tests/unit/state/lock-manager.test.ts. - A bootstrap marker naming an invalid asset bucket or ECR repository is treated as malformed (issue #4114) --
src/assets/asset-storage.ts(parseBootstrapMarker),src/cli/commands/gc.ts(comment).--asset-bucket/--container-repoare validated before any AWS call, but a name read from the marker's body was sent to S3 / ECR as-is, and anyone withs3:PutObjecton the state bucket chooses that body. The marker's names are now held to the same rules.cdkd bootstrapwarns and rewrites such a marker with the conventional or flag-supplied names, as it already did for a malformed one, and the warning names any recorded name the rewrite drops, so storage it tracked can still be found.cdkd deploy,cdkd gcandcdkd bootstrap --destroyrefuse it with the malformed-marker error, and never send or delete by the invalid name;cdkd state infoskips it with a warning. Tests:tests/unit/assets/asset-storage.test.ts,tests/unit/cli/bootstrap-destroy.test.ts,tests/unit/cli/gc.test.ts,tests/unit/cli/state-info.test.ts. - A DynamoDB stream
ResourcePolicyorTagsthat cdkd just applied is no longer left out of the drift baseline when AWS is slow to show it (src/provisioning/providers/dynamodb-table-provider.ts,src/provisioning/dynamodb-stream-members.ts,src/deployment/deploy-engine.ts,src/types/resource.ts, go-to-k/cdkd#4112). After a create or update, the deploy reads the table back to record itsobservedPropertiesbaseline. When the stream's policy or tags were still not visible after the re-read schedule, or the read failed (a throttle, for example), the baseline recorded the member as absent.cdkd driftthen reportedStreamSpecification.ResourcePolicyor.Tagsas drift until the next deploy. The capture after cdkd's own write now keeps the declared member in that case. Acdkd driftread still believes the live answer, so a policy or tag set removed out of band still reports as drift. Tests:tests/unit/provisioning/dynamodb-table-provider-stream-members.test.ts,tests/unit/deployment/deploy-engine-iam-capture-siblings.test.ts. - A
DeletionPolicy: Snapshotdelete no longer logs the final-snapshot identifier built from the physical id (issue #4111) --src/provisioning/providers/{rds,docdb,neptune,elasticache}-provider.ts,docs/cli-destroy.md. Deleting anAWS::RDS::DBInstance,AWS::RDS::DBCluster,AWS::DocDB::DBCluster,AWS::Neptune::DBClusterorAWS::ElastiCache::CacheClusterunder a Snapshot policy (incdkd destroy, a rollback or a deploy's delete) printedwith final snapshot <physicalId>-final-<timestamp>at INFO, so a physical id taken from a{{resolve:secretsmanager:...}}value reached default-verbosity output in a lowercased spelling. The line now readsDeleting <type> <logicalId> with a final snapshot (DeletionPolicy: Snapshot), and the other lines of those five delete paths name the logical id instead of the physical id: the debug lines (the entry line, the--remove-protectionflip failure, the already-gone skip, the wait-for-deleted status poll) and theTimed out waiting for ... to be deletederror. The snapshot is still taken under the same identifier;docs/cli-destroy.mdspells out how the identifier is derived from the physical id, so the snapshot can be found by its prefix. The--remove-protectionguard helper's debug line and AWS-supplied error text can still carry the physical id; a masker threaded through the delete context, which those and other providers' lines need, stays with issue #3869. Tests:tests/unit/provisioning/final-snapshot-delete-log-4111.test.ts. - The composite-id refusal and the malformed-physicalId message no longer print a non-plain logical id raw (issue #4107) --
src/provisioning/composite-id.ts(compositeIdSeparatorRefusal,compositeIdFormatMessage), with paste cases intests/unit/provisioning/composite-id-logical-id-paste.test.ts. Both message heads printed the logical id unquoted. Oncdkd destroyit is astate.jsonresource key, which nothing validates, so whoever can write the state bucket chose it: withX$(touch OWNED)as the id, pasting the head ran the substitution, and the delete-skip warning did the same under zsh. A plain logical id (hyphenated ones included) prints exactly as before; any other is described asa logical id that is not a plain identifier. The resource type in the refusal head is held to the CloudFormation type shape, soAWS::Glue::TableandCustom::MyThingprint unchanged. cdkd diff --recursiveresolves a nested stack's input parameters against the parent's bound parameters and conditions (issue #4094) --src/cli/commands/diff-recursive.ts. A nested row'sParameterswere resolved only against the values handed to the parent (none at the top-level stack). ARefto a parent template parameter bound from itsDefaultor an SSM-typed lookup stayed unresolved, and anFn::Ifin the row took its false branch, so the child showed a phantom UPDATE on every run and--failnever exited 0. The row now resolves against the parent's bound parameters and evaluated conditions, ascdkd deploydoes. A list-typed value is joined with,as the deploy passes it to the child. Tests:tests/unit/cli/diff-recursive-child-params-4094.test.ts; thenested-stack-3levelfixture gains aDefault-bound root parameter and restores itsMidPinIfrow.- An
AWS::IAM::PolicyorAWS::IAM::UserToGroupAdditionwhose principals come from a secret no longer refuses every in-place update after its first deploy (src/deployment/deploy-engine.ts,src/provisioning/iam-policy-targets.tswithUnchangedSecretPrincipalLists, go-to-k/cdkd#4064). State keeps a secret-derivedRoles/Groups/Usersentry as its{{resolve:...}}expression. That is not a list of IAM names, and neither provider can read the old side from IAM. So a change to the policy document, or an added principal, was refused with the orphan-and-redeploy repair. When the template still spells the same reference, the engine now drops that entry from the recorded side it hands the update. The desired side names that principal too, so the provider re-applies it (both calls are idempotent) and removes it from nothing. A principal named only by a secret value rotated away since the last deploy keeps its policy or membership and has to be removed by hand (before this, it kept it too, behind the refusal); a warning, by kind and never by name, says so. Only these two types' principal lists are touched, so no other provider stops re-sending a rotated secret on update. No plaintext enters the recorded side, and state keeps the reference. Nothing is dropped, and the update is refused as before, when any list keeps a re-pointed reference or a recorded***mask, or whenGroupName/PolicyNamechanged, is itself secret-derived, or, forPolicyName, disagrees with the physical id. Both refusals' wording now says so. Destroying such a record still skips (go-to-k/cdkd#4150). Tests:tests/unit/deployment/deploy-engine-secret-principal-previous.test.ts,tests/unit/provisioning/iam-policy-targets.test.ts; fixture:iam-secret-derived-principals. - A piece of a
NoEchoparameter's value no longer prints: anFn::Splitof it, or a nested child's list parameter split out of it (issue #4049) --src/deployment/intrinsic-function-resolver.ts,src/deployment/secret-redaction.ts(recordLogOnlySplitFragments,carryLogOnlyValuesCarriedBy). Before,Fn::Splitover aNoEchovalue printed each piece on the--verboseResolved Fn::Splitline, and a resource consuming a piece had it in the clear in its provider's warn and retry lines, the deploy's error, thedeployments/*.jsonlevent and thecdkd diffrow. A nested child declaring aCommaDelimitedList(or other list) parameter fed the parent'sNoEchostring printed each element the same way. Each piece's share of the value is now masked on those surfaces, like the value itself, including a stored piececdkd diffshows for a property that stopped reading it. A piece shorter than 4 characters is masked only where it is the whole text, and a piece masks any equal text, which can over-mask unrelated values. AnFn::Splitby an empty delimiter prints its pieces as***on its own line, but anFn::Joinover them still prints the characters. Nothing cdkd persists changes:state.jsonis byte-identical with or withoutNoEcho. One publication verdict reads these pieces too: anExport.Nameequal to a piece, or embedding one of 4 or more characters, is refused, as a name holding the whole value already is. Tests:tests/unit/deployment/noecho-split-fragments-4049.test.ts,tests/unit/cli/diff-noecho-split-4049.test.ts. cdkd deployrefuses anExport.Namethat equals or embeds aNoEcho: trueparameter's value (issue #4043) --src/deployment/outputs-export-alias.ts(exportNameSecretExposure),src/analyzer/outputs-diff.ts,src/cli/commands/diff-recursive.ts. Before, such a name was published as a key ofstate.outputs,exportNamesand the exports index, so the value sat in plaintext where any stack's reader could list it; a key cannot be masked. Now the alias is skipped with the same warning as a secret-bearing name, the value masked or the name withheld, and the next deploy that re-resolves the outputs drops an alias an earlier deploy published: a consumer'sFn::ImportValueon that name stops resolving, a deliberate CloudFormation-parity divergence. The value counts when an output value, or theExport.Nameof the same or an earlier-declared output, reads it through aReforFn::Sub. The name is refused when it equals the value, or contains it at 4 or more characters, full-width and other NFKC spellings included.cdkd diffpreviews the same verdict, so the refused alias is no phantom addition. Still published: a value only a resource or a laterExport.Namereads, one reaching the name through an attribute, an import or anFn::Selectfragment, a 1-3 character value inside a longer name, and a failed output's alias carried forward by a deploy with no resource change. Earlier versions ofstate.jsonand of the exports index keep a previously published value; rotate it.- An
Export.Namespelling a secret in compatibility characters (full-width, superscript, ligature, mathematical letters) is refused instead of published (issue #4001) --src/deployment/outputs-export-alias.ts, with unit tests intests/unit/deployment/secret-detection-space-4001.test.ts. A name likepre-hunter2followed by a full-widthpassreads as the recorded secrethunter2pass, but no form the containment test used held it, so the name was written intostate.jsonand the exports index, andcdkd scrub --dry-run --failcalled the state clean. The test now also runs on a compatibility-folded form of the name (Unicode NFKD, nonspacing marks removed, NFKC), on top of the existing checks, so it catches strictly more than before. The printed text is unchanged; a name caught only this way is withheld from warnings, in the deploy,cdkd diffandcdkd scrubalike. Cost: a name whose folded spelling holds a recorded secret of at least four characters, in both its mark-stripped and folded forms, or equals it whole, is refused, as its plain spelling would be. Cross-script look-alikes (a Cyrillic letter for a Latin one) are not folded, nor is a secret with a Hangul jamo at either edge, or ending in an open Hangul syllable, whose other letters are spelled in compatibility characters (the edge can recompose with its neighbour in the name). - Every pasteable
aws ...command cdkd prints now carries the run's explicit--profile(issue #3959) -- newsrc/utils/pasteable-aws-profile.ts,src/cli/program.ts(thepreActionhook records--profile),src/provisioning/replacement-protection-advice.ts(pasteableAwsCommandandrenderDisableCommandinsert it after everyawscommand word), and the hand-built commands incloud-control-provider.ts,scheduler-schedule-provider.ts,efs-provider.ts,dynamodb-globaltable-provider.ts,state/s3-replication-purge-gap.ts,cli/commands/import.tsandcli/commands/export.ts. Before, a remediation command such as the--remove-protectioncompensation'saws rds modify-db-cluster ... --deletion-protectionline never named a profile, so aftercdkd destroy --profile proda pasted copy resolved the operator's DEFAULT profile: at best a NotFound, at worst a change to a same-named resource in another account. Now it readsaws --profile prod rds modify-db-cluster .... Only an explicit--profileis rendered; an inheritedAWS_PROFILEis not, and a run with no profile prints exactly what it did before. A profile holding whitespace or a shell-active character, or one that cannot be printed exactly, becomes the quoted'<profile>'hole, and a run that also assumed a role via--role-arn/CDKD_ROLE_ARNprints'<role-profile>', since the base profile's principal is not the one cdkd ran as. Thedynamodb-globaltable-provider.tscleanup, live-only-index and auto-scaling commands now go throughpasteableAwsCommand, so their table name is shell-quoted and a name that cannot be printed exactly withholds the command. - A pasteable
aws ...command is withheld when its identifier would change the command once unquoted (issue #3950, partial) --src/provisioning/replacement-protection-advice.ts(pasteableArg, shared bypasteableAwsCommandandrenderDisableCommand, and its newPASTE_ARG_UNSAFEset) andsrc/provisioning/providers/dynamodb-globaltable-provider.ts, with paste cases inreplacement-remedy-preconditions.test.ts,logs-loggroup-provider-class-guard.test.tsandpasteable-aws-command.test.tsundertests/unit/provisioning/. These commands are printed inside markdown backticks (protectedReplacementAdvice, theLogGroupClassrefusal) and in sentences carryingcdkd's. A template- or state-borne id holding a backtick ended the substitution when the clause was pasted with its backticks, and an apostrophe earlier in the line flipped the quote parity, so the shell-quoted id ran. User-visible change: a hint that used to show such an identifier shell-quoted now withholds its command and falls back to its console wording. The set is measured under bash and zsh with their default options and listed onPASTE_ARG_UNSAFE: characters that change a word anywhere, plus#,=and~where the shell acts on them. A mid-word#,=,,,%or^stays admitted, so a log group such as/app#blueor an IAM name with=or,keeps its command. Under zsh'sEXTENDED_GLOBa mid-word#,^or~becomes a glob operator, but cdkd always shell-quotes those characters, and acdkd'squote flip leaves the word glued to prose, so the worst case is a no-match abort, not a run. The DynamoDB replica-drop command keeps cdkd's ownDelete={RegionName=...}text in the command, single-quoted around the region, so it is still printed for every table and, in a normal paste, zsh'sBRACE_CCLcannot expand it; a line flipped by an apostrophe in the AWS error text can expand it, at worst into an aws parse error. - A message line that shows an untrusted name no longer also offers a command to paste (issue #3950) --
src/state/lock-contention-message.ts,src/cli/commands/{rollback,events,state,destroy,scrub,import}.ts,src/deployment/rollback-executor.ts,src/provisioning/providers/ssm-parameter-provider.ts,src/provisioning/cc-import-identifier.tsandsrc/provisioning/composite-id.ts. These lines showed a stack name, logical id, resource type or ARN beside acdkd/awscommand or a--flag, and shell in the value ran when the line was pasted: a;under bash on thecdkd importlines, a$( )or backtick under both shells at the composite-id refusal heads, and under zsh past a following(on the others. A value that is not a plain identifier is now described on that line instead, each message in its own words (for examplea stack name that is not a plain identifierincdkd rollbackandstate destroy,(logical id not shown: it is not a plain identifier)incdkd import's composite-id refusals,a resource whose logical id is not a plain CloudFormation logical idin a rollback replay's refusals). The lock-contention refusal and the SSM ARN-adopt refusal withhold theircdkd force-unlock/aws ssm get-parametercommand for such a value.cdkd import'sFailed to importline and plan rows describe it too, and its not-found hint prints--resource '<logicalId>'='<physicalId>'for such a value (a plain one stays named, with the'<physicalId>'hole now quoted). A rollback collision refusal prints the provider's error on its ownUnderlying collision:line.cdkd eventsand the destroy / scrub versioning notes name cdkd's commands in prose rather than as a command. A plain name prints as before. The rest of this rule is tracked in #4214 and #4226. - A replacement that moves its explicit name off the one the old resource holds now creates before it deletes, and a rename onto a name a name-adopting create would take over is refused before anything changes (issues #3931, #3937; ELBv2 and ECS remain on #3937) --
src/deployment/deploy-engine.ts(checkedReplacementNameChange,createFirstThenDeleteOld,deleteReplacedAfterCreate),src/deployment/replacement-name-holder.ts(replacementNameProbe),docs/cli-deploy-safety.md, two unit files and the newtests/integration/replacement-rename-onto-holder/fixture. The UPDATE-not-supported fallback and the--recreate-via-cc-api/--recreate-via-sdk-providerrecreate used to delete the old resource first even when the template also renamed it; when another resource held the new name the create collided and the managed resource was gone. When the name is KNOWN to differ, both now create first: a collision fails withNAMED_REPLACEMENT_COLLISIONand nothing deleted, and the old resource is deleted only once its replacement exists (a failed delete warns, like the property-driven cleanup). SQS queues, SNS topics, Step Functions state machines, EventBridge rules, CloudWatch alarms and S3 buckets hand back, overwrite or adopt a resource already holding the name, so such a rename "succeeded" with a stranger's resource, deleted the old one, and recorded the stranger's for a latercdkd destroyto delete. On the SDK route the new name (a rule's new bus, or aTypechange onto one of these types, included) is now looked up first; a resource found there, or a lookup that cannot run, fails the deploy with nothing created or deleted. Names compare exactly, unless the type folds case. The third bullet of #3931 (--replaceafter a dropped name) was closed by #4028's holder proof. - The malformed-record refusals from
cdkd destroy,cdkd import,cdkd orphan,cdkd rollbackandcdkd state refresh-observednow print commands that include the run's--profile,--state-bucketand non-default--state-prefix(issue #3909). Changed files:src/state/malformed-resources-bag.ts,src/cli/commands/destroy-runner.ts,src/cli/commands/import.ts,src/cli/commands/state.ts,src/cli/commands/rollback.ts,src/deployment/nested-child-journal.ts,src/provisioning/providers/nested-stack-provider.tsandtests/unit/state/malformed-account-flags.test.ts. Before this change,cdkd destroy --state-bucket Xrefused an unreadable record and then pointed atcdkd state list --jsonandcdkd state show ...with no flags. Pasted as printed, those commands read the default profile's bucket, which does not hold the record. Now thecdkd state showline and thecdkd state orphantemplate include the flags. When the refusal cannot name the record, the listing is printed on its ownFind the exact name:line, and that line includes the flags too. Each value goes through the shared paste gate. A value that is not a plain identifier, that does not render exactly, or that begins with-is printed as a quoted hole such as'<profile>'. The message then states the reason and never repeats the value; this now holds forcdkd orphan's drop, listing and inspect lines too, which used to quote such a value. A run with no--profileor non-default--state-prefixstill carries the resolved--state-bucketon every printed command. - Refusals that send you to
cdkd state list --jsonfor a name they could not print now say to decode the value before shell-quoting it (issue #3908) --src/state/malformed-resources-bag.ts(JSON_LISTING_HOLE_VALUE, the shared listing pointer,inspectClause,withheldIdentityClause) andsrc/cli/commands/export.ts, with cases intests/unit/cli/state-list.test.tsand the message suites. The shared pointer said the listing "writes each name raw", and every message filling a quoted identity hole from it -- thecdkd destroywithhold arms, the other refusals and warnings in that module, andcdkd export's withheld-record notes -- said to fill the hole "with the shell-quoted value". The listing is JSON: it escapes"and\, and since #3163 the control, format and separator characters as\uXXXX. Those names are the ones the withhold arms exist for, so shell-quoting the value as printed named a record that does not exist. The messages now say the listing prints each name as a JSON string, escaped but never trimmed or sanitized, and to fill each hole with the value decoded from its JSON string, then shell-quoted. A new test drives the--jsonoutput path: a padded name survives beside its trimmed sibling, and an escaped name decodes back to the stored one.docs/cli-destroy.mdnow says that--jsonis the withhold arm's pointer while the exact arm keeps--long, that the region printed is the key's (#3328), and to leave--stack-regionout for a record listed with"region": null.docs/cli-diff.mdnow says thepropertiesrefusal comes when the deploy computes its diff, not when it loads the record. cdkd diffnow previews aFn::GetAttover a state record that lacks the attribute (or holds a pre-#1681 placeholder ARN) as the valuecdkd deployresolves (issue #3456) --src/cli/commands/diff.ts,src/cli/commands/diff-recursive.ts, newsrc/deployment/read-only-attribute-healer.ts,src/deployment/stale-attribute-heal.ts, and new unit teststests/unit/cli/diff-stale-attribute-heal-3456.test.ts,tests/unit/cli/diff-recursive-stale-attribute-heal.test.ts,tests/unit/deployment/read-only-attribute-healer.test.ts.cdkd deployre-reads such a record from AWS through its provider'simport()(#1852);cdkd diffdid not, so it previewed the raw intrinsic or the physical id and could printNo changes detectedfor an update the deploy then made -- measured live on anAWS::Lambda::Urlimported with--migrate-from-cloudformation, whose dependentAWS::SSM::Parameterthe deploy updated. The diff now issues the same read, once per resource per run, in the stack's own region, on every resolver context it builds (the resource diff, conditions, Outputs, and a nested child'sParameters). Nothing it reads is written to state; the next deploy records it. A failed read previews as before and does not fail the diff. Custom resources and nested stacks are never re-read. A credential attribute AWS returns unmasked (a Cognito user pool client'sClientSecret) is now printed by the diff for such a record, in its rows,--jsonand--verbose; before, the diff printed the unresolved reference. The rollback-orphan adoption preview's provider read (#2943) now also runs in the stack's region rather than the command's. Documented indocs/cli-diff.md.cdkd exportrefuses a stack record whoseresourcesmap it cannot read by name, root or nested child, instead of failing on a bareTypeErroror reporting every resource as not deployed (issue #3188) --src/cli/commands/export.ts(exportCommandat the state load,runPerStackImportLoopover every tree node before its pre-flight), with cases intests/unit/cli/export-malformed-child-bag.test.ts. A record whoseresourceswas a string, a number, a boolean, a list,nullor absent was refused only by accident:buildImportPlanread every template row against it, so anullor absent bag threwTypeError: Cannot read properties of null (reading '<id>')naming no record, and any other shape listed every row as "no entry in cdkd state". For a nested CHILD this mattered more, because the tree walk returns such a child with no children of its own, so the accidental refusal was the only thing keeping the export from migrating a truncated tree. Both paths now raise the sharedSTATE_RESOURCES_MALFORMEDrefusal (the onecdkd import,cdkd orphanandcdkd rollbackraise, among others), naming the stack and region and ending on how to inspect the record. It fires before the lock on the root, before any changeset, template upload or state write on a child, and on--dry-runtoo. A readable record is unaffected.cdkd destroy --remove-protectionrestores deletion protection on an ELBv2 load balancer or an Auto Scaling group whose delete then failed terminally (issue #2204, partial) --src/provisioning/providers/elbv2-provider.ts,asg-provider.ts, with unit tests intests/unit/provisioning/providers/elbv2-asg-remove-protection-compensate.test.tsand a live arm in theremove-protectioninteg (step 4b: an out-of-band VPC endpoint service makes an NLB'sDeleteLoadBalancerrefuse). Before, the flip-off (ModifyLoadBalancerAttributes deletion_protection.enabled=false/UpdateAutoScalingGroup DeletionProtection=none) was never undone, so a failed destroy left the resource live with its guard silently stripped. Now the guard is turned back on before the failure is reported, under the rules the DynamoDB, RDS-family, log group and user pool sites follow: only a guard a pre-flipDescribeLoadBalancerAttributes/DescribeAutoScalingGroupssaw on and this run turned off, only on a terminal failure, never once AWS has accepted the delete, and a failed re-enable is its own log line with a pasteable restore command. An Auto Scaling group gets back the level it had (prevent-force-deletionorprevent-all-deletion). The other--remove-protectiontypes stay open under #2204.- The EFS providers mask secret-resolved values in their own log lines and failure messages (issue #2177, EFS family slice) --
src/provisioning/providers/efs-provider.ts; unit tests intests/unit/provisioning/efs-provider-masked-log.test.ts.create()andupdate()ofAWS::EFS::FileSystem,AWS::EFS::MountTargetandAWS::EFS::AccessPointused to write their debug and warning lines through a logger no deploy-engine mask reaches. They now route every line through the deploy's masker: theBackupPolicyStatusandReplicationOverwriteProtectionvalues (masked before they join the sentence, so a secret shorter than four characters is caught too), the transient-retry and read-back retry lines, the rollback-failure warning and the adoption read-back failure, each of which can carry AWS text quoting a request value back. The AWS error text a create or update failure message wraps is masked before it is joined. A short secret embedded in AWS error text is not caught. Thedelete()path still has no masker (issue #2007). - The Secrets Manager secret provider masks a secret-derived name in its own log lines and failures (issue #2177, Secrets Manager slice) --
src/provisioning/providers/secretsmanager-secret-provider.ts; unit tests intests/unit/provisioning/secretsmanager-secret-provider-masked-log.test.ts. AnAWS::SecretsManager::SecretwhoseNamewas resolved from a{{resolve:secretsmanager:...}}reference used to print that name in plaintext inside the secret's ARN on the provider's own debug lines, which pass through no deploy-engine mask, and a name shorter than four characters also survived the engine's mask on a failure message.create()andupdate()now route every line that names the secret through the deploy's masker and the shared derived-name masks: the ARN renders as***at any name length, AWS error text is masked with the deploy's secrets first, and a secret name is then also masked where an AWS error quotes it back (a name of three or more characters anywhere, a shorter one where it is quoted inside an ARN). Where a recorded secret crosses or contains a name of four or more characters, or a shorter name quoted inside an ARN, the AWS error text is withheld as***rather than printed around it; a recorded secret crossing a bare three-character name can still leave up to two of its characters. Onupdate()the recorded ARN is masked as well when the previousNamein state is a secret reference or the redaction mask***, which covers a rotated secret whose old plaintext is in no bag of this deploy. This covers message text only: the error'sphysicalIdfield and the engine's own lines still carry the raw name or ARN (issue #3869), and thedelete()path still has no masker (issue #2007).
Recently Implemented (2026-09-29):
- A bootstrap-marker name, or a
lock.jsonowner / operation incdkd state show/cdkd state orphan, that is not a plain identifier is no longer printed raw (issue #4109) --src/assets/asset-storage.ts(isPlainName,shownName),src/cli/commands/state.ts(plainValueOrDescribed,formatLockSummary),src/deployment/replacement-name-holder.ts. Two predicates decided "plain" bydisplayIdentreturning the value unchanged, and a value that ISdisplayIdent's cut output (<cap characters> [cut: N more characters withheld]) returns unchanged too, so it was printed raw inside cdkd's quotes or beside a labelled row. Whitespace is now refused first.cdkd state show'sLock:row described nothing, so an owner such asx (operation: deploy), expired 3h agomade a live lock read as expired; its owner and operation are now described unless plain (the lock manager's own messages: issue #4115).cdkd bootstrap's progress lines print a marker-supplied bucket / repository name throughdisplayIdent, so a real name reads as before. The name-collision refusal now readsasks for a name (AlarmName) that is not a plain identifierinstead ofa AlarmName. Tests:tests/unit/assets/asset-storage.test.ts,tests/unit/cli/state-orphan.test.ts,tests/unit/cli/state-show.test.ts,tests/unit/cli/state-record-shape.test.ts,tests/unit/deployment/replacement-name-holder-prose-quotes.test.ts. cdkd diffno longer prints a deleted output'sSecureStringplaintext when the stored record also holds a correctly redactedsecretsmanagerreference (issue #4108) --src/analyzer/outputs-diff.ts,src/deployment/no-change-outputs-merge.ts,.claude/rules/layout-analyzer.md,.claude/rules/no-change-outputs-merge.md,docs/architecture.md,docs/cli-diff.md. A stored secret expression exonerated the whole record, so a deleted output's value printed on its REMOVE row. But asecretsmanager/ssm-secureexpression proves only a write after the GHSA fix, and a cdkd before issue #1901 stored aSecureStringparameter's plaintext beside it. Only a stored plainssmreference, which only a cdkd after #1901 writes, now exonerates the record, andcdkd deploy's no-change Outputs merge counts that token as a first secret expression, so it keeps the previous bag whole rather than write one beside a carried pre-#1901 plaintext. Fail-closed cost: in a stack whose only secret issecretsmanager, a deleted output's previous value is withheld while the template still references a secret. Tests:tests/unit/analyzer/outputs-diff.test.ts,tests/unit/analyzer/outputs-diff-ssm-export-name-4056.test.ts,tests/unit/deployment/no-change-outputs-merge.test.ts,tests/unit/deployment/deploy-engine-skipped-outputs.test.ts.- A Lambda
EventInvokeConfigrecordedprovisionedBy: 'cc-api'now returns to the SDK provider, whose update works, instead of staying on Cloud Control, whose update always fails for the type (src/provisioning/provider-registry.ts, go-to-k/cdkd#4091). Cloud Control's read handler returns emptyDestinationConfig.OnSuccess/OnFailureobjects that its own model then rejects, so every update of a resource pinned there (a record from before the SDK provider existed) failed withrequired key [Destination] not found. The type is now acc-brokensticky exemption: such a record moves to the SDK provider on its next deploy with its physical id (<FunctionName>|<Qualifier>) unchanged.cdkd drifton such a record no longer reports the emptyDestinationConfig.OnSuccess/OnFailuremembers Cloud Control's read injected into its baseline as drift. Of the providers documented as working around a broken Cloud Control handler, the CloudFront origin access identity, EventBridge rule and AppSync GraphQL API notes were re-measured stale and are corrected; the AgentCore runtime's documented failure is a create-time race, which records nothing and so cannot pin a resource to Cloud Control. Tests: integlambda-event-invoke-config-update(seededcc-apirecord, in-place flip, Cloud Control identifier parity). - An
AWS::RDS::DBProxyTargetGroupno longer ends up on Cloud Control, whose delete always fails for the type, and itsTagsare applied by the SDK provider (src/provisioning/provider-registry.ts,src/provisioning/providers/rds-dbproxy-targetgroup-provider.ts, go-to-k/cdkd#4087). Cloud Control's delete handler cannot deriveDBProxyNamefrom the TargetGroupArn identifier and sends it null (go-to-k/cdkd#385, re-measured 2026-09-29), yet the type was not inSTICKY_CC_MIGRATION_EXEMPT, so a key the SDK provider did not write (such as theTagsAWS just published) routed the resource through Cloud Control, recordedprovisionedBy: 'cc-api', and made the nextcdkd destroyfail on it. The type is now acc-brokenexemption: a record already pinned tocc-apireturns to the SDK provider, destroy always runs there, and an unrecognized key keeps the SDK route with a warning.Tagsis now handled: create tags the target group withAddTagsToResourceafter registering its targets (deregistering them again if tagging fails), update applies the tag difference withRemoveTagsFromResource/AddTagsToResource, andcdkd driftreads them withListTagsForResource. A malformedTagslist is refused before any call; a malformed recorded one only suppresses tag removals. The schema-refresh changelog template no longer says a post-snapshot key was dropped before the refresh (scripts/diagnose-schema-refresh.mjs). Tests:tests/unit/provisioning/rds-dbproxy-targetgroup-provider.test.ts; integrds-aurora. cdkd driftno longer reports a phantom name drift on a named IAM or ELBv2 resource deployed with the legacy stack-name prefix (issue #4081) --src/cli/commands/drift.ts(canonicalizeDerivedPatternBName),src/provisioning/resource-name.ts(derivesPatternBName),docs/cli-drift.md. A record with noobservedPropertiescompares its template name (my-role), while a stack deployed with--prefix-user-supplied-namesholdsMyStack-my-roleon AWS, so an IAM Role / User / Group / InstanceProfile / ManagedPolicy or an ELBv2 LoadBalancer / TargetGroup reported its name as drifted on every run andcdkd driftexited 1 with nothing changed. Detection now treats the two as equal when the live name is what cdkd derives from the recorded one under either prefix setting, in the stack's own name scope; a live name neither setting derives (another stack's prefix, a different name) is still reported. The name is then neither accepted nor reverted. Tests:tests/unit/cli/drift-derived-pattern-b-name.test.ts,tests/unit/cli/drift-revert-iam-name-scope.test.ts; integdrift-revertstep 6f.- An
Fn::GetAttcdkd cannot build is now refused instead of resolving to nothing, and an Output whose Value evaluates toAWS::NoValueis refused before provisioning (issue #4077) --src/deployment/intrinsic-function-resolver.ts(refuseUnconstructibleAttribute,refuseUndefinedAttribute) andsrc/deployment/output-value-preflight.ts, wired inDeployEngine, with unit tests intests/unit/deployment/output-value-preflight.test.ts,intrinsic-functions.test.ts,intrinsic-heal-before-constructing.test.tsanddeploy-engine-stale-attribute-heal.test.ts. DynamoDBStreamArnand IAM RoleRoleId(when the state record lacks them and the re-read does not supply them), a Cloud Map DNS namespaceHostedZoneIdwhoseGetNamespaceread fails or reports none, andAWS::IAM::PolicyPolicyId/AWS::ServiceDiscovery::HttpNamespaceHostedZoneId(attributes CloudFormation does not define) used to resolve toundefined. A resource property then dropped the value silently, anFn::JoinorFn::Subsent the textundefinedto AWS inside the joined value, and a stack Output was skipped with no warning, even under--strict-getatt. Each now refuses: the resource fails, and an Output is warned about and skipped (under--strict-getatt, the deploy fails). CloudFormation rejects a template whose Output Value evaluates toAWS::NoValue("The Value field of every Outputs member must evaluate to a String") before creating anything; cdkd now refuses it before provisioning too, with or without--strict-getatt, where it used to provision the stack and publish nothing for the Output. - A malformed
Tagsno longer untags a CloudWatch Logs log group or a Glue resource (issue #4073) --src/provisioning/providers/logs-loggroup-provider.ts,glue-provider.ts, now readingTagsthroughsrc/provisioning/tag-list.tsas #3994 did for the other providers. TheAWS::Logs::LogGroupupdate untagged every recorded key and re-tagged the raw desired value on anyTagsdifference, so a malformed desiredTags(reachable on a rollback orcdkd drift --revert) stripped every tag, and a recorded bare string failed the update; it now untags only the keys the template dropped and sets only new or changed tags, sent to the log-group ARN without the trailing:*. TheAWS::Glue::Job/Crawler/Triggerdiffs read a non-objectTagsas none and dropped or coerced malformed entries, then untagged every recorded key the short desired side lacked; anulllist entry threw aTypeError. For every Glue type that declaresTags(a key -> value map; a{Key, Value}list is read too), and for the log group, a malformed desiredTagsis refused before any AWS call on create and update, naming the property but never its content; a malformed recordedTagsis applied ADD-only with a warning; a recorded key holding a dynamic reference or its mask is never untagged. Separately,AWS::Glue::Workflownow applies aTagschange on update; it used to drop it. A number or boolean Glue tag value is now sent as its string instead of''. Neither repairs a resource already deployed that way until itsTagschange again: runcdkd drift --revertto bring such tags back to the template. A log group create or update error now passes through the secret masker. Tests:logs-loggroup-provider-malformed-tags-4073.test.ts,glue-provider-malformed-tags-4073.test.ts. cdkd destroy --remove-protectionno longer strips a Cognito user pool's Lambda triggers, advanced security or self-sign-up setting when it turns deletion protection off (issue #4066) --src/provisioning/providers/cognito-provider.ts,deletion-protection-compensation.ts,docs/cli-destroy.md, with unit tests intests/unit/provisioning/providers/cognito-remove-protection-rmw.test.ts. Before, the flip-off and the #2204 re-enable sentUpdateUserPoolwithDeletionProtectionalone, andUpdateUserPoolresets members a call omits: measured live,LambdaConfigtriggers,UserPoolAddOns,AutoVerifiedAttributes,VerificationMessageTemplate,DeviceConfigurationandAllowAdminCreateUserOnly(self sign-up switched on) all reset, so a pool whose delete then failed stayed live without them. On a pool requiring email verification before an attribute update, the bare flip was refused outright, so--remove-protectioncould not delete it. Both writes now send back, from the pool's ownDescribeUserPool, the settings a call would reset (and nothing measured to survive omission, except aDEVELOPERSES email configuration, which is unmeasured), and a pool left live with settings reset after a failed delete is reported at ERROR with what it held. If AWS refuses a write carrying the SES email configuration, it is re-sent once without it, with a warn that the email configuration may have been reset. Only when AWS refuses the echo on validation after that does the flip fall back to the bare write, at warn, and only then may the re-enable too. A pool cdkd cannot read first is no longer flipped blind: its guard stays on, at warn, so--remove-protectionon a user pool now needscognito-idp:DescribeUserPool. A throttle or server error on the read or the flip retries the whole delete before anything is deleted.cdkd diffno longer previews an export aliascdkd deployrefuses when an intrinsicExport.Namecarries a plain{{resolve:ssm:...}}to aSecureStringparameter, and no longer prints a secret's plaintext stored by an older cdkd beside, or instead of, its reference (issues #4056, #4101) --src/analyzer/outputs-diff.ts,src/cli/commands/diff-recursive.ts,.claude/rules/layout-analyzer.md,docs/architecture.md,docs/cli-diff.md. The Outputs preview refused an intrinsic alias only for asecretsmanager:/ssm-secure:spelling, but itsskipDynamicReferencespass also keeps a plainssmtoken for aSecureString, so the alias the deploy refuses was an[export]ADD on every run andcdkd diff --failnever went green. A surviving token of a service the deploy resolves now refuses the alias; another service's token still publishes. The legacy-record check reads that token too, so a record from before issue #1901 holding aSecureString's plaintext is withheld instead of printed as a MODIFY row'sold:side. A stored secret token now excuses its key only as one whole token or under exactly the desired value's literal text, never beside other text: an older deploy stored{{resolve:secretsmanager:A}}-<plaintext>for a joined reference, and the plaintext printed; the same shape rule decides whether a stored value proves the record redacted, and a value failing it is withheld per key whatever else the record holds; the no-change merge preview takes a whole token only. Fail-closed cost: when the text beside a secret reference changes (even a public parameter's value) or that output is condition-skipped, previous values are withheld until the next deploy. Tests:tests/unit/analyzer/outputs-diff-ssm-export-name-4056.test.ts,tests/unit/analyzer/outputs-diff.test.ts,tests/unit/cli/diff-recursive.test.ts.- A stack lock released during the final acquire attempt no longer fails the command with "Lock exists but could not read lock info" (issue #4055) --
src/state/lock-manager.ts,docs/troubleshooting.md,docs/cli-deploy.md,docs/state-management.md.acquireLockWithRetry(used bycdkd deploy,rollback,scrubandexport's nested children) read the lock after each failed acquire and, when the holder had released it in between, fell through: on the last attempt the loop ended, a second read also found nothing, and the command failed although one more acquire would have succeeded, telling the user toforce-unlocka lock that was gone. An empty read after a failed acquire now re-attempts at once without spending a retry, up to 3 times per command so a flapping lock, or alock.jsonwhose body is not a readable lock, cannot loop forever; past that an empty read waits the retry delay like a held lock. The refusal renders the read taken after the last failed acquire instead of reading again, counts the attempts actually made, and when no lock was found saysNo lock could be read after the last failed attemptand asks for a re-run, keeping theforce-unlockcommand for a repeat. Tests:tests/unit/state/lock-acquire-released-race.test.ts. cdkd diffno longer prints aNoEchotemplate parameter's value (issue #4049) --src/cli/commands/diff-recursive.ts, and unit cases. Before, the diff resolved aRefto aNoEchoparameter to its plaintext and printed it in a property row'sold:/new:lines, in--json'spropertyChangesandoutputChanges, in an Outputs row, and on--verbose'srequires replacement (<old> -> <new>)line and the resolver's debug lines. Each of these now shows***, as a CloudFormation change set shows****. The printed values are masked with a per-stack corpus made of three parts: everyNoEchoparameter's value, what the diff resolver records while it resolves (for example anFn::Base64of the value), and, for a nested child, the parent's corpus. The old side is withheld as***whenever the new side carries a masked value, because state keeps the previous value in the clear. An export row whose name embeds the value prints masked. ANoEchoparameter fed a SECRET{{resolve:...}}reference still prints as that expression, as every secret reference does incdkd diff. The limits are listed indocs/cli-diff.md. The change is still reported, so--failis unchanged, and nothing is written:cdkd diffpersists nothing, and the state record it reads is left byte-identical. Tests:tests/unit/cli/diff-noecho-render-4049.test.ts.- A
NoEchotemplate parameter's value no longer prints incdkd deploy --verbose'srequires replacement (<old> -> <new>)line or in the export-alias warnings (issue #4049) --src/analyzer/diff-calculator.ts,src/deployment/deploy-engine.ts,src/deployment/outputs-export-alias.ts,src/deployment/secret-redaction.ts(printingCorpusOf,recordLogOnlyParameterValue,createUnionSecretMasker),src/deployment/intrinsic-function-resolver.ts,src/cli/commands/scrub.ts, and unit cases. Before, the deploy's diff resolved aRefto aNoEchoparameter to its plaintext and printed it on the replacement line of a create-only property, and anExport.Namebuilt from one printed it in the collision warning. The diff calculator now takes a printing masker over the diff pass's own bag, everyNoEchoparameter's value and a nested child's inherited bag, masks each value before stringifying it, and withholds the old side as***when the new side carries a masked value, because the old value in state is usually the previous one, which is no needle. The export-alias collision, secret-bearing andcdkd scrubcollision warnings, andcdkd scrub's export-index repair lines, mask the log-only needles too. An output resolution failure, and the resolver's log lines and thrown errors, are masked with their bags in one pass, so a shorter value can no longer split a longer one. Nothing persisted or published changes:state.jsonis byte-identical with or withoutNoEcho, and whether anExport.Nameembedding the value is published is still decided from the recorded secrets alone (#4043).cdkd diff's own rendering and the Outputs summary remain on #4049. cdkd events,cdkd diff,cdkd driftandcdkd list--jsonoutput no longer carries terminal-active characters raw (src/cli/commands/{events,diff,drift,list}.ts, go-to-k/cdkd#4045). These payloads calledJSON.stringifydirectly, which escapes only C0,",\and lone surrogates, so a stored eventreason, run field, state property value, drift value or stack name reached the terminal with DEL, the C1 range (U+009Bis a live CSI),U+2028/U+2029, the bidi controls and the zero-width characters verbatim. They now go throughstringifyJsonPayload, likecdkd statesince go-to-k/cdkd#3163: every control, format, line-separator and paragraph-separator character is written as a\uXXXXescape, the output parses back to exactly the same values, and a plain payload is byte-identical.cdkd list --jsonvalues are still passed through the display sanitiser first, so that payload is escaped but not lossless (go-to-k/cdkd#3479); its YAML mode is unchanged. Tests:tests/unit/cli/{events-json-stream,drift-json-stream,list-display-safe,diff-json-escape}.test.ts.- A CodeCommit repository rename no longer adopts, overwrites and later deletes a different repository that already holds the new name (issues #4042, #4051) --
src/provisioning/providers/codecommit-repository-provider.ts,src/types/resource.ts(UpdateContext.recordedAttributes), and theupdate()calls insrc/deployment/deploy-engine.ts,src/deployment/rollback-executor.ts(both revert arms) andsrc/cli/commands/drift.ts(--revert), with unit tests intests/unit/provisioning/providers/codecommit-repository-provider-rename-probe-4042.test.tsand each caller's suite. When the rename call found the recorded repository gone, cdkd treated whatever repository held the desiredRepositoryNameas its own and sent it the description, key, tag and trigger writes, recording it as this resource so that a latercdkd destroydeleted it. Everyupdate()caller now hands the provider the attributes cdkd recorded for the resource, and the repository under the new name is adopted only when its id is theRepositoryIdcdkd recorded (or the one read just before this run's own rename). That still covers a deploy that stopped between a rename and its state write. A repository with a different id is refused with nothing sent to it. A record with noRepositoryIdis refused too, with thecdkd import ... --forcecommand that re-adopts the repository once the user has confirmed it is theirs. The same check guards the live trigger read on a retry, and the update now refuses before any call when the AWS client's region is not the one the record names. - A rollback no longer prints a physical id that spells a secret-derived name (issue #4037) --
src/deployment/rollback-executor.ts,src/deployment/replacement-name-holder.ts. The rollback masked its lines with the secrets it resolved, matching each plaintext literally, so a name taken from{{resolve:secretsmanager:...}}still reached the log in two shapes: the spelling an IAM / ELBv2 provider derives from it (alice@example.comsent asMyStack-alice-example-com, inRollback: R replacement reversed (old resource re-created as ...)at INFO, the delete-new-first line, the retry lines and the failure line), and the id of a record whose name is still a reference this op never resolves (the replacement's new copy, theUpdateReplacePolicy: Retainre-adopt line and survivor warning, aDeletionPolicy: Snapshotrollback's pre-delete snapshot lines; the RDS / DocDB / Neptune / ElastiCache provider lines are #4111). Every line, error, retry line and event reason a replay renders now goes through one per-op masker that adds, via the providers'withDerivedNameMasks, the physical ids (and their lowercased, snapshot-name and name-segment spellings) of every record whose name, or any...Name/...Identifierproperty (plus an allow-listed name spelled otherwise, such as ElastiCache'sReplicationGroupIdor an ECSFamily), is secret-derived and the names a rewriting provider derives from it under both prefix settings. An event'sphysicalIdfield stays exact. The unreproduced-name warning now names only the value it could not read. Tests:tests/unit/deployment/rollback-executor-derived-name-mask.test.ts,tests/unit/deployment/rollback-recreate-original-name.test.ts. cdkd export's unreadable-entry warning no longer tells the operator to fill a--leading stack name back intocdkd state show(src/cli/commands/export.tsreportDriftBaselineGaps, go-to-k/cdkd#4036). When the command it prints carried a quoted'<stack>'hole, the sentence before it said to fill the hole fromcdkd state list --jsonwhatever the name was, so a record named--allor--state-bucket=xled to a pasted command whose positional Commander parsed as an option. A stack name beginning with-, keyed on the raw-asinspectClauseis (go-to-k/cdkd#4011), now gets a sentence saying not to fill the stack hole with it and to repair or remove the record by hand; a withheld region beside an ordinary name keeps the fill-in. The withheldcdkd state refresh-observedsentence for a region beginning with-no longer says the CLI would read it as an option, which--stack-region's value never is; it says no AWS region starts that way. Tests:tests/unit/cli/export.test.ts.- Behavior change: an
AWS::RDS::DBCluster, or anAWS::RDS::DBInstancewithoutDBClusterIdentifier, that declares noDeletionPolicynow gets a final snapshot when deleted, as CloudFormation does (issue #4030) --src/provisioning/final-snapshot.ts,src/cli/commands/destroy-runner.ts,src/deployment/deploy-engine.ts,src/deployment/rollback-executor.ts,src/provisioning/cloud-control-provider.ts,src/provisioning/region-check.ts,src/cli/options.ts. CloudFormation's defaultDeletionPolicyisSnapshotfor these two shapes andDeletefor everything else. cdkd used to treat a missing policy asDeleteeverywhere, so destroying an L1CfnDBClusteror standaloneCfnDBInstancewithout the attribute deleted its data with no snapshot.cdkd destroy,cdkd state destroy, a deploy that removes the resource from the template, and a rollback of its creation now take the final snapshot (<physical id>-final-<UTC timestamp>) on the SDK route. On the Cloud Control route they refuse, as for an explicitSnapshot. Pass--skip-final-snapshotto delete without it; on the Cloud Control route that delete now also goes through RDS directly, since the Cloud Control handler would take an untracked snapshot of its own (issue #4029). CDK's L2DatabaseCluster/DatabaseInstancealways write aDeletionPolicy, and a cluster-member instance keeps the plain delete, so neither changes.UpdateReplacePolicydefaults toDeletefor every type, so replacement deletes are unchanged. The recorded policy is not rewritten, socdkd diffshows no change. Therds-dbinstance-backfillandrds-auroraintegration fixtures now assert the snapshot of their policy-less L1 instance and cluster, then delete it. - A Cloud Control-routed
AWS::Neptune::DBCluster, and anAWS::RDS::DBClusterin a global cluster, no longer leave an untagged final snapshot when deleted (issue #4029) --src/provisioning/cloud-control-provider.ts,src/provisioning/providers/rds-provider.ts,src/provisioning/providers/neptune-provider.ts,src/cli/commands/destroy-runner.ts,src/deployment/deploy-engine.ts. Cloud Control never tells a delete handler theDeletionPolicy, and some handlers then take a final snapshot of their own. cdkd already deleted Cloud Control-routed RDS clusters and instances through RDS for that reason (#3993), except a cluster in a global cluster, which it left on Cloud Control with a warning. A new integration fixture measured the Neptune cluster handler doing the same, and the ElastiCache cache-cluster handler not doing it. A Cloud Control-routed Neptune cluster is now deleted through Neptune withSkipFinalSnapshot, including one with noDeletionPolicy, whose CloudFormation default isDelete. An RDS cluster in a global cluster is now detached from it withRemoveFromGlobalCluster, then deleted through RDS once it has left the global cluster and is available again, as CloudFormation does. A busy or throttled detach is waited through. A cluster whose deletion protection is still on is not detached, since the detach cannot be undone and the delete would fail; the error says to pass--remove-protection. Any other detach failure fails the delete and never reads as already deleted. Two cases stay on Cloud Control with the warning: a Neptune cluster in a global cluster, and an RDS cluster whose recordedGlobalClusterIdentifieris not a plain name. The newcc-final-snapshot-handlersfixture fails if any of the three types leaves a snapshot. - A replacement or rollback delete of a Cloud Control-routed
AWS::RDS::DBClusterorAWS::RDS::DBInstanceno longer leaves an untagged final snapshot (issue #4029) --src/deployment/deploy-engine.ts,src/deployment/rollback-executor.ts,src/provisioning/final-snapshot.ts,src/provisioning/region-check.ts. The RDS Cloud Control delete handler takes a snapshot on every delete it runs, so cdkd deletes such a cluster or instance through RDS directly when the delete's policy isDelete(#3993). Only destroy and a deploy's removal delete said which policy governed them. A replacement's delete of the old copy, a rollback's delete of a replacement's new copy, and a rollback of a creation still went through the handler, and each left ards-snapshot-<random>snapshot that nothing recorded. Each of these deletes now passes its governing policy:UpdateReplacePolicyfor the replacement deletes, whose CloudFormation default isDeletefor every type, and theDeletionPolicythe rollback classified for a rolled-back creation. Therds-full-stackintegration fixture now renames its Cloud Control-routed instance to force a replacement, and fails if the old instance's delete leaves a manual snapshot. - Deleting an
AWS::ElastiCache::CacheClusterno longer fails when it takes more than ten minutes (issue #4029) --src/provisioning/providers/elasticache-provider.ts. cdkd waited at most ten minutes for a cache cluster to be gone, while a Redis delete can take longer; the delete then failed withTimed out waiting for CacheCluster ... to be deleted, and every retry failed again because AWS refuses a second delete of a cluster that is alreadydeleting. The wait now uses the same 60-minute limit as the Cloud Control route, and a cluster that is alreadydeletingis waited on instead of failing. Found by the newcc-final-snapshot-handlersintegration fixture. cdkd deploywarns again before dropping the stack-name prefix from an explicitly named IAM / ELBv2 resource --src/cli/commands/prefix-migration-check.ts, with a unit test. The pre-deploy check that lists resources the v0.94 default would REPLACE (a--prefix-user-supplied-namesstack deployed without the flag) runs inside the deploy's own prefix scope, so it computed the legacy name without the prefix and never found anything: the warning and its confirmation prompt were silently skipped and the resources were replaced. The legacy name is now always computed with the prefix. Found by therollback-explicit-name-rewriteinteg fixture (issue #4024).- A rollback restores an explicitly named IAM / ELBv2 resource under the name it had when the prefix setting changed between deploys (issue #4024) --
src/deployment/rollback-executor.ts,src/deployment/replacement-name-holder.ts, with unit tests intests/unit/deployment/rollback-recreate-original-name.test.tsand a new phase in therollback-explicit-name-rewriteinteg.cdkd rollbackand a deploy's automatic rollback replayed under the failed deploy's user-supplied-name prefix setting (#4018), but an earlier deploy under the other setting may have created the resource, so a reverse-replacement re-created an IAM Role / User / Group / InstanceProfile / ManagedPolicy or ELBv2 LoadBalancer / TargetGroup under a physical name it never had (ainstead of<stack>-a), and an in-place revert of a role or managed policy could replace it. The rollback now uses whichever setting turns the declared name into the old physical id (for an ARN, its name segment), for the re-create, the name-holder proof before deleting the new resource, and in-place reverts; when neither does, it keeps the failed deploy's setting and warns. The unproven-holder refusal also no longer says "cdkd cannot show" twice. cdkd drift --revertno longer replaces a named IAM Role or ManagedPolicy under a different name (issue #4023) --src/provisioning/providers/iam-role-provider.ts,src/provisioning/providers/iam-managed-policy-provider.ts,src/types/resource.ts(desiredFromAwsReadback),docs/cli-drift.md. Both providers derive the physical name insideupdate()and replace the resource when it differs from the recorded one. The revert runs outside the deploy's stack-name and prefix scope, and without an observed baseline its desired bag carries the template name. So on a stack deployed with--prefix-user-supplied-names(liveMyStack-my-role, templatemy-role) a revert created a new rolemy-roleand deleted the live one. On a revert (desiredFromAwsReadback) both providers now keep the recorded name, since a role or policy name cannot change in place. The drifted properties are still reverted on the live resource, and a mismatching name is reported in a warning rather than applied; a driftedPath(or policyDescription), which would need a replacement, now fails that resource's revert instead. Tests:tests/unit/cli/drift-revert-iam-name-scope.test.ts,tests/unit/provisioning/iam-role-provider.test.ts,tests/unit/provisioning/iam-managed-policy-provider.test.ts; integdrift-revertstep 6e.- An Auto Scaling group whose template enables ALL group metrics no longer drifts forever on a legacy record (issue #4021) --
src/provisioning/providers/asg-provider.ts(ALL_GROUP_METRICS,canonicalizeDriftPair,holdsAllKnownMetrics), with unit tests intests/unit/provisioning/asg-provider-metrics-all-4021.test.tsand an arm in thedrift-revert-vpcintegration fixture. AMetricsCollectionentry that lists no metrics (CDKGroupMetrics.all()) enables every group metric, but AWS reports enabled metrics one by one, so a resource whose drift baseline is its template (one deployed before observed-capture) never matched andcdkd driftreported it on every run. The drift comparison now reads an ALL baseline as "every metric AWS reports, which must include every metric cdkd knows ALL enables" (25, observed on AWS; the SDK documents 20): a readback holding them all, or more, compares clean, and one missing any of them reports drift for exactly the missing ones. Disabling a metric cdkd does not yet know is not reported.cdkd drift --revertre-enables them by enabling ALL (no metric list) and disables nothing. On create and update, aMetricsCollectionentry that explicitly lists every known metric is also sent as ALL, so it enables metrics AWS adds later too, where CloudFormation would send the explicit list. cdkd rollbackre-creates a resource under the physical name its deploy used (issue #4018) --src/cli/commands/rollback.ts,src/deployment/deploy-engine.ts,src/deployment/nested-child-journal.ts,src/types/rollback-journal.ts, with unit tests intests/unit/cli/commands/rollback-skip-prefix-4018.test.tsand a newrollback-explicit-name-rewriteinteg.cdkd deployderives the name of an explicitly named IAM Role / User / Group / InstanceProfile / ManagedPolicy or ELBv2 LoadBalancer / TargetGroup under its user-supplied-name prefix setting, butcdkd rollbackreplayed with no setting in scope, so it asked AWS for<stack>-<name>where the deploy had created<name>: a reverse-replacement restored the old resource under a name it never had, and an in-place revert of such a role or managed policy became a replacement. Each journal segment now records the setting (skipPrefix, additive, nojournalVersionbump; a non-boolean is refused) and the rollback, including a nested child's revert, replays each segment under it. A segment written by an older cdkd falls back toCDKD_PREFIX_USER_SUPPLIED_NAMES/cdk.json/ the default (no prefix) with a warning. Follow-ups: #4023 (drift --revertscope), #4024 (a flag flipped between deploys).cdkd diffno longer prints a recorded secret held in a stored Outputs key (issue #4015) --src/cli/commands/diff-recursive.ts(renderOutputChangeLines,diffTreeToJson) andsrc/analyzer/outputs-diff.ts(computeOutputsDiff,secretSpanInStoredKey), with unit tests intests/unit/cli/diff-output-key-secret-4015.test.ts. A binary from before issue #1919 could publish an export alias whoseExport.Nameresolved to a secret as a state KEY, and the diff printed each Outputs row name with only a control-character strip, so that secret reached the terminal and CI logs, contiguous or split by U+2028 / U+2029 (which the logger blanked to a space). Each row name, and the--jsonname, now prints only the verdict of the secret testcdkd scrubapplies to such keys: the name as is, masked (app-***), or<name withheld: contains a secret>;--jsonaddsnameRedacted: truewhen the name is not the stored key. The diff fetches no secret, so it tests against what it can know: the stretch of a stored key that a secret-bearingExport.Name's{{resolve:...}}tokens cover (condition-false exporters included), and the stored values of a pre-redaction record's secret outputs and of keys the template no longer declares. Beyond that search, a REMOVED export alias is withheld in a stack whose template references a secret: a key the template no longer declares that holds a character an Output logical id cannot and, where the record listsexportNames, is listed. A removed ordinary Output still prints its name. Two gaps remain, each printing unless the search finds its secret: an alias of only letters and digits, and any alias in a stack whose template no longer references a secret by asecretsmanager/ssm-securespelling. A stale alias sharing a secret-bearing name's literal ends is masked too.- An Auto Scaling group with several
GroupMetricsnow updates and drift-checks all of them (issue #4013) --src/provisioning/providers/asg-provider.ts(foldMetricsCollection, theMetricsCollectionupdate diff,canonicalizeDriftProperties), with unit tests intests/unit/provisioning/asg-provider-metrics-granularity-4013.test.tsand an update + drift arm in thelaunchtemplate-asg-inplaceintegration fixture. CDK renders oneMetricsCollectionentry perGroupMetrics, all at1Minute, and the update diff keyed the entries by granularity, so only the last entry counted on each side: adding two groups enabled only the last one's metrics, and dropping a metric from an earlier group left it enabled. A template-shaped drift baseline (a resource deployed before observed-capture) also never matched the readback's single entry. Both sides are now folded the way AWS holds the setting — one set of metrics per granularity, the union of every entry, with an entry that lists no metrics meaning all of them — so an update enables and disables exactly the difference between the two sets, and the same explicit set of metrics compares clean incdkd drifthowever the template splits it (an entry that enables all metrics still differs from a readback listing them one by one when the baseline is template-shaped). - A custom-resource handler is never sent a secret's plaintext, including on routes the template does not show (issue #4009) --
src/provisioning/providers/custom-resource-provider.ts(refuseResolvedSecret),findResolvedSecretPathsinsrc/provisioning/custom-resource-secure-references.ts, unit cases, a newcustom-resource-resolved-secretinteg fixture,docs/troubleshooting.mdand.claude/rules/provider-custom-resources.md. The #3976 pre-flight refuses a secure dynamic reference SPELLED in a custom resource's template; three routes still resolved a secret into the handler's event: a plain{{resolve:ssm:...}}naming a SecureString parameter, a nested child reading a parent parameter the parent filled from a secret, and a rollback replay re-resolving an older record.create/updatenow refuse, non-retryable and before the invoke, when this resource's resolution recorded a secret at all (the bag the deploy engine, the rollback replay and the nested child engine bind), so a derived form such as base64 is refused too, on either side of an update (cdkd rollback --revert-failedreplays a failed update's attempted properties asOldResourceProperties); the error names property paths only. ANoEchovalue is not a secret here and still reaches a dependent, as in CloudFormation. BEHAVIOUR CHANGE: a stack whose custom resource reads a SecureString through a plainssmreference, or a secret through a nested stack parameter, fails its next create or update of that resource until the handler reads the value itself. cdkd export'scdkd state orphanremedy withholds a stack name or region that is not a plain identifier (issue #3997) --src/cli/commands/export.ts, with unit tests intests/unit/cli/export.test.tsandexport-nested-failure-render.test.ts. The remedy named any value that rendered exactly, shell-quoted, so a record name with a space or a shell metacharacter was printed on the labelled command line, where a terminal can wrap its padding into what reads as a second line. It now builds the command with the plain-identifier rule thecdkd stateandcdkd destroyremedies use: such a value becomes a quoted hole like any other withheld value, and the note above the command says the value "is not a plain identifier (a letter or digit, then letters, digits, '~', '_', '.' or '-'), which cdkd does not print on a command line".- The custom-resource skip for a
ServiceTokenrecorded as a{{resolve:...}}dynamic reference names a region-scopedcdkd state orphantoo (issue #3996) --src/provisioning/providers/custom-resource-provider.ts, with the pin intests/unit/provisioning/custom-resource-masked-service-token.test.ts. This warning arrived in parallel with the #3996 sweep and still told the operator to clear the stack's records withcdkd state orphan <stack>, which without--stack-regionremoves that stack name's record in every region. It now readscdkd state orphan <stack> --stack-region <region>and says the command drops every record for the stack in that region. - Every
cdkd state orphanremedy cdkd prints as a template now carries--stack-region <region>and says it drops every record the stack has in that region (issue #3996) --src/provisioning/providers/{cloudwatch-anomaly-detector,custom-resource,iam-policy,iam-user-group,s3-bucket,lambda-permission}-provider.ts,src/provisioning/composite-id.ts,src/cli/commands/{destroy,state,import}.ts, with pins intests/unit/provisioning/*andtests/unit/cli/{destroy,state-destroy,import,import-resolver-error-masking}.test.ts. The provider skip warnings and refusals, the composite-id skip message and the destroy / state destroy summaries told the operator to clear one resource's record withcdkd state orphan <stack>: without--stack-regionthat removes the stack name's record in every region, and in any region it removes every record the stack has there, not one. The templates now carry the region and say so, the S3 region-mismatch refusal's delete arm says the region is the stack's, not the bucket's, and its update arm names the per-resourcecdkd orphan <StackPath>/<Path/To/Bucket>instead.cdkd import's unresolved-intrinsic warning now names the per-resourcecdkd orphan <StackPath>/<Path/To/Resource>instead. The three custom-resourceServiceTokenrefusals quote the commands instead of wrapping them in backticks, which ran them when pasted. - An Auto Scaling group's group metrics and SNS notifications now apply on its first deploy (issue #3995) --
src/provisioning/providers/asg-provider.ts, with unit tests intests/unit/provisioning/asg-provider-create-metrics-notifications-3995.test.tsand a first-deploy assertion in thelaunchtemplate-asg-inplaceintegration fixture.MetricsCollectionandNotificationConfigurationsare notCreateAutoScalingGroupmembers, and cdkd sent their dedicated APIs only from the update path, so a CDK group withgroupMetricsornotificationswas created without either, silently, until some later change triggered an update.create()now sends oneEnableMetricsCollectionperMetricsCollectionentry and onePutNotificationConfigurationperNotificationConfigurationsentry once the group exists. If one of them fails, cdkd deletes the group it just created, as CloudFormation rolls back a CREATE_FAILED group: it scales the group to zero (clearing a template-setDeletionProtection), lifts EC2 termination protection from the instances it launched, force-deletes it and waits, then reports the original error. If that delete cannot finish, the error also names the surviving group and the command to delete it by hand. - A malformed
Tagsvalue no longer untags every key a resource holds (issue #3994) --src/provisioning/tag-list.ts(new), read by most SDK providers that diffTags. EachTagsdiff read a present-but-malformed value as empty or short (a string walked by character, a non-list read as{}, an entry withoutKeyorValuedropped), and several untagged every recorded key before re-tagging, so on a rollback orcdkd drift --revert, which replay a recorded bag, a malformedTagsstripped the resource's tags.undefined/nullstill reads as no tags; anything else that is not a list of{Key: non-empty string, Value: scalar}(a number or booleanValuereads as its string, as CloudFormation coerces it; an omitted one as''onAWS::RDS::DBProxy/DBProxyEndpoint; forAWS::SSM::Parameter, a map of keys to scalars) is malformed. A malformed desiredTags, or one whose key holds a dynamic reference or its mask, is now refused before any AWS call on create and update, naming the property but never its content; a missing,nullor objectValueis no longer coerced to''(except an omitted one on DBProxy / DBProxyEndpoint). A malformed recordedTagsis applied ADD-only: the desired tags are set, nothing is untagged (an S3 bucket keeps the live tags it cannot account for), and cdkd warns. A recorded key holding a dynamic reference or its mask is never untagged, and cdkd warns about it. Updates now send only the tags that changed (an S3 bucket still replaces its whole set). Separately,AWS::ElastiCache::SubnetGroupnow sends itsTagson create and on update applies the missing ones, untagging only recorded keys it holds, andAWS::ServiceDiscovery::Servicediffs itsTagson update; both used to drop them. The Glue and CloudWatch Logs log-group providers follow in #4073. Tests:tag-list.test.ts, per-provider*-malformed-tags-3994.test.ts. - A Cloud Control-routed
AWS::RDS::DBClusterorAWS::RDS::DBInstanceno longer leaves an untagged final snapshot on every delete (issue #3993) --src/provisioning/cloud-control-provider.ts,src/provisioning/providers/rds-provider.ts,src/provisioning/region-check.ts,src/cli/commands/destroy-runner.ts,src/deployment/deploy-engine.ts. The RDS Cloud Control delete handlers take a final snapshot whenever the request does not say whether one is wanted, and Cloud Control never says, so every destroy of such a cluster or standalone instance left a manualrds-snapshot-<random>snapshot behind, even underDeletionPolicy: Delete. Under an explicitDeletionPolicy: Delete,cdkd destroy,cdkd state destroyand a deploy's removal delete now hand both types to the SDKRDSProvider, bound to the Cloud Control client's region, which deletes withSkipFinalSnapshot=trueand waits up to 60 minutes for the resource to be gone. With noDeletionPolicythe Cloud Control delete and its snapshot stay, since CloudFormation's default for these types isSnapshot(issue #4030); replacement and rollback deletes also stay on Cloud Control (issue #4029). It also brings--remove-protectionto these resources. ADeleteAutomatedBackupsvalue in the template is sent with the delete, as CloudFormation sends it.DeletionPolicy: Snapshoton a Cloud Control-routed resource is still refused as before. A cluster that setsGlobalClusterIdentifieris still deleted through Cloud Control, because its handler removes the cluster from the global cluster first, and cdkd now warns that this delete leaves the snapshot. Therds-auroraandrds-full-stackintegration fixtures now fail when a run leaves a manual snapshot of their Cloud Control-routed cluster or instance. - A malformed
Targets,NotificationsWithSubscribers/ResourceTagsorTriggers/Tagslist no longer removes every entry an ELBv2 target group, budget or CodeCommit repository holds (issue #3989) --src/provisioning/providers/elbv2-provider.ts,budgets-budget-provider.tsandcodecommit-repository-provider.ts, with unit tests intests/unit/provisioning/elbv2-targetgroup-malformed-targets-3989.test.ts,budgets-budget-provider-malformed-lists-3989.test.tsandproviders/codecommit-repository-provider-malformed-lists-3989.test.ts. Each list read a present-but-malformed value (a string, an object, an entry with no identifying member) as empty, and each update derives its removals from the gap between the desired and the recorded list, so a malformed desired side -- reachable on a rollback orcdkd drift --revert, which replay a recorded bag -- deregistered every target, deleted every budget notification or untagged every key, andPutRepositoryTriggerscleared every trigger. A malformed desired list is now refused before any AWS call, on every path, naming the property but never its content; a create refuses it too instead of dropping entries. A malformed recorded list (for example an unresolved intrinsic acdkd importleft) is applied ADD-only: the target group's registered targets and the repository's triggers are read from AWS and nothing is deregistered or cleared, notifications and tags are added without deleting any, and cdkd warns about what it left in place.undefined/nullstill reads as the empty list. Separately, a budget update that adds a notification which already exists now creates the desired subscribers on it instead of skipping them. The secret-derived check these lists share with the Auto Scaling group, Firehose and IAM principal-list reads now treats cdkd's***mask only as a whole value, so a legitimate key such asa***bis read as an ordinary name there too. - A nested
cdkd exportthat fails partway names only the steps each stack still needs, in order, lists the deletes its phase 2 needs, and recovers from every step after an IMPORT (issue #3988) --src/cli/commands/export.ts, with unit tests intests/unit/cli/export-nested-failure-render.test.tsandexport-nested-loop.test.ts. The failed stack's line said "once this stack's IMPORT / adoption and phase 2 succeed by hand" whatever was left: it asked for a phase 2 a stack had no work for, never for the no-op tag update a non-root stack needs to leaveIMPORT_COMPLETEbefore its parent adopts it, and a Phase 1A or 1B failure never listed the deletes of the IMPORT-unsupported resources phase 2 re-creates, so a by-hand phase 2 hit a name collision or left a duplicate unmanaged. The line now names only the IMPORT, adoption, phase 2 or closing tag update still needed, and prints those deletes first. A failure after a stack's Phase 1A (reading its CloudFormation stack, either tag update, preparing the adoption) threw the bare error with no recovery while a re-run was already refused; it now gives the same whole-tree recovery. The re-run refusal no longer wrapscdkd exportin backticks, which ran it when pasted, and the Phase 1B failure no longer calls an adopted stack standalone. cdkd deploy --replacedeletes the old resource first only when it provably holds the colliding name (issue #3979) --src/deployment/deploy-engine.ts,src/deployment/replacement-name-holder.ts, with unit tests intests/unit/deployment/deploy-engine-replace-name-holder.test.tsandtests/unit/deployment/replacement-name-holder.test.ts. When a replacement's create-first attempt collided on a name,--replacedeleted the old resource and re-created it, refusing only when the template's explicit name was known to differ from the old one. A template that named no name, or a name the IAM / ELBv2 providers rewrite under the stack prefix, collided just the same with an orphan of an earlier attempt, a retried create or a resource made outside the stack, and the live old resource was deleted though it never held that name. The delete-first now runs only once the old resource is shown to hold the name the create actually sent (its record, its physical id, cdkd's generated or rewritten name, and the parent that places it), with the same rule the rollback arm uses; otherwise the deploy fails withNAMED_REPLACEMENT_COLLISIONand deletes nothing. The same check runs without--replaceand underUpdateReplacePolicy: Retain, so their refusals no longer advise a--replacethat would refuse. ATypechange now deletes the old resource first only between types sharing one name space. In both directions — this and the rollback's reverse-replacement — a holder whose state record and last read-back name it differently (renamed outside cdkd) is no longer proven by its physical id, so nothing is deleted.- Reversing a replacement no longer deletes the new resource when something else holds the name the old one needs (issue #3979) --
src/deployment/replacement-name-holder.ts(reverseReplacementNewHoldsName) andsrc/deployment/rollback-executor.ts. When a rollback's re-create of the old resource reported the name as taken, cdkd deleted the NEW resource first. If something else held the name (a resource an earlier failed attempt left behind, a replayed create, or one made outside the stack), that deleted a live resource and the re-create collided again. cdkd now deletes the new resource only when it can show the new resource holds that name: the same explicit name recorded under the same parent (case ignored only where the service ignores it, such as IAM and RDS), or a physical id naming it; for a Route 53 record, the same DNS name in the same hosted zone. A generated name is proven only through the physical id, and only where cdkd knows it exactly. IAM roles, users, groups, instance profiles and managed policies, and ELBv2 load balancers and target groups rewrite even an explicit name before sending it, so for them only a physical id naming the name this re-create actually sent counts. Otherwise, and whenever cdkd cannot tell, the operation fails naming the colliding name, nothing is deleted, and the journal is kept; if the holder is the new resource itself, deleting it by hand and re-runningcdkd rollbackproceeds. Thecdkd deploy --replacepath without an explicit name is left for a follow-up (#3979). cdkd deployrefuses a custom resource whose properties hold a secure dynamic reference, which CloudFormation does not support (issue #3976) -- newsrc/provisioning/custom-resource-secure-references.ts, wired intoProviderRegistry.validateResourceProperties(validateCustomResourceSecureReferences),tests/unit/provisioning/custom-resource-secure-references.test.ts, thecustom-resource-masked-service-tokeninteg fixture anddocs/troubleshooting.md. CloudFormation does not support{{resolve:secretsmanager:...}}/{{resolve:ssm-secure:...}}in custom resources; cdkd resolved them and sent the secret's plaintext in the handler's event, and persisted the reference expression where the delete path later read it (#3960). The pre-flight now refuses such a template before anything is resolved -- including the same-stack CDK shape, whose token is split acrossFn::Joinparts around the secret'sRef-- naming each resource and property path (never the reference), with no--allow-*escape hatch. A plain{{resolve:ssm:...}}is still accepted. BEHAVIOUR CHANGE: a stack that deployed such a custom resource under an earlier cdkd now fails its next deploy until the template passes the secret's name or ARN instead;cdkd destroydoes not run the check.cdkd destroy's active-imports refusal no longer puts a non-plain state value inside cdkd's own quotes or prints it raw (issue #3950, partial) --src/utils/error-handler.ts(StackHasActiveImportsError), with paste cases intests/unit/utils/active-imports-prose-quotes.test.ts. The refusal printedCannot destroy stack '<producer>'andimports export '<name>'with the values raw, beside the raw consumer stack name and both regions. The consumer's name comes from its state key, its region from the key (a legacy record's from its body) and the export name from itsstate.json, so whoever can write the state bucket chose them. A value carrying'closed cdkd's quote, so pasting the sentence ran what followed it as shell. A plain value prints exactly as before, a CDK-generated export name carrying:included. Any other value is described (an export whose name is not a plain identifier,a stack name that is not a plain identifier,a region that is not a plain identifier).- The
--assume-roleparse and--assume-role-autorefusals no longer put the operand inside cdkd's own double quotes, or show it unbounded (issue #3950, partial) --src/cli/options.ts(parseAssumeRoleToken), with paste cases intests/unit/cli/assume-role-parse-prose-quotes.test.ts. The three refusals printed the value, its logical id and its ARN as"${displayIdent(...)}", anddisplayIdent's JSON boundary does not survive a quote around it: a"in the value closed cdkd's, and$( )or a backtick runs inside double quotes regardless when the sentence is pasted. A plain operand keeps its"..."and prints exactly as before, long ARNs included. Any other goes throughshellBoundedDisplay: shown as its JSON render inside single quotes, as the resolver shows a refusedFn::GetStackOutputRoleArn, since the operator needs it to correct the flag, or described when that render holds a clause break (a cut value's[cut: …]marker included). An empty operand prints'<unrenderable>'. The--assume-role-autorefusal (normalizeStartApiAssumeRole) printed the global ARN through baredisplayIdent, andisIamRoleArn'srole/[!-~]+admits$,(and a backtick, so pasting frombut --assume-roleonward ran a$( )in it. It now goes through the same helper, so a plain global ARN, bare before, prints in"..."too.record-value-prose-quotes-fence.test.tsdropsoptions.tsfrom its allow-list. - The composite-id refusal and malformed-id messages, the replacement name-collision refusal and the S3
UnknownErrorrewrites no longer put a record or template value inside cdkd's own quotes (issue #3950, partial) --src/provisioning/composite-id.ts,src/deployment/replacement-name-holder.ts,src/utils/error-handler.ts, with paste cases intests/unit/provisioning/composite-id-prose-quotes.test.ts,tests/unit/deployment/replacement-name-holder-prose-quotes.test.tsandtests/unit/utils/error-handler.test.ts. The composite-id messages and the bucket rewrites quoted the value RAW, and the collision refusal quoted adisplaySaferender, which keeps",$( )and a backtick. A value carrying a quote closed cdkd's, so pasting the sentence ran what followed it as shell. A plain value keeps its quotes and prints exactly as before, and so do a composite segment carrying the|separator, a masked***segment and a blank id. Any other value is described (a bucket whose name is not a plain identifier). Areplacement-name-holder.tsentry no longer sits inrecord-value-prose-quotes-fence.test.ts's allow-list. cdkd export's messages no longer put a state-record or template value inside cdkd's own quotes (issue #3950, partial) --src/cli/commands/export.ts, with paste cases and a fence intests/unit/cli/export-prose-quotes.test.ts. 96 sites wrapped a stack name, a CloudFormation stack name, a logical id, a template path or a record value in a hand-written'...', raw or throughsafeSegment, which keeps',$( )and a space. A value carrying a'closed cdkd's quote, so pasting the sentence ran what followed it as shell: the nested-child refusal's head, measured by go-to-k/cdkd#3924's paste case and excluded there by name, was the first confirmed site, and that case now covers the whole message. Two of the 96 are the SSM-typed parameter refusal's stack names, added by go-to-k/cdkd#4058 in the file's existing shape. A plain value keeps its quotes and prints exactly as before, and an empty one still prints as''. Any other reads(not shown: it is not a plain identifier). The nested-template read and parse refusals no longer append the underlying error's text: Node's read error echoes the path inside its own'...', and the parser's echoes a snippet of the file. The read refusal prints the fs error code (ENOENT), and the parse refusal says the template cannot be parsed. The hand quotes that stay are listed with a reason and a count in the new fence, which scans a quote holding text beside the value too: cdkd's own literals and generated names, a field name from AWS's schema, and values the operator typed on the command line.record-value-prose-quotes-fence.test.tsdropsexport.tsfrom its allow-list.- The
cdkd local--assume-rolemessages, two intrinsic-resolver refusals, the bootstrap-marker and asset-storage refusals and the Scheduler degraded-delete warning no longer put a record or template value inside cdkd's own quotes (issue #3950, partial) --src/cli/commands/local-invoke.ts,local-invoke-agentcore.ts,local-start-api.ts,src/deployment/intrinsic-function-resolver.ts,src/assets/asset-storage.ts,src/provisioning/providers/scheduler-schedule-provider.ts, with paste cases intests/unit/cli/local-assume-role-prose-quotes.test.ts,tests/unit/assets/asset-storage.test.ts,tests/unit/provisioning/scheduler-schedule-provider.test.ts,tests/unit/deployment/intrinsic-functions.test.tsandintrinsic-getstackoutput-cross-account.test.ts. A value carrying'(or") closed that quote, so pasting the sentence ran what followed it as shell. A plain value keeps its quotes and prints exactly as before. Any other logical id, unsupported intrinsic name, marker key, marker bucket or repository name, or schedule name is described (a logical id that is not a plain identifier), since those sentences also namecdkdor a flag. A refusedFn::GetStackOutputRoleArnshows JSON-quoted instead, as the operator needs it to fix the template.local start-apidrops the--assume-role <id>=<arn>example for a non-plain logical id. Therecord-value-prose-quotes-fence.test.tsfence now also refuses a hand-quotedsafe*variable. Theexport.ts,options.ts,replacement-name-holder.tsandcomposite-id.tssites stay open on #3950. - The intrinsic resolver's messages no longer put a masked template or record value inside cdkd's own quotes (issue #3950, partial) --
src/deployment/intrinsic-function-resolver.ts, with paste cases intests/unit/deployment/resolver-prose-quotes.test.ts. 27 lines wrapped adisplayMasked/displayLeafrender in a hand-written'...'or"...", 12 more wrapped alogged*binding of one, and the placeholder-ARN refusal wrapped astringifyValuerender: theFn::FindInMaprefusals, theFn::ImportValueandFn::GetStackOutputmessages and origin labels, the dynamic-reference secret, key and SSM parameter refusals, theFn::GetAZsrefusals, the physical-id and placeholder-ARN refusals, the nested-stack parameter and output refusals, and a few debug lines. The render keeps',",$( )and a backtick, so a value carrying a quote closed cdkd's, and pasting the sentence ran what followed it as shell. A render made of plain-identifier characters,|,*,<and>keeps its quotes and prints exactly as before, a masked***included. Any other reads(not shown: it is not a plain identifier)or a short description.record-value-prose-quotes-fence.test.tsdrops the resolver from its allow-list and now also refuses a hand-quotedlogged*binding. Other #3950 rows stay open. cdkd rollback,cdkd events, the SSM parameter import refusal and the Cloud Control import identifier messages no longer wrap a stack name, region, run id or physical id in cdkd's own'...'(issue #3950, partial) --src/cli/commands/rollback.ts,src/cli/commands/events.ts,src/provisioning/cc-import-identifier.ts,src/provisioning/providers/ssm-parameter-provider.ts, with paste cases intests/unit/cli/commands/rollback.test.ts,tests/unit/cli/events-display-safe.test.ts,tests/unit/provisioning/cc-import-identifier.test.tsandtests/unit/provisioning/pasteable-command-hand-quoted-ids.test.ts, and asrc/**fence intests/unit/utils/record-value-prose-quotes-fence.test.ts. A value carrying'closed that quote, so pasting the sentence ran what followed it as shell. The value now carries its own boundary: a plain one prints bare (Roll back MyStack (us-east-1)?instead ofRoll back 'MyStack' (us-east-1)?) and any other JSON-quoted.cdkd events' multiple-regions error ends in a--stack-regionremedy, so it describes a stack name or region that is not a plain identifier rather than printing it, and then points at a read-only S3 key listing, and the Cloud Control--resourceremedy prints a<logicalId>placeholder for such a logical id. Theexport.ts,state.ts,cdkd local,intrinsic-function-resolver.ts,composite-id.tsandscheduler-schedule-provider.tssites, and the double-quoted ones inoptions.tsandreplacement-name-holder.ts, stay open on #3950.- An
AWS::RDS::DBProxyTargetGroupwhoseDBClusterIdentifiers/DBInstanceIdentifiersis not a list of RDS DB identifiers is refused before any AWS call, instead of being walked character by character (src/provisioning/providers/rds-dbproxy-targetgroup-provider.ts, go-to-k/cdkd#3945).update()cast both sides tostring[], so a rollback revert orcdkd drift --revertreplaying a recorded"prod-cluster"as the desired side deregistered the real target and registeredp,r,o, ..., one-letter identifiers an unrelated DB instance can carry. Both sides are now read as identifier lists first; a malformed one throws, naming the side and property, before the connection pool is touched. A recorded list kept as a dynamic reference or its mask is read from the proxy instead (add-only). Identifiers compare case-insensitively.create()refuses the same shapes.delete()finishes a record naming no target and one whose proxy or group is already gone, and otherwise skips a malformed list (record kept, exit 2) rather than issuing a Deregister that named no target; a recordedTargetGroupNameother thandefaultis skipped too unless its proxy is gone.create(),update(),delete()andcdkd driftread a non-stringDBProxyNameas missing, anddelete()andcdkd driftread a non-stringTargetGroupNameasdefault.cdkd driftreads every page of targets, no longer reports a registered cluster's member instances asDBInstanceIdentifiers, and reports a case-only match in the recorded spelling. The create and update debug lines mask identifiers. The missing-DBProxyNameremedy prints its command throughpasteableAwsCommandand only ever names thedefaultgroup (part of go-to-k/cdkd#3136). Tests:tests/unit/provisioning/rds-dbproxy-targetgroup-provider.test.ts. - A nested
cdkd exporthands a child the value stored under a parent's SSM-typed Parameter, not the SSM parameter's name (issue #3915) --src/cli/commands/export.ts, with unit tests intests/unit/cli/export.test.tsandtests/unit/cli/export-nested-failure-render.test.ts, and an arm in theexport-nested-stackinteg. When a parent passed one of itsAWS::SSM::Parameter::Value<...>Parameters to a nested child, the child's IMPORT changeset was submitted the SSM parameter's NAME, so the migrated standalone child held the path as a literal value, and the IAM::Policy pre-delete check blocked a policy whose role came through it. cdkd now reads the value withssm:GetParameter(without decryption) and submits it to the child, as CloudFormation hands a nested stack the resolved value; the parent's own changeset still submits the name. A list type is a list to the child row'sRef(soFn::Join/Fn::Selectover it resolve) and is handed over comma-joined, and only a parameter the child's row mentions is read.cdkd exporttherefore needsssm:GetParameteron such a parameter: an unreadable one, or one whose type is notStringorStringList(such as aSecureString, which CloudFormation does not accept for an SSM-typed Parameter), refuses before any nested stack's lock or any changeset;--dry-runwarns instead and plans without the nested stacks' Parameter values. - An
AWS::IAM::Policywhose recordedRoles/Groups/Usersis secret-derived is no longer met with "repair it in state.json" (src/provisioning/providers/iam-policy-provider.ts, go-to-k/cdkd#3907). cdkd keeps a secret dynamic reference (or its***mask) in state by design, so theupdate()refusal and thedelete()skip told the user to write a secret-derived principal name into state.json. Both now name a secret-derived kind apart from a plainly malformed one: the update refusal says IAM has no call listing the principals holding an inline policy, so the attachment change is made by hand (plus removing the old-named policy when the change renames it) and the record dropped withcdkd orphan <constructPath>for the next deploy to re-attach it; the delete skip says cdkd will keep skipping the record, to remove the policy by hand, and whencdkd state orphanclears it. A plainly malformed kind keeps the state.json repair, except beside a secret-derived one, where that repair alone would still be refused. The desired-side refusal now also says where that side comes from: the template on a deploy, the recorded value being restored on a rollback revert orcdkd drift --revert, and a desired side holding an unresolved reference or mask is sent back to the template, before any recorded-side repair. Tests:tests/unit/provisioning/iam-policy-provider-target-shape.test.ts,provider-delete-skip-outcome.test.ts,iam-policy-targets.test.ts. - An "already exists" from a DynamoDB global table's replica add or TTL call during create no longer reads as the table's own name collision (issue #3877, the residual of #3826) --
src/provisioning/providers/dynamodb-globaltable-provider.ts, with a row intests/unit/provisioning/auxiliary-failure-3826.test.ts. A failure afterCreateTablereturns (the replica add'sUpdateTable,UpdateTimeToLive) now passes throughmarkAuxiliaryFailurein the catch that already retires the partial table. Before this change, such a failure relaying AWS's "already exists" was treated as a collision on the table's own name, so--replacedeleted the live old table and a replacement rollback deleted the live new one. The error text is unchanged, and a collision onCreateTableitself still classifies. cdkd diffandcdkd driftno longer report a state entry keyed(resources map)as if the wholeresourcesmap were unreadable (issue #3339) --src/cli/commands/{diff-recursive,drift}.ts,src/state/malformed-resources-bag.ts. Both commands carried an unreadable container as a stand-in string beside the dropped entries' logical ids, so a hand-edited key spelled like it produced the identical report.--jsonshape change (cdkd diff):unreadablenow holds only the ids of droppedresourcesentries; a newunreadableContainerslists"resources"/"orphans"for a container unreadable as a whole, and a newunreadableOrphanslists each dropped rollback-orphan record bylogicalId,nullfor one with no string id (it was'', indistinguishable from an entry keyed''). The preview renders a container's name from that field and every id through the quoting renderer, so a planted(resources map)prints quoted and keeps the "shown above as a create" sentence.cdkd drift: an unreadable map is reported under a newnotComparedcause,unreadableMap(exit 2, as before), with its own reason text; a dropped entry keepsunreadableRecord. Tests:tests/unit/cli/diff-unreadable-structure-3339.test.ts,tests/unit/cli/drift.test.ts, and the existing diff suites moved to the new fields.cdkd driftno longer reports a resource with an unreadablepropertiesmap as clean, and--accept/--revertrefuse it (issue #3315) --src/cli/commands/drift.tsandsrc/state/malformed-resources-bag.ts, with unit tests intests/unit/cli/drift.test.tsandtests/unit/state/malformed-resources-bag.test.ts. A resource record whosepropertiesmap was absent,null, a string, a number, a boolean or a list was read as-is: a string became one drifted key per character, and any other shape compared nothing and reported the resource clean at exit 0. Plaincdkd driftnow warns naming those records, leaves them out of the comparison and reports each as not compared with the causeunreadableRecord, so the run exits 2 (or 1 when something else drifted).--acceptand--revertrefuse the record before any readback, lock or write, naming the records and ending on thecdkd state showcommand to inspect it.cdkd export's half of the issue is not covered here.- A 1-3 character secret in a nested-stack parameter whose token sits in a nested
Fn::Join/Fn::Subpart, in a usedFn::Subvariable, or inside anFn::Ifaround the frame is no longer persisted in plaintext in the child, the parent's row or a grandchild's log lines (issue #3306) --src/deployment/intrinsic-function-resolver.ts,src/deployment/secret-redaction.ts; cases intests/unit/deployment/nested-stack-refused-frame-carry.test.ts. The sub-floor carry certifies a frame on the resolver's record of the parameter's own object, and that record spelled only the outer object's own tokens. It now takes a nestedFn::Join/Fn::Sub/Fn::Ifpart's record, and a usedFn::Subvariable's raw text (or, for an intrinsic variable, its record), so the token reaches the record raw beside the replacement that resolved it. AnFn::Ifrecords its selected branch's resolution; a string branch records its own pass, and a branch with no record, or two resolutions in one pass that record differently, poison it. Still refused, as refusals of a wrong reference or of a carried plaintext (listed onrecordNestedStackParameterExpressions): a public or multiple replacement, a token in a part with no record of its own (anFn::Selectelement), a spelling holding another recorded secret anywhere, the token included, where only a plaintext equal to the frame's own value is let through, and only inside its token (a{{resolve:ssm:/app/${Name}}}whoseNameis a secret is no longer carried; the parent's own row still takes such a token through the position pass, go-to-k/cdkd#4166), and two outside-the-token frames of one value through different tokens. Resolved values are unchanged. cdkd staterenders every stack name and region in a visible boundary, and its two confirmation prompts describe a value that is not a plain identifier (issues #3179, #3027, #3617, #3760, #3950) --src/cli/commands/state.ts,src/utils/display-safe.ts, with unit tests intests/unit/cli/state-*.test.ts. A stack name or region read from an S3 key or a state record reached the refusals, log lines and rows ofstate resources/show/list --long/orphan/destroy/refresh-observed/inforaw, through an allowlist that kept spaces beside cdkd's own(region)annotation, or inside cdkd's own'...', which a'in the value closed so a pasted sentence ran the rest. Each now renders throughdisplayIdent: a plain value prints unchanged, anything else JSON-quoted. Thestate orphanandstate refresh-observedprompts, printed beside a labelledDestroy with:/Migrate with:line, show a name or region only when it is a plain identifier and describe it otherwise, so a padded value can no longer wrap into a counterfeit labelled row, and a bare,can no longer add an entry to a joined list. Every line of astate orphanrun describes such a value the same way, including a lock owner or operation in the live-lock warning that does not render as itself; the prompts, the skip and no-record lines, and under--yes/--forcethe removal line add'cdkd state list --long' shows the records as stored.when they described a value. A refusal whose name is not followed by(region)puts its remedy on its own line, and the--stack-regionremedy quotes its'<region>'placeholder;cdkd rollback <stack>shares both refusals.--strict-getattnow fails the deploy for a stack Output whose value resolves to nothing, not only for one whose resolution throws (issue #3168) --src/deployment/deploy-engine.ts(resolveOutputs), with unit tests intests/unit/deployment/deploy-engine-skipped-outputs.test.tsanddeploy-engine-stale-attribute-heal.test.ts(the real resolver onFn::GetAtt [Role, RoleId]). SomeFn::GetAttattributes cdkd cannot construct (AWS::IAM::RoleRoleId,AWS::DynamoDB::TableStreamArn,AWS::IAM::PolicyPolicyId, a failed Cloud MapHostedZoneIdlookup) return no value instead of throwing, so such an Output was stored with no value and the deploy exited 0 even under--strict-getatt, leaving anExport.Namea consumer'sFn::ImportValuecould not find. Under the flag it now takes the same failure path as a thrown resolution: the deploy fails withFailed to resolve output <name>: the value resolved to nothing, and the failure save keeps the previous deploy's outputs. Without the flag nothing changes.cdkd state's--jsonoutput no longer carries terminal-active characters raw, andstate list --long --jsonrows gainstateReadErrorKind(src/utils/display-safe.tsstringifyJsonPayload,src/cli/commands/state.ts, go-to-k/cdkd#3163).JSON.stringifyescapes only C0,",\and lone surrogates, so a stack name, region or record value planted by anyone who can write the state bucket reachedstate list --json/--long --json/--tree --json,state resources --json,state show --json(with or without--show-nested) andstate info --jsonwith DEL, the C1 range (U+009Bis a live CSI),U+2028/U+2029, the bidi overrides and the zero-width characters verbatim. Every control, format, line-separator and paragraph-separator character is now written as a\uXXXXescape: the output is still valid JSON, it parses back to exactly the same values, and a plain payload is byte-identical.--long --jsonrows also carrystateReadErrorKind(read-failed/resources-malformed/null), so a consumer can tell a possibly transient read failure from a persistent malformed record without matching thestateReadErrortext. The other commands'--jsonpayloads are go-to-k/cdkd#4045. Tests:tests/unit/utils/display-safe-json-payload.test.ts,tests/unit/cli/state-{list,show,resources,info}.test.ts.- A
cdkd import --resource <id>=<physicalId> --forcere-import no longer replaces a good recorded attribute with the redaction mask, and says so when it cannot clear one (issue #2927) --src/cli/commands/import.ts(newreimportedAttributes, called frombuildStackState),src/provisioning/cloud-control-provider.ts(the two unreadable-model warnings),docs/import.md, a new unit file and a newimport-reimport-masked-attrsinteg fixture.CloudControlProvider.importmasks every model key it cannot certify as an attribute but keeps the key, so a re-import's bag of masks was non-empty and overwrote a deploy-recorded bag of real values for the same physical id; every dependent's nextFn::GetAttof those keys was then refused. Now a masked value never displaces an unmasked stored value, per top-level key (the import names each kept key), while certified keys still take the fresh value. The opposite case keeps its behavior on purpose: when the re-import yields no value for a key the record already masks (an unreadableGetResourcemodel, the key masked again, or a partial import omitting it), the mask is kept rather than cleared, because an attribute missing from state resolves to the physical id instead of failing. That used to happen silently, so the next deploy repeated the refusal whose remedy the user had just followed; the import now warns, naming the logical id and each key that stays masked, and the Cloud Control unreadable-model warnings no longer claim anFn::GetAttfalls back to the physical id when a same-physical-id record's attributes are kept. - An
Export.Nameholding a secret split by a nonspacing mark, or spelled in the other Unicode normalization form, is refused instead of published (issue #2889) --src/deployment/outputs-export-alias.ts,src/cli/commands/scrub.tsandsrc/state/export-index-store.ts, with unit tests intests/unit/deployment/secret-detection-space-2889.test.ts. A combining mark (\p{Mn}, e.g.U+09BC) renders at zero width, so a recorded secret with one inserted still reads as the secret, and a precomposed letter renders the same as its decomposed spelling. The containment test missed both, and the name was written intostate.jsonand the exports index. The test now also runs with the text decomposed, nonspacing marks removed and recomposed, on top of the existing checks, so it catches strictly more than before. The printed text still keeps its marks, so non-Latin names print unchanged. When the only match is in that wider space, the name is withheld from warnings, in the deploy and incdkd scrubalike.cdkd scrub's exports-index lines and the deploy's "published by both" exports-index warning now withhold any non-ASCII export name, since the terminal sanitiser blanked such characters after the check, or with no check at all in the exports-index warning. Cost: a name that differs from a recorded secret only by marks is refused, in either direction (cafewith or without an accent), when the unmarked form is at least four characters or the whole name. - A failed intrinsic resolution reports its EARLIEST failure again when the failures sit at different nesting depths (issue #2805) --
src/deployment/intrinsic-function-resolver.ts(allSettledKeepingFirstRejectionand its two call sites),.claude/rules/layout-deployment-secrets.mdand unit cases. Since the drain of #2563, a list nested inside anFn::Join(or another list) held its own failure until its slower siblings settled, so the enclosing drain received a LATER, shallower failure first and reported that one. Which failure surfaces is what the retry classifier reads, so the swap could turn a retry into a stop or the reverse. A drain now starts its parts itself, inside a scope holding its own record of capture orders; a nested drain writes the order its pick was captured in into its PARENT's record, keeping the smaller one if a sibling already wrote it, and the parent ranks that error by it instead of by arrival. The resolution fails either way, as before. Bounds: two failures inside one event-loop turn are ordered by when they reach a drain, not by when their lookups failed; a drain the 60-second cap releases reports the earliest failure it has received; an error that is not an object (undefined,null) carries no order and is ranked by arrival, as is an error a layer wraps between two drains; an error object that reaches a drain from another resolution is ranked by arrival there. cdkd destroy --remove-protectionrestores deletion protection on a CloudWatch Logs log group or a Cognito user pool whose delete then failed terminally (issue #2204, partial) --src/provisioning/providers/logs-loggroup-provider.ts,cognito-provider.ts,deletion-protection-compensation.ts,docs/cli-destroy.md, with unit tests intests/unit/provisioning/providers/logs-cognito-remove-protection-compensate.test.ts. Before, the flip-off (PutLogGroupDeletionProtection/UpdateUserPool) was never undone, so a failed destroy left the resource live with its guard silently stripped. Now the guard is turned back on before the failure is reported, under the rules the DynamoDB and RDS-family sites follow: only a guard a pre-flipDescribeLogGroups/DescribeUserPoolsaw ON and this run turned off, only on a terminal failure, never once AWS has accepted the delete, and a failed re-enable is its own log line with a pasteable restore command. The Cognito user pool now reads the liveDeletionProtectioneven when the recorded properties sayACTIVE, and skips theUpdateUserPoolflip when the pool is alreadyINACTIVE. The other--remove-protectiontypes stay open under #2204.- The Glue providers mask secret-derived names in their own log lines and refusals (issue #2177, Glue family slice) --
src/provisioning/providers/glue-provider.ts; unit tests intests/unit/provisioning/glue-providers-masked-warn.test.ts. A Glue database, table, workflow, security configuration, job, crawler, connection or trigger name resolved from a{{resolve:secretsmanager:...}}reference used to reach the provider's own debug and warning lines in plaintext, since those lines pass through no deploy-engine mask.create()andupdate()of every Glue type now route each line through the deploy's masker, and each name and catalog id is masked before it joins the sentence, so a secret shorter than four characters is caught too. A name of three or more characters that is itself a secret is also masked where an AWS error quotes it back in the spelling it was given; a short secret merely embedded in AWS error text, or a database or table name AWS echoes lowercased, is not. The same holds for the update refusals (a nested rename, aCatalogIdmove, an undecodable table id) and the pre-update read failure. Onupdate()the recorded name is masked as well when the previous value in state is a secret reference or the redaction mask***, which covers a rotated secret whose old plaintext is in no bag of this deploy; the***case is in the sharedsrc/provisioning/masked-retry-logger.tshelper, so the IAM and S3 bucket providers'update()lines gain it too. Thedelete()path still has no masker (issue #2007). - The skip messages of
cdkd destroy,cdkd state destroyand a deploy-side skipped delete no longer say cdkd could not address the resource when a custom-resource Delete handler ran and reported FAILED (issue #2122) --src/deployment/delete-outcome.ts,src/cli/commands/destroy-runner.ts,src/cli/commands/destroy.ts,src/cli/commands/state.ts,docs/cli-reference.md,docs/troubleshooting.md,docs/cli-destroy.md(which now also lists the handler causes). Since issue #2054 a handler that answers FAILED is reported as skipped, but every skip message was written for a record cdkd could not address: it said cdkdcould not addressthe resource, thatno delete was issued, and to fix the physicalId in state.json and re-run -- all false for a handler that received the delete and refused it, whose record is correct. The per-resource skip sentence now readscdkd did not confirm <id> (<physicalId>) was deleted ..., so it may still exist: <reason>; the per-stack summaries and the exit-2 error say the delete was not confirmed, point at theskipped (...)lines and their warnings for each cause and for whether repairing the record in state.json helps, and otherwise, such as for a handler that reported FAILED, say to delete the resources by hand and drop the records withcdkd state orphan. Tests:tests/unit/deployment/delete-outcome-message.test.ts,tests/unit/cli/destroy-runner-skipped.test.ts,tests/unit/cli/destroy.test.ts,tests/unit/cli/state-destroy.test.ts. - A
NoEchotemplate parameter's value no longer prints in a provider's warn line, in the deploy's error text or in thedeployments/*.jsonlevent (issue #1998) --src/deployment/secret-redaction.ts(recordLogOnlyValue,maskRecordedSecretsInText,hasMaskableValues),src/deployment/intrinsic-function-resolver.ts,src/deployment/deploy-engine.ts,src/deployment/rollback-executor.ts,src/provisioning/providers/nested-stack-provider.ts,src/cli/commands/scrub.ts, and unit cases. Before, only the resolver's own--verboseparameter lines honouredNoEcho. AReforFn::Subvariable serving aNoEcho: trueparameter now records the value as a LOG-ONLY needle of that resource's secret bag, and the masker every provider receives masks it, as do the engine's error and event masking and the resolver's debug lines. So does a nested child consuming the value through its own parameter, and the in-process rollback of the resource. TheFn::Base64encoding of such a value is masked in the log too. Nothing cdkd PERSISTS changes: the value is not a recorded secret,state.jsonand every redaction decision are byte-identical with or withoutNoEcho, and anExport.Nameembedding it is still published. Bounds: anFn::Splitfragment of the value is not recorded. The persistence half (cdkd rollback,cdkd drift, export names) is #4043;cdkd diff, the--verbosereplacement line and the Outputs summary are #4049.
Recently Implemented (2026-09-28):
- A create retried after an ambiguous failure no longer reads its replay's "already exists" as the resource's own name collision (issue #3978) --
src/deployment/retry.tsandsrc/deployment/retryable-errors.ts(isAmbiguousOutcomeError,markReplayMayCollide), with unit tests intests/unit/deployment/retry-ambiguous-create-replay-3978.test.tsandretry-ambiguous-cc-create-3978.test.ts. When a main create call (a KinesisCreateStream, or a Cloud ControlCreateResource) succeeded on the AWS side but answered with an HTTP 5xx, the retry wrapper ran the create again, and the replay met the resource the first call had made. That "already exists" was treated as a collision on the resource's own name, so--replacedeleted the live old resource and a replacement rollback deleted the live new one. After an attempt that ends without saying whether AWS acted on it -- a 500, 502, 503 or 504 that is not a throttle, or a Cloud Control handler failing mid-create (including itsThrottlingandGeneralServiceException, which it reports after it may have made the resource) -- every error that retry throws from then on is now marked so the collision is not credited. A connection reset or timeout after the send is recognised too, though no retry path replays one today. An API-level throttle or other 4xx does not arm this, so a collision with a resource that already held the name still classifies. Known costs: an update that first hit such an error can fail on Cloud Control'sUnsupportedActionExceptioninstead of replacing the resource (a re-run replaces it), and the orphaned-name advice is not printed for such a replayed create (#3984). The AWS SDK's own retries inside a single call are not covered by this change. - A retried create whose earlier attempt failed on an auxiliary call no longer reads its replay's "already exists" as the resource's own name collision (issue #3972) --
src/deployment/retry.tsandsrc/provisioning/auxiliary-failure.ts(isAuxiliaryFailure), with unit tests intests/unit/deployment/retry-replayed-create-collision-3972.test.ts. When a call made after the main create failed with a retryable error (a KinesisAddTagsToStreamthrottled withRate exceeded, say), the retry wrapper ran the wholecreate()again, and the replayed main create met the resource the first attempt had just made. That "already exists" was treated as a collision on the resource's own name, so--replacedeleted the live old resource and a replacement rollback deleted the live new one. Once an attempt has failed on an auxiliary call, every error the retry wrapper throws for that operation now carries the same auxiliary mark, including through the nested retry loops of the delete-then-re-create sites. The error text and the retry schedule are unchanged, and a collision on the first attempt's main create still classifies. - A nested
cdkd exportthat fails adopting its nested children (Phase 1B) prints region-bearing, per-stackcdkd state orphancommands (issue #3967) --src/cli/commands/export.ts, with unit tests intests/unit/cli/export-nested-failure-render.test.ts. The Phase 1B refusal told the operator to clear cdkd state "for the migrated stacks via 'cdkd state orphan'": a command with no --stack-region, which drops that stack name's record in every region, built outside the shared pasteable-command gate, and with no word on which records to clear or when -- clearing the failed parent's record before its phase 2 ran drops cdkd's only record of what phase 2 would have created. It now ends with the same recovery as the other nested failures: a whole-tree re-run refusal, a gatedcdkd state orphan <stack> --stack-region <region>on its own line for each stack that finished phase 2, the failed parent's own line to run once its adoption and phase 2 succeed by hand, and the stacks not yet imported. The by-hand adoption guidance and the "do NOT run cdkd destroy" warning stay. cdkd scrubrepairs a nested-stack parent row'sOutputs.<Name>attribute holding a child-sourced plaintext (issue #3961) --src/cli/commands/scrub.ts,docs/cli-scrub.md,tests/unit/cli/commands/scrub-nested-child.test.ts. Before: on a parent record older than the redaction of nested-stack outputs (#1899), the parent row'sattributes['Outputs.<Name>']kept the plaintext of a child output sourced from the child's own{{resolve:...}}: the parent is scrubbed before its children and had no needle for it, and--dry-run --failreported the parent clean. Now: after each child's scrub, scrub re-reads the parent's record and rewrites such an attribute under the parent's lock (taken only when something is to be rewritten), printingScrubbed N nested-stack output attribute(s) in <Parent>. It rewrites an attribute to the child's scrubbed output only when the attribute is exactly what that output resolves to in this run -- the value the child's next deploy would copy there; an attribute that still holds a recorded plaintext but matches no output exactly is reported, not rewritten. Both count toward--dry-run --fail.- A custom resource whose recorded
ServiceTokenholds a{{resolve:...}}reference is now skipped on delete with a named warning instead of being sent to Lambda as a function name (issue #3960) --src/provisioning/providers/custom-resource-provider.ts(CR_REFERENCE_SERVICE_TOKEN_SKIP_REASON),tests/unit/provisioning/custom-resource-masked-service-token.test.tsand a new arm of thecustom-resource-masked-service-tokeninteg fixture. cdkd persists a{{resolve:secretsmanager:...}}/{{resolve:ssm-secure:...}}reference as its expression rather than its value, so a ServiceToken built from one reached the delete path as that expression: it failsGetFunction's name validation as the #3938 mask does (whose pre-fix run left the readiness waiter polling for its full 10 minutes), and the invoke then failed with a message that named neither cause. It now reportsoutcome: 'skipped'before any AWS call, like the #3938 masked-token arm: the record is kept,cdkd destroyexits 2, and the warning says CloudFormation does not support secure (secretsmanager / ssm-secure) dynamic references in custom resources and names the remedies (tear the resource down by hand andcdkd state orphan <stack>, or restore the ARN while the handler still exists). The reference is not resolved on delete: CloudFormation rejects the shape, so a record like this exists only because cdkd deployed it. cdkd local invoke,local run-taskandlocal start-apiresolve a construct-path target in a stack under a CDK Stage (issue #3953) --src/local/lambda-resolver.ts,src/local/ecs-task-resolver.ts,src/cli/commands/local-start-api.ts, with the stack lookup shared fromsrc/cli/cdk-path.ts; unit tests intests/unit/local/{lambda,ecs-task}-resolver.test.tsandtests/unit/cli/local-start-api-pick-target-stacks.test.ts, and a new local-only fixturetests/integration/local-invoke-stage(a nested Stage beside a top-level stack). A path-form target took its FIRST/-segment as the stack pattern, so a stack inside aStage-- whose display path isMyStage/Api, orOuter/Inner/Apinested -- was unreachable:cdkd local invoke MyStage/Api/FnansweredStack 'MyStage' not found, andlocal start-api MyStage/Api/HttpApiselected no stack. The stack is now the one whose display path (or physical name) followed by/is the longest prefix of the target -- the rulecdkd orphanadopted in #3943 -- so a nested Stage resolves andMyStage/Apinever claimsMyStage/ApiV2/....local run-taskpicks the stack whose state feeds--from-state/--from-cfn-stackby the same rule, andlocal start-apiselects the matched stack itself rather than re-matching its physical name. A head no stack path prefixes, such as a wildcard, still goes through the stack matcher, and theStack:LogicalIdform keeps the stack it names.local start-service,start-alb,start-cloudfrontand the agentcore commands resolve targets in cdk-local, tracked by go-to-k/cdk-local#746.- A delete whose recorded address property cdkd redacted is now a named skip, or uses an unredacted source, instead of sending the redaction to AWS (issue #3952) -- new
src/provisioning/redacted-delete-address.ts(redactedDeleteAddressSkip,REDACTED_DELETE_ADDRESS_SKIP_REASON), the ApiGateway, ApiGatewayV2, ECS, Glue, Scheduler, Route 53, EC2 (security-group ingress), CloudWatch anomaly-detector, RDS DB proxy target-group, IAM policy / access key / UserToGroupAddition, Lambda permission and EMR instance fleet / group providers, andtests/unit/provisioning/redacted-delete-address.test.ts. State stores the***mask of aNoEchovalue a resource read, and a secret{{resolve:...}}reference as its expression; a delete that named the resource by such a property sent it to AWS, where several APIs answer an unknown name with "not found" -- which the provider read as already deleted, dropping the record over a live resource (a Route 53 TXT record whose value holds a masked token is the likeliest case). Now a second source is used where one exists (a Lambda permission's function or an ECS service's cluster in its physicalId, an IAM policy's name, an access key's owner looked up from IAM, a Route 53 record's zone in its physicalId), and otherwise the delete reportsoutcome: 'skipped'before any AWS call: the record is kept andcdkd destroyexits 2. A security-group ingress UPDATE whose old rule cannot be revoked now fails before authorizing the new one, and an ApiGateway Resource replacement reports the old resource as a survivor. EMR TASK configs skip only their best-effort scale-to-0. cdkd drift --accept/--revertno longer print a state- or readback-derived value raw in their plan (issue #3949) --src/cli/commands/drift.ts(printAcceptPlan,printRevertPlan, newreportPath/reportPlanChangeLine), newtests/unit/cli/drift-plan-sanitize.test.ts. The plan printed before the confirmation prompt (and under--dry-run) interpolated each heading's stack name and region, each resource's logical id and type, each change's path and both values, and the readback tag keys and paths the revert plan lists, straight into its output, so a newline, an escape sequence or a bidi override in a hand-edited state record or an AWS value set outside cdkd could invent or erase a plan row right above the prompt that asks the operator to confirm AWS writes. They now go through the same rules as the human report (What the human report does to a malformed value, #3232): identifiers sanitized and capped, values JSON-quoted when a control character or edge whitespace would otherwise hide a difference. A plan value containing the plan separator's->is quoted too. A readback key is masked for secrets before it is capped, so the cut cannot leave part of a secret unmasked. The report and the plans now also quote a value carrying an unpaired surrogate, which a terminal otherwise shows exactly like the replacement character;src/utils/display-safe.tsexports the character setsafeMsgalters (SAFE_MSG_ALTERED_CHAR) so the quoting rule reads it rather than a copy. An ordinary plan renders byte-for-byte as before.- An Auto Scaling group update no longer reads a malformed list as empty and detaches every load balancer or target group (issue #3948) --
src/provisioning/providers/asg-provider.tsandsrc/provisioning/providers/firehose-provider.ts, with unit tests intests/unit/provisioning/asg-provider-malformed-lists-3948.test.tsandtests/unit/provisioning/firehose-provider-malformed-tags-3948.test.ts. Each list diff read a present-but-malformed value as an empty list. On a rollback orcdkd drift --revert, where the desired side is a recorded bag, a hand-editedTargetGroupARNs: {}orLoadBalancerNames: "my-elb"detached every attachment and took the group out of service. The same held forTags,MetricsCollection,LifecycleHookSpecificationList,TrafficSourcesandNotificationConfigurations, and for a Firehose delivery stream'sTags. A malformed desired list is now refused before any AWS call, as is one increate(). A malformed recorded entry list is refused too, with a state.json repair (orset it to []for a secret-derived value); a recorded entry whose key came from a secret is read, and never deleted by that key. An absent (undefined/null) list still reads as empty. A recordedLoadBalancerNames/TargetGroupARNsholding any malformed value (for example a dynamic reference, or an unresolved intrinsiccdkd importrecorded) is read from the live group instead, but only when every desired list and every other recorded list is well-formed. That read is ADD-only: a target group or load balancer the template no longer names stays attached, with a warning. cdkd orphanresolves a construct path in a stack under a CDK Stage (issue #3943) --src/cli/commands/orphan.ts, with unit tests intests/unit/cli/orphan.test.ts. A construct path was split at its FIRST/and the head looked up as a stack, so every stack inside aStage-- whose display path isMyStage/Api, orOuter/Inner/Apifor a nested Stage -- was unaddressable:cdkd orphan MyStage/Api/Bucketansweredstack 'MyStage' not found in synthesized app. Available: MyStage/Api, naming the very stack the path started with. The stack is now the one whose display path (or physical name) followed by/is the longest prefix of the path, so a nested Stage resolves andMyStage/Apinever claimsMyStage/ApiV2/.... A path that is exactly a stack's display path is refused as naming a stack rather than a resource (even when a shorter stack path prefixes it), a trailing/is refused at any depth, the malformed-path hint now reads<StackPath>/<Path/To/Resource>, and a path no stack prefixes is refused withdoes not start with the path of any stack in the synthesized app, listing the available stack paths. Top-level stacks resolve as before.cdkd deploy/cdkd diffno longer exit 0 having done nothing when a create-only schema lookup is throttled (issue #3939) --src/provisioning/describe-type.ts,src/utils/concurrency-limiter.ts,src/deployment/resource-deadline.ts,src/cli/run-cli.ts(new),src/cli/index.ts, and unit tests. The deploy-start schema prefetch sleeps its throttle backoff on an unref'd timer, so it never holds the process open. When the diff then awaited the same resource type, the call was promoted but kept sleeping on that unref'd timer; with nothing else holding the event loop, Node exited 0 mid-command, leaving the template unapplied and the stack lock held. The backoff is now ref'd whenever a command waits on it: when the call is promoted (including a sleep already under way), and when an awaited lookup is queued behind background calls that hold every slot. The per-resource--resource-timeouttimer is now ref'd too, so a provider call that stalls times out instead of letting the process drain. Any remaining case of this kind now fails loudly: if the event loop empties while a command is still pending, cdkd prints an error with thecdkd force-unlockhint and exits 70 (EX_SOFTWARE), now listed in the exit-code table ofdocs/cli-reference.md.- A custom resource whose recorded
ServiceTokenis the redaction mask***is now skipped on delete with a named warning instead of being sent to Lambda as a function name (issue #3938) --src/provisioning/providers/custom-resource-provider.ts(CR_MASKED_SERVICE_TOKEN_SKIP_REASON), a newtests/unit/provisioning/custom-resource-masked-service-token.test.tsand a newtests/integration/custom-resource-masked-service-token/fixture. A dependent custom resource that reads aNoEchovalue equal to, or contained in, its ownServiceToken(a handler echoingevent.ServiceTokenintoData, read by a custom resource on the same provider) persistsServiceToken: "***". The delete path guarded only an absent or non-string token, so the mask reached the backing-LambdaGetFunctionpre-check and the Delete invoke, failing with an AWS error that said nothing about the mask. It now reportsoutcome: 'skipped'like the no-ServiceToken arm: no AWS call, the state record is kept,cdkd destroyexits 2, and the warning names the mask and the remedies (tear the resource down by hand andcdkd state orphan <stack>, or restoreServiceTokeninstate.jsonwhile the handler still exists). - A
Refto a parameter resolves to the parameter, never to a state record of the same name (issue #3916) --src/deployment/intrinsic-function-resolver.ts, with unit tests intests/unit/deployment/resolver-ref-parameter-precedence.test.tsandtests/unit/cli/export-nested-failure-render.test.ts. The resolver answered aReffrom cdkd state BEFORE the template's Parameters, so a state record keyed by a parameter's name (planted or stale, since CloudFormation cannot declare one logical id as both) replaced that parameter's value: oncdkd deployfor every property the parameter fed, and on nestedcdkd exportfor the child's IMPORT parameters, where a record naming an attacker's role could pass the IAM::Policy check and have phase 2 grant the policy to that role. A name the template declares as a Parameter, or the caller binds as one, and a pseudo-parameter name (AWS::...) are now never read from state:Refreturns the parameter (or pseudo-parameter) value, andFn::GetAtton such a name fails asResource <name> not found for Fn::GetAtt, while a${Name.Attr}placeholder inFn::Subon such a name warns and keeps the placeholder, as it does when no record exists. ARef/Fn::GetAttto a resource still reads its record, and a record whose name no parameter uses resolves as before. cdkd exportchecks eachAWS::IAM::Policypre-delete against the template, and renders nested-tree failures like single-stack ones (issue #3910) --src/cli/commands/export.ts,docs/cli-export.md, with unit tests intests/unit/cli/export*.test.ts. The IAM::Policy pre-delete removes the policy from the principals cdkd state records, while phase 2 re-attaches it to the ones the template names. A recorded principal or policy name the template does not carry now blocks the export before phase 1, instead of being removed for good under a note calling the gap brief. The block printscdkd diffthencdkd deployon the app stack. A principal passed as a Parameter is checked against the value the changeset submits, including one a parent passes a nested child; an SSM-typed parameter is not. A principal orPolicyNamecdkd cannot resolve, or a missingPolicyName, is marked in the plan. Every recorded principal is listed, not 20 per kind. A legacypolicyName:roleNamerecord with no lists detaches from that role, as the provider does. A record with no principals now points at a re-import, not a hand edit of the state record. Nested-tree failures (pre-delete, phase 2, the Phase 1A / 1BCause:lines) and the single-stack phase-2 failure now fold and cap AWS's text. The nested recovery, Phase 1A failures included, says a re-run is refused for the whole tree and gives acdkd state orphanfor every stack that finished; when nothing was imported, it says to delete the leftover stack and re-run. The by-hand recovery prints one pasteable delete per pre-delete type and IAM principal kind. The nested confirmation no longer calls the pre-deleted resources unchanged. A blocked-resource refusal says to destroy or remove only the rows without their own remedy.- IAM ManagedPolicy, InstanceProfile, User and UserToGroupAddition refuse a principal list that is not a list of IAM names before any call (issues #3906, #3888) --
src/provisioning/iam-policy-targets.ts,src/provisioning/providers/iam-managed-policy-provider.ts,iam-instance-profile-provider.ts,iam-user-group-provider.ts, with unit tests intests/unit/provisioning/iam-principal-list-shape.test.ts.AWS::IAM::ManagedPolicyGroups/Roles/Users,AWS::IAM::InstanceProfileRoles,AWS::IAM::UserGroupsandAWS::IAM::UserToGroupAdditionUserswere cast and iterated, so a string was walked by character. A rollback revert orcdkd drift --revert, whose desired side is a recorded bag, attached a managed policy to one-letter principals or swapped an instance profile's role for a one-letter one. A delete removed one-letter users from a group and never the recorded one. Each list is now read through the shared readerAWS::IAM::Policyuses. On create and update, a malformed desired or recorded list throws with no AWS call, bar the secret-derived recorded list below. On a UserToGroupAddition delete it is skipped, keeping the record. A recorded list holding a dynamic reference or its mask cannot be repaired in state, so with a well-formed desired side that kind is read from IAM instead: ADD-only for ManagedPolicy and User (IAM's list also holds attachments made elsewhere, so nothing is removed on its evidence; a warning names the kind when IAM lists a principal the template does not), a full diff for InstanceProfile, whose roles only the profile sets. UserToGroupAddition has no live source and names its way out, never astate.jsonedit. - EFS access-point adoption also compares
PosixUserandRootDirectorybefore adopting a conflicting access point (issue #3894) --src/provisioning/providers/efs-provider.ts(createOrAdoptAccessPoint,samePosixUser,sameRootDirectory),docs/provider-rules.md, with unit tests intests/unit/provisioning/efs-access-point-idempotency-token.test.ts. When a retriedCreateAccessPointis refused withAccessPointAlreadyExists, cdkd reads the named access point back and adopts it. The adoption used to be confirmed only on theClientTokenand theFileSystemId, but EFS refuses a repeated token whether or not the repeat's parameters match (measured in #2442), so a token re-sent with a different POSIX user or root directory would have been adopted. The adoption now declines on either mismatch, naming the field but not its value. EFS's defaults are applied first -- an unset POSIX user reads back as absent ornull, an omitted root as/-- so a default-root access point is still adopted.SecondaryGidsorder and a755versus0755mode spelling do not count as mismatches. No path sends a repeated token with different parameters today, so this is defense in depth. - A Glue table in a database whose name contains
|now deploys, updates, destroys and resolvesRefas in CloudFormation (issue #3892) --src/provisioning/providers/glue-provider.ts,src/provisioning/composite-id.ts,src/deployment/intrinsic-function-resolver.ts,src/deployment/deploy-engine.ts; unit tests intests/unit/provisioning/,tests/unit/deployment/intrinsic-{functions,ref-state-mask}.test.tsandtests/unit/analyzer/orphan-rewriter.test.ts; assertion 12 of theglue-update-hardeningfixture. Glue accepts|in a database name (probed through that fixture), butcdkd deployandcdkd importrefused a table in such a database. The table's id stays<databaseName>|<tableName>, and every reader places the table name by the recordedDatabaseName, which a create always records;cdkd importalso records it as an attribute, soRefcan anchor when the imported property stays unresolved. Without a usable anchor, update and destroy still refuse or skip an id with more than one|rather than guess, and their message now says to set the record'sDatabaseNamerather than edit a correct id. A maskedDatabaseNamerefuses aRefthat needs it on deploy, and passes the mask through elsewhere, since the first-|fallback is no longer exact. The refusal for an output with such a masked value no longer claims it would publish the raw physical id. Because two different tables can now share an id (tabledb|ordersin databasemy, tableordersin databasemy|db), a replacement between them keeps the id: the deploy engine's name-idempotent-create guards and the rollback's replacement, already-reverted and adopted-live readings now treat two records whoseDatabaseNameboth prefix the id and differ as two tables (equalIdNamesDifferentResourcesinsrc/deployment/type-change-guard.ts), so such a replacement completes and rolls back instead of stranding the new table. --recreate-via-cc-apirefuses a resource whose type Cloud Control cannot create, instead of deleting it and failing to recreate it (issue #3887) --src/deployment/recreate-targets.ts(blockedNoCcRoute),src/cli/commands/deploy.ts,src/provisioning/provider-registry.ts, with unit tests intests/unit/deployment/recreate-targets.test.ts. The recreate deletes the existing resource through its SDK provider and then creates the replacement through Cloud Control. For a type AWS reports asNON_PROVISIONABLE, such asAWS::CodeBuild::Project,AWS::IAM::PolicyorAWS::DocDB::DBCluster, and for a type whose SDK provider opts out of the Cloud Control fallback, that create fails. The resource was already deleted, the error blamed a missing SDK provider, and a rollback could not bring it back. The deploy now fails before anything is touched (RECREATE_TARGETS_INVALID, exit 1) and names each such resource with the reason. There is no bypass flag.- Re-running a destroy after fixing a Firehose delivery stream stuck in
DELETING_FAILEDno longer fails on the stale status --src/provisioning/delete-gone-wait.ts,src/provisioning/providers/firehose-provider.ts, and unit tests. The post-delete wait treatedDELETING_FAILEDas terminal on its very first status read, and a stream still in that status when the re-run'sDeleteDeliveryStreamis accepted can report it on the read straight after. The status is now terminal only once the wait has seen another status or one poll interval has passed. The failure message now also says that cdkd never sendsAllowForceDelete, so a forced delete has to be done outside cdkd. cdkd destroyno longer removes an inline policy from principals a malformedAWS::IAM::Policyrecord never named (issue #3878) --src/provisioning/iam-policy-targets.ts(new,readRecordedPrincipals),src/provisioning/providers/iam-policy-provider.ts(create,update,delete,readCurrentState;POLICY_MALFORMED_TARGET_SKIP_REASON),src/cli/commands/export.ts,docs/provider-rules.md, with unit tests intests/unit/provisioning/iam-policy-targets.test.ts,tests/unit/provisioning/iam-policy-provider-target-shape.test.ts,tests/unit/provisioning/provider-delete-skip-outcome.test.tsandtests/unit/cli/export-predelete-listing.test.ts. The delete cast the record'sRoles/Groups/Usersto a list and iterated it. A string was walked character by character, soRoles: "AdminRole"removed a same-named inline policy from any one-letter role and never fromAdminRole. An object threw after earlier principals were already detached. A present value that is not a list of IAM names ([\w+=,.@-], at most 128 characters) is now refused before any AWS call. On delete it is reported as a skipped delete that keeps the record. On create and update it is an error, checked on both the desired and the recorded side, since a rollback replays an update with a recorded bag as the desired side and a string used to attach the policy to one-letter roles. On drift it reads as unknown.nullis still read as absent.cdkd export's IAM::Policy pre-delete reads the lists through the same module, so it now also reads anulllist as absent rather than refusing it.- An "already exists" from an auxiliary create call of a Route 53 hosted zone, Kinesis stream or DynamoDB table no longer reads as the resource's own name collision (issue #3877, the residual of #3826) --
src/provisioning/providers/route53-provider.ts,kinesis-provider.tsanddynamodb-table-provider.ts, with rows intests/unit/provisioning/auxiliary-failure-3826.test.ts. Each create now sets a flag once its main create call returns (CreateHostedZone,CreateStream,CreateTable) and passes any later failure throughmarkAuxiliaryFailure: the hosted zone'sAssociateVPCWithHostedZoneandUpdateHostedZoneFeatures; the stream'sAddTagsToStream, retention change,StartStreamEncryptionandEnableEnhancedMonitoring; and the table'sUpdateContinuousBackups,UpdateTimeToLive,EnableKinesisStreamingDestination,UpdateContributorInsightsand the stream-arnTagResource/PutResourcePolicy. Before this change, such a failure relaying AWS's "already exists" was treated as a collision on the resource's own name, so--replacedeleted the live old resource and a replacement rollback deleted the live new one. The error text is unchanged, and a collision on the main create still classifies.AWS::DynamoDB::GlobalTableremains open under #3877. - A Ctrl-C during an engine retry backoff or inside a nested stack's deploy no longer rolls the stack back automatically (issue #3875) --
src/deployment/deploy-engine.ts, with unit tests intests/unit/deployment/deploy-engine-type-change-routing.test.ts. The engine's interrupt error reached its outer catch wrapped in aProvisioningErrorwhen it was raised by the engine's own retry backoff (a top-level stack too) or by a nested child engine's between-ops poll, and the check matched neither the wrapper nor its cause. The deploy then took the failure path, and without--no-rollbackthat is the automatic rollback, deleting what the run had created. The check now also takes the interrupted branch when the engine's own SIGINT handler has recorded a user interrupt — partial state saved, aninterruptedjournal segment written, no rollback — while an abort caused by another resource's failure still rolls back. Once Ctrl-C has been pressed, a genuine failure among the operations still draining no longer triggers the automatic rollback either; the run can be reverted withcdkd rollback. - Eleven more SDK-provider types with no Cloud Control handlers keep an unrecognized property on the SDK route instead of failing mid-deploy on Cloud Control (issue #3871) --
src/provisioning/unsupported-types.ts(SDK_PROVIDER_NON_PROVISIONABLE_TYPES,hasNoCloudControlHandlers),src/provisioning/provider-registry.ts,src/provisioning/property-coverage.ts,src/provisioning/property-coverage.generated.ts,scripts/gen-property-coverage.ts,scripts/diagnose-schema-refresh.mjs, with unit tests intests/unit/provisioning/non-provisionable-unrecognized-route-3871.test.tsandtests/unit/provisioning/property-coverage-cc-fallback-binding.test.ts. AWS reportsAWS::AppSync::GraphQLSchema,AWS::BedrockAgentCore::Browser,AWS::BedrockAgentCore::CodeInterpreter,AWS::Budgets::Budget,AWS::CloudFormation::WaitConditionHandle,AWS::EC2::NetworkAclEntry,AWS::Glue::Table,AWS::IAM::Policy,AWS::IAM::UserToGroupAddition,AWS::SNS::TopicPolicyandAWS::SQS::QueuePolicyasNON_PROVISIONABLE. Even so, a template key cdkd's schema snapshot does not know routed the resource to Cloud Control, which failed with an opaqueUnsupportedActionException. The resource now stays on its SDK provider and the deploy warns that the key has no Cloud Control route. The decision is per resource type, so types sharing a provider with these, such asAWS::EC2::NetworkAclorAWS::Glue::Database, keep their Cloud Control route. For the eight such types that already refused, a refusal now namesProvisioningType: NON_PROVISIONABLEas the reason. - BREAKING:
cdkd state destroy --allis removed (issue #3865) --src/cli/commands/state.ts,src/utils/interrupt-signals.ts, with unit tests intests/unit/cli/state-destroy.test.tsandtests/unit/cli/state-destroy-command-sigint.test.ts.--alldestroyed every stack in the state bucket, and that bucket is shared by every CDK app deployed to the account, so one command deleted stacks across apps. Destroy is now scoped to what you name or to one app, matching the #3839 decision forcdkd destroy. Passing--allexits 1 withSTATE_DESTROY_ALL_REMOVEDbefore the bucket is read or anything is prompted, locked or deleted, including when stack names are also given, and the message points at the replacements: name each stack (cdkd state destroy '<stacks...>';cdkd state listshows them), or runcdkd destroy --allfrom the CDK app, which destroys only that app's stacks. The option is kept hidden so this message replaces commander's generic unknown-option error. Its batch confirmation prompt went with it, along with the prompt-abort signal andisPromptAbortErrorthat only that prompt used. The force-quit notice printed when Ctrl-C lands before a stack's teardown is armed now says "on a multi-stack run" instead of "on an --all run". Docs:docs/cli-state.md,docs/cli-destroy.md(the batch prompt row and its exit-130 row; exit 130 now documents the Ctrl-C force-quit),docs/state-store.md. cdkd destroy --alland wildcard patterns are refused when the app cannot be synthesized (issue #3839) --src/cli/commands/destroy.ts, with unit tests intests/unit/cli/destroy.test.ts. When synthesis failed (a mistyped--app, a broken app, a missing dependency) or no app was configured,cdkd destroy --all,cdkd destroy '*'and any other wildcard fell back to every stack record in the state bucket, other apps' stacks included, with the synth failure logged only at debug level. They now exit 1 before reading any stack record or prompting, name the selector, and print the synthesis error asCaused by:. The hint points at fixing the app or naming each stack exactly (cdkd destroy '<stack>',cdkd state destroy '<stack>'). An exact physical stack name still resolves from state without a working app. TheCould not determine which stacks belong to this apprefusal no longer advises--allafter a failed synth, and now also prints the synthesis error.- A replacement whose new physical name is held by ANOTHER resource no longer points at
--replace, and--replaceno longer deletes the managed resource there (issue #3808) -- newsrc/deployment/replacement-name-holder.ts,src/deployment/deploy-engine.ts,docs/cli-deploy-safety.md. When a property-driven replacement's create-first attempt collided on a name, cdkd assumed the old resource held it and advisedcdkd deploy --replace, which deletes the old resource first. When the replacement also CHANGES the name (the template's explicit name property differs from the name state records for the old resource, and the old physical id does not name it), the holder is some other resource:--replacedeleted the managed resource and then hit the same collision. The deploy now fails withNAMED_REPLACEMENT_COLLISIONsaying the name is held by another resource, under every flag andUpdateReplacePolicy, and deletes nothing. The UPDATE-not-supported fallback'sRetainrefusal, which advised removingRetainso cdkd deletes the old resource first, takes the same verdict. The--recreate-via-cc-apireplacement also now refuses a type Cloud Control cannot create before deleting anything, inside the engine as well as at pre-flight (issue #3899). cdkd driftno longer compares anAWS::DynamoDB::TableWarmThroughputmember cdkd never sent (issue #3777) --src/provisioning/providers/dynamodb-table-provider.ts(readCurrentState,reverseMapSecondaryIndex, newcanonicalizeDriftPair), with unit tests intests/unit/provisioning/dynamodb-table-provider-warm-throughput-per-member.test.tsand adynamodb-gsi-updateinteg phase. A declaredWarmThroughputwith one usable member ({ReadUnitsPerSecond: 6000}, or a second member that does not resolve to a number) sends only that member, but the readback emitted AWS's value for both, so the unsent member was reported as drift against the template, or against a captured value once AWS raised it. The readback now emits only the members cdkd sends, table-level and per GSI; a member recorded as a{{resolve:...}}reference counts as sent. A drift baseline that still carries the unsent member, such as one captured by an earlier cdkd, is trimmed to the members the template sends before comparing, so an untouched table compares clean after upgrading. A change to a member cdkd sends is still reported, and a block that declares both members is compared in full as before.AWS::DynamoDB::GlobalTableno longer treats a key-order-only difference as a change (issue #3775) --src/provisioning/providers/dynamodb-globaltable-provider.ts.update()compared values withJSON.stringify, so key order mattered.aws-cdk-libwrites a GSIProjectionas{NonKeyAttributes, ProjectionType}, but a readback has the reverse order. Oncdkd drift --revert, an unchangedINCLUDEindex was therefore classified as modified. cdkd then warned thatKeySchema/Projectionchanged and advised recreating the index, or re-sent the index's on-demand ceilings for no reason.- The provider's comparisons now ignore object key order. This covers the GSI and replica diffs, the change gate behind the template-path refusals, the
KeySchema/LocalSecondaryIndexesimmutability refusals (a key-order-only difference no longer triggers the replacement refusal), and the stream, SSE, TTL, warm-throughput, auto-scaling and tag checks. Array order still counts.
cdkd local invoke/start-api/invoke-agentcorenow deliver an env var named__proto__, and thelocal start-*engine commands refuse a secret named after a container-client variable, via cdk-local 0.149.7 (issue #3515) --package.json(cdk-local^0.149.6->^0.149.7),tests/integration/local-invoke/,tests/unit/cli/local-invoke-env-proto-key.test.ts.- These commands resolve env vars through the bundled engine, which assigned each one onto a plain object, so a variable literally named
__proto__(from--env-vars, either block) ranObject.prototype's setter and never reached the container. It is delivered now (go-to-k/cdk-local#769); the same fix covers the engine's own env writes instart-service/start-alb/start-agentcore/start-cloudfront. start-service/start-alb/start-agentcore/start-cloudfrontnow drop a sensitive env var or secret whose name is a variable the container client, the dynamic loader or a credential helper reads (DOCKER_HOST,PATH,LD_PRELOAD, ...) or is malformed (empty, or containing=/ NUL), with a warning naming it, instead of writing it into the client's spawn env (go-to-k/cdk-local#772). The denylist is the onecdkd local run-task/invokealready apply, sodocs/local-run-task.md's "Secret names that are not forwarded" now holds for everycdkd localcommand.
- These commands resolve env vars through the bundled engine, which assigned each one onto a plain object, so a variable literally named
cdkd diff's repaired-outputswarning now sayscdkd deployrefuses the record, and the destroy refusals that cannot print a record's name point atcdkd state list --json(issues #3513, #3420) --src/state/malformed-resources-bag.ts(malformedOutputsWarning,malformedDestroyResourcesRefusalMessage,malformedDestroyOrphansRefusalMessage,divergentRecordRegionRefusalMessage),docs/cli-diff.md,docs/cli-destroy.md, with unit tests intests/unit/state/malformed-resources-bag.test.tsandtests/unit/state/state-key-region-authority.test.ts. The warningcdkd diffandcdkd scrub --dry-runprint over an unreadableoutputsmap described only the repair, althoughcdkd deployrefuses that record when it loads it. On a nested child, wherecdkd diffraises no exit3, nothing said so. The warning now does, in its own sentence about theoutputsmap rather than thepropertieswarning's "Do NOT run 'cdkd deploy'". Separately, the withhold arms of thecdkd destroyrefusals for an unreadableresourcesmap, an unreadableorphanslist and a record whose region disagrees with its key sent the reader tocdkd state list --longfor the exact name. That view trims, so a record keyed'prod-api 'listed as the healthyprod-api. They now namecdkd state list --json, which prints the raw name, and say to replace each quoted hole, quotes included, with the shell-quoted value. The same pointer incdkd rollback,cdkd state, the destroy runner and the shared pasteable-command clause is unchanged.cdkd importnames a CDK Stage that failed to load when stack selection comes back empty (issue #3507) --src/cli/commands/import.ts, with unit tests intests/unit/cli/import.test.ts. A stack under a Stage whose manifest could not be read is dropped from the synthesized app, socdkd import MyStage/MyStackansweredStack 'MyStage/MyStack' not found in synthesized app, and an app whose only stacks sit under such a Stage answeredMultiple stacks found: .with no argument. import now raises the messagedeploy,diff,list,publish-assets,scrubandsynthprint:No stacks matching <arg> found in assembly. Available: <stacks>(orThe assembly has no stacks, orNo stacks found in assemblywith no argument), plus the failed-Stage note naming the targeted Stage or listing every failed Stage behindPossibly unrelated:. TheMultiple stacks foundlist now shows each stack's display path beside its physical name, rendered through the same sanitizer. Exit codes are unchanged. The argument is still matched exactly, and an argument containing*is told so, since the shared wording reads as a pattern match.cdkd scrubnames a CDK Stage that failed to load when stack selection comes back empty (issue #3507) --src/cli/commands/scrub.ts, with unit tests intests/unit/cli/commands/scrub-stack-selection.test.ts. A stack argument matching nothing answered a bareNo stacks matched., naming neither the pattern, the stacks the app does have, nor a Stage whose manifest could not be read -- whose stacks are dropped from the synthesized app, socdkd scrub MyStage/MyStackcould not select them. scrub now raises the same messagedeploy,diff,list,publish-assetsandsynthprint:No stacks matching <patterns> found in assembly. Available: <stacks>, plus the failed-Stage note naming the targeted Stage or listing every failed Stage behindPossibly unrelated:. An app that synthesized no stacks is refused withNo stacks found in assemblyand the same note, where it answeredMultiple stacks found: .with no argument andNo stacks matched.under--all. The exit code is unchanged.--allover an app where some stacks synthesized still scrubs only those; the synthesis warning is what names the Stage.- A malformed-record message whose
cdkd state showline holds a withheld value now says why, before the command, andcdkd export's unreadable-entry warning prints its command on a line of its own (issue #3436) --src/state/malformed-resources-bag.ts,src/cli/commands/export.ts. Since go-to-k/cdkd#3764 the sharedinspectCommandholes an altered, capped, option-shaped or non-plain stack name or region, but the 24 callers that hand it a real identity discarded that verdict, so the hole went unexplained and an operator could fill it with the sanitized spelling the prose shows -- the wrong record the gate withheld it to avoid. Each now puts a sentence rendered from the gate's own reasons BEFORE its label: which value was withheld and why, then where to take it from (cdkd state list --json), except for a stack name beginning with-, which Commander could read incdkd state show's positional as the option, so that sentence says to repair or remove the record by hand instead. A named command leaves the message byte-identical.reportDriftBaselineGaps's unreadable-entry warning ended its prose line onInspect it with: cdkd state show ...; the command is now its own warn line, after the list of unreadable ids, which a sentence before the list points at. cdkd export'scdkd state orphanrecovery commands build through the shared pasteable-command gate, andcdkd state orphan's lock refusal no longer quotes the stack name by hand (issue #3436) --src/cli/commands/export.ts,src/cli/commands/state.ts.orphanCommandForkept its own copy of the gate; it now builds throughpasteableCommandand still names neither value when either is withheld. A non-string record value still fails closed. The note saying why comes from the gate's reason for each value (altered, empty, too long, or beginning with-; a region beginning with-is only refused conservatively, as--stack-regiontakes it as a value), and it sits on the line above its command: each command is on a line of its own, one per line in the nested-export list, where each note names its record by CloudFormation stack name, or describes it when that name is not a plain identifier. A stack name beginning with-is never to be filled back in, since Commander could read it as an option in the positional. On a sentence's line, an apostrophe (the parent's record) flipped the shell quote and a pasted'x; touch OWNED; #'ran. The lock refusal renders the name throughdisplayStackName, not inside'...', which a namex'$(touch OWNED) #closed when the sentence was pasted.- The malformed-record refusals behind
cdkd orphan, and theorphans-list refusalscdkd deploy,import,rollback,scrubanddestroyraise, build theirDrop the record:andInspect the record:commands through the shared pasteable-command gate, so a stack name beginning with-is no longer substituted (issue #3436) --src/state/malformed-resources-bag.ts. The sharedorphanRefusalremedy kept its own copy of the gate (rendersExactly), which admitted--all(it renders exactly, andshellQuoteleaves it bare), so the refusal printedcdkd state orphan --all ..., whose positional Commander reads as the option. That copy also compared a value of 1152 characters followed by...equal to its own truncation and named it in full, and it capped the region at 1152 where the prose cuts it at 128. The commands and the withheld-identity sentence now read one verdict frompasteableCommand, with the region at its own cap; a withheld region still makes the name a hole. The sentence follows the gate's reason: an altered value keeps "did not render exactly"; a value only too long gets its own sentence; a region beginning with-beside an admitted name is refused conservatively; and for a stack name beginning with-the drop is not available for that record, which is to be repaired by hand without filling the hole with the name, and nocdkd state list --jsonline is printed unless the region is withheld too. The legacy arm's object path keepsrendersExactly, since such a name is still its key.plainIdentis not passed, so a label-spelling name still substitutes (go-to-k/cdkd#3523). cdkd local *now substitutes${AWS::AccountId}from the account the stack was read from, so--from-state --role-arnno longer names the caller's account (issue #3230) --src/cli/commands/local-invoke.ts(resolvePseudoParametersForInvoke),local-start-api.ts(resolvePseudoParametersForStartApi),local-run-task.ts(resolveStackAccountId,resolvePlaceholderAccount) andlocal-invoke-agentcore.ts(resolveStackAccountId), plustests/unit/cli/local-stack-account-source.test.ts. Each resolver asked STS as the CALLER. Under--from-statewith--role-arnthe state record is read as the ROLE, andExpectedBucketOwnernormally pins that bucket to the reader's own account, so an emulated function resolved ARNs, ECR image URIs and the bare--assume-task-roleARN for an account its stack does not live in. Under--from-statethe account is now asked as the state reader (the role when one is published); under--from-cfn-stack, or with no state source, it stays the caller's own. A run without--role-arnor--profileis unchanged. The container's own credentials are not affected. Theinvoke/start-apiSTS-failure warnings now name--from-cfn-stackrather than--from-statewhen that is the source in use.- A
NoEchocustom-resource value EMBEDDED in a longer string no longer reachesstate.json(issue #2453) --redactSecretsForState,carriesFreshNoEchoValue,freshNoEchoLeafPositionsandmergeResolvedPairsinsrc/deployment/secret-redaction.ts, theFn::GetAttandFn::Base64registrations insrc/deployment/intrinsic-function-resolver.ts, unit tests intests/unit/deployment/, and thecustom-resource-getatt-datafixture, which gains an embedded dependent, an embedding output and a rotated-token phase. A dependent property or stack output built around the value (postgres://user:${cr.getAttString('Password')}@host) was persisted in the clear, because the mask replaced only a leaf EQUAL to the value; the same held for theFn::Base64encoding of a secret inside a longer leaf. Such a leaf is now persisted as***WHOLE -- not with an inline mask, which nothing downstream could recognise -- socdkd drift --revert/--acceptand the rollback replay refuse it as they refuse any masked value, and the next deploy that re-runs the handler still updates the dependent. An occurrence that lies inside a{{resolve:...}}reference's own text leaves the leaf alone. A value EQUAL to text state already holds in the clear (the region, the stack name, a literal template property of the custom resource, itsServiceTokenor one of that ARN's:-separated parts, such as the account id) is not matched inside a longer leaf, so a handler echoing it underNoEchodoes not mask every ARN of its dependents. The cost: the public text around the value is gone from the record, and any leaf of the same resource that happens to contain the value is masked too. A record an earlier cdkd wrote keeps the plaintext until the handler runs again and the consumer is rewritten. cdkd scrubnow repairs nested-stack child records (issue #2252) --src/cli/commands/scrub.ts,docs/cli-scrub.md,tests/unit/cli/commands/scrub-nested-child.test.ts,tests/integration/scrub-nested-child/. Before: scrub visited only synth stacks, so a{parent}~{Child}record a cdkd older than the nested-parameter redaction (#1903) wrote with the decrypted secret was never examined, andcdkd scrub --dry-run --failexited 0 over it. A redeploy never rewrote it either while the child's resources stayed unchanged. Now: scrubbing a stack scrubs every nested stack under it, at any depth, right after its parent and under the child's own lock. The child's needles come from the parent's resolution of theParametersblock it passes, threaded in the way a deploy threads them to the child engine, so an object-spelled parameter (#3062's frame carry) is repaired too. A child whose record exists but whose parameters scrub cannot derive -- including a<parent>~...record no template or state row reaches, found by listing the parent's region -- is refused withSCRUB_NESTED_CHILD_UNRESOLVABLE(exit 2), never reported clean; a cyclic or escaping nested template tree refuses its stack withSCRUB_NESTED_TEMPLATE_TREE_MALFORMED. A child state name passed as a stack argument is answered with the parent to name instead. As for any stack, the pre-scrub body survives as a noncurrent version on a versioned bucket; scrub does not purge it.cdkd destroy --remove-protectionrestoresDeletionProtectionon an RDS, DocDB or Neptune cluster or instance whose delete then failed terminally (issue #2204, partial) --src/provisioning/providers/deletion-protection-compensation.ts(new),rds-provider.ts,docdb-provider.ts,neptune-provider.ts,dynamodb-delete-budget.ts,docs/cli-destroy.md, with unit tests intests/unit/provisioning/providers/rds-family-remove-protection-compensate.test.ts. Before, the flip-off was never undone, so a failed destroy left the resource live with its guard silently stripped. Now the guard is turned back on before the failure is reported, under the rules the DynamoDB pair already follows (issue #1978): only a guard a pre-flipDescribe*saw ON and this run turned off, only on a terminal failure, never once AWS has accepted theDelete*, and a failed re-enable is its own log line with a pasteablemodify-db-*command. The mechanism moved out of the DynamoDB module into a shared one; DynamoDB's wording is unchanged. The other--remove-protectiontypes stay open under #2204.- The S3 bucket provider masks secret-derived bucket names in its own log lines (issue #2177, S3 bucket slice) --
src/provisioning/providers/s3-bucket-provider.ts; unit tests intests/unit/provisioning/s3-bucket-provider-mask-threading.test.ts. ABucketNameresolved from a{{resolve:secretsmanager:...}}reference used to reach most of the provider's own debug and warning lines in plaintext (everyApplied ... to bucket,Deleted ... on bucketand region-guard line), since those lines pass through no deploy-engine mask.create()andupdate()now run every configuration step on a per-call view whose logger routes each line through the deploy's masker, and each bucket name, configuration Id, lifecycle rule Id and storage class is masked before it joins the sentence, so a value that IS a secret shorter than four characters is caught too. Onupdate()the recorded bucket name is masked as well when the previousBucketNamein state is a secret reference, which covers a rotated secret whose old plaintext is in no bag of this deploy. Thedelete()path still has no masker (issue #2007). - A Glue table whose name contains
|now deploys, updates, destroys and resolvesRefas in CloudFormation (issue #1672) --src/provisioning/composite-id.ts,src/provisioning/providers/glue-provider.ts,src/deployment/intrinsic-function-resolver.ts; unit tests intests/unit/provisioning/composite-id*.test.ts,tests/unit/provisioning/providers/glue-provider.test.tsandtests/unit/deployment/intrinsic-{functions,ref-state-mask}.test.ts; assertion 11 of theglue-update-hardeningfixture. AWS accepts a table nameda|band CloudFormation manages it, butcdkd deployrefused it, because the recorded id<databaseName>|a|bwas read back by its last|:Refwould have returnedb.Refnow returns everything after the recordedDatabaseName, falling back to everything after the first|, and the create no longer refuses the table name.cdkd importadopts such a table when it is paired with the template'sDatabaseName, including CloudFormation's barea|bid; an id equal to the template's table name is read only as that name, and otherwise every reading of an id is probed and the import refuses when more than one exists and the template's table name does not settle it. A database name containing|is still refused. The per-type audit found no other type whose AWS-accepted values can contain|except the Route 53 record name, tracked in #3890; a Glue database name containing|is tracked in #3892. The refusal message now names those issues instead of the closed #1672, and makes no claim about AWS for any other type.
Recently Implemented (2026-09-27):
- Deleting a Kinesis stream or a Firehose delivery stream now waits until the stream is gone, so a destroy followed by a redeploy (or a delete-first replacement) of a named stream no longer fails with
already exists(issue #3872) --src/provisioning/providers/kinesis-provider.ts,src/provisioning/providers/firehose-provider.ts, newsrc/provisioning/delete-gone-wait.ts, and unit tests. Both services keep a stream's NAME while it isDELETINGand refuse a create of that name with the sameResourceInUseException ... already existstext a live stream gets, so no retry could tell the two apart: the window measured 4-7s for Kinesis and ~103-107s for Firehose, which outlasts the name-cooldown retry grid.delete()returned the momentDeleteStream/DeleteDeliveryStreamwas accepted; it now pollsDescribeStreamSummary/DescribeDeliveryStreamuntilResourceNotFoundException, like CloudFormation. The wait is capped (5 min Kinesis, 10 min Firehose; half an explicit--resource-timeoutwhen that is lower), keeps polling through throttling, and never fails the delete: a cap, a Ctrl-C, or a status read AWS refuses (e.g. no describe permission) warns and returns, since AWS finishes the accepted delete on its side; only a Firehose stream that entersDELETING_FAILEDfails the delete, keeping the state record. A stream already gone on delete stays idempotent and is not waited on. Integration fixture:tests/integration/stream-delete-redeploy. AWS::CodeBuild::Project,AWS::DocDB::DBClusterandAWS::DocDB::DBInstancerefuse an unhandled property pre-flight instead of failing mid-deploy on Cloud Control (issue #3866) --src/provisioning/providers/codebuild-provider.ts,src/provisioning/providers/docdb-provider.ts,src/provisioning/providers/docdb-subnet-group-provider.ts(new),src/provisioning/providers/docdb-shared.ts(new),src/provisioning/register-providers.ts,src/provisioning/property-coverage.generated.ts, with unit tests intests/unit/provisioning/non-provisionable-cc-route-refusal-3866.test.ts. AWS reports the three types asNON_PROVISIONABLE(Cloud Control has no handlers for them), but their SDK providers did not opt out of the Cloud Control fallback. So a template using a property the SDK provider does not handle, such as a CodeBuildTriggersblock (CDK'swebhook: true) or a DocDB cluster'sEnableCloudwatchLogsExports, was routed to Cloud Control and failed there with an opaqueUnsupportedActionException, after other resources had already been created. The deploy now stops before provisioning anything, naming each property and the--prefer-sdk-routeescape hatch.AWS::DocDB::DBSubnetGroup, which Cloud Control can manage, moved to a provider of its own and keeps its Cloud Control route.- A nested stack's
--no-rollbackfailure no longer suggests a stack-lesscdkd rollbackthat targets the top-level stack (issue #3864) --src/deployment/deploy-engine.ts(recoveryHint), with unit tests intests/unit/deployment/deploy-engine-type-change-routing.test.ts. A nested child engine printed "Run 'cdkd deploy' to resume, 'cdkd rollback' to revert, or destroy to clean up." on a--no-rollbackfailure. With no stack argument,cdkd rollbackresolves to the top-level stack, not the nested one. The child now says it is a nested stack and to recover it through its top-level stack, named, whose own message follows; the top-level stack's message is unchanged. The clean-rollback settle warning no longer names a command ("a later rollback replay is idempotent"). The interrupted path's hint is unchanged; its parent-side handling is go-to-k/cdkd#3875. - A reverse-replacement refusal in a nested child's own rollback now names the child stack in its
cdkd rollback --orphancommand (issue #3859) --src/deployment/rollback-executor.ts(orphanRemedy,RollbackExecutorContext.nestedChildStack),src/deployment/deploy-engine.ts, with unit tests intests/unit/deployment/rollback-executor-retain-new-resource.test.ts,tests/unit/deployment/deploy-engine-rollback-context.test.ts,tests/unit/deployment/rollback-executor-absent-replay-baseline.test.ts,tests/unit/deployment/rollback-executor-cross-region-secret.test.tsandtests/unit/cli/commands/rollback.test.ts. When a nested child's deploy failed and its own in-process rollback hit one of the two refusals (a replacement whose old type cannot be routed, or a name collision underUpdateReplacePolicy: Retain), the refusal ended onTo orphan it: cdkd rollback --orphan <id>. That command names no stack, so it resolved to the parent, and only a rollback of the child honours--orphanfor the child's ops. For a failed nested UPDATE row the parent's rollback refuses without--revert-failedand, with it, replays the child without--orphan; for a failed CREATE row it never replays the child, so the id was ignored or matched a parent resource of the same logical id, whose revert it skipped. The command now readscdkd rollback '<parent>~<child>' --stack-region <region> --orphan <id>; a child name the gate will not print is a quoted hole, explained in the prose. The collision refusal's resume sentence, the region-ambiguous secret refusal, and the unusable-baseline retry hint name the nested stack the same way. A top-level stack's messages are unchanged. The nested engine's own interrupted /--no-rollback/ settle hints are go-to-k/cdkd#3864. cdkd exportnames the roles, users and groups anAWS::IAM::Policypre-delete detaches from, in the plan printed before its confirmation (issue #3857) --src/cli/commands/export.ts(preDeleteListingLines),docs/cli-export.md, with unit tests intests/unit/cli/export-predelete-listing.test.tsandtests/unit/cli/export-plan-record-display.test.ts. Between phases, the pre-delete removes the inline policy from every role, user and group recorded in cdkd state. The plan listed only the policy's own name, and the confirmation says to "see plan above" for what is deleted. The single-stack plan and the nested-tree summary now print aremoves inline policy <name> from roles: ...; users: ...; groups: ...line under each such policy. It comes from the same check the pre-delete runs, so both name the same policy and principals. Each name is rendered with its own boundary, and each kind is capped at 20 names with the rest counted. A policy whose recordedRoles/Users/Groupsis not a list of IAM names, or records none, is now a resource that blocks migration, reported before phase 1 changes any AWS resource. The pre-delete used to walk a string character by character and remove a same-named policy from one-letter roles, or refuse only after phase 1 had created the CloudFormation stack. The nested-tree summary also names each pre-delete resource, where it previously printed only a count.- A DynamoDB-stream
AWS::Lambda::EventSourceMappingcreated alongside its function's new role is retried instead of failing the deploy (issue #3853) --src/deployment/retryable-errors.ts, with unit tests intests/unit/deployment/retryable-errors.test.ts. Lambda reads a DynamoDB stream with the function's role when the mapping is created, and a role that IAM has not finished propagating fails that read with "Received Exception while reading from provided stream. The security token included in the request is invalid." cdkd did not classify that wording as IAM propagation, so the deploy failed and rolled back on a transient race. It is now retried with the other IAM-propagation errors. cdkd's own expired credentials, which carry the bare token sentence without Lambda's prefix, still fail immediately. AWS::Lambda::EventSourceMappingapplies a changedSelfManagedKafkaEventSourceConfig/AmazonManagedKafkaEventSourceConfigon update (issue #3851) --src/provisioning/providers/lambda-eventsource-provider.ts(kafkaConfigForUpdate), with unit tests intests/unit/provisioning/lambda-eventsource-provider-kafka-update.test.tsand a schema-registry add and removal phase intests/integration/lambda-esm-self-managed-kafka/.update()never sent either block, so adding, changing or removing aSchemaRegistryConfigon an existing Kafka or MSK mapping reported success without reaching AWS, and the next diff showed no change.update()now sends the members that changed. A removedSchemaRegistryConfigis sent as{ SchemaRegistryConfig: {} }, the shape Lambda clears it with; an empty block leaves it in place. An unchangedConsumerGroupIdis left out, because Lambda rejects the member on every update, even with the same value. A changed one is sent on a template-path update, for Lambda to reject; a rollback orcdkd drift --revertnever sends it, since no change to it can have been applied. State an older cdkd wrote after such an update records the newSchemaRegistryConfigalthough AWS never received it, so the next deploy sees no change: compare withaws lambda get-event-source-mapping, and re-deploy after toggling the block if they differ.AWS::Lambda::EventSourceMappingrefusesSelfManagedKafkaEventSourceConfig.ConsumptionModeinstead of dropping it silently (issue #3848) --src/provisioning/providers/lambda-eventsource-provider.ts, with unit tests intests/unit/provisioning/lambda-eventsource-provider-consumption-mode.test.tsandtests/unit/provisioning/sdk-pending-members.test.ts. CloudFormation added the member (Stream|Queue), but@aws-sdk/client-lambdadoes not model it yet and its serializer dropped the value. So a mapping deployed without the requested consumption mode, and the deploy reported success. A template-path create, and an update that adds or changes the member, now fail before any AWS call and name the remedy: remove the member. A rollback state replay or acdkd drift --revertstill proceeds and logs a warning. The new test goes red once an SDK bump sends the member, so the refusal can then be replaced by forwarding it.- A reverse-replacement refusal inside a nested child's revert no longer prints a
cdkd rollback --orphancommand that cannot reach it (issue #3845) --src/deployment/rollback-executor.ts(orphanRemedy,RollbackExecutorContext.nestedChildRevert),src/deployment/nested-child-journal.ts, with unit tests intests/unit/deployment/rollback-executor-retain-new-resource.test.tsandtests/unit/deployment/nested-child-journal.test.ts. Since #3829 a nested stack's UPDATE revert replays the child's own journal, so both refusals (a replacement whose old type cannot be routed, and a name collision underUpdateReplacePolicy: Retain) can fire inside that replay. They ended onTo orphan it: cdkd rollback --orphan <id>, but--orphanfeeds only the replay of the stack it is run on, and a direct rollback of the child is refused while its parent's run is unsettled — so following the remedy hit the same refusal again. Inside a child's revert the two refusals now print no command and say instead to resolve the cause and re-run the top-level stack's rollback, or re-deploy it. Outside a nested revert nothing changes. - An "already exists" from a provider's auxiliary create call no longer reads as the resource's own name collision (issue #3826) --
src/provisioning/auxiliary-failure.ts(markAuxiliaryFailure), wired into thecreate()of the API Gateway (Stage, Method), AppSync GraphQL API, CloudTrail, CodeCommit, Cognito user pool, custom resource, EC2 (VPC, Subnet, EIP, SecurityGroup, Instance), ECR, EFS file system, ELBv2 (load balancer, target group, listener), EventBridge rule, IAM (role, user, group, instance profile, managed policy, access key), KMS key, Lambda function, log group, RDS proxy target group, S3 bucket, Cloud Map service, SNS topic and SSM parameter providers, with unit tests intests/unit/provisioning/auxiliary-failure-3826.test.ts. A provider'screate()makes calls besides the one that creates the resource -- a security-group rule, a concurrency setting, a subscription, a policy -- and when one of them failed with AWS's "already exists", the relayed text classified as the resource's collision:--replacedeleted the live old resource, and a replacement rollback deleted the live new one, before the re-create failed again on the same auxiliary object. Such a failure is now anchored to a logical id no template can spell, which the collision classifier refuses; the error text is unchanged, and a collision on the main create still classifies. The providers whose files were held by other open work at the time are go-to-k/cdkd#3877. cdkd exportrenders values read from a state record with a visible boundary and a length cap, including in the plan printed above its confirmation (issue #3375; refs #3617, #3420) --src/cli/commands/export.ts, with unit tests intests/unit/cli/export-record-value-display.test.tsandtests/unit/cli/export-plan-record-display.test.ts. A record'sphysicalIdand itsproperties/attributesvalues are not validated when read back. Several messages printed them raw or inside cdkd's own'...': the import plan and pre-delete listing, the✓ deletedlines, the identifier-overlay warning and refusal, and the refusals behind a blocked resource. A planted newline could forge a plan row, and a planted'could add a clause. A plain identifier still prints bare. Any other value now prints as one JSON string with every character outside printable ASCII escaped (←prints as\u2190), so a legitimate UTF-8 id stays readable and distinct. Values are cut at 2048 characters, with the cut marked. Blocked-resource reasons, and the CloudFormation failure reasons cdkd relays from a failed IMPORT or phase-2 UPDATE changeset, are folded to one line and capped at 4096 characters. The nested-stack asset-path refusals no longer wrap the logical id in cdkd's quotes. Thecdkd state orphanfallback and the drift-baseline hole now point atcdkd state list --jsoninstead of--long, which trims the name they were recovering.- A role ARN is shape- and length-checked before cdkd sends it to STS (issue #2348, following cdk-local#607) --
src/utils/role-arn.ts,src/cli/options.ts,src/cli/commands/local-invoke.ts,src/cli/commands/local-invoke-agentcore.ts,src/cli/commands/local-start-api.ts,src/cli/commands/local-run-task.ts,src/local/ecr-puller.ts.- One predicate,
isIamRoleArn(arn:<partition>:iam::<account>:role/<printable ASCII>, anchored at both ends, at most 2048 characters, the STSRoleArnmaximum), is enforced immediately before everyAssumeRoleCommandcdkd sends. A malformed--role-arn/CDKD_ROLE_ARN,--ecr-role-arn,--assume-task-roleor task-definitionTaskRoleArn, and a cross-accountFn::GetStackOutputRoleArn, now fail with "AssumeRole refused ... Nothing was sent to STS." instead of being sent. An empty--role-arn "",--ecr-role-arn ""or--layer-role-arn ""is refused too; it used to run as the caller's own identity. --assume-roleoncdkd local start-apiused a start-anchored pattern, so a value with a newline, an escape sequence or any length afterrole/passed. It is now rejected, as are an empty role name and a partition containing a space. The bare form is trimmed, as theLogicalId=<arn>form already was.- A malformed
--assume-role <arn>oncdkd local invokeandlocal invoke-agentcoreis now an error. It used to fail at STS and fall back to the developer's shell credentials.--ecr-role-arn,--assume-task-roleand--layer-role-arnare checked at startup too. A bare--assume-roleARN containing=now parses. - A role ARN read from deployed state, a sibling role's cached
Arn, or a templateRole/RoleArnliteral used to be accepted onstartsWith('arn:'). A malformed one now stopslocal start-api --assume-role-autoat startup.local invokeandinvoke-agentcorewarn and fall back, as they did when STS rejected it.
- One predicate,
- The IAM providers mask secret-derived names in their own log lines and remediation commands (issue #2177, IAM slice) --
src/provisioning/providers/iam-{role,user-group,policy,managed-policy,instance-profile,access-key}-provider.ts, with the shared sink buildercreateMaskedLogSinksinsrc/provisioning/masked-retry-logger.ts; unit tests intests/unit/provisioning/iam-providers-masked-warn.test.ts. A role, user, group, policy, instance profile or access-key user name resolved from a{{resolve:secretsmanager:...}}reference used to reach the IAM providers' own debug and warning lines in plaintext, since those lines pass through no deploy-engine mask, and to be pasted verbatim into theaws iam ...cleanup commands a failed create prints. Everycreate()/update()line now goes through the deploy's masker, each name is masked before it joins the sentence (so a name that IS a secret shorter than four characters is caught too; a short secret embedded in a longer name is not), a physical name cdkd derives from a secret (stack prefix, folded characters, truncation) is masked as well, including the OLD name on a replacement whose previous value is a secret reference in state, and a cleanup command that would carry a masked name is withheld rather than printed with***in it. The IAM role and managed policy replacement paths hand the masker to thecreate()they run. Thedelete()path still has no masker (issue #2007), and a derived name is masked only inside the operation that derived it (issue #3869). cdkd importresolves a name built from the account or region before the provider looks it up (issue #1897) --src/cli/commands/import.ts(resolvePseudoParameterIntrinsics, called fromimportOne), with unit tests intests/unit/cli/import-prepass-pseudo-parameters.test.tsandtests/unit/cli/import.test.ts, theroute53integ arm intests/integration/route53/verify.sh, anddocs/import.md. On an env-agnostic stack a name such as`cdkd-test-${this.account}.internal`synthesizes to anFn::JoinoverAWS::AccountId, andimport.tspre-substituted only{Ref: <logicalId>}before calling a provider, so the provider saw the intrinsic, itstypeof name === 'string'guard declined, and the documented name route (for exampleAWS::Route53::HostedZoneviaListHostedZonesByName) answeredskipped-not-found. Every intrinsic built only from string literals,Fn::Join,Fn::SubandAWS::AccountId/AWS::Region/AWS::Partition/AWS::URLSuffixis now evaluated for the resource's region beforeprovider.import(), with the pseudo-parameter values a deploy uses. Anything else in the intrinsic (a resource or parameterRef,Fn::GetAtt,AWS::StackName) leaves it unresolved; a result spelling a{{resolve:...}}reference is left unresolved and nothing is fetched; and a name needing the account is not resolved when STS could not answer. The properties recorded in state are unchanged. Separately,substituteOverrideRefsnow keeps a template property key named__proto__as an own key of the bag it hands the provider; it used to become the bag's prototype, so its members read as the resource's own properties.
Recently Implemented (2026-09-26):
- A create failure whose message quotes "already exists" from a template value no longer deletes a resource as if it were a name collision (issue #3816) --
src/deployment/retryable-errors.ts(isNameCollisionErrorFrom),.claude/rules/name-collision-classification.md, with unit tests intests/unit/deployment/retryable-errors.test.tsandtests/unit/deployment/rollback-executor-name-collision-route.test.tsand shared fixtures intests/unit/_aws-sdk-error.ts. The collision classifier read the "already exists" phrase in any top-level error message. A cdkd refusal that quotes a property value carrying it therefore sent the rollback's reverse replacement to delete the live NEW resource first, with no flag, and sent--replaceto delete the old one before a re-create that failed the same way. The phrase is now credited only when an AWS-authored error in the cause chain states it AND the top-level message relays it, and an…AlreadyExistscode only as a whole token — a logical id such asUserAlreadyExistsHandler…, echoed in an ARN by an unrelated Lambda conflict, no longer reads as a collision — so a provider that rewords a collision it knows a delete cannot clear (Glue's occupied table name) stays unclassified. The async Cloud Control collision is now read from itsAlreadyExistshandler code rather than its text. A thrown string, a non-Errorvalue, a custom resource's FAILED reason, a Cloud Control failure under any other code, and S3'sBucketAlreadyExists(another account holds the name, so a delete frees nothing) no longer classify. An AWS error that echoes a template value carrying the phrase still does. cdkd diff --recursivepreviews a nested stack whoseConditionturned false as a delete of its whole subtree (issue #3815) --src/cli/commands/diff-recursive.ts(computeStackDiffnow returns its condition-prunedeffectiveTemplate;buildDiffTreewalks nested children and collects the[via CC API]annotations from it), with unit tests intests/unit/cli/diff-recursive.test.ts. The walk read the raw template, so a nested row whoseConditionevaluated false, with its child still in state, showed as a DELETE on the parent while the child itself was diffed against its current template as if it stayed live. That child's template-driven refusals, such as a resourceTypechange into or out ofAWS::CloudFormation::Stack, then counted toward exit3over a deploy that deletes the child and never runs its template. The child now goes through the same state-only DELETE subtree as a nested stack removed from the code, matching whatcdkd deploydoes. A nested-template cycle that closes only through such a row is still refused, ascdkd deployrefuses it: the root node also walks the unpruned nested-template tree.cdkd destroyno longer writes control characters from a stack name or a state record to the terminal in its spinner, confirmation prompt and interrupt notice (issue #3811) --src/utils/live-renderer.ts,src/cli/commands/destroy-runner.ts,docs/cli-destroy.md, with unit tests intests/unit/utils/live-renderer.test.ts,tests/unit/cli/destroy-runner-empty-state-lock.test.ts,tests/unit/cli/destroy-runner-sigint.test.tsandtests/unit/cli/destroy-runner-events.test.ts. The live area's task labels and multi-stack[stack]prefix, the per-stack confirmation prompt (both wordings) and its non-interactive refusal, the first-Ctrl-C notice and the no-longer-empty refusal of the empty-state cleanup, theAcquiring lock,has no resources,Stack region:and lock-release warning lines, and the retained-resource, slow-delete,✗ Failed to delete, state-persist and dependency lines printed through the logger interpolated S3-derived values raw, so a crafted state key or record could move the cursor, clear lines, forge an extra resource row or reorder text, including on the line where the operator approves the delete. The live renderer now passes every label throughdisplaySafeat draw time, covering the deploy-side spinner as well; the logical ids and types in those lines takedisplaySafe, a region takesdisplaySaferestricted to ASCII, and every stack name above renders throughdisplayStackName. As a result the prompt, the refusals and the lock-release warning show a plain stack name without the surrounding quotes (destroy stack MyStack), and a name that is not a plain identifier JSON-quoted. Labels are otherwise unchanged.- A failed update whose error message contains "does not support UPDATE" no longer replaces the resource without
--replace(issue #3810) --src/deployment/retryable-errors.ts(isUpdateUnsupportedError;CC_UPDATE_UNSUPPORTED_MESSAGE_FALLBACKremoved), with unit tests intests/unit/deployment/retryable-errors.test.tsandtests/unit/deployment/deploy-engine-replace.test.tsand a shared fixture intests/unit/_cc-unsupported-action.ts. The update-failure fallback's Cloud Control classifier still matched that phrase in any top-level error message, after #3757 had excluded only the typed refusal. A cdkd refusal that quotes a template value (AppSync's non-numericDeltaSyncTableTTL, for one) or an AWS error echoing its input therefore ran the DELETE + CREATE on a non-stateful type with no flag, and the replacement's CREATE could then hit the same refusal, leaving the resource deleted. The classifier now reads only the structured signals: theUnsupportedActionExceptionname down the cause chain, and the asyncccErrorCodefor an UPDATE. Both are what Cloud Control's real rejection carries, so a genuine "no UPDATE handler" type still falls back as before; an error carrying only the wording now fails the deploy. Fn::Subrefuses a list where it needs a string, as CloudFormation does (issue #3809) --src/deployment/intrinsic-function-resolver.ts.- A
${X}that resolves to a list used to be comma-joined into the result. It now fails the resource that holds it, before that resource is created or updated, with a non-retryable error that names the variable and points toFn::Join. This applies to aList<...>orCommaDelimitedListparameter, a list-valuedFn::GetAtt, andAWS::NotificationARNs, which used to render as''. - A stack Output that holds one is skipped with a warning, and a
Conditionevaluates to false with a warning, as for any other refused intrinsic. - A variable-map value that resolves to a list is refused too, even when the template never names it.
- CloudFormation rejects each of these shapes. It fails
CreateStackwith "variable X in Fn::Sub expression does not resolve to a string" or "every value of the context object of every Fn::Sub object must be a string", checked against real CloudFormation. Ref AWS::NotificationARNsnow resolves to an empty list rather than'', soFn::Joinover it renders''as CloudFormation does. It used to fail with "resolved to string". AConditionbuilt asFn::Equalsover thatFn::Joinand""now evaluates to true, where it used to evaluate to false.- A bare
Refto a list parameter andFn::Joinover one are unchanged.
- A
- A reader promoted because a value it reads may move now treats a create-only property as create-only on every type, not only the ~28 in the hand-written replacement registry (issue #3803) --
src/analyzer/diff-calculator.ts, unit tests,docs/architecture.md.- Before. Both promotion passes, replacement propagation and in-place attribute propagation, asked only the registry. On any other type, for example
AWS::RDS::DBInstance.MasterUsernameorAWS::IAM::ManagedPolicy.Description, a moved value in a create-only property was sent to an in-placeupdate(). There it was ignored, so the resource stayed stale under a green deploy, or it was refused. - Now. The passes use the ordinary diff's CFn-schema createOnly fallback wherever the registry has no opinion. Only a whole-property path that is not also write-only counts. A nested path (
ConnectionInput.Name) or a write-only property stays in place, as before. The check needscloudformation:DescribeType: without it the reader stays in place, as before. - What this means in practice. The result is the usual replacement ceiling. The deploy still updates or skips the reader when the value did not move (including a
NoEchovalue AWS confirms). It replaces the reader when the value did move, and a stateful type stops atSTATEFUL_REPLACE_BLOCKED. cdkd diffnow shows such a ceiling as[may require replacement].- Deploy: a replacement kept because AWS could not confirm a
NoEchovalue is logged at warn level. - Deploy: the ceiling check compares objects regardless of key order, so an equal value recorded with its keys in another order is no longer replaced.
- Before. Both promotion passes, replacement propagation and in-place attribute propagation, asked only the registry. On any other type, for example
- A malformed
AWS::AppSync::GraphQLApiEnvironmentVariablesvalue no longer strands a template-path update or blocks a rollback (issue #3781) --src/provisioning/providers/appsync-provider.ts.- A per-key value that is not a string, number or boolean (an object, array or
null) is now refused on a template-pathcdkd deployupdate BEFORE any write, when the map changed. It used to throw afterUpdateGraphqlApihad already applied the other changed fields. - The rollback revert arms (
replayingState) andcdkd drift --revert(desiredFromAwsReadback) now warn instead of failing. They send noPutGraphqlApiEnvironmentVariables, because that call replaces the whole map and would delete the skipped key. State records the map AWS still holds, or no map when the previous one is unusable too. - The reverse-replacement create (
CreateContext.replayingState) warns, then puts every usable key. State records the keys it put. The same record now also covers a malformedEnvironmentVariablescontainer on these paths. - A template-path create still refuses, and rolls the new API back. Warnings name the key and the value's type, never the value.
- A per-key value that is not a string, number or boolean (an object, array or
cdkd rollback'sRe-run with:line andcdkd events'Read one run's events with:line name a stack only when it is a plain identifier, and a newline in a stack name, logical id or resource type no longer starts a line in the per-resource status line, the skipped-delete message, orcdkd destroy's progress lines (issue #3773) --src/cli/commands/rollback.ts(a sharedrerunRollbackfor a warning and two refusals),src/cli/commands/events.ts,src/utils/resource-line.ts,src/deployment/delete-outcome.ts,src/cli/commands/destroy.ts, and unit tests.cdkd rollbackcan take the stack name from a journal S3 key, and printed it on a labelled pasteable line through the command gate alone, which names any exactly-rendering value, so a name padded to wrap on screen could print a counterfeit row such asRe-run with: cdkd destroy --all --force #.cdkd eventsnames the stack the operator typed, so its footer gets the same rule as defence for a typed or pasted name. Each line now requires a plain identifier (a letter or digit, then letters, digits,~,_,.or-, the rulecdkd drift,cdkd stateandcdkd destroyalready apply); any other name prints a quoted'<stack>'hole, with a sentence before the line saying why. A real stack name is always a plain identifier. Separately, the per-resource✓ <id> (<type>) deletedline and thecould not address <id> (<physical id>)skipped-delete message now fold control characters in their identifiers and skip reason onto one line, and so docdkd destroy's progress, skip,--remove-protectionbypass and--purge-eventslines for the stack name, region and AWS error text.cdkd rollbackandcdkd eventsno longer print a padded stack name, region or run field in the output that ends in their labelledRe-run with:/Read one run's events with:lines (issue #3760) --src/cli/commands/rollback.ts,src/cli/commands/events.ts,docs/deployment-events.md, and unit tests. Folding control characters closed the newline route, but interior spaces survived. A stack name or region planted in a journal or event S3 key, or a field in the event index, could therefore be padded to wrap on screen into a counterfeit labelled row, such asRe-run with: cdkd destroy --all --force #, above the genuine one. Beside those lines cdkd now names a stack or region only when it is a plain identifier (a letter or digit, then letters, digits,~,_,.or-), and otherwise describes it, as in "a stack name that is not a plain identifier". This coverscdkd rollback's multi-journal candidate list (with a pointer tocdkd state list --long) and its lock-release warning, and theDeployment runs forheader ofcdkd events. The failed-persist, failed-strip and lock-release warnings ofcdkd rollbackno longer show the state backend's error text when the stack name or region is not plain: that text re-spells the name, so it cannot be cleaned piecemeal. Eachcdkd eventsrun-list field is printed only as one token, otherwise as<unrenderable>or?. Ordinary output is unchanged.- A rollback now reverts a nested stack it reports restored (issue #3754) --
src/provisioning/providers/nested-stack-provider.ts,src/deployment/nested-child-journal.ts(new),src/deployment/deploy-engine.ts,src/cli/commands/rollback.ts,src/state/s3-state-backend.ts,src/types/rollback-journal.ts, with unit tests and thenested-stack-rollbackinteg fixture. When a parent deploy failed after a nested child had deployed, the rollback reverted theAWS::CloudFormation::Stackrow by re-deploying the child's CURRENT template, which changed nothing, and printed "restored successfully" while the child kept the failed deploy's configuration. A nested child's successful deploy now keeps its completed operations and pre-deploy outputs in its rollback journal until the top-level deploy succeeds; the parent's revert replays the child's segments for the same deploy run (recursing into grandchildren) and fails the row when no such record exists or an operation fails. Standalonecdkd rollbackof a nested row now works instead of throwing. The top-level stack's successful deploy deletes every nested child's journal, and a settled rollback drops the segments it consumed. - A Glue Table or Connection rename through its nested name is now planned as a replacement, as in CloudFormation (issue #3750) --
glueTableNameChangedand anAWS::Glue::Tablerule insrc/analyzer/replacement-rules.ts; unit tests intests/unit/analyzer/replacement-rules-glue-nested-name.test.ts; phase 2c of theglue-update-hardeningfixture. ATableInput.Namechange diffed as an in-place update that the provider then refused, asking forcdkd deploy --replace, socdkd diffforecast a change the deploy never made. CloudFormation replaces the table on that change (measured), and cdkd now does too: the diff plans a replacement and the stateful guard asks for--force-stateful-recreation, as for any table replacement. A case-only difference is the same table (Glue folds names to lowercase) and still updates in place. AConnectionInput.Namechange was already a replacement through the schema's nested createOnly path; it is now pinned by tests, which also pin that an unrelatedConnectionInputedit is not. ADatabaseInput.Namechange is unchanged: CloudFormation's update fails on it, and cdkd still refuses it before any AWS call. When a table already holds the new name, the replacement's create now fails with a message naming that table and database instead of the engine's name-collision advice to retry with--replace, which would have deleted the managed table first and then failed again. The provider's refusal stays as a backstop for paths that still reach an update with a changed name. - Renaming an
AWS::Route53::RecordSetno longer leaves the old record live and untracked (issue #3741) --src/provisioning/providers/route53-provider.ts.- A change to a record's
Name,TypeorSetIdentifierused to send a singleUPSERTbuilt from the new properties. Route 53 created the new record beside the old one, and the old record kept resolving.cdkd destroycould not remove it. A rename whose new record conflicts with the old one was refused outright, for example a newSetIdentifieron a geoproximity record or an A -> CNAME swap on one name. - Such a change now sends one atomic
ChangeBatch: aDELETEof the old record, read back live because Route 53 requires an exact match, plus aCREATEof the new record. This matches CloudFormation (live A/B on the issue). A refused batch leaves the old record serving. If the old record is no longer live, the new record is written byUPSERTas before. - A rename also needs
route53:ListResourceRecordSets(to read the old record), besideroute53:ChangeResourceRecordSets. - Route 53's refusal to create a CNAME beside a live record of the same name now counts as a name collision. So a rollback reversing a CNAME -> A rename deletes the A first, then restores the CNAME.
- A difference in letter case or trailing dot only is still the same record, updated by
UPSERT.cdkd drift --revertnever renames. - On rollback, a
SetIdentifier-only rename is renamed back the same way, and aName/Typerename is reversed as a replacement. Either way the rollback leaves no record behind. cdkd driftnow reads a weighted, latency, failover or geo record by its recordedSetIdentifier. It used to read whichever sibling with the same name and type sorted first.
- A change to a record's
- A template-path update now refuses a malformed Glue
DatabaseInputblock it used to deploy with a warning (issue #3740) --src/provisioning/providers/glue-provider.ts,docs/provider-rules.md, phase 2e of theglue-update-hardeningfixture.- Refused on
cdkd deploy, before any Glue call: anAWS::Glue::Databaseupdate whoseDatabaseInput.TargetDatabase,FederatedDatabaseorCreateTableDefaultPermissionsis malformed (a wrong shape, an unresolved intrinsic, an empty or partly unreadable block, or a permission entry cdkd cannot read, including anullone, which used to fail with a rawTypeErroron every path), with the same messagecreate()gives plus "Nothing was applied". It is refused even when the block did not change, becauseUpdateDatabasereplacesDatabaseInputwholesale and sends every block on every update. - The rollback revert arms (
replayingState) andcdkd drift --revert(desiredFromAwsReadback) keep the warning and still retain the previously applied block. - Unchanged: the
AWS::Glue::TableOpenTableFormatInput.IcebergInputwarning on update, since nothing is sent for it on update and no template edit makes it apply in place.
- Refused on
- A create-only reader of a
NoEchocustom-resource value is no longer replaced when the handler returned the same value (issue #3729; docs bound for issue #3801) --src/deployment/deploy-engine.ts,src/deployment/secret-redaction.ts(freshNoEchoLeafPositions), unit tests,tests/integration/custom-resource-noecho-nested,docs/{state-management,cli-diff,cli-deploy-tuning,architecture}.md.- Before. The record holds
***, so on every handler re-run such a reader was replaced, and a stateful one stopped atSTATEFUL_REPLACE_BLOCKED. This happened for a same-stack reader, a nested child's parameter reader, and a recovered cross-stack consumer. - Now. cdkd reads the reader back from AWS, passing the masked record, and keeps it when AWS holds exactly that value. It is updated in place if something else changed, and skipped if nothing did.
- Every uncertainty still replaces: no readback, a write-only property, a failed or slow read, or a different value.
- Nothing is stored. The readback is never stored, and it is not gated by
--no-capture-observed-state. There is no schema change: a stored salted hash would let a state reader confirm guesses of a low-entropy value. - New docs bound. A
NoEchovalue used as a resource NAME is stored in the clear as the physical id, as CloudFormation shows it. - Residual: issue #3803.
- Before. The record holds
- An Output, ECS env var, FSx
FileSystemTypeor drift baseline key named after anObject.prototypemember (constructor,toString) is no longer mistaken for one that is present (issue #3515, itsin-membership READS row) --src/cli/commands/scrub.ts,src/local/ecs-task-resolver.ts,src/local/ecs-task-runner.ts,src/provisioning/providers/fsx-filesystem-provider.ts,src/analyzer/drift-calculator.ts.cdkd scrubtreated a condition-suppressed Output namedconstructoras written, becauseinchecks the prototype chain. So a failing best-effort resolution of its value or export name refused the whole stack, where a suppressed output must never refuse.cdkd local run-task(--from-state/--from-cfn-stack) skipped a cross-stackEnvironmententry namedconstructor, which left the container without it.- An FSx create with
FileSystemType: constructorpassed the "supported type" guard instead of getting cdkd's named refusal. cdkd driftcompared an undeclared, captured-empty baseline key of that name instead of skipping it: phantom drift that--revertwould act on. Only a hand-editedstate.jsoncan carry such a key.- All four sites use
Object.hasOwnnow. cdkd local run-taskalso dropped an env var named__proto__with no warning. The resolver's three env writes, the runner's merge of the template env and its--env-varsoverride writes all ranObject.prototype's setter. They are own-key defines now, so the variable reaches the container.
- A secret named
__proto__reaches the local container through the spawn env (issue #3515) --src/utils/docker-cmd.ts(partitionSensitiveEnv,dockerSpawnEnvWithSensitive),src/local/ecs-task-runner.ts,src/local/ecs-secrets-resolver.ts.cdkd local run-taskhanded each resolved secret todocker runby assigning it onto a plain object. For a secret NAMED__proto__, that ranObject.prototype's setter, so its value vanished while the value-less-e __proto__flag stayed on the argv, and docker resolved it against the client's own environment. The three writes (the runner's secrets merge, andpartitionSensitiveEnv/dockerSpawnEnvWithSensitivein the helpercdkd local invokeshares) are own-key defines now. The value still travels only through the spawn env, never the argv, which a test pins.cdkd local invokecould not carry such a name yet, because cdk-local's env resolution dropped it first; the bump to cdk-local 0.149.7 delivers it.- A Secrets Manager
json-keynamed after a prototype member the secret does not carry (e.g.constructor) resolved to an empty value, which again left docker to fill it from the client env. It now fails with the named "no such key" refusal.
cdkd destroynames a CDK Stage that failed to load when stack selection comes back empty (issue #3507) --src/cli/commands/destroy.ts, with unit tests intests/unit/cli/destroy.test.ts. A stack under a Stage whose manifest could not be read is dropped from the synthesized app, socdkd destroy MyStage/MyStackansweredNo matching stacks found in statefor a stack that was still in state. The two empty-selection messages (No matching stacks found in state,No stacks found in state) and theCould not determine which stacks belong to this apprefusal now append the same failed-Stage notedeploy,diff,list,publish-assetsandsynthprint, naming the targeted Stage or listing every failed Stage behindPossibly unrelated:. Those exit codes are unchanged, and when synthesis itself fails the messages are unchanged.cdkd destroy --allor a wildcard pattern over an app that synthesized but yielded no stacks is now refused (exit 1) rather than falling back to every stack in the state bucket, other apps' stacks included; an exact physical name still resolves from state, and the refusal no longer advises--allthere.cdkd scrubis not covered yet.cdkd diffnow agrees withcdkd deployon nested-stack and routing decisions (issues #3450, #3453) --src/cli/commands/diff-recursive.ts,src/cli/commands/import.ts,docs/cli-diff.md,docs/cli-deploy-safety.md,docs/provisioning-layers.md, and unit tests.cdkd diff --recursivenow refuses a nested-template cycle spelled through a symbolically linked directory (d -> .with a row namingd/child.json) at its first repeat. It keys the ancestor chain on the same template identity the deploy-side guard uses. Before, it walked on until the path or S3 key limit stopped it with an error that did not name the cause. In asset-redirect mode it indexes a child's nested rows before the asset-reference rewrite, so it follows the file the deploy follows, andcdkd import --migrate-from-cloudformationnow finds a grandchild's template the same way. A row the deploy replaces (a[requires replacement]property change or aTypechange) no longer carries[via CC API: sticky]or[returning to SDK provider]from the old record, because the replacement is routed as a fresh create. ATypechange into or out ofAWS::CloudFormation::Stackis now listed underBlocking (cdkd deploy will refuse):and exits3, where it used to preview as an ordinary update that the deploy then refused.- Provider messages no longer paste a template- or response-borne name or id raw into the
aws ...command they tell you to run (issue #3136, partial) -- newpasteableAwsCommand/WITHHELD_AWS_COMMANDinsrc/provisioning/replacement-protection-advice.ts, sharingrenderDisableCommand's gate; call sites in thedynamodb-delete-budget,ecs,rds,route53,elbv2,acm-certificate,iam-managed-policy,sns-topic,cloudfront-distribution,cognito,eventbridge-ruleandec2providers; unit tests intests/unit/provisioning/pasteable-aws-command.test.tsand each provider's existing suite. The recovery, wait and cleanup commands in these messages interpolated the value bare, so a table name, cluster, rule or bus name, DB cluster identifier or policy path carrying',;or$(...)pasted as extra shell, and a control byte forged terminal lines (anddeployments/*.jsonllines, for the thrown Route 53, ACM and ECS messages). Each value is now checked withdisplaySafe(asciiOnly), then shell-quoted, and the whole command is replaced by a[command withheld: ...]note when any value would render differently or the caller's secret masker would change it. The bare<task-arn>,<arn>,<name>and<group>placeholders in the ECS, IAM role / instance profile and EventBridge Scheduler messages are quoted too, and the EIP--no-waitwarning renders its templateInstanceIddisplay-safe in the sentence as well. A clean value renders exactly as before. The IAM role, user, group and instance profile names stay bare: cdkd's name generator already limits them to[A-Za-z0-9-], which their tests now pin. Residuals, held by open PRs:dynamodb-globaltable,rds-dbproxy-targetgroup,iam-access-key,apigateway,servicediscovery,glue. - The API Gateway Method and Cloud Map Service cleanup messages no longer paste their ids raw into the
aws ...command they suggest, and the Glue skip warning quotes its placeholder (issue #3136, partial) --src/provisioning/providers/apigateway-provider.ts,src/provisioning/providers/servicediscovery-provider.ts,src/provisioning/providers/glue-provider.ts; unit tests intests/unit/provisioning/apigateway-provider.test.ts,tests/unit/provisioning/servicediscovery-provider-service-attributes.test.tsandtests/unit/provisioning/providers/glue-provider.test.ts. Theaws apigateway delete-methodcommand (templateRestApiId,ResourceId,HttpMethod) and bothaws servicediscovery delete-servicecommands (the interrupt handle and the cleanup-failure warning) now render throughpasteableAwsCommand: each value is shell-quoted, and the whole command is replaced by a[command withheld: ...]note when a value would not print exactly or the caller's secret masker would change it. The Glue unusable-CatalogIdskip warning's--catalog-id <id>is now'<id>', since a bare<id>is two shell redirections. A clean value renders exactly as before. Residuals, held by an open PR:dynamodb-globaltable,rds-dbproxy-targetgroup,iam-access-key. cdkd force-unlockrenders the stack name, the region and S3's error text on one line in its progress lines and its failure summary (issue #3027) --src/cli/commands/force-unlock.ts, and unit tests intests/unit/cli/force-unlock-display.test.ts. Without--stack-region, the command unlocks every regionlistStacks()finds for the name, and it printed each region -- an S3 key segment anyone able to write the state bucket chooses -- raw inForce-unlocking stack:,Lock released for stack:,No lock found for stack:andFailed to unlock stack, and again as a row of the multi-lineFailed to release N lock(s):summary. A newline in the region, the typed name or S3's error text (which echoes the lock key) could forge a line of that output or a row of the summary. The name and region now render through the identifier guard (a plain value prints unchanged, anything else flattened inside double quotes), and the error text through the ASCII allowlist; the absent-lock check still reads the raw text.- Lock and state messages on the deploy, destroy, rollback, force-unlock and renewal paths render the stack name, region and S3 error text on one line (issue #3027) --
src/state/lock-manager.ts,src/state/s3-state-backend.ts, and unit tests intests/unit/state/stackname-display-sites.test.ts. The two read commands (cdkd state show/cdkd state resources) already guarded their messages; the lock acquire / release / renewal / takeover lines,cdkd force-unlock's banner, andsaveState/deleteState/ the rollback-journal delete still printed the stack name, region and S3's error text raw. A stack name taken from an S3 key segment (cdkd state destroy,cdkd state orphan) could therefore carry a newline and forge a line of cdkd's output or of a thrown error, and S3's error text, which echoes the key, carried the same bytes back in. Each of those values now renders through the identifier guard the read commands use: a plain name prints unchanged, and any other name prints flattened inside double quotes. The stack name in these messages is no longer wrapped in single quotes, soLost the lock for stack 'MyStack' (us-east-1)now readsLost the lock for stack MyStack (us-east-1). The raw S3 keyslistRawObjectsanddeleteRawObjectsreport, which embed a stack name, take the same guard. cdkd local invokeandcdkd local invoke-agentcoreno longer put the container's own stdout ahead of the response payload (issue #2419) --src/local/docker-runner.ts, doc comments insrc/cli/commands/local-invoke.ts,src/cli/commands/local-invoke-agentcore.tsandsrc/utils/logger.ts,docs/local-invoke.md,docs/local-invoke-agentcore.md,docs/cli-reference.md, thelocal-invokeinteg fixture, and unit tests. The Lambda runtime emulator putsSTART/END/REPORTand every handler log line,console.errorincluded, on the container's stdout, andstreamLogspiped that straight into cdkd's stdout, socdkd local invoke MyStack/Fn | jqfailed on any handler that printed. The container's stdout now follows the same stdout reservation that already routes cdkd's own logger anddocker pullprogress: on these two commands it joins the container's stderr on cdkd's stderr, moved rather than dropped.cdkd local start-api,local run-taskandlocal start-servicereserve nothing, so their stdout still carries the container logs as before. A ZIP Lambda's stdout is now the payload alone; a container-image target cdkd builds still printsBuilding container image (platform=...)on stdout, so| tail -1is still needed there (#2429).cdkd local run-taskrecognises a FIPS, dual-stack (on.aws) or upper-cased ECR host as a cdkd asset image in a region bootstrapped withcdkd bootstrap --container-repo <name>(issue #1846) --src/local/ecs-task-resolver.ts,src/utils/ecr-uri.ts,docs/local-run-task.md, and unit tests. The host test in front of the marker-named repository match was the case-sensitive substring.dkr.ecr., a third spelling of the ECR host grammar that knew only the plain form. An image such as<acct>.dkr-ecr.us-east-1.on.aws/<custom-repo>:<hash>therefore skipped both the bootstrap-marker read and the localcdk.outbuild, and was pulled from ECR instead. The test is nowhasEcrRegistryHostLabels, built fromECR_REGISTRY_HOST_FORMS, so it accepts all four label runs in any case and still accepts a host whose account, region and suffix are unresolved${AWS::...}placeholders. It is a shape test only;parseEcrRegistryHoststill decides which hosts get adocker login.
Recently Implemented (2026-09-25):
cdkd destroy's refusals and summaries name a stack, region or logical id beside a pasteable command only when it is a plain identifier (issue #3759) --src/utils/error-handler.ts(Retry with:,Cascade-delete with:,Destroy the child alone with:),src/cli/commands/destroy.ts(Remove one record with:),src/cli/commands/destroy-runner.ts(Inspect it with:/Drop the record with:,Read them with:, and every summary line),src/provisioning/providers/nested-stack-provider.ts(the nested child failure'sInspect it with:), newplainOrDescribed/quotedOrDescribedinsrc/utils/pasteable-command.ts, and unit tests. These messages printed state- or template-sourced names raw in their prose and gated their commands only for shell safety, so a name or region carrying a newline, or padding that wraps on screen, could print a counterfeit labelled row such asRetry with: cdkd destroy --all --force #. Each command now also requires a plain identifier (a letter or digit, then letters, digits,~,_,.or-) and prints a quoted hole otherwise, the prose names the value only under the same rule, and a sentence before the labelled lines says why a hole was printed. The multi-region refusal's region list, whose regions come from S3 keys, is gated the same way. A child record named likeProd*orStage/Prodnow also gets a hole inDestroy the child alone with:, where it used to be named. A real stack, nested child (Parent~Child) or region name is always a plain identifier, so no genuine name loses its command.A resource an SDK provider refuses to update in place is no longer replaced without
--replacewhen the refusal's message happens to contain "does not support UPDATE" (issue #3757) --src/deployment/retryable-errors.ts(isUpdateUnsupportedError), with unit tests intests/unit/deployment/retryable-errors.test.tsandtests/unit/deployment/deploy-engine-replace.test.ts. The update-failure fallback's Cloud Control classifier read any top-level message containing that phrase as "this type has no UPDATE handler" and ran the DELETE + CREATE with no flag. A typedResourceUpdateNotSupportedErrorinterpolates template-chosen text (the Glue rename refusal quotes both names), so a name carrying the phrase skipped the--replaceopt-in. The classifier now refuses a typed rejection outright; it replaces only under--replace, as documented.A Glue
CatalogIdchange is refused instead of updating the same-named resource in another Data Catalog (issue #3756) --refuseCatalogMoveinsrc/provisioning/providers/glue-provider.ts, called fromGlueProvider.updateDatabase/updateTableandGlueConnectionProvider.update; unit tests intests/unit/provisioning/providers/glue-provider.test.ts; phase 2d of theglue-update-hardeningfixture. Each update sent the template'sCatalogIdwhile the state record names only the resource, so a changed catalog addressed the same-named database, table or connection in the other catalog.CatalogIdis not createOnly onAWS::Glue::Database, so every such edit took that path; Table and Connection took it when the createOnly lookup degraded. The update now fails before any Glue call with aResourceUpdateNotSupportedErrornaming both catalogs, andcdkd deploy --replace(plus--force-stateful-recreationfor a table or database) moves the resource. An absentCatalogId, theAWS::AccountIdpseudo parameter and the caller's own account id count as one catalog, so switching a database between them still updates in place (onests:GetCallerIdentitycall).readCurrentStatenow carries the recordedCatalogIdback, socdkd drift --revertupdates the entity in the catalog it was read from rather than this account's default one.A template-path update now refuses more malformed values it used to deploy with a warning (issue #3740) --
src/provisioning/providers/{iam-access-key,lambda-url,appsync,dynamodb-table,dynamodb-globaltable,s3-bucket,secretsmanager-secret}-provider.ts,docs/provider-rules.md.- Refused on
cdkd deploy, before any write to AWS: a malformedAWS::IAM::AccessKeyStatusorAWS::Lambda::UrlAuthType; a changed, malformedAWS::AppSync::GraphQLApiUserPoolConfig/OpenIDConnectConfig/LambdaAuthorizerConfig/AdditionalAuthenticationProviders/EnhancedMetricsConfig/EnvironmentVariablesblock; a changed, malformedBillingModeonAWS::DynamoDB::TableandAWS::DynamoDB::GlobalTable, andStreamSpecificationor a non-arrayGlobalSecondaryIndexeson the GlobalTable; a changed, malformedAWS::SecretsManager::SecretGenerateSecretString; and a malformedAWS::S3::Bucketlifecycle, notification, replication, object-lock, metrics, analytics, intelligent-tiering or inventory configuration that the update would Put. - The rollback revert arms (
replayingState) andcdkd drift --revert(desiredFromAwsReadback) keep the warning: their properties are not editable from the template. A value unchanged from the recorded one is not refused where nothing is sent for it. - Kept as a warning on every path:
AWS::RDS::DBProxyTargetGroupTargetGroupNameandAWS::Lambda::EventInvokeConfigQualifier, both create-only, so the only template fix is a replacement. The GlueDatabaseInputarm is not part of this change.
- Refused on
cdkd export's blocked-resource summary no longer prints a template identifier that could forge a labelled line, and its masked-identifier refusal puts its repair command on a gated line (issue #3736) --src/cli/commands/export.ts(groupBlockedReasonswithblockedRowId/blockedRowType;buildImportPlan's redaction-mask refusal now routed throughimportRepairCommand),scripts/check-pasteable-command-shapes.ts(its one exemption deleted, aCDKD_PASTEABLE_EXEMPTIONStest seam honoured only with--root=), and unit tests intests/unit/cli/export-{identifier-overlay,composite-identifier}.test.tsandtests/unit/scripts/pasteable-command-shapes.test.ts. Each blocked row printed its logical id and resource type raw, directly above reasons that can end in aRepair with:line, so a template key carrying a newline, or padding that wraps on screen, could print a counterfeitRepair with: cdkd destroy --all --force #row. The row now names a logical id only when it is a plain identifier and a type only when it has CloudFormation'sA::B/A::B::Cshape (Custom::names included); any other value is described. Every reason is folded to one line, so no newline in a template key or state value interpolated into one can start a line, and the oneRepair with:line a row may carry is printed by the renderer from a separately gated command; padding that wraps on screen inside a reason is tracked in #3760. A real template loses nothing.buildImportPlan's refusal for an identifier that resolves to the redaction mask printed itscdkd import ... --resource <id>=<physicalId> --forceremedy inside a quoted sentence with the logical id interpolated, where a pasted id ran. It now printsRepair with: cdkd import '<stack>' --resource ...on its own line, naming the id only when it is a plain identifier.A template-path update now refuses five malformed values that it used to deploy with a warning (issue #3728) --
src/provisioning/providers/{dynamodb-table,cognito,route53,s3-bucket,ec2}-provider.ts,docs/provider-rules.md.- Refused on
cdkd deploy, before any write to AWS: a per-indexProvisionedThroughputof{0, 0}on anAWS::DynamoDB::Tablewhose index capacity changes or is new; a malformedAWS::Cognito::UserPoolMfaConfiguration; a changed, malformedAWS::S3::BucketVersioningConfiguration.StatusorLoggingConfigurationmember; anAWS::Route53::RecordSetupdate that renames the record into a name containing|; and anAWS::EC2::Routedeclaring more than one destination. - Each used to warn and continue on every update, because
update()could not tell a template deploy from a rollback orcdkd drift --revertreplaying a state record. The rollback revert arms (replayingState) anddrift --revert(desiredFromAwsReadback) still take the warning: their properties are not editable from the template. - Kept as a warning on every path: the
|separator check on anAWS::Route53::RecordSetwhose recorded name already contains it, and on anAWS::EC2::Route(both segments are create-only). The only template fix there is a different resource. - The Glue
DatabaseInputarm is not part of this change.
- Refused on
A Glue
TableInput.Namerename is refused instead of written onto another table (issue #3724) --GlueProvider.updateTable/updateDatabaseandGlueConnectionProvider.updateinsrc/provisioning/providers/glue-provider.ts, unit tests intests/unit/provisioning/providers/glue-provider.test.ts, and phase 2c of theglue-update-hardeningfixture. None of the registry schemas marks the nested name CDK emits (TableInput.Name,DatabaseInput.Name,ConnectionInput.Name) createOnly, so a rename through it was deployed as an in-place update. For a table,UpdateTableaddresses the table byTableInput.Name, so the update rewrote whichever table held the new name -- possibly one cdkd does not manage -- while the state record kept the old one; for a database or connection the record kept the old name whatever AWS did with the new one. The update now fails before any Glue call with aResourceUpdateNotSupportedErrornaming both names, andcdkd deploy --replace(plus--force-stateful-recreationfor a table or database) performs the rename as a DELETE + CREATE. A case-only change to a table or database name, which Glue folds to lowercase, still updates in place; a name that is neither a string nor a safe integer is refused. On a stack that already hit the bug, the state record can differ from an unchanged template, and the refusal says so.cdkd diffprefetches the create-only schema lookups, a lookup the diff waits on no longer queues behind the prefetch, and a failed lookup uses cdkd's bundled schema snapshot instead of missing create-only changes (issue #3718) --src/provisioning/create-only-properties.ts,src/provisioning/describe-type.ts,src/utils/concurrency-limiter.ts,src/provisioning/create-only-paths.ts, the generatedsrc/provisioning/create-only-snapshot.generated.ts(fromscripts/gen-property-coverage.ts),scripts/refresh-cfn-schemas.mjsand a newcreateOnlyPropertyPathsfield in everytests/fixtures/cfn-schemas/*.json,src/cli/commands/diff-recursive.ts,src/deployment/deploy-engine.ts, with unit tests.cdkd diffused to run onecloudformation:DescribeTypelookup at a time, about 0.6 s each. It now warms the types of the rows each stack's state already records (a first diff issues none), nested children included. The deploy-start prefetch sent every lookup at once: on a 134-type template the diff's own lookups waited about 18 s behind the throttled burst, and roughly 90 lookups ran out of retries, each silently missing that resource's create-only changes. AllDescribeTypecalls now share a cap of 20 in flight, and a lookup the diff waits on is sent ahead of queued prefetches (measured: 0.8 s, 9-23 exhausted). A prefetch is withdrawn once its diff is done, so it never delays the command's exit. A failed lookup (missing permission, throttling) uses the committed snapshot's create-only paths for the type, which covers every type with an SDK provider, and warns; other types keep the fallback to cdkd's own rules. The fallback is not cached, so a live answer wins when it returns.Two readers of a value that moves in a deploy are updated (issues #3717, #3722) --
DiffCalculator(extractGetAttRefsnow recordsRefreads;promoteInPlaceAttributeDependentsgains the custom-resourceRefcase and a fresh-parameter arm),DeployEngine.freshNoEchoParameters,carryFreshNoEchoMarkinsrc/deployment/secret-redaction.tsand its call in the resolver's inherited-parameter recording, unit tests intests/unit/deployment/andtests/unit/analyzer/, and a new stack plus phase 6 in thecustom-resource-noecho-nestedfixture. A resource reading a custom resource withRefkept the old physical id when the handler answered an Update with a newPhysicalResourceId, because the diff promotedRefreaders only for a predicted replacement;Refreaders of an updated custom resource are now promoted, and the deploy skips each whose id did not move. A resource in a nested stack that reads aNoEchovalue through a stack parameter kept the old value when the parent's custom resource returned a new one: the child's diff compares the redacted parameter,***against***. The child engine now promotes readers of a parameter carrying aNoEchovalue supplied in the same deploy, and that value stays fresh in the reader's own redaction bag, so the no-change skip does not absorb it.A property absent from cdkd's CFn schema snapshot now routes its resource through Cloud Control instead of being dropped on the SDK route (issue #3713) --
src/provisioning/{property-coverage,provider-registry}.ts,scripts/gen-property-coverage.ts(+ regenerated table),src/deployment/{deploy-engine,recreate-targets}.ts,src/analyzer/diff-calculator.ts,src/cli/commands/diff-recursive.ts,scripts/diagnose-schema-refresh.mjs,docs/{provider-rules,troubleshooting}.md,docs/design/3713-route-unrecognized-properties.md. A property AWS published after the snapshot used to be silently dropped on SDK-provider types until the daily refresh PR merged, with only a warn. It now reaches AWS through Cloud Control the same day, like a known silent drop; the check is an offline table lookup, with no runtimeDescribeType. A misspelled key on a new resource, or one newly added, now fails at Cloud Control withUnsupported property, as CloudFormation does;--prefer-sdk-route <Type>:<Prop>restores the drop. Unchanged for: a read-only key (CloudFormation ignores one), a type with no Cloud Control route, and a key an existing resource already carries with the same value — existing deployments keep their route. The warn for those now names which reason applies.Refto anAWS::EC2::VPCCidrBlockreturns the association id, not cdkd's composite physical id (issues #3700 and #3704) --src/deployment/intrinsic-function-resolver.ts,src/cli/commands/export.ts(comment only),tests/unit/deployment/intrinsic-functions.test.ts.- The type is always Cloud-Control-routed and stored as
<Id>|<VpcId>, while CloudFormation'sRefreturns the association id (vpc-cidr-assoc-…). A hand-writtenRefto one resolved to the raw composite. The type now joinsREF_RETURNS_SEGMENT_BEFORE_FIRST_PIPE; a pipe-free id passes through unchanged. CDK's own dual-stackVpconlyDependsOnthe resource, so generated templates were not affected. - The
cdkd exportsplitter's comment no longer says a migrate records the bare id. No cdkd writer does: Cloud Control rejects the bare id as an identifier (measured), so an import that passed it through adopted nothing, and since #3701 a migrate completes it first. The splitter still accepts it, defensively.
- The type is always Cloud-Control-routed and stored as
cdkd state's legacy-record refusals andstate orphan's warning name a stack in a pasteable command only when it is a plain identifier (issue #3696) --src/cli/commands/state.ts(legacyRecordRefusal, shared bystate resources/state show;refresh-observed's legacy refusal;state orphan'sDestroy with:line),withheldTargetClause's docblock insrc/utils/pasteable-command.ts, and unit tests intests/unit/cli/state-{refresh-observed,resources,show,orphan}.test.ts. TheMigrate with: cdkd deploy ...andDestroy with: cdkd destroy ...lines used the command gate alone, which names any exactly-rendering value, so a stack name with interior spaces and a:could wrap on screen into a counterfeit labelled row such asMigrate with: cdkd destroy --all --force #. Each now also requires a plain identifier (a letter or digit, then letters, digits,~,_,.or-, the rulecdkd driftalready applies). Any other name prints a quoted'<stack>'hole with a sentence saying why, andrefresh-observed, whose name comes from an S3 key, no longer shows such a name in its refusal message either;cdkd state list --longshows it. A CloudFormation stack name is always a plain identifier.Docker asset publishing now logs in to the registry it pushes to (issue #3681) --
src/assets/docker-asset-publisher.ts, its unit tests,docs/architecture.md. After a push failed auth, the ECR login targetedGetAuthorizationToken'sproxyEndpoint, which names the CALLER's default registry. That matched the push host only while the push account was the caller's own; a push to any other registry would have logged in to the wrong host and failed again withno basic auth credentials. The push URI and thedocker loginendpoint are now built from one registry host, the rulecdkd local *already follows on the pull side (#3670). Because the ECR password now goes to that host, a destination region in the asset manifest that is not a single DNS label (x.example.com,us-east-1/x), or an account id that is not 12 digits, is refused before any AWS or docker call for that destination. The per-process login cache is gone: every login followed a failed push and bypassed it, so it never skipped one; the push-first path and docker's own credential store are what avoid repeat logins.cdkd destroy --remove-protectionnow counts protected EMR clusters and DynamoDB global tables in its confirmation prompt, and the docs table lists both bypasses (issues #3676, #2660) --src/cli/commands/destroy-runner.ts,docs/cli-destroy.md, plusdestroy-runner-count-protected.test.tsandremove-protection-types.test.ts.- The prompt reads
REMOVING DELETION PROTECTION on K of them, and K came from a per-type map of one TOP-LEVEL property. - EMR keeps its flag at
Instances.TerminationProtected, and a CDK global table keeps it on the local replica (Replicas[?Region==<region>]). So a protected cluster was never counted, and a protected global table was counted only through the observed baseline, even though the destroy flips both. The map now takes a key, a path of keys, or a reader. - A resource now counts when EITHER the template or the observed baseline marks it protected, so protection enabled out of band is counted even when the template says off. This includes the ELBv2 load balancer, whose
LoadBalancerAttributesentry is now read through the same map. A torn attribute list no longer throws before the prompt. - A CFn boolean resolved to the string
'true'now counts for every type. - A
resourceTypenaming anObject.prototypekey no longer throws before the prompt. - The
--remove-protectiontable indocs/cli-destroy.mdgains theAWS::DynamoDB::GlobalTableandAWS::EMR::Clusterrows.
- The prompt reads
cdkd importadopts Cloud-Control-routed types whose identifier is composite, such asAWS::EC2::VPCCidrBlock(issue #3672) --src/provisioning/cc-import-identifier.ts(new),CloudControlProvider.import(),tests/unit/provisioning/cc-import-identifier.test.ts, and thetests/integration/import-migrate-vpc/fixture, which gains an IPv6 CIDR block. Cloud Control identifies such a resource by its primary-identifier values joined with|, while CloudFormation's physical id is often one of them: a VPCCidrBlock is the barevpc-cidr-assoc-…id, not<Id>|<VpcId>.--migrate-from-cloudformationpassed CloudFormation's id toGetResourceunchanged, so the lookup failed and the import did not adopt the resource. The provider now reads the type'sprimaryIdentifierfrom the registry schema. It fills every other field from the template's literal values, afterRefsubstitution, and records the completed identifier. When the template cannot supply exactly the other fields, the import fails for that resource and names the composite--resourcevalue to pass. A full-arity id, a single-field type, or an unreadable schema is passed through unchanged.cdkd local invoke/start-api/run-task/invoke-agentcorenow log in to the pull host for every ECR form, and recognize only hosts AWS serves (issue #3670) --src/local/ecr-puller.ts,src/utils/ecr-uri.ts, their unit tests,docs/local-run-task.md. A cross-account pull through the plain<acct>.dkr.ecr.<region>.<suffix>host, made on the caller's own credentials (a repository-policy grant, no--ecr-role-arn), failed withno basic auth credentials: the plain form still logged in toGetAuthorizationToken'sproxyEndpoint, which names the CALLER's registry.docker loginnow always targets the hostdocker pulluses. Three host shapes AWS does not serve are now refused instead of receiving a login: a dual-stackon.awshost outside theaws/aws-us-govpartitions, a FIPS host outside those partitions (AWS listsdkr.ecr-fipsendpoints only there), and a service-name label in the region position (<acct>.dkr.ecr.s3.amazonaws.com). A region whose PREFIX cdkd does not know yet (a futurenz-north-1, the wayil-/mx-arrived) is now refused too, until cdkd learns the prefix:local run-taskpulls such an image anonymously as a public image and logs at debug level that the host looked like ECR, andlocal invoke/start-apirefuse it as an ECR URI cdkd cannot authenticate against. Upstream counterpart: go-to-k/cdk-local#766.A consumer of a
NoEchocustom-resource value is updated when the handler returns a new value (issue #3662) --DeployEngine's UPDATE arm,carriesFreshNoEchoValueinsrc/deployment/secret-redaction.ts,DiffCalculator.promoteInPlaceAttributeDependents, unit tests intests/unit/deployment/andtests/unit/analyzer/, and thecustom-resource-noecho-nestedfixture, which drops itsDescriptionworkarounds and gains a phase. The post-resolution no-change check compared the redacted bag with the record, and aNoEchovalue redacts to***on both sides, so a re-minted token was skipped: the deploy exited 0 and the live resource kept the old value. A resolved bag carrying aNoEchovalue supplied in this deploy no longer takes that skip. An in-place update of a custom resource now promotes every reader of its attributes (its handler'sData, which no template property names), so a same-stack reader gets the new value too; the engine still skips a reader whose value did not move. A dependent that reads a persisted***is no longer withheld from the diff's speculative promotions (a nested stack'sOutputs.<Key>reader, later fixpoint rounds): the engine now refuses a redacted read only after the no-change skip, so a reader whose bag equals its record sends nothing instead of failing the deploy. A promoted reader whose other reads moved while its masked value was not re-minted is refused, where it used to be left stale. A promoted reader's replacement is decided on its resolved value: a create-only property whose value did not move no longer replaces the reader when another property changes in the same deploy. A reader recorded as***by an earlier cdkd, which used to be refused, is now skipped silently when nothing else moved; force its custom resource to update to supply the value.cdkd import --migrate-from-cloudformationnow adopts a CDK VPC completely (issue #3661) --src/provisioning/providers/ec2-provider.ts(import()/verifyExplicit),tests/unit/provisioning/ec2-migrate-import-ids.test.ts, and the newtests/integration/import-migrate-vpc/fixture.EC2Provider.import()returned nothing for these types:- route tables and routes;
- internet gateways and their VPC attachments;
- network ACLs and their entries;
- subnet route-table / network-ACL associations;
- instances.
So a migration reported them as
no matching AWS resourceand retired the CloudFormation stack with them still running. The nextcdkd deploycreated the route tables a second time and failed on the conflicting associations, andcdkd destroycould not delete the VPC they held. Each type now accepts CloudFormation's physical id, verifies it against AWS, and records cdkd's own form with the attributescreate()records. CloudFormation's ids for these types were measured live and are recorded on the issue. A VPC gateway attachment (IGW|<vpcId>in CloudFormation) and a network ACL entry (a generated name) are rebuilt from the template's properties. Malformed ids are declined without an AWS call instead of aborting the import. Measured live: a CDKec2.Vpcwith a NAT gateway, a custom network ACL and an instance imports as30 imported, 0 not found, and the following deploy changes nothing. Before this fix the same stack imported as10 imported, 20 not found.The resolver's account-identity, dynamic-reference and availability-zone caches are keyed by the credential identity (issue #3660) --
src/deployment/intrinsic-function-resolver.ts; tests intests/unit/deployment/resolver-value-cache-identity.test.ts. A library caller that installedAwsClientsfor one account, resolved, then installedAwsClientswith other explicit credentials in the same process was answered from the first identity's caches:AWS::AccountId(and every ARN built from it),{{resolve:...}}values on a reused resolver, andFn::GetAZslists all came back as the first account's. Each cache now also keys on the active credential fingerprint (profile plus access key id, never a secret), and the account cache keeps its short fabricated-answer window, single in-flight lookup and reset invalidation per identity. CLI runs are unaffected: one process has one identity. The masked-output recovery store is tracked separately in #3691.cdkd local start-service/start-alb/start-agentcorenow pull FIPS, dual-stack and ISO-partition ECR images (issue #3655) --package.json(cdk-local^0.149.4->^0.149.6),tests/integration/local-start-service/. These commands pull through cdk-local's own ECR puller, which recognized only the plain<acct>.dkr.ecr.<region>.amazonaws.com(.cn)host. An image on<acct>.dkr.ecr-fips.<region>.<suffix>,<acct>.dkr-ecr[-fips].<region>.on.awsor an ISO / EUSC plain host was treated as a public image and pulled anonymously, which failed withno basic auth credentials. The bundled engine now recognizes every form AWS serves (FIPS and dual-stack only in the commercial and GovCloud partitions), always runsdocker loginagainst the hostdocker pulluses, no longer treats a host whose region label is not a region (<acct>.dkr.ecr.s3.amazonaws.com) as ECR, and folds an upper-cased host to lower case before logging in and pulling. Also from the bump (go-to-k/cdk-local#765): adocker build failed for ...message from a container-image build (local invoke/start-api/invoke-agentcore, and the engine's own builds) now shows only the first element ofsource.executable, display-safe, in its(...), and a docker or executable spawn error readsFailed to execute <cmd> (<code>). Upstream: go-to-k/cdk-local#766; cdkd's own pull path was fixed in #3651 / #3673.Two rollback-orphan records sharing a
logicalIdare now refused where a command can write, and dropped where it cannot (issue #3643) --src/state/malformed-resources-bag.ts,src/cli/commands/diff-recursive.ts. The row check #3500 added asked only that each record'slogicalIdbe a string, so two healthy records carrying the same one (the empty string included) both passed and then collapsed: the rollback save mergesorphansbylogicalIdand kept only the last, and the deploy's adoption pass adopted only one. The other record's resource stayed live in AWS with nothing tracking it. No cdkd command writes such a record, so it takes a hand edit or a damaged file. Behavior change: every record whoselogicalIdanother record also carries is now treated like any other unusable record --cdkd deploy,cdkd destroy(both reads),cdkd rollback,cdkd import,cdkd orphanand a realcdkd scrubrefuse the record naming each of them (STATE_RESOURCES_MALFORMED);cdkd scrub --dry-runandcdkd diffdrop them and warn by name, andcdkd diff --failcounts them. Repair by keeping one record perlogicalId.Rollback-orphan adoption notices, refusals and logs no longer print state-chosen text raw (issue #3642) --
src/deployment/orphan-adoption.ts,src/deployment/deploy-engine.ts,src/cli/commands/diff-recursive.ts. The pre-deploy adoption pass interpolated an orphan record's logical id, resource type and physical id, the provider's returned physical id and a caught error's text straight into the noticescdkd deployprints, the refusal it throws, theAdopting ...line and its debug lines -- andcdkd diff --jsoncarried the same refusals in itsblockingfield unsanitized (its terminal render already stripped them).state.jsonis hand-editable, so a newline, an escape sequence or a bidi override in one of those fields could forge a log line or reorder the terminal. They now render through the same display helpers as the other orphan messages: the logical id and resource type are JSON-quoted when they are not plain identifiers, the physical id has control and bidi characters replaced by spaces (a long or non-ASCII physical id is never cut or rewritten), and error text is sanitized and bounded.cdkd diff's "resource(s) to adopt from a previous rollback" line quotes a non-plain logical id the same way. An ordinary logical id, resource type and physical id render exactly as before.cdkd scrub,cdkd importandcdkd diff --recursiverender assembly- and state-chosen names inside a boundary of their own, never raw or inside cdkd's quotes (issues #3638, #3590, #3617) --src/cli/commands/scrub.ts,import.ts,diff-recursive.ts.cdkd scrubprinted stack names, regions, resource and orphan logical ids, output and export names, property paths, secret names and producer stacks into its warnings and refusals with no sanitizer at all (about 60 sites), or inside'${displaySafe(...)}', which passes'; a crafted name could write a line or a clause of its own into text a--dry-run --failCI gate prints.cdkd import --migrate-from-cloudformation's nested-stack refusals and parameter-dependence warnings, andcdkd diff --recursive's nested-row refusals, carried the same shape. Each name now renders throughdisplayIdent(a stack name throughdisplayStackName, anaws:asset:paththroughdisplayAssemblyPath): bare when plain (Scrub of MyStack could not resolve the Fn::ImportValue in resource Db at MasterUserPassword), otherwise as one JSON string. A masked value is masked first and bounded after. The shell-quoted remedy commands are unchanged.A resource reading a nested stack's output is now updated when that output changes (issue #3631) --
src/analyzer/diff-calculator.ts(promoteInPlaceAttributeDependents,extractGetAttRefs),tests/unit/analyzer/diff-calculator-nested-output-promotion.test.ts,tests/unit/deployment/deploy-engine-nested-output-reader-promotion.test.ts, and a new phase-3 arm of thetests/integration/custom-resource-noecho-nested/fixture. The parent's diff runs before the child deploys, so a resource readingFn::GetAtt [Child, 'Outputs.<Key>']compared the child's previous output against its own record, diffed NO_CHANGE, and kept the old value on AWS under a green deploy. An in-place update of anAWS::CloudFormation::Stacknow promotes every reader of itsOutputs.*attributes. The deploy re-resolves each one against the output the child deploy returned and skips the provider call when it did not move, so an unchanged output still sends no update.cdkd difflists those readers as updates. The two promotion passes now run until nothing more is promoted, so a sibling nested stack fed by the moved output, a reader of THAT stack's outputs, and a promotion that turns into a replacement all propagate as well. The string formFn::GetAtt: "Child.Outputs.Key"is recognised as a reference too. A reader with any read of a value stored as the***mask (aNoEchocustom resource's) is not promoted this way, since the deploy would refuse that read; lifting that goes with issue #3662.cdkd deploynow heals DynamoDBStreamArn, IAM RoleRoleIdand path-bearing IAMArnattributes missing from a record an older cdkd imported (issue #3627) --src/deployment/intrinsic-function-resolver.ts,tests/unit/deployment/intrinsic-heal-before-constructing.test.ts, and the newtests/integration/import-heal-prefix-record/fixture. The deploy-time stale-attribute heal (#1852) runs only when a resolution is about to refuse or take the physical-id fallback. These resolver arms answered first:- the DynamoDB
StreamArnand IAM RoleRoleIdarms returnedundefined; - the IAM Role / User / Group / InstanceProfile
Arnarms built an ARN that drops a non-/Path.
So a record imported before the
import()read-backs stayed wrong on every deploy. Each arm now tries the heal first, and keeps its old answer when the re-read finds nothing. Measured live: a stack imported with cdkd 0.291.13 had all four rows wrong, and the first deploy with this fix corrected every record and live value.- the DynamoDB
cdkd importnow records the attributescreate()records for Step Functions state machines, WAFv2 web ACLs, Cognito user pools, RDS DB clusters and S3 Vectors buckets, and accepts CloudFormation's physical ids for the web ACL and vector bucket (issue #3627) --src/provisioning/providers/{stepfunctions,wafv2,cognito,rds,s3-vectors}-provider.ts, their unit tests, and the newtests/integration/import-attribute-readback-final/fixture. Before this fix,cdkd import, including--migrate-from-cloudformation, did the following:- State machine
Name/StateMachineRevisionId: resolved to the ARN. - Web ACL
Id/LabelNamespace: resolved to the ARN, and the import threw on CloudFormation'sname|id|scopephysical id. - User pool
ProviderName/ProviderURL: resolved to the pool id. - DB cluster endpoints: resolved to the cluster identifier.
- Vector bucket
VectorBucketArn: refused, and the import threw on CloudFormation's ARN physical id, because it sent the ARN toGetVectorBucketas a name.
A failed import left those resources running after the CloudFormation stack was retired. Each
import()now reads these attributes back. The web ACL and vector bucket accept CloudFormation's id and record cdkd's own form.create()changes too:- A web ACL now records
LabelNamespace, read withGetWebACL.WebACLSummaryhas no such member, so the value was always absent. - A state machine now records its
StateMachineRevisionIdfromDescribeStateMachine. Before, it recordedstateMachineVersionArn, a version ARN that is null unless the machine is published. A machine that was never updated reportsINITIAL, as CloudFormation does (measured live).
- State machine
cdkd importnow records the attributescreate()records for SSM parameters, IAM instance profiles / users / groups, and AgentCore evaluators (issue #3627) --src/provisioning/providers/{ssm-parameter,iam-instance-profile,iam-user-group,agentcore-evaluator}-provider.ts, their unit tests, and the newtests/integration/import-attribute-readback-misc/fixture. Aftercdkd import, including--migrate-from-cloudformation, a sibling'sFn::GetAttgot these values:- SSM parameter
Type/Value: the parameter name. - IAM instance profile / user / group
Arnunder a non-/Path: a path-less ARN, silently. The resolver builds the ARN from the name. - Evaluator
Status/CreatedAt: the evaluator ARN.
Each
import()now reads these back, and the #1852 deploy-time heal serves them for records imported earlier. The evaluator's ARN form now callsGetEvaluator, as its id form already did, and returns not-found for a missing evaluator. Importing an evaluator by ARN therefore now needsbedrock-agentcore:GetEvaluator. An SSM parameter'sValueis recorded only when the template declares a plain literal. A{{resolve:...}}dynamic reference or aRef(for example, to aNoEchoparameter) would put a plaintext into state that no redaction on the import or heal path covers.- SSM parameter
Template-, state- and manifest-chosen names render inside a boundary of their own in the remaining resolver, state, lock, provider, local-run and asset messages (issue #3617) --
Fn::GetStackOutput's refusals and the region guard that refuses to build AWS clients for an invalid region,cdkd scrub's secret-bearing output-key and Export.Name warnings (a masked key that still carries non-ASCII is now withheld rather than shown), the macro-expansion parameter-type warning, the S3 lifecycle-rule warnings,cdkd local run-task's docker build and re-tag failures and secret refusals, the asset-manifest passthrough warnings, the custom-resource region-correction line,cdkd orphan --force's unreadable-attributes warning, and the state and lock messages printed stack names, output names, regions, rule ids, container names, keys and commands inside cdkd's own'...', through sanitizers that pass'; a crafted name could close the quote and write a clause of its own. Each now renders throughdisplayIdent(a stack name throughdisplayStackName, a command path throughdisplayAssemblyPath): bare when plain (Fn::GetStackOutput: stack Producer not found in region us-east-1), otherwise as one JSON string. A masked name is masked first and bounded after. The read failures for a nested template (on deploy and oncdkd diff), a stack template and a stack metadata file no longer repeat the path inside Node's ownopen '...'text, which is shown asopen '<path>'since the message already names the path, bounded; a parse failure there readsinvalid JSONrather than echoing a window of the file's bytes.A
cdkd deploythat changes nothing now clears a masked drift-baseline position the resource's own secret references can certify (issue #3595) --src/deployment/masked-baseline-recapture.ts(new),src/deployment/deploy-engine.ts(kickOffAutoRefreshObservedProperties,kickOffMaskedBaselineRecapture,drainObservedCaptures),src/cli/commands/drift.ts(the--acceptrefusal wording),docs/cli-drift.md,docs/cli-state.md,docs/import.md, and thesecrets-array-nestedinteg. Where cdkd could not pair a secret reference with the value AWS reported, the recorded baseline holds the mask***there (issue #2852), andcdkd driftreports that position as not compared (exit 2). Only a deploy that changed the resource cleared it. The automatic refresh at the start of every deploy now also handles such a resource: it resolves the references in the resource's own recorded properties, reads it back, and replaces a mask with the reference wherever AWS holds exactly the value it resolves to. Every other position of the baseline is left as it was. The baseline is left alone if a reference fails to resolve, if two references resolve to the same value, or if the resource no longer reads back as its baseline records, so a re-capture never takes in drift. A secret rotated since the resource was deployed keeps its mask. ANoEchocustom-resource mask is unchanged, and so iscdkd state refresh-observed.The synthesizer's account lookups and the
cdkd localECR puller now use explicitly suppliedAwsClientscredentials (issue #3588) --src/synthesis/synthesizer.ts,src/local/ecr-puller.ts; tests intests/unit/synthesis/synthesizer-macro-integration.test.ts,tests/unit/local/ecr-puller.test.tsandtests/unit/utils/ambient-client-defaults-fence.test.ts. A library caller that installedAwsClientswith an explicitcredentialsobject had the synthesizer's twoGetCallerIdentitycalls (the synth-time account and the macro-expansion default state bucket) and the ECR puller'sGetCallerIdentity,AssumeRoleand ECR login sign with the SDK's default credential chain instead. They now go throughambientClientDefaults()/clientDefaultsFor(), with an assumed--ecr-role-arnrole still outranking the caller's own credentials on the ECR login. The puller's process-wide caller-account and assumed-role caches are keyed by that identity as well, so a second identity is never told the first one's account or handed the role credentials the first one obtained. Both files leave the fence allow-list. CLI runs are unaffected: no command passes explicit credentials. Three sites held by an open pull request still follow separately.The S3 Tables provider now uses explicitly supplied
AwsClientscredentials (issue #3588) --src/provisioning/providers/s3-tables-provider.ts; tests intests/unit/utils/ambient-client-defaults.test.tsandtests/unit/utils/ambient-client-defaults-fence.test.ts. A library caller that installedAwsClientswith an explicitcredentialsobject had everyAWS::S3Tables::TableBucket/Namespace/Tablecall sign with the SDK's default credential chain instead. Its client now goes throughambientClientDefaults(), like every other SDK provider, and the file leaves the fence allow-list. CLI runs are unaffected: no command passes explicit credentials. The two remaining allow-listed command handlers (deploy.ts's caller-identity lookup andexport.ts's two lookups) are CLI-only, reached by no library entry point, and stay as they are.cdkd driftnow reads back the local replica of a multi-regionAWS::DynamoDB::GlobalTable(issue #3573) --src/provisioning/providers/dynamodb-globaltable-provider.ts,src/cli/commands/drift.ts,src/types/resource.ts; tests intests/unit/provisioning/dynamodb-globaltable-provider-local-replica-readback.test.tsandtests/unit/cli/drift.test.ts. On a table with a cross-region replica,DescribeTablein the deploy region lists only the other regions, so the readback left the deploy-region entry out ofReplicas. Drift on that replica's tags, point-in-time recovery, contributor insights and read ceilings was invisible, and after a reverse-replacement rollbackcdkd driftreportedReplicason every run. The readback now appends the local entry, andReplicasis compared as a set (the registry schema declares it unordered), so a template listing the deploy region first still matches. A record captured by an earlier version lacks that entry, so a new optional provider hook,canonicalizeDriftPair, sees both comparison bags at once: when the baseline has no local entry, it is completed with the readback's.cdkd drift --revertapplies the same completion to the bag it sends, so reverting another replica leaves the local table's tags and read auto-scaling alone, and--acceptstores the full readback. State is not otherwise rewritten; the next deploy captures the new shape.A removed tag, attribute or map key named after an
Object.prototypemember (constructor,toString) is removed on update (issue #3515, itsin-membership row) --src/provisioning/json-patch-generator.ts; the Glue (Job, Crawler, Trigger), Kinesis StreamConsumer, AppSync GraphqlApi, AgentCore Evaluator, Lambda MicrovmImage, S3 Tables (Table + TableBucket), S3 Vectors, CodeCommit, DLM, Cloud Map Service, API Gateway Method, API Gateway V2, S3 Bucket and CloudFront Distribution providers;src/analyzer/diff-calculator.ts,src/analyzer/lambda-vpc-deps.ts. One unit case per site (the two CloudFront tests share one).- A removal diff asked
key in newMapof a plain object, which answerstruefor an inherited prototype member. So dropping such a key from the template sent no untag, remove or delete call, and it stayed live on AWS. Affected: a tag, a Cloud Controlremovepatch op, a Cloud Map service attribute, a V2 stage variable / integration request parameter / route request model, and a MethodrequestParametersentry. CloudFront skipped the removed top-level member instead of warning about it. A NEW key of that name was sent asreplaceinstead ofadd, both on a Method map and in the Cloud Control patch. Every site usesObject.hasOwnnow. - Three deep-equality checks compared an own
__proto__: {}key against another key as EQUAL: the diff's nested-map compare (NO_CHANGEwhere it is anUPDATE), the Cloud Control patch (noreplace), and the S3 lifecycle-rule fold (the unfoldedFilterkept). The narrowing warning also names a dropped declared key of such a name now. The Lambda VPC delete-order pass no longer adds an edge to aRefnamedconstructorthat names no resource.
- A removal diff asked
cdkd diffnow exits 3 over a record whose DROPPED containercdkd deployrefuses, instead of 0 (issue #3512) — an unreadableresourcesmap, aresourcesentry that is not a resource record (anullrow, or an object with no resource type, tornpropertiesmap or not), anorphansfield that is not a list, and anorphansrecord the adoption preview cannot read. Each was already named in the preview, in--json'sunreadableand in the--failcount, so plaincdkd diffexited 0 and--failexited 1 while the next deploy refused the record. Each now also puts one reason per container on the node'sblocking, the rule #3335 set for thepropertiesmap andoutputsbag:countBlockingraisesDeployRefusalPreviewError(exit 3), ranked above--fail. Theunreadablerows are unchanged, so--jsonconsumers see the sameunreadablelist and a newblockingentry. TOP-LEVEL only, as for #3335: a nested child keeps its warning and itsunreadablerow and adds no reason. The dropped-entry list is now pushed one id at a time, so a record with more dropped entries than the engine's argument limit no longer abortscdkd diffwith a bareRangeError. Changed:src/cli/commands/diff-recursive.ts,docs/cli-diff.md,docs/cli-reference.md,docs/state-management.md,.claude/rules/layout-cli-diff.md,.claude/rules/state-malformed-properties.md.The cross-stack read union and the masked-output recovery store are keyed by what actually identifies an entry (issues #3496, #3691, #3746) --
crossStackReadsForPartialSaveand the stale-attribute heal memo insrc/deployment/deploy-engine.ts,recordRecoverableMaskedOutput/recoverMaskedOutputinsrc/deployment/secret-redaction.tsand their callers in the resolver andNestedStackProvider,tests/unit/deployment/cross-stack-read-union-key.test.ts, and cases in the recovery-store, resolver-identity and nested-stack suites. The union of a stack's recorded cross-stack reads keyed each entry by its source stack, region and name joined with a NUL; the previous record is an unchecked cast, so two distinct entries could share a key and the second was dropped fromstate.imports, the listcdkd destroyrefuses a destroy on. The keys are now an injective encoding, as the other composite keys became in #3501. The in-run recovery store for aNoEchooutput masked in the same process now carries the credential identity as well, so a library caller that switchesAwsClientsbetween accounts is not handed one account's plaintext for another account's same-named producer stack.cdkd destroy's strong-reference guard (scanActiveConsumers) and the recreate prompt's consumer scan (findDownstreamConsumers) now compare a recorded region case-insensitively, as the deploy side does, so an import recorded in another case still blocks the producer's destroy.Every log line is now stripped of terminal control characters at the logger, and
cdkd statemessages flatten each interpolated value to one line (issue #3479, closes #3374) --src/utils/logger.ts,src/utils/display-safe.ts(terminalSafe,safeMsg),src/cli/commands/state.ts, newscripts/check-raw-log-interpolation.ts+tests/raw-log-interpolation-baseline.json.ConsoleLoggersanitized only its extra arguments, so a stack name, region, logical id or AWS message interpolated into the message itself reached the terminal raw, including cursor-movement, screen-clear and bidi-override sequences. It now removes every foreign CSI sequence whole (a foreign colour code becomes a reset) and replaces every other control character except newline and tab, keeping cdkd's own colours. That cannot tell cdkd's newline from a value's, so line forging is closed per call site bysafeMsg, a tagged template that keeps the template's newlines and flattens each value;cdkd state's messages are the first to use it, and itsdestroy --allconfirmation list, written to stdout directly, renders each name throughdisplayIdent. A per-file ratchet (vp run audit:raw-log-interpolation:check) fails CI when a file gains a logger call that interpolates raw.cdkd force-unlockrecovery hints build through the shared pasteable-command gate, so a lock record named like an option or past the stack-ref cap gets no command (issue #3436) --src/state/lock-contention-message.ts.buildForceUnlockCommandkept its own copy of the gate: emptiness and exactness of the name and region plus the recovery flags' own exactness, but no leading--refusal and no cap, so a record named--state-bucket=attackerrendered exactly and came out ascdkd force-unlock '--state-bucket=attacker' ..., which the shell strips to the flag Commander then parses, and a name longer than 1152 code points was named in full. It now builds throughpasteableCommandand keeps its SUPPRESS semantics: any value the gate withholds (empty,altered,too-long,option-shaped), or an inexact recovery fragment, suppresses the whole command and the message says so, with every cause, includingchanged by sanitizing, named in the no-command sentence; the region takes the same two refusals. Named commands render byte-for-byte as before. The refusal's head sentence now renders the stack name indisplayStackName's boundary (the stack-ref cap the command is gated at) instead of inside cdkd's own'...'(a plain name prints bare): the paste fence measured the hand-quoted form running when a name carrying a quote was pasted with its sentence.Three more pasteable
cdkdcommands leave their own gates, the SSM import refusal's--resourcefragment is gated, and every touched message is pasted under bash (issue #3436) --src/cli/commands/export.ts,src/cli/commands/gc.ts,src/state/malformed-resources-bag.ts,src/deployment/rollback-executor.ts,src/provisioning/providers/ssm-parameter-provider.ts,src/utils/pasteable-command.ts. ThebuildImportPlanhalf landed in #3763.gc.ts,export.ts'scdkd state showline andmalformed-resources-bag.ts'sinspectCommandbuild through the shared gate. At the last two, withplainIdenton the stack name AND the region, an altered, capped, empty, option-shaped or non-plain value prints as a quoted hole, never a sanitized or shell-quoted spelling, and the region keeps its 128 display cap through the gate's newmaxCodePoints; an absent region still omits--stack-region, andinspectCommand's no-name arm still returns its two-hole template.gc.tskeeps its outerisPasteableIdentgate and the default cap, withholding the whole line.cdkd export's drift-gap report printscdkd state refresh-observedonly when the gate named both values, says why from the gate's reason otherwise, and explains a hole in its read line before the command.cdkd import's SSM refusal names the logical id in--resource <id>=<parameterName>only whenisPasteableIdentadmits it, quotes the placeholder and bounds the displayed id at 1152.cdkd rollback's two--orphanremedies move onto a labelledTo orphan it:LAST line of their own (kept per line on theRollback failed forlog for those two objects only), lose their backtick wrappers and quote the withheld placeholder; the collision refusal collapses and caps the AWS text above that line. Every touched site's test pastes its message into bash (tests/unit/utils/paste-harness.ts).cdkd scrubno longer reports a stack clean when a cross-stack read name still holds a secret's value from before a rotation (issue #3382) --src/cli/commands/scrub.ts,docs/cli-scrub.md. A storedimports[].exportName,outputReads[].sourceStackoroutputReads[].outputNamewritten by an older binary around a secret that has since ROTATED matched none of scrub's needles, which are the secret's current value, so the run printedNo plaintext secrets foundand--failexited 0 with the plaintext still instate.json. Scrub still cannot repair such a name, but it now reports it: an entry that no read in today's template reproduces, and whose name fits the shape of a secret-bearing read of the same producer and region, is a finding. The warning names the list and index (state.imports[1]), never the value; the stack is not called clean and--failexits 1. An ordinary import from the same producer stays green because this run re-read it. Tests:tests/unit/cli/commands/scrub-rotated-cross-stack-read.test.ts.cdkd rollbacknow refuses a state record whoseregionfield disagrees with its key while it still lists resources (issue #3370) --src/cli/commands/rollback.ts,docs/cli-rollback.md,docs/state-management.md, plusrollback-divergent-region.test.ts.getStatereported the divergence andcdkd rollbackdropped the report. The replay deletes and reverts in the KEY's region and reads a not-found delete as already rolled back, so if the record's own region was the honest half, a rolled-back CREATE was reported reverted, dropped from the record, and left live in the other region.cdkd destroyrefused this record since #3328; rollback did not.- The refusal runs once, before the plan, the prompt and every replay arm, with code
STATE_REGION_DIVERGED. Every arm that calls AWS needs a current state row: the CREATE delete, the in-place revert, both reverse-replacement arms (re-CREATE included) and--revert-failed. So a record listing no resources is let through, as on the destroy path. - The message prints the kind of the body's value (
a string), never the value. - The retry save after a lost conditional write no longer writes over a record rewritten mid-run with a disagreeing
region. The segment in flight finishes without saving, no older segment runs, the journal segment and the record are kept, and the run exits 2; the re-run meets the refusal.
cdkd deployno longer plans a CREATE for a resource whose state row cannot be read (issue #3314) --src/analyzer/diff-calculator.ts,src/state/malformed-resources-bag.ts,src/deployment/deploy-engine.ts, plusdiff-calculator-malformed-properties.test.ts,deploy-engine-malformed-entries-refusal.test.tsand cases inmalformed-resources-bag.test.ts. The change calculation looked each template resource up by logical id and treated any falsy row as "not in state". So anull(or any non-object) entry inresources, from a hand edit or a torn write, was planned as a CREATE of a resource cdkd already manages: a named resource then failed on a name collision with a message that did not mention the record, and an unnamed one was created a second time with the first copy orphaned. A row with noresourceTypewas planned as a type-change replacement.cdkd deploynow refuses such rows by name (STATE_RESOURCES_MALFORMED, exit1) at its state load, before any resource is created, updated or deleted and before any state is written, under--dry-runtoo;calculateDiffrefuses them as well. A default deploy used to die before the diff on a bareTypeError(in the prefix-migration check, or in the observed-state auto-refresh walk), so the refusal sits at the load, which runs before both.cdkd diffstill previews the rest of the stack without those rows and warns, as before.cdkd drift's human report no longer prints a state- or readback-derived value raw (issue #3232) --src/cli/commands/drift.ts(writeHumanReport, three row helpers andreportValue), newtests/unit/cli/drift-report-sanitize.test.ts. The stack name and region of every heading, each~/!/?row's logical id and resource type, and each changed property's path and both of its values go throughsafeMsgat the row that prints them, so a hand-edited record or a hostile value on the live resource can no longer invent a✓ ... no drift detectedrow or reorder what the reader sees. In an identifier, a control character becomes a space and a CSI or terminated OSC is removed whole (cdkd's own colour codes are kept, as on every logger line, and any other colour code becomes a reset); identifiers are capped at 255 code points (the stack name at the state-reference cap) with a...mark. A property value is never trimmed or capped; a string value that carries a control character (ESC included), has whitespace at either end or starts with"prints as its JSON string, with every control character written as an escape ("abc\n","a\tb"," value "), so a drift that differs only by a trailing newline, a tab or padding still shows two different sides; a structured value's JSON is escaped the same way, and any other string prints byte-for-byte as before. What this does not cover (another invisible character, one kind of space for another, a look-alike letter, a string vs a number spelling the same text) is in the docs.--jsonis unchanged. An identifier that WRAPS into a row-shaped line (#3328's class) is bounded by the cap, not closed; a property value, uncapped, is not bounded either.Every remaining consumer of a
resourcesROW that is not a resource record now refuses or drops it by name, instead of throwing, misreporting or saving it (issue #3202) --src/cli/commands/destroy-runner.ts,deploy.ts,scrub.ts,import.ts,src/deployment/orphan-adoption.ts,src/local/s3-local-state-provider.ts, plus five new texts and three refusal entry points insrc/state/malformed-resources-bag.ts.cdkd destroy/cdkd state destroyrefuse such a row before the prompt and the lock, and again on the record re-read under the lock (anullrow died on a bareTypeErrorin the pre-prompt listing; a falsy row was skipped as "not found in state" and the record removed with its resource live; a typeless row was routed to a provider onundefined).cdkd deploy's pre-lock--recreate-via-*check refuses the bag and the rows before validating, so anullnamed row is no longer reported as missing from state.cdkd scrubrefuses on a real run (exit 2) and, under--dry-run, drops the rows, warns, and reports amalformedResourceRowsfinding. A selectivecdkd importrefuses pre-flight only the unreadable rows it would NOT re-import -- a row named by--resourceis that row's repair -- and refuses again on the assembled map when a listed row's import did not succeed; whole-stack--forceand--migrate-from-cloudformationreplace the map. Everycdkd local * --from-stateload reads an unreadable map as empty and drops unreadable rows, warning with local texts. The rollback-orphan claim scan walks a sibling record past a row with no readable physical id -- a list-shaped bag included -- so the set that stops adopting another stack's resource is never narrowed. Docs:docs/state-management.md,docs/cli-destroy.md,docs/cli-deploy.md,docs/cli-scrub.md,docs/local-invoke.md,docs/local-start-api.md,docs/import.md.A security group whose ingress AND egress wiring both fail now reports the failure that happened first (issue #2804) --
src/provisioning/providers/ec2-provider.ts(createSecurityGroup),tests/unit/provisioning/providers/ec2-provider.test.ts. The create wires tags, inline ingress and inline egress concurrently and waits for all three before deleting the half-wired group. When two of them failed, cdkd reported whichever came first in its own call list (ingress over egress), not the one that failed first, and the retry classifier read that message too. The create still fails and the group is still deleted; what changes is which of the two genuine errors you see, and so whether the retry classifier treats it as transient.The
--prefer-sdk-routeaccepted-drop warning no longer tells a type with no Cloud Control route to remove the override (issue #2792) --src/provisioning/provider-registry.ts(reportSilentDropDecisions, newccRouteUnavailableReason),tests/unit/provisioning/provider-registry-unroutable-drop-warn.test.ts. For every accepted silent drop the warning said "Remove the override forto route this resource via Cloud Control API instead", or, for a create-only property, that it can only be applied by recreating the resource. Neither holds for a type Cloud Control cannot serve -- a provider declaring disableCcApiFallback(AWS::CloudFormation::Stack, FSx, EMR and others) or a NON_PROVISIONABLE type: removing the override makes the next deploy refuse at pre-flight instead of rerouting, and a recreate lands on the same SDK provider that drops the property. For those types the warning now says so, names the reason, and prescribes no remedy, since no route applies the property. Other types keep their per-property remedies unchanged.A rollback replay keeps a
__proto__property key, andFn::Subrefuses a non-string template by name (issue #2776) --src/deployment/rollback-executor.ts,src/deployment/intrinsic-function-resolver.ts,src/provisioning/providers/apigateway-provider.ts, newtests/unit/deployment/rollback-executor-proto-key.test.ts,tests/unit/deployment/intrinsic-prototype-chain-reads.test.ts.resolveReplayPropsrebuilt each replayed bag onto a{}literal, so a property literally named__proto__(an own key afterJSON.parse) ran the inherited setter and was silently absent from whatprovider.update()/create()received on a rollback. The walk now defines every key as an own property, the remedy #2767 applied to the resolver's object walk; the bag keeps its ordinary prototype, so providers see no other change.- A non-string
Fn::Subtemplate (first element, or the scalar form) died attemplate.matchAll is not a function. It is now refused asFn::Sub: the first element must be a string, got <type>/Fn::Sub: the template must be a string, got <type>, non-retryably, naming the type and never the value -- the terms the second element already had. resolveSub's variable lookups useObject.hasOwn. No behavior change: the maps have had a null prototype since #2739, so${constructor}already fell through toRef; the rendered value (the kept placeholder) is now pinned. Thedrift --reverthalf of the issue shipped in #3124.- Swept from review: an
AWS::ApiGateway::Stageupdate testedkey in variables/key in overridesto decide removals, so dropping a stage variable (or canary override) namedconstructor/toStringemitted noremoveop and the variable stayed live. Both useObject.hasOwnnow; the rest of thatinpopulation is a row on #3515.
The
--remove-protectionhelp now namesAWS::EMR::ClusterandAWS::DynamoDB::GlobalTable(issue #2660) --src/provisioning/remove-protection-types.ts,src/cli/options.ts,src/cli/commands/state.ts, plusremove-protection-types.test.tsandcc-protection-doc-coverage.test.ts. Both providers flip their protection off under the flag, butcdkd destroy --helpandcdkd state destroy --helpeach carried a hand-written list that left them out, so a user with a protected EMR cluster or global table would conclude cdkd could not bypass it. Both help strings now render from one list,SDK_REMOVE_PROTECTION_TYPESfollowed by the Cloud Control protection registry. A unit test binds that list to the provider files that readremoveProtection, in both directions. Thedocs/cli-destroy.mdtype table still lacks the two rows; that half follows once the open PR holding the page merges. The destroy confirm prompt's protected-resource count does not yet include a protected EMR cluster (#3676).Replacing a nested stack now requires
--force-stateful-recreation(issue #2548) --src/provisioning/stateful-types.ts,docs/cli-deploy-safety.md, plusstateful-types.test.tsanddiff-calculator-createonly.test.ts.AWS::CloudFormation::Stackjoins the stateful guard. ItsStackNameis immutable in the registry schema, so aStackNameedit deployed with--prefer-sdk-route AWS::CloudFormation::Stack:StackNamediffed as a replacement. The refusal that followed recommended--replace, and the delete-first path it takes destroyed every resource in the child stack with no consent flag. The replacement is now refused before anything is created, until--force-stateful-recreationis passed.AWS::EC2::InstanceandAWS::SQS::Queuedeliberately stay off the guard: the instance's root volume is ephemeral by design (persistent data belongs on the guardedAWS::EC2::Volume) and a queue's backlog is transient.
Renaming an
AWS::S3Tables::Namespaceno longer demands--force-stateful-recreation(issue #2539) --src/provisioning/stateful-types.ts,docs/cli-deploy-safety.md,tests/unit/provisioning/stateful-types.test.ts.- The type was on the stateful-replacement guard list on the fail-safe side of an unmeasured question: whether AWS's
DeleteNamespacecascades to the tables inside. - A live probe on 2026-09-25 settled it: AWS refuses to delete a namespace that still holds a table (
BadRequestException: The namespace that you tried to delete is not empty.), and the Cloud Control delete fails the same way. cdkd's delete enumerates no tables, so a namespace replacement cannot destroy one. The replacement creates the new namespace first; if the old one still holds tables, its delete is refused and cdkd warns and continues, leaving the old namespace and its tables in AWS, untracked by state. - The entry, its row in the always-stateful table, and the matching doc paragraph are removed; a unit case pins the exclusion.
AWS::S3Tables::TableBucketandAWS::S3Tables::Tablestay guarded.
- The type was on the stateful-replacement guard list on the fail-safe side of an unmeasured question: whether AWS's
cdkd local start-api/local invokebracket an IPv6 host in the URLs they print and fetch (issue #2338) --src/utils/url-authority.ts(new),src/cli/commands/local-start-api.ts,src/local/rie-client.ts,src/utils/proxy-routing-agent.ts, their unit tests. Before,--host ::printedServer listening on http://:::3000, which no URL parser accepts, and the per-API route-table header had the same defect. An IPv6--container-hoston either command also broke the RIE readiness probe and the Lambda invoke, becausefetch('http://::1:<port>/...')throwsInvalid URLbefore sending. Every one of these sites now composes its authority throughformatAuthority, which brackets an IPv6 literal (http://[::]:3000) and drops a zone id, which no URL parser accepts. The RIE not-ready error names the host exactly as passed, zone id included (RIE did not become ready on fe80::1%lo0 port 9001). Every other IPv4 and hostname output is byte-identical. This follows go-to-k/cdk-local#599; thestart-alb/start-service/start-cloudfront/start-agentcorebanners already carry that fix through the cdk-local dependency.A container-image Lambda function (
PackageType: Image) declaringCodeSigningConfigArnorRuntimeManagementConfigis now refused by cdkd before any Lambda call (issue #1894) --src/provisioning/providers/lambda-function-provider.ts(refuseImageFunctionUnsupported, called fromcreateandupdate),tests/unit/provisioning/lambda-image-function-refusal.test.ts,tests/unit/provisioning/lambda-function-props-609.test.ts, and a new phase intests/integration/docker-image-asset/. AWS rejects both properties on an image function withInvalidParameterValueException: the image carries its own runtime, and Lambda code signing does not apply to images. cdkd sent them anyway, so the deploy failed on AWS's rejection -- forRuntimeManagementConfigon create, only after creating the function and deleting it again. The deploy now fails at once with a message naming the property to remove, on both create and update. This matches the read side, which since #1890 skips the two matchingGet*calls for an image function: that saves two calls per readback and the two warnings each drift snapshot of an image function used to print. The fixture now runscdkd drifton its image function and expects a clean result with neither warning.Glue tag reads and tag updates target the right partition (issue #1815) --
src/provisioning/providers/glue-provider.ts,tests/unit/provisioning/glue-arn-partition.test.ts. cdkd built every Glue ARN it hands to AWS asarn:aws:glue:...: theAWS::Glue::WorkflowARN behind its drift read'sGetTags, and the shared builder behind the Job / Crawler / Trigger drift reads'GetTagsand theirTagResource/UntagResourcetag updates. Inaws-cn/aws-us-govthat ARN names no resource, so a tag update failed and a drift read reported the tags as empty. Both builders now derive the partition from the Glue client's region. Commercial-region output is unchanged. The same PR corrects in-code comments that still saidupdate()takes no context or cannot tell a rollback replay from a template deploy (issue #1999); comment-only, no behavior change.cdkd driftno longer reports a phantomGlobalSecondaryIndexes/LocalSecondaryIndexesdifference for anAWS::DynamoDB::Tablewhose baseline an older cdkd captured, and a key-order-onlyProjectiondifference no longer fails an index update (issue #1812) --src/provisioning/providers/dynamodb-table-provider.ts,docs/provider-rules.md.- A table declaring indexes, deployed by a cdkd older than the #1767 readback change, recorded each index's raw
DescribeTabledescription (IndexStatus,ItemCount,IndexArn, the on-demand{0, 0}capacity, a computedWarmThroughputwithStatus, ...), and everycdkd driftreported the index list as drifted until the next deploy or--accept.canonicalizeDriftPropertiesnow strips those SDK-only members from both comparison sides, so such a record converges when its recorded template properties are non-empty. A template or a current readback carries none of them, so nothing it declares stops being reported. update()compared an index's recorded and desiredKeySchema/ProjectionwithJSON.stringify, so a template-ordered{NonKeyAttributes, ProjectionType}against a readback-ordered one (reachable viacdkd drift --revert) threw "changed its KeySchema or Projection". The compare now ignores object key order;KeySchemaelement order stays significant.
- A table declaring indexes, deployed by a cdkd older than the #1767 readback change, recorded each index's raw
A Cloud Control-routed S3 bucket records its
Arnin the right partition (issue #1794) --src/provisioning/cloud-control-provider.ts, its unit tests,docs/provider-rules.md. When Cloud Control did not report anAWS::S3::BucketArn, cdkd filled it in asarn:aws:s3:::<bucket>in every region, so a CC-routed bucket inaws-cnoraws-us-govrecorded (and served toFn::GetAtt) a commercial ARN that no policy in that partition matches, while the SDK-routed bucket already recorded the real partition. The fallback now derives the partition from the Cloud Control client's region through the same builder the SDK provider uses, so both routes record the same value. Commercial-region output is unchanged. The provider guide's "Returning attributes" example, which taught the hardcoded form, now uses the shared builders.Glue table update / destroy / drift decode the recorded id by its
DatabaseName(issue #1672) --src/provisioning/providers/glue-provider.ts,tests/unit/provisioning/composite-id-provider-guards.test.ts. AnAWS::Glue::Tableid is<databaseName>|<tableName>with an unescaped|. A table name containing|(recorded by a state-replay create, or by binaries before the create-time refusal) was split into two segments, socdkd destroydeleted a different table,updatetargeted it, andcdkd driftread it. The three decode sites now anchor on theDatabaseNamerecorded in state and take the rest of the id as the table name. Without a usableDatabaseName, an id with more than one|is treated as malformed, the same as an empty segment: destroy skips it and keeps the record, update fails, and drift reads nothing. The record format is unchanged, andcreateTable/importstill refuse a|in either name, because theRefresolver still returns the segment after the last|.
Recently Implemented (2026-09-24):
cdkd deploy --verboseno longer prints aNoEchocustom-resource value on itsResolved Fn::GetAtt ...debug lines (issue #3659) --src/deployment/intrinsic-function-resolver.ts(resolveGetAtt,serveHealedAttribute),tests/unit/deployment/intrinsic-getatt-noecho-log-order.test.ts, and the newtests/integration/custom-resource-noecho-nested/fixture, whose--verboselog scan found it on real AWS. A value a handler declared sensitive withNoEcho: truewas already masked instate.json, but eachFn::GetAttbranch serving it from the recorded attributes logged it before registering it as a secret of the resource reading it. So the debug line printed the plaintext, both for a same-stack read and for a parent reading a nested child's output. The four branches (the flat and nested attribute reads, the Route 53NameServersnormalization and the stale-attribute re-read) now register first, so the line shows***. Resolved values and state are unchanged. The same fixture adds real-AWS coverage of the cross-stack recovery theNoEchochannel relies on (issue #2460): nested-stack outputs andFn::ImportValuein onecdkd deploy --all.cdkd localDocker build-context refusals now come from the bundled engine alone, and a path with a blank-drawing mark prints escaped (issues #3652, #3656) --package.json(cdk-local^0.149.3->^0.149.4),src/local/docker-image-builder.ts,src/assets/docker-build.ts,src/cli/commands/local-start-{service,alb}.ts, the deletedsrc/cli/commands/emulator-docker-context.ts, andsrc/utils/assembly-path.ts. cdk-local 0.149.4 renders the value an assembly chose display-safe in its refusals (go-to-k/cdk-local#758), so cdkd drops its own copy of the check in front oflocal invoke/start-api/invoke-agentcore/start-service/start-alb. An escapingsource.directoryis still refused before anything is built, and the refusal now readsDocker image asset has source.directory=../victim which resolves to ...instead of cdkd'sRefusing to build the container image: ....start-service/start-albcheck each image when it is built rather than every stack's images at startup, so the network and metadata sidecar are created before a refusal, and an image the run never builds is not checked. With--no-build, the engine reuses the cached tag and never opens the directory, so it no longer refuses.<link>/..is now built from the lexical path, which stays inside the assembly, instead of being refused. The engine'ssource.executablewarning now sayscdkd runs it. Separately,displayAssemblyPathescapes a default-ignorable mark (U+034F, U+17B4, U+180B, the variation selectors) even directly after a letter, so/home/me/.ss\u034fhno longer prints like/home/me/.ssh.Fn::GetAttof a DocDB / Neptune cluster or instance endpoint now resolves to the endpoint (issue #3650) --src/provisioning/providers/{docdb,neptune}-provider.ts, their unit tests, the newtests/unit/provisioning/docdb-neptune-getatt-names.test.ts, andtests/integration/docdb-neptune/. Both providers recorded only RDS-style dotted keys (Endpoint.Address,Endpoint.Port,ReadEndpoint.Address). CloudFormation names these attributesEndpoint,PortandReadEndpointfor DocDB and Neptune. So on a normalcdkd deploy,Fn::GetAtt [Cluster, Endpoint]warnedUnknown attributeand resolved to the cluster identifier. That is what CDK'sclusterEndpointreads, so an application got a connection host that is not a hostname. The providers now record the CloudFormation names increate(),update()andimport(), and keep the dotted keys. A record written before the fix heals on the nextcdkd deploy: the miss triggers the stale-attribute re-read (#1852), which now reports these names. The clusterimport()also returns these attributes, where it returned none (#3627).cdkd importnow records the template'sDeletionPolicy/UpdateReplacePolicyon each imported record (issue #3645) --src/cli/commands/import.ts(buildStackState),tests/unit/cli/import-build-state-policies.test.ts, and the newtests/integration/import-retain-destroy/fixture.cdkd destroyreadsDeletionPolicyfrom state only, and an imported record carried none. So acdkd destroyrun aftercdkd importand before the firstcdkd deploydeleted resources the template declaresDeletionPolicy: Retain, and deletedSnapshotresources without their final snapshot. CDK retains stateful resources by default, so the data-bearing resources were the ones exposed. Measured live on v0.291.14:import --migrate-from-cloudformationthendestroy --forcedeleted aRetainSSM parameter. The first deploy after an import also no longer reports a metadata-only policy update. State imported by an earlier version gets the policies on its nextcdkd deploy, or by re-runningcdkd importfor those resources. Until then, do not runcdkd destroyon it.AWS::EMR::InstanceGroupConfignow recordsInstanceGroupIdas well asIdin its attributes (issue #3632) --src/provisioning/providers/emr-instance-group-config-provider.ts, plus tests. The CFn schema renamed the read-only attribute fromIdtoInstanceGroupId(#3629). Before this change, a template reading the new name got its value only through the physical-ID fallback, with a warning. It was refused under--strict-getatt, andcdkd orphancould not rewrite it. An aws-cdk-lib regenerated from that schema emits the new name asattrInstanceGroupId.create,updateandgetAttributenow serve both names as the instance group id, which is also the physical id. A group whose state was written before this change keeps onlyIduntil its next update or replacement. Until then,InstanceGroupIdstill resolves through the fallback.cdkd importnow records the attributescreate()records forAWS::SNS::Topic,AWS::DynamoDB::Table/GlobalTable,AWS::IAM::RoleandAWS::Lambda::EventSourceMapping(issue #3627) --src/provisioning/providers/{sns-topic,dynamodb-table,dynamodb-globaltable,iam-role,lambda-eventsource}-provider.ts,src/deployment/intrinsic-function-resolver.ts, their unit tests, and the newtests/integration/import-attribute-readback/fixture. These types'import()returned no attributes, and the resolver could not produce them from the physical id. Aftercdkd import, a sibling'sFn::GetAttgot one of these results:- SNS
TopicName: the topic ARN, with no warning. The physical id is the ARN, which the resolver read as a name. - SNS
TopicArn: a doubled ARN, with no warning. Same cause. - DynamoDB
StreamArn, IAM RoleRoleId:undefined. - IAM Role
Arn: an ARN with a non-/Pathdropped. - Event source mapping
EventSourceMappingArn: a refusal.
Each
import()now reads these attributes back, and drops any member AWS does not report. A record imported before this fix heals on the nextcdkd deployfor the event source mapping, through the deploy-time stale-attribute heal (#1852), and for SNS, through the resolver change below. It does not heal for the DynamoDBStreamArn, IAMRoleIdor path-bearing IAMArnrows: their resolver arms answer before the heal runs. Re-import those resources (#3627 tracks it). The resolver's SNS arms now treat an ARN physical id as the ARN. Importing an event source mapping now needslambda:GetEventSourceMapping.- SNS
cdkd importnow records the attributescreate()records for ELBv2 load balancers and target groups, EC2 subnets, Cloud Map services, EFS access points and CloudFront origin access identities (issue #3627) --src/provisioning/providers/{elbv2,ec2,servicediscovery,efs,cloudfront-oai}-provider.ts, their unit tests, and the newtests/integration/import-attribute-readback-network/fixture. Aftercdkd import, including--migrate-from-cloudformation, a sibling'sFn::GetAttgot these values:Resource Attributes Value before the fix ELBv2 load balancer DNSName,CanonicalHostedZoneID,LoadBalancerFullName,LoadBalancerNamethe load balancer ARN ELBv2 target group TargetGroupFullName,TargetGroupNamethe target group ARN EC2 subnet AvailabilityZonethe subnet id Cloud Map service Namethe srv-idEFS access point Arnrefused CloudFront OAI S3CanonicalUserIdthe OAI id Each
import()now reads these back, and the #1852 deploy-time heal serves them for records imported earlier. The OAIimport()now verifies the id exists and needscloudfront:GetCloudFrontOriginAccessIdentity. Separately, ELBv2TargetGroupFullNameis now CloudFormation'stargetgroup/<name>/<id>.create()/update()dropped thetargetgroup/prefix, which broke a CloudWatchTargetGroupdimension built from it. A target group created before this fix keeps the old value until its next update or re-import.cdkd importnow records theFunctionUrl/FunctionArnattributes of anAWS::Lambda::Url(issue #3624) --src/provisioning/providers/lambda-url-provider.ts,tests/unit/provisioning/lambda-url-provider.test.ts, and the newtests/integration/import-lambda-url-attributes/fixture.LambdaUrlProvider.import()returned an empty attribute map, and neither attribute can be derived from the physical id. So aftercdkd import(including--migrate-from-cloudformation), a sibling'sFn::GetAtt [<Url>, FunctionUrl], such as a CloudFrontFunctionUrlOrigin, was written to its record as the raw intrinsic, and every latercdkd diff/cdkd deploywarnedUnknown attribute FunctionArn.import()now reads both attributes fromGetFunctionUrlConfig, and returns not-found when no URL config exists. Importing a function URL now needslambda:GetFunctionUrlConfig: without it, that resource's import fails and is reported, where before it was recorded without any AWS call. The deploy-time stale-attribute heal (#1852) re-reads through the same method, so a record imported before this fix heals on the nextcdkd deploy. Measured live: that deploy UPDATEs the dependent resource once with the resolved value, and the deploy after it reports no changes.cdkd diffdoes not preview that update (#3456). Separately, anupdate()that changed nothing returned an empty attribute map, which replaced the recorded attributes. It now returns none, so the recorded values are kept.cdkd local invoke,local run-taskandlocal invoke-agentcorenow fold an upper-casedAWS_REGION/AWS_DEFAULT_REGIONat their entry, not only--region(issue #3622) --src/cli/commands/local-invoke.ts,src/cli/commands/local-run-task.ts,src/cli/commands/local-invoke-agentcore.ts,src/cli/commands/local-state-loader.ts, the three*-from-stateinteg fixtures, plus tests. Each command folded the--regionflag and the region chains it builds itself, but the SDK clients it creates with no region readAWS_REGIONdirectly and raw. SoAWS_REGION=US-EAST-1 cdkd local invoke --from-statewithout--regionfailed outright withFailed to verify state bucket ... HeadBucket (HTTP 400), andapplyRoleArnIfSet's STS client and the--profilecredential resolver were exposed the same way. The three handlers now callfoldRegionOption, aslocal start-apidoes since #2103.local run-taskcaptures the raw env spelling before the fold and passes it to the bootstrap-marker read asrawEnvRegion, so the marker read's second probe now also tries the key anAWS_REGION=US-EAST-1 cdkd bootstrapwrites (cdkd-bootstrap/US-EAST-1.json), including when the stack's own region decides the key. Before this change that read could not reach the state bucket at all.cdkd local start-service/start-alb/start-agentcore/start-cloudfrontnow refuse to forward secrets underCDK_DOCKER=finchon macOS / Windows unlessCDKD_ALLOW_SECRETS_ON_ARGV=1is set, via cdk-local 0.149.2 (issue #3620) --package.json,pnpm-lock.yaml,docs/local-run-task.md,docs/local-start-service.md. These commands run on the bundled cdk-local engine, so #3600's refusal did not reach them and their containers' ECS secrets and decrypted SecureString values went onto thelimactlcommand line without notice. cdk-local 0.149.2 (go-to-k/cdk-local#750) applies the same refusal and the credential warning: an ALB's Lambda targets and eachstart-agentcore/start-cloudfrontcontainer before they start, each service when its first replica boots; with several targets the ones booted before the refused one are already running, and at start-up the run then exits and tears everything down. The opt-in is read under cdkd's ownCDKD_prefix, the namerun-taskuses.Refusals and warnings no longer quote an assembly-chosen stack name or logical id in quotes of cdkd's own (issue #3617) --
src/utils/display-safe.ts(newdisplayStackName), plus the call sites insrc/synthesis/(assembly-reader,synthesizer),src/utils/nested-template-cycle.ts,src/provisioning/providers/nested-stack-provider.ts,src/assets/(asset-manifest-loader,docker-build),src/local/lambda-resolver.ts,src/state/export-index-store.tsandsrc/cli/commands/(orphan,emulator-docker-context,synth,local-invoke-agentcore,local-start-api); an asset's free-form display name renders throughdisplayAssemblyPathinstead, so non-ASCII stays readable. A stack name, a nested-stack or Lambda logical id, an asset id, a region read back from the exports index, and a template-chosen parameter or attribute name were printed inside'...', and the sanitizer passes', so a crafted value could close the quote and write a clause of its own.cdkd orphan's target line, confirmation prompt and success line also bound the stack name and region, which they printed with no boundary at all. Each now renders throughdisplayIdent: a plain identifier bare (Stack MyStack has no templateFile property), anything else as one JSON string, with non-ASCII characters blanked. The sites insrc/cli/commands/import.ts,export.ts,scrub.tsanddiff-recursive.ts, and the same shape in other identifier families (macro parameter keys, S3 lifecycle rule ids, ECS container names), are the remainder, tracked on #3617.--prefer-sdk-routehelp and thecdkd diffrouting example now name a property cdkd really does leave unwired (issue #3615) --src/cli/options.ts,docs/cli-diff.md. Both usedAWS::Lambda::Function:RuntimeManagementConfig, which the SDK provider has written since #1621. So the help's only example was a no-op, the invalid-token error suggested the same token, anddocs/cli-diff.mdshowed a[via CC API: RuntimeManagementConfig]annotation cdkd no longer prints. They now useAWS::ApiGatewayV2::Api:Body, an inline OpenAPI spec cdkd routes through Cloud Control by design, so the example does not go stale on the next back-fill. Doc comments indiff-recursive.ts,provider-registry.tsandrecreate-targets.tscarried the same example and were updated too. No behavior change.AWS::S3::Bucketno longer reads the SDK spellings of the inventory / analyticsDestinationblock (issue #3602) --src/provisioning/providers/s3-bucket-provider.ts, fivetests/unit/provisioning/s3-bucket-provider-*.test.tsfiles, and thetests/integration/s3-analytics-inventory/comments. ADestinationwritten as the nested SDK{ S3BucketDestination: { ... } }shape, or with aBucketalias forBucketArn, deployed before and is now refused before any AWS call by the nested required-member check (#3567), becauseBucketArnandFormatare schema-required. CloudFormation rejects these spellings too: the L1CfnBucket.DestinationPropertydeclares onlybucketArn/bucketAccountId/format/prefix.resolveS3BucketDestinationnow reads only that CFn block. If a replayed pre-#1707 state record still carries one of these spellings, the block has no destination bucket: it is refused, or on the update path warned about and skipped, and no Put is sent. The #1707 record fold stays for the CFn block, since the warn-and-substituteFormatstill reaches it. Same decision as #3585.cdkd local run-task/invoke-agentcorenow refuse to forward secrets underCDK_DOCKER=finchon macOS / Windows unlessCDKD_ALLOW_SECRETS_ON_ARGV=1is set, and warn that finch puts forwarded AWS credentials on a process command line (issue #3600) --src/utils/docker-cmd.ts,src/local/docker-runner.ts,src/local/ecs-task-runner.ts,src/local/ecs-network.ts,docs/local-run-task.md, plus tests. cdkd passes a secret as a value-less-e KEYso its value stays off thedocker runcommand line. finch on macOS / Windows resolves each such flag itself and rewrites it as-e KEY=<value>on the command line of thelimactlprocess it starts, where other local processes can read it, so the documented guarantee did not hold there. A task with an ECSSecretsentry now fails before any image, secret or container work, and an AgentCore runtime with a decrypted SecureString fails before its container starts; both name the secrets and the opt-in.cdkd local start-serviceruns through the cdk-local engine and does not apply the refusal or the warning yet. The AWS credential set cdkd hands the container is still forwarded, with a warning naming the keys, never the values, so finch stays usable for containers that need AWS access; forwarding is what puts--assume-task-role/--assume-roleand--profile/ SSO credentials on that command line. Detection matches the binary's basename (finch/finch.exe), so a wrapper or symlink under another name is not recognised; finch on Linux is unaffected.cdkd local run-task/cdkd local invokenow refuse secret names that would inject code into a credential helper written in a scripting language (issue #3599) --src/utils/docker-cmd.ts,docs/local-run-task.md, plus tests. The template chooses the image, so it chooses whichdocker-credential-*helper the container client execs for the pull, and that helper inherits the client's environment. A secret namedCLOUDSDK_PYTHON_ARGSreacheddocker-credential-gcloud, whose wrapper expands it unquoted onto the Python command line, so it could run code as the operator. The denylist now also covers bash'sSHELLOPTS/PS4and theBASH_FUNC_exported-function prefix; Python'sPYTHONPATH,PYTHONHOME,PYTHONUSERBASE,PYTHONPYCACHEPREFIX,PYTHONPLATLIBDIR,PYTHONWARNINGSandBROWSER; the CA bundlesREQUESTS_CA_BUNDLE,CURL_CA_BUNDLE,HTTPLIB2_CA_CERTSand the TLS key logSSLKEYLOGFILE; Node'sNODE_OPTIONS,NODE_PATH,NODE_COMPILE_CACHE,NODE_EXTRA_CA_CERTS,NODE_TLS_REJECT_UNAUTHORIZED; Ruby'sRUBYOPT,RUBYLIB,GEM_PATH,GEM_HOME,RUBYGEMS_GEMDEPS; Perl'sPERL5OPT,PERL5LIB,PERLLIB,PERL5DB; OpenSSL'sOPENSSL_CONF,OPENSSL_MODULES,OPENSSL_ENGINES; gcloud'sGCE_METADATA_HOST/_ROOT/_IP; and the wholeCLOUDSDK_prefix, since every gcloud property is an environment variable.CLOUDSDK_AUTH_ACCESS_TOKENis exempt from that prefix and still forwarded. A colliding secret is dropped with the existing warning; the operator's own value of each variable is untouched.cdkd local start-agentcoreandcdkd local start-cloudfrontnow refuse an asset-manifest path that escapes the assembly, like every othercdkd localcommand (issue #3597) --package.json(cdk-local^0.149.2->^0.149.3),src/local/docker-image-builder.ts, plus tests anddocs/local-emulation.md. The bundled engine now contains every manifest path it reads. That covers the Docker build context of every image it builds (thestart-agentcorecontainer arm, a container Lambda behind astart-cloudfrontfunction URL origin, thestart-service/start-albemulator), the--watchsoft-reload source, which also refuses an absolute value outside the output directory, the AgentCore code bundle, and the<stack>.assets.jsonlookup. It also warns about BuildKit passthrough paths outside the assembly on each image it builds.start-cloudfrontserves an absoluteBucketDeploymentsource (thecdk synth --no-stagingshape) only from a folder inside your project, with a warning, and never serves its hidden files. Thelocal invoke/start-api/invoke-agentcorebuilds now hand the engine the app's output directory, so acdk.Stageimage's../asset.<hash>still builds. Asource.executablethere is now announced once per command line, not twice. cdkd keeps its own pre-engine check until the engine's refusal wording is display-safe (cdk-local#758, #3652).cdkd driftreports a baseline position it could not certify as not compared, not as drift (issue #3595) --src/cli/commands/drift.ts(newpartitionUncertifiedBaselineChanges, newuncertifiedBaselinecause, the--acceptrefusal wording),src/deployment/secret-redaction.ts(newisUncertifiedBaselineMaskPosition),src/analyzer/drift-calculator.ts(newequalModuloMask),docs/cli-drift.md, a new unit file and a re-pinnedsecrets-array-nestedinteg stop. Where the recorded drift baseline holds the redaction mask***because cdkd could not pair a secret reference with the value AWS reported (issue #2852), no live value ever equalled it, so the resource readdrifted(exit 1) on every run. When the mask is the only difference at that position, the resource is now listed undernotComparedwith the causeuncertifiedBaselineand a detection-only run exits 2; any other difference there -- an edited neighbouring value, a changed length or order -- and every change elsewhere on the resource are still reported as drift.--revertsends AWS's own value at such a position instead of refusing the resource when a real change shares its property. ANoEchocustom-resource mask (issue #2274) is unchanged.--accept's refusal of a masked position now says that only a deploy that changes the resource re-captures the baseline, instead of a bare "Re-deploy to refresh it".Containment refusals and absolute-path warnings no longer quote the value the assembly chose in quotes of cdkd's own (issue #3590) -- the subject of each refusal (
Metadata['aws:asset:path']=,templateFile=, an asset manifest'sfile=,source.path=,source.directory=,Stack metadata file, an inlineHandler), the nested-template chain and cycle messages, thecdkd synth --verbosesymbolic-link refusal, and the absolute-path, whole-assembly and Stage-root warnings insrc/synthesis/,src/assets/,src/local/lambda-resolver.ts,src/utils/nested-template-cycle.ts,src/provisioning/providers/nested-stack-provider.ts,src/local/ecs-task-runner.tsandsrc/cli/commands/(diff-recursive,export,local-invoke,local-invoke-agentcore,synth,emulator-docker-context), plus the not-found refusals for a Lambda asset directory and an AgentCore code bundle and the nested-template and metadata-file read failures. These printed the value inside'...', and the sanitizer passes', so a crafted value could close the quote and write a clause of its own. Each now renders throughdisplayAssemblyPath(go-to-k/cdkd#3509): a plain path bare (Metadata['aws:asset:path']=../../etc/passwd which ...), anything else as one JSON string.cdkd import's two sites are the remainder, tracked on #3590.SDK clients built outside
AwsClientsnow use explicitly supplied credentials (issue #3588) -- newsrc/utils/ambient-client-defaults.ts(ambientClientDefaults,credentialFingerprint), the ~82 client constructions in the SDK providers (DocumentDB and Neptune included),cloud-control-provider.ts, the synthesis context providers,macro-expander.ts, the asset publishers,publish-assets.ts/state-migrate.tsandrole-arn.ts, plusintrinsic-function-resolver.ts's client caches; tests intests/unit/utils/ambient-client-defaults{,-fence}.test.tsandtests/unit/deployment/intrinsic-cfn-fallback.test.ts. A library caller that installedAwsClientswith an explicitcredentialsobject had every client cdkd builds for itself -- provider, context-provider and asset-publisher clients, and the source identity of the cross-accountFn::GetStackOutputsts:AssumeRolehop -- sign with the SDK's default credential chain instead. Those clients now carry the activeAwsClients' profile and explicit credentials, inAwsClients' own precedence. The cross-account credential cache and the resolver's per-region client and CloudFormation-fallback caches are keyed by that identity as well, so a second identity is never served the first one's clients or credentials. CLI runs are unaffected: no command passes explicit credentials. Five files (four held by other open pull requests, plus thecdkd localECR puller, whose caller-identity cache needs the same keying) still build the old shape and follow separately; the resolver's account-identity and dynamic-reference value caches are tracked in #3660.AWS::S3::Bucketno longer reads the SDK spellings of four schema-required nested members (issue #3585) --src/provisioning/providers/s3-bucket-provider.ts,tests/unit/provisioning/s3-bucket-provider-never-emitted-spellings.test.ts,tests/unit/provisioning/s3-bucket-provider-lifecycle-rule-keys.test.ts,tests/integration/s3-lifecycle/. A template spellingNoncurrentVersionTransitions[].TransitionInDaysasNoncurrentDays, or a notification item'sTopic/Queue/FunctionasTopicArn/QueueArn/LambdaFunctionArn, deployed before and is now refused before any AWS call by the nested required-member check (#3567), with the message naming the missing CFn member. CloudFormation rejects these spellings too: the L1CfnBuckettypes lack them, and no L2 emits them onAWS::S3::Bucket, so only an escape hatch or a hand-written template reaches them. The provider's alias reads and their #1748 record folds are removed. TheTransitions[]Days/Datealiases, the notificationEventslist and the inventory string booleans are unchanged, because the check does not refuse them.Fn::Selectnow resolves its index, and a state record with a non-string physical id is refused instead of crashing the resolver (issues #3574, #3576) --src/deployment/intrinsic-function-resolver.ts(resolveSelect, newselectIndexPosition/lookupResourceRecord), comment updates insrc/cli/commands/import.tsandsrc/utils/error-handler.ts, two new unit files and a newintrinsics-torture-2arm. TheFn::Selectindex was used exactly as written: aRefto a parameter or anFn::FindInMapindex (both valid CloudFormation) selectedundefinedwith no warning, and a non-numeric string read the list's prototype chain ("constructor"gave theArrayfunction). The index is now resolved and must be a non-negative integer or its decimal string (no leading zero); anything else, a negative index included, is refused with a message naming the value and its source. An integer past the end still warns and yields the{{Fn::Select:<n>:OutOfBounds}}placeholder, unless the index came from a secret, which is refused rather than written into that property value; an operand that is not a two-element list is refused rather than destructured. Separately, a hand-edited or foreign-written state record whosephysicalIdis not a string (or that is not an object) made severalRef/Fn::GetAttarms throw a bareTypeError; the resolver now refuses such a record once, where it reads it, naming the logical id, before any AWS call.Re-adding a DynamoDB GlobalTable replica now waits for a same-named table that is still being deleted in that region, instead of failing (issue #3569) --
src/provisioning/providers/dynamodb-globaltable-provider.ts(addReplica, newwaitForRegionalCopyReleased/isReplicaCopyExistsRefusal) and a new unit file. Deleting a GlobalTable waits only until each cross-region replica has left the source table'sReplicas[], and the replica region's own copy staysDELETINGfor minutes after that. A replica-add of the same name in that window was refused ("one or more replicas already existed as tables"). A reverse-replacement rollback of a multi-region table re-creates the old table under its old name straight after deleting it, so it hit this every time and left the table missing.addReplicanow reads the replica region on that refusal: while the copy isDELETINGit waits (up to ~15 minutes, interruptible) and retries, up to 3 attempts in total. A copy in any other state, or a probe that fails, re-throws AWS's refusal untouched. cdkd never removes a table it finds there.Cognito user pools: adding
WEB_AUTHNwithWebAuthnFactorConfiguration: MULTI_FACTOR_WITH_USER_VERIFICATIONto a pool already atMfaConfiguration: ONnow deploys (issue #3562) --src/provisioning/providers/cognito-provider.ts.update()used to sendUpdateUserPoolbeforeSetUserPoolMfaConfig. AWS refuses to addWEB_AUTHNto a pool atONuntil its factor configuration isMULTI, so the edit failed, although CloudFormation deploys it. For that shape alone, the update now reads the live MFA configuration and sendsSetUserPoolMfaConfigfirst. If theUpdateUserPoolbehind it then fails, the previous MFA configuration is put back and the original error surfaces. A failed read keeps the usual order. Lowering MFA fromONwhile addingEMAIL_OTPstill fails, atomically, because CloudFormation rolls that edit back too.A context key named
__proto__is resolved and saved instead of ending in a no-progress refusal (issue #3522) --src/synthesis/context-providers/index.ts,src/synthesis/context-store.ts,src/cli/options.ts. The manifest'smissinglist isJSON.parsed, so a row keyed__proto__arrives as an ordinary key.ContextProviderRegistry.resolvewrote its answers onto a{}literal, where that assignment ranObject.prototype's setter and DROPPED the value (success, unknown-provider and failure arms alike);ContextStore.savemerged into the parsedcdk.context.jsonobject the same way, so the key never reached the file either. The app then asked for the same key again and synthesis refused with "Context resolution made no progress", advice that could not help for a lookup that had succeeded. The registry now collects ontonullPrototypeRecord()and the store merges withdefineOwnKey, so the key is stored, written and passed back to the app as an own key.parseContextOptions(src/cli/options.ts) had the same{}literal, so-c __proto__=xwas silently dropped too; it now builds ontonullPrototypeRecord()as well. CDK never emits this key itself, so the manifest path takes a hand-edited or hostile assembly.A containment refusal no longer wraps an assembly-derived path in quotes of its own (issue #3509) --
src/utils/assembly-path.ts(renderAssemblyPathEscape, newdisplayAssemblyPath). The shared tail of every "resolves to ..., outside ..." refusal printed the resolved path, the symbolic-link target and the assembly directory inside cdkd's own'...'. The resolved path embeds the value the assembly chose, and the sanitizer passes', so a crafteddirectoryName,templateFile, assetfileoraws:asset:pathcould close that quote and write a clause saying the opposite of the refusal. Each path now renders bare only when every character is a visible letter (with any combining accents on it), a digit, or one of/ \ . _ ~ + @ : = -. Anything else renders as a JSON string. Inside that string, an embedded", a curly or fullwidth quote, and any character that draws as a blank are escaped, so none of them can pass for the end of the value. A legitimate path loses its quotes (resolves to /etc/passwd, outside /path/to/cdk.out.); one with a space or any other symbol renders in double quotes. Non-ASCII letters and long paths are shown in full. The command still refuses in every case; only the explanation changes.Stack patterns:
*is the only wildcard, and a star-heavy pattern no longer hangs (issue #3508) --src/utils/glob-match.ts(new),src/cli/stack-matcher.ts,src/synthesis/failed-stages.ts. A stack pattern containing*used to be compiled into a regular expression, in which every other character was regex syntax. So'My.Stage*'also matchedMyXStage1, and'My(Stage*'failed with aSyntaxError, while the same characters in a pattern without*stayed literal. A pattern such as'*a*a*a*a*a*a*a*a*b'backtracked exponentially in the length of the stack name, taking about a minute at 60 characters. Both matchers now share one glob matcher that never backtracks:*matches any run of characters, line terminators included, and every other character is literal. This coversdeploy,diff,destroy,list,synth,publish-assets,scrub, thecdkd localstack resolvers, and the Stage named when a selection comes back empty. A pattern that uses only*matches what it matched before.cdkd local invoke/start-api/invoke-agentcore/start-service/start-albnow REFUSE a Docker asset whose relativesource.directoryescapes the assembly (issue #3503) --src/local/docker-image-builder.ts,src/assets/docker-build.ts,src/cli/commands/emulator-docker-context.ts,src/cli/commands/local-{invoke,start-api,invoke-agentcore,start-service,start-alb}.ts. These commands build container images through the bundledcdk-localengine, which joinssource.directoryonto the manifest directory with no check, so a hand-modified assembly could make any host directory the build context of an image cdkd then runs.cdkd local run-taskwas the only local image build already contained. Behavior change: the three direct builds now go through a shim that judges the path with the sameresolveDockerContextDirectorythe deploy path uses, bound by the app's output directory (acdk.Stage's../asset.<hash>still loads), before the engine runs.start-service/start-albhand the whole run to the engine, so cdkd checks every Docker asset in every stack's manifest at the strategy'sresolveBoots, which the engine calls before any build, at startup and on each--watchreload (a refused reload keeps the previous replicas serving), bound like the engine's own (climbing from an--app cdk.out/assembly-<Stage>to its declaring parent, with a warning). An absolutesource.directoryis judged as the engine joins it, under the manifest directory, and every path is also judged as the kernel resolves it, so<link>/..cannot step out. Still not contained: images built insidecdkd local start-agentcoreandstart-cloudfront, which cdk-local runs end to end, and the directory astart-service/start-alb--watchsoft reload copies (#3597).A rollback-orphan record no reader can use is now refused where a command can write, and dropped where it cannot (issue #3500, PR #3641) --
malformed-resources-bag.ts,deploy-engine.ts,destroy-runner.ts,rollback.ts,import.ts,scrub.ts,diff-recursive.ts. go-to-k/cdkd#3379 validated the CONTAINER; its records were still taken off the cast.isReadableOrphanRecordis the one predicate for a usable row -- a WHOLE-record question, including a NON-EMPTYstate.physicalId, the only handle on a resource cdkd left LIVE and the one field aresourcesentry is not asked for. Behavior change:cdkd deploy,cdkd destroy(both reads),cdkd rollback,cdkd importand a realcdkd scrubrefuse such a record, naming the rows;cdkd scrub --dry-runDROPS it, warns, and reports it in the audited-record refusal (exit2).cdkd diffdrops byisPreviewableOrphanRecord, the NARROW half: a non-stringlogicalIdor an empty or non-stringstate.physicalIdgoes, a tornproperties/attributesmap STAYS previewed -- and the preview now WARNS naming the row, at every node it reaches with an adoption preview, thatcdkd deployrefuses the record over it; the TOP-LEVEL stack also exits3, so a clean--failrun never precedes a deploy that cannot start. The quietest shape read asundefinedfield by field, socdkd destroyprinted a row with a field missing and deleted the resources and record once approved. Two more fixes ride along, both reachable with every row usable:cdkd scrubno longer loses one row's secret needles to another row sharing itslogicalId, which could save a plaintext; andcdkd destroy's pre-confirmation banner is sanitized -- the orphan listing AND the resources list above it -- so a stored id or type cannot forge a row, fake the tally, or redraw the lines the y/N answers. An absentorphansfield stays absent. Not in scope: a readablepropertiesmap's CONTENTS.Resolver and template-parser log lines no longer print an
Fn::Selectindex, anFn::Splitdelimiter, a state-record physical ID or a template logical ID raw (issue #3479, partial) --src/deployment/intrinsic-function-resolver.ts,src/analyzer/template-parser.ts. These values could carry terminal control characters, line terminators or bidi overrides, and several lines printed them as written: theFn::Selectout-of-bounds warning and its debug line, theFn::Splitdebug line, the VPCIpv6CidrBlockslines, the ServiceDiscoveryHostedZoneIdandDescribeLaunchTemplatesfailure warnings (including the AWS error text, which echoes the ID), and the template validator'sResource <id> is not an object/missing Typeerrors plus its condition-exclusion debug line. Each now renders through the display sanitizer, which maps those characters to spaces. An ordinary value prints exactly as before. Tests:tests/unit/deployment/resolver-operand-physical-id-control-chars.test.ts,tests/unit/analyzer/template-parser-control-chars.test.ts.An
AWS::EC2::Volumeis now deleted with EC2DeleteVolumeinstead of through Cloud Control (issue #3455) --src/provisioning/ec2-volume-delete.ts(new),src/provisioning/cloud-control-provider.ts. The Cloud Control delete handler for this type can take a snapshot of its own, and it did so on 2 of the 35 Cloud Control volume deletes in this project's test account's CloudTrail for the 90 days to 2026-09-24, both right after cdkd'sDeletionPolicy: Snapshotfinal snapshot. Both times the handler deleted the volume while its snapshot was still pending, the volume stayed indeletingfor more than 15 minutes, and cdkd's delete gave up after its 900-second wait (on 2026-09-19 that failed the rollback of a failed deploy). The extra snapshot carried no cdkd tag, so nothing recorded it and it kept billing. Every volume delete that goes throughCloudControlProvidernow callsDeleteVolumeand waits until the volume is gone, with the same 15-minute limit. A volume that is alreadydeletingis waited on rather than failed.InvalidVolume.NotFoundgoes through the same region-checked already-deleted handling as before. The delete is refused, before any call, if the EC2 client and the Cloud Control client are set to different regions. Therollback-deletion-policy-snapshotanddeletion-policy-snapshotintegration fixtures now assert that no Cloud ControlDeleteResourcerequest exists for their volumes, and the rollback fixture's cleanup now deletes every snapshot of its volume, tagged or not.Resolver messages no longer print a template's type name or malformed
Fn::GetAtttext raw (issue #3441) --src/deployment/intrinsic-function-resolver.ts. A resourceType, a parameterType, a string-formFn::GetAttoperand or a non-literalFn::GetStackOutputRoleArnfrom a hand-written or migrated template could carry terminal control characters, and several resolver refusals and warnings printed that text as written: the physical-ID fallback's two refusals and two warnings, the placeholder-account refusal, the unserved-attribute refusal,Invalid Fn::GetAtt format, theRoleArnshape echo, the nested-stack secret-coercion refusal, and the unrecognized SSM parameterTypewarning. Each now renders through the resolver's display builder, which strips control characters and line or bidi overrides and masks recorded secrets. An ordinary type name prints exactly as before. Three renders stay raw because they are reached only after an exact match against cdkd's own type names. Tests:tests/unit/deployment/resolver-type-name-control-chars.test.ts.Pasteable
cdkdcommands move out of prose quotes and off bare placeholders, and a source fence reports three shapes that put them back (issues #3436, #3307) -- newscripts/check-pasteable-command-shapes.ts+ its test,src/cli/commands/{deploy,destroy,drift,export,state}.ts,src/deployment/deploy-engine.ts,src/provisioning/providers/s3-tables-provider.ts,src/state/malformed-resources-bag.ts. #3499 gave the repo one command BUILDER; a grep for its NAME misses a site spelling its gate differently, so the fence keys on the SHAPE: acdkdverb plus an interpolation inside a prose'...'span, a span built from two or more holes, or a bare<placeholder>with words after. The tree reports zero NON-EXEMPT findings and the test asserts that, so a new site fails by name; oneexport.tsrefusal is EXEMPT for a follow-up PR, and the exemption goes stale when that lands. For users: a bare<stack>before a flag is two shell REDIRECTIONS -- pastingcdkd events prune <stack> --allbeside a file namedstackcreated--allbefore the binary ran. Placeholders are quoted now.cdkd drift's three remaining prose remedies and its untracked-properties plan note give the stack AND the region on a labelled line under one shared predicate -- closing #3307's last three sites and its--stack-regionrequirement. Its legacy region-less refusal names the stack on itsStack:line and in itsMigrate with:command only for a plain identifier; otherwise noStack:line, a quoted'<stack>'hole and a reason clause.state.ts's threeUsage:lines andcdkd drift's revert summary print a QUOTED'<stacks...>'placeholder — Commander's[stacks...]is a bracket expression that expands to a one-character filename when pasted;cdkd destroy --helpnamescdkd events prune '<stack>' --all, that command's real arity.cdkd orphanrefuses the rest of a record it could not read, instead of saving it (issues #3350, #3345, #3344, #3388) --src/cli/commands/orphan.ts,src/state/malformed-resources-bag.ts,src/analyzer/orphan-rewriter.ts. Before, only the resource map,outputsand each surviving resource'spropertieswere checked. A surviving resource entry that was not an object was saved reshaped ("abcdef"as per-character keys, a number as a record with no physical id). An unreadableattributesmap, or an unreadableorphanslist or record, was saved as it was. All of these are now refused at the load,--dry-runincluded, with the same ways out as thepropertiesrefusal. A sibling'sRef,Fn::GetAttorFn::Subof an orphaned record that cannot be read is now reported as unresolvable. Before, it was resolved toundefinedorx-undefined.--forceno longer reads an unreadableattributescache, where a list or string used to supply values for keys it did not hold. The state and outputs refusals now name the region the record is listed under, and for a legacy record listed with no region they name its S3 object instead of acdkd state showcommand that cannot read it.The SSM parameter and S3 bucket providers, and the log group cleanup warning, now render the names, physical ids and logical ids in their own message prose display-safe (issue #3269) --
src/provisioning/providers/ssm-parameter-provider.ts,src/provisioning/providers/s3-bucket-provider.ts,src/provisioning/providers/logs-loggroup-provider.ts, newtests/unit/provisioning/provider-prose-display-safe.test.ts. Issue #3136 made the pasteableaws ...command in four of these messages safe, but the same value was also written into the sentence beside it unchanged, so a terminal escape,U+0085, a Unicode line separator or a bidi override in a template-supplied parameter or bucket name reached the terminal in the same line as the hardened command. Each such value now goes throughdisplaySafewhere it is interpolated, across both files: the partial-create cleanup warnings, thecdkd importrefusal for an ARN-shaped SSM id, the delete / update / create log lines, the lifecycle-rule warnings, and the value a malformed analytics or inventory destination refusal quotes (whichJSON.stringifyalone did not make terminal-safe). TheAWS::Logs::LogGrouppartial-create cleanup warning (src/provisioning/providers/logs-loggroup-provider.ts) had the same raw prose copy beside its hardened command; it is sanitized too, and that warning now goes through the caller's secret masker, which it never reached before, with the masker also threaded into its command so a secret-bearing log group name suppresses the command. Where a masker is available it runs beforedisplaySafe, so a secret that contains a characterdisplaySaferewrites is still masked. A clean value renders exactly as before.A recorded secret whose own leading or trailing space sits at the edge of an export name or state key is now detected and masked (issue #2890) --
src/deployment/outputs-export-alias.ts, plus tests. The secret scan used to read only the key with its edge whitespace trimmed, so a secret such as' abcd'at the start of' abcd-x'matched nothing: the name printed asabcd-x, the secret minus its space, in deploy warnings andcdkd scruboutput, and the deploy published it as an export alias. The scan now reads the key both trimmed and untrimmed. Masking runs over the untrimmed key and the printed text is its trim, so the name prints as***-x, and the deploy refuses the alias. A sub-floor secret that is the whole key, spaces included, is now masked where it was withheld. Names that hold no such secret print exactly as before. Only\p{Zs}edges were affected: a tab or newline at a secret's edge was already deleted rather than trimmed.cdkd importnow drops the drift baseline of a resource it refuses to capture one for, including a resource a selective import leaves in place (issue #2872) --src/cli/commands/import.ts,docs/import.md,tests/unit/cli/import-deployed-parameters.test.ts. When an import refused theobservedPropertiesbaseline of a record it did not re-import (one preserved by a selective merge, refused by this run or by an earlier one), it skipped the capture but left whatever baseline the record already carried. That baseline was read back against the samepropertiesthe refusal distrusts, and a record written by an older cdkd could hold a decrypted secret there. Both refusal arms now remove it, so a refused resource always ends with no baseline;cdkd driftdoes not compare it until a deploy that creates or updates it, or a re-import that proves it, restores one.A
Datein a resource's recorded AWS readback (LastModified,CreationDate, ...) is persisted as its ISO timestamp instead of{}on resources that record a dynamic-reference secret (issue #2427) --src/deployment/secret-redaction.ts. The secret-redaction value walk rebuilt every object it visited, and aDatehas no own entries, so whenever the resource's secrets map was non-empty (a stack that resolved at least one{{resolve:...}}reference) theobservedPropertiesdrift baseline got{}at that field: a permanent phantom difference incdkd drift, and a valuecdkd drift --revertcould push. The same walk runs overproperties,attributesandoutputs, andcdkd drift --acceptandcdkd scrubreach it too. The walk now keeps an unmodifiedDate(exact prototype, a real date value, no own keys) by identity (isOrdinaryDate), after scanning its ISO form through the string arms so a recorded secret spelled as a timestamp is still replaced. Every other non-plain object is still rebuilt and scanned, sinceJSON.stringifypersists a class instance's own fields and returning one untouched could persist a plaintext the scan would have redacted. A{}already persisted is not repaired in place;cdkd state refresh-observedre-captures the baseline.cdkd local run-task/cdkd local invokenow refuse secret names that would hijack a podman, nerdctl or finch client, not only a docker one (issue #2188) --src/utils/docker-cmd.ts, the collision warnings insrc/local/docker-runner.ts/ecs-task-runner.ts/ecs-network.ts,docs/local-run-task.md, plus tests.DOCKER_CLIENT_ENV_KEYS(from #2186) listed only the docker CLI's variables, so underCDK_DOCKER=podmana resolved secret namedCONTAINER_HOSTreached the client's environment and could redirect it -- theDOCKER_HOSThijack, one binary over. The list now also covers podman / containers tooling (CONTAINER_HOST,CONTAINER_CONNECTION,CONTAINER_SSHKEY,CONTAINER_PROXY,PODMAN_CONNECTIONS_CONF,CONTAINERS_SSH_CONF,CONTAINERS_CONF, the registries / storage config files and their_OVERRIDEs,CONTAINERS_POLICY_JSON,STORAGE_OPTS,CONTAINERS_HELPER_BINARY_DIR,REGISTRY_AUTH_FILE,DBUS_SESSION_BUS_ADDRESS,NOTIFY_SOCKET), nerdctl (CONTAINERD_ADDRESS,CONTAINERD_NAMESPACE,NERDCTL_TOML,NERDCTL_LOG_FILE,CNI_PATH,NETCONFPATH,ROOTLESSKIT_STATE_DIR), finch's Lima layer (SSH, which Lima execs in place ofssh), the base directories they fall back to (XDG_CONFIG_HOME,XDG_RUNTIME_DIR, and on WindowsAPPDATA,PROGRAMDATA,PROGRAMFILES,LOCALAPPDATA), andAWS_ECR_CACHE_DIR. The JSDoc states the rule for additions; behaviour toggles such asSTORAGE_DRIVERandCONTAINERD_SNAPSHOTTERstay off. A colliding secret is dropped with the existing warning, now worded for any container client; the operator's own value of each variable is untouched.cdkd local start-apinow folds an upper-casedAWS_REGION/AWS_DEFAULT_REGIONto its canonical spelling, not only--region(issues #2103, #1843) --src/cli/commands/local-start-api.ts,tests/integration/local-start-api/, plus tests. The command folded the--regionflag at its entry but read the two env vars raw, soAWS_REGION=US-EAST-1 cdkd local start-apihandedUS-EAST-1to every Lambda container it started, to the--assume-roleSTS client, to the API server's service-integration region and to the WebSocket container'sAWS_REGION. AWS SDK endpoint resolution is case-sensitive, so a handler's own SDK calls could fail to sign, andCN-NORTH-1resolved the commercial partition. The handler entry now callsfoldRegionOption, as the other region-taking commands do, and each of the four read sites folds again at the read, matchingcdkd local invoke. Credentials forwarded into the container are still copied verbatim.Cognito user pools:
WebAuthnFactorConfigurationis supported, and the MFA pre-flight now coversWEB_AUTHNand the pool's live sign-in policy (issues #2064, #2051, #1924, #2675) --src/provisioning/providers/cognito-provider.ts,package.json(SDK floor^3.1135.0).WebAuthnFactorConfigurationis now sent asWebAuthnConfiguration.FactorConfigurationand read back; it used to be an unhandled property that routed the pool through Cloud Control.WEB_AUTHNas a first auth factor besideMfaConfiguration: ONis refused before any AWS call unless it isMULTI_FACTOR_WITH_USER_VERIFICATION: AWS rejects thatSetUserPoolMfaConfigonly afterUpdateUserPoolhas landed the sign-in policy. When a template does not declarePolicies.SignInPolicy, the update reads the pool's live list first (DescribeUserPool), because an omitted sub-key keeps the live value. So deleting the block while turning MFA ON, withEMAIL_OTP/SMS_OTPstill live, is refused too. A failed read falls back to the template. A bareEmailAuthenticationMessage/Subjectnow warns that it enablesEMAIL_OTP(the block has no enable switch), and the Schema-replacement advice no longer callsDeletionProtectionunmeasured: omitting it does not reset it (measured).The CloudFormation cross-stack fallback now uses explicitly supplied credentials (issue #1983) --
src/deployment/intrinsic-function-resolver.ts(getCfnClient), new cases intests/unit/deployment/intrinsic-cfn-fallback.test.ts. TheFn::ImportValueListExportsandFn::GetStackOutputDescribeStacksfallback client was built from the SDK's default credential chain alone, so a library caller that constructedAwsClientswith an explicitcredentialsobject had those reads made under a different identity. The client now carries the activeAwsClients' credential configuration (profile and explicit credentials), as the resolver's other lookups already do. CLI runs are unaffected: no command passes explicit credentials, and--profile/--role-arnalready reached this client through the environment. The other SDK clients built outsideAwsClients(providers, context providers, the cross-account STS hop) are tracked in #3588.A multi-region
cdkd deployno longer lets concurrently deploying stacks issue AWS calls against each other's region (issue #1981) --src/utils/stack-aws-scope.ts(new),src/utils/aws-clients.ts(runWithStackAwsClients,getAwsClients),src/utils/aws-client-defaults.ts,src/cli/commands/deploy.ts, and every provider that readprocess.env['AWS_REGION'].cdkd deployused to point the process-global AWS clients andprocess.env.AWS_REGIONat each stack's region in turn. At the default--stack-concurrencyof 4, stacks in two regions overwrote each other's setting, so a provider call made after anyawaitcould create a resource in the other stack's region, where cleanup keyed by the stack's region never finds it. Each stack now runs inside anAsyncLocalStoragescope that carries its own clients and region.getAwsClients(),awsClientDefaults()(which every SDK client insrc/is built from) and the newambientRegion()read that scope first. The deploy no longer changesprocess.env.AWS_REGIONper stack. Commands that never enter a scope behave as before.cdkd destroy, which removes stacks one at a time, keeps its own per-stack switch.cdkd local invoke/start-api/run-task/invoke-agentcorenow pull a FIPS or dual-stack ECR image (issue #1855) --src/local/ecr-puller.ts,tests/unit/local/ecr-puller.test.ts,docs/local-run-task.md,tests/integration/local-run-task-from-state/. An image on<acct>.dkr.ecr-fips.<region>.<suffix>,<acct>.dkr-ecr.<region>.on.awsor<acct>.dkr-ecr-fips.<region>.on.awsfailed withno basic auth credentials.docker loginalways targeted the plain<acct>.dkr.ecr.<region>.<suffix>host thatGetAuthorizationTokenreports asproxyEndpoint, whiledocker pulltargeted the host the template named, and Docker keys its credential store on the hostname verbatim. The login now targets the pull reference's own host. The plain form is unchanged and still prefers the reportedproxyEndpoint. The ECR token authenticates on all four host forms. The image host's case fold is now ASCII-only: a full Unicode fold turned a Kelvin sign (U+212A) intokbefore the region guard ran, so<acct>.dkr.ecr-fips.us-<U+212A>east-1.amazonaws.comparsed as the regionus-keast-1; it is now refused.start-service/start-alb/start-agentcorepull through cdk-local's puller and are tracked in #3655.Deploy pre-flight now refuses a nested property block that omits a member CloudFormation requires (issue #1802) --
src/provisioning/nested-required.ts,src/provisioning/nested-required.generated.ts,src/provisioning/provider-registry.ts,scripts/gen-nested-required.ts,scripts/refresh-cfn-schemas.mjs,tests/fixtures/cfn-schemas/*.json. cdkd used to forward such a block, and where the service API replaces the nested struct the omitted member was silently reset: anAWS::ECS::ServiceDeploymentCircuitBreakerwithoutRollbackturned a live rollback off, while CloudFormation refuses the template. The check reads each type's schemarequiredlists (a newnestedRequiredfixture section, keyed by property path), refuses only a PRESENT block, skips anything behind an unresolved intrinsic, and runs only for the 78 types CloudFormation was measured to enforce these lists on. Types it was measured NOT to enforce them on (AWS::Logs::LogGroupaccepts a tag with neitherKeynorValue, among 11), the 3 EMR types, and types not measured are left unchecked. Aggregated into one error, with no override flag.A rollback that re-creates a DynamoDB GlobalTable whose recorded
GlobalSecondaryIndexesis malformed no longer sends or records a cross-region replica's override for the index it did not build (issue #1741, second instance) --src/provisioning/providers/dynamodb-globaltable-provider.ts(newwithdrawReplicaIndexBlocks), its replay-create unit tests, and an opt-in cross-region arm ontests/integration/rollback-replay-effective-props(CDKD_INTEG_MULTI_REGION=1). On a reverse-replacement rollback, a state record with a malformed index block is re-created with NO indexes (the #1544 downgrade). Each cross-region replica'sGlobalSecondaryIndexesoverride was still sent with the replica-add and kept in the recorded properties. AWS accepted the call, but the re-created replica holds no override, since the index does not exist. So the record described a valuereadCurrentStatecan never read back, the phantom-drift class of #1724. The omit now withdraws every replica's index block: nothing is sent, the pre-flight scan no longer reports on it, no per-index autoscaling target is requested for it, and it is left out of the recorded properties. A warning names each cross-region replica whose override was withdrawn. The first instance, the orphanedAttributeDefinitionsprune, shipped earlier.
Recently Implemented (2026-09-23):
- A Lambda layer ARN in ANY partition downloads under
cdkd local invoke, and the docs said otherwise for four weeks after the upstream fix (issue #3551, PR #3557) -- No code change.docs/local-invoke.md,src/local/lambda-resolver.ts,src/cli/commands/local-start-{alb,cloudfront}.ts, three tests.docs/local-invoke.mdtold users an ARN inaws-cn,aws-us-gov,aws-euscor the four ISO partitions "passes cdkd's parse and then fails at the AWS call, because the download rebuilds the ARN with a hardcodedawspartition". Measured on the installed engine:fetchLayerContentUrlstrips only the:<version>suffix from the caller's ARN and hands the remainder toGetLayerVersionCommandasLayerName, so the partition survives. go-to-k/cdk-local#575 closed 2026-08-27 and shipped in cdk-local 0.147.13 three minutes later -- and it reached USERS immediately, because cdkd declarescdk-localas a plain runtime dependency with nobundledDependencies, so a consumer resolves the RANGE, and the range then in effect (^0.147.7) already admitted it. Only this repo's pinned tree lagged, to the 0.148.4 bump on 2026-09-12. Why it went unnoticed for four weeks: SIX sites citedcdk-local@0.147.7and the bundle chunkdist/local-studio-BBtUAVNy.js, a content-hashed name regenerated every release, so each claim read as verified while being uncheckable. The SEVENTH -- the user-facing page, the only one that mattered to a reader -- cited nothing at all, which is why grepping for the stale citation could not find it and searching for the stale CLAIM was required. The six were rotted line numbers behind claims that still hold, each re-measured one at a time. Every site now names the package VERSION and the SYMBOL NAME, and carries thegrepthat re-checks it; the symbols are notexported, they are findable because the bundle is unminified. cdkd synth MyStackprints that stack's template, where a multi-stack app previously had no way to get one template onto stdout at all (issue #3550, PR #3559) --src/cli/commands/synth.ts,docs/cli-{synth,reference,deploy-tuning}.md.synthdeclared no positional, so a stack name was silently ignored undercommander@12and becameerror: too many argumentsundercommander@14(go-to-k/cdkd#3534). That was the visible half. The real gap: the stdout gate read the size of the WHOLE ASSEMBLY, so with several stacks cdkd printed no template and offered no way to narrow --cdk synth MyStackprints one, and piping it intojq/yqis ordinary use of a drop-in CDK CLI. Behavior change:synthaccepts[stacks...](physical names, CDK display paths, wildcards -- the matcherdiffuses); a selection of exactly one prints its template; any other prints none and lists the SELECTED ids, matchingcdk'sSupply a stack id (...); a pattern matching nothing is refused naming what the app holds, and a ZERO-stack assembly is now refused rather than reported asFound 0 stack(s)at exit 0. What a name narrows, stated because the first draft of this entry claimed it narrowed nothing: annotations (--strict/ error aborts now scope to the selection), the--verboseper-stack template dump, and the summary -- which countsN of Mwhen a pattern narrowed, sinceFound 1 stack(s)on a three-stack app is false. What it does NOT narrow is synthesis itself. Measured inaws-cdk@2.1142.0:Toolkit.synthrunssynthAndMeasure(...)and only thenassembly.selectStacks(...), and the context loop readsassembly.manifest.missingfor the whole assembly -- so a stack argument does not change whichcdk.context.jsonlookups run, and a case fences that.resolveAssetCodeDirectorytakes every parameter in an options bag, so the positional swap between the assembly-derived base and the attacker-chosen asset path no longer compiles (issue #3549, PR #3556) -- No user-visible change.src/local/lambda-resolver.ts,src/cli/commands/local-start-api.ts. The function decides where a Lambda'sMetadata['aws:asset:path']really lives forcdkd local invokeandcdkd local start-api, and positionals left FOUR adjacentstringparameters with TWO transposable pairs.(manifestDir, assetPath)is the pair go-to-k/cdkd#3544 removed from the Docker twin, here with a worse sink: swapping them changes which directory is resolved and which is contained, for a value that is then BIND-MOUNTED into a running container.(assetOutdir, logicalId)made the containment boundpath.resolve('<logicalId>')under the cwd. Required-ness answered only a DROP -- and that DROP was real, a call passing the outdir fourth and stopping there used to compile while binding it tologicalIdand defaulting the bound tomanifestDir, silently reinstating go-to-k/cdkd#3493's B1. Both call sites updated, behaviour identical, old spelling nowerror TS2554(verified by compiling it). Stated precisely, because the first draft of this entry overstated it: a bag makes the swap UNORDERABLE, not inexpressible --{ manifestDir: assetOutdir, assetOutdir: manifestDir }still compiles, andlocal-asset-code-path-containment.test.tsis what catches it, by driving both call sites against a STAGE manifest where the two directories differ. Measured: that spelling reds three cases there. The newasset-code-resolve-options-shape.test.tspins only what no behavioural test sees -- the arity, and that the interface sits IMMEDIATELY above its function, four of these doc blocks having detached from the declaration below them by an edit inserting between the two.resolveDockerContextDirectorytakes EVERY parameter in its options bag, so swapping the assembly-derived base with the attacker-chosen directory no longer compiles (issue #3544, PR #3547) -- No user-visible change.src/assets/docker-build.ts,src/local/invoke-agentcore-watch-loop.ts.resolveDockerContextDirectorynow takes every parameter in its options bag, not only the trailing ones go-to-k/cdkd#3537 moved. It had a SECOND transposable pair and that one was not cosmetic:manifestDiris the assembly-derived base a relative value resolves FROM,directoryis the attacker-chosensource.directory, and both arestring, so swapping them compiled and changed which directory was resolved and which was contained -- where go-to-k/cdkd#3537's pair only changed a word in a warning.buildDockerImage's localcontextDirectoryclosure takes a bag for the same reason ((directory, sink)were also two adjacent strings).resolveFileAssetSourcePathkeeps its leading positionals deliberately: its second parameter is aFileAsset, so the swap does not typecheck, and the invariant is "no transposable adjacent same-typed pair" rather than "both twins look alike". Verified by compiling the old positional call, which now produces twoerror TS.cdkd local start-serviceandcdkd local start-albnow announce a Docker asset'ssource.executablebefore running it, instead of executing an assembly-chosen command line silently (issue #3540, PR #3553) --package.json,pnpm-lock.yaml,docs/{cli-deploy-safety,local-emulation}.md. Those two build their ECS container assets through the bundledcdk-localengine, several layers past any cdkd-side boundary, so go-to-k/cdkd#3539's warning could not reach them anddocs/cli-deploy-safety.mdnamed the gap explicitly. Fixed upstream in cdk-local 0.149.1 (go-to-k/cdk-local#747) atbuildDockerImage, which is the ONE spawn point every path shares -- sostart-agentcoreandstudio, which this issue did not name, are covered too, andcdklusers get the line rather than only cdkd's. Behavior change: one warning at default verbosity, never a refusal, namingexecutable[0]and the argument COUNT; the full argv stays at--verbose, because a build script is notdocker buildand its own--token/--passwordmatches no argv masker's flag shapes -- rendering them at warn level would be a NEW leak introduced by a warning about leaks. Announced once per distinct command per run rather than once per build: aDesiredCount: 3service rebuilds per replica and again per crash-loop restart, and repeating a ~450-character paragraph that often buries it. The key is the command AND its working directory, since./build.shunder two asset directories is two different scripts. Deduping the line does not dedupe the work -- every spawn still happens.- The asset resolvers take their trailing values as an options bag, so a TRANSPOSITION is a compile error rather than a wrong word in a warning (issue #3537, PR #3543) -- No user-visible change.
src/assets/{asset-manifest-loader,docker-build,file-asset-publisher}.ts.resolveFileAssetSourcePathandresolveDockerContextDirectorytake their trailing values as an options bag (FileAssetResolveOptions/DockerContextResolveOptions) instead of positionals. go-to-k/cdkd#3532 madeassetOutdirrequired so that DROPPING it is a compile error; every remaining value is also astring, so a TRANSPOSITION still compiled --(…, assetOutdir, assetId)on the Docker resolver bound the asset id tosinkand printedcdkd will cdkd-asset-<hash>, and(sink, assetOutdir)on either bound a prose clause to the containment bound. The same PR deletedFileAssetPublisher.publish's unusedprofilefor that reason; this is the shape that was left. Six call sites updated, behaviour identical, and both old positional spellings are now type errors (verified by compiling them). - Nineteen commands now REFUSE an extra operand they used to ignore (issue #3534, PR #3545) --
package.json,pnpm-lock.yaml,src/cli/region-options.ts. cdkd moved tocommander@^14becausecdk-local@0.149.0requires it, and commander 13 flippedallowExcessArgumentsto false. The split is by ARITY, not by whether a positional is declared: only a VARIADIC one absorbs extras. The six taking NONE --synth,bootstrap,gc,state list,state info,state migrate-- now answererror: too many arguments for 'synth'. Expected 0 arguments but got 1.; the thirteen taking EXACTLY ONE (rollback,events,events prune,import,export, the twostateread commands, and sixlocalones) reject a SECOND; the fourteen variadic ones,deploy/destroy/diff/listamong them, are unaffected. The one-argument half is the likelier trip:cdkd deploy A BIS valid, so passing several stack names is trained behaviour, andcdkd state show A Bnow fails where it used to silently answer aboutAalone. The extra word was never read in any of these, so the new error is the correct behaviour and the old silence was the bug -- butcdk synth MyStackIS valid upstream, and that parity gap, masked by commander 12 rather than created here, is tracked in #3550. Re-derive the sets by walkingbuildProgram()forregisteredArgumentsand each argument'svariadicflag, NOT by descending only to leaves:eventscarries a positional AND a subcommand, so a leaf-only walk reports twelve. Also verified against the 34 option builders: no multi-char short flag (which commander 13 throws on at construction), nostoreOptionsAsProperties, noHelpsubclass, noallowUnknownOption/passThroughOptions;--concurrency -5parsed the same on both majors. cdkd local start-albandcdkd local start-cloudfrontnow contain a Lambda'sMetadata['aws:asset:path'], like their siblings (issue #3534, PR #3545) --package.json,pnpm-workspace.yaml,docs/local-emulation.md. Those two reach Lambda code through the bundledcdk-localengine rather than cdkd's own resolver, so go-to-k/cdkd#3494's guard did not apply to them: an assembly naming a directory outside the output directory had it bind-mounted at/var/taskwith no refusal and no warning, whilecdkd local invokeandcdkd local start-apiwarned about the identical value. Behavior change: the bump tocdk-local@0.149.0carries go-to-k/cdk-local#744, which applies the same rule in that engine -- a relative escape is refused, an absolute one is accepted with a warning naming the directory, and the bound is the asset outdir rather than the manifest directory, so a Stage's../asset.<hash>still resolves. Verified against the installed package, not the version number: the guard's text is present innode_modules/cdk-local/distand the old unguardedisAbsolute(assetPath) ? assetPath : …spelling is gone.cdk-localis also excluded from pnpm'sminimumReleaseAge, with the trade recorded at the entry -- it is first-party and reviewed in its own repository before the tag, and the cost is that a compromised publish would install immediately.cdkd deploy/cdkd publish-assetsnow warn about the asset-manifest values they forward verbatim, instead of passing them silently (issue #3497, PR #3539) --src/assets/manifest-passthrough-warnings.ts,src/assets/{docker-build,asset-publisher}.ts.cdkd deploy -a <dir>consumes a pre-synthesized assembly with no CDK subprocess in between, and most of an asset manifest is handed to Docker or S3 as written. Behavior change: three new warnings at default verbosity, none of them a refusal. (1) A BuildKit passthrough whose host path resolves outside the assembly --dockerFile,dockerBuildContexts,dockerBuildSecrets,dockerBuildSsh,cacheFrom,cacheTo,dockerOutputs-- names the field and the path, and says WRITE when the value carries adest=key (the FIELD does not decide:cacheFromcan carry adest=). (2)source.executablenames the command line before it runs, because a Docker asset may declare one instead of a Dockerfile, so-a <dir>DOES execute code from the assembly and nothing said so. The warn names the command and the argument COUNT only; the full argv stays at--verbose, because the layer's redaction knowsdocker buildflag shapes and not a build script's own secret arguments.cdkd local invokewarns too. (3) An asset destination bucket or ECR repository that is neither default-bootstrap-shaped nor cdkd-managed names itself, sinceredirectFileAssetrewrites only bootstrap-shaped destinations and anything else is uploaded to verbatim. Deliberately NOT a refusal and NOT an opt-in flag, per the maintainer decision recorded on the issue: this is CDK-CLI parity territory, the attack presupposes someone who already controls the assembly and therefore the account, and a default-deny plus flag reduces who holds the capability without protecting who uses it. Docs:docs/cli-deploy-safety.mdgains a section saying a pre-synthesized assembly is trusted input.- An AWS error that quotes a name assembled around a short secret no longer prints the secret, and two
Fn::GetAttarms now refuse a masked persisted value (issues #3171, #2936) --src/deployment/intrinsic-function-resolver.ts. When anFn::Subbuilt a dynamic-reference name or anFn::GetAZsregion around a secret shorter than four characters, cdkd printed the name masked in its own words, but the AWS SDK's text quoted the name back in the clear: the rethrownParameterNotFound/AccessDenied/ staging-label miss from a{{resolve:ssm:...}}or{{resolve:secretsmanager:...}}lookup, the--verboseretry line of a throttled lookup, and theFn::GetAZsdescribe-failure message. Each now masks the whole name the request carried, through one helper (namedRequestMasks) that returns the masked error clone (class, cause chain and retry classification kept), the masked message and the masking retry logger together. Separately,Fn::GetAtt [<VPC>, CidrBlock]andFn::GetAtt [<Events rule>, Arn]read a value out of the persisted record without recording a masked read, so a***stored there would have been sent to AWS; both now record it, and the deploy refuses the consumer as it does for every other masked attribute. Tests:tests/unit/deployment/intrinsic-resolver-name-argument-log-twin.test.ts,tests/unit/deployment/intrinsic-getatt-noecho-note.test.ts.
Recently Implemented (2026-09-22):
cdkd deployandcdkd publish-assetsnow consume acdk synth --no-stagingassembly, instead of failing with anENOENTon a path that exists nowhere (issue #3532, PR #3536) --src/assets/{asset-manifest-loader,docker-build,absolute-asset-path-warning}.ts,src/utils/assembly-path.ts. Underaws:cdk:disable-asset-stagingCDK writes each asset's absolute SOURCE directory intofiles[*].source.pathanddockerImages[*].source.directory.resolveAssemblyPathjoins withpath.join, which ignores a leading separator, so an absolute value was folded INTO the output directory and died atstatSyncor BuildKit, with nothing naming the flag. Behavior change: an absolute value is now HONOURED, warned about when it falls outsideStackInfo.assetOutdir, silent when inside; a value resolving onto the outdir ITSELF is accepted with its own line rather than silently; a RELATIVE escape still refuses. The warning names the sink, per caller: the destinations3://bucket/key, the BuildKit context, asource.executable's working directory, or the AgentCore watch loop'sdocker cp. Security note, because the first draft of this entry got it wrong: the old fold was a boundary, not just a bad message -- no spelling of an absolute path reached a file outside the assembly (/etc/passwdfolded inside and ENOENTed,/../etc/passwdand a symlink were refused). Honouring one removes that: an untrusted assembly can now have any readable directory packaged to a bucket it names, gated by the warning. Accepted because upstreamcdk-assetshonours the field with no containment at all.assetOutdirbecame REQUIRED on both resolvers and both publishers, so dropping it is a compile error (go-to-k/cdkd#3489's own defect). Also fixed:absoluteAssemblyPathEscapewarned about two spellings of ONE directory (/varvs/private/var), crying wolf on ordinary macOS runs. Docs:docs/cli-deploy-safety.md.- Every message in the malformed-state module now treats an empty stack name or region as ABSENT rather than rendering it as
<unrenderable>(issue #3526, PR #3531) --malformed-resources-bag.ts. go-to-k/cdkd#3520 fixed five builders and recorded the other thirteen in its fence asKNOWN_UNFIXED, each asserted to still be broken so the list could not drift. This sweeps them: that list is now empty. Behavior change: a builder handed''renders the no-identity clause and the hole template instead ofState for '<unrenderable>' ('<unrenderable>')and--stack-region '<unrenderable>', a flag that selects no record at all. No caller can deliver''today (cdkd statethrows on a falsy region,cdkd exportnormalises at its call,namedCliRegionfloors it), so this finishes stating a contract rather than fixing an output anyone sees. Each builder stopped spelling its ownState for ${shellQuote(safeStackName(...))}clause and its owncdkd state show ...tail, takingstackClauseandinspectCommandinstead: byte-identical for a present identity, and the absent form for''.stackClausegained an optional KIND qualifier for the one message whose subject is not a plain stack (State for nested stack child '<name>'), so its word order is unchanged and the no-identity arm keeps a single spelling.namedEntriesClausewas widened the same way. The fence is per builder and per AXIS, so a half-swept builder -- one that normalised the region and not the stack name -- reds by name and axis rather than passing. - An empty stack name or region in a malformed-
resourcesmessage is now treated as ABSENT rather than rendered as<unrenderable>(issue #3520, PR #3525) --malformed-resources-bag.ts.safeStackName('')andsafeRegion('')both yield the<unrenderable>stand-in, so a builder handed''rendered('<unrenderable>')in its clause and--stack-region '<unrenderable>'in the command it tells the reader to paste -- a flag that selects no record at all.undefinedwas always right: the clause and the flag are both dropped, which is what a legacyversion: 1record needs. No caller can reach this today (cdkd statethrows on a falsy region,cdkd exportnormalises at its call), so this states a contract rather than fixing an output. Behavior change: five exported builders now normalise'' -> undefinedat their boundary, so''renders identically toundefinedthere. Three more are already correct by their exactness gate, which rejects'', and are fenced for that property rather than trusted; the remaining thirteen spell their own identity clause and are NOT covered — they are named in the fence rather than filtered out of it, and swept by go-to-k/cdkd#3526. At the boundary, not in the shared helpers:malformedOrphanResourcePropertiesRefusalMessagemeasured a helper-only fix as a half-fix, and a floor instackClause/inspectCommandwas tried here and removed: three unboundaried builders do reach them, but each passesexact ? x : undefinedand''is never exact, so they already receiveundefinedand the floor reddened nothing. The fence is per builder for the same reason -- an aggregate case stays green for a fix applied to only some -- and the builder list is derived from the source, so one added later is classified as fenced or as known-unfixed instead of joining the ungated side silently. - A malformed-
resourcesmessage no longer buries its pasteablecdkd state showmid-sentence, and three refusals no longer name a target whose own name could forge the command beside it (issue #3516, PR #3517) --malformed-resources-bag.ts.malformedStateDetailbaked a fully-substitutedcdkd state show <stack> --stack-region <region> --jsoninto the end of a shared diagnosis half that every consumer appends prose after, so the one runnable command sat a single space from the next clause and a line-select paste handed it that clause as positional arguments. Behavior change 1:cdkd deploy's refusal, the shared write refusal and the read-onlyresourceswarning now END on the read command; the two DESTROY refusals emit one command per line instead (Inspect the record:thenDrop the record:), because one line cannot both end on the read and keep thecdkd state orphan <stack> --stack-region <region>template last with no substituted region after it. Behavior change 2, and INDEPENDENT of the placement change: every message that offers the destructive remedy as a TEMPLATE now requires its identifiers to be shell-plain (isPasteableIdent) as well as exactly rendered, so a stack name or region spelling one of cdkd's own labels no longer takes the naming arm. Three messages therefore WITHHOLD a target they previously named: both destroy refusals andcdkd destroy's divergent-record-region refusal. Each still names how to find the record; only the DELETING command degrades to a template.cdkd orphan's properties refusal is deliberately NOT gated here -- it substitutes rather than templating, so the same change would withhold the drop command from a legacy record whose name merely needs quoting; tracked by go-to-k/cdkd#3523. The divergent-region exposure predates this issue and is closed here opportunistically. Residuals: go-to-k/cdkd#3518, go-to-k/cdkd#3519. cdkd local invoke/cdkd local start-apinow REFUSE a relativeaws:asset:paththat escapes the assembly, and WARN on an absolute one that points outside it (issue #3494, PR #3529) --src/local/lambda-resolver.ts,src/cli/commands/local-start-api.ts,src/utils/assembly-path.ts. Both resolvers spelled a Lambda's code path asisAbsolute(p) ? p : resolve(cdkOutDir, p), so..folded exactly asjoindoes and left the assembly silently. The result is bind-mounted read-only at/var/task(a layer's at/opt) into a container running handler code the SAME assembly supplied, andcdkd local invokeforwards the caller's credentials -- so a hand-modifiedcdk.outcould hand the attacker's handler any host directory. Measured before the change: both sites returned a throwaway directory beside the assembly verbatim as the path to mount. Behavior change: a relative path resolving outside the app's outdir is now refused; nothing a realcdk synthemits has that shape. An ABSOLUTE path is still honoured, and that asymmetry is deliberate:cdk synth --no-staging(aws:cdk:disable-asset-staging) makes CDK emit the asset's absolute SOURCE directory rather thanasset.<hash>(measured, aws-cdk-lib 2.268), so refusing it would reject the output of a documented CDK CLI flag. It now prints a warning naming the directory when it leaves the outdir, and stays silent when it does not. Containment binds toStackInfo.assetOutdir, NOT the manifest's directory: a Lambda under acdk.Stagelegitimately carries../asset.<hash>. Both sites share ONE spelling,resolveAssetCodeDirectory, throwing through each caller's ownwrapErrorso each keeps its error class; the absolute arm's verdict comes from the newabsoluteAssemblyPathEscape, besideresolveAssemblyPathso the rule is not re-spelled. Verified accepted against a real 2.268 synth with a Stage, a nested stack, aCode.fromAssetfunction and a layer.- A refusal raised inside a CDK Stage is fatal again, and a stack dropped with a failed Stage is reported as such (issue #3482) --
src/synthesis/{assembly-reader,synthesizer,failed-stages}.ts,src/cli/stack-matcher.tsand four commands; ten suites.AssemblyReader.getAllStacksrecursed into acdk:cloud-assemblyartifact inside atrywhosecatchonly WARNED, so every refusal raised while reading a Stage -- the absoluteaws:asset:pathtripwire, an escapingtemplateFileor asset manifest, an unreadable template, the side-file refusal -- degraded to advice and silently dropped every stack under it, though each aborts the command at the top level. Thetrynow wraps ONLY the read of the Stage's ownmanifest.json-- its SCOPE is the classifier, no error subclass or message match -- so a Stage whose directory was never synthesized still warns and the run continues, while everything else propagates, re-raised naming the innermost Stage and keeping the origin's frames. The surviving tolerance is now honest:readAssemblyreturns the Stages that failed,Synthesizercarries them onSynthesisResult.failedStages, and the sharedrenderNoStackMatchappendsStage MyStage failed to load, so stacks under it are missing from this list rather than missing from the app: <reason>-- to the no-match message AND to the zero-stack case, which used to answerMultiple stacks found: .. Every assembly-chosen name in these messages -- a Stage path, adirectoryName, each stack name -- renders throughdisplayIdent, and a read failure names only its own error code, so none can forge a cdkd-sounding clause.SynthesisResult.failedStagesis now REQUIRED, which is breaking only for code that hand-builds that literal; reading the result is unaffected.cdkd scrubandcdkd destroystill word their own empty-selection message (#3507). - A nested-stack row whose logical id collides with
Object.prototypeis followed instead of dropped or silently mis-answered (issue #3480) --src/synthesis/assembly-reader.ts,src/cli/commands/{diff-recursive,export,import,diff,deploy}.ts; two suites. Every nested-template index maps a template logical id to the child template's path, and four of the five builders assigned onto a{}literal.JSON.parseyields__proto__as an ordinary own data property, so a row spelled that way ranObject.prototype's setter: the assignment DROPPED the key, the lookup then answeredObject.prototype, andcdkd deploy/diff/export/importrefused, naming[object Object]rather than following the row. All four now build ontonullPrototypeRecord(), joiningNestedStackProvider.indexGrandchildTemplates, converted by #3448. This also WIDENS validation: a dropped top-level row was invisible torefuseMalformedNestedTemplateTrees, so the cycle / absolute-path / containment pre-flight silently skipped that row's whole subtree. And a second defect is closed: a{}literal also ANSWERS a lookup for a logical id never indexed when the name collides with the prototype (toString,valueOf,constructor), which six readers test withif (!index[id])acrossNestedStackProvider,diff-recursive.ts,import.tsandexport.ts. An inherited member is truthy there, so a row carrying noMetadata['aws:asset:path']lost its "no template path" refusal and failed later and unreadably -- though still CLOSED:path.join/path.resolve/readFileSynceach refuse a non-string withERR_INVALID_ARG_TYPE, so[object Object]is what the message renders, never what was opened. The??fallbacks take a null-prototype record for the same reason.defineOwnKeywould have left that second half, which is why the issue's prescription was reversed. - Every assembly-, manifest- or template-derived value
cdkd list,cdkd orphan, synthesis and macro expansion render is now display-safe (issue #3479) --src/cli/commands/{list,orphan}.ts,src/synthesis/{synthesizer,macro-expander}.ts,src/synthesis/context-providers/index.ts; five suites.displaySafemaps C0 / DEL / C1,U+0085, the line terminators and the bidi overrides to a space, neither quoting nor truncating, so a legitimate value is byte-identical. Previously astackName,displayName, context key, transform name,Parameterskey, template logical id oraws:cdk:pathreached the terminal raw, so a crafted value could erase cdkd's own line withESC [ 2 Kplus CR and forge one. The most reachable sites needed no error at all:cdkd listwrites one display id per line to stdout at default verbosity, socdkd list -a ./cdk.outover someone else's assembly printed it; so docdkd orphan'sTarget:/ success lines. The--long/--show-dependenciespayloads are covered too: measured per character, neitherJSON.stringifynoryamlescapes DEL, C1,U+2028or the bidi overrides, so their encoder is no boundary. A region takesasciiOnly; AWS text echoing a template value back takesdisplayAwsMessage, which caps it — includingformatError'sCaused by:line, previously sanitized but UNBOUNDED, so it printed the whole text one line under a cut marker.cdkd orphan's unquoted identity lists takedisplayIdent:displaySafeTRIMS, so a planted entry would otherwise render byte-identical to the genuine one said to be missing.cdkd listdoes NOT route throughdescribeStack: its field order is inverted anddisplayIdentquotes, which a payload cannot take. Rows still open on the issue: the CDK-annotation display, thenested-stack-messages.tspasteable remedy,assembly-reader.ts's per-slot helper choice, anddiff-recursive.ts/export.ts(held by open PRs).
Recently Implemented (2026-09-21):
- Eleven more composite keys are encoded rather than separated, so a planted name can no longer make one thing answer for another (issue #3496) --
src/state/record-keys.tsplus nine files acrosssrc/analyzer/,src/deployment/,src/provisioning/providers/andsrc/state/, two suites plus one new one. These are the keys OUTSIDE the record class #3323 closed. The consequential ones:lambda-vpc-deps.tsdropped a delete-dependency EDGE, so a Lambda could be deleted without waiting on the subnet or security group holding its ENI;idempotency-token.ts's memo key is an IDENTITY, and a collision hands a second create the first's token -- the file documents that failure itself; the resolver's CFn-outputs cache could answer one stack'sFn::GetStackOutputwith another stack's outputs,s3-replication-purge-gap.ts's warn key joined FREE TEXT; and three provider attribute caches keyedphysicalIdagainst an attribute name with a PRINTABLE separator, read BEFORE the attribute switch, so one table's or proxy's attribute could answer another'sFn::GetAtt. Moving those three keys also had to move their READERS: two RDS providers evict by scanning for a leadingphysicalId, andinjectiveKeyPrefixderives that prefix from the same encoder so the two cannot drift. Every surviving exemption reason was rewritten as what is CHECKED rather than what a value is expected to be.secret-redaction.ts's two keys stay separated, on REACH rather than layering. Their false justifications are fixed instead:CROSS_STACK_KEY_SEPARATORclaimed no AWS name can contain a NUL while its halves are TEMPLATE literals, and what makes it safe is not injectivity but that a collision POISONS the slot into a refusal. Thedeploy-engine.tsrows stay open on the issue -- two PRs held that file. - An assembly-supplied path can no longer escape the assembly directory (issue #3489) -- new
src/utils/assembly-path.ts, applied at 19 sites acrosssrc/synthesis/,src/assets/,src/local/,src/provisioning/providers/nested-stack-provider.tsandsrc/cli/commands/(diff-recursive,import,export,synth,local-invoke,local-start-api,local-invoke-agentcore). Every path cdkd built from a Cloud Assembly was joined onto a directory and used unchecked, andcdkd deploy -a <dir>consumes a PRE-SYNTHESIZED assembly, so nothing upstream validated one. AtemplateFileof../../../home/<user>/.aws/credentialswas read from outside the assembly and, where it parsed, became the template cdkd deploys. The sharpest case was the asset manifest, which names the DESTINATION bucket too: asource.pathof../../../home/<user>/.awswas zipped and uploaded with the caller's own credentials. AHandlerof../../x.handlerwrote the inlineCode.ZipFilebody to an arbitrary host path.resolveAssemblyPathrefuses a candidate resolving outside, lexically and throughrealpath(3); its doc comment carries the rest. The absolute-path tripwire is KEPT and worded apart, and every refusal renders values throughdisplaySafe(go-to-k/cdkd#3277). Asset paths RESOLVE against the manifest's directory but are CONTAINED within the app's outdir -- atdeploy,publish-assets,local run-taskandlocal invoke-agentcorealike:cdk synthstages a Stage's assets into the app root, sosource.pathis../asset.<hash>by design, and binding to the manifest's own directory refused every Stage asset. Residuals: go-to-k/cdkd#3494, go-to-k/cdkd#3497, go-to-k/cdkd#3482.cdkd synth --verbosealsolstats the path it writes, refusing a symbolic link. Tests: eleven new files, cases in eight existing ones, and a newstage-assetsinteg fixture. - Thirteen message modules now build the
cdkdcommand they tell you to paste through one gate, and print it on its own labelled line (issue #3436) -- newsrc/utils/pasteable-command.ts, 13 message modules acrosssrc/cli/commands/,src/deployment/,src/assets/,src/provisioning/andsrc/utils/, tests. A command printed inside a prose'...'span is pasted WITH those quotes, so an interpolated value carrying'closed the wrapper and the rest ran as shell -- measured in #3363 and reproduced independently by the maintainer over 324 arms; quoting the value does not help, the wrapper is what inverts.pasteableCommand(verb, args, extraFlags)sanitizes each value and compares it against the RAW one, caps it, then shell-quotes it or prints a QUOTED hole ('<stack>') -- never the altered spelling, which addresses a different record, nor nothing, which resolves the ambient default. It also returns WHY it withheld a value, andcdkd state refresh-observed's legacy refusal renders its sentence from that reason, so the explanation cannot disagree with the hole. A flag and its value go or stay together, and a value starting with-or, forcdkd deploy, holding*//becomes a hole too. Messages end in labelled lines rather than naming the command mid-sentence.shellQuoteandcommandHolemoved into the new module, re-exported fromsrc/state/lock-contention-message.tssincesrc/utils/**imports nothing fromsrc/state/**.scripts/check-docs-error-strings.tsnow collapses a\nescape like the whitespace it renders as, without which a trailing command line is unanchorable. Thecdkd driftsites are #3307 / #3486; the source fence, the remaining own-copy builders and the messages still printing a bare<hole>are the follow-up on #3436. - A state record whose
orphanslist is not a list is no longer read as "no orphans", andcdkd rollbackno longer rewrites it (issue #3379, PR #3492) --malformed-resources-bag.ts,deploy-engine.ts,destroy-runner.ts,rollback.ts,import.ts,diff-recursive.ts,scrub.ts.parseStateBodyvalidates only the record root and the schema version, so a hand-edited"orphans": "abc",5,{}ornullsurvived the load with no reader askingArray.isArrayfirst. Three outcomes: a string was WALKED one character at a time andorphansAfterRollbackreturned a list of those characters, whichcdkd rollbackSAVED; shapes counting zero read as an empty list, socdkd diffpreviewed no adoption andcdkd destroydeleted the record without reporting resources an earlier failed deploy left live in AWS; the rest threw a bareTypeErrornaming nothing. Behavior change: a command that READS this container on a path that can write state now refuses the record before it acts --cdkd deploybefore any resource operation,cdkd destroyat its first read and its under-lock re-read,cdkd rollbackbefore any replay,cdkd importbefore it carries the container into the record it writes,cdkd scrubon a real run -- with a non-retryable error naming the container and how to inspect the record.cdkd orphannever reads it and still runs, carrying it verbatim; the destroy refusal namescdkd state orphanas the way to drop a record deliberately. Read-only paths continue:cdkd diffandcdkd scrub --dry-runtreat it as empty, warn, and list an(orphans container)row in the diff'sunreadableset, which--failand--jsonsee. A well-formed record is unaffected, and a record with NOorphanskey stays untouched -- the ordinary shape for a stack that never had a failed deploy -- with no empty list materialized over it. Entry-level damage inside a readable list is unchanged (go-to-k/cdkd#3344). cdkd diffnow exits 3 over a record whose repaired containercdkd deployrefuses, instead of 0 (issue #3335) — an unreadable resourcepropertiesmap (#3317) and an unreadableoutputsbag (#3189) are repaired to empty for the preview and warned about, while the deploy refuses the record. When the template declared nothing in the damaged container the repaired{}produced no change rows, socdkd diff --failexited 0 and a CI step gating on it passed over a record the next deploy stops on; where the template did declare something,--failexited 1 for a change the deploy will not make and exit 3 stayed unset. Both repairs now put one reason per REPAIR PASS and damaged CONTAINER on the node'sblocking— a record with both containers torn reports two, and so does one damaged in both the load and the orphan splice — the mechanism #2943 already uses for a refused rollback-orphan adoption, socountBlockingraisesDeployRefusalPreviewError(exit 3) ahead of--fail. The ids are deliberately NOT added to--json'sunreadable, which means "dropped from the diff" — these rows are previewed. A torn map on an ADOPTED orphan record counts too, from the second repair after the splice. TOP-LEVEL only, as a conservative choice: a nested child keeps its repair warning and adds no reason, because the deploy skips an unchanged nested-stack row and an attribute-only UPDATE never diffs the child, so a reason there would promise exit 3 over a deploy that succeeds. Changed:src/cli/commands/diff-recursive.ts,src/cli/commands/diff.ts,docs/cli-diff.md,docs/cli-reference.md,docs/state-management.md,.claude/rules/layout-cli-diff.md. Residual: #3512 (theresourcesbag and its entry drop carry the same gap), #3339 (stand-in rows in--json), #3513 (the nestedoutputswarning does not say the deploy refuses).cdkd scrubcan no longer read one producer's state record as another's (issue #3323) -- newsrc/state/record-keys.tsplus eight files acrosssrc/state/,src/cli/commands/andsrc/utils/, four suites. Eight composite keys identified a producer by joining two attacker-influenced halves with a NUL: five from the issue and its file sweep, three more from a review round that grepped the OTHER source spelling. Three are inscrub.tsand all feed the cross-stack pre-pass that decides whether a producer still holds a plaintext secret: the read memoizer served record X's state for a query about Y, the chain walk SKIPPED a hop whose coordinate collided with the seed and reported no secret expression over a producer that has one, and the verdict cache misfiled a verdict. Each could end a run atNo plaintext secrets foundover surviving plaintext. The route is the consumer's own TEMPLATE:Fn::GetStackOutput'sStackNameis gated only on being a non-empty string before the resolver records it, and a JSON string may contain a NUL. All eight now go throughproducerRecordKey(astack+regionrecord) or the newproducerCoordinateKey(astack+export-namecoordinate) -- two names over one privateJSON.stringifyencoding injective over ALL string pairs, in the new leaf and re-exported. The fence now sweeps ALL ofsrc/for six NUL spellings and requires every hit to be a helper call or a listed exemption CARRYING ITS COUNT, so neither a one-spelling sweep nor a whole-file exemption can be the last word again; the remainder is #3496. Per-site reachability (they are NOT equally exploitable;listStacksnever was), the bound corrected from #3308, and the fences' limits: docs/design/3323-composite-record-keys.md.cdkd state's two S3 listings takeEncodingTypetoo, closing the last site of issue #3313 --src/cli/commands/state.ts, one suite. #3331 fixed nine of the ten listings; this one was deferred because another PR held the file, and that PR has merged. Both listings here READ the key they return: the state-file walk collects keys every latergetStateaddresses, and the bootstrap-marker walk SLICES the key into a region name and hands it back toGetObject-- so a carriage return turned into a line feed by the XML round trip names the wrong region and reads the wrong object. The marker key is decoded BEFORE its defensivestartsWithre-check, or the guard would test a different string from the one that gets used. The suite'sHELD_BY_ANOTHER_PRlist, which recorded this deferral with an assertion that FAILED once the site was wired, is now empty and asserted empty: a deferral stays legitimate while another PR holds a file, but it must name that PR and come out when it merges, rather than becoming a permanent exemption wearing a temporary label. Removing the entry also promotesstate.tsinto the per-file loop, so it now carries a real positive case.cdkd drift's legacy region-less refusal no longer interpolates a state-key stack name into thecdkd deployit tells you to paste (issue #3307) --src/cli/commands/drift.ts, tests. That refusal built its command fromlistStacks's stack name -- an S3 KEY -- with no sanitizing, quoting or exactness gate, inside a prose'...'. Pasting the span RAN the key, and a name sanitizing ALTERS addresses a different record. A newstackCommandForgates it: sanitize (asciiOnly) and compare against the RAW value, refuse an empty one, cap atSTACK_REF_MAX_CODE_POINTS(a legitimateParent~Child~...still renders), shell-quote, and refuse a leading-or, forcdkd deploy, a*//pattern.stackIdentityLineputs the identity on a gatedStack:line rather than in the sentence: a name carrying a newline above a labelled line FORGES one, anddisplayIdentcloses only that half -- a$(...)name comes back inside its JSON quotes intact. Withheld, the message states the rule. This is the only labelled command linecdkd driftprints, deliberately: the other three sit in blocks that also carry a property path, a resource type, a STATE-WRITE error message or an AWS readback value, which cannot be gated for exactness and still printed. Those keep their command in prose, and the class, with the criterion, is #3436 -- but they DID change for every user: the stack name came out of the quoted command, soRun 'cdkd drift MyStack --revert'is nowRun 'cdkd drift --revert' for this stack,re-run 'cdkd drift MyStack --revert'is nowre-run 'cdkd drift --revert' for this stack, andRun 'cdkd state refresh-observed MyStack'is nowRun 'cdkd state refresh-observed' for this stack. A grep matching the old spelling breaks;cdkd drift --revertwith no positional argument auto-detects a single stack.- A hand-modified Cloud Assembly can no longer forge terminal lines inside the messages cdkd prints while refusing it (issue #3277) --
src/synthesis/assembly-reader.tsandsrc/synthesis/stack-messages.ts. Every value those two rendered was chosen by whoever wrote the assembly -- a manifest key, astackNameproperty, a template key, aMetadata['aws:asset:path']string, adirectoryNamejoined into a path, and thereadFileSync/JSON.parsefailure text quoting a hand-fed file -- and all of them were interpolated raw. C1 bytes,U+0085, the Unicode line terminators and the Trojan-Source bidi overrides pass through ordinary string building, andformatErrorsanitizes only an error'scause, never itsmessage, so a crafted value appended what read as a second, cdkd-authored line. Synthesis is the FIRST layer the CLI reaches, so the absolute-aws:asset:pathrefusal -- whose own text says the input was hand-modified -- was the message a user was asked to trust. All ten render sites inassembly-reader.ts(thrown refusals anddebug/warnlines alike) and the side-file refusal instack-messages.tsnow go throughdisplaySafe.displaySafeand notdisplayIdent: it neither truncates nor quotes, so a legitimate long asset path still renders in full and byte-identically, and no well-formed assembly changes behaviour at all.processStackMessages' CDK-annotation display is still raw and is recorded as an OPEN residual on go-to-k/cdkd#3479: an annotation legitimately carries newlines, whichdisplaySafemaps to spaces, so it needs a helper that does not exist yet. This is the third copy of the class go-to-k/cdkd#3243 closed insrc/cli/commands/diff-recursive.ts. Tests:tests/unit/synthesis/assembly-reader-display-safe.test.ts(a distinct hostile marker per interpolated value, plus the ordinary-path polarity) and two cases intests/unit/synthesis/stack-messages.test.ts. - A secret assembled into a
{{resolve:...}}token of a service cdkd does not resolve is no longer sent to AWS or written to state (issue #2743, PR #3475) --intrinsic-function-resolver.ts,secret-redaction.ts, gated arms on thesecrets-dynamic-refinteg. AnFn::Sub/Fn::Jointhat puts a resolved secret where a reference's service name goes ({{resolve:${Pw}}}over asecretsmanagervariable) assembles{{resolve:<plaintext>}}. The unsupported-service arm warned and left that token in the value, so a deploy went green while handing it to the provider as the property's value, and redaction's rule for text inside a{{resolve:...}}span spared it intostate.json(properties,observedProperties,outputs, the exports index), the rollback journal and the deploy summary'sOutputs:lines. Behavior change: on the template route such a token is refused with a masked, non-retryableRefusing to resolve {{resolve:***}}error. In a resource property the resource fails before its provider is called and the deploy rolls back; in an Output the output is skipped under a warning and the deploy exits 0 (--strict-getattfails it). A token carrying no secret still warns and stays as written, and so does any token in PERSISTED text (cdkd drift, the rollback replay, a producer's stored output), which AWS already holds. Independently, state redaction spares text inside a{{resolve:...}}span only when it namessecretsmanager,ssmorssm-secure: a recorded plaintext in any other span is replaced by its expression, socdkd scrubheals a record an older release leaked into (the result is unresolvable text, not plaintext; deploy the corrected template first, seedocs/troubleshooting.md). Cost: a look-alike token whose text coincides with a recorded secret of four or more characters is refused when the secret resolved first, else rewritten in state like other text.
Recently Implemented (2026-09-20):
- A stack imported with cdkd 0.290.35 no longer records a decrypted secret as a drift baseline on the next
cdkd deployor re-import (issue #3468, PR #3471) --analyzer/parameter-dependence.ts(new; the walk, moved unchanged),deploy-engine.ts,import.ts,types/state.ts,docs/import.md,docs/state-management.md, a reason-stripping arm on theimport-deployed-parameter-secretinteg. 0.290.35 shipped the unverifiable-parameter baseline refusal (#2854) without the reason field that lets it survive an in-place UPDATE (#3462), so its markers carry noobservedBaselineRefusalReasonand were still cleared by an UPDATE: a code-only Lambda update wrote the deployed secret toobservedProperties. A marker with NO reason is now read fail closed. At the start of every deploy, a reason-less marker on a resource whose template definition reads a declared template parameter -- directly, through a condition, through an attribute of a resource that does, or, in a template that reads one, in a shape the walk cannot classify -- is stamped"unverifiable-parameter": the update keeps it, reads nothing back, and the next state write records the explicit form. An unreadable template stamps all.cdkd importcarries such a marker the same way across a re-import on an unchanged physical id. Pseudo parameters and CDK'sBootstrapVersiondo not count; a reason-less marker on a resource that reads no parameter still clears on UPDATE. Every refusal now records a reason -- the other three import arms write the new value"incomplete-resolution"(no schema bump; older binaries clear it on UPDATE, as before) -- so the cautious reading only ever applies to old records. Cost: an old refusal of that other kind on a resource that reads a parameter stays until replacement or a proving re-import. Known gap: replacing the parameter reference with its literal before the first fixed-binary deploy that writes state hides the dependence. - A
cdkd deployaftercdkd importno longer records a decrypted secret as a drift baseline for a resource whose parameter was deployed over a placeholderDefault, and neither does a re-import (issue #3462) --deploy-engine.ts,import.ts,types/state.ts,docs/import.md,docs/state-management.md, a redeploy arm on theimport-deployed-parameter-secretinteg.cdkd importrefuses the observed baseline of a resource that depends on a template parameter it cannot prove was deployed at itsDefault(#2854), and an UPDATE used to clear every such refusal. Butcdkd deploybinds the sameDefault, so an update that leaves the parameter-bound property alone (a code-only Lambda change) left the deployed value in AWS, dropped the marker, and wrote the readback -- the decrypted secret -- toobservedProperties. The import now records WHY it refused, in a new optionalobservedBaselineRefusalReason: "unverifiable-parameter"besideobservedBaselineRefused(no schema version bump; absent on every older record, where an UPDATE clears the marker as before). For that reason an in-place UPDATE keeps both fields and reads nothing back from AWS, whatever it changed; a replacement (wasReplacedAND a changed physical id) or a fresh CREATE clears them. The same hole one command over is closed too: re-runningcdkd importonce the CloudFormation stack is gone had nothing to compare against, rebuilt the record without the marker and captured the secret; it now carries the refusal across a re-import on an unchanged physical id, and clears it only when that run had deployed parameter values and they proved theDefault. Cost: such a resource has no observed baseline for the life of the physical resource. Not covered: a stack imported with 0.290.35, the one version that wrote the refusal without the reason (#3468); a binary older than the field ignores it. AWS::DynamoDB::Table:StreamSpecification.ResourcePolicyandStreamSpecification.Tagsare now applied and read back, instead of being dropped on the wire (issue #3458) --dynamodb-table-provider.ts, newdynamodb-stream-members.ts. The SDK'sStreamSpecificationhas neither member, so a template granting access to a table's stream deployed green with NO policy attached and no tags, and drift saw neither. Write: once the stream exists, cdkd tags the STREAM arn and then callsPutResourcePolicyagainst it -- at create, on an update-time enable, when either member changes, and AGAIN after aStreamViewTypechange, which replaces the stream: both go to the newly mintedLatestStreamArn, and the deploy fails loudly if only the replaced stream's arn can be read. A member REMOVED from a block whose stream stays is removed, as CloudFormation does; a policy that changes together with the tags is deleted before the first tag call and put back after the last; a disabled stream gets no call. A rollback restores the previous policy and tags. A block whose view type the live stream already has no longer re-sendsUpdateTable. Unreadable member (unresolved intrinsic, wrong shape): refused by position, beforeCreateTableand before any call of an update; a rollback replay ordrift --revertwarns and keeps the live setting instead. Read:cdkd driftcallsGetResourcePolicy/ListTagsOfResourceon the current stream arn, each ONLY when the template declares that member, droppingaws:tags and re-asking a not-yet-consistent answer for a few seconds. An out-of-band policy delete or untag is reported underStreamSpecification, anddrift --revertrestores it. New IAM actions on the stream arn, needed only by a template declaring the member:dynamodb:PutResourcePolicy,GetResourcePolicy,DeleteResourcePolicy,TagResource,UntagResource,ListTagsOfResource.cdkd importno longer records a decrypted secret as a drift baseline when a template parameter was DEPLOYED with a secret reference over a placeholderDefault(issue #2854) --import.ts, newimport-deployed-parameters.ts,docs/import.md, newimport-deployed-parameter-secretinteg.cdkd importtakes no parameter values, so a parameter is recorded at itsDefault; a stack deployed with--parameters DbPassword='{{resolve:secretsmanager:...}}'overDefault: CHANGEME(the ordinary--migrate-from-cloudformationshape, and what a nested stack receives from its parent) left no reference in any template the import read, so the AWS readback -- the decrypted value -- was written toobservedPropertiesinstate.jsonwith no warning. When a CloudFormation stack backs the import (the migration source, each nested child, or on any other import a stack with the cdkd stack's name) cdkd now reads its deployed parameter values withDescribeStacksand compares them with what it bound. Every resource whose properties depend on a parameter it cannot PROVE equal -- a different value, a literal reference, anyNoEchoparameter, one missing from the response, a failed call -- throughRef,Fn::Sub, any nested intrinsic, a (transitive) condition or the resource's ownCondition, plus every resource reading an attribute (Fn::GetAtt) of such a resource, gets no baseline and theobservedBaselineRefusedmarker, and one warning per stack names the parameters. The deployed values are compared only: never bound, logged or persisted. New IAM requirement:cloudformation:DescribeStacks(only when something in the template references a declared parameter); without it the import succeeds, warns with the error name, and refuses every parameter-dependent baseline. Not closed here: an import with no CloudFormation stack of that name (#2868), and the NEXT deploy of such a resource, which clears the marker (#3462).cdkd deployheals a state record that lacks an attribute aFn::GetAttasks for, instead of refusing forever (issue #1852) --intrinsic-function-resolver.ts,deploy-engine.ts, newstale-attribute-heal.ts, the RDS / DocDB / Neptune providers'import(),docs/troubleshooting.md, newstale-attribute-healinteg. A record written before its provider recorded an attribute (AWS::SSM::ParameterArn,AWS::RDS::DBSubnetGroupDBSubnetGroupArn), written under--no-waitwhile aDBInstancehad noEndpoint.Address/Endpoint.Port(go-to-k/cdkd#3077), or holding a pre-#1681 placeholder AppSync ARN, was never rewritten by a deploy that changed none of that resource's properties -- so an addedparam.attrArnoutput hit the*Arnrefusal on every deploy, with a false "not enriched ... file an issue" message. Now, when a resolution is about to fall back to the physical ID (or refuse a placeholder ARN or a DBProxyVpcId), the deploy re-reads that resource's attributes ONCE through its provider's read-onlyimport()(routed by the record'sprovisionedBy), serves the value, and merges it into the record at the state save: only keys the record lacks are added, an empty value is never recorded, nothing else changes, and the resource is not updated. A reference that resolves from state costs no AWS call; a record this deploy (re)wrote, a custom resource and a nested stack are never re-read. A failed read degrades to the previous outcome, and the refusal now names the stale record, the error class and HTTP status (AWS's text stays behind--verbose) and the real remedy.--dry-runreads but writes nothing;cdkd diff/drift/exportnever re-read and point atcdkd deploy.cdkd importof aDBInstancenow recordsEndpoint.*andArn. Design: docs/design/1852-stale-attribute-heal.md.AWS::DynamoDB::Table: a GSI's per-indexContributorInsightsSpecificationis now applied and read back, instead of being dropped on the wire (issue #1782) --dynamodb-table-provider.ts, newdynamodb-contributor-insights.ts. The SDK'sGlobalSecondaryIndexhas no such member, so a template asking for per-index Contributor Insights deployed green with the feature OFF, with no drift read-back. Write: once the index is ACTIVE, cdkd callsUpdateContributorInsightswithIndexNamefor each index whose block changed (at create, index added,Enabledflip,Modechange). A block REMOVED from an index that stays is DISABLED (the rule the table-level block already follows); an index the update removes gets no call. A rollback restores the previous setting. Read:cdkd driftissues oneDescribeContributorInsightsper index, ONLY for an index whose template entry declares the block, and reportsModeonly when the template declares one, so an undeclared index and AWS's default mode are never phantom drift.ENABLING/DISABLINGread as their target (the deploy does not wait for the toggle);FAILEDor a failed read omits the block. An out-of-band toggle of a declared index is reported asGlobalSecondaryIndexesdrift anddrift --revertundoes it. Also fixed, table level: the block is now read by the same reader, soEnabled: 'false'(a CloudFormation-legal string) no longer ENABLES the feature, and an unreadable block (either level) is refused beforeCreateTableand, on an update, announced and left alone -- never read as a default. Also:GlobalSecondaryIndexes/LocalSecondaryIndexesare now compared as unordered sets (leaf-only, so each index'sKeySchemastays order-significant). Real AWS returned a[giA, giB]template as[giB, giA], which apropertiesbaseline reported as whole-list drift; the phantom pre-dates this change.
Recently Implemented (2026-09-19):
cdkd deployrefuses a cyclic nested-template tree before it starts, not at the nested-stack row (issue #3449) --deploy.ts, newsrc/cli/commands/nested-template-preflight.ts, tests,docs/cli-deploy-safety.md,nested-stack-deepinteg arm. #3448 madeNestedStackProviderrefuse a cyclic (or absolute-path, too deep, too large) nested-template tree before the first nested level deploys. That is the earliest point the provider owns, but by then the parent stack had been started: its assets were published, its lock was held, and any of its own resources that do not depend on the nested row could already be created and then had to be rolled back.cdkd deploynow runs the same walk (findNestedTemplateTreeDefect, same rules, same display-safe rendering) right after synthesis, over every stack in the final deploy set including auto-included dependency stacks, and before macro expansion, asset publishing, any lock, any state write and any engine. One malformed stack stops the whole run with exit code 1, under--dry-runtoo; a stack outside the deploy set is not judged. The error is a non-retryableSynthesisErrorending inRefusing to start the deploy; nothing has been published or provisioned.The provider's per-row walk is unchanged and stays as the guard for a nested deploy started by anything other than this command, with its ownNestedStackProvider: ... Refusing to deploy any level of it.wording. The walk is not seeded with the root template's path (StackInfodoes not carry it); a child pointing back at the root template is still refused, one lap later in the rendered chain.cdkd rollback,cdkd destroyandcdkd state destroybuild a destroy-mode nested context that cannot start a nested deploy, andcdkd import --migrate-from-cloudformationrecurses over the real (finite) CloudFormation stack tree, so none of them needed the check.- AWS published 1 writable property the 2026-09-19 schema refresh now carries, so cdkd classifies it instead of dropping it with a warn (PR #3445) --
AWS::BedrockAgentCore::Runtime:PlatformVersion. Changed:tests/fixtures/cfn-schemas/,src/provisioning/property-coverage.generated.ts(regenerated). OnAWS::BedrockAgentCore::Runtime, no SDK provider writes it yet (not yet implemented by cdkd), so pre-flight classifies each as a silent drop and the issue #614 auto-route sends a resource whose template carries one through Cloud Control, which forwards the full property map. Until this refresh the same key post-dated the committed snapshot, so a resource on the SDK route with no other actionable drop and noprovisionedBy: 'cc-api'record warned and dropped it.AWS::BedrockAgentCore::Runtimeis not inSTICKY_CC_MIGRATION_EXEMPT, so an existing SDK-provisioned resource of that type whose template gains one moves to Cloud Control and pinsprovisionedBy: 'cc-api'ONE-WAY. The snapshot marks none of them create-only, socreateOnlyDropsand the record narrowing are unchanged; the capture is top-level-only, so it says nothing about a nested create-only path. cdkd orphan'spropertiesrefusal: three arms an operator could not act on (issue #3439) --malformed-resources-bag.ts, tests. Residuals of #3363, all in the legacy region-less arm. An EMPTY--state-bucketprintedState bucket: ''while the sentence promised that line named the bucket, and disagreed withrecoveryCommandFlags, which emits no--state-bucketfor''; it is now treated as no bucket, and the sentence sends the operator tocdkd state info. Like the empty-stack floor beside it this arm is not reachable through the CLI today --orphan.tscallsverifyBucketExists(), whoseHeadBucketthrows on''before any refusal renders -- and both are kept for the reason the module already records: a later caller inherits the floor rather than the defect. An empty PREFIX keeps its own arm, because it keys a real key space (the object sits directly under/). When a RECOVERY fragment was the inexact value, the command printed--state-prefix '<prefix>'with nothing saying it was a hole (identityWithheldreads the stack name and region only, and the prefix note is suppressed once a prefix is supplied); a new clause names it and says to fill it from the value passed this run, kept separate from the identity clause because that one points at a listing which does not carry a--profile.cdkd state infois quoted again in the prose, as every fixed command name in this module is: unquoted, selecting and pasting the parenthetical INVOKED the real CLI. The inspect clause also floors an empty stack name, the floor its two siblings carry, and the prefix note is its own sentence rather than a second parenthetical.- A template-supplied identifier can no longer redraw your terminal from a cdkd message (issue #3432) --
src/deployment/intrinsic-function-resolver.ts,deploy-engine.ts,src/cli/commands/scrub.ts,src/utils/display-safe.ts. Bug: go-to-k/cdkd#3426 closed this class at the masker and fixed the one render it measured; 19 siblings stayed behind a note whose premise ("CloudFormation requires a static string") answers the SECRET question, not the control-character one. It is false twice over:resolveSubre-entersresolveRefwith unvalidated${...}text, and cdkd reads the template as JSON, so a KEY is only as constrained as the file. Measured: an id carryingESC[2K+ CR redrew five renders, two of them THROWS the user sees at any verbosity. Fix: per-site, not a blanket wrap. 18 render throughdisplayMaskedand their notes are deleted. The 19th is left alone -- reaching it meansresolvePseudoParameter'sswitchMATCHED, so the id is one of its own case labels. Review widened the population twice more (it had been defined by grepping ONE note spelling), reaching every remaining raw identifier render: theParameters/Conditionsname lines,Fn::If's condition renders, the unsupported-intrinsic throw, theAWS::NoValueline, the twodisplay:builders, andDeployEngine.maskedRecordRemedyFor-- which rendered a raw id into itscdkd import ... --resource <id>=command, now WITHHELD for an id outsideisPasteableIdent, since sanitizing would name a different record--forceaccepts. Every site is pinned by its emitted BYTES, one case each. Gate note: all threecdkd scrubshape patterns now match an id whose only unmatched characters were invisible, moving such a unitcount->warn. Also DELETES the AST mask-coverage checker and its suite (1,760 lines) as high-maintenance tooling; go-to-k/cdkd#3426's entry names it as what holds this class, the AST containment fence does now. - An
Fn::ImportValueexport name from a template can no longer redraw your terminal (issue #3426) --src/deployment/intrinsic-function-resolver.ts,scripts/check-resolver-mask-coverage.ts. Bug: go-to-k/cdkd#3408 routed 97 render sites throughdisplayMaskedand fenced that spelling, and the class MOVED one indirection out -- to ten local bindings interpolated later, which a line-shaped scanner cannot see. Measured live on the DEFAULT deploy path at DEFAULT verbosity: an export name carryingESC[2K+ CR emitted awarnAND athrowthat erase and rewrite the line (raw CR 7across both sinks in onecdkd deploy; 0 after). The dynamic-reference names, the attribute name, theoriginbuilders and the parameter-value lines were the same shape. Fix: closed at the chokepoint, not at the sites.maskSecretsForLogis GONE -- the masking answer ismaskSecretsRaw, reachable only frommaskThenStripThenMask, itself reachable only fromdisplayMasked, so no site can bind a bare masker. Also swept, each measured on the same repro: the region-scoped-clients debug line (no sanitizer at all), the invalid-region refusal (stripControlCharsleaves U+2028), theRef <id> not foundwarn (anFn::Subplaceholder name is not validated) and every logical-id render insrc/analyzer/dag-builder.ts, whose dangling-DependsOnwarn was the last live one (displayIdentthere). Messages still name what failed; an ordinary name renders byte-identically. What keeps it closed: the containment is asserted from the AST (one caller each), andMASKERSin the mask-coverage checker now means "masks AND sanitizes", so its walk -- which resolves an interpolated identifier,letincluded, to its declaration -- reports a bare-masker binding wherever it is rendered; no exclusion marker silences it. Cross-account refusal semantics are untouched: class,markNonRetryablemarker and every message's WORDING are unchanged. - A force-exit from
cdkd local run-task/start-apinow NAMES the credentials file it leaves behind (issue #3410) --src/cli/commands/local-profile-credentials-file.ts,local-run-task.ts,local-start-api.ts. Bug: with--profile <p>both commands write a mode-0600 AWS credentials file to a tmpdir and bind-mount it into every container. A double-^Creachesprocess.exit(130), which runs nofinallyand awaits nothing, so the cleanup disposing that tmpdir never fires. The message named only the CONTAINERS -- the artifactdocker psalready finds -- and nothing about the one nothing reports. The file holds live credentials,$TMPDIRis not somewhere anyone looks, and one accumulates per force-quit. Fix: both force-exit arms now append a remedy sentence naming the path.local-start-api.tswas NOT named by the issue and was found by sweeping the class -- its force-exit arm already told the operator about orphan containers and had the same gap. The sentence comes from one shared builder that the--detachnotice adopts too, so the three sites cannot drift into three wordings. A run with no--profilegets no extra sentence. The path is read from a hook the writer fires atmkdtemptime rather than from the resolved file, so a^Clanding inside the write itself still names the directory it strands.cdkd local invokeandlocal invoke-agentcoreneed none: their second^Cre-enters the same single-flight cleanup and the file is disposed. Why NAME rather thanrmSync: every path that reaches this notice leaves the containers running with the file bind-mounted, which is the premise the--detacharm already records for not disposing it there; removing a mount those containers are still reading rests on behaviour this repo has not measured, while the file being INVISIBLE is the part an operator can act on. Recorded at the builder. - A stack / output / export name from a template can no longer redraw your terminal from ANY
cdkd deployresolver message (issue #3397 review) --src/deployment/intrinsic-function-resolver.ts; 97 render sites.maskSecretsForLoganswers "does this text contain a recorded secret" and makes no claim about CONTROL CHARACTERS, so every message interpolating its result was masked but not stripped: aStackNamecarryingESC[2K+ CR emitted a line that erased and rewrote itself. It cannot simply strip -- its result is also a COMPARISON deciding whether a twin was registered -- so the two jobs are split:displayMasked(value, context)(mask, strip, mask, thendisplaySafe) is now the ONLY way a masked value reaches a message, anddisplayLeafis its log-twin sibling for theoriginbuilders. Both are inMASKERSinscripts/check-resolver-mask-coverage.ts, on the strength of delegating to the masker. Three review rounds each fixed the sites they found while the population was 86; three reviewers between them reached eight.tests/unit/deployment/resolver-display-masked-population.test.tsnow fails on any${this.maskSecretsForLog(...)},${this.maskThenStripThenMask(...)}or${this.logTextOfLeaf(...)}, so a new raw render IN THAT SHAPE cannot be added silently; it found two more sites nobody had reported on its first run. Residual, stated rather than implied away: the scanner is line-shaped, so ten LOCAL BINDINGS that take a raw masker result and interpolate it later are still uncovered -- one of them a liveFn::ImportValueexposure. go-to-k/cdkd#3426 carries the measurement and the reason it needs go-to-k/cdkd#3411's parser rather than another pattern. Cross-account refusal semantics are untouched -- class,markNonRetryablemarker and every message's WORDING are unchanged. - The S3 directory-bucket not-empty refusal no longer pastes an UNQUOTED state-borne bucket name into a recursive-delete command (issue #3270) --
src/provisioning/providers/s3-directory-bucket-provider.ts,tests/unit/provisioning/providers/s3-directory-bucket-provider.test.ts,tests/integration/s3-directory-bucket/verify.sh. The refusal for a non-emptyAWS::S3Express::DirectoryBucketwith no auto-empty opt-in advisedaws s3 rm s3://<name> --recursivewith the name interpolated BARE. That name is the state-borne physical id (TEMPLATE-chosen), so one carrying;, a backtick,$(...)or a newline APPENDED a command to a recursive delete when pasted, in a thrown message that also lands indeployments/*.jsonl. The command now renders through the sharedrenderDisableCommand(sanitize,shellQuote, SUPPRESS the whole command when sanitizing CHANGED the name, since the sanitized name is a DIFFERENT bucket). The quoted unit is the ASSEMBLEDs3://<name>argument, becauseaws s3 rmhas no--bucketform. Site-specific suppressions: a name containing/(s3://a/baddresses bucketa) and an empty name. A suppressed command becomes console advice. The PROSE copy of the name goes throughdisplayIdent, so a control byte cannot forge lines in the events store. The refusal is now marked NON-RETRYABLE:delete()wraps it with the LOGICAL id, and the destroy and replacement-delete paths readNotFoundException/NotFoundin a message as already deleted, so a logical id containing one had its non-empty bucket treated as gone. A clean name renders BARE: a well-formed bucket's message is byte-identical. NomaskSecrets:DeleteContexthas no masker (#2007). Residuals: the other bare-interpolating advice sites are #3136, provider-warning prose is #3269. cdkd deployrefuses a cyclic nested-template tree before any level of it deploys (issue #3247) --nested-stack-provider.ts, newsrc/utils/nested-template-cycle.ts, tests,docs/cli-deploy-safety.md,nested-stack-deepinteg arm.NestedStackProviderfollowedMetadata['aws:asset:path']from one nested template to the next with no check that the path stayed off its own nesting chain, and each level of that walk is a child deploy. A hand-modified or non-CDK assembly whose relative path resolved back onto the chain therefore DEPLOYED the same template again and again -- real resources and acdkd/<parent>~<Loop>~<Loop>.../state.jsonrecord per level -- until S3's key-length limit stopped it at roughly 190 levels.createandupdatenow walk the WHOLE subtree under the nested-stack row before the first child engine is built, and refuse with a non-retryable error naming every hop of the cycle. The walk refuses a repeat along one root-to-node chain only, so two sibling rows sharing one template still deploy; directories arerealpathed, so a cycle spelled through a symlinked directory (whose joined path never repeats as a string) is caught; a row'sConditionis not evaluated, matching the diff walk. The same walk reports an absoluteaws:asset:pathat any depth before anything deploys. Template-controlled text in this provider's thrown messages, skip reason and log lines now goes throughdisplaySafe. A tree over 512 levels deep (no state key could hold it) or over 10,000 rows is refused too. Residuals: the parent stack's own resources and assets start before its nested-stack row is reached (#3449);cdkd diff --recursivestill misses the symlinked-directory spelling (#3450); one delete-path message still renders the child stack name raw (#3277).- A resource
Typechange on an existing logical id is replaced through BOTH types (issues #2668, #3036) --deploy-engine.ts,rollback-executor.ts,type-change-guard.ts,rollback-journal.ts,commands/rollback.ts, newtype-change-replacementinteg. The diff emits a Type change as an UPDATE carrying the template's (new) type, and the engine routed both halves of the replacement on it, so the OLD resource's delete went to the NEW type's provider: an API error, a leak, or, where two types share a physical-id namespace, the deletion of an unrelated live resource. The old resource's delete, final snapshot and stateful guard now use the state record's type and layer; the create uses the template's. So leaving a stateful type for a non-stateful one now requires--force-stateful-recreation, and the reverse no longer does. An equal physical id across two types is treated as two resources. A Type change whose two property bags compare equal is no longer skipped as "no changes" (it left AWS and the record on the old type under a green deploy). The rollback journal gains an additivepreviousResourceTypeon completed UPDATE ops (nojournalVersionbump, no state-schema change); both rollbacks re-create the old resource through its own type's provider. A journal from an older cdkd falls back to the previous record's type; an op naming no old type, two different ones, or a nested-stack pair is refused withROLLBACK_REPLACEMENT_UNROUTABLEand the journal is kept (--orphan <id>skips it).--revert-failedskips a failed Type change with a warning. A Type change into or out ofAWS::CloudFormation::Stackis still refused (TYPE_CHANGE_NESTED_STACK), with its message corrected. Design: docs/design/2668-type-change-routing.md.
Recently Implemented (2026-09-18):
cdkd exportresolves the AppSync identifiers AWS re-declared in September 2026, blocksAWS::AppSync::GraphQLApiup front from a measured list, andcdkd importrecords a GraphQL API'sArn(issue #3414) --src/cli/commands/export.ts,src/provisioning/providers/appsync-provider.ts. AWS moved the API'sprimaryIdentifierfromApiIdtoArn(issue #3327) and re-publishedAWS::AppSync::ApiKeywith a read handler and the composite[ApiId, ApiKeyId]. Before: the API shipped its bareapiIdasArn; the key, no longer refused by the pre-flight, hit theadd an entry to COMPOSITE_ID_SPLITTERSthrow and aborted the export. After: the key has a splitter (<apiId>|<apiKeyId>, the key ARN, a bare pre-flip id withApiIdfrom properties; otherarn:values refused), overlay narrowed toApiId-- a standalone IMPORT changeset carrying one key reachedCREATE_COMPLETE(us-east-1, 2026-09-18). The API resolves from the recordedArn(tolerating a registry still reportingApiIdfor a non-ARN physicalId), BUT theexportinteg run measured CloudFormation refusing the type for IMPORT despite its read handler, so a datedCFN_IMPORT_REFUSED_DESPITE_REGISTRYmap now blocks it before the lock with the same remedies;--skip-import-support-preflightbypasses both checks. BEHAVIOUR CHANGE: an AppSync API used to fail atCreateChangeSetafter locking the stack and now blocks pre-flight.AppSyncProvider.importrecordsApiId/Arn/GraphQLUrlfor an adopted API (wasattributes: {}). Live: theappsyncfixture asserts anexport --dry-runnames exactlyGraphQLSchema+GraphQLApias blockers with the other identifiers resolved. No flag or schema change.AWS::SNS::Topicnow writesMaximumMessageSize, so the property AWS published on 2026-09-18 reaches the topic on the SDK route and the type's'sdk-coverage'exemption premise (an empty silentDrop map) holds again (issue #3413) --src/provisioning/providers/sns-topic-provider.ts,src/provisioning/property-coverage.generated.ts(regenerated),tests/fixtures/cfn-schemas/_todo-backfill.json(regenerated). Since the 2026-09-18 refresh the member was a silent drop, so a topic declaring it auto-routed through Cloud Control, and -- on the one type admitted toSTICKY_CC_MIGRATION_EXEMPTon the premise that it has NO drops --cdkd diffrenderedstickyfor acc-apitopic thatcdkd deploy --allow-unsupported-properties AWS::SNS::Topic:MaximumMessageSizewould flip to the SDK provider. Now:create()sends it inline onCreateTopic;update()sends a change throughSetTopicAttributesand resets a template REMOVAL to262144(SNS's standard maximum) instead of the''the string members are cleared with, which SNS refuses for this integer (1024..1048576; all measured live, us-east-1, 2026-09-18);readCurrentStateemits the member unconditionally as a number, folding an absent attribute to262144(a fresh topic reports nothing while one that has ever set it keeps reporting it, and both enforce the default), so every drift baseline this binary captures carries the key (a record observed by an older cdkd gains it on its next update or viacdkd state refresh-observed): a template removal compares equal to itself and a console-side change -- including an ADD after a removal -- is drift--revertcan push back. Thecc-to-sdk-reroutefixture carries the member through SDK create, the Cloud Control recreate, the SDK update on the flip, and a removal phase asserting the 262144 reset plus a cleancdkd drift. No flag or schema change.- AWS published 1 writable property the 2026-09-18 schema refresh now carries, so cdkd classifies it instead of dropping it with a warn (PR #3402) --
AWS::SNS::Topic:MaximumMessageSize. Changed:tests/fixtures/cfn-schemas/,src/provisioning/property-coverage.generated.ts(regenerated). OnAWS::SNS::Topic, no SDK provider writes it yet (not yet implemented by cdkd), so pre-flight classifies each as a silent drop and the issue #614 auto-route sends a resource whose template carries one through Cloud Control, which forwards the full property map. Until this refresh the same key post-dated the committed snapshot, so a resource on the SDK route with no other actionable drop and noprovisionedBy: 'cc-api'record warned and dropped it. The snapshot marks none of them create-only, socreateOnlyDropsand the record narrowing are unchanged; the capture is top-level-only, so it says nothing about a nested create-only path. - A user-supplied IAM role ARN can no longer redraw your terminal from a DEPLOY-path message either (issue #3397, completing the class go-to-k/cdkd#3390 closed for
cdkd local) --src/deployment/intrinsic-function-resolver.ts,src/utils/role-arn.ts,src/cli/options.ts,src/cli/commands/drift.ts; 14 sites plussrc/cli/commands/rollback.ts, each throughdisplayIdentwithROLE_ARN_MAX_CODE_POINTS, so an ordinary ARN renders byte-identically (an IAM PATH admits( ) ! # $ % & * [ ], which now render JSON-quoted -- recorded at the site). The issue named five sites, derived by grep. Widening the go-to-k/cdkd#3390 display FENCE (tests/unit/cli/local-profile-display-population.test.ts, whoseinArnScopecarries the measurements) to the four USER-SUPPLIED-ARN surfaces found ten: two--assume-roleargparse refusals insrc/cli/options.ts-- argv, whoseIAM_ROLE_ARN_REGEXis START-anchored, so even an ACCEPTED value is unconstrained pastrole/-- and five insrc/utils/role-arn.tsitself, the file the issue body cites as having adopted this rule in issue #2170. It had, in ONE of its two functions;assumeRoleForCrossAccountStateReadrenderedroleArnraw at all five of its sites --AssumeRole into ${roleArn} failed: ${message}, where STS echoes an unparseable RoleArn back verbatim, so the value returned past the guard through the error text. That neighbour now takesdisplaySafe, as doesdrift.ts's IAM error text. The cross-account refusal semantics are untouched --CrossAccountSecretRefusalError, itsmarkNonRetryablemarker and the message wording are unchanged, socdkd scrub'sinstanceofbranch and every retry classifier behave exactly as before. NOT widened tree-wide: that reports 153 sites undersrc/provisioning/src/synthesis, out of scope by REACHABILITY (go-to-k/cdkd#3405). AWS::DynamoDB::Tablenow REFUSES a per-GSIOnDemandThroughputon a PROVISIONED table before any call, and REMOVES a dropped ceiling at both of the property's positions (issues #3392 and #3373) --src/provisioning/providers/dynamodb-table-provider.ts. DynamoDB acceptsOnDemandThroughputonly on a PAY_PER_REQUEST table, andapplyGsiUpdates' THREE per-index send sites all ride oneGlobalSecondaryIndexUpdatesarray -- so AWS rejected the whole request, took a co-resident capacity edit with it, AFTER the flip had landed.update()now walksGlobalSecondaryIndexes[]pre-flight and throws, naming every offending index. The gate reads the EFFECTIVE mode (billingOrThroughputChanged ? billingMode : liveBillingMode): the desired mode alone falsely refuses a live on-demand table omittingBillingMode, and falsely accepts a live PROVISIONED one whose record says PAY_PER_REQUEST. It downgrades to a warning when the desired bag is not template-borne (replayingState/desiredFromAwsReadback): unlike the table-level twin, a VALID record can carry this shape. BEHAVIOUR CHANGE: a PROVISIONED table carrying an UNCHANGED per-index ceiling used to deploy green and now fails pre-flight. Separately, an absent member used to KEEP whatever maximum the table carried, so dropping a ceiling deployed green and was recorded as applied while the live maximum stayed in force forever; cdkd now sends-1, the reset sentinel, PER MEMBER, at the table level AND per index. It fires only for a member the record holds in a spelling cdkd SENT, that the template does not declare raw (so a rejected spelling cannot clear the ceiling it was setting), thatDescribeTableOBSERVES live, and only when this update does not flip to PROVISIONED (the snapshot predates the flip).-1is live-verified at both positions (#1434; #1423 plus PR #1433 atUpdateGlobalSecondaryIndexAction).- A
--assume-role/--ecr-role-arnARN can no longer redraw your terminal from acdkd localfailure message (go-to-k/cdkd#3390 security review, a sibling class of issue #3377) --src/cli/commands/local-invoke.ts,local-invoke-agentcore.ts,local-run-task.ts,local-start-api.ts,src/local/ecr-puller.ts; 11 sites.src/utils/role-arn.tshas rendered its own ARNs throughdisplaySafesince issue #2170; thecdkd localcopies never adopted it, andConsoleLogger.formatMessagesanitizes only a call's EXTRA ARGS, so a message's own text was raw. This is the WIDER boundary of the two values on those lines:resolveAssumeRoleArncan source the ARN from a DEPLOYED STATE RECORD rather than from argv, and nothing validates it before the STS call whose failure prints it. A legitimate ARN's characters are all plain, so every existing line renders byte-identically. The SDK error message interpolated beside it takesdisplaySafetoo, and that one is not symmetry: STS answers an unparseable RoleArn with aValidationErrorthat ECHOES THE SUBMITTED VALUE VERBATIM, so the attacker-controlled ARN came back through the error string onto the very line the ARN guard protects. Two sites are left raw deliberately -- aencodeURIComponent'd credentials URI and a cache key, neither of which is displayed. The deploy-path copies insrc/deployment/intrinsic-function-resolver.tsare a separate surface, tracked separately. - The
cdkd force-unlock ...command a lock-contention failure tells you to run can no longer be redrawn by its own--profile/--state-bucket/--state-prefix(issue #3377, widened by go-to-k/cdkd#3390's review) --src/state/lock-contention-message.ts,src/cli/commands/state.ts. All three fragments reached the terminal throughshellQuoteALONE, which is the wrong instrument: inside'...'an injected\ncurl ... | shis a literal, while an ESC still redraws the line. The BUCKET is third-party plantable --config-loader.tsresolves it from a repo'scdk.jsoncontext.cdkd.stateBucket, so cloning a repository chooses it. All three now take one shared sanitize + EXACTNESS guard, the same pair the stack name and region already took: a fragment whose rendering CHANGED names a different lock, and for--profilea different ACCOUNT, so the whole command is suppressed rather than pointed at the wrong one. They take the DENYLIST where the stack name and region take the ASCII allowlist, because those two come from an S3 key and have a known charset while a profile name does not --asciiOnlywould have deleted the entire hint forprod-café. Three sentences told a user WHICH value could not be shown and two of them still said "the name or region"; they now share one binding, so the list cannot go stale in one copy again. - A
--profile <name>you passed can no longer redraw your terminal from acdkd localerror; a name the shell would reinterpret is no longer pasted into a command cdkd tells you to run (issue #3377) --local-profile-credentials-file.ts,local-start-api.ts(resolveProfileCredentials),local-invoke-agentcore.ts(both--assume-rolefallback notices). The issue reported two sites; the sweep found four here plus one inbuildForceUnlockCommand(its own entry) -- the two fallback notices were nobody's report.ConsoleLogger.formatMessagesanitizes only a call's EXTRA ARGS andformatErroronly acause, so a message's own text was raw argv. The two shapes take DIFFERENT helpers, which is the substance rather than a detail. A MESSAGE renders throughdisplayIdent-- allowlist, length cap, and a JSON-quoted boundary only for a value that could spoof, so a legitimate name reads as before. Theaws sso login --profile <name>line is a command an operator PASTES, anddisplayIdentis not enough: every character of~eviland-rfis a plain identifier character, so both render BARE while the shell reads one as a home directory and the other as a flag. That arm is gated onisPasteableIdentand writes no command out when it fails --cdkd gc's disposition for an unpasteable stack name. Unchanged on purpose:writeProfileCredentialsFile's CR / LF /[/]validator asks what corrupts the INI FILE and the docker-eline -- a different question -- and the section header still carries the name byte-for-byte. cdkd orphan'spropertiesrefusal no longer points a legacy region-less record at a region its remedies cannot select, and gates its drop remedy on an exact identity (issues #3359, #3360) --orphan.ts,malformed-resources-bag.ts,lock-contention-message.ts, docs, rules, tests. For a lone legacy<prefix>/<stack>/state.jsonlisted with no region,pickStackRegionloads it under the SYNTHESIZED region, and the refusal (#3318) printed that region intocdkd state orphan ... --stack-region <region>andcdkd state show ... --json; both select by the LISTED region, so neither found it.pickStackRegionnow also returns the record's own region and the refusal takes it: the drop remedy is the barecdkd state orphan <stack>; a record listed under a region keeps--stack-region.cdkd state showcannot read that shape, so its inspect line names the S3 object instead, on labelledObject key:/State bucket:lines, only when the name renders exactly. Drop and inspect substitute name and region only when both render exactly at the state-reference cap (a trimmed name named a healthy sibling); otherwise templates plus a pointer tocdkd state list --json. Both carry the run's--profile/--state-bucket/ non-default--state-prefixviarecoveryCommandFlags, shared withcdkd force-unlock's hint (an altered value prints as a hole; that hint now also emits an explicit empty prefix). Commands print last, one per labelled line, holes QUOTED -- inside prose quotes a quoted stack name, region (released) or bucket broke out when pasted, and a bare<stack>is a shell redirection. Quoting the sharedinspectCommandtemplate also changes theInspect ... with:line of the destroy, divergent-region and deploy/diff refusals and the diff warning. Siblings: #3388.- A
cdkd destroyfailure that is a value JavaScript cannot stringify is now REPORTED as itself, instead of as aTypeError(issue #3348) --src/cli/commands/destroy-runner.ts(9 sites),src/deployment/retry.ts(withRetry),src/deployment/retryable-errors.ts(retryClassificationText),src/provisioning/final-snapshot.ts(errMsg),src/types/deployment-events.ts(extractDeploymentEventError).String(value)THROWS for a null-prototype object or a hostile /nulltoString, and every one of these sites sits inside a catch that is already the LAST handler. They sit in and around the per-resource catch of thelevel.map(...)thatPromise.allawaits, and its retry loop stringifies first -- so a hostile value became aTypeErrorand theRESOURCE_FAILEDrow namedTypeError: Cannot convert object to primitive valuerather than the AWS failure. Measured: the run is NOT abandoned -- aTypeErroris anError, so handlers below take theErrorbranch. Guarding any one leaves an earlier one throwing first, which is why all five are here -- andwithRetrywraps every provider op, socdkd deploy/rollback/importgain the same honesty. The remaining seven are the lock-release, client-teardown and state-persist arms of the same command, where an out-throw strands a lock or loses the record of what was already deleted. The ternary becomesdescribeAwsFailure(err).detail, byte-identical -- so the "not found means already deleted" prose matcher and every retry classifier read what they read before -- and the two bare forms becomesafeStringify(err), keeping the error'sname. A value that cannot be converted is now RECORDED asa value that could not be converted to text. The sibling population is tracked by issues #3361 and #3341. cdkd scrubnow repairs a cross-stack read name an older binary persisted in plaintext, instead of reporting the record clean. go-to-k/cdkd#3289 closed the WRITE side —imports[].exportName,outputReads[].sourceStackandoutputReads[].outputNameare redacted back to their{{resolve:...}}expression at the persist choke point — but both lists rodescrubStack's...carriedStatespread untouched, so a record already on disk kept its plaintext andcdkd scrub --failexited 0 over it. The same three template-derived names are now matched against this run's recorded plaintexts and rewritten;sourceRegionandimports[].sourceStackare deliberately left verbatim, the second becausecdkd destroymatches its producer refusal on that literal key and redacting it would drop a destroy-blocking record. A repaired record is counted, so--dry-run --failreddens on it as a standing CI gate. Population, stated rather than implied by a clean verdict: this closes the NEVER-AGAIN case — a reference no later deploy re-resolves, which is the ordinary shape for a stable stack. It does NOT close ROTATED, and does not claim to: scrub derives its needles by re-resolving the live template, so it holds the secret's CURRENT value while the stored name holds the one it had when the record was written, a value nothing in the run can see. Changed:src/cli/commands/scrub.ts, plustests/unit/cli/commands/scrub-cross-stack-read-repair.test.tsand adocs/cli-scrub.mdsection. Issue go-to-k/cdkd#3337; the four-case analysis and the per-field provenance table are indocs/design/3289-cross-stack-read-name-redaction.md. Scrubbing does not un-expose an already-stored secret, and the state bucket is versioned — the pre-repair body stays readable in prior object versions until swept, so ROTATE.Fn::GetAtton anAWS::SNS::Subscription'sArnis answered from the recorded attribute instead of the physical-id fallback (issue #3329) --src/provisioning/providers/sns-subscription-provider.ts,scripts/gen-sdk-attr-coverage.ts,docs/_generated/sdk-attr-coverage.{json,md},tests/unit/provisioning/sns-subscription-{provider,arn-resolution-issue-3329}.test.ts,tests/integration/sns-subscription-update/. The provider recordedattributes: {}and letguardedPhysicalIdFallbackanswer off the physical id, which IS the subscription ARN. That worked on the ordinary path -- so the type held the tree's onlySDK_ATTR_ALLOW_LISTentry rather than being reported as a gap -- and left three edges: a CONSTRUCTED<topicArn>:<logicalId>whenSubscribereturns no ARN (ARN-shaped, so the resolver's guard passes a fabricated value), an importedPendingConfirmationid (where it refuses), and--strict-getatt(which throws on any fallback).createnow recordsArnfrom the response, answering the first and third from cache. The rule, and why each half of it, is on the two call sites: cache the ARN AWS returned, or cache nothing -- never the constructed fallback, never an imported id.updatedelegates tocreate, so a replacement carries it. The allow-list entry is DELETED, which is what verifies the caching (classifyTypereadscachedKeysfirst, so one left behind goes inert while the matrix still reports a carve-out); probed by reverting the caching, which makesaudit:sdk-attr-coverage:checkname the gap again. The list is empty, its green state since #1824. Existing state records keep resolving through the fallback until their subscription is next replaced -- nothing backfillsattributeson a no-change deploy.- A divergent state record now also keeps
cdkd deploy's stateful-recreate probe conservative, and the destroy refusal withholds its target when the record's identity does not render exactly (issue #3328, review round 2) --src/deployment/recreate-targets.ts,src/cli/commands/deploy.ts,src/state/malformed-resources-bag.ts,src/cli/commands/export.ts,tests/unit/deployment/recreate-targets.test.ts,tests/unit/state/state-key-region-authority.test.ts,tests/unit/cli/export.test.ts. Two defects the sibling entries introduced. (a)deploy.tsfeedsstate.regioninto the--recreate-via-*probe'sexpectedRegion, whose mismatch arm refuses to read aResourceNotFoundExceptionas "this log group is empty". A divergent body USED to be that mismatch, so the arm fired and kept the data guard on; once the record's region is normalized to its key's the comparands agree by construction and a not-found clears the guard instead.StatefulProbeClients.expectedRegionDivergedrestores the fail-closed outcome -- a BOOLEAN, not the value, because that arm renders its comparand in a default-verbosity warn. (b) The destroy refusal ended oncdkd state orphan <stack> --stack-region <region>while naming a stack whose renderingdisplaySafemay have TRIMMED -- so a record keyed'prod-api 'opened byte-identically to a healthyprod-api, and an operator filling the template with the name above it orphans the intact record. It now computes the sameexacttest its sibling has carried since #3332 and, when it fails, names no target and sends the reader tocdkd state list --long. It also fails CLOSED on a bag it cannot count.export.ts'ssafeSegmentcaps too -- the last uncapped rendering of a body-supplied region.cdkd rollbackshares the inference and is #3370. cdkd destroy/cdkd state destroynow REFUSE a state record whose body named a different region than its key while it still lists resources, instead of reporting the whole stack destroyed (issue #3328, review round 1) --src/state/malformed-resources-bag.ts,src/cli/commands/destroy-runner.ts,src/cli/commands/destroy.ts,src/cli/commands/state.ts,src/provisioning/providers/nested-stack-provider.ts,src/types/state.ts,tests/unit/state/state-key-region-authority.test.ts. The sibling entry makes the S3 KEY decide such a record's region -- right for the lock and the record's identity, undecidable for WHERE THE RESOURCES ARE. If the body is the planted half they are in the key's region; if the KEY is (anaws s3 cpbetween region prefixes, a restore, another tool), they are not, and the runner reads each provider's*NotFoundas ALREADY DELETED:deletedCount++, the row dropped, thendeleteState. A whole stack would be reported destroyed with every resource still live in the other region and nothing naming it.assertRegionMatch, the guard for that inference, cannot fire --DeleteContext.expectedRegionand the client region are now the same value by construction. So the runner refuses, above the empty-stack fast path and below the malformed-bag guard that proves the bag can be counted, withSTATE_REGION_DIVERGEDand a message naming the key's region, the resource count and the value's KIND but never the value. The trigger is the CONJUNCTION: a resource-less record is NOT refused, since nothing can be stranded and that is the recovery destroy the read side exists to protect;cdkd state show/state list/state orphanall keep working on a record this refuses to delete from. Also drops a non-string or emptyregionon the LEGACY read path (tryGetLegacy's gate short-circuits on falsy, so""and0reachedacquireLockas regions and builtcdkd/<stack>//state.json).cdkd export's refusals no longer build a pasteablecdkd state orphanline out of a record-derived stack name, and no longer render one raw (issue #3328, review rounds 3-4) --src/cli/commands/export.ts,tests/unit/cli/export.test.ts.walkCdkdStateStackTreemints each nested child's stack name as${parent}~${logicalId}from the record's ownresourceskeys -- body content anyone able to write one state key chooses -- and RECURSES, so from depth 2 the name a refusal prints is itself record-derived. Three messages then suggestedcdkd state orphan <that name>: the missing-nested-child refusal sanitized it but did not SHELL-QUOTE, so',;,|and backticks reached a line an operator pastes into a shell; the missing-asset-path refusal and the CFn-migration state-deletion warn rendered it RAW, so a newline in a logical id forged whole lines around a DELETE command. All three now go throughorphanCommandFor, which applies both halves of the rulemalformed-resources-bag.tsrecords: sanitize THEN shell-quote, and GATE the substituted form on the identity rendering EXACTLY --displaySafetrims, so a record keyed'prod-api 'renders byte-identically to a healthyprod-api, and an operator filling the template with the name printed above it would orphan the intact record. When it does not render exactly the message names no target and sends the reader tocdkd state list --long. Itsregionis required: omitting--stack-regiondrops the record for that name in EVERY region. The migration's failure SUMMARY -- a multi-line- cdkd/<stack>/<region>/state.json: <reason>block ending on "orphan each record listed" -- sanitizes every field too, since a planted newline forges a row there naming a healthy stack; the free-form SDK reason takessafeDetail, not the identifier helper.safeSegment's truncation marker isdisplayIdent's counted form rather than a forgeable bare[cut].- A state record's S3 KEY now decides its region on read, so
cdkd destroy/cdkd state destroycan no longer lock, save and delete against a region the record's BODY named (issue #3328) --src/state/s3-state-backend.ts,src/cli/commands/export.ts,src/cli/commands/state.ts,src/state/malformed-resources-bag.ts,tests/unit/state/state-key-region-authority.test.ts,tests/unit/_state-read-double.ts. Measured against the shipped binary: a resource-less record planted at.../<stack>/us-east-1/state.jsoncarrying"region": "eu-west-1"madecdkd state destroy <stack> --stack-region us-east-1print✓ State deletedwhile the us-east-1 key survived -- only the LEGACY read path compared body against caller. With resources in the record the same divergence sent the lock and every incremental save to a region nobody looks at, and misdirectedDeleteContext.expectedRegion, which is how a provider tells "genuinely gone" from "wrong-region miss".getStatenow replaces the loaded record'sregionwith the key's (adoptKeyRegion), making the read symmetric withsaveState, which has stamped the key's region into every body it writes since the v2 layout -- so a divergence means another writer. Replacement, not??: a""region builtcdkd/<stack>//state.jsonand a numeric one reachedacquireLockas a region, neither of which a nullish fallback catches. An absent region adopts the key's silently; anything else that disagrees WARNS at default verbosity naming only the value's KIND (a string,an array) -- the value is body content, and printing it is the misdirection channel this closes.--verboseshows it, sanitized.cdkd export's nested-child region refusal reads the divergence from the newdivergentBodyRegionfield. Destroy-path code is unchanged: normalizing at the read fixesdestroy-runner.ts's four sites,deploy.tsanddrift.tswith nothing threaded. - An
AWS::DynamoDB::TableGSI whoseIndexNameis an unquoted YAML NUMBER no longer takes the wholecdkd deploydown (found while closing issue #3287) --src/provisioning/providers/dynamodb-table-provider.ts(warmThroughputOpFor).IndexNameis an UNCHECKED cast off the template, soIndexName: 2024reaches the provider as a number, and the realmaskSecretsis aString.prototype.replacecall that THROWS on one. That method opened with a bare, EAGERmaskSecrets(indexName)ahead of every gate, and it runs for every same-name and adopted index -- so on a stack with any resolved secret, an ORDINARY capacity edit on such a table failed withFailed to update DynamoDB table <id>: text.replace is not a function, from a purely diagnostic code path. TheindexScopeAtguard the review of issue #3255 added for exactly this covered the create-path and capacity sites and missed this one. The CLASS is now swept rather than the reported site: this method's four interpolations plus three more that survived elsewhere in the file (both adopted-index warns andskipZeroCapacityIndexUpdate's zero-capacity refusal, the one of the three a numeric name can actually reach) all read through it, andindexNameis typedunknownat both methods so a future direct interpolation does not compile. The population is derivable:grep -nE 'maskSecrets\((name|indexName)\)'over the file returns only the two lines inside the scope builders themselves. A string name renders byte-identically; a non-string renders<unnamed>instead of throwing. Pinned bysite 5 update: survives a NUMERIC IndexName with a masker in playintests/unit/provisioning/dynamodb-table-provider-cfn-integer.test.ts, whose site-4 twin could not reach this arm. AWS::DynamoDB::Tablenow SENDS a per-GSIOnDemandThroughputchange (issue #3287) --src/provisioning/providers/dynamodb-table-provider.ts,tests/integration/dynamodb-ondemand/.UpdateGlobalSecondaryIndexAction.OnDemandThroughputexists in the SDK model and NEITHER per-index update arm set it, andupdateHasMemberwas set only by the capacity and warm arms -- so a template whose only edit was a live index's ceiling fired no op at all, deployed GREEN, and was recorded as applied: the edit lost permanently with nothing warned. Both arms now carry it throughcoerceOnDemandCeilingsForSend, the reader the other four send sites use. A live-DescribeTableidempotency guard withholds an op AWS already applied (#1630); the detector narrows both sides only when NOTHING was dropped, falling back to a RAW compare otherwise so a still-malformed template keeps being announced; and a block with no surviving GRAMMAR member emits no op. One BEHAVIOUR CHANGE: AWS acceptsOnDemandThroughputonly on a PAY_PER_REQUEST table, so a PROVISIONED table declaring a per-index ceiling -- which used to deploy GREEN with it silently ignored -- now has theUpdateTableREJECTED, taking co-resident capacity edits with it. Loud beats silently lost; a cdkd-worded pre-flight refusal across all THREE per-index send sites is issue #3392. Removing a ceiling stays a no-op at both positions (issue #3373), and the RECORD side is untouched: aneffectiveProperties+canonicalizeDesiredPropertiesfold was written for this change and WITHDRAWN, so issue #3286 stays open with the evidence.cdkd local invoke-agentcore --sigv4now NAMES the--role-arnexclusion when it refuses, instead of telling you to set a variable that is already set (issue #3250) --src/cli/commands/local-invoke-agentcore.ts,src/utils/caller-credentials.ts(envCredentialsAreAssumedRole),tests/unit/cli/local-invoke-agentcore-sigv4-identity.test.ts,tests/unit/cli/local-container-caller-identity.test.ts. The signature on/invocationsis what the emulated agent sees as its INVOKER, so cdkd signs as the CALLER and deliberately never as a--role-arnassumed role (issue #3130). On an SSO / instance-role / container-role caller there is nothing to put back, and the old refusal readno AWS credentials available... Set AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEYwhileAWS_ACCESS_KEY_IDwas visibly set -- cdkd had set it, to the role -- so it described cdkd as broken rather than as refusing. It now says which value it declines and why; the remedies are ORDERED per branch (this branch fires only for a caller with no static pair by design, so--profile/--assume-rolelead) and--profiledrops out when one was already passed. Rationale onsigv4NoCredentialsRefusal's doc comment. Same PR, no behavior delta:AWS_CREDENTIAL_ENV_KEYSis now IMPORTED by all threeforwardAwsEnvbodies rather than documenting a guarantee nothing consumed (item 4); thecdkd localcredential fence gained the INI credentials-file channel that neither fence could see -- a lowercaseaws_access_key_idwritten into a bind-mounted host file (item 7) -- plus comment stripping, a non-empty-reason requirement, a statement-scoped annotation walk in place of two fixed line windows FOUR sites sat exactly on (items 2 and 6), and one shared scope module so the two fences cannot disagree about which files are workload-facing.
Recently Implemented (2026-09-17):
cdkd orphanno longer saves a resource record whosepropertiesmap it could not read (issue #3318) --src/cli/commands/orphan.ts,src/state/malformed-resources-bag.ts,docs/state-management.md,.claude/rules/state-malformed-properties-orphan.md,tests/unit/cli/orphan.test.ts,tests/unit/state/malformed-resources-bag.test.ts.rewriteResourceReferencespasses each surviving record's bag throughrewriteValue, whose first line returns a non-object verbatim, and re-assigns it through a bare cast;refuseMalformedStateabove it answers a question about the record ROOT only, andunreadableResourcePropertyBagsreturns[]for a record whose root bag is unreadable -- so the per-ENTRY container was unguarded on a WRITE path. Measured through the real rewriter:"abcdef",5,nulland[]each came back byte for byte and an absent bag stayed absent -- nothing fabricated, but the run saved and reported success over a record the nextcdkd deployrefuses (#3191) and acdkd diffin between previews as a replacement. A LIST bag is the one unreadable shaperewriteValuewalks, so its rewrites landed in the audit table while the container stayed un-map-like.refuseMalformedResourcePropertiesForOrphannow refuses at the load, above the rewrite and the--dry-runreturn, with its own text (the deploy sibling's diff verdict and theoutputsrefusal's laundering sentence are both false here). It is SCOPED to the records the save would KEEP, so orphaning the damaged resource itself still works; the message leads with the two ways out needing no CDK app (hand repair,cdkd state orphan <stack>), since a construct path exists only while the app still declares the resource. No--forcebypass. Residuals: #3344 (state.orphans[]), #3345 (attributes).- Every S3 listing cdkd takes now survives a carriage return in a key, so a sweep can no longer report success having deleted nothing (issue #3313) -- new
src/utils/s3-listing-keys.ts, plussrc/state/{s3-state-backend,s3-noncurrent-version-purge}.ts,src/cli/commands/{gc,bootstrap-destroy,state-migrate}.ts,src/provisioning/providers/s3-{bucket,directory-bucket}-provider.ts, one suite.ListObjectsV2/ListObjectVersionsanswer in XML, and an XML parser normalises a CR to an LF per the specification -- so a key containing a CR came back with an LF in its place and every later use addressed a key that does not exist. Measured against real S3:PutObjectstores such a key, the un-encoded listing returns\n, andEncodingType: 'url'returns%0Dwhich decodes to the real CR. The dangerous half is the delete that follows: it SUCCEEDS and removes nothing, sos3-noncurrent-version-purge.ts-- the sweep that removes state versions carrying a secret plaintext -- could skip an entry and still report a clean run. The request and the decode are ONE decision, which is why they share a module: a site that asks for encoding without decoding corrupts every legitimate%in a key.NextContinuationTokenis deliberately NOT decoded (it is opaque and unencoded; decoding it breaks pagination) whileNextKeyMarkeris (the next request sends the raw value) -- both measured, not inferred. Two sites deliberately do not ask:config-loader.tsreadsKeyCountandrecreate-targets.tsis an emptiness probe, so neither reads a key at all. NUL is out of scope by measurement -- S3 refuses to store such a key -- and an ESC survives verbatim, so the transformation is specific to the line-ending class.src/cli/commands/state.tsis the one listing still unfixed, held by an open PR, and the new suite records it so it cannot be forgotten. - A Cloud Control attribute enrichment that fails with a value JavaScript cannot stringify degrades to a debug line, instead of failing the whole create (issue #3309) --
src/provisioning/cloud-control-provider.ts. Fifteencatchblocks read the caught failure's text witherror instanceof Error ? error.message : String(error).String(value)THROWS for a null-prototype object (TypeError: Cannot convert object to primitive value) and for anything with a hostile ornulltoString, and every one of these handlers exists to DEGRADE: an enrichment that cannot read an attribute logs atdebugand carries on. The out-throw escaped the handler and failed the create or update instead, reporting aTypeErrorthat names neither the resource nor the attribute. Reachable because a non-Errorrejection genuinely travels this far -- the same shape #3236 measured reaching the poll loop. All fifteen now readdescribeAwsFailure(error).detail, which is byte-identical for anErrorand applies the guard that module gained for exactly this. Two occurrences are deliberately left:cleanupFailedCreateRemnant's catch, whose bare arm is UNREACHABLE (every throw into it comes fromthis.delete(...), which wraps into aProvisioningError) and where converting would blindisNotFoundMessage, a PROSE matcher, by replacing the message with a wire name; and one inside a comment explaining why the form is wrong. Both are pinned BY NAME rather than by a count, so a sixteenth site fails the fence instead of shifting a number. - Every graceful degradation under
src/provisioning/**survives a caught value JavaScript cannot stringify, instead of becoming a hard failure (issue #3309 follow-up) -- 61 files. A catch reading its caught value withString(err)-- bare, or behind theerr instanceof Error ? err.message : String(err)ternary -- THROWS for a null-prototype object or a hostiletoString. Computed on a path that can leave the handler WITHOUT throwing, that does not mean a worse message: it takes down whatever was degrading. Partial-create cleanups stopped running and left LIVE orphans while reporting aTypeErrorinstead of the create failure that explains them (ACM, S3, five IAM types, API Gateway, ECS, SNS, EventBridge, Logs, SSM, DynamoDB GlobalTable); EC2's four delete retries died mid-loop, as did S3's not-empty retry, EventBridge's already-gone destroy and four pollers. Measured on the merge base, the population is 569 occurrences -- 205 that can degrade (61 files) and 364 that cannot. 204 of the 205 are swept and the bucket is enforced at its one documented exemption (cleanupFailedCreateRemnant's catch, whose bare arm is unreachable). The ternary form becomesdescribeAwsFailure(err).detail, byte-identical to it; the bare form becomes the newsafeStringify(err)--String(err)plus a guard, and not.detail, which drops the error'snameand so shortens four persistedoutcome: 'partial'reasons. Every site whose text has a downstream READER keeps it exactly and now names that reader at the call site -- retry / already-gone classifiers, ten that reach a THROWN, persisted message, and one that becomes a struct field read elsewhere. The remaining 363 occurrences sit where the handler rethrows regardless, so the same defect substitutes a less useful error rather than breaking a degradation; deliberately not swept, tracked by issue #3341. - A damaged producer record is named once per RUN and cannot be hidden by a planted stack name (issues #3308, #3293) --
src/state/malformed-resources-bag.ts,src/cli/commands/{scrub,local-state-loader}.ts,src/local/s3-local-state-provider.ts, three suites. Both fixes land together because they change the same lines: one changes what the warned-once key IS, the other WHERE the set lives. #3308: the two warned-once-per-record sets joined stack and region with a NUL, which narrows a collision without closing it -- a stack name is read out of an S3 key and is as attacker-chosen as theoutputsbag, so it can carry a NUL too, and this repo already plants one. Measured: stackEvil<NUL>us-east-1inap-northeast-1and stackEvilin regionus-east-1<NUL>ap-northeast-1produced the SAME key, so whichever was warned about second was silently not warned about at all. Both sites now call one sharedproducerRecordKey, which encodes withJSON.stringify(injective over string pairs) instead of separating -- and a fence pins that both sites go through the helper, since a second spelling is the failure it exists to prevent. #3293: the set lived in each resolver's closure, socdkd local invoke-agentcore-- whose boot builds TWO resolvers, one for thefromS3bundle's bucket intrinsic and one for the container env -- reported one damaged record as two.S3LocalStateProvidernow owns it and passes it to every resolver it builds, which is the honest scope (a run) and needs no change at the call sites;buildCrossStackResolverkeeps its own set when no caller supplies one, solocal invokeandlocal run-taskare unchanged. Per provider INSTANCE, never module-global. Consequence in both cases is a dropped warning LINE, never a wrong resolution. - A transport failure keeps its wording everywhere
describeAwsFailureis used, and a credential-resolution failure stops passing through verbatim (issue #3297) --src/utils/aws-failure-text.ts,src/provisioning/cloud-control-provider.ts.isAwsAuthoredFailuredecides whether a caught failure's text is AWS's -- and so must be withheld from a user-facing or PERSISTED message. It tested for the PRESENCE of$metadata/$fault/$response, and@smithy/core's retry middleware stamps$metadataonto EVERY error it gives up on, socket errors included. Measured: a plainECONNREFUSEDcarries one, so it was reduced to itsname-- for a socket error the bare tokenError. The predicate is now a real SERVICE signal ($fault, or a NUMERIC$metadata.httpStatusCode) plus theCredentialsProviderErrorNAME, which carries neither because credential resolution runs OUTSIDE the retry middleware.$responseis gone: the same catch that sets it also sets$metadata.httpStatusCode, so the status-code arm subsumes it. The change runs BOTH ways. Five call sites inherit it --asset-storage.ts,s3-bucket-provider.ts,efs-provider.tsand two incloud-control-provider.ts: each had been reporting a transport failure asError, and four of the five had been letting aCredentialsProviderErrorthrough verbatim into a persisted message, which undercredential_processmeant the helper's ARGV and its stderr (the fifth catchesconfig.region(), which that shape cannot reach). That is withheld now and reachable atdebug; the cost is the corporate-proxyself-signed certificatesymptom reading as a class name at default verbosity.s3-state-backend.tsis deliberately untouched -- it readsdetail, notsummary. The LOCAL duplicate predicate #3236 had to carry is deleted with it, so one error can no longer be classified two ways. - A malformed Cloud Control resource model no longer echoes part of itself into a default-verbosity warning (issue #3290) --
src/provisioning/cloud-control-provider.ts.parseResourceModel's failure line promises, in the comment directly above it, that it logs the model's SHAPE and never its body -- the residual of GHSA-p5qg-v9gv-hc7w that issue #1908 closed. It loggedJSON.parse's own message, and V8 ECHOES the input around the failure point:JSON.parse('{"pw": hunter2SuperSecretValue}')answersUnexpected token 'h', "{"pw": hunter2Sup"... is not valid JSON, so roughly thirty characters of the model reached awarnline at DEFAULT verbosity. That matters because a{{resolve:secretsmanager:...}}value resolved into a property that is not write-only can round-trip back through a Cloud Control readback, which is precisely the case the comment's promise exists for. The line now logs the failure CLASS throughdescribeAwsFailure-- the same instrument the sibling abandonment path uses -- with the full text atdebug; the existingModel shape: <n> chars, <keys>clause already carried the diagnosis the line exists for, namely WHICH document failed to parse, so nothing is lost. Needs a malformed model to reach at all, and never leaves the terminal: the catch returns{}rather than throwing, so nothing copies it into the durabledeployments/store. state.imports/state.outputReadsno longer persist a secret in plaintext, andcdkd deploy's recreate prompt no longer silently omits a consumer it cannot name. Both lists carry names the resolver took from the TEMPLATE and both roderedactStateForPersist's...statespread untouched, so anFn::ImportValue/Fn::GetStackOutputwhose name anFn::Subassembled around a resolved{{resolve:secretsmanager:...}}wrote that plaintext intostate.jsonand kept it there.imports[].exportName,outputReads[].outputNameandoutputReads[].sourceStackare now redacted back to the unresolved expression at persist;sourceRegionandimports[].sourceStackdeliberately are NOT. No state schema bump — the fields keep their shape. The third of those IS the literal keyfindDownstreamConsumersmatches on, so a redacted entry can never match a live stack name; rather than vanish from a DATA-LOSS[y/N]prompt it is now reported, region-narrowed, with a line saying cdkd cannot name the producer and that it may or may not be this stack — asserting that cdkd cannot rule the dependency out, never that it holds. Changed:src/deployment/deploy-engine.ts,src/cli/commands/recreate-downstream-consumers.ts. Issue go-to-k/cdkd#3289; the per-field provenance table, why redaction sits at persist rather than record time, and the no-bump reasoning are indocs/design/3289-cross-stack-read-name-redaction.md. Residuals:MIN_NEEDLE_LENGTHstill applies, so an EMBEDDED secret of 1-3 characters is not substituted (a whole-value one is, at any length); and a record an older binary wrote self-repairs only if a later deploy re-resolves that reference to the SAME value — if the secret has since ROTATED, or it is never re-resolved again, the stale plaintext survives, andcdkd scrubcannot reach the rotated one either.cdkd scrubis owed the repair (go-to-k/cdkd#3337).- Every
AWS::DynamoDB::TableOnDemandThroughputceiling now reaches AWS as a NUMBER, and a spelling CloudFormation rejects is dropped and named instead of forwarded (issue #3265) --src/provisioning/providers/dynamodb-table-provider.ts,tests/unit/provisioning/dynamodb-table-provider-cfn-integer.test.ts. Both members areLongin the SDK model, and all FOUR send sites forwarded the declared block verbatim, so a CFn-legalMaxReadRequestUnits: '100'went out as the STRING"100"and the request failed, while a' 100 '/'0x64'/'1e2'CloudFormation refuses the template for went out unchanged. All four now readcoerceCfnInteger, the measured DynamoDB Integer grammar every sibling capacity property has used since issue #3147. FOUR, not the three the issue enumerated:create()'s per-index forward is a CAST of the wholeGlobalSecondaryIndexesarray, invisible to any grep for the property name -- the blind spot that also hid itsProvisionedThroughputtwin. A rejected spelling drops the MEMBER and never the block, keeping a sibling the template spelled legally and leaving the drift-side "would cdkd send this block" gate unchanged. The announcement is a SEPARATE sentence from the capacity one on purpose: both ceiling members are OPTIONAL, so DynamoDB does not reject the request -- the deploy succeeds with no maximum applied for that half, and the warning says so rather than promising a failure that never comes. That success is also the residual the capacity twin escapes by failing at AWS: state keeps the declared spelling andcdkd driftreports it until the template is fixed, tracked as issue #3286. The two per-indexUpdateactions send no ceiling at all -- a missing send, issue #3287. --remove-protectionno longer stalls two minutes per resource when the network is down (issue #3253) --src/provisioning/cloud-control-provider.ts. The Cloud-Control deletion-protection flip (AWS::DSQL::Cluster,AWS::EKS::Cluster,AWS::RDS::GlobalClusterand their siblings) polls its ownUpdateResourcethroughwaitForOperation, so it inherited the transport-retry grace #3236 added -- two minutes of re-polling before giving up. That is dead wall clock there: the flip is best-effort by construction and every failure is swallowed, theDeleteResourcethat follows surfaces the real error if the protection is still on, and under the same outage that delete fails too. It now takes a 10-second grace instead, long enough to ride out the blip the retry exists for (the 1s -> 1.5s -> 2.25s -> 3.4s schedule fits inside it) and short enough that acdkd destroy --remove-protectionagainst a dead link fails in seconds rather than minutes per protected resource. The grace is a per-call argument towaitForOperationdefaulting to the two-minute value, so every provisioning call site is unchanged and a new one has to say why it differs. When the flip IS abandoned, the swallowed warning carries the request token and theaws cloudcontrol get-resource-request-statusline -- it always did, since the abandonment error builds them in; what this changes is only how long the user waits to see it. That warning also stopped quoting the caller back: it runs at DEFAULT verbosity, and AWS words a rejected flip asUser: arn:aws:sts::<acct>:assumed-role/<role>/<session> is not authorized to perform: ..., so a role lacking the update permission printed the account id, role and session name into the terminal and into any CI log. It now carries the wire name, with the full sentence atdebug.- The new Cloud Control abandonment error is refused by every already-deleted classifier, so a delete cdkd stopped watching is never recorded as done (issue #3236) --
src/provisioning/wait-abandoned.ts(new),src/provisioning/cloud-control-provider.ts,src/deployment/deploy-engine.ts,src/cli/commands/destroy-runner.ts. cdkd decides a failed DELETE means the resource is already gone by SUBSTRING-matching the error message, at four sites: the Cloud Control provider's owndelete()catch, the deploy engine's replacement delete-then-CREATE arm and its template-removal delete arm, and the destroy runner's per-resource delete loop.CloudControlWaitAbandonedError-- shipped by the sibling entry dated 2026-09-16 -- says the OPPOSITE, and is deliberately left RETRYABLE for DELETE and UPDATE so the delete can be re-issued, so it carries no non-retryable marker and clears every pre-existing typed guard. Its message interpolates the LOGICAL ID and the last-seen IDENTIFIER, both user- or template-chosen, so a resource namedPageNotFoundwould satisfy the needles: destroy would report success and drop the state row over a delete AWS may never have completed, and the replacement arm would create the replacement beside a resource whose delete was still in flight. All four sites test the newisWaitAbandonedErrorfirst; the replacement arm, which carried no typed guard at all, also gainsisInterruptedWaitErrorandisMarkedNonRetryable. A marker rather than an exported class, because three of the four cannot import the provider without closing a cycle.cleanupFailedCreateRemnanttakes the same guard for a sharper reason: itsisNotFoundMessageis case-INSENSITIVE, so agetaddrinfo ENOTFOUND ...cause matches/not\s*found/iand would suppress the real "remove it manually" warning over a remnant still holding the name. - A Cloud Control abandonment no longer persists AWS's raw rejection text, and no longer speaks in AWS's voice when it has nothing to say (issue #3236) --
src/provisioning/cloud-control-provider.ts,src/utils/aws-failure-text.ts.CloudControlWaitAbandonedError's message is copied verbatim into the durabledeployments/{runId}.jsonl, and it interpolates whatever the poll failed with. A 403 there carries the account id and the assumed-role ARN; aCredentialsProviderErrorcarries thecredential_processhelper's ARGV and its stderr (measured: a vault token). Both are now reduced to the wire error name, with the full text atdebugso--verbosestill diagnoses it. A TRANSPORT failure keeps its wording --connect ECONNREFUSED <ip>:443is the sentence this issue was reported with, and it names a host, never a caller. The discriminator is a real service signal ($fault, or a NUMERIC$metadata.httpStatusCode) plus the*ProviderErrorfamily, which has neither because credential resolution runs outside the retry middleware that stamps$metadata, measured. Three shapes that previously rendered as nonsense or as a leak: an ABSENT cause (the ordinary slow-resource timeout) adds no clause now instead ofa non-Error value of type undefined; a non-Errorrejection is WITHHELD but reported by type rather than dropped; and anErrorthe SDK normalized to an empty message renders no dangling colon.describeAwsFailurealso stopped being able to out-throw the failure it describes:String(value)throws for a null-prototype object, which replaced the abandonment with aTypeError. The sharedisAwsAuthoredFailurekeys on$metadataPRESENCE, which smithy stamps on socket errors too, so this path carries its own predicate; retiring that divergence is #3297. Per-arm reasoning, including the shapes deliberately NOT matched, is atabandonWait. Fn::GetStackOutputno longer prints a producer stack name or region in the clear when the state read fails (issue #3234) --src/deployment/intrinsic-function-resolver.ts,src/deployment/secret-redaction.ts, cases inintrinsic-resolver-name-argument-log-twin.test.ts. The resolver masked every name it printed itself, then handed the RAWStackNameandRegionto the state read -- they are state-key segments -- andS3StateBackendquoted them back through an ASCII sanitizer, not a masker. The read was uncaught, so that sentence reachedevaluateConditions' default-verbosity warn, and every other caller as a throw, with anFn::Sub-assembled 1-3 character secret readable. The read is now wrapped and the failure re-thrown as a masked CLONE of the whole cause chain rather than a new wrapper:formatErrorrendersCaused by: <cause>, so masking a fresh top-level message alone would print the original one link down. The clone keeps every own descriptor, somarkNonRetryable's marker,$metadata,Codeandnamestill reach the retry classifiers.maskSecretsInErrortakes an optional text transform for this, since a bag masks by VALUE and cannot see a sub-floor secret embedded in a longer name; it substitutes thedisplaySafe-sanitized spelling as well, that being what the reader is shown. TheDescribeStacksfallback warn takes the same pass, since a realAccessDeniedquotes the stack ARN back at default verbosity. Residuals: a name the state backend LOGS directly rather than throwing (its legacy-layout warn) cannot be reached from the caller's catch at all; theFn::ImportValuesibling masks its caught message with the bags alone, so the positional half is still open there; and a log twin keeps the template's literal parts verbatim, so the resolver's own lines can still print a control character a name carried --maskSecretsForLogmasks, it does not sanitize.cdkd deployno longer REPLACES a live resource because of an unreadablepropertiesmap in state (issue #3191) --src/analyzer/diff-calculator.ts,src/state/malformed-resources-bag.ts,src/cli/commands/diff-recursive.ts, plusdiff-calculator-malformed-properties.test.ts,diff-recursive-malformed-properties.test.ts,deploy-engine-malformed-properties-refusal.test.tsand cases inmalformed-resources-bag.test.ts.parseStateBodyvalidates nothing inside a record, so a resource'spropertiescould be a string, a list, a number ornull-- from a hand edit, a torn write, or anyone holdings3:PutObjecton the state bucket -- and the change calculation read it with no check. A non-object compares unequal to every declared property, so the whole template read as absent-in-current: measured, anAWS::S3::BucketdeclaringBucketNameagainst a stored"abcdef"reachedrequiresReplacement: true, a DELETE + CREATE of the live bucket.calculateDiffnow REFUSES (STATE_RESOURCES_MALFORMED, exit1) before provisioning or writing state, naming up to five damaged records then a count -- under--dry-runtoo, because the plan a dry run prints is built from that same comparison. Repairing to{}is no safer and is not offered: a stored[]and a stored5enumerate no keys -- they ARE the repaired case -- and each reached the same verdict. The refusal names NO stack or region (the only pair in reach is the record's own unvalidated self-report, which would aim the remedy at a healthy stack), and it renders each logical id throughdisplayIdent, so a padded"Bucket "no longer prints as a bareBucketindistinguishable from a healthy sibling.cdkd diffstill previews: it repairs those maps to empty and warns, naming the records and the caller's own stack, at its load AND again after splicing adopted rollback orphans in. An absentpropertiesis refused; an empty{}is healthy.cdkd scrubno longer stops learning a value's secrets at the first reference it cannot fetch — the resolver walked a string's{{resolve:...}}tokens, an object's keys, and anFn::Subvariable map in bare sequential loops with no per-unittry, so one unfetchable reference, or one unresolvable key beside one, abandoned every later unit in the same value. A leaf holding{{resolve:ssm-secure:/deleted/param}}before a live{{resolve:secretsmanager:prod/db:SecretString:password}}never fetched the second one, so recorded no needle for it — and a needle is the only thing that drives redaction, so a plaintext already instate.jsonsurvived acdkd scrubthat reported the stack clean. Each unit now resolves under its owntry: a failed FETCH is recorded and the walk continues, so the later reference is scanned and redacted.cdkd scrubopts in at the three passes that discard the resolved value (resource properties, orphan records, output values);Export.Namekeeps its value and stays on the previous abort. An abandoned unit is still counted and warned about (#3160), judged per unit and folded to the most severe — so a genuinely unfetched reference still gates--dry-run --faileven behind a template-shape failure, while an abandoned key carrying no reference of its own does not count. A deliberate REFUSAL still aborts the walk: a region-ambiguous reference, anssm-securespelling over a public parameter, and the NAMELESS spellings ({{resolve:secretsmanager:}}, #2692). The recovery is OPT-IN viaResolverContext.abandonedResolutions, and a caller that keeps its resolved value must fail on a non-empty bag;deploy,diff,driftandrollbackpass no bag and keep the previous abort. One exception: a region no AWS client can safely be built for now raises a typed refusal on every path, bagless ones included, where it previously degraded. Issues #3181 and #3218; #3160 is the counted-finding half and #3196 the per-property half.cdkd gcandcdkd bootstrap --destroystop rendering an S3 state key's segments raw, and gc stops building a pasteable command out of one (issue #3179 section C) --src/cli/commands/{state-file-keys,gc,bootstrap-destroy}.ts, three suites. A state-bucket key is chosen by anyone holdings3:PutObject, the premise #3207 already rests on for the record BODY;describeStateKeysplit one intostack (region)and every reader put that in front of an operator deciding whether to destroy something. Measured before the change: a segmentProd<ESC>[2K<CR>SafeStackrendered as itself, so a terminal ERASED the line and showedSafeStack (us-east-1)-- a different stack than the record names. Both halves now render throughdisplayIdent, and so do the raw key beside each row, the marker-sibling region list, and threelogger.debugkeys -- guarding one and not the others is inert, since the same escape reaches the same line either way. A whole KEY renders at its own cap, since a key CONTAINS a stack name. The worse reader was one the issue did not name:gc.tsre-parsed its own rendered string to build acdkd state show ...command it tells the operator to RUN, which measured out ascdkd state show $(whoami) --stack-region us-east-1. It now takes the parts from a new structuralparseStateKeyand offers the command ONLY when both passisPasteableIdent, else names the key. That predicate is an allow-list plus thedisplayIdentround-trip; the deny-list written first was defeated in review by a leading~, and the measured shell behaviour behind the set is recorded on the constant's own doc comment. Legitimate names and regions render byte-identically. Sections A, B, D, E and F of the issue stay open (F is a completeness note aboutdisplayIdent's cut marker, in the same held file); go-to-k/cdkd#3313 is a separate listing defect this surfaced.cdkd destroyno longer reports SUCCESS having deleted nothing over a record whoseresourcesmap cannot be read, andcdkd deployno longer re-creates the stack from one (issue #3161) --src/cli/commands/destroy-runner.ts,src/deployment/deploy-engine.ts,src/provisioning/providers/nested-stack-provider.ts,src/state/malformed-resources-bag.ts,diff-calculator.ts, plus three new suites and cases in three others.parseStateBodyvalidates nothing inside a record, soresourcescould hold a string, a list, a number, a boolean ornull, and the destroy routed onObject.keys(state.resources).length— which splits three ways, the middle one being the damage:[], a number or a boolean enumerate NO keys, so the count was0, the run took the empty-stack fast path, deletedstate.jsonwith no confirmation, and reported success, leaving every resource it named live in AWS and untraceable. A string enumerated one id per character;nulland an absent field threw a bareTypeError. The deploy read the same map and planned every template resource as aCREATE. All three now REFUSE (STATE_RESOURCES_MALFORMED, exit1) above their first read: the destroy before the prompt AND the lock, and again on the record the fast path re-reads; the deploy before any provider call or state write, under--dry-runtoo; a nested CHILD record before the parent counts it. Repairing to{}is not the safe half:[], a number and a boolean ARE the repaired case and each reaches the same outcome, driven through the real runner andDiffCalculator. The discriminator is the container's SHAPE, not its size, so an empty{}still takes the fast path and still deploys. The destroy refusal namescdkd state orphanfor dropping the record and leaving the resources -- as a template, not a pasteable command, and withheld when the identity does not render exactly.cdkd diffandstate showstill repair and warn.- A 1-3 character secret in a nested-stack parameter spelled as an
Fn::Join/Fn::Subaround anssmtoken (or a service left to aRef), or with aRefoutside the token, is no longer persisted in plaintext in the child, the parent's row or a grandchild's log lines (issue #3156) --src/deployment/secret-redaction.ts,src/deployment/intrinsic-function-resolver.ts; cases intests/unit/deployment/nested-stack-refused-frame-carry.test.tsandnested-stack-refused-frame-carry-delegation.test.ts, aFramedarm in thenested-stack-3levelinteg. The sub-floor carry certified an intrinsic frame only on asecretsmanager:/ssm-secure:spelling the position pass wrote. The resolver now records, perFn::Join/Fn::Subobject and pass, each token it replaced with that replacement's own verdict (fresh, cached or from a region-pinned sibling); a second, different resolution of the object poisons the record. The carry certifies a frame when exactly one secret replacement produced the value, and where the position pass wrote the leaf it must have written that spelling. The middle stack's bag then holds the whole value, so the grandchild'sParameter,RefandFn::Joinlines mask it whole, pass-through and re-wrapped alike;nested-stack-secret'sSubFloorPinSsmlines now read***, notport:***. Still refused (listed onrecordNestedStackParameterExpressions): a public or multiple replacement, a token inside a nested intrinsic part or a usedFn::Subvariable, anFn::Ifaround the frame, an affix holding another recorded secret, and two outside-the-token frames of one value through different tokens (#3306). Resolved values are unchanged.
Recently Implemented (2026-09-16):
- Every per-GSI
ProvisionedThroughputnow reaches AWS as a NUMBER, and a spelling CloudFormation rejects is dropped and named instead of forwarded (issue #3255 item 1, widened by its review) --src/provisioning/providers/dynamodb-table-provider.ts,tests/unit/provisioning/dynamodb-table-provider-cfn-integer.test.ts. FOUR per-index send sites forwarded the declared block verbatim:create()'sCreateTablemapper and all threeapplyGsiUpdatesactions (the adopted-index repair, theCreateand the same-nameUpdate). So a CFn-legalReadCapacityUnits: '7'went out as the STRING"7"in anumberfield and a' 7 '/'0x9'/'1e1'CloudFormation refuses the template for went out unchanged -- while the BillingMode-flip reader (readCapacityNumber) has read CloudFormation's measured DynamoDB Integer grammar (/^[+-]?\d+$/, no trim) since issue #3147. All four now read through it. Fixing onlycreate()would have INVERTED the divergence rather than closing it -- one template succeeding on a fresh create and rejected when the same index was added later -- which is why the review pulled the other three in. A DECLARED member the grammar rejects is dropped and ANNOUNCED; an ABSENT member stays absent (the per-index reader deliberately has no5default); a non-object block is forwarded verbatim. The GUARDS around the update sites (liveCapacityAlreadyMatches,skipZeroCapacityIndexUpdate, the change detector) deliberately stay on the widertoFiniteNumber, so no deploy changes which calls it issues, only what they carry. The rejected spelling drops the MEMBER, not the block, so DynamoDB names the exact index position; reasoning for each decision is oncoerceIndexCapacityBlockandindexCapacityForSend. Item 2 of the issue (RecoveryPeriodInDays) is untouched and it stays open for that. cdkd diff --recursivenow names the cycle when a nested template's asset path closes one, instead of failing ~190 levels later on an S3 key (issue #3239) --src/cli/commands/diff-recursive.ts,docs/cli-diff.md,tests/unit/cli/diff-recursive.test.ts. The TEMPLATE arm of the recursive walk located each nested child through itsMetadata['aws:asset:path']with no visited set and no self-reference check:indexNestedChildTemplatesrefuses an ABSOLUTE path while a RELATIVE one was joined unconditionally. A path resolving back onto the same nesting chain therefore walked the cycle, appending one~<childLogicalId>segment to the nested state key per level, until the state read failed withYour key is too longagainst a ~190-segment stack name that does not exist -- roughly 190 pointless S3 reads, no diff printed, and an error naming neither the cycle nor the asset path. So that arm was always bounded, by the same 1024-byte key limit as the state-only arm; what it lacked was a diagnosis.buildDiffTreenow threads the resolved absolute template paths of the ancestor chain down the walk and REFUSES a repeat, naming the logical id, the owning stack and the path that closed the cycle, and exits 1. It catches the first repeat, except for a cycle returning to the stack's own top-level template -- reached by name rather than by a nested asset path -- which is caught one level further down. A refusal rather than a depth cap, and an ancestor chain rather than a global visited set, so two sibling rows naming one template still diff and nesting depth is never limited; the reasoning for both is at the guard. All three interpolations in that refusal go throughdisplaySafe, and so do the two in the sibling absolute-path refusal, since every one of those values comes from the hand-modified assembly those messages exist for.- A network blip during a Cloud Control wait no longer discards the request token and leaves the resource untracked (issue #3236) --
src/provisioning/cloud-control-provider.ts.waitForOperationpolledGetResourceRequestStatuswith no per-poll error handling, so a transport failure propagated out of the loop and took theRequestToken-- which lived only in that parameter list -- with it. The operation kept running server-side, so a CREATE that AWS went on to complete left a LIVE resource with no state record: invisible to rollback, tocdkd destroy, and tocleanupFailedCreateRemnant, whose first guard admits only aCloudControlOperationFailedError. Reported against anAWS::RDS::DBInstancethat reachedavailablewhile a VPN dropped; the untracked instance then blocked the deletion of five tracked resources. The poll is now retried on the SAME token -- idempotent in a way replaying the create is not, sinceGetResourceRequestStatusinvokes no resource handler, so #2039's duplicate-create is structurally absent. Throttles and transient 5xx are covered too, and were the worse case: aborting propagated AWS'sRate exceeded, which the retry classifier matches, so the outerwithRetryre-invokedcreate()against a resource already being created. The retry stays inside the existing wall-clock budget, bounded by a two-minute grace over one unbroken run of failures. When cdkd does stop waiting -- at the grace, at the deadline (which lost the token for the same reason), or on a failure too non-transient to retry -- it throws the newCloudControlWaitAbandonedErrorcarrying the token, the lastIdentifierany poll reported, and a pasteableaws cloudcontrol get-resource-request-statusline. Fixes all fourwaitForOperationcall sites, not just the reported CREATE. Mechanism and the per-operation retryability rule are in the module's own doc comments. - A Lambda Managed Instances capacity provider created alongside its security group now RETRIES
One or more security group IDs are invalidand theec2:DescribeSecurityGroupsrejection, instead of failing on the first one (issue #3227) --src/deployment/retryable-errors.ts. The create is issued within two seconds of the same stack's security group and operator role, and two more wordings of that race matched noIAM_PROPAGATION_ERROR_MESSAGE_PATTERNSentry, so neither was retried:One or more security group IDs are invalidanddoesn't have permission to perform ec2:DescribeSecurityGroups, which the list'snot authorized to performdoes not cover. Three fresh deploys from empty state failed on the first wording. Both are now IAM-propagation patterns, so the create rides the dense 26-retry / 47.75s grid that issue #3174's operator-role entry already used. Measured inside that budget: the role andec2:stages (a probe created 13s and 14s after the role), and one live security-group occurrence that created ~4s after its first rejection -- one sample, so not a bound. What broke was a SEQUENCE: one retry, then an unmatched wording ended the loop. Neither entry is scoped to Lambda or to a status code, so a genuinely wrong security group id, or a role genuinely missing that permission, spends the bounded retries before surfacing -- the trade this table already makes forInvalid IAM Instance Profile. The CAUSE is not established and the entries do not claim one: role permissions still propagating and the security group not yet visible to Lambda both fit, and the wording did not reproduce outside cdkd in six probe shapes. Live,tests/integration/lambda-capacity-provider-default-name/in ap-northeast-1: one run failed a create's FIRST attempt on the security-group wording, retried and created; another retried theec2:wording on attempt 2. - A create-only change to a cdkd-named ELBv2 target group deploys again, and a rollback that must delete the new resource first can reach that path (issue #3208) --
src/deployment/retryable-errors.ts,src/deployment/deploy-engine.ts,src/deployment/rollback-executor.ts.isNameCollisionErrorrecognised onlyalready exist(s)and theAlreadyExistscode, and ELBv2 says neither: measured live (us-east-1, 2026-09-16) a duplicate name answersDuplicateTargetGroupNameExceptionwithA target group with the same name 'x' exists, but with different settings, and the NAME never reached the predicate because providers renderFailed to create X: ${err.message}. Both consumers of that predicate are RECOVERY paths, so both went inert for such a service:cdkd deploy --replace's delete-first fallback never engaged, making a create-only change to a cdkd-named target group undeployable with or without the flag (the deploy died at the forward replacement reportingNo completed operations to roll back), and the rollback executor's delete-new-first arm could not be reached either. NewisNameCollisionErrorFromreads the ERROR instead, walking the boundedcausechain for an exception NAME inNAME_COLLISION_ERROR_NAMES, ANCHORED on the resource's logical id so a rejection naming another resource cannot classify this one, and reading the existing message test at depth 0 only -- which keeps the message half exactly as wide as before. All four call sites holding the error object now use it; the string form is unchanged for callers that have only text. The admission rule for that list, and whyDuplicateListenerException/DuplicateTagKeysExceptionare excluded, are in.claude/rules/name-collision-classification.md. Live coverage:tests/integration/elbv2-same-name-replacement/, measured failing on a pre-fix binary and passing with the fix. - The six gate-scoped consumers of a non-object
outputsbag stop fabricating from it,cdkd deployandcdkd destroyincluded (issue #3207) --src/deployment/{deploy-engine,intrinsic-function-resolver}.ts,src/cli/commands/{destroy-runner,local-state-loader,scrub}.ts,src/provisioning/providers/nested-stack-provider.ts,src/local/s3-local-state-provider.ts,src/state/malformed-resources-bag.ts,src/utils/error-handler.ts, plus their docs and suites. #3192 scoped these out for sitting in a real-AWS integ gate scope, not for taking a different answer; each now tests the bag through the sharedhasReadableOutputsat its LOAD. Refuse versus repair is per site;docs/design/3192-outputs-consumers.md§4 records each call.cdkd deploy,cdkd destroy/state destroy, and a nested stack's CHILD record REFUSE (STATE_RESOURCES_MALFORMED, exit 1) -- no new exit code;cdkd local *(--from-state) REPAIRS and WARNS once per damaged RECORD. Destroy is the one where repairing is unsafe, not merely lossy: the bag decides whether the cross-stack check runs, and anull/ number / boolean reads as "exports nothing", SKIPPING it and deleting a producer other stacks import from. The one site that RE-APPLIES rather than displays is the resolver'sFn::GetStackOutputarm:Object.hasOwn('abcdef','0')is true, soOutputName: "0"resolved the characteraand the deploy SENT it to AWS. It refuses with a newMalformedProducerRecordRefusalError, anIntrinsicResolutionRefusalErrorsubclass so anFn::Subcannot launder it -- and socdkd scrubkeeps #3192's answer: the consumer stack is still scrubbed, the read counted, exit 2. An ABSENT field stays exempt; a healthy or empty bag is silent. Theresourcesbag at these sites stays open on #3161 / #3202. - A rollback no longer strips every property off a live resource when the record it would restore has no usable
propertiesbag (issue #3203) --src/deployment/rollback-executor.ts, one new suite. All three replay arms coalesced the desired bag (desiredProps ?? {}), and{}is not a no-op: it is a complete desired state saying "this resource has no properties". A patch provider then removes every key; an SDK provider may reset a subset or, where the bag names the resource, replace it; and the reverse-replacement arm CREATES a default-configured resource and DELETES the live one. Each arm now refuses to replay such a bag, and the disposition follows what the record HOLDS: an ABSENT bag is SKIPPED (nothing was recorded, so nothing is repairable — theskip-failed-absentprecedent), while a PRESENT but unusable one (null, a string, an array) FAILS the op, keeping the journal so the record can be repaired and the rollback re-run — therefuseMaskedReplayBaselineprecedent. Either way the pass continues. Each message names the remedy its arm supports:cdkd deploy, orcdkd drifton--revert-failed, where the op died mid-flight socdkd diff(which readsstate.properties, not AWS) would report no change. The predicate tests "is this a plain object", not "is it absent":null ?? {}is also{}-- shapes issue #3149 refuses at the parser for a JOURNAL-sourced record, whilepreviousStateis equally often STATE-sourced, where nothing validates them. A present but empty{}still replays: a resource that really has no properties is a legitimate thing to restore to. Recorded rather than implied: thereverse-replacement-readoptarm writes such a record intostate.json, but it sends NO desired bag to a provider, so it is issue #3211's class rather than this one's. - A rollback that reverses a replacement now re-creates the old resource under the name cdkd would have minted, instead of letting AWS invent one (issue #3199) --
src/deployment/rollback-executor.ts. Thereverse-replacementreplay-CREATE handed the provider the RECORDED property bag, which holds the template's resolved properties and so never carries a name cdkd generated. The three create sites inDeployEnginefill aFALLBACK_NAME_RULESname before a Cloud Control create; these two replay sites were the fourth and filled nothing, so a Cloud-Control-routed replay re-created under an AWS-random physical name and the restored resource silently stopped matching what the forward path mints. Both sites now run the bag throughapplyDefaultNameForFallbackwhen the registry's ROUTING DECISION -- not the recordedprovisionedByhint, which is absent on a pre-v7 record and can disagree via the sticky rule'ssdk-coverageexemption -- sayscc-api; an SDK-routed replay is untouched, since its provider mints the name itself. Both rollback entry points already bindwithStackName, so the replayed name is byte-identical to the forward create's. The fill lands only on the bag handed tocreate(); the state record is still rebuilt from the recorded bag, keeping the "a recorded bag never holds a generated name" invariant the Cloud Control UPDATE path depends on. Reachable whenever a replacement changes the physical id while the recorded bag names nothing -- adding an explicit name to a previously unnamed resource, but also any create-only change on a table type whose physical id is not its name (AWS::ElasticLoadBalancingV2::TargetGrouponPort/VpcId,AWS::WAFv2::WebACLonScope) -- and a later resource then fails. A type whose Cloud Control handler requires the name would fail the replay outright, stranding the resource; none is in the table yet. cdkd scrubno longer loses a secret reference because an unrelated property in the same resource failed to resolve — the per-resource pass handedresolver.resolvethe WHOLEPropertiesbag, and one throw aborts the walk, so an unresolvableRefin property A abandoned the{{resolve:secretsmanager:...}}in property B. B recorded no needle, its legacy plaintext was never rewritten, and the stack could still printNo plaintext secrets foundat exit 0. Reachable by accident —scrubStackcatchesresolveParameterswholesale and continues with an EMPTY parameter bag, so ONE parameter with noDefaultmakes every{Ref: <param>}in the stack throw — and, on a repo runningcdkd scrub --all --dry-run --failin CI, defeatable on purpose by putting one danglingRefahead of a secret. The resolve is now scoped per TOP-LEVEL PROPERTY: a failure is confined to the property that caused it and its siblings are still scanned, so the needle is recorded and the plaintext rewritten. Warnings name the property (resource 'Db' property 'DBSubnetGroupName') rather than just the record. Nothing else changes: the context is per-RESOURCE, so every property still accumulates into the same recorded-secret map;pinCrossRegionSecretsalready walked per-leaf and returns the same shape; a CloudFormation intrinsic references other RESOURCES, never a sibling property; andresolveCrossStackReadsstays whole-bag because its refusals are per-stack. A non-object bag (array, bare value,null) is resolved whole, so the scoping can never drop a unit. Scoped to the TOP-LEVEL property: inside one property's value a failing key still stops the keys after it (Environment.Variables.Ataking out...B), because the resolver's own object walk has no per-keytry— #3218 tracks that. Issue #3196; the counted-finding half is #3160 and the resolver's per-token loop is #3181.cdkd orphan,importandscrubrefuse a state record whoseoutputsbag is not an object instead of saving a rebuilt one over it;cdkd scrubexits 2 when a producer it imports from is unreadable; and the shared export-set predicate fails closed (issue #3192) --src/types/state.ts,src/state/{malformed-resources-bag,export-index-store}.ts,src/cli/commands/{orphan,import,scrub,diff-recursive}.ts, fourdocs/pages,docs/design/3192-outputs-consumers.md, cases across 10 test suites plus a newtests/unit/state/importable-outputs-malformed.test.ts. Sibling sweep of #3189, which guardedcdkd diffalone. Two WRITE paths, confirmed by execution:cdkd orphanrebuiltoutputs: 'abcdef'into{"0":"a",..}and SAVED it, destroying the only signal the record was damaged; and a string bag published one fabricated export per character into the sharedexports.json. Such loads now REFUSE (STATE_RESOURCES_MALFORMED), naming the broken container. Exit-code change: acdkd scrubwhose CONSUMER is healthy but whose PRODUCER record cannot be read now raisesSCRUB_PRODUCER_RECORD_UNREADABLEand exits 2 — with or without--fail,--dry-runincluded — rather than exiting 0 over a stack whose imported plaintext it could not verify (docs/cli-scrub.mdcarries the table). TheexportNamesFIELD is a further site: a hand-edited non-array killedcdkd diffwith a rawTypeError, and now reads as an EMPTY export set, never the pre-v9 every-key rule re-opening #2193's shadowing;cdkd diffWARNS, naming stack and region. The same predicate covers the deploy resolver andcdkd local. An ABSENToutputsfield stays exempt; a healthy or empty bag is untouched and silent. Sites left unguarded are in the design doc and #3207.cdkd diffno longer invents OutputsREMOVErows from a storedoutputsbag that is not an object (issue #3189) --src/cli/commands/diff-recursive.ts,src/analyzer/outputs-diff.ts,src/state/malformed-resources-bag.ts,docs/cli-diff.md,tests/unit/cli/diff-recursive-malformed-outputs.test.ts,tests/unit/analyzer/outputs-diff.test.ts,tests/unit/state/malformed-resources-bag.test.ts. A state record is read as an unchecked cast, socomputeOutputsDiffadmitted the stored bag on a bare?? {}-- which coversnullandundefinedonly.Object.entriesthen walked a string or a list as readily as a map: measured ataced052a,'abcdef'produced sixREMOVErows named"0".."5", each carrying one character of the record as itsold:side,[1,2,3]produced three, andcdkd diff --failexited 1 on them. The bag is now repaired AT THE LOAD (loadStateOrEmpty), through theisReadableBagpredicate theresourcesguard already used, withmalformedOutputsWarningnaming the stack, the container and what continuing empty means -- judged per node, so a healthy parent can sit above a malformed nested child. The load dominates every consumer:resolveTemplateOutputs's stored-key lookups for issues #2740 and #1942 answeredhasOwnPropertyTRUE on a string for'0'/'length'and THREW outright onnull, one call above the walk.computeOutputsDifftakes the same predicate as a second line of defence. Reading the bag as EMPTY rather than refusing keeps the preview usable: every resolved output is reported as anADDand no stored key as aREMOVE. An ABSENToutputsfield is exempt -- inert on this path, and a record cdkd itself writes. A healthy or empty bag is untouched and silent;cdkd diffwrites no state, so the repair is in memory.cdkd state showandcdkd state resourcesno longer invent rows from anoutputs,skippedOutputs,attributesorpropertiesthat is not an object (issue #3187) --src/cli/commands/state.ts,src/state/malformed-resources-bag.ts,docs/cli-state.md,tests/unit/cli/state-record-shape.test.ts,tests/unit/state/malformed-resources-bag.test.ts. Issue #3172 guarded theresourcesbag; these four containers are walked by the same renderers with the sameObject.entries, out of the same unchecked cast, and kept fabricating a row per character or element. A view reports only the containers IT renders, socdkd state resourcesspeaks aboutattributesin--longand--jsonand is silent in its plain listing. Measured on the shipped bundle:cdkd state showover a HEALTHY-bag record whoseoutputsheld a 5,000,000-character string cost ~1616 ms / ~1010 MB RSS and emitted 5,000,002 lines;skippedOutputscost ~2436 ms / ~2148 MB, and--show-nestedpays it per node. Each unreadable container is now emptied at the render ENTRY -- through the plain-object predicateisReadableBagtheresourcesguard already used, never a second spelling at the four walks -- and one warning per record (malformedRenderedContainersWarning) names every container that was emptied, once, whatever the resource count.cdkd state showrenders the record without that block (noOutputs:, noSkipped outputs:,Attributes: (none),Properties: (none)), judged per node under--show-nested;cdkd state show --jsonstill emits the stored value verbatim so the evidence survives. An absent ornullcontainer is NOT a defect here, unlike theresourcesbag:skippedOutputsis absent on every pre-#2740 record andattributesis optional, neither can invent a row, and both render exactly as an empty one with nothing said. A healthy container is untouched and silent.cdkd state resourcesandcdkd state showno longer invent resource rows from aresourcesbag that is not an object (issue #3172) --src/cli/commands/state.ts,src/cli/commands/export.ts,docs/cli-state.md,docs/cli-flag-coverage.*+docs/_generated/integ-last-run.tsv(regenerated),tests/unit/cli/state-record-shape.test.ts,tests/unit/scripts/integ-aws-commands.test.ts,tests/integration/state-info-command/verify.sh. A state record is read as an unchecked cast, so a hand-edited or truncated one can carry a string, a list, a number or a boolean inresources.Object.entriesaccepts all four:"abcdef"yielded six[index, character]pairs, rendered bycdkd state resourcesas six resources that do not exist -- in--jsontoo -- and bycdkd state show's text view under aResources (6):header. One pair per element also made a planted multi-megabyte bag a memory-exhaustion path. Both commands now repair the bag throughrepairMalformedResourcesForReadOnlyand warn throughmalformedResourcesWarning(src/state/malformed-resources-bag.ts), the treatmentcdkd diffalready takes; neither refuses.cdkd state resourcesrepairs at its LOAD, covering both output modes;cdkd state showrepairs after every--jsonbranch, per node of the--show-nestedtree, socdkd state show --jsonstill emits the stored bag verbatim, preserving the evidence. The nested-stack walker reads the bag before either, so it now returns a childless node on an unreadable one at EVERY depth: that closes the allocation under--show-nested(both modes) plus a hard failure where a bag edited from a map into a LIST of resource objects made the walk seek a child at<parent>~0and abort. An absent ornullbag warns too, rendering unchanged;cdkd state list --longstill counts those as0. A healthy or empty bag is untouched and silent.cdkd state list --treeno longer loses the WHOLE listing to one deep parent chain (issue #3155) --src/cli/commands/state-list-tree.ts,src/cli/commands/state.ts,docs/cli-state.md,.claude/rules/layout-cli.md,tests/unit/cli/{state-list-tree,state-list,state-list-tree-cap-doc-sync}.test.ts. Four steps in the--treepath recursed once per level of the tree -- the child sort insidebuildStackTree, the box-drawing renderer,stackTreeToJson, and theJSON.stringifythat serializes it for--tree --json-- and NONE of them sits insiderenderTreeMode's per-stack guard, which covers only the state READ. So a long enough chain ofparentStacklinks ended the command with aRangeErrorand printed no rows at all, for every stack in the bucket, not just the chain's own.buildStackTreenow caps a node's depth at 100 levels below its root: one that would sit deeper is placed at the root instead, the treatment an orphan or a parent-loop member already gets, and the chain resumes there as its own tree. That bounds all four steps at once;MAX_STACK_TREE_DEPTH's doc comment carries why rewriting any single step iteratively could not. Every record still appears exactly once, and a tree within the cap renders byte-identically, so no ordinary listing changes -- CloudFormation stops at five levels of nested stacks, so an app that also deploys through CloudFormation cannot exceed it; cdkd's own nested-stack engine imposes no limit, so a deeper tree IS possible and now stays readable instead of failing. The depth is computed in one non-recursive pass over the parent links, like the existing loop detection; the child sort still recurses, bounded by the cap like every other step. This also covers a deep chain hanging off a parent LOOP, which go-to-k/cdkd#3157 made reachable by correctly rooting every loop member.- A planted
rollback-journal.jsoncan no longer hand a provider a string where a property bag belongs, or flip the replacement arm with a non-boolean flag (issue #3149) --src/types/rollback-journal.ts. Issue #3140 refused an operation's own scalar fields; the NESTED records were still unchecked casts, so apreviousStateof"x"lost the whole desired bag — every arm coalescing with?? {}— and a patch provider applies that by removing every property; apropertiesof"abc"(on the operation or insidepreviousState) reached a provider verbatim, a non-stringpreviousState.physicalIdwas rendered, compared AND written back intostate.json, andoldResourceRetained: "no"selected the readopt arm through??-- skipping the re-create and re-pointing state at the old physical id.parseRollbackJournalrefuses each, by index and TYPE with the value never echoed. The rule is refuse what no handling can make correct, tolerate what the state boundary tolerates: every check is TYPE-when-present, sincepreviousStateis forwarded verbatim from a state recordparseStateBodydeliberately does not validate, andprovisionedByis not refused at all -- an unrecognised value only routes to the SDK provider, so refusing it would lockcdkd rollbackout of a journal cdkd itself wrote. Thestate.jsonhalf of the issue was refused against the placement decisionparseStateBodyrecords, and its remainder tracked as issues #3018 / #3191 / #3211; an absentpropertiesstill reaches an arm that coalesces it to an empty desired bag, which is #3203. - The
AWS::DynamoDB::Tablecapacity and PITR forwarders read an Integer the way CloudFormation does, instead of forwarding a bareNumber()reading (issue #3147) --src/provisioning/providers/dynamodb-table-provider.ts,tests/unit/provisioning/dynamodb-table-provider-cfn-integer.test.ts,.claude/rules/layout-provisioning.md. Issue [#3135] moved everytoFiniteNumber-reading DynamoDB forwarder ontocoerceCfnInteger, CloudFormation's MEASURED grammar (/^[+-]?\d+$/, NO trim). That sweep greppedtoFiniteNumber, so this provider's reads never appeared: they go throughNumber(), wider still. Five sites move:create()'s table-levelProvisionedThroughput, its twin onupdate()'s BillingMode-flip arm, the per-GSIreadCapacityNumber, andrecoveryPeriodInDays. A template spelling" 7 "/"0x9"/"1e1"-- every one rejected by CloudFormation -- reached AWS as7/9/10, and""as0(not the?? 5default:''is not nullish). Per site, a rejected spelling is now ANNOUNCED and DROPPED, never silently defaulted: the table-level forwarders omit the member so DynamoDB rejects the request naming it -- the arm this type already takes for an absent required capacity -- the per-GSI one keeps its omit-and-warn arm, and an ABSENT member keeps the silent?? 5default.RecoveryPeriodInDaysis dropped with a warning, so PITR enables at DynamoDB's default period rather than failing onNaN; warn not throw, since the update path is replay-reachable. The MIRROR predicateshasUsableTableCapacity/hasUsableDeclaredCapacitymove onto the same reader, closing a divergence the other way:Number(null)was0, so a DECLAREDnullread as unusable and refused the pre-flip index removal while the forwarder's?? 5sent 5 and AWS accepted the flip. Behavior change: those spellings now fail the deploy at AWS instead of deploying a capacity CloudFormation refuses. - A rollback of a log group whose state record spells
RetentionInDaysfalsy now completes instead of being refused (issue #3141) --src/types/resource.ts,src/deployment/rollback-executor.ts,src/provisioning/providers/logs-loggroup-provider.ts,tests/unit/provisioning/logs-loggroup-provider-retention-coercion.test.ts,tests/unit/deployment/{rollback-executor,rollback-executor-secret-redaction,deploy-engine-provider-secret-masker}.test.ts,tests/unit/cli/commands/rollback.test.ts,docs/provider-development.md,docs/provider-rules.md,.claude/rules/{provider-diff-record-folds,provider-replay-and-refusals,layout-deployment}.md. Since issue [#2699]applyUpdaterefuses aRetentionInDaysof0/'0'/false/nullon a template bag, admitting0only underUpdateContext.desiredFromAwsReadback. The rollback executor's two revert arms handupdate()the journaledpreviousState.propertiesand set no such flag -- correctly, since that bag is a template recorded earlier, not an AWS readback -- andUpdateContexthad noreplayingStatecounterpart toCreateContext's. So a record spelling the retention falsy (a pre-#2699 template cdkd accepted, ordrift --acceptwriting a readback0onto a record with noobservedProperties) was REFUSED where it used to take the delete arm: the rollback op failed and the resource stayed at the failed deploy's retention.UpdateContextnow carriesreplayingState, set by both revert arms, and under it the provider reproduces what the record CERTIFIES -- a spelling the writing binary read as a positive integer throughNumber()('0x1e','30.0') is RESTORED as that integer, everything else takes the delete arm, both with a warning naming the spelling. This is the update twin of the create-path downgrade #3137 shipped. Template bags anddrift --revertkeep the refusal unchanged; the flag licenses the downgrade and nothing else. - The four remaining HAND-QUOTED pasteable
aws ...commands in provider messages now sanitize, shell-quote and suppress their id -- and a secret-bearing id is suppressed too (issue #3136, the hand-quoted half) --src/provisioning/providers/ssm-parameter-provider.ts,src/provisioning/providers/s3-bucket-provider.ts,src/provisioning/replacement-protection-advice.ts, newtests/unit/provisioning/pasteable-command-hand-quoted-ids.test.ts. Four sites wrapped a TEMPLATE- or state-borne value in'...'by hand inside a command the user is told to paste: the SSM partial-create cleanup warning,refuseUnwritableParameterId's thrown remedy, and BOTH S3 partial-create cleanup arms. A'in the value broke out of the quoting, and a control byte forged lines on the operator's terminal. All four now render through the exportedrenderDisableCommand--displaySafe(asciiOnly), thenshellQuote, then SUPPRESS the whole command when sanitizing CHANGED the value, since a command naming the sanitized id would act on a DIFFERENT resource. The S3 pair shares onemanualBucketDeletionClause, and both S3 arms now route their whole warning through the caller's masker, which they previously never reached at all.renderDisableCommandgained an optionalmaskSecrets:shellQuoterewrites an inner'to'\''and a message-level masker matches by LITERAL occurrence, so without it a secret-bearing id carrying a quote would have come through in PLAINTEXT -- a regression the hand-quoted form did not have. Such an id is now suppressed too. A clean value renders BARE, which is whatshellQuoteemits and what issue #2669 established; three existing assertions moved with it. Residuals: the PROSE half is #3269, the unquoted S3 directory-bucket command #3270. ignoreAssumedRolenow genuinely resolves the CALLER's identity, so thecdkd localopt-out is no longer a no-op without a profile (issue #3130) --src/utils/aws-client-defaults.ts,src/utils/role-arn.ts,tests/unit/utils/aws-client-defaults-assumed-role.test.ts,tests/unit/utils/aws-client-defaults-chain-logger.test.ts,tests/unit/utils/role-arn.test.ts,tests/unit/local/ecs-secrets-resolver.test.ts.awsClientDefaults({ ignoreAssumedRole: true })returned{}with no proxy configured, so the client fell through to the SDK's own chain -- whose FIRST link reads the veryAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKENtripleapplyRoleArnIfSethad just overwritten with the role. Only the--profileshape was safe, becausecredential-provider-nodeskips that link once a profile is named. So with--role-arnand no profile,cdkd local run-task/invoke/start-apiresolved ECS SecureString and Secrets Manager PLAINTEXT as the deploy role and injected it into the user's container, took the--assume-roleSTS hop as the deploy role (a container could receive a role its caller could not assume), and reported the role's account as the emulated${AWS::AccountId}. The opt-out now answers in three shapes: a selected profile is left to the SDK chain, a caller that had a static triple gets the pre-assume snapshot back, and a caller with neither (SSO, an instance or container role) gets its real chain with the environment link EXCLUDED. A secondapplyRoleArnIfSetis now refused BEFORE itssts:AssumeRolerather than after. That shape-3 chain also dropscredential-provider-node'sMultiple credential sources detectedwarning -- false for a triple cdkd itself wrote -- while forwarding every other warning.cdkd local invoke/start-api/run-task/invoke-agentcoreno longer hand an emulated function or task a--role-arnassumed role through the process environment (issue #3130) --src/utils/caller-credentials.ts(new),src/utils/aws-client-defaults.ts,src/utils/role-arn.ts,src/cli/commands/local-invoke.ts,src/cli/commands/local-start-api.ts,src/cli/commands/local-invoke-agentcore.ts,src/local/ecs-network.ts,src/local/docker-runner.ts,docs/cli-reference.md, plus their unit fences.--role-arnoverwrites theAWS_*credential triple with the role's, and each command copied it into the container, socdkd local invoke --role-arn <deploy-role> MyFnran the user's own handler AS THE DEPLOY ROLE, normally the more privileged identity. A--profile-flag overlay masked it; an EXPORTEDAWS_PROFILE, or no profile at all, did not. The caller's own triple is now snapshotted before the assume and restored on the way in;--sigv4host signing reads the same snapshot. When the caller has no static credentials to restore -- SSO, an instance role, a container role -- the triple is STRIPPED rather than inherited, and the container falls back to its own resolution (for a barecdkd local invokethat is nothing, so its first AWS call fails loudly; pass--profileor--assume-role). A run with no--role-arnis unchanged. The four long-runninglocal start-*commands are NOT covered --start-service,start-alb,start-cloudfrontandstart-agentcorehand off to an emulator that assumes the role itself, so the role's triple still reaches the containers of the last three, and the ECS task secrets of the first two are still fetched with it; pass the--profileFLAG alongside--role-arnthere (issue #3240). Mechanism and both channels:.claude/rules/local-caller-identity.md.--role-arnis now the identity every AWS call cdkd makes runs as, even when--profileis also passed (issue #3130) --src/utils/aws-client-defaults.ts,src/utils/role-arn.ts,src/utils/aws-clients.ts,src/local/ecs-secrets-resolver.ts,src/cli/commands/local-*.ts,src/cli/options.ts,docs/cli-reference.md,docs/cross-stack-internals.md,docs/getting-started.md,docs/troubleshooting.md.applyRoleArnIfSetpublished the assumed credentials through theAWS_*env vars alone, and@aws-sdk/credential-provider-nodeskips the env provider whenever a profile is selected -- so--profile ci --role-arn <r>assumed the role, reported it assumed, then ran every call asci, in whatever account that was; an ambientAWS_PROFILEdid it with no flag. ClearingAWS_PROFILEwould not have fixed it (cdkd also threadsprofileas an explicit client-config key, which outranks the environment), so the credentials go toawsClientDefaults()instead -- spread FIRST at all 157 client sites undersrc/**, so each takes the role unless it opts out or carries its OWN credentials -- 18 sites on thecdkd localsurface opt out deliberately, to keep the emulated workload on the caller's identity. Published once only: a second publish is refused and theAssumeRolehop itself opts out, so no run chains role onto role. Whatcdkd localresolves FOR the emulated workload keeps the caller's identity, now fenced at every client site on that surface includingnew AwsClients(...)bags.- UPGRADE NOTE for anyone already combining
--profilewith--role-arn(issue #3130) --docs/cli-reference.md,docs/cross-stack-internals.md,docs/troubleshooting.md; the behaviour they document comes fromsrc/utils/aws-client-defaults.tsandsrc/utils/role-arn.ts. Now that cdkd genuinely runs as the role rather than as the profile, three things move on the first run after upgrading, all of them the same correction seen from different places. (1) The default state bucket follows the identity, so it is the ROLE account's -- copy the state across before the first deploy;--state-bucketcannot point back at the profile account's bucket, becauseExpectedBucketOwnerrejects it. (2) A cross-accountFn::GetStackOutputassumes the producer role AS the assumed role, so the PRODUCER role's trust policy has to name the--role-arnrole rather than the profile's principal -- until it does, the first deploy resolving such a reference fails withAssumeRole into <producer> failed: AccessDenied, which names the producer role and not the principal that changed. (3)CDK_DEFAULT_ACCOUNTbecomes the role's account, which is correct (it is where the stack is deployed), while the CDK app's OWN context lookups --Vpc.fromLookupand friends -- still resolve the profile, so a cross-account run can synthesize for one account and look up in another. The remedy for both (2) and (3) is the same: name the role in the trust policy, or drop--profile(pass--regionand let--role-arnsupply the identity alone).docs/cli-reference.mdcarries all three. - A state record cdkd cannot read is now refused where a command would WRITE it, and reported where it would not (issue #3018) —
{"resources": {"R": null}}passedhasReadableResources, which tests the BAG, and surfaced as a bareTypeError; so did a typeless object.cdkd state refresh-observedandcdkd drift --accept/--revertREFUSE (STATE_RESOURCES_MALFORMED), naming ids, before any lock or save; over several stacks refresh-observed pre-checks every region-scoped record, so one already malformed refuses the run before any write; a legacy record's refusal shows acdkd deployexample only for an exact name with no*,/or leading-. Before, a LIST bag was refreshed and saved, and both drift writers persisted phantom0,1rows. Plaincdkd driftandcdkd diffREPAIR instead and warn, reporting each dropped row, or an unreadable map as one(resources map)row:driftasnotCompared(new causeunreadableRecord), exiting 2 (1 if drift);diffin its preview and--jsonunreadable, counted by--fail. A dropped row the template declares previews as a CREATE. An unreadable rollback-orphan record is dropped before adoption and counted with them; anullone used to abort.cdkd export's baseline report warns on every non-object bag, names unreadable entries outside its tally, sends the reader to repair, builds commands from the loaded stack and region, and withholds the writingrefresh-observedcommand when rendering would alter its name or region, the name reads as an option, or the record has none; its identifiers are sanitized and capped by kind (stack 1152, id 255, region 128) — and every shared message, whichever command prints it, cuts a stack name at 1152. Its overlay preview cuts by code point. Changed:src/state/malformed-resources-bag.ts,src/cli/commands/{state,drift,diff-recursive,export}.ts,src/utils/display-safe.ts. Residual: #3202, #3232, #3307, #3335, #3339, #3350, #3379.
Recently Implemented (2026-09-15):
- A Lambda Managed Instances app whose
AWS::Lambda::CapacityProviderhas noCapacityProviderNamedeploys, instead of failing withResource Handler Internal Failure(issue #3174) --src/provisioning/resource-name.ts,src/deployment/deploy-engine.ts,src/deployment/retryable-errors.ts. The type has no SDK provider, and a Cloud Control create without the name fails although the schema does not require it. It now has aFALLBACK_NAME_RULESentry, so cdkd generates<stack>-<logicalId>(140-character cap) on the create; a template-supplied name is kept. The generated name is no longer sent on a Cloud Control UPDATE, for every type in that table: the recorded bag never holds it, so each update patch used toaddit -- a refused change for a resource running under another name where the name is create-only, a rename where the type accepts it (AWS::Cognito::UserPool'sUserPoolName). The same holds for a name an SDK provider'spreparePropertiesForFallbackhook adds, and a falsy value the template itself sets (an empty string) is sent back as written. The create is also retried as IAM propagation when it fails with "The operator role is invalid or doesn't have sufficient permissions": CDK creates that role in the same stack, and the first deploy failed on it single-shot (measured: rejected 8s after the role's policy was attached, accepted at 23s). The entry admits the type to rollback-orphan re-adoption (go-to-k/cdkd#2934); a create under an existing name fails withAlreadyExists(measured). Live coverage:tests/integration/lambda-capacity-provider-default-name/. VectorIndexesonAWS::DynamoDB::Table/AWS::DynamoDB::GlobalTableis a known silent drop routed to Cloud Control, instead of an unrecognized property dropped with a warn (PR #3167) --tests/fixtures/cfn-schemas/(AWS-DynamoDB-Table.json,AWS-DynamoDB-GlobalTable.json,_todo-backfill.json),src/provisioning/property-coverage.generated.ts(regenerated). The daily schema refresh picked the property up from AWS's public CloudFormation bundle, along with its nestedVectorIndex/VectorAttribute/SearchSchemaElementdefinitions. Before this,VectorIndexespost-dated the committed fixture snapshot, so pre-flight read it as UNRECOGNIZED -- for a resource on the SDK route with no other actionable drop and noprovisionedBy: 'cc-api'record,reportUnrecognizedPropertieswarned and the SDK provider dropped it while the deploy reported success (the issue #614 failure class that machinery cannot observe). A table already auto-routed by a sibling drop, or already sticky on CC, was forwarding the key to Cloud Control all along, which accepts it once AWS's registry carries it. It is now asilentDropentry on both types -- neither SDK provider writes it (not yet implemented by cdkd) -- so the #614 auto-route applies: a template carrying it moves that resource onto Cloud Control, which forwards the full property map. Neither DynamoDB type is inSTICKY_CC_MIGRATION_EXEMPT, so an existing SDK-provisioned table whose template gains the property moves to Cloud Control and pinsprovisionedBy: 'cc-api'ONE-WAY. The snapshot does not mark it create-only, and its capture is top-level-only, socreateOnlyDropsand the record narrowing are unchanged; a nested create-only path is invisible to that capture, which no refresh fixes.- A planted stack name or region can no longer render byte-identical to a different, genuine
Stack (region)reference incdkd state listor in thestate orphan/state refresh-observedconfirmation prompts (issue #3164) --src/utils/display-safe.ts,src/cli/commands/{state,rollback}.ts,docs/cli-state.md, newtests/unit/cli/state-ref-display-boundary.test.ts. Both halves of a reference now render throughdisplayIdentinstead of the baredisplaySafe(..., { asciiOnly: true })allowlist. That allowlist closes line FORGERY only -- it is the identity on printable ASCII -- while(region)is cdkd's OWN annotation, so a 2-segment legacy keycdkd/ProdStack (us-east-1)/state.jsonprinted byte-equal to the genuineProdStackinus-east-1, and acdkd state list | while read -r refcleanup loop acted on the wrong one. One trailing space did it too: sanitization maps non-printable ASCII to a space then TRIMS, socdkd/ProdStack /...was trimmed back toProdStackbefore any boundary test -- as were leading space, tab, NUL, ESC and a padded REGION.displayIdentnow renders a value bare only when sanitization was the IDENTITY on it AND what remains is a plain identifier; the identity half closes the padding class at its root. The NAME half caps at 1152, not the 255 default, because a state-record name is not a CloudFormation stack name (nested-stack children accrete a~<logicalId>per level) and 255 would CUT a legitimate deep child;cdkd rollbacktakes that cap at all twelve of its stack-name renders. All SIX callers change together, and every legitimate row is unchanged to the byte. The quotes are a boundary for a READER, not shell quoting, and a terminal that wraps a very long name can still show a line reading like a genuine row;--jsonis unchanged and still unsanitized (go-to-k/cdkd#3163, go-to-k/cdkd#3179). cdkd scrubno longer reports a stack CLEAN when a dynamic reference failed to RESOLVE — a deleted SSM parameter, a secret with noSecretString, a missingJSON_KEY, a non-JSON secret, or a denied / pending-deletion / undecryptable secret each abandon the whole leaf, because the resolver's token loop stops at the first failing token. A real{{resolve:secretsmanager:...}}sitting after it was never fetched and recorded no needle, so a legacy plaintext already instate.jsonsurvived underNo plaintext secrets found, exit 0. These are now a counted FINDING rather than a silentdebugline: the rest of the stack is still scrubbed, each abandoned record is named in a warning at default verbosity (the resource, orphan record or output it belongs to), the per-stack and whole-run summaries carry the count, and the run does not report clean —--dry-run --failexits 1, and so does a real run. The detection is POSITIONAL — it asks whether the value being scanned carried a{{resolve:...}}at all — not keyed on the error's message text:sendWithThrottleRetryrethrows an AWS rejection RAW, so a genuinely deleted parameter arrives as a bareParameterNotFoundwith none of the resolver's own prose, which a text-keyed predicate missed. A scan stopped by a TEMPLATE failure is warned but does NOT raise the exit code: one throw aborts the whole properties bag, and an unresolvableRef/Fn::GetAttor aDefault-less parameter is not a failure to fetch a reference — scrub resolves with template defaults and takes no--parameters, so failing the gate on those could not be cleared. Same for a token whose argument kept an unsubstituted${...}: it was never fetchable. Both still warn, because the leaf really was left unscanned. None of these refuse the stack the way a NAMELESS reference does (#2692). Issue #3160; the resolver-side fix is #3181.- A 1-3 character secret assembled into an intrinsic's NAME argument no longer prints in the clear on the resolver's log lines and refusals (issue #3150) --
src/deployment/intrinsic-function-resolver.ts, cases inintrinsic-resolver-name-argument-log-twin.test.ts. Names (map keys, stack / output / export names, regions, attribute names, dynamic-reference parts) took the needle mask alone, which matches a sub-4-character value only whole, soenv-${P}printedenv-q7. The resolver's log masker now prints the pass's log twin (go-to-k/cdkd#3100),***when the needle mask also changes the raw text, else the needle mask; a string equal to a masked one elsewhere in the pass is masked too. A name parsed out of a dynamic reference takes the token twin's matching part, even if assembled in the sameFn::Sub; it prints***when the pieces cannot be paired or the name carries two different masks, and a refusal prints***for an unpairable token. A synthesized default (AWSCURRENT, an absent version id) prints as it is, or***if the token spells it inside a longer piece. Transformed names take the twin of the raw value: a region lowercased bycanonicalizeRegion, a nested stack's output name sliced offOutputs.(***when a mask covers that prefix; also in the declared list), and an invalid region at theFn::GetAZs/Fn::GetStackOutputgates,***whenever it has a twin. A producer-region resolver's region (a secret ARN's too) is masked, stripped and masked again on its creation line, clients line and invalid-region refusal, and prints***once two spellings mask differently. A cross-stackoriginis built from twinned names. No log twin is newly registered:Fn::Base64output and resolved values are unchanged. Only names this pass assembled are covered; other text still gets the needle mask alone, e.g. SDK error text (go-to-k/cdkd#3171) or a name passed to another module raw (go-to-k/cdkd#3234). - A planted
rollback-journal.jsonwhose operation carries a non-stringlogicalIdis now refused by the parser instead of crashingcdkd rollbackat its first lookup (issue #3140) --src/types/rollback-journal.ts.parseRollbackJournalvalidated onlyjournalVersionand thatsegmentswas an array; every per-operation field was an unchecked cast, and the executor keys every lookup onop.logicalId-- so a numeric id coerced into the record named'123', and an object whosetoStringis not callable threw a rawTypeErrorfromstateResources[op.logicalId]after the lock was taken. The parser now refuses a segment that is not an object, anoperations(or a presentfailedOperations) that is not an array, an operation that is not an object, and an operation whoselogicalIdis not a non-empty string, whoseresourceType/changeTypeis not a string, or whose presentphysicalIdis not a string. The refusal names the index and the TYPE (segments[0].operations[1].logicalId must be a non-empty string (got number)), never the value, so there is nothing to sanitize -- and names the way out (remove the stack'srollback-journal.json;cdkd destroysweeps it too). The same text reachescdkd deploy'sFailed to write rollback journalwarning, since appending a segment parses the existing journal first. Every journal cdkd itself writes passes unchanged. The executor keeps its owntypeofguard on the pasted--orphanremedy as defense-in-depth: the deploy engine's in-process rollback reaches it without this parser. Found by the round-5 security review of #3104. - The DynamoDB capacity / throughput forwarders read an Integer the way CloudFormation's DynamoDB handler does, and refuse the spellings it rejects instead of forwarding their
Number()reading (issue #3135) --src/provisioning/dynamodb-warm-throughput.ts(coerceWarmThroughput, both DynamoDB types; the DynamoDB row of the measurement added totoCfnInteger's doc beside the Logs one),src/provisioning/providers/dynamodb-globaltable-provider.ts(every capacity / ceiling forwarder, the auto-scalingRegisterScalableTargetmin / max andPutScalingPolicycooldowns, plus the diagnostic / mirror predicates paired with each),.claude/rules/layout-provisioning.md, unit tests. Live A/B onAWS::DynamoDB::Table.ProvisionedThroughput(us-east-1, 2026-09-14): the accepted grammar is/^[+-]?\d+$/with NO whitespace trimming --" 7 ","7 "and""are rejected at properties validation, hex / exponent / decimal-point by the handler,"+8"/"010"accepted. The forwarders read throughNumber(), so a template CloudFormation refuses deployed with7/9/10. They now read throughconfig-shape.ts'scoerceCfnInteger(that grammar exactly), and a rejected spelling takes the arm each site already had for an unresolved intrinsic -- dropped and NAMED (WarmThroughput), the announced 5/5 fallback on a PROVISIONED capacity, an announced suppression on an on-demand ceiling -- never silent, never absent, never a throw (#1428's replay reasoning). Diagnostics name the grammar. Guard-only readers (live-readback compares, the already-matches / decrease / zero-capacity tests) stay ontoFiniteNumber, where over-acceptance is the safe side.toCfnInteger(the Logs helper) is unchanged. TheAWS::DynamoDB::Tableprovider's rawNumber(x ?? 5)capacity forwarders are a sibling class with their own default arithmetic, filed as #3147. cdkd driftno longer hides a drift at a key literally named__proto__and no longer flattens aDatereadback to{}before comparing it (issue #3121) --src/analyzer/{drift-normalize,drift-principal-normalize,cc-api-strip,drift-calculator}.ts, newsrc/utils/own-keys.ts(hasOwnKey/ownValue/defineOwnKey/hasPlainPrototype/nullPrototypeRecord, moved out ofsrc/cli/commands/drift.ts, which now imports them), unit tests. The six comparison-side canonicalizers rebuilt every object node onto a{}literal, on BOTH comparison copies: an own__proto__key (whatJSON.parseyields fromstate.jsonor a Cloud ControlPropertiesdocument) was assigned as the rebuilt node's prototype and dropped symmetrically, socalculateResourceDriftreturned[]for two bags differing only there; andObject.entries(new Date())is[], so aDatemember of a raw SDK readback reached the comparator as{}-- phantom drift against an ISO-string baseline, andcdkd drift --acceptthen persisted{}into the baseline. Each walk now rebuilds onto a null-prototype record and returns a non-plain value by identity, the pattern PR #3124 applied insidedrift.ts. Net effect: a top-level or nested__proto__drift is detected, reported and accepted like any other key, and aDatereadback is compared as itself and -- on a resource with no recorded secret -- persisted by--acceptas its ISO string (with a recorded secret the redaction value walk still flattens it, #2427);drift-calculator.tscompares aDatein its JSON form, so the next run reads that string and the readback'sDateas EQUAL rather than as drift forever. Sweep:grep -rn "Record<string, unknown> = {}" src/analyzer/9 -> 3 (the three left areorphan-rewriter.ts/diff-calculator.ts, not on the drift comparison path); 0key in bagmembership tests in the three files.- A 1-3 character secret that an
Fn::Join/Fn::Subembeds in a longer string underFn::Base64(cdk.Fn.base64('port:' + secret.secretValueFromJson('pin')), the CDK UserData shape) is no longer persisted tostate.jsonin its base64 form (issue #3119) --src/deployment/intrinsic-function-resolver.ts(resolveBase64), a unit case intests/unit/deployment/intrinsic-resolver-sub-floor-log-mask.test.ts, and aBase64Pinarm in thesecrets-dynamic-refinteg. Issue #2759's mask-only detector at that site asked the needle mask alone ("did masking change the input?"), which cannot see a plaintext belowMIN_NEEDLE_LENGTH(4) unless it is the whole text, so the encoding ofport:q7reached the persist path in the clear -- decodable in one command -- while theResolved Fn::Base64:debug line beside it was already masked by position since issue #3100. The detector now also asks the position mask (logTextOfLeaf, the pass registry #3100 introduced), and registers the encoding mask-only whenever either fires, so every persistence reader (state, the rollback journal, the outputs bag,cdkd diff) stores***for it, as it already did for a 4+ character secret. The resolved value handed to AWS is unchanged. Residual: a value the position mask over-masks (a template literal equal to a string a secret was written into) also has its encoding masked -- the directionlogTwinOfProductaccepts, since such a string carries the secret's text. --verboseno longer prints a 1-3 character secret on a nested-stack child's parameter lines, or on anFn::Joinover a value that still spelled a reference after an earlier stage resolved it (issue #3114) --src/deployment/intrinsic-function-resolver.ts, with cases intests/unit/deployment/intrinsic-resolver-nested-child-log-twin.test.tsand aSubFloorPinSsmarm in thenested-stack-secretinteg. Issue #3100's log twin was looked up only under the pass's own bag, while a child receives, as its inherited bag, the object its parent registered the twin of a built parameter value under (port:q7->port:***). The lookup, which also givesFn::Splitits piece masks, now reads the inherited bag too, and two different registered twins mask the whole string. TheParameter X: using user-provided value,Resolved Ref to parameterand fourFn::GetAttvalue lines now mask through the resolver's leaf mask, and aFn::Joinpart that is re-resolved starts from its registered twin. It matters where the parent's carry refuses the frame, such as anFn::Joinaround anssmtoken. One state effect:Fn::Base64over such a value in the child now stores its encoding as***, as the parent already does (issue #3119's detector reads the same lookup), so a rollback replay of that child resource refuses the masked baseline as it does the parent's. Residual (#3156): one level only, since a child hands its nested stack its own resource bag, so no twin registered above the child reaches a grandchild. The resolved values are unchanged. Not changed: anFn::Substring variable, which is template text its one substitution resolves whole.cdkd diff --failnow reports an Outputs change the next no-change deploy writes beside an output that keeps failing, instead of omitting the whole Outputs section and exiting 0 (issue #3101) --src/cli/commands/diff-recursive.ts(computeStackDiff),src/analyzer/outputs-diff.ts(failedOutputKeys,failuresMirrorDeploy),docs/cli-diff.md, with cases intests/unit/cli/diff-recursive.test.tsandtests/unit/analyzer/outputs-diff.test.ts. Since go-to-k/cdkd#2771 a deploy with no resource change persists the outputs that resolved and keeps a failed output's stored value, but the diff still dropped the section whenever one output failed. When the resource diff is empty and every failure is one the deploy records too (the resolver threw, or returned nothing), the diff now previews through the deploy's ownmergeNoChangeOutputs: an added or changed sibling renders its row, a failed output shows no row and is named in a warning, as compared at its stored value or as having none under its own name (the deploy may still resolve it), and unless every value carried from state (an alias included) is a secret expression the rendered rows withhold their previous values, the warning saying why when a row loses one, and the merge's keep-whole refusals keep the section omitted as before, except the deploy's save-time re-check, which the diff cannot run. The section is still omitted when a resource change is pending, when a condition verdict can reach an output (its ownCondition, orFn::If/{Condition}outsideResourcesandConditions; evaluated best-effort, so a verdict can differ from the deploy's) or its parameters cannot be bound or its conditions evaluated for the diff, when a failure is one only the diff reports (a surviving intrinsic, anAWS::NoValuesymbol, an unsubstitutedFn::Subplaceholder, or a container holding one), and when anExport.Namecannot be resolved or decided.Fn::GetAtt [<SecurityGroup>, VpcId]on a security group declared WITHOUTVpcId(a hand-written L1 landing in the account's default VPC) now resolves to the default VPC's id, as CloudFormation answers, instead of''from the record or the literal'undefined'inside anFn::Join/Fn::Sub(issue #3097) --src/provisioning/providers/ec2-provider.ts(readSecurityGroupVpcId),src/deployment/intrinsic-function-resolver.ts(theAWS::EC2::SecurityGroupVpcIdarm,isImpossibleEmptyStoredAttribute), a default-VPC group and its Output in theec2-instanceinteg. The provider copied the TEMPLATE's ownVpcIdwith?? ''(the last two sites #3077 exempted), so a template with noVpcIdrecorded''; the resolver arm behind it answeredundefined. Both halves now read the group back: the provider recordsVpcIdfrom a best-effortDescribeSecurityGroupsafter create and update (a not-found answer, EC2's read-after-write lag, is retried up to 5 attempts a second apart), omitting the key (never'') when the read fails or reports none, the error class at debug; the resolver re-reads it live on an absent key --GroupIds, cached per deploy like the #3096 arms -- and REFUSES (refuseUnservedAttribute, unmarked, retryable) on an empty or failed read rather than answering the group id, after a markedsg-<hex>shape guard on the persisted physical id. A LEGACY record holdingVpcId: ''(a pre-#3097 binary, group declared withoutVpcId) is read as absent for this one attribute (no security group has no VPC), so the live arm answers; the record keeps''until the nextupdate().GroupIdis unchanged. The attribute-map fence's allow-list is now empty; the handled-property-wiring matrix recordsVpcIdseeded by the create seams only (the update-path read was the attribute copy).- An attribute the resolver reads LIVE never degrades to the physical id any more (issue #3096) --
src/deployment/intrinsic-function-resolver.ts(refuseUnservedAttribute),src/utils/error-handler.ts,docs/cli-deploy.md,docs/cli-deploy-safety.md,tests/integration/ec2-instance/verify.shPhase 0 (c). When the state record omits the attribute (#3077) and the live read finds it unassigned or fails, theAWS::EC2::InstancePrivateIp/PublicIp/PrivateDnsName/PublicDnsName/AvailabilityZonearm, theAWS::EC2::VPCDefaultSecurityGrouparm (new live filteredDescribeSecurityGroups, cached for the run) and the newAWS::CloudFront::DistributionDomainNamearm (liveGetDistribution, cached) throw anIntrinsicResolutionRefusalError(not declared non-retryable: the condition is time-dependent) naming the resource, the attribute, the instance state or the error CLASS (AWS text at--verbose) and the remedy -- previously ani-.../vpc-.../ distribution id was returned into an address, security-group or hostname position, silently in an Output or export. A settled instance with no public address still resolves to the known''(CloudFormation parity, the provider's own three-state rule).AWS::RDS::DBProxy/DBProxyEndpointVpcIdwith no recorded value refuse without a live read (marked non-retryable; no RDS client in the resolver). In a resource property the refusal fails the resource; in an Output it is warned, skipped and recorded inskippedOutputs, then re-resolved on the next deploy;Fn::Subre-raises it (#1740);Conditionsstill absorb it.InstanceId/ CloudFrontIdkeep answering the physical id. A VPC record whose id is notvpc-<hex>is refused before the read (EC2 filter values read*/?as wildcards); the three-state settled rule is one shared predicate,src/provisioning/ec2-instance-state.ts. cdkd state list --longno longer loses the whole listing to one stack it cannot read (issue #3069) --src/cli/commands/state.ts(stateListCommand, newreadOrFailure),docs/cli-state.md, unit tests. Each row read its state record and lock inside one unguardedPromise.all, so the first rejection (an unreadable body, an unsupported schema version, an AWS read failure) made the command exit 1 with no rows. The two reads are now guarded separately. A row whose record failed showsResources: unknown (...)and keeps its lock; a row whose lock failed keeps its real counts and showsLock: unknown (...).--long --jsonaddsstateReadErrorandlockReadError, and setsresourceCount/lockedtonullon the failed side instead of0/false. The reason text is a fixed string, never the caught message: the invalid-JSON refusal quotes bytes of the state body, which whoever can write the bucket chooses. A stderr warning counts the degraded rows; the exit code stays 0. Also closed in this view: alastModifiedoutside theDaterange (1e300) no longer throws past the guard; aresourcesthat is neither a JSON object nornullshows an unknown count instead of one counted per character or element, and under--jsonsetsresourceCount: nullwith its ownstateReadErrorreason; and the plain listing and the--long/--treetext rows print the stack name and region through the ASCII display sanitizer, since an S3 key segment can carry a newline or ESC (--jsonis unchanged).--tree(state-list-tree.ts) no longer empties on a non-stringparentStack/parentRegionand drops such a link whole, and a parent loop's stacks land at the root instead of vanishing. Residual: a legacyversion: 1reference with no resolved region is still not read by this view and renders zero resources with no reason attached.- A number or boolean
Fn::Joinpart now counts as literal text when cdkd positions a secret reference instate.json(issue #3055) --src/deployment/secret-redaction.ts(intrinsicSkeletonSegments, newjoinPartLiteralText), unit tests. The resolver joins such a part asString(part)(8080,true), but the parser read only a string part as literal, so a number or boolean became an unknowable part. The skeleton arm then wildcarded that position: two recorded references differing only there both matched, positioning refused, and the leaf persisted the collapsed map's surviving reference instead of its own. The frame arm read it as a placeholder in the frame and refused, leaving a 1-3 character middle in plaintext, and a nested-stack parameter spelled that way was not carried to the child. The parser now takes the part asString(part), so the frame is written and carried. Anull, array or object part stays unknowable, and a number delimiter is still refused. Residual, stated injoinPartLiteralText's docstring: a source that left two candidates matching can now leave one, so the skeleton arm's existing wrong-reference residual (an unclassifiable or publicssmleaf) is reachable in a few more cases. No L2 construct is known to emit such a part: a synth probe found a TypeScript number interpolated orToken.asString(8080)folded into a literal string, and a non-string part only from a hand-writtenLazy.anyinsideFn.join. - A malformed
resourcesbag no longer killscdkd diff,import,orphan,rollbackorscrubwith a rawTypeError— a hand-edited or truncatedstate.jsonwhose resource map is absent,null, or some other non-object used to abort each of those commands from the firstObject.keys/in/ index read, naming no stack, no key and no remedy, from exactly the commands a user reaches for WHEN the state is broken.cdkd diffnow reports on the record with an empty resource set and warns that its output describes zero resources, which is not the same as the stack having none. The commands that can WRITE state —scrubon a real run,import,orphan,rollback— instead REFUSE by name (STATE_RESOURCES_MALFORMED) rather than repairing:scrubsaves whenever anything changed, an Outputs change alone included, so repairing would save a well-formedresources: {}over the only evidence that the record is broken, after which the nextdeployre-creates the whole stack and the nextdestroyorphans every live resource.cdkd scrub --dry-runis the one exception — its write gate carries!opts.dryRun, so it audits the record and then exits non-zero rather than reportingNo plaintext secrets found, which would make the documented--dry-run --failCI gate pass over resources it never read. Every message names the record to inspect and states thatdeploy/destroymust not be run against it.deployanddestroythemselves are unchanged and are tracked as #3161: they abort on anullor absent bag, but an[]or a number reads to them as an EMPTY stack, so destroy takes its empty-stack fast path and deletes the record while every resource stays live. The remaining null-ENTRY shape ({"resources": {"R": null}}) stays on #3018. Issue #3018. cdkd drift --acceptand the--revertnarrowing write mask an UNCERTIFIABLE position instead of persisting a stored plaintext when the bag lands inobservedProperties(issue #2939) --src/cli/commands/drift.ts(runAccept's re-redaction,runRevert's narrowing write),.claude/rules/layout-drift.md,.claude/rules/layout-deployment-secrets.md, unit tests. Both writers passed the non-failingSTATE_SOURCED_READBACK_RULESunconditionally although each site already knows its destination:--acceptcomputeshasObservedtwo lines away, and the narrowing write persists a VALUE only against an observed baseline. Each now selectsSTATE_SOURCED_BASELINE_RULES(fail-closed, the constantcdkd importandcdkd state refresh-observeddeclare) when the bag is a drift baseline and keeps the readback constant when it lands inproperties, where a mask blockscdkd exportand the rollback replay. Net effect: a pre-#2852 record carrying a rotated-away plaintext at a position the walk cannot pair is cleaned toSECRET_MASKby the--acceptit was invoked for, and a provider echo the walk cannot pair is masked in the baseline rather than persisted decrypted; a masked position reports as drift,--acceptrefuses it and--revertpreserves the live value there until a deploy rewrites the baseline. A clean record is never made worse.cdkd exportrefuses a resource whose CloudFormation import identifier would be the redaction mask, whatever produced it -- and does NOT block the Cloud-Control-imported records whose masked attributes it never reads (issue #2932) --src/cli/commands/export.ts(buildImportPlanchoke point,resolveIdentifierValue,maskedIdentifierAttributeReason),tests/unit/cli/export-composite-identifier.test.ts,docs/cli-export.md. The #2274 blocker testedpropertiesonly, whileCloudControlProvider.importwrites the mask intoattributesfor every model key it cannot certify read-only (#2847), and the export's two attribute-reading resolvers refused a mask only because each happens to test a prefix. The guarantee now sits where the identifier map is built: a resolvedresourceIdentifieror overlay equal to***-- a masked identifier attribute, a masked physical id, a splitter or backfill answer, a future resolver without a shape test -- blocks the resource with a mask-specific reason, and aCOMPOSITE_PHYSICAL_ID_IDENTIFIERSresolver failing on a maskedattributes.<field>names the mask and the real remedy (re-import withcloudformation:DescribeType; a re-deploy re-masks) instead of "re-deploy so cdkd records it". Deliberately NOT a whole-bag test: that would make every Cloud-Control-imported record permanently unexportable, and a unit row pins that population still exports. Behavior change: none for records that exported before; a masked identifier that a resolver already refused gets a different message.cdkd drift --revertno longer copies a NON-STRING live value into a whole-{{resolve:...}}-token position (issue #2920) --src/cli/commands/drift.ts(preserveLiveValuesAtUnresolvedTokens),docs/cli-drift.md, unit tests. The token preserve arm copied whatever AWS held at a whole-token leaf without a type test, so a live object, array, number ornullat a position the baseline types as a string was shipped toprovider.update-- the #2855 wrong-shape class through the preservation arm, reachable on every pairing route and widened by PR #2912's forced 1-vs-1 singleton. Only a string is copied now; otherwise the token is KEPT, this pass's documented residual (whatcdkd deploysends). The sibling mask walk keeps its opposite choice, because its refusal drops the whole resource.cdkd drift --revert's masked-leaf preservation no longer pairs a list element whose identity field is a whole{{resolve:...}}token with the live element that literally echoes the token text (issue #2919) --src/cli/commands/drift.ts(pairedLiveItems), unit tests. The identity-arm refusal was keyed on the caller's corroboration wildcard, so the MASK walk (wildcard: the mask alone) still paired a token-named element by string equality and donated the echo's live leaves into the masked slots -- an uncorroborated guess. The identity refusal is nowisTokenOrMaskLeaffor both walks; the mask walk's residual there is its safe drop (the resource is refused with the position named), and the token walk's answer is unchanged.cdkd drift --revertno longer drops a baseline member whose key is literally__proto__, and the rebuilt node no longer refuses the preserve walks (issue #2899) --src/cli/commands/drift.ts(resolveStateSecretExpressions,buildRevertNewProperties,setAtPath/getAtPath,collectNarrowedTopLevelKeys), unit tests. The baseline walk rebuilt every object node onto a{}literal;JSON.parseonstate.jsonyields an own__proto__key, and assigning it onto a literal set the node's prototype and dropped the key, so the member vanished from the revert payload and the polluted node failedhasPlainPrototype-- the token preserve walk returned it by identity andmergeUntemplatedValuestopped merging it. The walk rebuilds ontoObject.create(null), as the preserve walks already did. The same-class sweep of the file took the same remedy at every sibling: the revert overlay inbuildRevertNewPropertiesand--accept'ssetAtPathdefine the key as an own data property,getAtPathand the preserve walks read own keys only (a bare read answeredObject.prototypefor an absent segment), the narrowing delta rebuilds onto a null-prototype target, and everykey in bagmembership test (mergeUntemplatedValue,collectMissingPaths,collectNarrowedTopLevelKeys, the two plan reporters) is own-key — a property namedconstructor/toString(a legal env-var or tag name) read as present on a plain bag through the chain, so the untemplated merge dropped it from the revert payload. Residual, filed separately: the comparison chain's own normalisers (src/analyzer/drift-normalize.tsand siblings) still rebuild both sides onto{}literals, so a top-level__proto__member cannot be REPORTED as drift today -- a symmetric miss, never a wrong write.cdkd drift --revertrecords a provider narrowing whose value is a non-plain object (aDate, aUint8Array) instead of calling it unchanged, and--accept's post-write check no longer calls two such values equal (issue #2897) --src/cli/commands/drift.ts(deepEqualUnordered, consumed bycollectNarrowedTopLevelKeysandrunAccept's "was NOT recorded" check), unit tests. The comparator walked own enumerable keys, so any twoDates compared equal (0 === 0keys): a narrowing at such a key was hidden andobservedPropertiesstayed stale there, and the accept check's warning was suppressed. A non-plain value on either side now compares unequal unless it is the same object -- the guardsecret-redaction.ts'sdeepEqualJsonValuealready carries -- so the provider's echo is recorded (the truthful answer). The accept warning's wording names both causes. Through the CLI aDatereadback is flattened by the drift normalisers before either consumer sees it (issue #2427's class), so the narrowing pin is at the function level.- A
RetentionInDaysoffalse,null,0or'0'no longer silently removes a live log-group retention policy (issue #2699) --src/provisioning/providers/logs-loggroup-provider.ts(isAbsentRetention,assertUsableRetention;create()/update()now take their contexts),tests/unit/provisioning/logs-loggroup-provider-retention-coercion.test.ts(CREATE_MATRIX/UPDATE_MATRIXre-pinned to the measurement),tests/unit/provisioning/logs-loggroup-provider-roundtrip.test.ts,docs/cli-deploy-safety.md. The live A/B settled the falsy family: an absent property,''and a whitespace-only string ARE CloudFormation's spelling of "no retention" (the policy is removed) and keep the delete arm -- whitespace-only was refused before and now matches CloudFormation -- whilefalse,0,'0'(rejected by the Logs handler) andnull(rejected atupdate-stack; cdkd's resolver omits the key forAWS::NoValue, so anullhere is a template literal) are refused like'abc'. One cdkd-side exception: a numeric0on adesiredFromAwsReadbackbag (cdkd drift --revert) isreadCurrentState's own never-expire and still deletes, so reverting a console-added retention keeps working; on a template bag0is refused. On a rollback replay (CreateContext.replayingState) every refusal downgrades to warn-and-skip so an old record stays restorable. Behavior change: those four template values now fail the deploy instead of deleting the retention. AWS::Logs::LogGroupreadsRetentionInDaysthe way CloudFormation does, and refuses the spellings CloudFormation rejects instead of forwarding theirNumber()reading (issue #2698) --src/provisioning/dynamodb-warm-throughput.ts(newtoCfnInteger, carrying the live A/B table),src/provisioning/providers/logs-loggroup-provider.ts,tests/unit/provisioning/dynamodb-warm-throughput.test.ts,tests/unit/provisioning/logs-loggroup-provider-retention-coercion.test.ts,docs/cli-deploy-safety.md. Measured on a live stack: CloudFormation's Integer grammar is an optional sign plus decimal digits, surrounding whitespace trimmed;'0x1e','1e3','30.5'and'30.0'are rejected by the Logs handler. cdkd forwarded'0x1e'as 30 and'30.5'as 30.5 on templates CloudFormation refuses; it now refuses them by name before any AWS call, while'+30'and' 30 'deploy as 30 and compare equal to a recorded 30.toCfnIntegerreusesconfig-shape.ts'scoerceCfnIntegerdigits rule plus a trim (each helper's padding rule is its own measurement). The DynamoDB capacity forwarders stay ontoFiniteNumberuntil their own A/B, #3135. Behavior change: a template spelling the retention in hex / exponent / decimal-point notation now fails at cdkd instead of deploying.cdkd scrubno longer reports a stack CLEAN when a NAMELESS dynamic reference aborted the scan —{{resolve:ssm-secure}}with no parameter name, and{{resolve:secretsmanager}}/{{resolve:secretsmanager:}}with no secret id, each raise a bareErrorthat fell through scrub's typed-refusal test into adebugline. The resolver's token loop has no per-tokentry, so either one abandons every remaining{{resolve:...}}token in the leaf: a real secret reference after it is never fetched and records no needle, so a legacy plaintext already sitting instate.jsonsurvived underNo plaintext secrets found, exit 0. Both spellings now re-raise at all four best-effort catches. The four sibling RESOLUTION failures in the same loop are deliberately NOT re-raised — scrub resolves with template defaults and no--parameters, so anFn::Subthat keeps its raw${Field}reaches them on a healthy stack; they are tracked separately as #3160. Issue #2692.- The
LogGroupClassrefusal's out-of-band disable command no longer pastes the state-borne log group name raw (issue #2669) --src/provisioning/providers/logs-loggroup-provider.ts,src/provisioning/replacement-protection-advice.ts(renderDisableCommandnow exported),tests/unit/provisioning/logs-loggroup-provider-class-guard.test.ts,tests/unit/provisioning/logs-loggroup-provider-partial-create-cleanup.test.ts,tests/integration/loggroup-class-guard/verify.sh. The protected arm rendered--log-group-identifier '${physicalId}'by hand, so a'in the recorded id broke out of the quoting and a control byte forged terminal lines anddeployments/*.jsonllines. It now goes through the same sanitize /shellQuote/ suppress the five #2610 sites use, keeping the log-group text around it: a clean id is rendered BARE (--log-group-identifier /my/group, the form the fixture greps), a quote is shell-quoted, and a non-ASCII or control-carrying id suppresses the whole command in favour of theUNNAMEABLE_ID_CLAUSE. The same-file create-cleanupaws logs delete-log-groupcommand took the same rendering. Behavior change: message only -- the quotes around a clean id are gone. Residual: the other 40 pasteableaws ...commands in provider messages, #3136. - Six asset- and state-layer failures now thread the underlying error as
cause. The fourAssetErrorarms (docker re-tag, ECR login, tag, push) and bothStateErrorarms (HeadBucketreporting the state bucket missing,PutObjectlosing the optimistic-lock race) previously wrapped the caught value in an interpolated message and dropped it, soisTransientServerError/isThrottlingError/isMarkedNonRetryableclassified on that message alone andextractDeploymentEventErrorlost the AWS error code and request id it persists. The four docker causes are REDACTED rather than raw: docker echoes the failing command line into its stderr, which becomes the error's message, so attaching the caught error verbatim would have printed a future-e/--build-argvalue throughformatError'scause.message— the channel issue #2440 closed. Those four reject throughspawnStreaming, whose error carriesstderr/stdout/exitCodeand nothing else, soexitCodeis what the redacted cause preserves; for them the cause buys a legible diagnostic rather than a retry decision, which the AWS-shaped fields serve only where an SDK error can be the cause. Issue #2075.
Recently Implemented (2026-09-14):
- A relative symlink inside a Lambda Layer no longer arrives dangling in the local container (issue #3106, found in the #3105 review) --
src/cli/commands/local-invoke.ts(materializeLambdaLayers),src/cli/commands/local-start-api.ts(thestart-apilayer merge), unit tests, thelocal-invoke-layersfixture. Both layer merges copied each layer's asset directory into one tmpdir withfs.cpSync, whoseverbatimSymlinksdefaults tofalsein every Node release: a RELATIVE link such asbin/rel-link -> real.sh(the shape a layer build tool emits) was rewritten to the ABSOLUTE host path of the source asset, so/opt/bin/rel-linkresolved to a path that does not exist inside the container and a handler executing through it failed withENOENT. Both merges now go through one helper,src/local/layer-tree-copy.ts(copyLayerTreeLastWins): an explicit walk that recurses only into real directories, copies each file on its own withcpSync(so+xsurvives), recreates each symlink with the SOURCE's own target string, and lets the last layer win for every kind (a link, a file or a directory over any of the others) — a bareverbatimSymlinks: truewould instead have made Node's C++cpSync(every release after 22.12) throwEEXISTwhen two layers carry a symlink at the same path (node_modules/.bin/<tool>from a shared dependency) and a recursivereaddirSyncdescends into directory symlinks on the same releases. The comment above each call had claimed the opposite of the default since the #241 backlog. Only the multi-layer merge path was affected (a single layer is bind-mounted directly); absolute symlinks are unchanged. Measured live: thelocal-invoke-layersfixture execs/opt/bin/rel-linkand returnsreal-via-link; the pre-fix binary fails withENOENT. cdk-local tracks the same fix as #727. --verboseno longer prints a 1-3 character secret that anFn::JoinorFn::Subembeds in a longer string (issue #3100) --src/deployment/intrinsic-function-resolver.ts, with cases intests/unit/deployment/intrinsic-resolver-sub-floor-log-mask.test.tsandintrinsic-resolver-assembled-secret-region.test.ts, and a--verbosearm in thesecrets-dynamic-refinteg. The debug lines masked resolved strings with the recorded-secret needle mask, which matches a value under four characters only as the whole text, soport:+secretValueFromJson('pin')printed asport:q7. The resolver now builds a log twin of each value at the writes that put a recorded secret into it (the dynamic-reference substitution's fresh, cache-hit and region-pinned arms, reference-bearing parts and variables, intrinsic products masked whole or by a mask an earlier write of the pass registered,Fn::Splitpieces) and prints the twin, masking the whole text when the needle mask also changes the value. It reaches theFn::Join/Fn::Sublines, theFn::Base64line (whose encoding is masked whole when its input carries a mask), and every line and thrown message that prints a value through the resolver's leaf mask (Fn::Select,Fn::Split,Fn::Cidrand the other callers), and no other line. A literal Join part, Sub variable or Sub body equal to a recorded value is not masked, while aFn::Splitwhose whole source is a recorded secret masks every piece. The resolved value and state are unchanged. Also closes #3113: thenested-stack-secretverbose scan no longer excepts theResolved Fn::Join:line. Residual: #3114 (a nested childRefto an intrinsic-framed parameter, and a value resolved in two stages).- The rollback executor's replay log and
cdkd rollback's plan preview can no longer be forged or spoofed by a plantedrollback-journal.json(issue #3092) --src/utils/display-safe.ts(newdisplayIdent),src/deployment/rollback-executor.ts,src/cli/commands/rollback.ts,src/deployment/retry.ts. Issue #3064 sanitized the preview and the parser; this is the layer that RUNS the plan, undercdkd rollbackAND the deploy engine's automatic rollback. Fifty-three interpolations of a journal op'slogicalId/resourceType/changeTypeinto log lines and thrown messages were raw -- among them the pasted--orphan <id>remedy -- so an injected newline invented a line that read like the executor's own. Every identifier both layers render now goes throughdisplayIdent: the ASCII allowlist with<unrenderable>for nothing-left, a 255-code-point cap naming how much it withheld, and a JSON-quoted rendering for a non-plain value -- the all-ASCIIX (AWS::RDS::DBInstance) -- already revertedthat survives any allowlist and planted the line's own wording inside a real row. Conditional, so every legitimate value renders as before. The five free-form SDK errors it renders take the denylist,withRetry's lines sanitize their label, and the--orphanremedy is printed only for an id in CloudFormation's own logical-id charset (~xis plain but shell-expanded; otherwise it says why it is withheld). The new resource'sphysicalIdfromstate.json(same bucket, same trust class) is sanitized on the same lines. Deliberately still raw, each pinned by a test: ids handed to a provider or used as state keys, themsga collision classifier reads, and thereasonstrings recorded intodeployments/*.jsonl(persisted;cdkd eventssanitizes at render). A source-shape fence pins that a bare${op.*}exists only inside an event-reason statement or the gated remedy. - A nested-stack
Parametersentry spelled as a raw PUBLICssmtoken that acdkd importrecord kept is no longer recorded as the child's reference on a rollback replay when its resolved value coincides with a secret the parent's bag holds (issue #3090, found in the #3088 review) --src/deployment/secret-redaction.ts(recordNestedStackParameterExpressions, the whole-token walk), unit tests. UnderSTATE_DERIVED_RULESthe position pass certifies such a token with no pair evidence; refusal 1 passed on the secret's key and refusal 4 found nothing to disagree with, so the child's{Ref}leaf persisted the public reference and the next deploy reported one spurious UPDATE (the #1901 class, never a plaintext). A fifth refusal asks the pass's own pair table -- the same gate the sub-floor carry's per-name association already carried -- so a losing sibling (its pair recorded uncollapsed) and an unpinnedssmreference (a pass-local pair) still certify, while a token this pass never resolved falls to the value scan. Consequence for therulesargument:trustAnyExpressionalone no longer changes what the recorder writes for a resolved reference (an unpinnedssmpositions through the span arm's empty frame under either ruleset); the constants still decidesourceIsSameGeneration. Unit fixtures that hand the recorder a bareMapnow record the pairs the resolver would. - A lock's deadline renders the same everywhere it is shown, and a numeric-string
expiresAtreads as unknown in the contention refusal as it already did incdkd state show(issue #3085) --src/state/lock-contention-message.ts(new exportedformatLockExpiry, replacing the privateformatRemaining),src/cli/commands/state.tsandsrc/state/lock-manager.ts(their own copies from issue #3083 deleted in favour of the export),docs/troubleshooting.md, a newtests/unit/state/lock-expiry-renderer-sync.test.tsand a case intests/unit/state/lock-contention-message.test.ts. The helper takes the RAWexpiresAtand tests it withNumber.isFinite-- the testisLockExpiredmakes -- so a numeric string, which the check treats as expired and the next acquire takes over, no longer printsexpires in ~Xmin the refusal that advises against force-unlock. One user-visible wording change on the finite path: the contention refusal now prints seconds precision (expires in 12m4s,expired 1m0s ago) instead ofin ~12m/in under a minute/already expired, matching theLockErrorrefusal that advises the same decision and already did. The sync test refuses the expiry phrases in any other file undersrc/, and a consumer that hands the helper a difference. - A lock whose
expiresAtis not a finite number renders asexpires at an unknown timeinstead ofexpired NaNmNaNs ago(issue #3083) --src/cli/commands/state.ts(formatLockSummaryvia a newformatExpiry),src/state/lock-manager.ts(a privateformatExpiryshared by the expired-lock takeover warning, the acquire retry line and the finalLockError),docs/cli-state.md,docs/troubleshooting.md, and cases intests/unit/cli/state-record-shape.test.ts/tests/unit/state/lock-manager.test.ts.expiresAtis an unchecked cast, so{},"soon", an absent field, or theNaNthatgetLockRecordsubstitutes for a coercion that throws (issue #2947) all reachedformatDurationthrough subtraction and printedNaNmNaNs, whilelock-contention-message.ts'sformatRemainingalready said "at an unknown time" for the same input. For those inputs the two lock renderers now agree (a NUMERIC string still diverges:formatExpiryreads it raw as non-finite, while the contention message subtracts first — that file is held by go-to-k/cdkd#3072; issue #3085 folds the three renderers into one after it lands);isLockExpired's verdict on such a value (expired, so the lock is taken over) is unchanged. The takeover warning also stops blaming a crashed owner for a deadline that was simply unreadable: it names the non-finiteexpiresAtas the cause. Two changes on the FINITE path: theLockErrorrefusal readsexpires in 4m12srather thanexpires in: 4m12s, so all threeLockManagersites share one spelling; and the retry line and that refusal render a finite PAST deadline (an expired lock whose takeover was refused) asexpired 5s agowhereformatDurationused to floor it toexpires in -5s. - Two nested-stack
Parametersspelled through ONE literal frame around two DIFFERENT secrets resolving to the SAME 1-3 character value (port:{{resolve:...A...}}andport:{{resolve:...B...}}, bothport:q7) now each persist their OWN frame in the child'sstate.json(issue #3079, residual (a) of #2745) --src/deployment/secret-redaction.ts(recordNestedStackParameterExpressions), unit tests,tests/integration/nested-stack-secret/(PinTwinParam). The parent's bag is keyed by plaintext, so the #2745 whole-value entry named one token and the losing parameter's child{Ref}leaf took the other token's frame -- a wrong referencecdkd rollback/drift --revertre-resolve after that sibling rotates. The recorder now also writes a per-NAME association (name -> its own frame) for every framed leaf passing the carry's conditions, survivor and loser alike, into the same table the whole-token walk fills; the child inherits it and reads it by name at the carry, its persist walk andcdkd diff --recursive. Condition (iii) gains a second arm: a loser's frame becomes the entry when every framed spelling of the value in the row carries that token, which closes the residual's other half (a loser under a DIFFERENT frame stayed plaintext). What remains: one child RESOURCE consuming both parameters holds one plaintext-keyed slot, and a leaf of it the association cannot reach by name (anFn::Subembedding theRef) reads that slot's frame -- the #2320 class. - An attribute a provider could not read back is OMITTED from state instead of recorded as
''(issue #3077) -- new leafsrc/provisioning/attribute-map.ts(definedAttributes/stringifyIfAssigned), applied at every?? ''/|| ''reachable from a provider'sattributesvalue (96 sites in 18 files):ec2-provider.ts(Instance create + update via a state-gateddescribedInstanceAttributes; VPC create),rds/docdb/neptune(cluster + instance, create + update),ecr,elasticache,codecommit-repository,servicediscovery,appsync,cloudfront-distribution,eventbridge-bus,s3-vectors,rds-dbproxy(-endpoint), fouragentcore-*providers;docs/cli-deploy.md,docs/provider-development.md. Under--no-waitapendingEC2 instance recordedPublicIp/PublicDnsNameas''and acreatingRDS-familyDBInstancerecordedEndpoint.*as''; state persisted it, the Outputs pass exported it, the resolver served it forever. Absent, an EC2 public address is re-described on the next resolution (the real value once running; while pending the instance id with a warning, nothing cached -- #3096), and an RDSEndpoint.*resolves to the identifier with a warning until the nextupdate()(#1852). An empty string AWS itself reports is KEPT: a running instance with no public address records''for both (CloudFormation's answer), so a private-subnet reference does not degrade to the instance id.Endpoint.Portis never'undefined'. An AppSyncGraphQLUrlor a CodeCommitCloneUrl*/Arnmissing from the create response now hits the resolver's*Url/*Arnrefusal instead of serving''.tests/unit/provisioning/attribute-map.test.tsfences the shape; the twoAWS::EC2::SecurityGroupVpcIdsites are allow-listed (#3097). GenerateSecretStringnow honoursIncludeSpaceandRequireEachIncludedType, draws from the service's punctuation set, and refuses what Secrets Manager refuses (issue #3068) --src/provisioning/providers/secretsmanager-secret-provider.ts,tests/unit/provisioning/secretsmanager-secret-provider-update-value-source.test.ts. The password is minted LOCALLY (never read back -- the premise the #2212 fence protects), so the recipe is whatever cdkd implements, and it diverged fromGetRandomPasswordfour ways: two declared members were never read (IncludeSpace;RequireEachIncludedType, whose service default is TRUE, so a short password could miss a class), the punctuation class was a 26-character subset of the service's 32 (anExcludeCharactersnaming"'/\backtick~was inert), and a fully excluded charset fell back to lowercase the template had excluded. Now: onegenerateCharsetfunction serves the member predicate and the generator; the space is admitted underIncludeSpace(and taken back byExcludeCharacters); underRequireEachIncludedTypeone character of every included class is PLACED at a random position, so the guarantee holds per draw at any length; draws are uniform (rejection sampling replacesbyte % n). Three refusals, each measured on CloudFormation rather than read off the docs:PasswordLengthbelow the number of required classes (Password length is too short based on the required types), a REQUIRED class emptied byExcludeCharacters(All characters of the desired type have been excluded-- withRequireEachIncludedType: falsethe same template creates, so the rule is scoped to required classes), and an empty pool (All characters have been excluded from selection). On the create path a refusal throws; on the update path it is the same SKIP the other members take, with the previous block retained.- A 1-3 character
secretsmanager/ssm-securesecret a parent embeds in a nested-stackParametersvalue through anFn::Join/Fn::Subframe (cdk.Fn.join('', ['port:{{resolve:secretsmanager:', secretArn, ':SecretString:pin::}}'])) now reaches the child'sstate.jsonasport:{{resolve:...}}instead ofport:q7(issue #3062) --src/deployment/secret-redaction.ts(recordNestedStackParameterExpressions), unit tests,tests/integration/nested-stack-secret/(PinJoinParam). The sub-floor carry issue #2745 added required the parameter's SOURCE to be a string, so an object-spelled source was never carried: the parent's own row persisted the frame while the child persisted the plaintext, andcdkd diff --recursivereported that child leaf on every run. For an object source the recorder now takes the spelling the position pass wrote for the leaf, when the pass rewrote it, the source's own token spellssecretsmanager:orssm-secure:literally, and the value scan is silent on the value -- a combination only the intrinsic frame arm produces -- and applies the same five conditions to that spelling. The object spelling also counts as its frame in the one-frame-per-value condition. Residuals (recorder docstring): anssm:token, or one whose service is itself an intrinsic part, is not carried, because the frame arm can match a recorded secret sibling for a public parameter's leaf; a frame that is not wholly literal is still refused; a child record persisted before this change keeps the plaintext until the child is next redeployed; at--verbosethe parent resolver'sResolved Fn::Join:debug line still prints the framed value (issue #3100). - The published Node.js floor is now 22.12.0 — Node 20 (end of life 2026-04-30) is no longer supported (issue #3037) --
package.json(engines.node>=22.12.0),vite.config.ts(vp packtargetnode22),.github/workflows/ci.yml(theruntime-compatmatrix smokes the built CLI on exactly 22.12 — the floor row — and on 24), README /docs/getting-started.md/docs/testing.md/CONTRIBUTING.mdand the agent-facing pages that state the range. installing on Node 20 now warns (npmEBADENGINE, pnpmUnsupported engine) and is refused underengine-strict, instead of installing silently onto an unsupported runtime; 22.12 is the floorcommander@15declares, so the nextcommandermajor cannot move the floor again as a side effect of a dependency bump. The five surfaces are pinned to one another bytests/unit/scripts/node-floor-sync.test.ts, which goes red when any one of them moves alone. Underbump-minor-pre-majorthis lands as a MINOR release. The dev / CI pin (.node-version, 24.15.0) is unchanged. The same floor lands in cdk-local (#722) and cdk-real-drift (#1905) in the same pass. - A resolvable Output added beside a persistently failing one now lands on a no-change deploy, so
cdkd diff --failstops showing a realADDfor it forever (issue #2771) --src/deployment/no-change-outputs-merge.ts(new),src/deployment/deploy-engine.ts(no-change branch),src/analyzer/outputs-diff.ts,docs/cli-diff.md,docs/state-management.md,docs/architecture.md, with cases intests/unit/deployment/no-change-outputs-merge.test.ts,deploy-engine-outputs-only-change.test.ts,deploy-engine-skipped-outputs.test.tsanddeploy-engine-outputs-generation-rules.test.ts. Any unresolved output used to keep the previous outputs bag whole. The no-change path now persists a merge: a resolved key writes its value, a failed key keeps its stored value (and its literal export alias when the previous record published it) or stays absent, and a key this pass did not produce is removed. The export set and exports index follow the merged bag. Two shapes still keep the whole previous bag, with a warning naming why: a failed output WITH a stored value whoseExport.Nameis intrinsic, and a merge that would carry a value into a bag it gives its first secret-bearing expression, re-checked on the bag as the save redacts it, after the observed-state drain. The save no longer positions a carried key, or a bag kept whole, by today's template, which could persist a reference the stored value never came from.computeOutputsDiffreads its secret-expression predicate from the merge module. Follow-up: go-to-k/cdkd#3101.
Recently Implemented (2026-09-13):
- A planted
rollback-journal.jsoncan no longer forge the plan rowscdkd rollbackasks you to confirm (issue #3064) --src/types/rollback-journal.ts,src/cli/commands/rollback.ts, cases in two suites. The journal is a SIBLING ofstate.jsonin the same bucket, so anyone withs3:PutObjectwrites it, and it carries no more validation than an unchecked cast. Issue #3003 hardened thestate.json/lock.jsonreaders; this is the reader it did not cover. Two surfaces.parseRollbackJournalinterpolated the raw V8SyntaxError-- which QUOTES the offending input -- plus the stack name (an S3 key segment) and the record's ownjournalVersion; nothing downstream caught it, becauseformatErrorprints a non-CdkdError'smessageRAW and sanitizes onlycause. The worse one is the PLAN PREVIEW, which rendered each entry'slogicalId/resourceType/changeTypeand each segment'sreason/runIdunsanitized: cdkd's output is line-oriented, so an injected newline invents a line that reads exactly like a planned operation -- and unlike a forged diagnostic, this is the text a user CONFIRMS against, so the confirmation attested to something other than what would run. Both modules route every rendered value through ONE predicate (the ASCII allowlist -- a logical id, a CFn resource type, a change type, a run id and a region all have known charsets), rendering a value that sanitizes to nothing as<unrenderable>rather than an empty slot. The sixpreviewStateLOOKUPS keep the rawlogicalIddeliberately: sanitizing a key silently mismatches the record it is meant to find, and a test pins that. NOT changed: the thrown error's CLASS.formatErrorprintsmessageraw in both branches, so what fixes this is sanitizing the interpolation, not wrapping -- and a new class would break callers matching on the current one. - A malformed MEMBER of
GenerateSecretStringis refused on create and SKIPPED on update, instead of minting a wrong secret or throwing (issue #3056) --src/provisioning/providers/secretsmanager-secret-provider.ts,src/provisioning/config-shape.ts,tests/unit/provisioning/secretsmanager-secret-provider-update-value-source.test.ts. The members of the block #3048 guarded were still read by cast. Bug:PasswordLength: 'abc'(truthy) reachednew Uint8Array('abc')and minted an EMPTY password; a truthy'false'EXCLUDED punctuation; an unparseableSecretStringTemplatesent the bare password RAW where a JSON document was declared -- as the newAWSCURRENT, silently; andExcludeCharacters: nullTHREW fromupdate(), the #1544 hazard one level below the container. Fix: every member is read through the sharedconfig-shape.tspredicate for its type -- the newconfigIntegerRefusal/coerceCfnIntegerpair forPasswordLength(a CFn"32"coerces;0/3.5/'abc'/'1e2'/nullrefuse), the boolean pair for theExclude*flags,configStringRefusalfor the three strings -- plus a PAIR rule measured on CloudFormation (SecretStringTemplate and GenerateStringKey must both be set or removed): together or not at all, the template a JSON object, the key not__proto__.PasswordLengthis capped at 4096. The retention of the previous block runs the SAME member predicate, so a stale record with a malformed member is dropped. On the create path a refusal throws (template-borne); on the update pathgenerateSecretStringhands the message to the sameonUnusableskip the container takes, so the value is SKIPPED, announced once, and the previously-applied block is retained viaeffectiveProperties.IncludeSpace/RequireEachIncludedType(declared, never read) are a fidelity gap: #3068. - A 1-3 character secret a parent embeds in a nested-stack
Parametersvalue through a LITERAL frame (Pin: 'port:{{resolve:...:pin::}}') now reaches the child'sstate.jsonasport:{{resolve:...}}instead ofport:q7(issue #2745, its last site) --src/deployment/secret-redaction.ts(recordNestedStackParameterExpressions), unit tests,tests/integration/nested-stack-secret/(PinParam). The child's carry matches an inherited pair whole at any length or as a substring at/aboveMIN_NEEDLE_LENGTH; the framed value was neither. The parent's recorder now positions a MARKED copy of the row's resolvedParametersand records the framed value as a WHOLE-VALUE entry of the parent's own bag under five conditions: the pass certified the leaf; the value scan is silent on it; the frame's token is the map's survivor for its middle; no other parameter of the row spells the value through a different frame or without this pass's pair; no other leaf of the row would be rewritten by the entry. Every consumer (the child's carry and persist,redactParametersForDiff,cdkd diff --recursive, the rollback replay) reads it floorless through existing arms. No pair and no pin are recorded for the entry. Residuals (recorder docstring): over one middle with two tokens the CHILD gets the survivor's frame for both leaves when the frames match and plaintext for the loser when they differ (the parent record stays per leaf); the framed value is a substring needle in child resources that consumed the parameter; an over-long frame (MAX_SKELETON_CANDIDATE_LENGTH) sends the resource's intrinsic-sourced leaves to the value scan; a child OUTPUT carrying it re-resolves in the parent to plaintext; a child record persisted before this carry keeps the plaintext until the child is next redeployed (cdkd scrubcannot repair it); anFn::Join/Fn::Subparameter source is issue #3062.
Recently Implemented (2026-09-12):
A malformed
GenerateSecretStringno longer strands a secret un-rollbackable, nor poisons its state record (issue #3048) --src/provisioning/providers/secretsmanager-secret-provider.ts,tests/unit/provisioning/secretsmanager-secret-provider-update-value-source.test.ts. Bug:changedSecretValuepassed the container straight togenerateSecretString, which readsExcludeCharactersthroughreadConfigString-- so once #3032 widened that guard to the unresolved-intrinsic shape, a malformed container THREW on the update path, which the rollback executor's revert arms reach with a cdkd STATE record the user cannot edit (the #1544 hazard;drift --revertnever hands this key over). Fix: the value is SKIPPED (UpdateSecretgoes out with noSecretString, so AWS's merge semantics keep the live value) and the previously-applied block is RETAINED in the recorded bag viaeffectiveProperties. The second half is load-bearing: recording the malformed desired bag would overwrite the usable one, the next deploy would compare equal, take theunchangedearly return, and leave the secret un-regenerated with NO warning. The previous side runs the SAME predicate the wire does; when it is absent or itself unusable the key is DROPPED, and the drop is WARNED, since a record with no value source later reaches the replay-create. Deliberately NOT anonUnusabledowngrade, because proceeding is the harm: a malformed container indexes every member toundefined, minting a bare default-charset password, returned RAW as the newAWSCURRENT.create()keeps REFUSING (no live secret to fall back to), now through the samerequireConfigObjectguard, and WARNS when the bag carries no value source at all (a secret with no version). Both container gates are!= nullrather than truthy (a falsy malformed block no longer falls through to the literal read).An unresolved intrinsic is no longer accepted as a CFn config CONTAINER, so the block stops silently emptying (issue #3032) --
src/provisioning/config-shape.ts, plus its unit suites. Bug: the container gates testedisPlainObject, but{ "Ref": "X" }is a plain object -- so it passed, every member probe indexed toundefined, and the block read as EMPTY: the silent DROP this family exists to refuse (#1581). All FOUR gates had it, not justrequireConfigObject: the siblings are safe only in their FIELD halves, and review measuredreadConfigStringreturning the FALLBACK for anFn::IfVersioningConfiguration-- versioning OFF on a live bucket, this module's own #1471 headline through the new shape. Widest site: the replicationFilter, whose fall-through emitsFilter: {}-- valid CFn for "replicate EVERY object" -- so an intrinsic widened replication to the whole bucket; a lost lifecycleFilterexpired it bucket-wide. Reach is dominated by the STATE-borne replay paths (the rollback executor's reverse-replacement create and revert arms,cdkd drift --revert). A fresh template is mostly but not wholly excluded: the resolver throws on an unresolvableRef/Fn::GetAttand on an unknownFn::*, butFn::Transformhas no dispatch arm and arrives verbatim -- refusing it is right anyway, an unexpanded transform AS a container meaning the macro never ran. Fix: a single-keyRef/Fn::*object is unusable, taking each site's EXISTING arm: a refusal where the site refuses (UPDATE sites included -- several deliberately do, per #1493/#1471, their fallback blanking a live value), warn-and-skip where it passesonUnusable. No call site changed. The message names the cause, notgot an object. The predicate is deliberately NOT shared with the other near-copies; its doc comment says why, and a test pins itskeys.length === 1boundary against the broaderkeys.everyspelling.AWS::S3::Bucket'sObjectLockConfiguration.Rule.DefaultRetention.DefaultEventHoldnow reaches AWS (PR #3002) --src/provisioning/providers/s3-bucket-provider.ts,package.json,tests/fixtures/cfn-schemas/AWS-S3-Bucket.json,docs/_generated/nested-key-coverage.{json,md},docs/schema-refresh-runbook.md,tests/unit/provisioning/s3-bucket-provider-*.test.ts,tests/integration/s3-object-lock/. Bug: the 2026-09-11 CFn schema capture addedDefaultEventHold(an OBJECT carryingDays/Years) underDefaultRetention, andapplyObjectLockConfigurationnamed onlyMode/Days/Years-- so a template declaring an event hold had it dropped by the SDK serializer while the deploy reported success, the #1370 silent-drop class. Fix: the applier forwards it, andreadObjectLockemits it back in the same CFn spelling -- without that half a recorded key the readback can never report is permanent phantom drift, re-issued by everycdkd drift --revert. The floor moved:@aws-sdk/client-s3is^3.1129.0, up from^3.0.0, because the member reaches the wire only where the client's model declares it (3.1126.0 does not). A malformedDefaultEventHoldSKIPS the whole Put (requireConfigObject; warn-and-skip on replay-reachable paths, refusal on a template-path create): it is the only OBJECT here, so unlike its scalar siblings it vanishes silently instead of failing at S3, and the Put replaces the entire rule, so retention minus the hold would apply a WEAKER configuration than declared. An unresolved intrinsic is a plain object and still passes -- a residual everyrequireConfigObjectsite shares, pinned by a test. On upgrade a hold declared BEFORE this release does not self-apply: the old binary recorded the declared bag while never sending it, so the next deploy compares equal and reportsNO_CHANGE.cdkd driftsurfaces it;drift --revertor any edit to the block applies it.A 1-3 character secret embedded through an
Fn::Join/Fn::Subsource, and onecdkd importresolves itself, now persist as their{{resolve:...}}token instead of plaintext (issue #2745, two of its three sites) --src/deployment/secret-redaction.ts(positionByIntrinsicFrame),src/cli/commands/import.ts, unit tests,tests/integration/secrets-dynamic-ref/(DB_PORT_SUB/DB_PORT_JOIN/PortJoin, Guard 3d) andtests/integration/import-secret-observed/(SubFloorParam). The dominant CDK shape --'port:' + secret.secretValueFromJson('pin'), anFn::Joinfusing the prefix into the token's opening part, the ARNRefinside -- had no positioning route: the skeleton arm answers only a WHOLE-token leaf and the literal span arm needs a source string, so the leaf fell to the value scan, which wrote a sibling's spelling above its needle floor and NOTHING below it. The new arm, run after the skeleton's refusal, renders the literal parts with the skeleton's own parser, finds the one token, and writes the ONE candidate matching the token's pattern (caps kept) over the skeleton's stores plus the pair table -- which alone names a collapsed loser, so an unpinnedssmwhole-token join the skeleton refused is written back as itself -- when this pass resolved it to the middle, under the literal arm's bound or, below the floor, the engine's same-generation mark.cdkd importnow marks the bag its own resolver produced (pair provenance). Residuals: a nonliteral intrinsic frame (aRefoutside the token) still falls to the scan; a publicssmleaf beside a same-service secret sibling resolving to the same middle takes the sibling's token (a wrong reference, which three consumers re-resolve LIVE:cdkd rollback,drift --revert, and a consumer stack's cross-stack read of the marked output; stated on the arm's docstring); the nested-stack twin stays with #2745;maskSecretsInTextkeeps the floor (#2453).cdk-local0.147.7 -> 0.148.4 closes a wrong-region-host bug and stops a resolver error's text reaching the user (issue #1821). Two upstream user-visible deltas, both previously pinned by cdkd tests asserting the OLD behaviour on purpose:Partition table. cdk-local's table predated the three rows #1764 added, so every
cdkd localpath reachingderivePseudoParametersFromRegion—Fn::JoinCode.ImageUrionlocal-start-api,local-invoke-agentcore(x3),lambda-resolver— resolvedus-isof-/eu-isoe-/eusc-regions as COMMERCIAL even spelled canonically, synthesizing hosts like<acct>.dkr.ecr.us-isof-south-1.amazonaws.comthat do not exist. They now resolveaws-iso-f/csp.hci.ic.gov,aws-iso-e/cloud.adc-e.uk,aws-eusc/amazonaws.eu— identical to cdkd's own table.tests/unit/local/intrinsic-image.test.tsstopped asserting the commercial answer and now asserts the correct literals AND agreement withderivePartitionAndUrlSuffix, the invariant #1821 was really about. Commercial regions unaffected.Resolver error text. A
--from-state/--from-cfn-stackcross-stack lookup that THROWS no longer puts the error'smessageinto the per-key unresolved reason; the reason names the export and the stage, reports a character count, and points at--verbose. Same hazard #2803 closed on the import path — a thrown message can carry an assembled secret and the audit entry is rendered to the user.tests/unit/cli/local-invoke-cross-stack.test.tsnow drives a canary and asserts it is ABSENT.Changed:
package.json,pnpm-lock.yaml,pnpm-workspace.yaml, those two tests,.claude/rules/layout-local.md. Supersedes #2725, whose CI failed on exactly these four cases.
Recently Implemented (2026-09-11):
cdkd driftnow warns on a stack whose state predates the refused-baseline marker (issue #3011) --src/cli/commands/drift.ts,tests/unit/cli/drift.test.ts,docs/cli-drift.md. Bug: (#2952)'s detection gate keys onResourceState.observedBaselineRefused, which only a v10-or-later binary could have written. The import refusal ((#2842), widened by (#2850)) shipped in released 0.288.x, so a stack adopted by one of those carries the same untrustworthypropertieswith NOTHING marking them -- the gate does not fire, drift compares it, and because a refused record spells no{{resolve:for the redaction to key on, the live decrypted value is printed. The marker cannot reach that population by construction, and widening the gate to bareobservedProperties === undefinedwould sweep in every pre-observed-capture record. Fix:warnIfPreV10BaselineGapprints ONE warning per stack when the loaded state is below schema v10 AND some resource drift actually COMPARED has no drift baseline (askippedorunsupportedresource is never compared, so it cannot carry the disclosure), naming the stack, the region and those resources. The condition is exact, not a heuristic:version < 10is precisely "cdkd could not have recorded a refusal here, whatever it did"; a v10 record omitting the field is one nothing refused. No behaviour or exit-code change; a version-less record reads as older than v10. The warning explicitly says it is not a repair signal --saveStatestamps the current version unconditionally, so any write (this run's--accept/--revert, or a NO_CHANGE deploy whose baseline refresh then refills those resources FROM the untrustworthy properties) silences it while the records stay broken; only a re-import, or a deploy that CHANGES a listed resource, repairs one. Cost: one line on a pre-v10 stack never imported -- a warning, not a refusal, which is the direction to err in.cdkd state show/cdkd state resourcesrefusals can no longer forge a row with the record that caused them (issue #3003) --src/utils/error-handler.ts,src/utils/logger.ts, bothsrc/state/backends, thestate/exportcommands,docs/cli-state.md, cases in eight suites. Issue #2772 made every value those views RENDER control-safe: cdkd's output is line-oriented and a state record is an unchecked cast anyone withs3:PutObjectcan write. The ERROR paths were not, and a refusal is what a malformed record MOST likely triggers -- so the diagnostic a reader trusts most could invent a row the rendered output cannot. The widest of them isformatError'sCaused by:line, which printed the underlying error verbatim: every site below passes the original error as itscause, routinely aJSON.parseSyntaxError, which QUOTES the offending input -- so an unreadablestate.jsonput bucket bytes on the terminal one line BELOW an already-sanitized message. Measured:state showrendered four lines where two were owed, one with a live ESC. Sanitized at that one site, so every thrower inherits it. The refusal sites take it too:parseStateBody,getState,tryGetLegacy,getLockRecord(its catch AND the has-no-body sibling aLockErrorrethrow skips),resolveSingleRegion, both commands' refusals, and the nested-stack walker. Each command file routes them through ONE predicate, not a per-site expression. Known-charset values take the ASCII allowlist, free-form text the denylist. The LOGGER's extra args take it too:JSON.stringifyescapes C0, so no line could be forged, butU+0085, the C1 range xterm reads as CSI, andU+2028survive it -- andgetLockRecordlogs the parsedlock.jsonwhole, of which two keys were safe. NOT closed: a value throwing on STRING COERCION still throws -- issue #2947.--allow-unsupported-propertiesis renamed--prefer-sdk-route, and cdkd now says when your preference had no effect (issue #3000) —src/cli/options.ts,src/cli/commands/deploy.ts,src/provisioning/provider-registry.ts,src/deployment/recreate-targets.ts, 8 docs pages,tests/unit/cli/options.test.ts,tests/unit/provisioning/provider-registry-report-silent-drops.test.ts. The old name read as the OPPOSITE of its behaviour and rhymed with--allow-unsupported-types, which runs the other way: that one ENABLES a Cloud Control attempt, this one DECLINES the Cloud Control auto-route. Whypreferrather than a negative is inpreferSdkRouteOption's doc comment: routing is per RESOURCE while the preference is per<Type>:<Prop>, so "do not route" is a promise the flag cannot keep. The help text carries what the name does not — the named values go UNWRITTEN when the preference is honoured, and it is ignored when the resource routes to Cloud Control for another reason. The old spelling still works: hidden from--help, warning once per process and naming the successor. Both feed one set. New behaviour: when the preference is INERT — a property was named, an uncovered sibling routed the resource to Cloud Control, and Cloud Control wrote the named property after all — cdkd WARNS, naming the ignored property, the sibling that caused it, and the remedy. It fires only for a user who passed the flag for a property on that resource, so it is not the broadcast warn issue #2750 retired; that one falsely claimed the property would be dropped, and the narrowed assertion inprovider-registry-report-silent-drops.test.tskeeps the false sentence gone. Five option-surface probes, all red; live-verified on the built binary.cdkd driftno longer prints the live secret for a resource whose observed baselinecdkd importrefused (issue #2952) --src/cli/commands/drift.ts,tests/unit/cli/drift.test.ts,docs/cli-drift.md,docs/state-management.md. Bug: detection pickedobservedProperties ?? properties, so a refused record (schema v10'sobservedBaselineRefused, (#2944)) was compared against the recordedproperties-- which after a downgradedFn::Ifhold the WRONG-BRANCH LITERAL while AWS holds the secret the deployed branch resolved. Both masking inputs are EMPTY for such a record, for the same reason:secretPathscomes from RESOLUTION and its offline fallback scans for where{{resolve:strings ARE, and a refused record spells none. So nothing marked the path secret-bearing and the decrypted value rendered as the AWS side of an ordinary drift row -- human report,--json, and both plans. Reachable since (#2828) introduced the refusal; (#2944) only made it detectable. Fix: a fourthNotComparedCause,baselineRefused. Detection reports such a resourcenotComparedrather thandrifted, and the gate sits BEFORE the provider lookup, so cdkd does not read the resource back from AWS at all -- the plaintext never enters the process.notComparedrather thanclean: a silentcleanis the "report a resource cdkd never compared as a pass" failure the cause enumeration exists to prevent. Exit code: the new cause inheritsoutcomeExitSignal's non-zero side, which that switch already defaults a new member to -- correct here, since a refusal is CLEARABLE by deploying a change to the resource. Consequence: the (#2944)--accept/--revertrefusals become a second layer, since a marked resource no longer becomesdriftedat all. Not closed: a baseline refused by a PRE-v10 binary carries no marker, so none of these guards fire for it — (#3011).cdkd state show/state resources/state listrender a record whose shape violates its declared types instead of aborting on it (issue #2947) --src/utils/display-safe.ts(displaySafeno longer throws on a JSON value, newtruncateCodePoints),src/state/s3-state-backend.ts,src/state/lock-manager.ts,src/cli/commands/state.ts,src/cli/commands/export.ts,docs/cli-state.md, a newtests/unit/cli/state-record-shape.test.ts, and cases in three suites. A state record and a lock are unchecked casts, so a hand-edited one could abort those views with a rawTypeErrorbefore anything rendered. Inner record-shape validation stays out of the read boundary:parseStateBodyis the read path for deploy, destroy, rollback and the recovery commands, so beyond invalid JSON and the schema-version check it refuses only a non-object ROOT, by name, and inner shapes are tolerated where they are dereferenced. Anullresources entry or bag renders in every mode,--show-nestedand both JSON walks included.getLockRecordturns anexpiresAtwhose number coercion throws intoNaN, which reads as expired, so a takeover no longer deletes the lock and then throws.displaySafefalls back toObject.prototype.toStringwhenStringthrows, closing the half of issue #3003's version refusal that its own entry records as still open. AskippedOutputsdigest preview is cut by code point, so it never splits a surrogate pair. Residuals: #3018 -- consumers outside these views still abort on a malformed bag,state destroyandstate orphanamong them.- A
cdkd importbaseline REFUSAL is now carried on the state record, so no later writer refills it (issue #2944, state schema v9 -> v10) --src/types/state.ts,src/cli/commands/{import,state,drift,export}.ts,src/deployment/deploy-engine.ts, unit tests,tests/integration/schema-v9-to-v10-migration/. Bug: the observed-baseline refusal ((#2828) / (#2850)) left onlyobservedProperties: undefinedbehind — ALSO what a pre-v3 record leaves — so the commands whose job is to FILL a missing baseline filled it against the record'sproperties, which after a refusal can hold the WRONG-BRANCH LITERAL the refusal distrusted. Such a literal pairs with a string readback as an ordinary drifted literal, so the redaction refuses nothing and the DECRYPTED value is persisted — the GHSA-p5qg-v9gv-hc7w direction re-opened by a later run. FIVE writers, three more than the issue named (this fix's sibling sweep plus review): the deploy auto-refresh andcdkd state refresh-observedpersist the plaintext as a baseline,cdkd drift --acceptpersists it intoproperties,cdkd drift --revertpushes the placeholder to AWS OVER the live secret, andcdkd importitself refills a record a SELECTIVE MERGE preserved. Fix:ResourceState.observedBaselineRefused?: true(schema v10).cdkd importsets it on a refusal and clears it on a capture that succeeds FOR A ROW IT RE-IMPORTED; all five writers skip a marked record. Clearing it: deploy a CHANGE to the resource — the record rebuild drops the flag. A NO_CHANGE deploy does not, nor does re-runningrefresh-observed. Visibility:cdkd state showrenders anObservedBaseline: REFUSEDline. Migration is transparent — absent means "not refused", the pre-fix behaviour of every writer. An OLDER binary refuses a v10 blob: the point, not a side effect, since one ignoring the flag would refill the baseline — at the cost of hard-failing on any stack a v10 binary wrote. cdkd diffpreviews a rollback-orphan adoption, andcdkd scrubscans the records (issue #2943). Both commands were blind toStackState.orphans, the field #2934 added. Design rationale — why the preview renders before reporting a refusal, why the scrub needles are a union of two sources, and the residual neither covers — in docs/design/2943-diff-scrub-orphan-records.md.cdkd diffran no adoption pre-pass, so it reported a CREATE for a resourcecdkd deployre-adopts and UPDATES. It now runs the same pre-pass, at the same point (after condition pruning, before the diff), at every node including nested children. An adopted row is annotated[adopted from a rollback orphan]. It does not mutate the state record it is handed.- New exit code
3: the preview completed ANDcdkd deploywould refuse to start. Reasons print underBlocking (cdkd deploy will refuse):after the full preview. Distinct from--fail's1and from2.--jsongainsblockingandadoptedOrphansper node. cdkd scrubwalkedresourcesandoutputsonly, soNo plaintext secrets foundwas silent about a field carrying a wholeResourceState, andorphansrode the persist spread unscrubbed. It now derives needles per record from the record's own{{resolve:...}}expressions, unioned with every secret the run learned elsewhere, and writes the scrubbed records back.readSiblingPhysicalIdsmoved from a privateDeployEnginemethod tomakeSiblingClaimReaderinsrc/deployment/orphan-adoption.ts, shared by both commands.- Changed:
src/cli/commands/{diff,diff-recursive,scrub}.ts,src/deployment/{orphan-adoption,deploy-engine}.ts. Docs:docs/cli-{diff,scrub,reference}.md.
- A deploy following a rolled-back deploy no longer collides with what the rollback left behind (#2934). A
DeletionPolicy: Retainresource survives a rollback and leaves cdkd's state, and because cdkd's generated physical names are deterministic, the nextcdkd deployasked AWS for a name the orphan still held — failing and repeating forever (#2902). The rollback now records the discardedResourceStateinStackState.orphans, and the next deploy verifies it (still in AWS, still declared by the pruned template under the same logical id and type with no explicit name, claimed by no other stack) and splices it back into state before the diff runs, so the diff produces an ordinary UPDATE and the redeploy works, contents intact. A hand-deleted orphan drops its record and the ordinary CREATE succeeds; a renamed or unverifiable one is KEPT with an info line, so the #2916 diagnosis still prints on the collision that follows; one another stack already claims STOPS the deploy.cdkd rollbackandcdkd destroyno longer treat a stack holding such records as empty: the former deletedstate.jsonin the same run that minted the record, the latter's fast path deleted it unconfirmed. Destroy now lists what stops being tracked. No schema bump: absentorphansis today's behaviour in every direction. Changed:src/types/state.ts,src/deployment/{orphan-adoption,deploy-engine,rollback-executor}.ts,src/cli/commands/{rollback,destroy-runner,import}.ts,src/provisioning/resource-name.ts. Verified on real AWS bytests/integration/retain-orphan-redeployandretain-orphan-secret, the latter seeding a canary secret out of band and proving the plaintext reaches no S3 object version. Residual: #2943. - A failure-path state save no longer MASKS an
observedPropertiesbaselinestate.jsonalready holds intact (issue #2906) --src/deployment/secret-redaction.ts,tests/unit/deployment/secret-redaction-uncertified-fail-closed.test.ts,secret-redaction-array-identity.test.ts,deploy-engine-secret-redaction.test.ts. Bug:scrubResourceRecord's rules derivation selected the fail-closedSTATE_SOURCED_BASELINE_RULESfor the observed bag whenever the per-resource secrets map was EMPTY. That is right for the fresh drained readback the refusal exists for, butDeployEngine.redactStateForPersistreaches the same arm on every failure-path and intermediate save for a resource whose resolve threw before recording anything -- and there the bag is the PRIOR generation'sobservedProperties, re-written UNCHANGED. A position the walk could not pair (an array AWS reordered, an identity key AWS normalised) becameSECRET_MASK, producing permanent phantom drift nocdkd driftrun clears until the next successful deploy overwrites the record -- the persist-path cousin of the journal poisoning closed as (#2886), and not a disclosure. Fix: the arm asks TWO questions now -- the map is empty AND the bag carriesmarkSameGenerationBag's mark, i.e. THIS RUN produced it.drainObservedCapturesmarks every bag it installs and is the deploy path's onlyobservedPropertieswriter, so the (#1900) / (#2852) / (#2885) refusal is unchanged for every bag it was written for; an unmarked bag is a previous generation's by elimination, and failing open there is safe for the reason (#2886) gives -- the value already sits instate.jsonverbatim. Scope: the derivation, so theorphansarm (#2948) added to the same function inherits it;cdkd state refresh-observedandcdkd import's capture pass the constant explicitly and are untouched. Not closed: a REFUSED baseline refilled by a later writer -- (#2944). cdkd import's observed-baseline capture now FAILS CLOSED at a position it cannot certify (issue #2885) --src/cli/commands/import.ts,secret-redaction.ts(doc only),tests/unit/cli/import-observed-baseline-refusal-matrix.test.ts,docs/{import,cli-drift}.md,.claude/rules/layout-deployment-secrets.md. Bug: the capture passedSTATE_SOURCED_READBACK_RULES, so the residue (#2852) left behind was live: where the readback and the record could not be paired at a reference-bearing position, the DECRYPTED value was persisted intoobservedPropertiesinstate.json-- the GHSA-p5qg-v9gv-hc7w direction, on ordinary shapes (a reshaped container, an added wrapper level, a scalar returned as a container, an identity key AWS normalised, a list AWS reordered or whose length it changed, two lookalike elements, a bare-token element with no literal frame). Fix: it passesSTATE_SOURCED_BASELINE_RULES, the constant a caller DECLARES when its bag becomes a drift baseline and nothing else -- which this writer's does.cdkd drift --acceptkeeps the other constant because for a record with NO baseline it writes intoproperties, where a mask blockscdkd exportand the rollback replay. Cost, on a population the pre-existing skip does not cover: a resource the classifier ADMITS whose readback cannot be paired keeps its record and its baseline with***at the uncertified leaves, so it reports as drifted on every run and--acceptrefuses it until a deploy CREATES or UPDATES that resource (the auto-refresh fills only a MISSING one). Over-masking is the deliberate direction; a resource with no dynamic reference cannot be masked at all. Still open, measured rather than inferred: a dropped or traded reference ((#2850)), a parameter bound to a placeholderDefault((#2854)), and a secret with no counterpart in the source ((#2868)) -- the last of which (#2852)'s own note listed as closed and is not.cdkd import's observed-baseline refusal now detects DISCARD events, not just lost opener counts (issue #2850) --src/cli/commands/import.ts,tests/unit/cli/import-observed-baseline-refusal-matrix.test.ts,docs/import.md,.claude/rules/layout-deployment-secrets.md. Bug: the success-arm refusal compared{{resolve:opener COUNTS over the raw property bag, so a reference the bag never spelled was persisted DECRYPTED intoobservedProperties-- the GHSA-p5qg-v9gv-hc7w direction. Measured leaking shapes: a downgradedFn::Ifdropping one reference while a parameter-Default-sourced one is ADDED elsewhere (totals balance); a reference sourced from a parameterDefaultorMappingsthen dropped (both counts zero, no warn); multi-key dispatch sibling drops; and, from review,Fn::FindInMap's LAZY 4-argDefaultValue(discarded unresolved on a lookup hit) plus non-literal lookup keys, andFn::Select's RAW-consumed index (a non-static index discards the eagerly-decrypted list). Fix: a third refusal arm,resolveDiscardsNonInertSubtree, statically detects those discard events on the raw bag and refuses a baseline when any discarded subtree is not provably inert (pure literals, no opener text, no intrinsic-shaped key;{Ref: 'AWS::NoValue'}counts inert -- it cannot produce a value). Source-blind but fail-closed, so reference sources need no enumeration; the walk itself fails closed -- a bag too deep to traverse REFUSES rather than aborting the import mid-write. Additive: refusal only widens. Cost: anFn::If(or keyed lookup) discarding a benign intrinsic loses its drift baseline until that resource's next deploy; pure-literal discards stay admitted. Not closed: placeholder-Defaultvalue divergence ((#2854)), readback-only secrets ((#2868)), refused baselines refilled by later writers ((#2944)); option (b) priced and deferred on the issue thread.- A secret a part records after the drain cap stopped waiting for it is no longer dropped, and every outputs reader that runs after it arrives redacts against it (issue #2814) --
src/deployment/deploy-engine.ts(ForwardingSecrets,absorbOutputsPassSecrets),src/deployment/intrinsic-function-resolver.ts(allSettledKeepingFirstRejection'sonAbandoned),src/deployment/drain-budget.ts,.claude/rules/layout-deployment.mdandlayout-deployment-secrets.md. The #2563 drain's wait is capped, so a part it stops waiting for keeps running and recordsplaintext -> expressionLATE. TheExport.Nameblock resolved into a per-output map copied into the outputs pass map in afinally, so such a record was DROPPED and a later export name carrying that plaintext became a state key; it now resolves into a map that keeps its own entries (the exposure check's exact set) and writes each through to the pass map as it is recorded. The outputs bag was redacted when the pass ended and read unchanged afterwards; everyredactOutputsnow re-reads the pass map, and the final save, the exports index and the deploy summary (the no-change path's summary included) each redact the bag again when they read it, so a record landing during the observed-capture drain or the save's await reaches every one that runs after it. A drain the cap releases with parts still running now warns, once per budget, with the count. Not closed, and not closable without the wait the cap bounds: a reader that took its copy BEFORE the record arrived (a failure message already printed, a state save already sent). Cancelling the part instead would leave its plaintext missing for every reader. cdkd deployno longer calls a provider'supdate()for a change the no-change re-check should absorb -- aDeletionPolicy/UpdateReplacePolicy-only flip, or an intrinsic resolving to the stored value -- on a resource carrying a--prefer-sdk-routesilent drop (issue #2809) --src/deployment/deploy-engine.ts(the UPDATE arm's no-change re-check) andtests/unit/deployment/deploy-engine-silent-drop-record.test.ts. Since issue #2750 the re-check compared a record NARROWED to what the SDK route can write against a desired bag that still carried an allow-listed removable drop (a create-only drop stays on both sides), so the two could never match for an SDK-routed resource carrying one. Such a resource took a redundantupdate()whenever the diff reported a change the re-check exists to absorb, and the attribute-only branch nested inside the skip was unreachable. ForAWS::SNS::Subscription, whereRegionis such a drop, that update is the provider's delete-then-create, so by that code path aDeletionPolicy/UpdateReplacePolicy-only flip re-created the subscription under a new ARN. Live-tested onAWS::CloudWatch::Alarmbysdk-to-cc-autoroute4b/4c. The desired operand is now narrowed by the diff's own desired-side rule (withoutAcceptedSilentDropProperties: the allow set only, and not for a record on Cloud Control; a pre-v7 record with noprovisionedBycounts as SDK, as it does for the stored side) -- deliberately not the record side's every-drop rule, because an un-allowed drop auto-routes the resource to Cloud Control, which does write the key. One consequence: a record a pre-#2750 binary wrote keeps its stale allow-listed key through such a deploy, where the redundantupdate()used to rewrite it away; the diff and the re-check narrow it before comparing, andcdkd driftcompares against it only for a record with no observed baseline.
Recently Implemented (2026-09-10):
A deploy that collides with a resource cdkd itself left behind now says so, and names the command that recovers it (issue #2902). A resource carrying
DeletionPolicy: Retainstays in AWS when a deploy rolls back and its state record is dropped — CloudFormation semantics, and whatRetainis for. But cdkd's generated names carry no random component, so the next deploy asks AWS for a name the orphan still holds, fails already-exists, rolls back, and repeats: the stack cannot be redeployed at all, and nothing named the collision or a remedy. CloudFormation never shows this — its names carry a random suffix.On a plain CREATE whose colliding name is one cdkd DERIVED, the failure is now followed by a line naming the cause and giving the adoption command (
cdkd import <stack> --resource <id>=<name>). A diagnosis, not a behaviour change; whether a rollback should instead RE-ADOPT the resource is issue #2914.It stays silent for a name cdkd did not derive, and withholds the command — saying why — for a nested-stack child, a type with no import, or a name or logical id whose characters would make it name something else. The command is shell-quoted. A derived name is predictable rather than proof of ownership (a globally-unique one can belong to another account; the same stack in another region derives it too), so the message asks you to confirm it is yours first.
docs/troubleshooting.mdgains this case, and corrects its physical-name description:<StackName>-<LogicalId>holds only when it fits the type's length limit, otherwise cdkd truncates and appends 8 hex chars.Changed:
src/deployment/deploy-engine.ts,docs/troubleshooting.md. Fences:deploy-engine-orphaned-name-collision.test.ts(most cases are refusals). Live:tests/integration/retain-orphan-redeploy, which runs the command the message emitted and asserts the redeploy then succeeds.A Cognito user pool that consumes a same-deploy SMS (SNS-caller) IAM role no longer fails the deploy outright on IAM trust-policy propagation (issue #2901). CDK's
UserPoolL2 auto-creates that role formfaSecondFactor: {sms: true}, and cdkd's fast SDK path issuesCreateUserPoolbefore IAM has propagated the trust policy, so AWS answeredInvalidSmsRoleTrustRelationshipException/ "Role does not have a trust relationship allowing Cognito to assume the role". No entry inIAM_PROPAGATION_ERROR_MESSAGE_PATTERNSmatched it (0 of 49, across all three arrays), so this was not a mis-shaped retry budget — the create was single-shot. The same template deploys undercdk deploy, whose latency lets IAM settle.cdkd now retries it on the dense IAM-propagation grid, like the Firehose / Glue / Step Functions / CloudTrail / KMS races already in that table. One entry covers every call that validates the role, including
SetUserPoolMfaConfig— reached from bothcreate()andupdate(). The entry keys on the message tail rather than the exception name the issue asked for, and drops the service name even though AWS supplies one; both reasons, and the arithmetic behind not widening the provider's own inner retry instead, are at the entry inretryable-errors.ts.Live A/B against real AWS, one variable (a stack whose SMS role deliberately omits Cognito from its trust policy, so the rejection is permanent): 70s / 26 IAM-propagation retries with the entry, 13s / no retry without it. Same template, same AWS message — which is what establishes the dense grid is selected, not merely that a retry happens.
Changed:
src/deployment/retryable-errors.ts. Fences:retryable-errors.test.ts(this message is matched by exactly the new pattern; the near misses keep missing it) andcognito-provider.test.ts(the inner-loop rethrow, on both paths). Live: edge 5 oftests/integration/propagation-races-2.cdkd scrub/cdkd deployno longer print a recorded secret that an invisible character had split, andscrub --failnow reports such a key instead of passing over it (issue #2874). Four warning sites insrc/deployment/outputs-export-alias.tsdecided "does this name hold a secret" from the RAW name and then printed a SANITISED one.stripControlCharsDELETES its class, so a plaintext split by one of those characters is absent from the raw name and contiguous in the printed text: the verdict said safe and the message published the secret atwarn, into the CI logs the--dry-run --failgate runs in. Measured, eight of ten tested characters reconstituted the plaintext verbatim; the two that did not printed it one character short.Every printed name now goes through
secretSafeKeyDisplay, whose verdict, masking and returned text all happen in one canonical string space, derived from Unicode categories rather than enumerated. The mechanism and the design that was measured still leaking are on that function's doc comment.Two user-visible consequences.
cdkd scrub --failexits1on a state key it previously passed over — that key was already leaking; the change is what cdkd can see, not what the state holds. Rotate the secret and change theExport.Name. And a warning may print<name withheld: contains a secret>where masking cannot leave a safe name; the collision and its remedy are still named.Changed:
src/deployment/outputs-export-alias.ts,src/cli/commands/scrub.ts,src/deployment/deploy-engine.ts,src/utils/display-safe.ts(a back-pointer only). Fences:tests/unit/deployment/secret-safe-key-display.test.tsandsecret-scan-class-superset.test.ts, which scans the BMP to prove the class covers both sanitisers'. The remainingstripControlChars-alone sites keep the narrower class — issue #2888.cdkd drift --revertstops shipping two wrong values to live AWS, and its mask refusal names the right remedy (#2855, #2893, #2881; all insrc/cli/commands/drift.ts).#2855 — a record refused an
observedPropertiesbaseline (#2842) falls back to rawpropertiesholding intrinsic OBJECTS ({Fn::Join: ...}) fromcdkd import's warn path, and the revert pushed the raw object live. Measured with mocked SDKs: no route fails loudly — the SDK route puts the object into the wire call, Cloud Control emits it as a patch value, and a JSON-string property stringifies it into a schema-VALID string AWS accepts silently. The revert now REFUSES such a resource beforeprovider.update(exit2, remedy:cdkd deploy), scanning the BASELINE at drifted top-level keys, only for records with NO observed baseline (collectUnresolvedIntrinsicObjectPaths).#2893 —
preserveLiveValuesAtUnresolvedTokenspaired array elements POSITIONALLY behind a length test, so a reordered readback copied another element's live value into a token position. It now pairs through the masked-leaf walk'spairedLiveItemsrule (identity first, else per-leaf frame corroboration), wildcard per caller; on refusal the token is KEPT — this pass's residual — except a 1-vs-1 array, a FORCED position where refusal would destroy the live value, so it is preserved as before. Closes the token half of #2884 (PR #2882 closed the masked half).#2881 — the mask refusal asserted "(a NoEcho custom-resource value)" and prescribed a nonce bump, wrong for the now-common #2852 fail-closed cause. It now names both causes and remedies; both token warnings state the pairing condition (partial: that issue's other messages stay open on it).
Docs:
docs/cli-drift.md. Tests:tests/unit/cli/drift-masked-leaf-preserve.test.ts,drift-secret-redaction.test.ts.The readback position walk FAILS CLOSED, so a position it cannot certify no longer persists a decrypted secret (issues #2852, #2846, #2869) --
src/deployment/{secret-redaction,deploy-engine}.ts,src/cli/commands/{state,drift,scrub}.ts, nine unit suites, one integ fixture,docs/cli-{drift,state}.md. Bug: the walk substituted the source expression only where it could CERTIFY and otherwise returned the raw bag -- the GHSA-p5qg-v9gv-hc7w disclosure (state refresh-observed, plain deploys). Reached by reshaped containers, normalised keys, reordered arrays, rawFn::Joinrecords ((#2846)), BINARY leaves. (#2012)'s rule-3 residual is retired: a refused indistinguishable position persists***, not the readback. Fix: such a position persists asSECRET_MASK, for a caller that DECLARES its bag a drift baseline (STATE_SOURCED_BASELINE_RULES) --cdkd state refresh-observedand the deploy's own capture. Destination is not derivable: an--acceptmask inpropertiesblockscdkd export. REPLAYED baselines keep the old answer ((#2886) closed: the journal passes the readback constant);cdkd import's capture is one constant away ((#2885)).--revertnow pairs masked array leaves BY IDENTITY, else per-LEAF corroboration -- index copying shipped a sibling's value ((#2884), masked-leaf half). Evidence comes from the PRE-token bag: the token pass's index copies cannot corroborate or identity-launder. The source is never written; a needle wins only on a WHOLE leaf. (#2869) + (#2897) write half: a non-plain object (Date/Uint8Array) corroborates NOTHING; the position/preserve walks return it BY IDENTITY, the untemplated merge takes the baseline -- never rebuilt as{}; rebuilds target null-prototype objects (own__proto__keys survive). Still open: an observed KEY the source does not carry.cdkd importno longer writes an unredacted AWS readback intoResourceState.attributes(issue #2847).attributeswas the only bag on an imported record no redactor touched (propertiessince the GHSA fix,observedPropertiessince #2828).The choke point:
resolveImportedPropertiesredactsattributesagainst the same secrets map that redactedproperties, on the THROW arm too: a resolve that failed partway already put plaintext in the bag.The Cloud Control surface:
CloudControlProvider.importsurfaced the wholeGetResourcemodel. It is narrowed to the type's schema-declaredreadOnlyProperties; uncertified keys are MASKED (***) rather than dropped, LEAF-WISE so containers stay walkable: a dropped key or masked container dead-ends inconstructAttribute's wrong physical-id return; a masked leaf is refused by name. An unresolvable schema certifies nothing and warns namingcloudformation:DescribeType.The
Refreader: a state-keyRefrecovery returned the first non-empty string, and***is one. It now skips a masked leaf and REPORTS it, for an OPTED-IN caller only:cdkd deployfails the resource — or the stack OUTPUT reading it — by name.cdkd diff/scrub/importare unchanged;cdkd orphanreports the site unresolvable,--forcewarns.Not closed: a read-only attribute that IS a credential stays in the clear. The same payload lands unnarrowed in the
observedPropertiesdrift baseline (#2868); the deploy path rewrites the whole model back (#2925);cdkd export's mask blocker testspropertiesonly (#2932).Changed:
import.ts,cloud-control-provider.ts,intrinsic-function-resolver.ts,deploy-engine.ts,orphan-rewriter.ts, newread-only-properties.ts.The intrinsic resolver masks a resolved secret AT the throw and in the log line beside it, and registers an
Fn::Base64encoding as a derived needle so it is not persisted (issues #2827, #2759) --src/deployment/intrinsic-function-resolver.ts,scripts/check-resolver-mask-coverage.ts,tests/unit/deployment/intrinsic-resolver-throw-masking.test.ts,tests/integration/secrets-dynamic-ref/. Bug (#2827): the resolver masked a resolved value when it LOGGED it and left it bare when it THREW, so an assembled plaintext travelled to every caller inside an error message; #2728 and #2803 each fixed one boundary only. Bug (#2759):Fn::Base64over a dynamic reference returned the secret in a reversible encoding no needle matches, so it reachedstate.jsonand printed on the debug line that masked its own input. Fix: every throw and log statement naming aresolveValueresult masks the RAW value, never the assembled message. The two truncating region gates mask aroundstripControlChars, never after.slice(0, 64).resolveBase64records its OUTPUT as a mask-only needle when masking changed its input; JSON encodings are leaf-masked first. The population is no longer hand-enumerated -- three sweeps disagreed, soscripts/check-resolver-mask-coverage.ts(CI) requires every interpolating site in that file to be masked or carry a written exclusion note. That mask also reaches four refusals that name only their NoEcho source -- folded onto #2881, which owns them. Three residuals are stated at their sites;Fn::Base64also resolves differently on the deploy and diff paths (#2909, predating this change).cdkd state shownow renders theskippedOutputsrecord, the field that explains a missingcdkd diffrow (issue #2772) --src/cli/commands/state.ts,docs/cli-state.md, and new cases in thestate show/state resourcessuites. Issue #2740 addedStackState.skippedOutputs-- the Outputs keys the last deploy could not resolve, each mapped to a digest of its template inputs -- andcdkd diffuses it to preview such a key as ABSENT with no row and no warning, while the record binds and the key is still absent from the stored outputs. That silence is deliberate, and it left the text view with no explanation for a vanished Output. The field was readable through--jsonand in the stored record, and nowhere else. ASkipped outputs:block now renders when the map has entries, afterOutputs:where that section exists at all, one line per key sorted by name, each carrying its digest's first 12 characters and a trailing…when the value was cut (the(unserializable)sentinel is shown whole), then an explanation at column zero, printed ONCE per render, not per child under--show-nested. It is omitted when the field is empty and when it is absent; absence means only that nothing was RECORDED. Exact comparison stays with--json, unchanged; the render site says why it truncates. PRE-EXISTING rows changed too, acrossstate showand bothstate resourcesmodes: every string either command renders from a state record is now stripped -- name, type, id, dependency list, header -- so none can forge a row in output joined by newlines. The lock row already was, upstream. And a VALUE whose declared type the record got wrong now prints -- as itself, as JSON or as(unserializable)-- where it used to empty the whole render.docs/cli-state.mdcarries the per-command detail and go-to-k/cdkd#2947 the shapes still out of reach.
Recently Implemented (2026-09-09):
AWS::ApiGatewayV2::ApiresolvesExecuteApiArninstead of hard-failing the deploy (issue #2833) --src/provisioning/providers/apigatewayv2-provider.ts,src/deployment/intrinsic-function-resolver.ts,scripts/gen-sdk-attr-coverage.ts,tests/unit/provisioning/apigatewayv2-execute-api-arn.test.ts,tests/unit/deployment/intrinsic-functions.test.ts. Bug: AWS published this read-only attribute in the schema capture refreshed by issue #2821; the provider recorded onlyApiId/ApiEndpointandconstructAttributehad no handler for the type. A cross-resourceFn::GetAttreads the CACHEDresource.attributes[<CFnName>]and never callsgetAttribute, so it reachedguardedPhysicalIdFallback, which HARD-THROWS for a*Arnwhose fallback is notarn:-shaped -- the physical id is a bare api id. Broken deploy, not a wrong value (the #1179 class). Fix, two halves.CreateApireturns no ARN, sobuildExecuteApiArnconstructsarn:<partition>:execute-api:<region>:<account>:<apiId>on create, onimport(), and as an update HEAL -- partition DERIVED rather than hardcoded, region canonicalized, a FABRICATED account refused with a warning rather than persisted, and a build failure never failing the create. The heal writes only when EVERY member is in hand: the engine REPLACES attributes, so a partial map would ERASE an ARN an earlier deploy recorded. That still leaves every record whose API is unchanged, since NO_CHANGE runs no provider -- soconstructAttributegained the type too, covering pre-existing, CC-routed and cross-binary records with no property change required. Not allow-listed: aknownGapentry would have turned the critic green in one line; thecarries no KNOWN GAP entriesfence refuses that, and deleting an entry is what verifies a fix, sinceclassifyTypetestscachedKeysfirst.cdkd importnow redacts theobservedPropertiesbaseline it captures (issue #2828) --src/cli/commands/import.ts, three unit suites, one integ fixture. Bug:captureObservedForImportedResourcesassigned the provider'sreadCurrentStateresult onto the record VERBATIM -- the DECRYPTED value for a{{resolve:secretsmanager:...}}property -- at both call sites (the root walk and the recursive--migrate-from-cloudformationchild walk). The gap issue #1926 closed forcdkd state refresh-observed. Fix: route the captured bag throughredactSecretsForStatewith an EMPTY secrets map, positioned against the record's own redactedpropertiesunderSTATE_SOURCED_READBACK_RULES; and, that redaction being POSITION-based, refuse a baseline outright for any resource whose resolve THREW or LOST a{{resolve:opener the raw template spelled. PLAINTEXT CAN STILL BE PERSISTED BY THIS CAPTURE, andstate.jsonmust still be treated as sensitive -- separately,cdkd importalso writesResourceState.attributesfrom a live AWS read through no redactor at all (#2847). Known ways: a reference the opener comparison cannot see (#2850); a parameter bound to a placeholderDefaultwhile the deployed value was the reference (#2854); a position the redaction walk cannot pair -- reshaped containers, identity keys AWS normalised, readback keys the source lacks, arrays anchors cannot corroborate (#2852, carrying the probe table); a secret with no counterpart in the source (#2868). Not proven exhaustive: the only evidence is a post-hoc comparison of the persisted bag against the readback, so whatever it cannot see is trusted.- The nested-key critic judges a CFn definition against the SDK interface a REQUEST can carry, and the refreshed fixtures move three properties to Cloud Control (issue #2821) --
scripts/gen-nested-key-coverage.ts, 19 refreshedtests/fixtures/cfn-schemas/*.json,src/provisioning/property-coverage.generated.ts,docs/_generated/. Bug: the shape pass resolved a CFn definition to the same-spelled SDK interface. Several services name the request shape<X>Inputwhile<X>is the RESPONSE, so the match lands on a shape no request can carry and members existing only on the twin reportdefinition-member-missing-- a CI-BLOCKING verdict whose remedy is an allow-list entry: a promise cdkd never sends a value it does send. AWS dropped the suffix fromAWS::Glue::Connection's definitions, turning four clean keys into blockers. Fix:resolveDefinitionInterfaceprefers the exact name when it is reachable from an operation input, else an<X><suffix>twin overINPUT_ROOT_SUFFIXES, else the exact name; the twin lookup is gated on the exact name EXISTING and on a half-members overlap, so neither an unmatched definition nor an unrelated shape is rebound. Reachability follows ref, array and map-value edges. Measured: 357 definitions, 6 not reachable, 5 with a twin; the old resolution reds those four keys and nothing else, the new one is clean with every other bucket identical. User-visible from the refresh:AWS::Glue::Connection.Tags,AWS::CloudWatch::Alarm.WarmUpConfigurationandAWS::BedrockAgentCore::Runtime.CapacityProviderConfigurationbecome known silent drops routed to Cloud Control;AWS::RDS::DBCluster.AvailabilityZonesis no longer create-only (no replacement follows).AWS::Glue::Connectiongains its FIRSTsilentDrop, so an existing SDK-provisioned connection settingTagsmoves to Cloud Control and pinsprovisionedBy: 'cc-api'ONE-WAY -- it is not inSTICKY_CC_MIGRATION_EXEMPT. cdkd importmasks the resolver error text it logs, so an assembled secret plaintext no longer reaches stderr (issue #2803) --src/cli/commands/import.ts,src/cli/commands/scrub.ts,tests/unit/cli/import-resolver-error-masking.test.ts. Bug:resolveImportedPropertiesdeclared its per-resourcerecordedSecretValuesINSIDE thetrythat callsresolver.resolve(...), so thecatchcould not name it and interpolatederr.messageverbatim into alogger.warnprinting at default verbosity.cdkd importsets noskipDynamicReferences, so the resolve decrypts, and the resolver's throws interpolate what they were handed --key '<jsonKey>' not found in secret '<id>'carries a plaintext whenever a later reference's secret id or JSON key was ASSEMBLED from an earlier one throughFn::Sub/Fn::Join, in the command whose contract is to persist the{{resolve:...}}expression and never the value. State was unaffected; this was a log exposure. Fix: hoist the declaration above thetryand wrap the message inmaskSecretsInText, the shaperollback-executor.tsanddrift.tsalready document at their declarations. Third instance of one class after #2728 and #2531. Twocdkd scrubcatches gain the same call for UNIFORMITY, with comments stating the measurement rather than assuming:resolveParametersis called with no context so its bag is provably empty, and theevaluateConditionscatch cannot receive a resolver error at all, that method wrapping every condition in its owntry(#2748) and downgrading tofalse. Residual: issue #2827 -- the resolver still throws resolved values unmasked, which is why a boundary mask keeps being added one caller at a time.- Every read of a resolver or template-parser bag by a template-controlled name is own-key now, and a
__proto__key stops being silently dropped (issue #2767) --src/deployment/intrinsic-function-resolver.ts,src/analyzer/template-parser.ts, two new test files. Fourteen sites: a name that is anObject.prototypemember answered where no entry existed, mostly as a WRONG RESULT rather than a refusal.Ref/Fn::GetAttreadresources['constructor']as theObjectFUNCTION and shippedundefinedinto a live property;Fn::Iftook the TRUE branch where an unknown condition assumes false;Fn::FindInMap, whose keys are free-form text, returned the function as the value; and aSecretString:constructorreference rendered the function's SOURCE as the resolved secret. Three WRITES lost a__proto__key to the inherited setter, the worst beingfilterResourcesByCondition-- a declared resource vanished from the effective template, so the diff read "in state, absent from desired" and issued a DELETE for it. Reads useObject.hasOwn(membership, NOT a value test, so a DECLARED parameter holdingundefinedstill returns it, per #2285); the value-bearing writes SHADOW the key withdefineProperty, sinceObject.create(null)would makeString()on a resolved object throw. Per-site mechanism and the probe results are in the two test files' doc comments. The method is the durable part: four review rounds each surfaced sites the last had missed, so the third replaced grep-and-guess with an ENUMERATION of everyintest and identifier-keyed index in both files -- and the fourth showed that enumeration had been complete while the TRIAGE missed three, each dismissed without reading its callers. Residual #2776: the downstream walks inrollback-executor.ts/drift.ts, andresolveSub's own map. - An Output that never resolves at deploy is no longer a phantom
ADDincdkd diff --failon the unchanged stack (issue #2740) -- newsrc/analyzer/skipped-outputs.ts,src/types/state.ts(skippedOutputs, informational, no schema bump),src/deployment/deploy-engine.ts,src/analyzer/outputs-diff.ts,src/cli/commands/{diff-recursive,import,drift,rollback,scrub,state}.ts,src/analyzer/orphan-rewriter.ts, newtests/integration/output-never-resolved-diff/, four new unit suites, docs. Behavior change: an output the deploy skipped never reachesstate.outputs. When the resolver threw inside a SECRET lookup (a JSON key the secret does not hold) the diff could not reproduce it --skipDynamicReferencesassembles the reference cleanly -- socomputeOutputsDiffpushed anADDon every run; the other arm (an unconstructible attribute) dropped the whole Outputs section. The deploy now records each skipped key with a sha256 of its template inputs (every section butResourcesandOutputs), and the diff previews a recorded key whose digest still matches, while still absent from state, as ABSENT: no row, and not a failed section, so a sibling's genuine change still renders. Both sides digest a snapshot taken BEFORE any resolution; the diff also declines the record for an output referencing a resource THIS run changes, sinceResourcesis not digested. The no-change path saves on a record difference alone (the upgrade path). Saves that carry the bag forward without re-resolving carry the record (a partial destroy's snapshot too); every writer that rebuilds state outside a deploy (import,drift,rollback,scrub,orphan,refresh-observedonce it refreshed anything) DROPS it: an out-of-band rewrite can make a skipped output resolvable with nothing for the diff to un-bind on. Limitation: five repairs stay invisible until the next deploy (one by design: the maskedNoEchodefault). cdkd scrubrepairs the cross-stack exports index, not onlystate.json(issue #2667) --src/cli/commands/scrub.ts,src/state/export-index-store.ts,docs/cli-scrub.md,docs/state-management.md,tests/unit/cli/commands/scrub-export-index.test.ts,tests/unit/state/export-index-persisted-read.test.ts,tests/unit/deployment/outputs-redaction-idempotence.test.ts. What it does now: after each stack'sstate.jsonwrite, scrub reads{state-prefix}/_index/{region}/exports.json-- one store per region it touched -- and for every entry whoseproducerStack/producerRegionname that stack, rewrites the entry's VALUE tostate.outputs[<entry name>]where that value carries{{resolve:and differs.patchEntryper entry: nothing added, nothing removed. Convergence, not a plaintext match: scrub's map comes from LIVE resolution, so it holds the secret's value as it is NOW while an entry written before a rotation holds the legacy one; the state record is what a redeploy writes either way. The lookup runs in ONE direction -- anExport.Namealias is a key of the outputs bag, andExportIndexEntrycarries no output-key field.--dry-runwrites nothing and still READS, so--dry-run --failexits 1 on an index divergence over records that already hold the expression. Reported, not written: a refused write or an unreadable index raisesSCRUB_EXPORT_INDEX_INCOMPLETE(exit 2) naming the remainder, since a swallowed failure leaves the value in a region-wide object readable withs3:GetObjectand no version id; an owned entry ABSENT fromstate.outputsis warned; an entry whose producer this run did not scrub is coverage and never reddens--fail. The resolver still gets noexportIndex, so nopatchEntryfires as a resolution side effect. No new flag, no IAM change, no schema bump. A rewritten entry is SUPERSEDED, not erased --docs/cli-scrub.mdcoversexports.jsonthere too.- An embedded 1-3 character secret now persists as its
{{resolve:...}}token instead of plaintext, on the bags the deploy pass produced (issue #2516; residuals #2745) --src/deployment/secret-redaction.ts(markSameGenerationBag, the relaxed bound inpositionByEmbeddedSpan),src/deployment/deploy-engine.ts, three unit test files, andtests/integration/secrets-dynamic-ref/(DB_PORT_LITERAL/SECRET_PIN_STAGED/PortLiteral, with Guard 3c and the Phase 1e / 1g checks). The value scan's SUBSTRING arm drops every plaintext under four characters (its exact-match arm has no floor), so a literal leaf embedding one persistedport:q7inproperties, the readback and aCfnOutput; #2485's span arm bound itself to the scan's answer, which below the floor proves nothing on an old bag. The arm now accepts that silence ONLY on a bag whose generation the ENGINE proved: aWeakSetmark on the record'sproperties, thedrainObservedCapturesreadback and theoutputsbag, plus copies at the re-check and the journal. Composes with #2788: the record's bag is the resolved one MINUS the keys the SDK route drops, so the mark is taken AFTER that narrowing -- before it, the narrowed copy is unmarked and the leaf plaintext again. Read for the object handed toredactSecretsForState; an old record, a scrub / import / drift walk and a sub-bag alone stay unmarked. Per-site conditions are on the function's docstring. Residuals:effectiveProperties; a coinciding readback; an interference refusal leaving the sub-floor middle (silent when the interferer is itself sub-floor -- the OTHER secret under-redacted, never fabricated -- unless it matches the WHOLE leaf);maskSecretsInText, sharing the floor (#2453);cdkd scrub, walking a stored bag no deploy marked; theFn::Join/Fn::Subsource shape -- with the nested-stack andcdkd importtwins, #2745.
Recently Implemented (2026-09-08):
- A concurrent intrinsic resolution now drains every part before a rejection surfaces, so a sibling's secret is recorded before the caller sees the failure (issue #2563) --
src/deployment/intrinsic-function-resolver.ts(allSettledKeepingFirstRejection, wrappingresolveJoin's parts andresolveValue's array arm), newsrc/deployment/drain-budget.ts(the shared budget + opener), and the four callers that hoist one over a locked resolve loop (deploy-engine.ts,scrub.ts,import.ts,export.ts), a new unit file,.claude/rules/layout-deployment.mdand.claude/rules/layout-deployment-secrets.md. A secret dynamic reference recordsplaintext -> expressionintocontext.recordedSecretValuesjust before its promise settles, andPromise.allsurfaces a rejection AT ONCE, so a caller'scatch/finallyran while a sibling was in flight.DeployEngine'sExport.Nameblock copies its privatenameSecretsmap into the pass map in such afinally: a later recording reached nothing, leaving a plaintext the pass did not know as a needle. The drain keepsPromise.all's error, the EARLIEST REJECTION IN TIME, where scanningPromise.allSettledfor the first rejected ENTRY selects by input order. Both sites are wrapped: a list nested in a join part rejects through the array arm. The wait is capped at 60 s once a rejection is in hand:resolveOutputsruns after every resource exists, beforesaveState, with the lock held. One drain-wait budget covers a resolution, and each caller hoists one over its whole loop, so neither nesting depth nor loop length multiplies it; the timer is NOTunref'd, since an unref'd cap lets Node exit 0 mid-deploy. Residuals: #2805, earliest-in-time holds per invocation, so a NESTED failure can lose to a shallower one; #2814, a drain arming on a spent budget waits not at all. - A property dropped under
--allow-unsupported-propertiesis no longer RECORDED, so removing the flag actually applies it (issue #2750) --src/provisioning/{property-coverage,provider-registry}.ts,scripts/gen-property-coverage.ts,src/deployment/deploy-engine.ts,src/analyzer/diff-calculator.ts, four docs pages,.claude/rules/**,tests/integration/sdk-to-cc-autoroute/, 3 new unit files. The flag keeps a resource on the SDK route and accepts that the property is not written -- but the record carried the TEMPLATE bag, claiming a value AWS did not hold. A later flag-less deploy auto-routes to Cloud Control (issue #614), butCloudControlProvider.updatediffs its patch against that record: the property matched on both sides, the patch omitted it, and the deploy reported success having sent nothing. With nothing else moving the diff was NO_CHANGE and no update ran at all.propertiesnow describes what the SDK route SENT. The engine strips such a key from an update's previous side and its no-op skip -- ansdkrecord cannot have had one written, which heals a poisoned record -- but NOT from the data-loss guards or the replacementdelete(), which ask what the resource HOLDS.DiffCalculatornarrows both sides: the record side by every drop, the DESIRED side only by the ACCEPTED ones. The ROUTE is the whole question: an un-allowed sibling drop, or a stickycc-apirecord, sends the resource to Cloud Control, which forwards the full map -- nothing is dropped, and the false "will be silently dropped" warn on both is retired. A CREATE-ONLY drop is deliberately NOT narrowed: removing it would make it an ADDITION, hence a REPLACEMENT of a resource nobody touched (residual: issue #2790). Its fence's first cut was vacuous, parsingcreateOnlyPropertiesas/properties/Xwhere the fixtures store bare names, so it now floors the COMPARAND and not just the walk. - The Step Functions log-destination rejection is reclassified as IAM propagation, reversing a decision a live A/B falsified (issue #2783) —
src/deployment/retryable-errors.ts,tests/unit/deployment/retryable-errors.test.ts,tests/integration/stepfunctions-logging/**,docs/troubleshooting.md. Bug: a state machine withLoggingConfigurationmakesCreateStateMachinevalidate that the role can reach the log destination, and cdkd issues it ~1s after the role's DefaultPolicy CREATE — before IAM propagates thelogs:CreateLogDelivery/PutResourcePolicygrants — so AWS rejects it with "The state machine IAM Role is not authorized to access the Log Destination" and the deploy hard-failed. The phrase was already pinned PERMANENT by a test, on the reasoning that "authorized to ACCESS" differs from "authorized to ASSUME". Fix: add the sentence toIAM_PROPAGATION_ERROR_MESSAGE_PATTERNSso the dense 250ms→2s / 26-attempt / ~47.75s schedule absorbs it; its permanent causes (missing grants; a CloudWatch Logs resource-policy quota) only burn that bounded budget. A live A/B settles the reversal: with the grants absent the message is permanent; written 1s earlier, the IDENTICAL call succeeds. The fixture could not reach this window at all — SFN checks assume-role FIRST, so a role and grants created together let window 1 absorb the wait, and the run passed identically with the fix reverted. A new Phase 0 deploys the LogGroup + an explicit Role ALONE and lets the trust policy settle, so Phase 1 races the grants only, and Phase 1a ASSERTS the retry (needle +attempt N/26sentinel). Two runs measured 10 and 6 log-destination retries then success, 0 assume-role rejections in either. The pinning test is INVERTED, and a computed case proves NO truncation of the anchor is left unfenced -- a claim that was prose twice and wrong once.covers: AWS::StepFunctions::StateMachine. - The changelog's last duplicated entry is merged, and the uniqueness fence now covers the SECTION HEADINGS it was blind to (issue #1837) --
docs/changelog-cdkd.md,tests/unit/scripts/changelog-entry-uniqueness.test.ts,.claude/skills/work-issues/references/ship.md. The one cluster issue #1837 left open -- two copies of the issue #1933 dynamic-reference-cache entry -- was a FOURTH rewrite chain, not the genuine merge itsMERGE_PENDINGrow claimed. That row said the stale copy uniquely carried the--stack-concurrencyrace and thecdkd scrubdetail; both copies carried the live-coverage sentence the row said only one had, and the race and scrub detail sat in the very next entry, issue #1957's, at greater depth -- where the survivor's "(see the entry above it)" points. #1957 is closed, so the survivor's claim was true and the stale copy asserted nothing the document had lost; it is deleted andMERGE_PENDINGis empty. Auditing one level up found what the entry verdicts cannot see, since they key-bullets:**Recently Implemented** (2026-08-18):and(2026-08-25):each appeared twice among 52 headings, and the 08-25 pair had an 08-24 section wedged between its halves, so the dates ran 25 -> 24 -> 25 -> 23 down a newest-first file. Both duplicates are merged and the misplaced entry moved under the section already carrying its date. Three verdicts now key the heading line -- a population floor with an unparsed-heading companion, exact-TEXT uniqueness (so the deliberate "(2026-07-02, second batch)" split still passes), and a non-rising date order -- backed by a fixed self-probe corpus, because against the real file all three ACCEPT arms rest on that one second-batch pair and rewording it would retire them silently.ship.md's rebase recipe, which produces this defect and whose three residual checks are blind to it, gains the case and an assert.
Recently Implemented (2026-09-07):
check-scope-checker-inputs.test.tsno longer reds a checkout that has a.claude/settings.local.json(issue #2751) --tests/unit/scripts/check-scope-checker-inputs.test.ts, a comment in.markgate.yml; nosrc/change and no user-visible behavior change. Since #2738settings-bash-first-optout.test.tsspells.claude/settings.local.jsonas a literal it hands togit check-ignore/git ls-files; the scope checker resolves bare literals and demands an include entry for every one that exists on disk, so a developer with that gitignored file saw the main assertion name it as a missing include entry, while CI and a checkout without it never did. The literal is now aNON_READ_LITERALScarve-out (an argument, never a read), qualified by a newOPTIONAL_ON_DISKset: the fence still demands that every other carve-out entry exists, and for an optional entry demands instead that the repo's top-level.gitignorecovers it.gitIgnoresreadscheck-ignore -vand accepts only a.gitignore:<line>:source. A global excludes file and.git/info/excludeare refused because either would make the verdict differ between a developer machine and CI, which is the defect itself; a nested.claude/.gitignorebecause it is outside the gate'scheckinclude, so a rule moved there stales nothing; a!-negated pattern because it means the path is NOT ignored, which-v's exit 0 hides; and a TRACKED file is never ignored, which stops one being listed to dodge the check. A nonzero exit other than 1 is rethrown, not read as a verdict. The rules are one pure function pinned with controlled inputs, plus a throwawaygit -c init.templateDir= initrepo with.git/info/created explicitly -- only the init template makes it, so a machine settinginit.templateDirfailed the write while CI stayed green (.claude/hooks/stop-warn.test.sh:60-63records the same trap).The schema refresh job now settles the removals that carry no judgement, instead of handing every one to a human (issue #2774) -- new
scripts/offline-property-evidence.ts,scripts/diagnose-schema-refresh.mjs(+ its.d.mts),.github/workflows/cfn-schema-refresh.yml,docs/schema-refresh-runbook.md,.claude/rules/layout-scripts.md, newtests/unit/scripts/offline-property-evidence.test.ts; nosrc/change. A property AWS drops while a provider still declares it used to be a hand-written decision every time. The job writes it now when TWO structural facts hold: the type's OWN service client declares a member of the name -- a TypeScript-AST walk ofdist-types/models, not the case-insensitive name scan whose own doc calls it the wrong tool for deciding -- AND the provider wires it (properties['X']reads,.Xaccesses), so a name appearing only in a declaration array does not qualify. Exact AND lowerFirst spellings count, measured:@aws-sdk/client-api-gatewaydeclaresstageNameand noStageName, so exact-case-only would escalate every camelCase-modelled service. A rename candidate on the type escapes the rule outright -- the SDK keeps the old name either way, so both facts hold for a property whose fix is repointing the declaration;AutoScaling.DefaultCooldownis that shape live. The standing argument against auto-silencing is about choosing under UNCERTAINTY; it is rewritten rather than deleted, because these cases have none. Writes run BEFORE the checks so the PR arrives green, are listed in their own PR-body section, and are reversible -- delete the entry and the next cycle reports the property again.Route53::RecordSet.GeoProximityLocation, the case that motivated this, settles without reaching anyone.describe-typeis demoted from the runbook with it -- it re-reads the same upstream description and no job holding no credentials can run it.Fn::Sub's two-argument form no longer resolves its variable map IN PLACE, so a template object stays a description rather than a cache of resolved plaintext (issue #2739) --src/deployment/intrinsic-function-resolver.ts(resolveSub), a reader comment insrc/deployment/secret-redaction.ts, newtests/unit/deployment/intrinsic-sub-variables-not-mutated.test.ts.resolveSubwrote each resolved variable back intosubArgs[1]-- the object inside the caller's template (an Output'sValue['Fn::Sub'][1], a resource property's) -- so after one resolution the template held a secret's plaintext where it had held the reference or an intrinsic. Nothing persisted it and the first pass's own map recorded the secret, but a later resolution of the same object with a fresh per-pass map returned the plaintext WITHOUT recording it (no token left forresolveDynamicReferences), and the positioning sourceDeployEngine.resolveOutputsretains carried the plaintext (no positioning arm reads the map's values). The variables now resolve into a fresh null-prototype object: a__proto__variable (an own key afterJSON.parse) still substitutes; a placeholder naming anObject.prototypemember the map lacks (${constructor}) no longer renders that member's source on either form and falls through toRef; anull/ primitive second element is refused unconditionally and non-retryably (newly enforced -- before, a number, boolean or empty string failed only once a placeholder reached theintest), so the cross-stack reader's assumption that the shape is never recorded stays true; an array second element still resolves by index, but its non-enumerablelengthis no longer a variable. Every arm is pinned in the new test file; its core resolves one object twice through the real resolver over a faked Secrets Manager client (byte-identical; the fresh second map records via the cache-hit arm; the client answers once).A schema-refresh decision now reaches the maintainer instead of sitting unmarked, and the backfill umbrella stops describing a state that never landed (issue #2774) --
.github/workflows/cfn-schema-refresh.yml, new.github/workflows/backfill-umbrella-sync.yml,scripts/diagnose-schema-refresh.mjs(+ its.d.mts),.markgate.yml,docs/schema-refresh-runbook.md,.claude/rules/layout-scripts.md, newtests/unit/scripts/backfill-umbrella-sync-workflow.test.ts; nosrc/change. Measured on a manual dispatch (PR #2770, closed): two failed checks and three decisions in a 162-line body, presenting as no label, no assignee, and a title byte-identical to a clean refresh -- with CI held ataction_required, as GitHub does for aGITHUB_TOKEN-created PR, so no red mark either. Such a PR is now ASSIGNED (the only mark that notifies; both marks are attempted so neither can take the other down), labelled, titled with the count, and opened by a verdict line rewritten each cycle. The count iscountDecisions, whichrenderDiagnosiscalls rather than restating: five terms, two rendering no heading. It is CLEARABLE --RegenerateandDiagnosealso run while a PR is open -- but a cycle that published nothing may only clear it, never lower it (removals are diff-derived), and it refuses to clear over an un-regenerated tree. A PR that closed mid-run is not marked. The umbrella splice left this job for apush-to-mainworkflow, takingissues: writeoff the repo only unattendedcontents: writejob; both spliceers cut on LINE NUMBERS, sincesed -n 1,/BEGIN/pnever closes on a marker at line 1. Three-axis review plus two fix-delta rounds found 5 blockers, three in guards this PR had just added: a size test passed the sentence the script printed at exit 0; a whole-line marker match broke on web-UI CRLF; a fatal count stopped the PR opening. No surviving mutants.A failed condition's resolver error is no longer printed unmasked, and the same fix makes issue #2728's lookup-echo masking effective on
cdkd diff/import(issue #2748) --src/deployment/intrinsic-function-resolver.ts(evaluateConditions),outputs-export-alias.ts+src/cli/commands/scrub.ts(comment corrections), newtests/unit/deployment/intrinsic-resolver-condition-failure-masking.test.ts,.claude/rules/layout-deployment-secrets.md. That per-conditioncatchrenders at warn -- no--verboseneeded -- and condition evaluation re-entersresolveDynamicReferenceswith the ASSEMBLED string, so an entry whoseFn::Subvariable resolves a secret and uses it as a second reference's JSON KEY fails naming the password. Masking the line was not enough:maskSecretsForLogno-ops against absent bags, and 2 of the 4 callers (diff-recursive.ts,import.ts) pass context literals with neither -- a livecdkd diffagainst a real secret still printed it in full, which the unit suite could not see.evaluateConditionsnow INVENTS a private bag when the caller brought none, at the CALLEE so a fifth caller cannot reopen it; that bag reaches #2728's mask sites too, so the lookup echoes and retry labels are masked on those commands as well -- no-ops there before. Private constrains only the INVENTED map:cdkd scrubhands this pass its OUTPUTS bag on purpose, so a condition's secret IS a needle overstate.outputsthere, and three comments asserting otherwise are corrected. Six cases, incl. the bagless shape an earlier revision only asserted did not CRASH. Residuals: #2754, #2516, and one unclaimed --maskSecretsInTextmatches LITERALLY, so a re-encoded plaintext is masked by none of this.cdkd deployreports an Output resolution failure MASKED on both arms,causeincluded, and the resolver's own echoes are masked too (issue #2728) --src/deployment/deploy-engine.ts(handleOutputResolutionFailure+ its tworesolveOutputscall sites),src/deployment/intrinsic-function-resolver.ts(theResolving dynamic reference:debug lines, the throttle-retry labels, the SSM unrecognized-Typewarn and theUnsupported dynamic reference service:warn, viamaskSecretsForLog),tests/unit/deployment/deploy-engine-output-failure-masking.test.ts, a live arm ontests/integration/secrets-dynamic-ref/(OutputFailureLeak, declared only underCDKD_TEST_OUTPUT_LEAK=truefor one--verboseprobe deploy; Guard 1b). The default arm warnedFailed to resolve output <key>: <error>and--strict-getattthrew it, neither masked. The resolver's failures echo the reference token, secret id / JSON key or SSM parameter name, andresolveSub/resolveJoinre-enterresolveDynamicReferenceswith the ASSEMBLED string -- so a reference built from a value the same pass resolved out of a secret put that plaintext into the echoed field (key '<password>' not found), the lookup's echo printed it too, and a value landing in the SERVICE position reached a warn at default verbosity. Both arms now mask against the inherited bag and then the outputs pass map (the resolver's order; a still-pending part is #2563's residual), the strict arm'scauseis amaskSecretsInErrorclone (the non-retryable marker survives), and the default arm printserror.message || error.name. Pinned with a mocked resolver and with the REAL resolver over faked SDK clients through both re-entry seams, the SSM echo, both retry labels, theTypewarn and the service-position warn. Found by the round-2 review of #2562.The bash-first experiment is now pinned OFF in the repo's settings, so a contributor in that cohort no longer loses three file-tool-keyed guards silently --
.claude/settings.json,.gitignore,CLAUDE.md,.claude/rules/hooks.md, newtests/unit/scripts/settings-bash-first-optout.test.ts; nosrc/change and no user-visible behavior change. Claude Code'sCLAUDE_CODE_THRIFTY_SONICappends a system-reminder telling the session to read and WRITE files throughcat/sed -i/ heredocs instead of Read / Edit / Write. Three surfaces are keyed to those tools and go inert with no error line:worktree-owner-gate.sh(matcherEdit|Write|NotebookEdit), the PostToolUseWrite|Edit->vp run lint:fixentry, and everypaths:-scoped file under.claude/rules/. Measured on Claude Code 2.1.263: an explicitly set value SHORT-CIRCUITS the server-side cohort assignment, so the pin decides it for every clone where~/.claude/settings.jsoncovers one machine. Probe: at"1"claude -preports the reminder PRESENT, at"0"and unset ABSENT -- only the"1"arm discriminates. The fence resolves each entry by WHAT IT RUNS and reads matchers through the BINARY's own selection rule: four review rounds cleared each weaker lookup in turn -- a decoy entry ahead of the real one, the path demoted to a trailing comment,echo vp run lint:fix, and a matcher (...|[) compiling to nothing while a hand-split alternatives list still reported the tools present. It asserts a JSON string, so a case pins the Claude Code MAJOR.MINOR the measurement was taken on and says to re-probe when it moves (issue #2737) -- a reminder, not a detector. Separately,.claude/settings.local.jsonoutranks the pin, making it a repo DEFAULT; it is now listed in.gitignore-- fenced viacheck-ignore -v's SOURCE, since a developer's global ignore file covers the same path and made the first cut pass with the repo's line deleted.cdkd scrubresolves each intrinsicExport.Namethrough a VIEW of the outputs pass map that shares the entries live and keeps the resolved pairs to itself (issue #2531; engine-side residual #2563) --src/cli/commands/scrub.ts(SharedEntriesSecrets),tests/unit/cli/commands/scrub-export-name-collision.test.ts,tests/unit/cli/commands/scrub-shared-entries-secrets.test.ts,tests/integration/secrets-dynamic-ref/. The outputs pass's name loop ran FIRST and recorded into the SAMEoutputSecretsmap the value loop fills. Since #2485 the resolver records the uncollapsedexpression -> plaintextpair beside each entry and a second write with a different plaintext marks it conflicting; anssmreference whoseTypecame back unclassifiable is never cached, so a name and a value resolving it in one run each ask AWS, and a value that moved in between conflicted the pair -- a literalOutputembedding that token then fell to the value scan and persisted a same-plaintext sibling's spelling (the #2485 class; never plaintext at four or more characters). The name is now resolved through aMapsubclass whose every read and write goes to the pass map while the pairs, keyed by instance, die with it. A view rather than a copy becauseFn::Joinresolves concurrently and a late-recording part misses an end-of-block copy -- the shape the deploy engine keeps, which is #2563; the alternatives and the two residuals are in the class doc. Unit cases pin the moved value through BOTH seams (the primary resolver and the cross-region pin), the part-way throw masked, the late write reaching a later name, the later name's view, and the override set against the runtime'sMap.prototype; the live arm (FunctionNameExport, anFn::Subname) pins that the arm ran without either name-resolution fallback warning.A resource pinned to
provisionedBy: 'cc-api'can now return to its SDK provider, per resource and without churn (issue #2719) --src/provisioning/provider-registry.ts,src/deployment/deploy-engine.ts,src/cli/{options.ts,commands/deploy.ts,commands/diff-recursive.ts},src/types/state.ts, newtests/integration/cc-to-sdk-reroute/+ two unit suites, docs. Behavior change: the sticky rule made the #609 backfill benefit NEW resources only -- a stack that hit the auto-route earlier paid Cloud Control latency permanently, with no exit short of destroy + recreate.STICKY_CC_MIGRATION_EXEMPTis now a table of MODES:'cc-broken'(today's unconditional escape) and the new'sdk-coverage', conditional on the RESOURCE -- it flips only on a mutating deploy where neither the desired nor the RECORDED property bag carries an actionable silent drop. The recorded bag is the load-bearing half: a property applied under CC and since deleted from the template leaves a clean desired bag, so a desired-only condition would route that deploy to the SDK provider, which cannot unset it -- silently skipping the removal. It waits one deploy instead. A call passing NO properties never flips, which keeps destroy, rollback, drift and the legacygetProvider()conservative with no special case each.--pin-cc-api <LogicalId>declines a flip;--recreate-via-cc-apisets the same suppression internally, or the exemption would silently reverse it.cdkd diffrenders[returning to SDK provider], fixing a mislabel where an exempt type showedstickywhile routing to SDK. No schema bump.AWS::SNS::Topicis the first'sdk-coverage'member; a unit test refuses an entry whose integ fixture does not exist or never ran, so the parity claim cannot land before it was observed live. Residual: #2651.The SDK-vs-Cloud-Control routing decision is now inside both real-AWS integ gates, in
integ-destroy's STRICT half (issue #2720) --.markgate.yml,.claude/hooks/{integ-destroy-gate,integ-broad-gate,integ-stale-base-detector}.shand two suites,CLAUDE.md,.claude/skills/{verify-pr,pick-integ}/SKILL.md,work-issues/references/triage.md,.claude/rules/layout-provisioning.md,tests/unit/scripts/{cross-cutting-list-sync,skill-file-payload}.test.ts; nosrc/change and no user-visible behavior change.src/provisioning/register-providers.ts-- the registration TABLE -- was ininteg-broad, whileprovider-registry.ts, which decides WHICH provider runs every resource, was in NEITHER gate: a routing regression could merge with no real-AWS run. Blast radius measured, not inferred:getProviderForpicks the DELETE provider atdeploy-engine.ts:5809(plain delete) and:4557(a replacement's old delete),destroy-runner.ts:1342, and seven sites inrollback-executor.ts, plus create / update / replace, drift, state, import and the orphan rewriter -- one decision reroutes a whole template, delete included. STRICT, not hunk-filtered, is the load-bearing choice:integ-destroy-gate.sh's filter greps changed lines fordelete|rollback|ENI|detach|..., which a routing edit does not write -- five realistic mutations (a type added toSTICKY_CC_MIGRATION_EXEMPT, a replaced sticky predicate, a flipped returnedprovisionedBy) matched it 0 times while a control namingdeleteProvidermatched, so hunk-filtering would have been a fail-open for the very change this entry gates. Both halves of each gate moved together (the include AND the activation pattern);cross-cutting-list-sync.test.tsis the authority on which copies must agree, and the pin there makes a later removal cost a conscious test edit. New cases in both hook suites exit 0 against the pre-change hooks -- the fail-open -- and 2 after.The PreToolUse guard layer got a stopping rule, and the nine gates it excludes were retired (issue #2717) --
.claude/settings.json(39 -> 29 Bash gates), 10 deleted hooks + suites, 6 newscripts/check-*.ts,.github/workflows/{pr-content-checks,issue-conventions}.yml(new) +pr-title-check.yml,.claude/rules/{hooks,layout-ci-checks,layout-scripts}.md,CLAUDE.md,.markgate.yml; nosrc/change and no user-visible behavior change. The layer had grown by accretion with no rule for what belongs in it, and a PreToolUse hook is handed raw SHELL COMMAND TEXT, so each gate spent 300-1500 lines reimplementing pieces of a shell parser to find the artifact inside a command -- heredocs,--body-file,-F, glued flags, quoting,cd/git -Cresolution -- and every miss landed FAIL-OPEN. The rule: a gate may BLOCK only when the harm completes at the moment of the action AND lands on a THIRD PARTY's artifact, where the actor cannot undo it. Everything else goes to CI, or nowhere. Both clauses are load-bearing -- by irreversibility alone a duplicate issue would block, and it correctly did not; what decides is whose artifact carries the residue. Seven gates moved to workflows, which see the web UI and every non-ghclient the hooks never could and re-run onsynchronizewhere a hook fired once; two were deleted outright.gh-pr-edit-deprecation-gatewent on a MEASUREMENT instead:gh pr edit --bodyexits 0 and applies on gh 2.92.0, so every sentence in this repo saying otherwise was retracted. Residuals: #2729, #2736.A deleted gate's class got a mechanism back, and workflow
if:conditions became testable (issue #2736) -- newscripts/check-pr-closes-paren.ts+tests/unit/scripts/pr-closes-paren.test.ts,scripts/check-gh-body-english.ts(isBotSender),.github/workflows/{pr-content-checks,issue-conventions}.yml,tests/unit/scripts/{workflow-registration,gh-body-english}.test.ts,.claude/rules/{hooks,layout-ci-checks,layout-scripts}.md,.claude/skills/verify-pr/SKILL.md,CLAUDE.md; nosrc/change and no user-visible behavior change. Two residuals of #2717. (1)closes-paren-form-gatewas deleted on the stopping rule -- an issue left OPEN after a merge is one query to find and one command to fix -- and the criterion holds, but what it LEFT was a prose row in a skill, which is the instruction that gets skipped under time pressure, the argument the gate's own header made for existing. The successor WARNS from CI and exits 0; a case pins that 0, because returning 1 would make it a gate again. It adds a code-span and fence exemption the hook lacked (the hook read the raw body, so a PR documenting the rule tripped its own check) and drops the whole repo-resolution half, which existed only to find a PR inside a shell command. (2) No test could read a workflowif:, so deleting the Bot-sender filter -- whose failure mode is a comment loop the check then reports on -- was silent. That rule now lives in the script, theif:stays a runner-saving filter, and every job condition is pinned with table-vs-workflow coverage asserted both ways.
Recently Implemented (2026-09-06):
- Nothing watched CFn schema fixture freshness, so a property AWS added after the snapshot was silently dropped instead of auto-routing to Cloud Control (issue #2718) —
.github/workflows/cfn-schema-refresh.yml(new),scripts/refresh-cfn-schemas.mjs(+ its.d.mts),vite.config.ts,.markgate.yml,src/provisioning/{property-coverage,provider-registry}.ts, 3 new test files,docs/provider-rules.md,.claude/rules/{layout-scripts,layout-provisioning,providers}.md. SDK-vs-Cloud-Control routing is decided fromproperty-coverage.generated.ts, derived OFFLINE fromtests/fixtures/cfn-schemas/*.jsonwith no runtimeDescribeType— so a property AWS published after the snapshot produced nosilentDropentry, never auto-routed, and reached neither AWS nor an error while the deploy reported success. The issue #614 class through the input #614 cannot see. Measured against the 2026-08-13 snapshot: 9 types drifted, 3 writable additions live. Two halves. A daily workflow captures every registered type from AWS's PUBLIC schema bundle — no credentials, which removed the CI-IAM-role prerequisite that had kept this manual — and opens a PR on drift; it writes only fixtures that actually changed,generatedAtexcluded, because the first cycle rewrote 132 fixtures of which 114 differed in the date alone. Its PR is allowed to land RED: the bot runs only the mechanical chain, and a removed property or a new nested key needs a human — both fired on the first cycle. Between cycles, the deploy path now WARNS that an unrecognized top-level property will not reach AWS; it has no false-positive mode, since an SDK provider writes only itshandledProperties, and it fires only on the SDK route, mirroringgetProviderFor. Warn, not an error or an auto-route (a typo must not trigger the one-waycc-apiflip, issue #2719); suppressed by the existing--allow-unsupported-properties. - The stray-control-byte sweep now covers UNTRACKED files, so a byte in a brand-new file reds before its first commit (issue #2696) --
scripts/check-source-control-bytes.ts,tests/unit/scripts/{source-control-bytes,rule-file-payload}.test.ts,.markgate.yml,.gitattributes,.claude/rules/layout-scripts.md; nosrc/change and no user-visible behavior change. The population wasgit ls-filesalone, so a file a session had just written sat outside it: measured 2026-09-06, a raw NUL at offset 6547 in an untracked test file cleared every pre-commit gate including a full 910-filevp test run, andgit diffthen printedBinary files ... differinstead of a diff. It now also listsgit ls-files --others --exclude-standard--git status --porcelain's??set, so.gitignorestill excludesnode_modules/anddist/-- through the SAME loop and filters. The added half's floor is deliberately not a count: the untracked set is arbitrary and measures 0 on a clean tree, sountracked.length > Nfails on a clean checkout or is vacuous at N = 0. The invariant is the RELATION, so the test SEEDS a NUL into an untracked.tsfile in a SUBDIRECTORY -- pinning that the walk DESCENDS (#2696's own case was a file undertests/) and that no untracked-only extension exemption hides it -- and requires the sweep to name it; clean and gitignored twins cover the other directions.readstays SPLIT by origin so the tracked half's four extension floors cannot be topped up. The read is now guarded like the stat beside it: the untracked population is mutable mid-run, and an unreadable file killed the suite onEACCESrather than reporting a finding. Rationale and probe receipts in that test's doc comment. Five siblinggit ls-filespopulations share the blind spot -- #2701. - Every free-text cell a coverage-matrix generator writes now goes through one shared
escapeCell, and a fence reads the GENERATORS rather than the pages (issue #2636) -- newscripts/markdown-table.tsplusscripts/{audit-stateful-candidates,build-integ-coverage-matrix,build-scenario-coverage-matrix,gen-handled-property-wiring,gen-nested-key-coverage,gen-update-wrap-coverage}.tsand two new unit suites. Behavior change: none; regenerating every matrix leftdocs/byte-identical, which is why this was latent. A pipe in a table row is a cell delimiter in GFM even inside a code span, so a futureinvoke|start-apirationale would have shifted every value one column right of its header. NINE cells now escaped: seven prose sites (one more than the issue listed --gen-nested-key-coverage.ts's shape table renderssdkDetail, the prose its divergence table renders asdetail) plus two inaudit-stateful-candidates.tsthat are structural in ROLE but arrive from acloudformation:DescribeTyperesponse, third-party schemas included -- the escaping line is PROVENANCE, not role. Exactly ONE implementation existed, inbuild-scenario-coverage-matrix.ts; the other five had none. Contract: escape\and|, collapse any line ending, leave whitespace, tabs and<br>alone.docs-table-shape.test.tscould not have caught this -- it scans committed PAGES, measured zero across all five defective generators, and names the page, not the generator.tests/unit/scripts/table-cell-escape.test.tsparses every script module with the TS compiler, population derived from the tree, and reds on a prose-named value read DIRECTLY into a template or+-chain row. Method-call, alias andjoin-built detections were written and deleted: each needed an exemption to its exemption, and the first called correct per-element escaping a violation whose only remedy double-escapes. The gaps ship as asserted cases. - The stateful guard's log-group "cheap positive" now coerces a stringly-typed
RetentionInDaysand reads BOTH recorded property bags (issue #2521) --src/provisioning/{stateful-types.ts,providers/logs-loggroup-provider.ts},src/deployment/{deploy-engine,recreate-targets}.ts,src/cli/commands/recreate-confirm-prompt.ts,docs/cli-deploy-safety.md,.claude/rules/layout-provisioning.md, two newloggroup-never-expire-guardarms, and unit suites.isStatefulRecreateTargetSyncgated ontypeof retention === 'number' && retention > 0and was handedpropertiesalone, so a CloudFormation-legal'30'and a retention set OUT OF BAND (console,put-retention-policy, or an import recording it only inobservedProperties) both failed to answerhas-retention. Not a data-loss hole since the never-expire fix (#2558): anullthere DEFERS, so mid-deploy it is refused anyway and at pre-flight the probe decides. What it cost is the CHEAP POSITIVE -- and an EMPTY log group with such a retention ALLOWED through where a numeric one was refused. Behavior change: the pre-flight now refuses both. A plain OR over the two bags, NOT the precedence the issue prescribed:readCurrentStatewrites0for never-expire, so the observed bag almost always carries the key and precedence would drop a verdict the guard already produced. Sibling sweep, one layer down:LogsLogGroupProvidersent'30'to the SDK as a string and compared both update sides uncoerced. Both coerce now, and some values the send path cannot use are REFUSED, when the RAW value changed, rather than reaching the arm that issuesDeleteRetentionPolicy-- while the FALSY family still routes there, exactly as pre-#2521. Every review round holed a prose statement of which is which, so the answer is a table that RUNS:CREATE_MATRIX/UPDATE_MATRIXin the coercion suite. - The
descendArrays: falserationales stopped claiming cdkd never walks a readback array by index, and the replay's ssm parameter-name parser stopped naming the SERVICE as the secret (issues #2286 / #2501) --src/deployment/{secret-region-classification,rollback-executor}.ts,src/cli/commands/{drift,state}.ts,.claude/rules/layout-{drift,deployment-secrets}.md, thessm-secure-dynamic-reffixture and three unit suites. Behavior change:ssmParameterNametookinner.substring(inner.indexOf(':') + 1), so a colon-less{{resolve:ssm-secure}}/{{resolve:ssm}}body returned the SERVICE STRING as the parameter name -- with a foreign producer region on record the replay refused it as an ambiguous-region secret named'ssm-secure'instead of reaching the resolver'sPARAMETER_NAME is required. It now returns''. Not purely cosmetic on a MIXED leaf: the bogus name bought a pre-fetch refusal of the whole leaf, so alocalsibling is now fetched before the degenerate token throws (pinned by a new case). Accepted -- that refusal was an accident of the mis-parse, and the sibling is still fetched from the region its own verdict names. Residual is LOUDNESS:cdkd scrubre-raises only the typed refusal, so it swallows this todebugand reports clean -- #2692, blocked on (#2562) by a scrub-side PREDICATE change. The rest is prose: FIVE comment sites saiddescendArrays: falserefuses positional descent without saying BLIND, but since #2012 that constant selectsrefuseUncertifiedReadbackPositions, whose unkeyed arm pairs by index whenunkeyedArrayPairsByAnchorscorroborates. The fifth, inrollback-executor.ts, is fixed here too (#2691). Held-file siblings: (#2688), (#2693). - A new SDK provider can no longer silently drop
update()'sUpdateContextparameter (issue #2613, remedy 1 of 3) -- newscripts/check-provider-update-context.tsandtests/unit/scripts/provider-update-context.test.ts; nosrc/change and no behavior change.ResourceProvider.update'scontextparameter is optional, so a provider that omits it compiles,deploy-engine.ts/rollback-executor.ts/drift --revertkeep passingexpectedRegion(issue #2301 item 1), andassertRegionMatchnever runs for it. Measured on 6ba3cf16: 85 provider classes across 82 files, 18 declaring and 67 omitting -- not the 17/60 the issue recorded, whosegrep "async update("collapsedglue-provider.ts's seven classes into one and could not see the two non-asyncupdate()s (ServiceDiscoveryProvider, which DOES declare the parameter, andEFSProvider, which does not). The checker parses each class with the TS compiler, derives the population from interface MEMBERSHIP (create+update+deletepresent) rather than the optionalimplementskeyword, and requires the 6th parameter to beUpdateContextimported fromtypes/resource.ts-- refusing an unmodelled shape instead of bucketing it.OMITS_UPDATE_CONTEXTnames the 67, so a new provider without the parameter fails, one that gains it fails with "good news -- delete the entry", and a dead entry fails as stale. The suite cross-checks every verdict againstupdate.lengthon the objectsregisterAllProvidersbuilds. Declaring the parameter is not calling the guard: the backfill (remedy 2) and dropping the?(remedy 3) stay open on #2613. - Every destructive prefix sweep whose filter collapses to the empty string
now refuses a widened scope, in every integ fixture teardown (issue
#2621) --
docs/integ-fixture-conventions.md,docs/testing.md,.claude/rules/testing.md,.claude/skills/new-integ/SKILL.mdand thirteenverify.shteardowns. A sweep that lists under--log-group-name-prefix "${LG_PREFIX}"orstarts_with(RoleName, '${STACK}')and deletes every name it gets back matches EVERYTHING when the variable is empty, andcleanupruns underset +eu. Behavior change (teardown only): twelve sweeps now refuse a scope outside the fixture's own and warn on stderr -- a wrappingcase,exit 0in a subshell-bodied helper, or an earlyreturnwhere the whole function is the sweep. Real scopes sweep as before;loggroup-class-guardalready had a guard and only loses its retired deferral comment. Unreachable today (every scope variable is a literal). The convention is documented but NOT mechanically enforced:Cdkd?*is what these fixtures need rather than a house pattern (36 of the 213 fixtures with a literalSTACK=do not begin with it), so the docs teach the shortest literal prefix your own scope has plus?*, the accepting arm first, and a WARN carryingteardown sweep refusedfor a future checker to key on. That checker is #2690; the namespace-anchored back-fill is #2682. - A
Typechange into or out ofAWS::CloudFormation::Stackis refused at plan time, before anything is deleted (issue #2668, stopgap) -- newsrc/deployment/type-change-guard.ts,src/deployment/deploy-engine.ts,docs/cli-deploy-safety.md, plus a new unit suite. ATypechange on an existing logical id is diffed as an UPDATE carrying the TEMPLATE's (NEW) type, andprovisionResourceroutes BOTH halves of the resulting replacement on that one type -- so the OLD resource's delete is dispatched at the NEW type's provider.NestedStackProvider.deleteignores the physical id it is handed and destroys<parent>~<logicalId>, so a plaincdkd deploy, with no flag, could tear down a child stack and every resource it owns (or, with no child state, silently leak the old resource).AWS::CloudFormation::Stackis not inSTATEFUL_TYPES, so nothing else stopped it. Behavior change: such a deploy now fails withTYPE_CHANGE_NESTED_STACK, naming the logical id, both types, the physical id and the child stack that would have been destroyed; there is no override flag, because the delete's TARGET would be wrong rather than merely its consequences.--dry-runrefuses too, instead of previewing a plan cdkd will not run. The check sits in the engine right after the diff rather than in a CLI pre-flight: nested children get their ownDeployEngineand never pass throughdeploy.ts, and the engine owns the verychangesmap the routing decision is made from. Deliberately NOT widened to everyTypechange -- that would refuse a deploy that is correct today, sinceUpdateReplacePolicy: Retainskips the old delete entirely. The full routing fix (which type each half of a replacement routes on) stays on #2668. --recreate-via-cc-api/--recreate-via-sdk-providertargets are now scoped to the stack the pre-flight validated them against (issue #2567) --src/deployment/deploy-engine.ts,src/cli/commands/deploy.ts,src/deployment/recreate-targets.ts,src/provisioning/providers/nested-stack-provider.ts,docs/cli-deploy-safety.md, plus two new unit suites and therecreate-nested-logical-id-collisioninteg fixture. The two bareReadonlySet<string>options became onerecreateTargets: { stackName, viaCcApi, viaSdkProvider }, and the engine honours an id only while deploying that stack. Behavior change: a resource in a NESTED child that merely SHARES a logical id with a validated parent one is no longer destroyed and recreated.NestedStackProvider.runChildDeployspreads the parent's options into the child engine, so the bare set matched the child's ids -- andrecreateFlaggedis exactly what SKIPS the mid-deploy stateful guard, whose justification is that the pre-flight already validated the target. A nestedAWS::S3::BucketorAWS::Logs::LogGroupcould therefore be DELETE + CREATEd with neither the pre-flight probe nor the guard having examined it. No capability is lost: an id declared only in a child was never in the parent's template, so the pre-flight already refused it -- that refusal now also names the template's nested stacks (nestedStackLogicalIds) instead of reading as a typo. Second behavior change: naming a nested stack's OWNAWS::CloudFormation::Stackrow is refused outright (blockedNestedStackTargets, no--force-stateful-recreationbypass) -- it was accepted before, and honoring it tears down every resource the child owns with no per-resource consent. Scoping was chosen over re-running the pre-flight inside the child, which would have validated the accidental target rather than declining it.- Four places where cdkd reported data removed while AWS silently kept a readable copy now say what survives (issue #2624) --
src/cli/commands/{scrub,events,destroy}.ts, eightdocs/**pages, the shippedplugins/cdkd-skillsskill, plus cases in four unit suites.cdkd scrubsaid "The plaintext is no longer stored there"; its only write is a plainPutObjecton a versioned bucket, so the pre-scrub body survives as a noncurrent version readable with aVersionId, and nothing purges it.cdkd events pruneandcdkd destroy --purge-eventsdelete throughDeleteObjectswith noVersionId, and the store re-PUTs each run's whole JSONL per flush, so noncurrent versions of content the docs class as sensitive exist in quantity. Every one of those claims -- four log lines, three help strings and six docs assertions of an empty prefix -- now says the LISTING is empty, not the bucket, and every MESSAGE cdkd prints hedges on versioning actually being ON (cdkd bootstrapskipsPutBucketVersioningfor a pre-existing bucket unless--force).AWS::EFS::FileSystemgained the destroy caveat its FSx neighbour already carried: cdkd's ownPutBackupPolicyenables automatic backups, and the recovery points outliveDeleteFileSysteminaws/efs/automatic-backup-vaultfor the 35-day default retention, fully restorable. Live-proven against real S3 -- afterevents prune --allthe listing was empty while three noncurrent versions stayed readable byVersionId. Behavior change: wording only. No purge was added: that half of #2624 stays open, with the exports-index sibling #2667. - Eleven user-facing remedy messages stopped naming a flag or a re-run whose precondition the code never checks (issue #2610, partial) -- new
src/provisioning/replacement-protection-advice.tsplussrc/provisioning/providers/{elbv2,emr-cluster,cognito,dynamodb-globaltable,asg,dlm-lifecycle-policy,scheduler-schedule,efs,fsx-filesystem,s3-vectors}-provider.tsandsrc/state/{lock-manager,lock-contention-message}.ts. Three shapes, swept from issue #2579's log-group fix. (1) Five providers advised a replacement while already holding a bag saying the resource was protected -- which no deploy-side flag clears, sincecdkd deployregisters no--remove-protectionand the engine never setsDeleteContext.removeProtection. Each now reads that bag and names the dead end plus a disable command whose id is SANITIZED, shell-quoted vialock-contention-message.ts's now-exportedshellQuote, and SUPPRESSED when sanitizing changes it -- the id is astate.jsonvalue. ASG coversprevent-all-deletiononly:prevent-force-deletionwithholds a FORCE delete, which a replacement never issues. (2)cdkd deploy --replace <LogicalId>was pasted at two sites;--replaceis boolean anddeploytakes[stacks...], so the id parsed as a STACK NAME. (3)lock-manager.tsnamed a--force-unlockflag existing nowhere; it now builds the real subcommand line, sanitizes all three acquire-path sites, and shares the suppression clause. Sibling sweep: nine advices named a bare--replacefor aSTATEFUL_TYPESmember, refused again withSTATEFUL_REPLACE_BLOCKED. Both polarities pinned per site;tests/unit/cli/replacement-remedy-cli-facts.test.tsfences the CLI facts, the doc pointer and the literal-only command contract.docs/cli-deploy-safety.mdnames all six refusing types. Sites 9-11 stay open; log-group id rendering is #2669. - The custom-resource response-object debug lines now render bucket, key, AWS error text and the region-correction values through
displaySafe(issue #2635) --src/provisioning/providers/custom-resource-provider.ts, plus the newtests/unit/provisioning/custom-resource-response-display-safe.test.ts.cleanupResponseObject's failure line interpolateds3://<bucket>/<responseKey>anderror.messageraw. The sibling sweep found the same shape at TWO more sites the issue did not name:generatePresignedResponseUrl's success line, andensureResponseClient's region-correction line, which also renders abucketRegion(fromresolveBucketRegion) and acurrentRegion(from the client's ownconfig.region()). All three are fixed together -- sanitising some of three readers of the same value IS the widening-by-hand failuresrc/utils/display-safe.ts's header exists to stop. Buckets and regions take theasciiOnlypositive allowlist, AWS text and the key the denylist form. Every value that CAN sanitize to nothing carries a|| UNRENDERABLEfloor, each probed by reverting it;responseKeydeliberately has none, becausegetResponseKeyalways appends/cdkd-<digits>-<alnum>.jsonso the branch cannot fire -- a floor added there came back GREEN under its probe and was removed as dead code rather than shipped unfenced. One correction to the issue's own account, verified rather than inherited: the request id is cdkd-minted (prepareInvocation); the non-literal half is the caller-suppliedresponsePrefix, which a case drives. Eight cases assert on the DEBUG STREAM, with two negative controls pinning that ordinary values come through unchanged. All three lines aredebug-- rendering hygiene, not disclosure.
Recently Implemented (2026-09-05):
cdkd eventsnow renders stored provider text throughdisplaySafe, so a control byte in a name or areasoncannot forge a post-mortem line (issue #2438) --src/cli/commands/events.ts,docs/{deployment-events,cli-events}.md, plus the newtests/unit/cli/events-display-safe.test.ts.JSON.stringifyescapes control bytes INTO the JSONL store, but the reader is a plainJSON.parsethat restores them and the renderer printed them verbatim -- so an ESC-bracket sequence in a logical id, a resource type, a providerreason(which interpolates a user-chosen physical name) or an AWS error message could blank a line of the one command whose purpose is to be believed after a run. Live A/B on a real bucket: pre-fix 8 rawESC[2K+ 2ESC[1;31m+ a CR reached the terminal, the CR overwriting the reason line with a forgedRUN_FINISHED SUCCEEDED; post-fix zero. 44 call sites now wrap every stored value on the HUMAN path -- 23printRunEvents, 9printRunList, 1colorizeEventType, 6 in theEVENTS_*errors, 4 inevents pruneincl. its confirmation PROMPT, 1 in the--keeprefusal.colorizeEventTypeand theresultcolumn classify the SANITISED token so the colour matches the text, and an unrenderable result is coloured UNKNOWN rather than failed.<unrenderable>is a claim about the INPUT, so the one field legitimately empty with nothing forged is exempt: a barenew Error()rendersError, notError: <unrenderable>.--jsonis deliberately untouched: machine-consumed, andJSON.stringifyescapes the whole C0 range, so sanitising would only corrupt what tooling reads back. 36 unit cases across four review rounds, every added branch mutation-probed; a general presence-tracking helper was tried and REVERTED, each round having found it re-making the same false claim on a new input class. Siblings: #2645.- A replacement rollback now honours
UpdateReplacePolicy: Retainon the resource the replacement CREATED (issue #2598) --src/deployment/rollback-executor.ts,src/cli/commands/rollback.ts,docs/cli-rollback.md, plus a new unit suite. All three arms that dispose of the new copy (readoptDelete,deleteNewFirst,deleteNewAfterRecreate) read onlyrollbackFinalSnapshotId, so a resource the template marked to survive was orphaned when a plain CREATE made it and DESTROYED when a replacement did -- decided by op shape rather than by the declared policy. Which attribute governs it is measured, not reasoned. The AWS documentation answers nothing here: every sentence on both attribute pages, in the API reference and in the release notes describes the OLD resource. A live four-variant A/B settled it --DeletionPolicy: Retainalone leaves the new copy DELETED,UpdateReplacePolicy: Retainalone leaves it SURVIVING (DELETE_SKIPPED). The table is onrollbackRetainsNewResource's doc comment. Behavior change: a retained new copy is left running, dropped from state (CloudFormation orphans it out too), announced with awarn, recorded durably in the deployment events with its physical id and the survivor's own layer, and counted, so the run exits 2. Onreverse-replacement-readoptthat is the ALWAYS-case: one template read sets both the verdict and the new record's attribute, so that arm no longer deletes the new copy for any journal cdkd wrote.deleteNewFirstis the one arm that cannot honour it -- that delete exists to free the name the re-create collided on -- and REFUSES withNAMED_REPLACEMENT_COLLISIONinstead of destroying the pinned resource, keeping the journal for a re-run.RollbackPlanItem.retainsNewResourcekeeps the plan preview from promising a delete the replay will skip (the #1366 class). - The update-failure replacement fallback names the resource it already deleted, its observed capture follows the layer state was stamped with, and the rollback reads the retain verdict the deploy ACTED on (issues #2616 / #2608 / #2603) --
src/deployment/{deploy-engine,rollback-executor}.ts, plus two new unit suites and cases in two existing ones. #2616: the!retainOldOnReplacearm rethrew the provider's raw create error, leaving the user unable to tell "the replacement never started" from "it destroyed the old resource and then failed". It now carries the--replacedelete-first fallback's sentence, masked at construction, and CHAINS the create rejection so theRESOURCE_FAILEDevent still names the AWS code, which an unchained wrap would drop; two sibling wraps gained the samecause. #2608: the capture ran against the provider that FAILED the update while the record stamped the replacement's FRESH routing decision, so a re-routed replacement read the new resource through the old layer. AcaptureProviderbinding now moves in lockstep with the stamp. Its sweep found the same defect at the schema-upgrade baseline refresh, which routed by type alone. #2603:classifyRollbackOpre-derived retention frompreviousState.updateReplacePolicy-- a different source than the TEMPLATE read every engine path decides from -- so ADDINGUpdateReplacePolicy: Retainmade a rollback re-create a live resource, and DROPPING it left state naming a deleted one with no re-create.CompletedOperation.oldResourceRetained(additive, nojournalVersionbump) now records what the deploy did; the old read survives only for a journal written by an older binary. Residual: #2631. - The stateful guard stops asserting an emptiness it never measured, and two of its documented claims gained fences (issues #2615 / #2554) --
src/provisioning/stateful-types.ts, plus two new unit suites.renderStatefulReason('has-objects')said "S3 bucket is non-empty" on all three of its cases, and was true of only one: the probe finding a version, a probe page carrying a continuation marker and no entries, and a mid-deploy site with no probe at all. On the last of those -- theisStatefulRecreateTargetForReplacebucket arm reached from the deploy engine's two replacement guards -- it throws with no warning beside it, so the assertive sentence was the only thing the user saw. It is now hedged the way its log-group sibling already was: "S3 bucket is not provably empty". Separately, two claims the docs make about the guard had nothing holding them: the property-driven guard's--recreate-via-*exemption (deleting the term left the whole deployment suite green, because every existing recreate test picks a non-stateful type) and the rollback replay's stateful advisory warn, which PR #2519 had reworded with no test on either its wording or its gate. Both now have both polarities pinned, the second also pinning that the advisory line stays OUT ofresult.warnings-- that count maps to exit code 2, and the op succeeded. - A failing
dockercall no longer echoes the unredacted argv, and the--verboseargv log stops disagreeing with it (issue #2440) --src/utils/docker-cmd.ts,src/local/{docker-runner,ecs-task-runner,ecs-network,invoke-agentcore-watch-loop}.ts.execFilefolds the WHOLE command line intoerr.message, so theerr.stderr?.trim() || err.messagefallback -- taken when docker writes no stderr -- echoed every-e KEY=valuepair, while the debug line eleven lines up redacted it. The issue's suggested remedy (wrappingredactAwsCredentialsInArgs) would have been INERT: it masks only the three AWS credential keys, whichpartitionSensitiveEnvalready keeps off the argv. What leaks is user config:Environment.Variables,--env-vars,DriverOpts.redactDockerArgvInTextmasks the VALUE of-e/--env/--opt/--labeland keeps the KEY, in four structural (positional, never value-based) passes: exact substitution ofargs.join(' ')(the only one that delimits a WHITESPACE-bearing value), a per-token one, an index-directed repair of Node's spawn-refusal message (it TRUNCATES long values and CHUNKS multiline ones, so no text search finds the token), and a flag-position scan. The per-token needle usesutil.inspect-- the function Node itself quotes with -- after a hand-rolled\xNNtable proved wrong for 16 of the first 128 code points. An EMPTY key (-e =value) is masked too. The--verboseargv line uses the same redactor now, andredactAwsCredentialsInArgsis deleted as dominated. Delta: a--verbosedocker run line prints-e KEY=***per env pair. All 20 failure texts in the four modules are COMPOSED bydescribeDockerFailureand two siblings, whose REQUIREDargsmakes redaction type-checked, not fenced -- four review rounds each broke a text fence, the last becauseerr.cmdleaks the argv with no field read. Design:.claude/rules/docker-argv-redaction.md. - A bucket whose emptiness probe FAILED is now a third state on the recreate plan, not silence (issue #2595) --
src/deployment/recreate-targets.ts,src/cli/commands/recreate-confirm-prompt.ts. The S3 probe fails OPEN by design (issue #648): whenListObjectVersionsthrows, the target keepsstatefulReason: nulland the recreate proceeds. That posture is unchanged -- a role withouts3:ListBucketVersionsmust still be able to recreate an empty bucket -- but the SCREEN was wrong: with the verdict left atnull, a bucket nothing could be learned about rendered exactly like one the probe measured and found empty, on the one page a user reads before consenting to a DELETE + CREATE. The target now carries a siblingprobeUnresolvedflag (a field, deliberately NOT aStatefulReasonvalue, which would have made it REFUSE and flipped the documented posture), and the plan prints a row saying the emptiness was not established plus anUNKNOWN:line saying what that costs. No**DATA LOSS**prefix, because cdkd observed no contents and will not assert any. A bucket AWS reports as NOT EXISTING is excluded by a typedNoSuchBucket/NotFoundcheck -- that is an ANSWER, so it passes through silently, matching what the log-group arm already does with its own typed not-found. A log group cannot reach this state at all: its probe promotes on both failure paths, the fail-closed half of the deliberate asymmetry. - The update-failure replacement fallback now honours
UpdateReplacePolicy: Retain, and classifies its trigger structurally (issues #2518 / #2520) —src/deployment/{deploy-engine,retryable-errors,rollback-executor}.ts,docs/cli-deploy-safety.md. #2518: the fallback deleted the old resource whatever the policy said, while every other replacement path honouredRetain— androllback-executor.tsalready ASSUMED retention, re-adopting a deleted id. UnderRetainthe fallback is now create-ONLY on both triggers: the old resource is orphaned, the stateful guard exempts it (nothing is lost to confirm), and the two shapes retaining makes impossible take the property-driven path's ownNAMED_REPLACEMENT_*codes. Behavior change: the CC trigger needs no flag, so the leak is announced — a⚠warn naming the survivor's id — and the row reportspartial, so a top-level run counts an orphaned predecessor and exits 2 (--allow-unaddressedopts out; a nested child's counts do not reach the parent — #1989). PR #2519's Retain note is gone. #2520: the trigger substring-matched AWS prose, and itsincludes('UnsupportedActionException')half was UNREACHABLE (handleErrorinterpolateserr.messageonly).isUpdateUnsupportedErrornow reads the exception NAME structurally, anchored onlogicalIdso a nested stack's child rejection cannot replace the child stack. Its doc comment carries the mechanism, the refused codes and the reachability audit. 36 new unit cases plus 3 rewritten. Residuals: #2598, #2603, #2604, #2608. - Five
cdkd localdefects where a flag or a message disagreed with the code (issues #2523 / #2528 / #2522 / #2524 / #2536 / #2527) --src/cli/commands/local-*.ts,src/cli/region-options.ts,src/local/{container-pool,ecs-task-runner,local-state-provider}.ts.--no-assume-rolewas named in every--assume-rolehelp string and rejected by the parser (commander synthesizes no negation for an optional-value option), so theassumeRole === falsearms were dead; it is now registered on the four commands that advertise it --invoke,invoke-agentcore,start-agentcore,start-cloudfront-- and NOT onstart-api/start-service/start-alb, which never advertised it and whose downstream code was written for a different shape.local start-cloudfrontnow REFUSES--from-state/--state-bucket/--state-prefix(exit 1, pointing at--from-cfn-stack) instead of accepting them and ignoring them: nothing in the CloudFront path consults a host-registered state source, upstream go-to-k/cdk-local#699. The fourlocal start-*shims adopt the shareddeprecatedRegionOptionand fold--region/--stack-region/AWS_REGION/AWS_DEFAULT_REGIONin apreActionhook -- user-visible delta:--regiondisappears from those four--helpoutputs and prints the removal warning; it keeps working. Two stale strings corrected:--stageno longer claims HTTP API v2requestContext.stageis always$default, and the ECR-pull fallback no longer claims same-region-only (11 sites swept, one legitimate use kept).src/local/cfn-local-state-provider.tsdeleted -- an unreferenced fork of a class cdk-local owns. - Both stateful emptiness probes retry a throttle, and the S3 probe no longer reads a truncated page as an empty bucket (issues #2566 / #2578) --
src/deployment/recreate-targets.ts. The--recreate-via-*pre-flight probes each target once and, until now, treated any failure as a failure to answer: the bucket arm fell through to its open failure arm and the log-group arm refused, so a plain rate limit either widened the S3 hole or refused a deploy the user asked for. Both now retry a THROTTLE and nothing else (three retries, 500 ms initial backoff, at most 3.5 s per target,isThrottlingErroras a deliberately narrow classifier -- a throttling error code or HTTP 429/503, not the shared table's message backstop -- every other error here is either an answer or something a second identical call will not change). Separately, an S3 page carryingIsTruncated/NextKeyMarker/NextVersionIdMarkerwith no entry in either array no longer clears the guard: the listing is unfinished, so that page's emptiness is not the bucket's. The absent-array half of #2578 was closed as not-a-defect, and the reason is recorded beside the check: S3 OMITS an empty collection rather than sending an empty array (measured against real S3,us-east-1, 2026-09-05: a bucket holding versions and no delete markers answered withDeleteMarkersabsent entirely, and a zero-result listing answered with BOTH arrays absent,IsTruncated: falseand neither continuation marker), so requiring the present-and-empty pair the log-group arm requires would refuse EVERY empty bucket and turn a conditional arm into an unconditional one. The two APIs encode "none" differently, so the log-group fix is not transposable field for field. Behavior change: a bucket whose probe page is truncated now demands--force-stateful-recreationat pre-flight. - A log group with no retention policy is no longer read as ephemeral: an unset or zero
RetentionInDaysis CloudWatch Logs' never-expire, and the stateful guard now treats it as data-bearing (issue #2558) --src/provisioning/{stateful-types.ts,providers/logs-loggroup-provider.ts},src/deployment/{recreate-targets,deploy-engine}.ts,src/cli/{options.ts,commands/deploy.ts,commands/recreate-confirm-prompt.ts}, the newtests/integration/loggroup-never-expire-guard/**, plus docs and unit suites. The predicate wasRetentionInDays > 0on the recorded bag, so the valueLogsLogGroupProviderrecords for "never expire" (0) read as "not stateful": a renamed log group took the property-driven replacement path on a plaincdkd deploy, destroying every event with no flag, prompt or warning. The type now follows theAWS::S3::Bucketshape it sits beside -- a recordedRetentionInDays > 0still answershas-retention, anything else DEFERS: the--recreate-via-*pre-flight resolves it with a livelogs:DescribeLogStreams(limit=1), and the mid-deploy sites, having no probe, resolve it to the newhas-log-eventsreason and refuse without--force-stateful-recreation. Only ONE probe answer clears the guard -- a present, emptylogStreamswith no continuation token; the two non-answers warn-and-refuse like a failed probe. TheLogGroupClassremedy names both flags UNCONDITIONALLY, retiring the advice half of issue #2521. The confirm prompt sheds the same reading: the force flag skips the probe, so anullreason is re-derived conservatively and every bucket and log group shows DATA LOSS. Why notstoredBytes, and why this probe alone fails CLOSED: seeprobeStatefulRecreateTargetsAsync. Behavior change: replacing a log group with no recorded retention now demands--force-stateful-recreationmid-deploy, and at pre-flight unless it is provably empty. - The
LogGroupClassrefusal no longer advises a remedy that cannot succeed on a deletion-protected log group (issue #2579) --src/provisioning/providers/logs-loggroup-provider.ts,tests/unit/provisioning/logs-loggroup-provider-class-guard.test.ts,docs/cli-deploy-safety.md,docs/cli-destroy.md. The refusal advised--replace --force-stateful-recreationUNCONDITIONALLY. WithDeletionProtectionEnabledset that dead-ends: the replacement's DELETE never setsDeleteContext.removeProtection(nocdkd deploy --remove-protectionexists), so AWS refuses theDeleteLogGroupand the user, following cdkd's own advice, hits a SECOND wall. It now branches on the RECORDED bag (previousProperties; the DESIRED bag is wrong in both directions, both pinned) and names the out-of-bandaws logs put-log-group-deletion-protection. It stops there and hands off todocs/cli-deploy-safety.mdfor what happens to the flag afterwards -- three review rounds each proved a different sentence about that FALSE, sinceupdate()sees neitherUpdateReplacePolicynor what the replacement does (underRetaina same-name log group cannot be replaced at all); all three are recorded at the guard and pinned as negative assertions. It claims cdkd's RECORD, not AWS state (two of the fourupdate()callers pass a different bag); unprotected wording and the accepted residuals are inapplyUpdate's comment. 6 unit cases plus a source/doc fence over the emitted CLI remedy and the section it cites, every claim mutation-probed. Residuals filed: #2610, #2613, #2616. Behavior change: message only. - The
LogGroupClassrefusal's deletion-protected arm, and the remedy it names, now run against real AWS (issue #2579) --tests/integration/loggroup-class-guard/**,.claude/skills/cleanup/SKILL.md,docs/cli-flag-coverage.md+docs/_generated/{cli-flag-coverage.json,integ-last-run.tsv}. The fixture only ever reached the UNPROTECTED arm, and no fixture anywhere combined a protected log group with aLogGroupClasschange, so the branch shipped on unit tests alone. Three phases were added: enable protection through a real deploy (the guard reads the RECORDED bag, so it cannot arrive by a hand edit ofstate.json); change the class again and assert the three things only that arm says; then EXTRACT the disable command from the refusal's own output and run it before re-deploying with--replace --force-stateful-recreation-- what the message PRINTS is what the fixture proves WORKS. Phase 2 forbids the same three needles, so a predicate stuck ON reddens too. AWS refusesDeleteLogGroupoutright while protection is on, so the log group is explicitly named under a fixture-owned prefix and the teardown clears the flag out of band -- from theEXIT/INT/TERMtrap, tri-state on not-found, ahead ofstate destroyso it never depends on the flip-off it is testing -- and needs no state record./cleanuplearned that prefix and the flip-first order too. Behavior change: none (test-only). - A purge cdkd reports as complete now says so when S3 replication kept the bodies one bucket over (issue #2447) -- the new
src/state/s3-replication-purge-gap.ts,src/state/s3-noncurrent-version-purge.ts,docs/{state-management,cli-gc,import,cli-force-unlock}.md,.claude/rules/{state-version-purge,layout-misc}.md, and 13 unit suites. The purge makes "cdkd deleted the object" mean the body is unreadable, by deleting specificVersionIds. S3 never replicates a version-id delete (PUTs replicate; delete MARKERS replicate when opted in), so on a bucket with CRR or SRR the purge is SOURCE-ONLY: the replica keeps every version andGetObjectwith aVersionIdstill returns the secret. Nothing in the code or docs mentioned replication, so the user was not merely unprotected but REASSURED. cdkd cannot fix it -- that would mean cross-BUCKET deletes -- so it DETECTS: a purge that removed a BODY, or could not settle whether there was one, ends with oneGetBucketReplicationand warns through the CALLER's sink, naming the destination, the mechanism and the remedy. A bare delete MARKER is neither, which matters becausecdkd deploywrites one for the rollback journal on every success.ReplicationConfigurationNotFoundErroris S3's ORDINARY answer for an unreplicated bucket (verified live, 404) and is silent; so isAccessDenied, ands3:GetReplicationConfigurationjoins the recommended policy as its one DIAGNOSTIC entry. Coverage errs toward warning: a tag-filtered rule covers everything, aDisabledrule is reported AND flagged. The load-bearing scoping and caching decisions -- and the review failure each closes -- are in.claude/rules/state-version-purge.md. 65 new unit cases; live-proven both ways. Behavior change: one extra API call per bucket per run when a purge removed a body, plus the new warning. - The scenario-coverage generator escapes pipes, so six rows of the coverage matrix stop rendering ragged (issue #2545) --
scripts/build-scenario-coverage-matrix.ts,docs/scenario-coverage.md,tests/unit/scripts/{build-scenario-coverage-matrix,docs-table-shape}.test.ts. SixKNOWN_SCENARIOSdescriptions carry a|, and in GitHub-flavoured Markdown a pipe in a table row is a cell delimiter even inside an inline code span -- the row splits before inline parsing, so backticks protect nothing. Those rows rendered with up to six columns against a three-column header, putting every fixture link under a heading that did not name it. A newescapeCellcovers every interpolated cell at BOTH emit sites: the per-scenario table, and the orphan table, which read its description from the module global rather than from the report and so was a separate site easy to miss. It escapes a BACKSLASH too (escaping only the pipe leaves\\|, a live delimiter) and collapses any line ending. The cases assert the emitted row's cell count against its header's, with a guard-the-guard case and one over the whole taxonomy, so a seventh piped description needs no test edit.docs-table-shape.test.tsloses its only exclusion (its own companion case, asserting that exclusion still excused something, is what forced the removal) and stops asking a hand-counted floor to catch awalkthat stopped recursing, a count it had already drifted past: every immediate subdirectory ofdocs/must now be reached, derived from the tree, with the counts left as a backstop. The cell-count rule is spelled in both suites so one blinded counter cannot blind both, and a case holds the two in step -- they drifted once here, and the comment asking otherwise was the mitigation that had just failed.docs/_generated/scenario-coverage.jsonis unaffected: JSON has no cell delimiter. Behavior change: none (docs + tooling only). cdkd deploy --verboseno longer prints a Docker image asset's--build-argVALUES, and the argv fence can now seesrc/assets/**(issue #2623) --src/utils/docker-cmd.ts,src/assets/{docker-build,docker-asset-publisher}.ts,src/local/{ecr-puller,ecs-task-runner}.ts,tests/unit/local/docker-argv-redaction-fence.test.ts, plus a new unit suite. Issue #2440 closed this class on thecdkd localdocker paths; its fence derived its module list fromreaddirSync('src/local'), so the deploy-time asset build was structurally invisible to it. Nine unredacted sites insrc/assets/**are now zero: the--verbosebuild line, theexecutable-mode command line and its "produced no output" throw, three hand-composed docker-failure texts in the publisher, and -- widest -- aJSON.stringify(source)that dumped everydockerBuildArgsvalue AND everydockerBuildSecretsentry into a THROWN error, now reduced to the field KEYS.--build-argjoins the sharedARGV_VALUE_BEARING_FLAGS(the same argv builder serves the ECR publish andcdkd local run-task), so the KEY survives and the value prints***-- in all four argv spellings pflag accepts, since theexecutablemode renders a USER-authored command line.--secret/--build-contextstay unmasked (locators), while--cache-from/--cache-toare not: BuildKit's cache backends take credentials inline, so their params are filtered through an ALLOWLIST of locator names.ARGV_VALUE_TOKEN_REis now BUILT from the Set, so the argv pass and the text pass cannot diverge. The fence sweepssrc/local+src/assets, selects on "spawns docker through a shared helper", and refuses any docker-spawning module outside those roots. Behavior change:--verboseprints--build-arg KEY=***. Design:.claude/rules/docker-argv-redaction.md.cdkd local start-alb --from-statenow says so when a Lambda target group's environment is out of its reach, plus three stale.claude/rules/statements (issues #2602 / #2599) --src/cli/commands/local-start-alb.ts,docs/local-start-alb.md,.claude/rules/layout-{cli,local}.md, plus a new rules-citation fence. The flag is PARTIAL here: an ECS service target resolves against S3 state, while cdk-local'sresolveAlbLambdaTargetEnvrebuilds the options bag as a six-key allow-list droppingfromState/stateBucket/statePrefix-- so the dispatcher'soptions[key]gate never selects cdkd's factory and the Lambda boots with its intrinsics dropped. Not REFUSED the way #2528 refusesstart-cloudfront: that command has no working consumer, this one has half.warnUnresolvedLambdaTargetEnvdecorates the ALBEmulatorStrategy, reads the front-door plan it already resolved (no second synth) and appends a warning naming each affected function; upstream fix go-to-k/cdk-local#707. For #2599, #2607 had already fixed three of five statements; the rest arelayout-cli.md'sregion-options.tsparagraph (it omittedadoptDeprecatedRegionFlag) pluslayout-local.md's false same-region-only ECR-pull parenthetical and its "ONLY cdkd-owned stop" claim. Nothing checked those, sotests/unit/scripts/rule-file-module-citations.test.tsfences the checkable half: every.tsa rules file cites -- bare, path-form or markdown link -- must exist. It immediately found a fourth:analyzer.mdsent future sessions tosrc/analyzer/intrinsic-resolver.ts, which has never existed. Behavior change: one boot warning, only onstart-alb --from-stateagainst an ALB with a Lambda target group.
Recently Implemented (2026-09-04):
- A transient
DescribeTypefailure no longer becomes a permanent pre-flight refusal (issue #2571) --scripts/audit-provider-coverage.ts,tests/unit/scripts/audit-provider-coverage.test.ts.partitionCoverage's defaultonErrorclassified ANY failed lookup as Tier 3, and Tier 3 is not a neutral bucket:gen:unsupported-typescodegens it intosrc/provisioning/unsupported-types.generated.ts, where the pre-flight REFUSES the type. Measured on the 2026-09-04 refresh: onesocket hang upduring a 1737-type walk classifiedAWS::OpenSearchService::Domainas NON_PROVISIONABLE, and shipping that report would have made cdkd refuse to deploy an OpenSearch domain. The retry could not see it -- it keyed onThrottlingException, while a socket hang-up arrives as a plainErrorwhose NAME isError. Two fixes, since either alone leaves the class open:isRetryableDescribeTypeErrorretries transient TRANSPORT failures by name, bycode, and by the errno spellings Node puts in the message whencodeis absent; and an unresolved type is recorded in a newundeterminedlist instead of guessed into a tier, with--regeneraterefusing to write a report that has any and--checkrefusing a committed one. No escape flag:--checkruns on every PR, so a report written past the refusal would leavemainpermanently red.runCheckalso compares the committedprovider-coverage.mdagainst whatrenderMarkdownproduces from the committed JSON, because this PR corrected a false sentence in that generator and shipped the artifact still carrying it -- editing a generator does not touch its output. Mechanism and the withdrawncause-walk draft are in the module's own doc comments. - The tier-2 stateful sweep's first refresh added six types to the guard list (issue #2571) --
docs/_generated/provider-coverage.{json,md},docs/_generated/stateful-candidates.{json,md},src/provisioning/unsupported-types.generated.ts,src/provisioning/stateful-types.ts,docs/cli-deploy-safety.md. The coverage cache was five weeks stale (generatedAt: 2026-07-31); regenerating it moved tier 2 from 1372 entries to 1469 and tier 3 from 199 to 268, and 20 types LEFT tier 3 --AWS::FSx::Volume,AWS::CodeBuild::ReportGroup, theAWS::SES::Receipt*family and others AWS has since made provisionable, which cdkd was refusing at pre-flight on a stale cache. The issue #2553 derivation re-ran over the refreshed list and proposed 99 candidates against the previous 93, of which SIX were dispositioned by neitherSTATEFUL_TYPESnorNOT_GUARDED, so the fence refused the build. That is the mechanism working on its first refresh after shipping. All six are guarded:AWS::RDS::DBSnapshot,AWS::RDS::ClusterSnapshotandAWS::DynamoDB::Backupon the argument the already-guardedAWS::NeptuneGraph::GraphSnapshotrests on -- a snapshot IS the data, so replacing one destroys the copy the user kept;AWS::FSx::VolumeasAWS::FSx::FileSystemone level down;AWS::CloudHSM::ClusterasAWS::KMS::Key's class, where the loss reaches ciphertext in other stacks; andAWS::IoTSiteWise::Workspaceon the FAIL-SAFE side of an open question, since AWS makes encryption at rest REQUIRED on it while whether a delete cascades to the datasets inside is unmeasured. No candidate was removed and noNOT_GUARDEDentry went stale; the two types that moved tier 2 to tier 3 (AWS::AppStream::StackFleetAssociation,AWS::SageMaker::ExperimentTrialComponent) are not guarded. - The provider-coverage audit's tier lists are sets again —
AWS::Logs::LogStreamwas landing in tier 2 twice (issue #2571) --scripts/audit-provider-coverage.ts,tests/unit/scripts/audit-provider-coverage.test.ts.partitionCoveragewalkedallTypesstraight into the tier arrays, andallTypescomes frompaginateListTypes, which yields whateverListTypesreturns -- AWS returned one type twice across pages. The committed cache therefore held 1372 tier-2 entries against 1371 distinct, withsummary.tier2Countpublishing the inflated figure on the coverage page. The duplicate also cost a wastedDescribeTypeand reached every downstream consumer twice, which is whyscripts/audit-stateful-candidates.ts(issue #2553) dedupes at its own boundary; that guard is now redundant rather than load-bearing. Deduped at the producer boundary, so the walk describes each type once, and fenced by a case that injects a repeat into all THREE tiers and in both page positions -- asserting the arrays, the published counts, AND that the repeat costs no secondDescribeType, since an output-time dedupe would fix the counts while still paying for the call. The committeddocs/_generated/provider-coverage.jsonis regenerated in the same change, so it is a set again rather than carrying the duplicate until someone next runs the audit. cdkd force-unlocknow exits 1 when it could not delete a lock, instead of reporting the failure on stderr and exiting 0 (issue #2575) --src/cli/commands/force-unlock.ts,docs/cli-force-unlock.md(new page, issue #2573),tests/unit/cli/force-unlock-exit-code.test.ts(new). The per-regioncatchloggedFailed to unlock stack ...at ERROR level and continued with nothing recording that a failure had happened, so the process still exited 0: a scriptedcdkd force-unlock && cdkd deployproceeded against a stack whose lock was still there and then failed on lock acquisition, reporting the second, less informative error while the first had scrolled past; a CI cleanup step recorded an unlock that never occurred. Failures are now collected across the whole walk and raised once at the end asFORCE_UNLOCK_FAILED, naming every lock that survived. Collected rather than rethrown in place: one unreachable stack must not cost the caller the others, which is the reason the per-region catch continues at all. The two arms are NOT symmetric and both are pinned -- a lock that was already absent (No lock found/NoSuchKey) stays a SUCCESS, since the command's contract is that no lock remains and an absent one already satisfies it. Probed both directions against the real file: removing the failure tracking reddens three cases, and counting the absent-lock arm as a failure reddens the success case. The one integ call site (tests/integration/stack-lock-renewal/verify.sh) runs it inside aset +eucleanup, so the new non-zero exit does not abort that teardown.cdkd migrateis REMOVED -- it was the only command that required the upstream AWS CDK CLI binary (issue #2572) -- deletessrc/cli/commands/migrate-command.ts,src/cli/commands/migrate/**,tests/unit/cli/commands/migrate/**and themigrate-from-bare-cfn/migrate-from-bare-cfn-codegen-onlyinteg fixtures; editssrc/cli/program.ts,src/cli/commands/{import,retire-cfn-stack,confirm-prompt,state,destroy-runner}.ts,src/cli/cfn-stack-states.ts,src/utils/error-handler.ts,src/provisioning/providers/dynamodb-globaltable-provider.ts,scripts/build-scenario-coverage-matrix.ts,docs/cli-destroy.md,docs/design/465-cfn-migrate.md,.claude/rules/,.claude/skills/pick-integ/SKILL.mdand the regenerated coverage matrices. Every other command runs the user's own app command as a subprocess and never touches thecdkbinary; this one shelled out to it twice (cdk migrate --from-stack,cdk synth --quiet) and probed it once (cdk --version), so a user without the AWS CDK CLI got a spawn failure.--cdk-binrelocated the binary without removing the requirement. Replacement:cdkd import --migrate-from-cloudformationis unchanged and still adopts a CloudFormation stack when a CDK app already synthesizes it; for a plain non-CDK stack run upstreamcdk migrate --from-stackyourself first.cdkd state migrateand thelocal-*commands' own--from-cfn-stackare unrelated and untouched. Dead exports go with it --runImport/RunImportOptions,MissingCdkCliError,LocalMigrateError-- dropping the guarded-prompt population from five to four and makingretire-cfn-stack.ts's non-interactive refusal name ONE command instead of two. TheexplicitBinfloors intests/unit/scripts/integ-cdk-cli-pins.test.tshad that fixture as their only source and become an asserted zero that reds if a fixture reintroduces the shape.cdkd destroy/cdkd state destroynow remove a legacy state record that names no region, instead of deleting the AWS resources and leaving the record behind (issue #2550) --src/state/s3-state-backend.ts,tests/unit/state/s3-state-backend.test.ts.tryGetLegacy's read gate isif (state.region && state.region !== region) return null, so a body naming NO region is accepted from ANY region: the destroy read the record, deleted the resources and finished. The sweep at the end askedlegacyMatchesRegion, i.e.readLegacyRegion(...) === region, which answeredundefined === 'us-east-1'-- false -- so{prefix}/{stack}/state.jsonsurvived a successful-looking destroy. The stack kept appearing incdkd state list, and the nextcdkd deployof that name read a record asserting resources that no longer existed and planned UPDATEs against their physical ids. The one-line fix -- acceptundefined-- would have been wrong, which is why the repair is a type:readLegacyRegioncollapsed THREE answers intoundefined(the key is absent, the body names no region, the body could not be read), andstateExistsreads the same helper, so acceptingundefinedthere would have reported state for a stack that has none and fedreconcileRegionWithLegacyDefault's region choice.probeLegacyStatenow returnsabsent/no-region/region/unreadable, and each consumer answers for itself: the delete sweep acceptsno-region(mirroring the read gate it must agree with) and refusesunreadable-- a denied or throttled read says nothing about who owns a record and must never authorise a delete -- whilestateExistssays yes tono-regionfrom any region, matching whatgetStatewould return, and no to the other two.listStackskeeps its behaviour through a thinreadLegacyRegionwrapper. Proved by a live A/B on real S3 with a hand-seededversion: 1record carrying noregionand zero resources: the pre-fix binary prints✓ State deletedand leaves the object, the fixed one deletes it, andcdkd state listno longer shows the stack. Both polarities of the wrong fix are pinned -- revertingno-regionto the old answer reds 2 cases, and accepting everyundefinedreds 4.- An empty
--stack-regionis rejected at parse time instead of being read as absent (issue #2556) --src/cli/options.ts(newparseStackRegion+guardStackRegionOptions),src/cli/program.ts, the eleven--stack-regiondeclarations acrosssrc/cli/commands/{state,drift,rollback,force-unlock,events,orphan,export,local-invoke,local-invoke-agentcore,local-run-task,local-start-api}.ts, andtests/unit/cli/options.test.ts.--stack-region ''is falsy, so every consumer that tested the option for truthiness read it as ABSENT -- and absent means "every region" on the commands that take--all, so a flag passed to NARROW an operation silently WIDENED it.cdkd drift --all --revert --stack-region ''pushed cdkd state values into AWS across every stack in every region;cdkd state destroydestroyed in a region the user had not named;cdkd force-unlockreleased every region's lock;refresh-observed --allrewrote every drift baseline.--stack-region "$UNSET_VAR"in a script is the realistic way to reach it. Fixed at the boundary rather than per consumer: there are more consumers than declarations, the value is threaded through helpers that cannot see how it was spelled, and no region is legitimately empty -- so a commanderargParserrefuses it before any command sees it, and a future--stack-regionconsumer inherits the guard instead of having to know about it. Whitespace-only is refused for the same reason it is worse than empty:' 'is truthy, so it takes the filter branch and matches no ref, which reads as "no state in that region" rather than as the typo it is. A padded but non-empty value is passed through unchanged rather than trimmed -- reinterpreting a caller's value is how the class started. Four of the fifteen declarations are not cdkd's:local start-{service,alb,cloudfront,agentcore}inherit the option from cdk-local, so no grep of this repo finds them and there is nothing here to hang a parser on -- and they were the worst place to miss it, because cdk-local resolves the CloudFormation client's region withoptions.stackRegion ?? options.region ?? AWS_REGION, where''is not nullish and therefore WINS over both fallbacks.buildProgramsweeps the built tree so those, and any option arriving the same way later, are covered without each command knowing. The fence is that walk rather than a table of commands: the first version drove fivestatesubcommands, which share one option factory, so it proved one declaration five times while ten others had none -- the walk names the offenders (removing the sweep reds it with exactly those four commands listed). The helper throws commander'sInvalidArgumentError, so the user gets a one-line... is invalidand exit 1 rather than aFatal error:stack trace. - The stateful-recreation guard is now swept over the Cloud-Control-routed population, and 63 tier-2 types joined it (issue #2553) --
scripts/audit-stateful-candidates.ts,docs/_generated/stateful-candidates.{json,md},src/provisioning/stateful-types.ts,docs/cli-deploy-safety.md,tests/unit/scripts/stateful-candidates.test.ts,vite.config.ts,.github/workflows/ci.yml.STATEFUL_TYPEShad two mechanical lower bounds, both derived fromsrc/provisioning/providers/**-- so both could only see the 134 types with an SDK provider, never the 1371 tier-2 types that have none and whose replacement routes through Cloud Control's DELETE. A rename of a MemoryDB or MSK cluster, a Timestream table or a Backup vault therefore destroyed the data on a plaincdkd deploy, with no flag and no prompt. The new audit reads every tier-2 type's registry schema through the samecloudformation:DescribeTypecreate-only-properties.tsuses at diff time, and proposes a type when it declares a createOnly property AND fires a data-bearing signal -- five over top-level property names (snapshot/backup, retention, provisioned storage, deletion protection, encryption at rest) plus one over the type name's last segment, which exists becauseAWS::Backup::BackupVaultdescribes its recovery points nowhere in its schema. 93 proposals: 63 joined the guard list, 27 are written off in the script'sNOT_GUARDEDmap with the reason, 3 were already guarded. The fence fails on a proposal in neither place and on aNOT_GUARDEDentry the derivation stopped proposing, so the next widening is checkable rather than another hand pass. Regeneration needs AWS credentials and issues a DescribeType per tier-2 type, so it stays manual likeaudit:coverage:regenerate;vp run audit:stateful-candidates:checkis the offline half CI runs. Signals are heuristic, so a proposal whose answer is not knowable from outside is guarded rather than written off. cdkd state orphannow deletes the state file of a record that names no region, instead of deleting only its lock and reporting success (issue #2537) --src/cli/commands/state.ts,src/state/s3-state-backend.ts,tests/unit/cli/state-orphan.test.ts.listStacksyields a region-lessStackStateReffor a legacy{prefix}/{stack}/state.jsonwhose body carries noregionfield. The orphan removal path branched on that ref and, for the region-less arm, calledforceReleaseLock(stackName, undefined)and nothing else -- which targets{prefix}/{stack}/lock.json, the LOCK, while the record itself sits at{prefix}/{stack}/state.json. The success line printed unconditionally, so cdkd reported a removal it had not performed: the stack kept appearing incdkd state list, and a later deploy of the same name read a record the user believed was gone.deleteStatecould not be reused -- it takes a region, keys its primary delete off it, and sweeps the legacy key only when that key's ownregionfield MATCHES, so a body naming no region falls through every branch there. The backend gainsdeleteLegacyState(stackName), unconditional -- justified by its one caller's intent (state orphanwith no--stack-regionmeans every record for the name) rather than by any property of the ref, sincereadLegacyRegionalso yieldsundefinedon a swallowed 403 / 503 / unparseable body -- and with no rollback-journal sweep (journal keys exist only in the region-scoped layout). The orphan branch calls it before the force-release, so a failed delete now surfaces instead of being papered over by the success line -- pinned by a case that makes the delete throw and asserts neither the line nor the lock release happens. The opposite polarity is pinned too: a legacy key whose body DOES name a region reacheslistStacksas a region-carrying ref and must keep taking the existingdeleteStatepath, whose legacy sweep stays region-conditional.docs/cli-state.md's--stack-regionrow fororphandrops the "region-scoped" qualifier it carried to describe the buggy effect. Review round:--stack-region ''is falsy, so the truthy region filter was skipped outright and the flag a user passed to NARROW the removal silently widened it to every region -- the check is now on presence; the lock-refusal message rendered a region-less ref asStack 'X' ((legacy)) is lockedbecause the template already parenthesises the placeholder, now a barelegacy; and the destroy path carries the SAME defect on a worse consequence --getStateaccepts a region-less body from any region whiledeleteState's legacy sweep accepts it from none, so the AWS resources go and the record stays -- filed as issue #2550 rather than fixed here, because the repair needs a tri-statereadLegacyRegion(itsundefinedtoday means absent OR region-less OR unreadable) and touches the real destroy path.cdkd state migrate --dry-runno longer raises the confirmation prompt, so a preview runs unattended (issue #2538) --src/cli/commands/state-migrate.ts,tests/unit/cli/state-migrate.test.ts. Theif (!options.yes)confirmation block sat ABOVE theif (options.dryRun)early return, so a preview asked the user to confirm a copy it would never perform -- and on a non-TTYconfirmOrRefusethrewNON_INTERACTIVE_CONFIRMand the command exited 1, making--dry-runalone unusable in CI, the one place a preview is most useful. Everything the preview reports (both bucket names, the source bucket's real region, the object count, the active-lock refusal) is printed before either block, so returning at the dry-run check loses nothing. The order is now bail-then-prompt, matchingstate refresh-observed, whose prompt condition has always been!options.yes && !options.dryRun. The existing dry-run case passed--yesand therefore never reached the prompt, which is how the ordering survived; the new cases drive--dry-runWITHOUT--yeson an interactive stdin (prompt never consulted, and a scripted 'y' answer would fail the run loudly on the unplanned CopyObject) and on a non-TTY (exits cleanly, no refusal on the error path), plus the opposite polarity -- a run without--dry-runstill prompts and still cancels on 'n'.docs/cli-state.mddrops the paragraph documenting the prompt as current behavior, anddocs/cli-destroy.md's "on a non-TTY each refuses" line gains the--dry-runexception it now has two rows for. Review round: the bail also prints what--remove-legacywould do, since that flag was named NOWHERE except the prompt string the bail now precedes -- a preview of the one destructive flag that said nothing about it would have been a regression of its own.docs/changelog-cdkd.mdentries are capped at 2000 characters, with the overflow rationale moving todocs/design/<issue>-<slug>.mdor to the implementing module's or test's doc comment (issue #2552, PR #2559) --tests/unit/scripts/changelog-entry-size.test.ts(new), this file's header,CLAUDE.md, and.claude/skills/check-docs/SKILL.md. The file had grown to 523 entries and 2,363,928 characters (median 3,452, p90 8,999; the longest entry 19,548 characters on one line, and a different entry spanning 165 lines) because a bullet carried its whole design record inline -- valuable content, but in the one place nothing can verify it. The fence measures the ENTRY, joining a column-0 bullet with its continuation lines, so wrapping cannot buy room; a conservation verdict additionally requires every line below a heading to belong to some entry, closing the----ends-the-entry hole that would otherwise stop the count partway through. Forward-only is a date cutoff on the**Recently Implemented**heading rather than an allowlist that would go stale as the file grows, set to the day AFTER this landed so it cannot race a lane merging the same day. Entries dated before 2026-09-05 are unchanged.- A literal leaf EMBEDDING a
{{resolve:...}}token now persists its OWN expression when a sibling reference resolves to the same plaintext (issue #2485) --src/deployment/secret-redaction.ts,src/deployment/intrinsic-function-resolver.ts,tests/unit/deployment/secret-redaction-embedded-span.test.ts, a resolver-driven case intests/unit/deployment/dynamic-references.test.ts, and a live arm ontests/integration/secrets-dynamic-ref/(DB_DSN_LITERAL).RecordedSecretValuesis keyed by PLAINTEXT, so two expressions resolving to one value keep one entry -- whichever the resolver recorded last. A WHOLE-token leaf was immune (the position pass copies its own source, issue #1904), but a leaf that embeds the token in a literal string (postgres://app-svc:{{resolve:ssm-secure:NAME}}@db/app) was redacted by the value scan, which could only write the map's survivor: measured on real AWS by thessm-secure-dynamic-reffixture's first run (go-to-k/cdkd#2492), the embedded leaf persisted...NAME:1}}@...for a template that spellsNAME, and the next deploy diffed that leaf forever (a REPLACEMENT every run for a create-only property); a rollback ordrift --revertthen re-resolved the version-pinned expression to a stale value after rotation. The resolver now records, beside itssecrets.set(plaintext, expression), the uncollapsedexpression -> plaintextpair into a side table keyed by the MAP INSTANCE (recordResolvedPair), andredactByPathgainedpositionByEmbeddedSpan: a source leaf with exactly ONE span whose bag carries the source's prefix and suffix around a middle that EQUALS what this pass recorded the token resolving to is written asprefix + token + suffix. The evidence is pass-local by construction -- keyed by the map instance, so a map the resolver did not populate (a derived needle map, a nested-stack inheritance copy, anew Map(secrets)copy) lacks it and keeps the old fall-through, and a map it did populate (drift's, scrub's, import's own resolution) carries only THAT resolution's pairs. The arm is further bound to the value scan's own answer -- it accepts a leaf only when the scan would rewrite exactly the middle -- so it can never rewrite a leaf the scan left alone; on a previous generation's bag it therefore changes only WHICH of this pass's expressions is written, never whether one is (secrets.has(middle)alone could not tell "the token lost the map slot" from "the token was never resolved here", which is why the pass-local record exists). A publicssmtoken is never recorded (the seam records secret verdicts only), a mask-onlyNoEchovalue is never recorded, a middle that is itself a complete token is refused as an already-redacted record, two-span /Fn::Sub/Fn::Joinsources keep the value scan (the placeholder primitive issues #2298 / #2320 ask for), and an expression seen resolving to two values vouches for nothing. An embedded middle SHORTER than the value scan's needle floor is left alone as the scan leaves it (a rewrite below the floor would be a new claim on a bag this pass did not necessarily produce, since the arm cannot tell a previous generation's bag from its own), so the 1-3 character residual stands -- tracked by issue #2516, which asks for a bound that proves the bag's generation. TheExport.Nameresolution copies its ENTRIES into the pass map but deliberately not its resolved pairs: a name never positions a leaf, and the merge could only mark a pair conflicting. The value scan of a string leaf runs ONCE, inside the arm, which compares against it and returns it on every refusal -- so the compared value provably comes from the same bag and map the arm positions. The frame is copied from the source rather than scanned, for the reason the whole-token arm returns its source unscanned: a needle in the literal frame would fabricate a reference the template never had at that offset. The live arm buildsDB_DSN_LITERALwith a CONCRETE account (CDK_DEFAULT_ACCOUNT, which cdkd's synth exports to the app; a premise guard asserts the synthesized leaf is a plain string) so it is a literal rather than theFn::Joinevery other env var of that fixture is, places it ABOVESECRET_PASSWORD_STAGEDso the collapsed survivor is the staged spelling, and asserts exact equality of the persisted leaf with its own expression on top of the existingdiff --failguard. A second live arm ontests/integration/ssm-secure-dynamic-ref/(SSM_SECURE_VERSIONED_SAME, the:1selector on the SAME parameter the embedded leaf reads, declared after it) restores the exact same-parameter collision that fixture's first run measured and asserts the embedded leaf keeps its own spelling. Both fixtures' premise guards assert that the colliding key is the LAST synthesized env key resolving to the shared plaintext (matched on the template's expression, in declaration order) -- the collapsed survivor itself, not one pair of positions. - A stateful resource routed through Cloud Control is no longer DELETE + CREATEd with no flag and no stateful guard (issue #2514) --
src/deployment/deploy-engine.ts,tests/unit/deployment/deploy-engine-replace.test.ts,tests/unit/deployment/resource-replacement.test.ts,tests/unit/deployment/deploy-engine-resource-secrets-binding.test.ts,tests/unit/deployment/stateful-replace-message-doc-sync.test.ts(new),src/provisioning/stateful-types.ts,tests/unit/provisioning/stateful-types.test.ts,src/cli/options.ts,docs/cli-deploy-safety.md,docs/architecture.md. The engine's update-failure fallback turns a rejected in-place UPDATE into a DELETE + CREATE and has two triggers: a Cloud ControlUnsupportedActionException/ "does not support UPDATE" rejection (auto-fallback, no flag needed to reach it) and an SDK provider's typedResourceUpdateNotSupportedError(reached only with--replace).isStatefulRecreateTargetForReplacewas consulted insideif (replaceOptIn)only, so a stateful type on the Cloud Control trigger was destroyed and recreated on a plaincdkd deploy-- no--replace, no--force-stateful-recreation, no prompt -- while the same type behind an SDK provider was refused twice over. The guard is now hoisted above both triggers: the delete below is identical on either, so the data-loss consent is a property of the REPLACEMENT rather than of which provisioning layer the type happened to route through -- and that routing is re-decided every deploy (provisionedByis recorded, not pinned), so the guard's presence was not something a user could reason about. The Cloud Control arm gets its OWN message: it points at the provisioning layer that cannot update the resource and at--force-stateful-recreation, never at--replace(which does not gate that path), and it deliberately contains neither substring the fallback's own trigger matches on, so the refusal cannot re-fire the fallback if it is ever re-thrown through an update. Both arms append a sentence when the template declaresUpdateReplacePolicy: Retain, because the remedy they offer deletes the resource anyway; the policy is read TEMPLATE-only, so aRetainthe current template has dropped produces no note. Both refusals -- this one and its property-driven twin -- aremarkNonRetryable; only this one carries acause(the update rejection that routed it here), since the property-driven guard fires from the diff with no provider call behind it.UpdateReplacePolicy: Retainis deliberately NOT an exemption on either mid-deploy trigger, unlike the property-driven path, because this fallback deletes the old resource unconditionally instead of creating first. Behavior change: a stateful resource whose Cloud Control UPDATE is unsupported now fails withSTATEFUL_REPLACE_BLOCKEDunless--force-stateful-recreationis passed; non-stateful types still replace with no flag, exactly as before. The guarded arm is REACHABLE, and the route was found by review rather than by the first reachability pass: an SDK-createdAWS::DocDB::DBClusterwhose template gains one of its silent-drop properties (AvailabilityZones,CopyTagsToSnapshot,EnableCloudwatchLogsExports,GlobalClusterIdentifier, ...) is auto-routed to Cloud Control by the registry's silent-drop rule -- it is absent fromunsupported-types.generated.tsand its provider declares nodisableCcApiFallback-- and Cloud Control reports the typeNON_PROVISIONABLE, so it ships no UPDATE handler. Probing it on 2026-09-04 (aws cloudcontrol update-resource --type-name AWS::DocDB::DBCluster) answeredUnsupportedActionException: Resource type AWS::DocDB::DBCluster does not support UPDATE action-- the exact substring the fallback matches on, so a plaincdkd deploytook the unguarded arm. What that arm did NEXT for this particular type is bounded rather than catastrophic: the fallback's delete goes through the same Cloud Control provider, anddelete-resourcefor the type answersdoes not support DELETE action(probed the same day), so the pre-fix deploy failed at the delete instead of destroying the cluster. The guard turns that into an honest refusal, and closes the data loss for any type Cloud Control can delete but not update. The first pass had called the defect latent by enumerating only the types with NO SDK provider, missing that a registered provider's silent drops route to Cloud Control too. The reachable population is wider than the first two attempts said, and both of their eliminations were wrong. Four routes to Cloud Control exist for a guard-list type on an ordinary deploy: unconditionally (no SDK provider), conditionally (an SDK provider with a non-emptysilentDropmap, when the template declares one of those properties), only through a stickyprovisionedBy: cc-apirecord or--recreate-via-cc-api(their map is empty), and never (AWS::Elasticsearch::Domain, pre-flight-rejected asNON_PROVISIONABLE). Neither the per-type split nor its sizes are recorded here: both are re-derivable fromPROPERTY_COVERAGE_BY_TYPEanddocs/_generated/provider-coverage.jsonat any commit, both move whenever a provider gains a property, and a changelog entry can be re-read but never re-derived. The PR carries the derivation; what is durable here is the two corrections, both in the third bucket.AWS::Glue::Tablewas named as reachable, but its silent-drop map is empty, sofindActionableSilentDropsreturns[]andProviderRegistry.getProviderForhands it to the SDK provider -- the type's own provider says exactly that inglue-provider.ts.AWS::FSx::FileSystemwas eliminated "because its provider setsdisableCcApiFallback", but that check is only reached whenactionableDrops.length > 0and FSx's map is empty too, so the stated reason never runs; FSx sits on the same footing asGlue::Table. Under the sticky route neither is eliminated at all -- theprovisionedBy: cc-apirule returns Cloud Control BEFORE anydisableCcApiFallbackcheck. The second condition -- Cloud Control having no UPDATE handler for the type -- is a property of the AWS registry rather than of this repo, and is reported as the dated live probe it is:us-east-1, 2026-09-04,aws cloudcontrol update-resource --type-name AWS::DocDB::DBClusteransweringUnsupportedActionException. An earlier draft's aggregate Cloud Control mutability figures are withdrawn, and are not restated here: no repo artifact backs them (provider-coverage.jsonmergesFULLY_MUTABLEandIMMUTABLEinto tier 2 and excludes tier-1 types from tier 3), and they were measured over a guard list this change has since widened. The guard list itself was widened in the same change, in the safety-increasing direction, and gained two mechanical lower bounds so it stops being purely hand-curated.STATEFUL_TYPESandfinal-snapshot.ts's snapshot-capable union were compared in both directions, and the union is now discovered from that module's*_SNAPSHOT_TYPESexports rather than from two named imports, so a third snapshot mechanism cannot land outside the bound. The types in the snapshot union and NOT in the guard list --AWS::Redshift::Cluster,AWS::ElastiCache::ReplicationGroup,AWS::ElastiCache::CacheCluster-- were being final-snapshotted before acdkd destroywhile being replaced mid-deploy with no consent flag at all; the first two are Cloud-Control-routed, i.e. exactly the population this fix is for, and the third routes there whenever its template declaresSnapshotArns. Each is added with reasonalways: none has a cheap emptiness signal the way S3 andLogs::LogGroupdo, and AWS migrates nothing to the replacement. The other direction is a deliberate strict-superset -- most guard-list types (S3, DynamoDB, LogGroup, ECR, ...) have no snapshot API at all and CloudFormation rejectsDeletionPolicy: Snapshoton them -- so the fence pins the subset relation ONE way only, and says so. A SECOND lower bound closed more gaps, one of them mechanically checkable. Every resource type registered to a provider whosedelete()consultsDeleteContext.forceDataDeletemust be on the guard list: that field is set ONLY by the replacement / recreate delete sites under--force-stateful-recreation, so a provider reading it has already declared its delete destroys user data.AWS::S3Express::DirectoryBucketwas the one type on the wrong side --S3DirectoryBucketProvider.deleteimplements the issue #1344 sibling of the S3 bucket's #1340 data guard, so with CDK'sautoDeleteObjectstag a mid-deploy replacement emptied the bucket with no consent flag, and without it the create-first delete failed and left an untracked bucket. It isalwaysrather than conditional likeAWS::S3::Bucket, on the narrow ground of the probe cdkd HAS: the pre-flight issuesListObjectVersions, a general-purpose-bucket API, so no CURRENT probe can report a directory bucket empty. A probe is not impossible in principle --S3DirectoryBucketProvider.emptyBucketenumerates the same buckets withListObjectsV2-- and an earlier wording that claimed otherwise is corrected; until one is wired into the pre-flight, an unprovable emptiness must not read as empty. The fence is derived from the SOURCE (provider files grepped for the field, resource types read fromregister-providers.ts) with its own coverage floors, since a hand-listed version of either half is what it replaces. Neither fence can see a provider whose delete destroys data with NO opt-in at all, so the rest are hand-added with the reason at each entry:AWS::S3Tables::TableBucketandAWS::S3Vectors::VectorBucket, whose deletes callemptyTableBucket/emptyVectorBucketunconditionally (the former walks every namespace andDeleteTables every table in it);AWS::S3Tables::Table, which holds the rows themselves rather than the catalog entryAWS::Glue::Tablealready covers;AWS::KMS::Key, whoseScheduleKeyDeletiontakes every ciphertext under the key once the window elapses -- including data in other stacks that merely reference it -- andAWS::KMS::ReplicaKey, guarded on the same UNMEASURED footing as the namespace below rather than on an argument, since whether a destroyed replica's ciphertexts survive through another key in its multi-region set is a question about AWS and about the user's topology that this repo has not measured (AWS::KMS::Aliasstays off, and that one IS repo-derivable: deleting an alias drops a pointer, not key material);AWS::CodeCommit::Repository, whose delete destroys the repository's git history; andAWS::EMR::Cluster, whoseTerminateJobFlowsdestroys the HDFS volumes on the core nodes -- reachable on a PLAIN deploy, since the type has noReplacementRulesRegistryentry and its registry-schema createOnly set (Name,ReleaseLabel, ...) drives a replacement throughcreate-only-properties.ts.AWS::S3Tables::Namespaceis guarded on the fail-safe side of an OPEN question rather than on a proof. cdkd's own delete for a namespace issues a bareDeleteNamespaceand enumerates no tables -- but whether AWS's API cascades server-side is unmeasured; an earlier draft inferred that it does not fromemptyTableBucketdeleting each table explicitly first, and that inference is WITHDRAWN, being equally consistent with merely defensive ordering. Since the type's createOnly properties make a rename a property-driven replacement that a plaincdkd deployreaches with no flag, the unproven answer does not get to decide it; issue #2539 holds the live probe, and AWS refusing there is the only grounds to revisit the entry. A third fence pins the PUBLISHED type tables indocs/cli-deploy-safety.mdagainstSTATEFUL_TYPES-- per TABLE, so it watches each type's CONDITIONALITY and not only its membership -- because the list is a user-facing contract. Behavior change, widening refusals: a--replace(or property-driven, or Cloud Control auto-fallback) replacement of an ElastiCache cluster or replication group, a Redshift cluster, an S3 Express directory bucket, an S3 Tables bucket / table / namespace, an S3 Vectors bucket, a KMS key or replica key, an EMR cluster, or a CodeCommit repository now demands--force-stateful-recreationwhere it previously went through -- forAWS::CodeCommit::RepositoryandAWS::KMS::Keyonly through--recreate-via-*,--replaceor a stickyprovisionedBy: cc-apirecord, sincereplacement-rules.tsclassifies every CodeCommit property as updateable and neither type has a silent-drop map to auto-route on. That is the intended direction for a data-loss guard: a type cdkd snapshots on destroy, or empties, or schedules for deletion, must not be silently destroyed mid-deploy. Unit tests pin both polarities of the hoisted branch, both message spellings the trigger accepts, theRetainnote in both directions and on both arms, the template-over-state policy read, the non-retryable marking, the chained cause, and the both-triggers-true case; a new doc-sync fence keeps the documented example and the shipped message from drifting apart; the live-AWS coverage arm is filed as issue #2515, the fallback'sRetaindivergence as #2518, the prose-matching trigger predicate as #2520, the guard's LogGroup input bags as #2521, and the unmeasuredAWS::S3Tables::Namespacecascade as #2539 -- which now decides whether that entry can be RELAXED rather than whether it should exist. Issue #2540, which filedAWS::KMS::KeyandAWS::CodeCommit::Repositoryas the types neither fence can see, is closed by this change instead: leaving a known no-consent path to unrecoverable ciphertext and to deleted git history open for a later PR was the wrong trade. {{resolve:ssm-secure:...}}is resolved to the SecureString parameter's value instead of being sent to AWS as the literal template text (issue #2482) --src/deployment/intrinsic-function-resolver.ts,src/deployment/secret-region-classification.ts,src/cli/commands/drift.ts,tests/unit/deployment/dynamic-references.test.ts,tests/unit/deployment/rollback-executor-cross-region-secret.test.ts, the drift / scrub suites, and the newtests/integration/ssm-secure-dynamic-ref/fixture.resolveDynamicReferenceshad arms forsecretsmanagerandssmonly;ssm-secure-- the spelling CloudFormation defines for aSecureStringparameter, and what CDK'sSecretValue.ssmSecuresynthesizes -- fell into the unsupported-serviceelse, which logged a WARN and left the token in the property bag. cdkd never goes through CloudFormation, so nothing resolved it server-side: the provider handed the literal to the service API, and where the API accepted it (an IAM console password does; RDS rejects a/in a master password and fails loudly instead) the live credential WAS the template text, with the deploy exiting 0. The spelling now takes the SecureString path:isKnownSecrettreats it as secret by spelling (so the comparison-path resolver skips it with NOGetParameter, exactly likesecretsmanager), a new arm callsresolveSSMReference(parts, true)-- thename:versionselector reachesGetParameter.Nameas-is, which SSM accepts -- pins the verdicttrueunconditionally, and records theplaintext -> expressionpair, so state persists the expression, logs / events / errors are masked and a rollback replay re-resolves it through the existing GHSA machinery unchanged. A parameterGetParameterreports as a definitiveString/StringListis REFUSED (IntrinsicResolutionRefusalError,markNonRetryable, naming the type and never the value) rather than resolved as public config under a secret spelling; an unclassifiableTyperesolves as a secret.secret-region-classification.tsaddsssm-securetoREPLAY_SECRET_SERVICESso the region question (ambiguous/named-region/local) is asked of it too, andssmParameterNamestrips the actual service prefix -- the fixed'ssm:'.lengthwould have turnedssm-secure:/pwintosecure:/pw. The drift side loses itsssm-secure-specific arms: a{{resolve:...}}token that survives the pass is no longer any CloudFormation service, soisSecretBySpellingand the two guards it fed (marking a survivor's path secret-bearing, andpreserveLiveValuesAtUnresolvedTokensregistering the live value it copied) are removed with the population -- a survivor is text that merely looks like a reference, and registering its live value would make a redaction needle out of ordinary data. TheunresolvedTokencause, its exit-code exclusion and the whole-token live-value preservation stay for look-alike spellings; the survivor-path unit tests now use one, and thessm-securecases flip to the resolved path (a CFn-migrated record compares CLEAN, a rotated value is masked by the recorded pair,--revertwrites the decrypted value and persists the expression, the region-less form is refused under a foreign producer region and resolved locally without one,cdkd scrubrecords the needle through a scrubbed producer). Docs:docs/cli-drift.md's causes table,Unresolvablebullet and known-limitation paragraph,docs/testing.md, the drift layout rule, and thesecrets-dynamic-reffixture's explicit skip, which now points at the new fixture. Residual the fix does not repair by itself, stated indocs/cli-drift.md: a stack cdkd deployed BEFORE this change holds the literal token on AWS and the same token in state, so the deploy diff readsNO_CHANGEand the upgrade does not rewrite the live value; for a property the provider reads back,cdkd driftnow reports it and--revertwrites the resolved value, while a write-only destination (MasterUserPassword,LoginProfile.Password) needs that property to be updated once. The new integ fixture seeds a SecureString out of band (CloudFormation cannot create one), consumes the whole, embedded and:1-versioned forms in a Lambda environment variable -- chosen because it READS BACK; this is a cdkd-only resolver check, not CloudFormation destination-allowlist parity -- and asserts the live values are the plaintext and never the token, thatstate.jsonholds the expressions and never the plaintext, thatdiff --fail,scrub --dry-runanddrift --jsonare clean, and that a Tags-only re-deploy keeps all of that, sweeping the state bucket's object versions at the end.
Recently Implemented (2026-09-03):
- An in-place update of an
AWS::SecretsManager::Secretno longer replaces the secret value unless the value's SOURCE changed (issue #2472) --src/provisioning/providers/secretsmanager-secret-provider.ts,tests/unit/provisioning/secretsmanager-secret-provider-update-value-source.test.ts,tests/integration/secretsmanager-update-value-source/.update()re-ran its localGenerateSecretStringgenerator on EVERY call and re-sent an unchanged literalSecretString, andUpdateSecretwith aSecretStringcreates a new version and movesAWSCURRENTto it -- so a Tags-only or Description-only deploy (or a rollback replay of the same bag) silently minted a fresh password for the CDK-defaultGenerateSecretString: {}shape, and a database seeded from the old value then rejected every consumer reading the new one; a literal secret stacked a redundant version per update and advancedAWSPREVIOUSoff the real previous value. The value now rides the update only when its source changed: a generated value is minted only when theGenerateSecretStringblock differs frompreviousProperties(isDeepStrictEqualover a JSON round-trip, so a key-order difference between the state-read bag and the resolver's is not a change and neither is an explicitundefinedmember), a literal only when it differs from the previous literal (an empty string is a value; a non-string is refused by shape rather than forwarded -- oncreate()as well, and onupdate()only once it is known to be a CHANGE, so a record a pre-fix binary wrote with a non-string still takes an unrelated update), switching source in either direction counts as a change, and a bag carrying neither source keeps the live value -- CloudFormation's semantics.UpdateSecrethas merge semantics, so an omittedSecretStringleaves the current version untouched. The block is compared as STATE SPELLS IT. A{{resolve:...}}insideSecretStringTemplatereaches the provider as plaintext while state holds the redacted expression, so a raw comparison would always differ and re-roll on every Tags-only update for exactly the shape that embeds a secret; the provider rewrites the desired block through the sameredactSecretsForStatethe state writer uses, with the pairs from the per-resource secrets scope the deploy engine and the rollback executor bind around the call (absent ondrift --revert/ import, where the raw comparison applies as before). A LITERAL that is itself a{{resolve:...}}is deliberately still re-sent on every in-place update: cdkd cannot tell whether the referenced value changed since the last deploy, CloudFormation re-applies it when it did, and a redundant version is the milder failure than a stale copy.cdkd drift --revertwas never affected (its bag starts from the readback, which omits both value-source fields). The new integ fixture reads the version ledger back from real AWS: a Tags-only re-deploy keeps the sameAWSCURRENTVersionIdand version count on both a generated and a literal secret, and a positive-control phase that changes theGenerateSecretStringblock and the literal proves both values DO move (newAWSCURRENT, previous current nowAWSPREVIOUS), so the keep-assertion cannot pass on a provider that never sends a value. - Every AWS SDK client in cdkd now routes through the proxy defaults — the #2398 allow-list is empty and the completeness claim is measured, not asserted (PR #2465, external contribution) -- 65 files under
src/(everysrc/provisioning/providers/*andsrc/synthesis/context-providers/*client site,src/deployment/intrinsic-function-resolver.ts,src/provisioning/cloud-control-provider.ts,src/local/{ecr-puller,ecs-secrets-resolver}.ts,src/utils/role-arn.ts, the asset publishers, and the CLI commands),scripts/check-aws-client-defaults.ts,tests/aws-client-defaults-allowlist.json(now[]),tests/unit/scripts/aws-client-defaults-fence.test.ts,.claude/rules/proxy-support.md. The follow-up #2398 promised: all 158new XClient(...)sites across 79 files spreadawsClientDefaults()FIRST, so site-specificregion/profile/credentialsstill win. The commonest provider idiom is wrapped, not rewritten ({ ...awsClientDefaults(), ...(this.providerRegion ? { region: this.providerRegion } : {}) }), and every site whose scope carries aprofilepasses it to the helper. Review found the sweep's own oracle had a quarter-population blind spot: the AST checker read only top-levelImportDeclarations, so 18 destructuredawait import('@aws-sdk/client-*')sites (17 unmigrated —deploy.ts'sGetCallerIdentityon every deploy among them) plus 6new mod.XClient()namespace-form sites had no verdict at all, and the empty allow-list asserted a completeness the tool could not measure. The checker now binds both dynamic-import shapes, and the fence test reconciles the AST walk against a text scan that is wrong in DIFFERENT ways — every hit must be a detected site or a line of prose, so a new binding shape the walk misses fails the suite instead of shipping invisible (reverting the dynamic arm reds it naming 18 sites). Two fail-open, test-unpinned exemptions inside the checker's shared-bag pass (a direct helper call at the site / inside a getter exempting whatever else the literal spread) were removed with accept/reject pairs after both were shown wrong for a constructible later-key-wins shape; removal measured byte-identical on the real population. The remaining detector blind spots are enumerated in the checker's docstring (static namespace import, inline(await import(...)).XClient,.thenchains, re-exports, and the aggregated non-Client-suffixed class — the one shape both methods miss identically). Verified by a 3-axis + security review over three rounds plus a real-AWS broad integ run (lambda) and a Docker local run (local-run-task-cdkd-assets) on the unproxied path. cdkd synth's YAML now parses, and parses back to the template -- the hand-rolled emitter is replaced by theyamlpackage (issue #2421) --src/utils/yaml.ts,src/cli/commands/{synth,list}.ts,docs/cli-reference.md, plustests/unit/utils/yaml.test.ts(rewritten around a round-trip table) and new / re-baselined cases intests/unit/cli/{synth-stdout-stream,list,list-json-stream}.test.ts.toYamldecided quoting with a predicate covering four cases -- an embedded newline, a leading{/[/", and the literals containing#/ empty /true/false/null-- so every other YAML indicator went out bare.AllowedOrigins: ['*']therefore rendered as- *, an alias node, and a parser rejected the document: measured ontests/integration/basic, the repo's MINIMAL fixture (one S3 bucket, no IAM policy involved),node dist/cli.js synth | yaml.parsefailed withYAMLParseError: Alias cannot be an empty string ... code: BAD_ALIAS. Issue #2410 had made the STREAM clean; the DOCUMENT on it was still invalid, which is whycdkd synth | yqdid not work after it. The fix is a change of INSTRUMENT, not a wider predicate: correct YAML quoting has an unbounded number of spellings to get wrong one at a time (the "spelling treadmill"), theyamlpackage was already a runtime dependency, and the AWS CDK CLI does this exact job withyaml.stringify(obj, { schema: 'yaml-1.1' })at fold width 0 (@aws-cdk/toolkit-lib/lib/util/yaml-cfn.ts), so "CDK CLI compatible output" is now PRODUCED by the library CDK CLI uses rather than approximated.aliasDuplicateObjects: falseis passed for the reasonsrc/cli/yaml-cfn.tsalready passes it -- CFn does not understand YAML anchors, and a template holding one object twice by identity would otherwise emit the very alias syntax this issue is about, from the other side. Neither schema resolves a superset of the other -- 1.1 addsyes/no/on/offand timestamps, 1.2 core adds0ooctal -- which is why the emitter ends up asking BOTH rather than picking the wider one, and why the paragraph below exists. Measured blast radius, whole-file diff ontests/integration/basic: of 182 lines, only the defect and its mirror move.- *becomes- "*"; numbers stop being emitted as strings (ExpirationInDays: 90, not"90"); numeric STRINGS start being quoted (schemaVersion: "2.2", the bootstrap-version list- "1"); and the document no longer opens with a blank line. Indentation, key order and sequence style are byte-identical. The type-fidelity pair is the same root cause in both directions -- the old emitter rendered every number as"N"and every all-digit string bare, so a template'sMaxAge: 3600came back a string while its IAMVersion: '2012-10-17'came back a Date -- and the visible consequence forcdkd list --longis that an account id is nowaccount: "123456789012", agreeing with what--jsonalways returned. The leading-newline question the issue carried as its third item is decided here too, INSIDE the renderer rather than per consumer:toYamlreturns a document starting at column 0, solist.ts's.replace(/^\n/, '')is gone andsynthno longer opens with a blank line -- one serializer, one contract. The 3-axis review round then found the delegation was not sufficient on its own, and the fix for that is the shape of the whole change in miniature. Emitting under 1.1 leaves two classes un-quoted: YAML 1.1 is not a SUPERSET of 1.2 core, so'0o17'-- 1.2 added0ooctal and 1.1 has no resolver for it -- goes out bare and the DEFAULT reader hands back the number 15; and'<<'as a map KEY emits<<: v, which a 1.1 reader resolves as the MERGE key and rejects withMerge sources must be maps. Both were found by review, neither by enumeration. The remedy is deliberately NOT two more entries on a must-quote list -- that is the same artifact with the same failure mode -- but a ROUND-TRIP ORACLE:toYamlbuilds aDocument, and for every string scalar asks the library whether the plain rendering survives a parse under BOTH readers (yaml-1.1andcore), forcingQUOTE_DOUBLEwhere it does not. Two oracles, because POSITION decides the question --<<is special as a key and ordinary as a value -- and memoized, since templates repeat their scalars. Review round 2 then found the<<half of that fix was INERT, and it is the sharpest lesson in the PR. The oracle correctly returnedfalsefor the key and the visitor correctly setQUOTE_DOUBLE-- and the assignment did nothing, because under theyaml-1.1emit schema the library's MERGE tag owns<<and itsstringifyreturns the literal<<while ignoring the node's style. So{'<<': 'v'}still emitted<<: v, which a 1.1 reader rejects. The suite could not see it either: the four-position round-trip loop parses under 1.2, where<<:is an ordinary key, and the 1.1 arm covered only the map-VALUE position -- the one position that differs was the one position not asserted, under the one reader that resolves it. Both were fixed: the emitter now emits undercore(no merge tag, so the forced quotes survive and the output is"<<": v), and the 1.1 arm runs all four positions, which reds on the old schema. Parity is preserved in fact rather than by the option name, and measured rather than argued: with the oracle in place BOTH of this repo's real templates render byte-identically under the two schemas, and every probe agrees except<<itself -- what changes is only which layer does the quoting (undercorethe library emitsyesbare and the oracle quotes it; under 1.1 the library quoted it directly). The live arm now parses under both readers too, since a default-reader-only arm could not have caught this. Review round 3 then found the schema swap had a side effect the byte-identical measurement could not see, because that measurement compared STDOUT only. The oracle PARSES, and a parse can WARN as well as throw: undercorea date-shaped map KEY emits bare, so probing it under the 1.1 reader resolves aDateandyamlcallsprocess.emitWarning-- 254 bytes on real stderr, during a GREEN unit run (against this repo's "a green run must print nothing" rule) and fromcdkd synthfor any template carrying such a key. The probe reads are nowlogLevel: 'silent'; the oracle only ever reads the parsed value and has no use for the diagnostics. The same round independently falsified a UNIVERSAL claim the previous one had written -- "the schema differs and the output does not" -- by finding the one class it does change: JS types 1.1 has exclusive tags for (Date,Map,Set,Uint8Array). That is a style change rather than a regression (all four rendered{}under the pre-PR emitter,core'sDateoutput is the safer of the two, and neither consumer can produce one), but the sentence was wrong as written and is now scoped. A 2,454-case sweep for a SECOND<<-shaped hole -- a tag whosestringifydiscards a forced style -- found none undercore, and exactly<<under 1.1. The per-branch probe table is recorded in the source rather than left implied: the KEY oracle'scatchis load-bearing (it is the path<<takes; flipping it reds), the VALUE oracle's identical-lookingcatchis defensive and unreachable (0 hits over 461 strings, and 0 again over 91,033 afterlogLevel: 'silent'narrowed what can reach it), and thekey === 'key'visitor guard reds nothing -- each said so in a comment instead of a reader having to assume.
The same round found the option probes were not all live. Removing lineWidth: 0 reddened NOTHING, because the fixture's long value was a single 200-character token and yaml folds only at whitespace -- so the option was documented, believed, and unfenced; the fixture now carries spaces and the probe reds. PLAIN_SCALARS had the mirror problem: its round-trip assertions are vacuous for OVER-quoting (a quoted scalar round-trips), and the one live assertion covered the TOP-LEVEL position only, so an emitter quoting every sequence item passed 0 red; it now asserts all four positions. The anchor test's /[&*]a\d/ was bound to the library's default anchorPrefix and went green under anchorPrefix: 'x', so it asserts the characters themselves. And the live arm's own glob guard was DEAD CODE -- under set -euo pipefail a glob miss killed the script at the assignment while 2>/dev/null ate the diagnostic, the opposite of what the guard was added for (probed both ways: rc=1 with no message before, rc=3 with the message after).
The fence is a ROUND-TRIP table, not a list of expected literals, because a literal table can only pin the spellings somebody thought of, which is the failure mode that produced the bug: 50 hostile scalars (one per hazard class -- every leading indicator, : and # in-value, leading / trailing whitespace, the implicit-null / boolean / number / date resolutions, multi-line content) plus 7 that must stay unquoted, each asserted at all four positions the old emitter reached by different code paths -- top-level scalar, map VALUE, sequence ITEM and map KEY, the last having had its own separate and also-incomplete rule (key.includes(' ')). Asserted under the default 1.2 reader AND under a 1.1 one, since 1.2 does not resolve yes / on / 2026-09-03 implicitly and would leave them unfenced. Mutation-probed: with src/utils/yaml.ts restored from origin/main, 50 of the 70 cases go red at an unchanged test count (so no load error is being read as discrimination); restored byte-exact from a copy, 70/70 green. Re-measured on the SHIPPING tree rather than carried forward -- the pre-review table was 48 of 67, and both numbers moved when the review round added cases. A command-level case was added to synth-stdout-stream.test.ts as well -- that suite already parsed stdout and still could not see this defect, because its fixture holds no indicator character and no number, a green parse over a fixture that cannot fail. Live-tested end to end on tests/integration/basic after the fix: cdkd synth stdout parses under both readers AND is deep-equal to the per-stack template JSON in cdk.out, and cdkd list --long parses and starts at column 0. A live arm was added to tests/integration/local-invoke/verify.sh, which already synthesizes and until now DECLINED to assert that the template parses, naming this issue as the reason. It now parses cdkd synth stdout and asserts DEEP EQUALITY against the assembly's own cdk.out/*.template.json -- parsing alone is the weaker half, satisfied by output whose scalars changed type on the way out. Probed in both directions with the binary rebuilt from origin/main's emitter: rc=1 before, rc=0 after. The pre-fix failure there is NOT the one the issue was filed for, which is the finding: YAMLParseError: Nested mappings are not allowed in compact mappings (BLOCK_AS_IMPLICIT_KEY) at the InlineHandler Code.ZipFile line, whose body ({ inlineEcho: event }) carries a colon-space that the old predicate never quoted -- reached with no wildcard anywhere in the fixture. src/cli/yaml-cfn.ts is untouched and remains a different job -- it emits CFn intrinsics as shorthand tags (!Ref) for cdkd export / import, while cdk synth and this renderer print the long form.
- The cdkd.dev documentation site ships (this PR) --
vite.docs.config.ts(Ox Content SSG overdocs/— hand-authorednavigationgroups so the site's IA is independent of the flat file layout;generateOgImage+docs-site/og-template.tsrenders a 1200×630 OG image per page;siteMapsemits sitemap.xml / robots.txt / llms.txt;markdownSourcepublishes a raw-Markdown companion per page for AI agents),docs-site/(brand SVGs, OG template, noop client entry),.github/workflows/docs-deploy.yml(build on PRs, build+deploy to GitHub Pages on main),vp run docs:dev|build|previewtasks, eleven new user-facing pages underdocs/(index / introduction / getting-started / ai-agents / concepts / wait-modes / ci-per-pr / rollback / drift / export / mixed-estates — content lifted from the README, which a follow-up PR slims down to point at the site), frontmatter (title/description) on the existing user-facing docs pages, andunlisted: trueon internal ones (design docs, plans, this changelog). The client build gets an emptydocs-site/noop-entry.tsentry because Ox Content emits every page incloseBundleand Vite refuses to build the client environment without an entry module. cdkd events prunerefuses a non-interactive stdin with exit 1 like the other nine, and its helper — the last copy — is folded away (issue #2454) --src/cli/commands/events.ts,tests/unit/cli/{non-interactive-confirm-guards,readline-prompt-population}.test.ts,docs/cli-reference.md. It was the TENTH mutating confirmation prompt and the one issue #2275 left alone, because it was already guarded and so never hung. What it did instead was refuse by logging a line and RETURNING, i.e. exit 0 with noNON_INTERACTIVE_CONFIRMcode -- so a CI job branching on exit 1 could not see it, and could not tell "cdkd refused" from "cdkd pruned nothing". The guard also sat at the CALLER rather than in a helper, which is whyevents.tsstill carried its own byte-identical copy of the prompt helper: the tenth, and the last. It now delegates toconfirmOrRefuselike the nine, which fixes both at once -- the exit-code contract becomes uniform and the population fence drops from 11 sites across 10 files to 10 across 9. BREAKING for this command only: a non-interactivecdkd events prunewithout--yesused to exit 0 and now exits 1; nothing was pruned in either case, so the remedy is-y/--yes. Deferred from the go-to-k/cdkd#2275 lane on the ground that it must not share that PR -- a reason the retro's promotion check found EXPIRED the moment that PR merged, which is what brought it back into the same session.- A custom-resource handler can now keep its generated secret OUT of
state.json, by declaring the responseNoEcho: true(issue #2274) --src/deployment/secret-redaction.ts(the MASK-ONLY needle class:recordMaskOnlyValue/recordMaskOnlyValuesIn/carriesSecretMask/wholeStringLeavesOf/substringNeedlesOf/recordedExpressionsOf, plus the in-run recovery storerecordRecoverableMaskedOutput/recoverMaskedOutput/clearRecoverableMaskedOutputs),src/types/resource.ts(newNoEchoAttributesResult, mixed into both provider result types),src/provisioning/providers/custom-resource-provider.ts(carryNoEchoacross the simple-handler envelope synthesis; relay it on create / update),src/deployment/deploy-engine.ts(noEchoAttributeResources,registerNoEchoAttributes,refuseRedactedAttributeReads),src/deployment/intrinsic-function-resolver.ts(noteAttributeSecrecyon both attribute reads, the cross-stack twin insidereresolveCrossStackValue, and the two newResolverContextfields),src/cli/commands/drift.ts(a masked BASELINE is secret-bearing;preserveLiveValuesAtMaskedLeavesplus the--revertrefusal),src/deployment/rollback-executor.ts(refuseMaskedReplayBaselineon the three written-side replay arms),src/cli/commands/export.ts(a record whose properties hold the mask joins the per-resourceblockedlist),src/provisioning/providers/nested-stack-provider.ts(recover a child output THIS RUN masked, and name it per attribute),docs/state-management.md,docs/provider-development.md,docs/cross-stack-references.md,docs/cli-reference.md, plustests/unit/deployment/secret-redaction-mask-only.test.ts,tests/unit/deployment/deploy-engine-noecho-custom-resource.test.ts,tests/unit/deployment/rollback-executor-masked-baseline.test.ts,tests/unit/provisioning/custom-resource-noecho.test.ts(all new), new blocks intests/unit/cli/drift-secret-redaction.test.tsandtests/unit/deployment/intrinsic-import-value-index.test.ts, and a newNoEchoarm intests/integration/custom-resource-getatt-data/. THE PROBLEM. A Lambda-backed custom resource'sDatais persisted verbatim into the resource'sattributesand into the resolvedpropertiesof everything that consumed it, so a handler that MINTS a value (a generated password, an issued API key) put that value instate.jsonin the clear. The response envelope has carriedNoEchoall along -- cdkd declared the field and read it NOWHERE. WHY NOT MASK AT CAPTURE, which is the obvious fix and is wrong: it was measured against real CloudFormation (a probe stack in us-east-1, on the issue thread) that CFn delivers aNoEchocustom resource'sDatato a dependent resource as PLAINTEXT -- the AWS docs' "masked with asterisks" sentence describes the display channel, the way aNoEchotemplate PARAMETER behaves. So masking at resolution would make a template feeding that value intoAWS::SecretsManager::Secret.SecretStringstore the literal***AS the secret, which is worse than the leak.Fn::GetAtttherefore keeps resolving to the real value and only the PERSIST path changes. THE MECHANISM. A third needle class beside the expression-bearing ones: the plaintext is recorded asplaintext -> SECRET_MASKin the sameRecordedSecretValuesbag, so every existing persistence reader (scrubResourceRecord's three fields, the rollback journal, the outputs bag,maskSecretsInText) is covered by code that already walks it, with the SENTINEL VALUE as the marker -- there is no side table (an earlier revision'sWeakMapwas removed after a mutation probe showed its extra conjunct unfenceable and wrong-pointing). A mask-only needle must clearMIN_NEEDLE_LENGTH, unlike an expression-bearing one: an expression pair came from a POSITION cdkd resolved, while a bare plaintext has none, so a handler answeringData: { Ready: "true" }would otherwise mask every leaf whose whole value is"true". For the same reason the registration EXCLUDES whole string leaves of the resource's own resolved template properties -- a handler echoingevent.ResourcePropertiesintoDatawould otherwise mask the resource's ownServiceTokenin the recordCustomResourceProvider.deletereads it back from. Scope is the pass's own bag -- the same answer PR #2415 was forced to after review found a process-wide positive store is itself a cross-stack disclosure (residual #2425). Registration happens in the CONSUMER's resolution pass, becauseperResourceSecretsis keyed by LOGICAL ID: a needle recorded under the custom resource's own id is not in the bag its DEPENDENT's record is scrubbed with, so masking only the producer would leave the SSM parameter that consumed it holding the plaintext -- a line that cannot be explained to someone who setNoEcho. WHOLE-LEAF ONLY, and that bound is the design. The mask class is withheld from the substring arm, mirroring the restricted blast radius PR #2415 established for itsinferredneedles. An EXPRESSION substituted into a longer leaf is lossless; a MASK is not -- an inline***is indistinguishable from a literal***a user wrote, so nothing downstream could recognise it anddrift --revert/resolveReplayPropswould push the corrupted string to AWS.maskSecretsInTextis deliberately NOT narrowed the same way: its output is a log line or an event, which nothing reads back as a value, so a partial mask there is free. The residual (an embedded value keeps its plaintext in state) is filed as #2453. THE COST IS GUARDED RATHER THAN HIDDEN, and this is the half the issue thread's own design had not accounted for. Once the mask is in state,Fn::GetAtton a LATER deploy that does not re-invoke the handler reads back***, and a dependent that has to be written would push it to AWS -- the #1498 / #1501 data-corruption class. So the resolver RECORDS every read that served a mask (ResolverContext.redactedAttributeReads, written forFn::GetAttand, throughreresolveCrossStackValue, forFn::ImportValue/Fn::GetStackOutput/ a nested stack'sOutputs.<Key>) and the deploy engine REFUSES to provision a resource whose resolution recorded one, naming the read and the remedy. It records rather than throws on purpose: the DIFF pass resolves the same leaf, and a throw there would make every later deploy of such a stack fail, where recording leaves***compared against***-- a clean NO_CHANGE -- so an untouched stack still deploys and only a resource that genuinely has to be written is refused. The same reasoning givescdkd driftits three arms (mask the live value in the report, refuse--accept, preserve the live value on--revertand refuse the resource when AWS has nothing to preserve) and the rollback replay its refusal on the written side only. THE CROSS-STACK REGRESSION, and why the refusal alone was not shippable. Every cross-stack route reads the PRODUCER's persistedstate.outputs-- a nested stack'sOutputs.<Key>(viaNestedStackProvider),Fn::ImportValue(index or state scan) andFn::GetStackOutput-- so masking a child's output made the FIRST deploy of a parent refuse a template that deployed before this change. Where the producer was deployed by THIS process in THIS run the plaintext is still in memory, and it is now threaded rather than refused:DeployEngineremembersstack + region + output key -> plaintextfor any output its redaction just masked, and the three read sites recover from that coordinate and re-register the value as a MASK-ONLY needle in the CONSUMER's own bag, so the wire value is correct while the consumer's record still persists***. The key is a COORDINATE, never a bare plaintext, for the reason PR #2415 had to withdrawprovenPublicExpressions(residual #2425): keyed on a value alone, one stack's answer is served to another stack's identically spelled read.NestedStackProviderreports the recovered outputs PER ATTRIBUTE (ResourceCreateResult.noEchoAttributeNames), never as the whole bag -- a child typically exports one sensitive output among several ordinary ones, and a whole-bag declaration would mask every unrelated output into the parent's record and into any parent resource that reads one. WHAT STAYS REFUSED, and the messages say so accurately. A producer deployed by an EARLIER run has no plaintext anywhere cdkd can read, so the consumer is refused. "Re-deploy the producer stack first" would not help -- the producer re-masks on the way into its own state -- so the deploy refusal now names the two remedies that work (force the custom resource to update so its handler runs again IN THIS RUN, or stop settingNoEcho) and states that a cross-stack value needs producer and consumer in ONE run. Thecdkd exportblocker says the reverse for the same reason: forcing an update does NOT clear it, because export reads state and state is where the mask lives. Andcdkd driftreports a permanent, unresolvable comparison at such a leaf (both sides render***), documented indocs/cli-reference.mdrather than silently dropped. The runs this cites are whatever the ledger currently holds (docs/_generated/integ-last-run.tsv; one row per test, so an earlier lane's row is gone rather than wrong):custom-resource-getatt-data(2026-09-02T17:40:08Z, PASS, 150s), which carries the newNoEchoarm, andlambda(2026-09-02T17:40:08Z, PASS, 190s) for theinteg-broadgate thatdeploy-engine.tsandintrinsic-function-resolver.tsactivate -- 10 and 9 resources deleted, 0 errors, 0 orphans on either. TheNoEchoarm asserted BOTH directions against real AWS in one run: the dependent SSM parameter held the real token on AWS while the custom resource'sattributes, the dependent'sproperties,state.outputsand the whole state blob held none of it, and the echoedServiceTokensurvived as an addressable Lambda ARN. The existing fixture asserts the OPPOSITE and keeps doing so:custom-resource-getatt-datarequires the non-NoEchocustom resource's value in CLEARTEXT on AWS and instate.outputs, which is the negative case. The new arm adds a second custom resource returningNoEcho: truethrough the SIMPLE-HANDLER shape (noStatusfield) -- the delivery path whose envelope synthesis DROPPED the flag, so the arm would have been inert without that half of the fix -- and asserts both directions at once: the dependent SSM parameter holds the real token on AWS, while the custom resource'sattributes, the dependent'sproperties,state.outputsand the whole state blob hold none of it. Deliberately NOT included: persisting the flag onResourceState, which is a v9 -> v10 schema bump with its own gate and migration integ -- #2449, whose body's stated consequence this change corrects (nothing re-leaks; a specific deploy shape is REFUSED instead). - The nine remaining confirmation prompts now REFUSE a non-interactive stdin instead of hanging forever, and all nine share ONE guarded helper (issue #2275) --
src/cli/commands/confirm-prompt.ts(the shared helper),src/cli/commands/{rollback,state,orphan,import,export,drift,retire-cfn-stack,state-migrate}.ts(folded),docs/cli-reference.md,.claude/rules/cli-internals.md, plustests/unit/cli/{non-interactive-confirm-guards,readline-prompt-population}.test.ts(new), a rewrittentests/unit/cli/commands/confirm-prompt.test.ts, and new cases intests/unit/cli/{state-orphan,orphan,import,state-migrate,retire-cfn-stack,export-nested-loop,drift-json-stream,state-refresh-observed}.test.ts.rl.questionnever settles once stdin is at EOF, and EOF delivers no signal, so a command that prompted without a non-TTY guard parked FOREVER in CI rather than failing -- burning the job's whole timeout budget. Issue #2259 closed that for the destroy prompts; these nine were the rest of the class:cdkd rollback,cdkd state orphan,cdkd state refresh-observed,cdkd orphan,cdkd import,cdkd export(three call sites through one helper),cdkd drift --accept/--revert(two), the CloudFormation stack retirement reached fromcdkd import --migrate-from-cloudformation/cdkd migrate --retire-cfn-stack, andcdkd state migrate. Each now throwsCdkdErrorwith the codeNON_INTERACTIVE_CONFIRMand exits 1, with a message naming the command AND the flag that avoids the prompt (--forceonrollback;-y/--yeseverywhere, plus-f/--forceon the two orphan commands). REFUSE, never auto-confirm, uniformly: the issue body worried about "deciding refuse-vs-auto-confirm for the read-only commands", and there are none -- 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 stack retirement, a state-bucket migration).deploy.ts's asset-storage auto-create stays the one deliberate exception. The DUPLICATION was the root cause, so the fix removes it rather than adding a tenth copy of the guard: six of the nine helpers were byte-identical,drift's differed only by taking an output stream, androllback/state orphandiffered only in prompt suffix; issue #2259 guarded ONE copy and nine survived. All nine now callconfirmOrRefuse(prompt, { refusal, suffix?, output? }), which carries the guard BEFOREreadline.createInterface. Its position is correct for free -- every call site sits inside its command's own--yes/--forceshort-circuit, so a flagged run never consults stdin at all. No user-visible output changed: each site keeps its exact prompt string (thesuffixparameter preserves(y/N):onrollback/state orphanagainst[y/N]on the other seven) and supplies its own refusal message; the two answer-parsing spellings (/^y(es)?$/ion seven sites,t === 'y' || t === 'yes'on two) were equivalent and converged on the regex.promptYesNostays in the same module as the deliberate default-YES, UNGUARDED carve-out -- its only caller short-circuits on a non-TTY before reaching it.destroy-runner.tsandstate.ts'sstate destroy --allprompt deliberately keep their own inline guards (a default-YES/default-NO pair, and an abortsignalfor the #2117 Ctrl-C handling; foldingdestroy-runner.tswould also drag a pure refactor into theinteg-destroy/integ-broadgate scopes).tests/unit/cli/readline-prompt-population.test.tsis what stops copy #10: everyreadline.createInterfaceinsrc/must be listed there with a reason, counts included, so a second prompt added inside an already-listed file is visible too -- the exact blind spot that letstate.tshold one guarded and two unguarded interfaces at once. Measured live against the built CLI:cdkd state orphan <stack> < /dev/nullexited 1 in 6 s naming-y / --yesand-f / --forcewith the state record untouched (pre-fix: hangs indefinitely), while--forcestill bypassed the prompt and removed the record. A four-reviewer round (spec / code / test / security) then found one real resource leak and three coverage gaps, all fixed here. The LEAK:retire-cfn-stack.ts's confirmation gate drains its transientcdkd-migrate-tmp/child-template uploads in the DECLINE branch, and the new guard THROWS past it -- past the recursive walk'scatch(already left) and past the post-UpdateStackfinally(still below) -- so a non-TTYcdkd import --migrate-from-cloudformationon a nested stack whose children exceed the 51,200-byte inline limit left those bodies in the state bucket with no command that ever reaps them. Fixed by hanging ONE drain off afinallykeyed on whether the operator proceeded, so the decline and the refusal share it rather than the refusal getting a second copy of the loop -- and the four drain sites in that file (recursivecatch, the gate, the no-modification fast path, the post-UpdateStackfinally) now share onedrainTemplateUploadshelper, since four copies of a loop is how the gate's exit came to have none. Its refusal message also gained the two things it lacked: it is the only site reachable from TWO commands, so it now NAMES both (cdkd import --migrate-from-cloudformationandcdkd migrate --retire-cfn-stack), and it is the only one of the nine that fires AFTER its command's state write, so it says cdkd state has already been written -- a refusal there leaves cdkd claiming the resources while the CloudFormation stack is still live.docs/cli-reference.md's claim that "nothing is written, deleted or locked on the refusing path" was measured and WRONG on both counts: a lock IS held at the prompt on four commands (orphan.ts,import.ts,export.ts,rollback.ts-- every one releasing in afinally, so nothing leaks), and the retirement writes before its prompt; the corrected text states the guarantee that IS true, that no partial mutation survives the refusal.state.ts's two prompts now route theirtargetListthroughdisplaySafe(..., { asciiOnly: true }), matching what the lock error sixty lines above already does to the SAME S3-key-segment values (issue #2170 round 4) -- a forged line inside a CONFIRMATION is worse than one inside an error, since it is the sentence the operator answersyto. Coverage:rollback.ts's prompt had NO end-to-end routing case at all (both rollback suites hardcodeforce: true, so the guard could have been deleted and both stayed green),drift.ts's--revertcall site had none where--acceptdid, andexport.ts's two in-exportCommandprompts were reported unclosable becauseexportCommandis not exported --createExportCommand()is, so a newtests/unit/cli/export-non-interactive-confirm.test.tsdrives both through argv. The population fence's regex was widened from an assignment-shaped pattern to a call-shaped one with a comment-line exclusion, catching five more spellings a probe confirmed the old one missed (rl = ...re-assignment,return ...,this.rl = ...,readline.promises.createInterface(, a ternary arm and an object-literal value) at identical counts (19 pre-fix / 11 post-fix), and its comment now states the one hole it genuinely has -- an ALIASED import puts no occurrence ofcreateInterfaceat the call site and evades any needle spelled on that name. ThesetStdinIsTtystub that had been copy-pasted into ELEVEN suites is now one sharedtests/stdin-tty.ts, whose restore DELETES the property rather than definingundefinedonto it (measured:'isTTY' in process.stdinisfalsewith stdin at/dev/null, so writingundefinedback CREATED an own property that was genuinely absent). Deliberately NOT changed:cdkd events pruneis a TENTH mutating prompt and already carries its own non-TTY guard, so it does not hang -- but it refuses with alogger.infoand exit 0, which a CI job branching on exit 1 cannot see. Aligning it is a breaking exit-code change on a command neither issue covers, so the docs now name it as the one exception and state its actual behaviour, and the alignment is filed as #2454. cdkd state listreserves stdout for its payload UNCONDITIONALLY (issue #2435) --src/cli/commands/state.ts,docs/cli-reference.md,.claude/rules/cli-internals.md, plus new flagless cases intests/unit/cli/state-json-stream.test.ts.state.tscallsreserveStdoutForPayload()four times and every one sat underif (options.json), which is why issue #2410'sgit grepread the file as covered and its body excluded this site.stateListCommand's DEFAULT mode writes oneStack (region)reference per line -- the same record-set shape ascdkd list's default mode, which #2410 had just made unconditional -- so on a flagless run the logger's prose shared the stream with the references and awhile read -r refconsumer read log lines as stack names. Measured on the pre-fix build:cdkd state list --state-bucket <nonexistent> --verboseput 1,094 bytes of DEBUG stack trace on stdout; after the fix the same command puts 0 bytes there and 1,357 on stderr, and a--verboserun against a real bucket leaves stdout the 30-byte reference line alone. THE DISCRIMINATOR IS THE OUTPUT'S SHAPE, NOT THE FLAG, and it is written into the code comment rather than only into the issue: a line-oriented RECORD SET is a payload; a formatted human VIEW (aligned columns, a rendered tree, a metadata block) is not. Sostate resources(three columns padded to data-derived widths),state show(renderTreeWithChildren/renderStateBlockblocks) andstate info(a metadata block) keep their--jsongate deliberately, and a case in the suite fences that -- without it, "make everystatereservation unconditional" would have passed.state list --long/--treewithout--jsonARE formatted views and are swept along, because the reservation is taken before the mode is known; that is deliberate and costs nothing (both still write their view to stdout, so only interleaved prose moves to stderr) and the alternative -- a mode-aware condition -- is the flag-shaped gating the issue exists to remove.
Recently Implemented (2026-09-02):
cdkd synth/cdkd list/cdkd local invoke/cdkd local invoke-agentcorenow reserve stdout for their payload UNCONDITIONALLY, closing the flagless half of the issue-#2230 class (issue #2410) --src/cli/commands/{synth,list,local-invoke,local-invoke-agentcore}.ts,src/utils/logger.ts(doc comment only),docs/cli-reference.md,docs/local-emulation.md,tests/integration/local-invoke-{container,layers}/verify.sh(comments only), plustests/unit/cli/{synth-stdout-stream,local-invoke-stdout-stream,local-invoke-agentcore-stdout-stream}.test.ts(new) and new cases intests/unit/cli/list-json-stream.test.ts. Issue #2280 keyed the reservation onoptions.json, but a--jsonflag was never what makes a stream a payload stream -- it picks the ENCODING. These four write a machine-consumable document to stdout with NO flag:synththe CloudFormation template,lista listing in EVERY mode (one display id per line by default, YAML under--long/--show-dependencies, JSON under--json),local invokethe function response,local invoke-agentcorethe agent response across four terminal emitters (emitResult/emitMcpResult/emitA2aResult/emitWsResult, the last writing only the stream terminator) plus the SSE /--wschunk sinks. Each now callsreserveStdoutForPayload()at command ENTRY, before the synth whoseapp-executor.tsre-emits the CDK app's stderr at INFO. The DEFAULT human output contract therefore moves, deliberately and per command: onsynththe accepted consequence is that a MULTI-stack app writes nothing at all to stdout (the template is emitted only for a single stack) and the wholeSynthesis complete!summary block goes to stderr -- stdout onsynthis the template or it is empty, matchingcdk synth. Lines are MOVED, never suppressed. Explicit non-goals, so a reader does not read them as gaps:cdkd deployand all six long-runninglocalservers (start-api,run-task,start-service,start-agentcore,start-alb,start-cloudfront) keep their human stdout (banner, route table, prefixed container logs);diff(demotes towarn),events,driftand the fourstate {list,resources,show,info}payload writes were already correct under--json-- and that qualifier is load-bearing, because all fourstatereservations are--json-gated, socdkd state list's DEFAULT one-ref-per-line mode is an uncovered site of this same root cause, filed as #2435 and taken by the next lane rather than deferred.ConsoleLogger.emit'srunStackBufferedshort-circuit stays a documented latent gap -- none of the four runs work inside a buffer (deploy.ts'srunStackBufferedcall is the only opener; the bare line number this entry used to carry was removed from the source comment in the same commit, for the reason that it goes stale with nothing noticing) -- with its JSDoc updated to say the trigger condition is now "ANY reserving command", not "a--jsoncommand". Live A/B ontests/integration/basic, the repo's minimal fixture (one S3 bucket), running this branch's binary against the same binary rebuilt with all four reservations disabled:cdkd list --long --verboseput 15 prose lines on stdout above the YAML (20 lines / 1678 B) and theyamlparser rejected the capture withImplicit keys need to be on a single line at line 1, while with the reservation stdout is the 5-line / 119 B payload alone and PARSES, the 15 lines / 1559 B having moved to stderr;cdkd synth --verbosewent from 208 lines / 7496 B on stdout with 0 B on stderr to 183 lines of template on stdout with all 25 prose lines / 1 KB on stderr.cdkd list --long --jsonandcdkd list --show-dependencieslikewise parse off stdout after the change. Mutation-probed as one unit, both directions, and the two red sets UNION to the whole suite: across the 28 cases in the four suites, disabling all four reservations reds 24, and the complementary probe -- rewriting everyprocess.stdout.writein the four commands toprocess.stderr.write-- reds 26. The two survivor sets are disjoint by construction (the 4 that survive the first all assert the payload IS on stdout while reserved, which the second reds; the 2 that survive the second assert stdout is EMPTY, which the first reds), so the union is 28 and no case is inert. The per-fix probes: reverting BOTHpromptOnStdoutsites reds 2 of 15 and either site alone reds 1 -- before the review round the same probe read 1 of 11, because only the plain--wssite was driven and the--watchone was reverted-and-green; over-tightening the SIBLING half of that split gate (frameSource = promptOnStdout ? ...) went from 0 of 11, i.e. wholly unfenced, to 2 of 15; and dropping thespawnForegroundfd-1 redirect reds 1 of 8, as does making it unconditional. Three defects in the OUTPUT-ROUTING class surfaced during this work and are filed rather than fixed here, each a distinct root cause (the lane filed two more outside that class -- go-to-k/cdkd#2435 and go-to-k/cdkd#2440):streamLogspipes the CONTAINER's stdout into ours on the twolocalcommands, which the reservation cannot reach (#2419; measured against a realpublic.ecr.aws/lambda/nodejs:20RIE container, whoseSTART/END/REPORTlines AND bothconsole.logandconsole.errorland on container STDOUT while only therapidinternals go to stderr),toYamlleft YAML indicator characters unquoted socdkd synthemitted a bare- *that a parser rejects (#2421) -- so this PR claimed only that nothing but the template reaches stdout, never that it parses; #2421 is FIXED as of the entry above, and the pipe works now because both halves landed -- and, found by the code-review round, cdk-local ships a SECONDConsoleLoggerwith its own module state and no reservation hook (CdkLocalEmbedConfigcarries no logger field), so thesrc/local/*.tsshims re-exporting itsbuildContainerImage/buildAgentCoreCodeImage/downloadAndExtractS3Bundlestill printBuilding container image (platform=...)on stdout for a container-image Lambda (#2429). Because of the two that remain -- the container pipe and cdk-local's logger; the foregrounddocker pullone is closed by this PR, so the lists say TWO and name the closed third so it is not re-filed -- the docs and both command comments state explicitly thatcdkd local invokestdout is NOT yet payload-only and prescribe| tail -1; the unconditional-reservation claim is scoped to cdkd's OWN logger. The 4-reviewer round then found two more stdout writers on those same commands and BOTH are fixed here rather than documented as residuals, because each is a diagnostic stream rather than an ambiguous one:spawnStreaming(src/utils/docker-cmd.ts) mirrors a child process's stdout live wheneverstreamLiveis on (which defaults to--verbose), andsrc/local/ecr-puller.ts'sdocker loginanddocker image inspectboth reach it, socdkd local invoke Stack/ImageFn --no-pull --verbose > out.jsonwrote a multi-hundred-line inspect array -- the image's baked-inConfig.Envincluded -- into the payload; it now JOINS the logger on stderr while a reservation is held, fixed in the shared helper rather than at the two call sites so the nextrunDockerStreamingcaller cannot rediscover it, and byte-identical for any command that reserves nothing. Round 2 then found the SIBLING helper leaking the same class more reachably:spawnForegroundpassesstdio: 'inherit', andecr-puller.tsrunsrunDockerForeground(['pull', ...])UNCONDITIONALLY, socdkd local invoke Stack/ImageFn > out.jsonputdocker pullprogress in the payload with no--verboseand no flag at all; under a reservation its fd 1 is now redirected to fd 2 (a descriptor rather than a pipe, which is what keeps docker's progress bars animating -- though only while stderr is itself a terminal; under2> filethey degrade to plain lines, the correct trade against corrupting the payload).streamLogsindocker-runner.tsis the one member of that class deliberately left alone -- a CONTAINER's stdout may legitimately be what the user wants on that stream, which needs a per-caller contract decision and a real-Docker round (#2419) -- andisStdoutReservedForPayload's JSDoc now says so, rather than leaving the class reading as closed. And the--wsREPL prompt was gated onprocess.stdin.isTTYalone while writing>to STDOUT, socdkd local invoke-agentcore Agent --ws > frames.txtfrom a terminal appended a prompt after every frame and the| tail -1these very docs prescribe returned>-- the workaround broken by the thing it works around; the two questions are now separate (interactivestill drives reading stdin lines, the newpromptOnStdoutadditionally requiresprocess.stdout.isTTY), so a redirected stdout keeps the raw WS-protocol-faithful shape while a terminal stdin still gets its REPL.A pre-flight safety guard that could NOT reach a verdict now leaves a DURABLE trace, not just a
logger.warn(issue #2301 item 3) --src/types/resource.ts(newIndeterminateGuard, plus an optionalindeterminateGuardsfield on BOTH arms ofResourceDeleteResult),src/types/deployment-events.ts(newRESOURCE_GUARD_INDETERMINATEevent type + aguardfield;reasonwidened pastRESOURCE_SKIPPED),src/deployment/delete-outcome.ts(withIndeterminateGuard/deleteIndeterminateGuards),src/provisioning/cloud-control-provider.ts(confirmDeleteTargetIdentityreturns its verdict;CC_DELETE_REGION_IDENTITY_GUARD),src/cli/commands/destroy-runner.ts(emission +DestroyRunnerResult.guardIndeterminateCount+ the summary suffix and aggregate warning),src/cli/commands/events.ts(yellow arm +guard=column),src/index.ts,docs/deployment-events.md,.claude/rules/layout-deployment.md, plustests/unit/deployment/delete-outcome-indeterminate-guards.test.ts,tests/unit/cli/destroy-runner-guard-indeterminate.test.ts,tests/unit/cli/events-guard-indeterminate-render.test.ts(all new), 8 cases appended totests/unit/provisioning/cloud-control-s3-delete-identity-2283.test.ts, and a newPhase 0c-IDarm intests/integration/s3-lifecycle/verify.sh. Issue #2283's bucket-identity probe has three outcomes, and the third -- the probe CANNOT answer -- proceeds by design (refusing would strand a least-privilege destroy). It used to proceed leaving nothing but console output, so after the run a destroy that had NOT confirmed its target was indistinguishable from one that had -- while the motivating attack is exactly ans3:GetBucketLocationDenyin the target's own bucket policy, settable by anyone holdings3:PutBucketPolicyon it. Two design calls are recorded rather than implied. The verdict rides on the EXISTING'deleted'arm as an optional field rather than as a thirdoutcomemember, because a suppressed guard is orthogonal to whether the resource was addressed and becauseundefinedalready means'deleted'for the ~80 providers that returnvoid-- so every existing consumer reads "not'skipped'" as deleted and a new member would need per-site handling. And the event is emitted IN ADDITION toRESOURCE_SUCCEEDED, not instead of it (the shape issue #1819's partial-UPDATE skip already established): instead-of would leave theRESOURCE_STARTEDrow with no terminal partner and would contradictRUN_FINISHED'scounts.deleted, which this deliberately does not move. The counter likewise never touchesdeletedCount/skippedCount/errorCount, never forces state preservation and never changes the exit code -- an observability addition must not turn a completed destroy into an exit 2. A thirdreasontext was also separated out: the SDK-region-chain-rejected arm falls THROUGH into the no-region warn, so a client that was asked and errored used to be reported as one that was never asked; console and durable record now say the same thing. The new integ arm plants a bucket, deniess3:GetBucketLocationon it, proves the deny took effect, then asserts the delete PROCEEDS and that the event lands in thedeployments/*.jsonlOBJECT (not in console text). Its stack holds TWO cc-api-routed buckets with only one denied, and the assertion is the guard rows' exact MEMBERSHIP rather than their count: the contract is "a row for the resource whose probe was denied, and for no other", and a one-resource stack yields exactly one row whether the event is conditional on the verdict or emitted on every delete -- it would have read as fenced while discriminating nothing. The second bucket is the in-run control (same command, same route, same guard, answering probe), and it covers a producer-side failure the runner unit tests structurally cannot see, since they feed the runner its delete results directly. That the delete still succeeds under the deny is a claim about AWS and was measured, not assumed:cloudformation describe-type --type-name AWS::S3::Bucket(us-east-1, 2026-09-02) listss3:GetBucketLocationin none of the five handlers, anddeleteneeds onlys3:DeleteBucket+s3:ListBucket. Four mutation probes were run against the SHIPPING tree (not carried forward from an earlier revision; the later fixture-only edits and the rebase onto issue #2430 touch nosrc/file and no unit test the probes read, so the four results still hold) and all four went red (drop the summary suffix: 2/10 failed; makewithIndeterminateGuarda no-op: 7/49; remove theevents.tscolorize arm andguard=column: 2/3; delete the destroy runner's emission loop: 8/10). A four-reviewer round (spec / code / test / security) then found one BLOCKER, confirmed independently by two of them: thereasoninterpolated AWS's own message, and S3 words theAccessDeniedfor this exact population asUser: arn:aws:sts::<account>:assumed-role/<role>/<session> is not authorized to perform: .... Pre-change that text was an ephemerallogger.warn; persisting it would have written the destroying principal's account id, role and session name into a durable artifactcdkd destroydoes not sweep -- at a moment the ATTACKER picks, since they set the deny policy. Fixed with issue #2302'sdescribeAwsFailuresplit (the error CLASS into the persistedreasonand the warn, AWS's wording atdebug), which is the same answer the SDK-routed twin of this guard already reached ins3-bucket-provider.ts'sprobeFailedCause. The fence asserts the ABSENCE of each identity fragment rather than only the expected string, and finding it required making the test double AWS-authored:wireBucketLocationErrorbuilt a bareError, anddescribeAwsFailurekeys the redaction on the$metadata/$faultmarkers every@aws-sdk/*error carries, so the unrealistic double made a correct fix look like a product bug. The same round also gated the aggregate warning'scdkd eventspointer on a recorder actually existing (cdkd state destroythreads none, so it was directing that caller to an empty command), switchedwithIndeterminateGuard's field-by-field rebuild to a spread so a future arm field cannot be dropped, corrected a doc claim that the row can accompanyRESOURCE_FAILED(it cannot -- a guard rides the delete's RETURN value, so a throwing delete carries none), and added the missing coverage: the partially-destroyed summary arm, and live positive evidence that the control bucket's probe actually RAN rather than being skipped. Both real-AWS runs are on record indocs/_generated/integ-last-run.tsv:s3-lifecycle(2026-09-11T17:45:21Z, PASS, 507s), which is the new arm plus the phase 0c arms it re-runs, andlambda(2026-09-02T17:40:08Z, PASS, 190s) for theinteg-broadgate thatdestroy-runner.tsactivates -- that row was re-taken by the issue #2274 lane in the same session, which is the ledger's one-row-per-test invariant doing its job rather than a second run of this change. Both were clean: 7 deleted on the s3-lifecycle row, 9 on the lambda one, 0 errors and no orphans on either. The live run also caught a defect no unit test had: the redacted cause and the sentence after it producedfor AWS's own message.. S3 bucket names, because the helper's two summary shapes disagree about trailing punctuation. Deliberately NOT included: the deploy engine's fiveprovider.deletesites and the rollback executor's arms still discard the verdict -- filed as #2422. The same audit also found thatcdkd state destroythreads no event recorder at ALL, so both copies of theRESOURCE_SKIPPEDdoc claiming it is "Emitted bycdkd destroy/cdkd state destroy" were FALSE; the prose is corrected here (main must not carry a claim its shipped code contradicts) and the missing capability is filed as #2423.The last two readback shapes that kept a resolved secret in plaintext are closed (issue #2012) --
src/deployment/secret-redaction.ts,docs/cli-reference.md,.claude/rules/layout-deployment-secrets.md, plustests/unit/deployment/secret-redaction-derived-needles.test.ts(new), flipped residual assertions intests/unit/cli/state-refresh-observed.test.ts/secret-redaction-array-identity.test.ts/secret-redaction-anchor-pairing.test.ts, and two new phases plus two env vars intests/integration/secrets-dynamic-ref/(verify.sh,lib/secrets-dynamic-ref-stack.ts,README.md). THE PROBLEM. On the readback paths the secrets map is EMPTY by construction --cdkd state refresh-observedresolves nothing, and a plaincdkd deployreaches the persist choke point the same way for an UNCHANGED resource -- so the value scan has no needles and POSITION is the only mechanism. Two shapes have no position to argue from and kept their DECRYPTED value instate.json: an UNPAIRED array element beside a paired one, and an observed KEY the source does not carry. #2012: DERIVED NEEDLES. The two surviving rows — an UNPAIRED array element beside a paired one, and an observed KEY the source does not carry — are exactly the two positions whereredactByPathALREADY delegates to the value scan. They never lacked a needle in principle: the same plaintext almost always sits at a position the pass DOES certify, and certifying it IS the assertion that AWS's value there is that expression's resolved form.deriveReadbackNeedlesruns the refusal walk once in LEARN mode over the same two bags — the SAME function, so the two passes can never disagree about what "certified" means — collects the(plaintext -> expression)pairs, and hands them toredactByPathas the secrets map. Nothing is resolved and nothing is fetched. The issue's own proposed direction was to resolve the record's expressions, which would have madecdkd state refresh-observedand every deploy's observed capture FETCH secrets: a new IAM requirement, a new failure mode, and a new place plaintext lives in memory. AWS had already handed us the plaintext, in the very bag being redacted. FIVE BOUNDS ON WHAT MAY BECOME A NEEDLE, because a needle is a rewrite with a blast radius rather than a per-leaf decision. Only the readback-projected rules (a cross-generation or template source describes a different generation of the resource). Only when the map is EMPTY —crossStackAssociations/nestedStackParameterExpressionsareWeakMaps keyed by the RecordedSecretValues INSTANCE, so substituting a derived Map would silently lose every association the pass recorded; with an empty map there are none to lose. Only from an expression naming a SECRET-BEARING service and not PROVEN PUBLIC (expressionMaySeedANeedle):isSingleDynamicReferenceTokenaccepts any{{resolve:<anything>}}spelling and the resolver's unsupported-service arm WARNS and returns the LITERAL, so{{resolve:notaservice:/x}}'s readback is ordinary data —cdkd driftpins the same rule for its own registration path, and without this the two commands answer differently about one expression. Only at or aboveMIN_NEEDLE_LENGTH, since the whole-value arm matches at ANY length and a two-character needle would rewrite every leaf equal to it. And a plaintext learned twice under DIFFERENT expressions is POISONED rather than collapsed — the issue #1910 wrong-reference class, on a pathcdkd drift --revertandresolveReplayPropsre-resolve against the live resource. A MIXED leaf yields a needle too, which matters because this module calls that shape DOMINANT for CDK (Fn::JoinaroundsecretValueFromJson). Guarded to EXACTLY ONE span whose literal prefix and suffix both bracket the bag, anchored at the ENDS rather than searched (a secret repeating the suffix —abc@hinsidepostgres://u:abc@h@h— still slices correctly). The pair maps the extracted plaintext to the TOKEN, never to the whole leaf: mapping it to the leaf writes a whole connection string over a field AWS reported as a bare password, which is fabricated baseline content that--revertthen pushes. That was caught by its own test before review, not reasoned about. ONE ANCHOR-GATE RELAXATION, in the direction that never guarded anything.anchorsCorroboratePairingcompared object key COUNTS, so an AWS-addedArn/LastModifiedinside an unkeyed-array element refused the whole element and left the secret in it as plaintext. It now requires CONTAINMENT — every SOURCE key present and corroborating — while a source key MISSING from the bag still refuses, which is the fabrication direction that stopped[{Value:'x'}]becoming[{Name:'db', Value:<expr>}]. The corroboration argument is unchanged: an extra bag key is not an anchor (anchors are the SOURCE's non-reference positions) and not part ofanchorSignature(computed on SOURCE elements), so neither rule 1 nor rule 3 moves. THE TWO PLACES THIS COULD HAVE BECOME A DISCLOSURE, both invisible in the diff. First, HOW THE SCAN COMPOSES WITH THE POSITION PASSES -- three attempts, each wrong in its own direction, each measured by a review round. Scanning FIRST (the original revision, needles fed intoredactByPath) lets the scan rewrite a frame LITERAL that happens to embed a learned plaintext -- a coinciding anchor, exactly this issue's population -- after whichunkeyedArrayPairsByAnchors, re-run against the SCANNED bag, no longer matches, the WHOLE array refuses, and a sibling MIXED leaf the SHIPPED code redacts by POSITION persists in full plaintext (UNDER-redaction). Scanning LAST over their OUTPUT is the mirror: the scan then sees leaves whose content came from the SOURCE, and a needle in such a leaf's literal frame turnspostgres://appuser:{{resolve:secretsmanager:...}}@h/dbintopostgres://{{resolve:ssm:/app/db-user}}:{{...}}@h/db-- a FABRICATED baseline thatcdkd drift --revertre-resolves and pushes to the live resource. Merging on "the passes did not CHANGE this leaf" (refused === bag) STILL fabricates, because value equality cannot tell an UNDECIDED position from one decided IN FAVOUR of the value already there -- thessm-secure:unsupported-service arm makes AWS echo the token literally, so source EQUALS bag. What ships asks the pass itself:refuseUncertifiedReadbackPositionsruns a second time inmarkmode, returning a POSITION_DECIDED sentinel wherever it decides, andpreferPositionDecisionstakes the scan's answer only at a STRING leaf that mark tree does not claim. Re-running the REAL pass rather than mirroring its navigation keeps the pairing rules single-sourced. EVIDENCE STRENGTH BOUNDS THE BLAST RADIUS, not admission.secretsmanager:andssm-secure:say what they are, and a RECORDED verdict is a realGetParameteranswer; a bare{{resolve:ssm:token with no verdict is accepted on the #1901 premise (a publicStringis persisted RESOLVED, so a token SURVIVING in a state bag is aSecureString) -- sound for the leaf ITSELF, and not a licence to rewrite every other leaf that merely CONTAINS the value. Measured: a baressmtoken whose value isproductionturnedmy-production-logsintomy-{{resolve:ssm:/app/env}}-logs, which is the failureexpressionMaySeedANeedle's own doc names -- and if the parameter is in fact public,cdkd drift --revertre-resolves that and renames the live bucket the day the parameter changes. So an INFERRED needle rewrites a leaf only WHOLE, while a CERTAIN one keeps the substring arm -- and CERTAIN means SPELLING alone. A RECORDEDSecureStringverdict deliberately does NOT promote a baressmtoken, becauserecordedSecretExpressionsis keyed on the bare expression and lives for the whole process: on acdkd deploy --alla verdict pinned where the parameter is aSecureStringis inherited where it is a plainString, and theskipDynamicReferencesdiff path skips the lookup on atrueverdict so the second region never retracts it. That is the same region blindness this PR withdrew issue #2036's store for. Inheriting a secret-direction verdict is safe for ADMISSION (it can only over-redact the leaf itself) and unsafe for BLAST RADIUS. The spelled-secret prefixes are an explicit ALLOWLIST rather than the admission list minus one entry, so the NEXT prefix added defaults to the narrower radius instead of the wider one. Issue #2012's own two rows are whole-value positions, so the closure is untouched; the security review that found this proposed dropping baressmfrom the seeding set instead, which would have failed exactly oncdkd state refresh-observed-- the process the issue is reported against. Its object arm is guarded onhasPlainPrototypebecauseisPlainObjectadmits aDateandObject.entries(new Date())is[]-- without it the merge itself rebuilt a readbackLastModifiedas{}, newly extending issue #2427's flattening to the empty-map path. Second, the MAP: the derived map goes to neither position pass, and in particular NOT torefuseUncertifiedReadbackPositions.mixedLeafMayCarryPublicReferenceSPLITS on whether a map exists, and a derived map satisfiessize > 0while proving nothing was resolved — handing it over would read every{{resolve:ssm:mixed leaf as public and persist the decryptedSecureString, i.e. the regression thesecrets-dynamic-refinteg caught before #1926 shipped, arriving through a new door. Pinned by a dedicated case. FIVE PINNED RESIDUAL ASSERTIONS FLIPPED ON PURPOSE, each with its old value named in place: the threeRESIDUAL (issue #2012)cases (two instate-refresh-observed.test.ts, one insecret-redaction-array-identity.test.ts) and the two anchor-gate key-count cases. A sixth kept its assertion and was re-documented — an AWS-added element holding an UNRELATED literal is still untouched, which is the discrimination that separates a value-keyed needle from the blanket source-subtree rewrite the #1915 fences rejected. THE INTEG FIXTURE'S PUBLIC-LEAF ARMS RESTED ON A FALSE PREMISE and were rebuilt (tests/integration/secrets-dynamic-ref/verify.sh, its README). Phase 1f asserted thatcdkd state refresh-observedleavesPUBLIC_URLas the EXPRESSION; that arm failed against fixed code AND againstmainon the pre-merge run (rc=1 at exactly that line), and Phase 1g's CONTROL 3 -- claimed as issue-#2036 coverage -- passed identically onmain, i.e. discriminated nothing. Both had the same upstream cause:propertiesholds a public ssmStringRESOLVED by construction (#1901), so the POSITION SOURCE carries no reference and the mixed-leaf arm is never consulted. A new Phase 1f3 stamps thecdkd importwarn-path shape intoproperties(the same S3 write/restore idiom Phases 1f / 1f2 already use) and pins the needle's BLAST RADIUS -- oncePUBLIC_URL's source carries the reference, the needle learned from it also rewritesSSM_VALUE, which holds the same resolved value of the same parameter. That is the arm that DISCRIMINATES;mainleavesSSM_VALUEresolved. The residual assertion beside it is labelled a PIN rather than coverage, and Phase 1g's CONTROL 3 keeps its assertion and loses its claim. ISSUE #2036 WAS IN THIS LANE AND WAS WITHDRAWN, which is worth recording because the withdrawal is the finding. Its over-redaction (a genuinely PUBLIC ssm reference inside a MIXED leaf, refused on the empty-map paths) was to be closed by a POSITIVEprovenPublicExpressionsstore, written from the resolver's ownpinSecretVerdictretraction so no lookup was added. PR #2415's security review measured the cost: the store is keyed on the bare expression and lives for the whole process, so on acdkd deploy --allspanning regions a verdict recorded where the parameter is a plainStringun-redacts aSecureStringof the same name in another region --Server=db;Password={{resolve:ssm:/app/token}};becameServer=db;Password=<decrypted>;. That is the UN-redacting direction, i.e. worse than the over-redaction it fixes, and it falsified the store's own claim that a cross-region verdict "can at worst store a public reference as an expression". The whole half was reverted rather than patched: keying the verdict by SCOPE (region + account) has to happen at the READ side, which threads a parameter throughredactSecretsForStateand its callers -- a cross-cutting change that belongs in its own PR with its own review. #2036 stays open, with the constraint recorded on the issue and inmixedLeafMayCarryPublicReference's own doc. Issue #2179's verification row, answered rather than deferred: that issue asks whoever closes #2012 to check the destroy-events sink, on the grounds that a state record holding plaintext becomes durably published the moment a delete fails. Confirmed on this tree —destroy-runner.ts's twoRESOURCE_FAILEDrecords still persist an UNMASKED message, and the coupling is bounded by what a delete actually sends:provider.deletereceivesresource.propertiesand neverobservedProperties(which is read only bycountProtectedResources, for two protection flags).propertiesis redacted against the TEMPLATE source, soisReadbackProjectedFromStateis false for it and #2012's rows never applied there. #2179 stays open on its own terms (the sink has no masker); no plaintext reaches it through #2012's residuals, and after this change those residuals are closed inobservedPropertiestoo.lock.json's noncurrent versions are now purged wherever cdkd deletes the lock, closing issue #2346 site 5 and correcting the docs that argued against it --src/state/lock-manager.ts,src/state/s3-noncurrent-version-purge.ts,docs/state-management.md,tests/unit/state/{lock-noncurrent-version-purge,lock-manager,s3-noncurrent-version-purge}.test.ts,tests/integration/{rollback-command,gc-custom-asset-names}/verify.sh.deleteLockwas a bareDeleteObject, so on the versioned state bucket every priorlock.jsonversion stayed readable: renewal writes one version every two minutes at the default 30-minute TTL,deleteStatenever sweeps the lock key, and nothing purged it -- the chain was monotonic in stacks EVER deployed (452 versions on one measured key, invisible toaws s3 lsand still paged through by everyListObjectVersionsthe other purge sites issue). The sharedpurgeNoncurrentKeyVersionsnow runs at each of the four sites wheredeleteLocklands a delete marker:releaseLock's conditional delete and its unconditional escape hatch (one key-scoped purge in afinallycovers both),forceReleaseLock(cdkd force-unlock, andcdkd state orphan), andacquireLock's expired-lock takeover -- the last after the re-acquisition PUT rather than between the delete and the retry, so the delete-then-reacquire contention window is not widened. The takeover site is also what reclaims a CRASHED run's chain, which no per-process scheme could: the purge is scoped to the KEY, not to versions this process minted.A per-
VersionIddesign was built first and rejected on adversarial review, and the rejection is the interesting half. Threading each PUT'sVersionIdthroughHeldLockand issuing oneDeleteObjectson release costs one call instead of two and one IAM action instead of two -- but every hazard it carried was created by the mechanism: deleting one's own delete marker RESURRECTS a stale lock wheneverDeleteObjectspartially fails (a phantom holder with a liveexpiresAt), a per-id delete has noIsLatestguard so thetry/finallyshape sites 4 and 6 use would fire onreleaseLock's refusal branches and delete our own CURRENT version, a"null"version id under suspended versioning is a legal delete target, and the 1000-entry cap forces re-implementing the shared helper's batching. The shared helper'sIsLatestfilter removes all of them at once -- it can never touch what is current -- which is why the simple uniform form is also the SAFE one, including on the refusal arms where our own lock is still live.The one genuine regression is UX, and it is handled rather than accepted. A release runs at the tail of every mutating command, so inheriting the four secret-bearing sites' WARN would give a principal on the pre-#2340 four-action policy -- who gets a silent clean deploy today -- a warning after every single command, about a heartbeat record with no secret in it. Release-path purge failures go to
debug; the two rare reap paths still warn, which is the cost profiledocs/state-management.mdalready means by "only the cleanup paths that need them". A warn-once-per-process dedupe was rejected for the reason the helper's own JSDoc gives: it needs module-global state, which this repo has been bitten by under--stack-concurrency > 1. Separately, a per-keyNoSuchVersionfromDeleteObjectsnow counts as SUCCESS at EVERY call site: it says the version is already gone, which is the outcome the purge wants, and on the lock key two actors legitimately purge concurrently (a reaper and the waking original owner), so without it the never-throw warning tells a blameless user to grant IAM they already hold.NoSuchKeyis deliberately NOT tolerated -- that says the listing and the delete disagree about the key.Two of the three claims the shipped docs made against this change were already wrong, independently of it.
docs/state-management.mdsaid purging on release "would makes3:ListBucketVersions/s3:DeleteObjectVersionrequired for ordinary use" -- contradicted by the same document 470 lines later ("Without the two grants, nothing fails -- and that is the point to understand.") and by the helper's never-throw contract. And it said "the remedy is a bucket lifecycle rule on noncurrent versions rather than a code change" -- not expressible:getLockKeyyieldscdkd/{stackName}/{region}/lock.json, solock.jsonis a key SUFFIX interleaved withstate.jsonunder one prefix, and S3 lifecycle filters support onlyPrefix/Tag/ObjectSize; the one expressible prefix rule (cdkd/) would expirestate.json's history too, doing in bucket configuration exactly what sites 1-3 are held open to prevent. Only the round-trip cost claim survives, and site 4 already pays it on a strictly broader population --deleteRollbackJournalBestEffortfires from BOTH deploy-success arms, so aListObjectVersionsgoes out on every successful deploy including the common case where no journal ever existed. Sites 1-3 (state.jsonand the v1 -> v2 migration delete) remain deliberately unpurged: those noncurrent versions ARE the recovery capability.The two integ NEGATIVE CONTROLS had to be re-pointed in the same PR, and that is the subtle part.
rollback-commandstep 4b2 andgc-custom-asset-namesPhase 4b both assertedlock.jsonkept at least one noncurrent version "as site 5 intends" -- the control proving the other sites' purges are not purge-everything bugs. Under this change that assertion stops discriminating, and worse, it would keep PASSING on accumulated delete markers while its comment became false: a fixture that is green and meaningless. The scoped-purge control role moves tostate.json, documented in three places as deliberately never purged and therefore the key most likely to break LOUDLY if a future purge over-reaches. Each fixture now ALSO asserts the new invariant on the lock key (after a clean release: exactly one row, the current delete marker, andnoncurrent == 0), so the same two arms fence both directions. A sweep found a THIRD copy the issue had not named:tests/integration/migrate-from-cfn/run.shcarried the same control in prefix form, and re-pointing it exposed that its premise was gone --cdkd importcallssaveStateonce, which is one CURRENT version and zero noncurrent, so after site 5 anoncurrent >= 1floor there could only pass on the previous run's residue. It now asserts NOTHING at that point, and the reasoning is worth keeping: weakening the floor toall >= 1was tried and rejected, becauseassert_state_presenthas already run ahead-objectonstate.jsona few lines above, soall >= 1is IMPLIED by an assertion that already passed -- a floor that cannot be the first thing to fail is not a control. The discriminating assertion moves instead to the first moment the run genuinely guarantees a noncurrent row: a post-destroy check thatstate.jsonkeeps one, which fences sites 1-3's deliberate non-purge directly and which a purge that widened its prefix and lost itsIsLatestfilter would fail.Tests:
tests/unit/state/lock-noncurrent-version-purge.test.ts(35 cases) drives all four call sites through a recording S3 double and asserts the command SEQUENCE, the version ids handed toDeleteObjects(the current delete marker must survive -- taking it would undo the release), the key-scopedPrefixwith a<lock key>.baksibling row in the listing so thewantedmembership filter is actually fenced,ExpectedBucketOwneron every call, thefinallybehaviour on a refused and on a throwing delete, the debug-vs-warn split at BOTH the helper's warning and the wrapper's could-not-start arm, the caller's ownobjectDescription, the legacy region-less key, a current-version BODY (the shape the foreign-lock arm really has, where theIsLatestfilter is all that stands between the purge and someone else's live lock), a region re-resolution when the delete's own resolution failed, a THROWING log sink, and the ABSENCE of any purge on a pre-delete refusal. It closes with an AST fence that parseslock-manager.tsand fails if a member callingthis.deleteLockdoes not also callthis.purgeLockVersions(and vice versa), with the member set pinned as a literal so a query that silently stopped matching cannot satisfy itself. Four cases ins3-noncurrent-version-purge.test.tscover theNoSuchVersioncarve-out, including that a real failure alongside one -- on the same key and on a different key in the same batch -- is still reported, and thatNoSuchKeyis not swallowed.Three things review caught that are worth recording, because each was invisible to the round before it. The purge's never-throw claim was FALSE where it mattered most:
reportis handed to the shared helper as itswarnSINK and the helper calls that sink outside anytry, whilelogger.debugreachesconsole.debugon stdout -- which this repo has measured throwing EPIPE synchronously under--verbose | head. Since every call site is afinally, that throw would have REPLACED theLockErrorthe release was raising;reportis now total. The AST fence attributed calls only throughMethodDeclaration, so a class-property arrow or a constructor callingthis.deleteLockwas invisible -- the fence failed OPEN on exactly the change it exists to catch, and its own guard-the-guard never called the function it was guarding, so deleting the parse-failure throw left the suite green. And the fake S3 client arm added tolock-manager.test.tswas NOT added to its siblinglock-renewal.test.ts, whose ~40 releases were therefore running the purge's error path on every one, green only because release-path failures go todebug. The population of suites that construct a realLockManageris three, and all three now carry the arm.A removed Cognito
Policiessub-key is now ANNOUNCED instead of deploying as a silent no-op (issue #1979) --src/provisioning/providers/cognito-provider.ts,tests/unit/provisioning/cognito-provider.test.ts,tests/integration/cognito/{lib/cognito-stack.ts,verify.sh}. DeletingPolicies.SignInPolicy(orPasswordPolicy, or the wholePoliciescontainer) from a deployed pool's template used to deploy green while AWS kept the old policy:UpdateUserPoolpreserves an omitted sub-key (measured us-east-1 2026-08-19, issue #1968) andtoSdkUserPoolPoliciesforwards only what the template declares, so nothing was sent and nothing changed -- the dangerous direction, since removing a passwordless first-auth factor is a TIGHTENING that never lands. The issue also predicted a permanentcdkd driftdifference; MEASURED live (us-east-1, 2026-09-02, stackCdkd1979LiveVerify) there is none, and the prediction is withdrawn rather than shipped --update()refreshesobservedPropertiesfrom the post-update readback, so the retained sub-key lands in the drift baseline too, the announcing deploy CONVERGES, andcdkd drift/cdkd diff/ the next deploy all report nothing. That makes the warn the ONLY signal an operator ever gets, which is precisely why the silence was the whole defect. The design question the issue deferred -- reset to AWS defaults, or warn -- was settled by a real CloudFormation A/B rather than assumed (us-east-1, 2026-09-02, transcript in thetoSdkUserPoolPoliciesdocstring and the PR body): a CFn stack whose pool declared both sub-policies reached UPDATE_COMPLETE on all three removal edits (SignInPolicy alone, PasswordPolicy alone, the whole container) with every live value intact -- CloudFormation performs the SAME silent no-op, so a cdkd-side reset would be a DIVERGENCE from its stated template compatibility, and the only wrong option under both A/B answers was the silence. What changes is therefore the silence alone:update()warns once per removed sub-key (warnOnUnremovablePoliciesSubKeys), naming the sub-key, stating the live value is unchanged (with both measurements cited), and giving the one remedy that exists on the wire -- declare the sub-key explicitly with the intended (e.g. default) configuration. The removal detection MIRRORS the wire's own truthiness gates (sendsPoliciesSubKey, the config-shape.ts share-the-predicate rule applied to a truthiness gate) so it fires exactly when the wire stops carrying a sub-key it previously carried; pinned by an 11-shape agreement table driven through the PUBLICupdate()(warn iff not sent). The announcement reaches everyupdate()caller truthfully:cdkd deploywarns on the deploy carrying the removal (later deploys are honest NO_CHANGEs, matching CFn's converged stack -- both measured live),cdkd drift --revertreaches it only in one narrow shape -- the revert's desired side starts from the LIVE bag (buildRevertNewProperties) and a record with noobservedPropertiesmerges AWS's untemplated sub-keys back in, so what actually fires is a record WITHobservedPropertiesplus a sub-key added out-of-band after that capture -- and the rollback executor's two arms differ from each other, therevertarm warning when rolling back to a record that lacked the sub-key whilerevert-failed-updatepasses the FAILED attempt's bag and so can name a sub-key that never reached AWS. Each call site's exact condition is documented atwarnOnUnremovablePoliciesSubKeys, because the obvious reading of two of them is wrong. Six new unit cases, mutation-proven two ways against the real provider: deleting the call site reds 4, and anin-based presence predicate (disagreeing with the wire on a declarednull) reds the agreement table by name. The cognito integ gains aPolicyRemovalUserPoolUPDATE arm asserting the announcement (bound to the pool id and to the exact producer wording the unit suite also pins), RETENTION of the live sign-in policy (the CFn-parity pin -- a future reset change fails it and must be deliberate), the still-declaredPasswordPolicykept forwarding, and a fired-call companion (AutoVerifiedAttributesadded in the same update) so retention cannot pass vacuously when no call went out. This is the Cognito instance of the #1225 sub-field-removal class, resolved as announced-parity rather than reset-to-default because that is what CloudFormation measurably does for this type.A retried 500 no longer duplicates an EFS access point, and the
ClientTokenrationale that said the SDK handled it is corrected (issue #2080, Plan A) --src/provisioning/providers/efs-provider.ts,src/provisioning/property-coverage.generated.ts,docs/provider-development.md, plustests/unit/provisioning/efs-access-point-idempotency-token.test.ts(new).CreateAccessPointsent noClientToken, so a 500 whose request had actually SUCCEEDED server-side re-enteredcreate()through the engine's outer retry and minted a SECOND access point with no state entry -- invisible tocdkd destroy, billing until found by hand. The field was not simply absent from the wire: EFS models it with Smithy'sidempotencyTokentrait, so the SDK auto-filled a FRESH uuid per request, which is the regenerated-per-attempt shape #2080 calls worse than no token at all. Measured against@aws-sdk/client-efs3.1018.0 by capturing the serialized body of two identical sends:846a541f-...thend84cafec-..., while a caller-supplied value went out verbatim. That measurement also falsifies theunhandledByDesignrationale this file has carried forClientToken("AWS SDK manages this idempotency token internally ...; no user-supplied value is honored"), which is rewritten in the same change;ClientTokenSTAYS a silent drop, because cdkd now overrides it with its own value and a template-supplied one really would be discarded -- only now that is true by cdkd's construction rather than by a false claim about the SDK. The token comes fromacquireIdempotencyToken(per-process nonce, memoised for one create, released on success) rather than the deterministic sha256-of-immutable-inputs this same provider uses for a file system'sCreationToken. The two are not interchangeable, and the argument deliberately does NOT rest on a retirement window: EFS publishes no retirement period for aCreateAccessPointClientToken(the "one minute" in the EFS User Guide's "Creation token and idempotency" section is about file-system CREATION tokens, and theCreateAccessPointreference describes the repeat unconditionally), so the duration is treated as unknown. The process-scoped derivation is correct under both readings: if the token retires quickly, stability across runs buys nothing while it would put a--replacedelete-then-create inside the replay window of the access point it just deleted; if it instead binds for the access point's lifetime, a deterministic hash is worse still, since any re-create with unchanged inputs would collide with the live access point rather than create one. When EFS refuses the repeat withAccessPointAlreadyExists, the first attempt's access point is live AND orphaned with a failed deploy on top, socreateOrAdoptAccessPointadopts it -- the same shaperoute53-provider.tsuses for the same issue family -- after confirming viaDescribeAccessPointsBOTH that itsClientTokenis the one cdkd minted and that it belongs to the requested file system. The confirmation read itself is retried, on a transient 5xx OR a throttle (isRetryable: (_text, err) => isThrottlingError(err) || isTransientServerError(err)) -- a modeled@throwslist is not exhaustive, and an unmodeled throttle on the read-back strands the same orphan a 5xx would: declining there is the worst decline in the method, because the first attempt's access point is already live, so a blip on the CONFIRMATION would fail the deploy AND leave exactly the orphan the token exists to prevent -- and the outer engine retry cannot cover it, sinceisTransientServerErrorwalks.cause, which holds the 409 conflict rather than the 5xx. AnAccessDeniedis NOT retried: that grant is absent rather than propagating. That retry is interrupt-threaded with a per-WAITstartInterruptWatchdisposed in afinally(never onthis-- the provider is a singleton serving concurrent resources, so a shared watch would let one resource's Ctrl-C abort another's retry);scripts/check-withretry-interrupt.tsenforces this and caught the omission, since an un-threadedwithRetryleaves Ctrl-C dead for the whole backoff schedule and, on the destroy path, keeps issuing writes behind a run the user was told had ended. Threading the interrupt then CREATED a second hazard that two independent reviewers caught:withRetrythrowsInterruptedWaitErrorout of its backoff, which lands in the samecatchas a lookup failure, anddeclinesetscauseto the CreateAccessPoint conflict -- ERASING the interrupt from the chainisInterruptedWaitErrorwalks, sodeploy-enginewould miss it and automatically roll the whole stack back on Ctrl-C, the exact outcomeinterrupt-watch.tsexists to prevent. The catch now rethrows an interrupt first, matchingdynamodb-globaltable-provider.tsandelbv2-provider.ts. The decline is additionallymarkNonRetryabled rather than trusting its wording, because the message interpolates a user-chosen logical id andRETRYABLE_ERROR_MESSAGE_PATTERNSholds whitespace-free entries that such an id could contain. Every way the confirmation can still fail DECLINES: it warns, and rethrows aProvisioningErrornaming the token collision and carrying the AWS error ascause. AWS-authored text is withheld from that thrown message viadescribeAwsFailure(summary names the failure CLASS,detailgoes todebug), for two reasons that happen to coincide: the message reaches the durabledeployments/{runId}.jsonlstore, and the headline population of this arm is a missingelasticfilesystem:DescribeAccessPointswhose AWS wording names the account id, the deploy role and the session; that same wording containsnot authorized to perform, anIAM_PROPAGATION_ERROR_MESSAGE_PATTERNSentry, so interpolating it made a permanently missing grant retry on the dense IAM cadence -- 27 attempts re-issuingCreateAccessPoint. The decline is now terminal, which is correct for an absent grant. Adoption also refuses an access point reported asdeleting/deleted, and the decline path no longer double-wraps (if (error instanceof ProvisioningError) throw error, matchingroute53-provider.tsandfsx-filesystem-provider.ts). The wrap is load-bearing rather than cosmetic --DeployEngine's replacement path classifies withisNameCollisionError, which matches the raw conflict'salready existswording, so a bare rethrow would make the engine read a TOKEN collision as a physical-NAME collision and, under--replace, delete the OLD access point and re-create with the same still-unreleased token. Tests: 30 cases counting RESOURCES rather than calls, with the clock advanced inside the retry's sleep per #2080 acceptance item 3 -- and the fixture FENCES its own precondition (createTimes[1] > createTimes[0]), because ifsetSystemTimeever no-ops both attempts land in the same millisecond, aDate.now()-derived token matches by coincidence, and the probe written to catch that implementation passes. The redaction fixture is SDK-SHAPED ($fault/$metadata), which is load-bearing twice over:describeAwsFailurekeys on exactly those marker fields, so a redaction assertion written against a barenew Error(...)holds vacuously -- it did, until the assertion caught it -- and smithy'sServiceException[Symbol.hasInstance]matches on$fault && $metadata && name === this.namerather than prototype identity, so thename ===fallback besideinstanceofis reached only by a conflict carrying NO marker fields, which is what its own case now supplies. Fifteen mutation probes, all RED against a 30-passing baseline: no token sent 28/30; a clock-derived token regenerated per attempt 26/30; a bare rethrow instead of the decline 13/30; the no-double-wrap guard removed 9/30; adoption not confirmed against the token 4/30; the confirmation retry disabled 2/30; and 1/30 each for the file-system confirmation, the withheld AWS text, the ARN guard, the interrupt rethrow, the throttle clause in the retry predicate,markNonRetryable, thename ===fallback, and thedeletedanderrorlifecycle clauses. A sibling sweep of the CREATE-shaped SDK command constructions undersrc/provisioning/providers/, each resolved to its request model, finds 8 call sites now taking the shared helper (ACM, CloudFront, EFS, Route 53, and four in EC2) plus the two deliberately hash-derived tokens (EFSCreationToken, FSxClientRequestToken); the remaining token-carrying models are non-defects (S3'sTokenonPutBucketReplication/PutObjectLockConfigurationis an Object-Lock confirmation token; the Scheduler / Secrets Manager creates are NAMED, so a replay collides rather than duplicating; andcloudfront-oai-provider.ts/agentcore-runtime-provider.tsderive or pass through a caller reference already). That closes #2080's tier 2; its tier-1 and tier-3 entries -- the creates with no token member at all -- remain open as Plans B and C.The six remaining
--jsonsurfaces now put the payload and nothing else on stdout, closing the issue-#2230 class (issue #2280) --src/cli/commands/state.ts(the fourstate {list,resources,show,info}subcommands),src/cli/commands/list.ts,src/cli/commands/events.ts,src/utils/logger.ts(doc comment only),docs/cli-reference.md, plustests/unit/cli/{state,list,events}-json-stream.test.ts(new). Each command now calls the module-levelreserveStdoutForPayload()the #2230 lane added, once, when--jsonis in effect and BEFORE any collaborator can print -- ahead ofapplyRoleArnIfSet(whoseAssumed role ...INFO line fires on any--role-arn/CDKD_ROLE_ARNrun) and, forcdkd list, ahead of the synth (whoseapp-executor.tsre-emits the CDK app's stderr at INFO on a DEFAULT run -- the worst of the six, corruptingcdkd list --long --json | jqwith no--verboseinvolved). No edits inrole-arn.ts/s3-state-backend.ts/app-executor.tswere needed: their prose flows throughchild()loggers, and the reservation is module-level precisely so those are covered. The per-command suppress-vs-move decision the issue called out is answered MOVE for all six, matchingdrift(cdkd diffkeeps its pre-existing demote-to-warnsuppression; the re-derived population confirmed the other three ofstate.ts's seven un-gatedsetLevel('debug')sites sit on non---jsonsubcommands and are untouched). The three new suites mirrordrift-json-stream.test.ts-- real logger, console spies into an ordered fd-1/fd-2 transcript, asserting BOTH that stdout parses as a single JSON document AND that each human line ARRIVED on stderr -- and were mutation-probed as one unit: disabling all six reservations reds 10 of 13 cases while the three no---jsoncontrols stay green. Live before/after against a stderr-chattering fake CDK app: released 0.284.85cdkd list --long --jsonputs the chatter on stdout andjson.loadrejects it; this branch's binary parses, chatter on stderr. The same defect class on stdout payloads with NO--jsonflag (cdkd synth's template,cdkd list --long's YAML mode,cdkd local invoke's response payload) is deliberately out of scope -- filed with the two regression-fence rows carried from this issue as #2410.AWS SDK calls route through
HTTPS_PROXY/HTTP_PROXY, honouringNO_PROXY— cdkd now works on machines whose only egress is a corporate proxy (issue #2388, PR #2398, external contribution) --src/utils/aws-client-defaults.ts(new),src/utils/proxy-routing-agent.ts(new),src/utils/aws-clients.ts,src/cli/commands/bootstrap.ts,src/cli/config-loader.ts,src/utils/{aws-region-resolver,bucket-region-client,expected-bucket-owner}.ts,src/assets/asset-storage.ts,scripts/check-aws-client-defaults.ts(new),docs/troubleshooting.md,.claude/rules/proxy-support.md(new). The SDK for JavaScript v3 does not read the proxy variables, so every command previously failed at credential resolution behind a proxy.awsClientDefaults({ profile })returns{}when no proxy variable is set (the unproxied path is byte-identical), and otherwise supplies a per-client routing agent (anagent-basesubclass evaluatingNO_PROXYper request viaproxy-from-env, inner agents cached per instance,destroy()forwarded) plus an injecteddefaultProvidercredential chain so the SSO portal / SSO-OIDC hops route through the proxy too (the STS hops already inherit the client's handler). Fresh agent per call becauseNodeHttpHandler.destroy()destroys shared agents' ACTIVE sockets. Migrated in this PR: the client factory plus the bootstrap / state-write paths; the remaining 61 files are allow-listed intests/aws-client-defaults-allowlist.json(shrink-only, enforced by the new AST checker +audit:aws-client-defaults:checkCI step) and empty out in the follow-up PR.NO_PROXYsemantics (exact hostname unless the entry starts with.or*; CIDR silently ignored),NODE_EXTRA_CA_CERTSagainst TLS-terminating proxies, and the Docker-daemon egress caveat are documented indocs/troubleshooting.md. SDK-contract fences (aws-client-defaults-sdk-contract.test.ts) pin the two behaviours the design rests on against SDK bumps. First.ts-spelled relative import undersrc/(theaws-clients.tsliteral-resolution closure; carve-out in CLAUDE.md). New deps:https-proxy-agent/http-proxy-agent/proxy-from-env/agent-base/@aws-sdk/credential-provider-node(deliberately NOTproxy-agent— its PAC support dragsvminto the bundle). Verified by the contributor's live A/B on a corporate network plus five real-AWS integ arms on the unproxied path.The
docker-image-assetinteg fixture waits for the image function to leavePendingbefore invoking it (issue #2403) --tests/integration/docker-image-asset/verify.sh. cdkd deliberately does not wait forState=ActiveafterCreateFunction, and a container-image function takes 10-60 s to activate, so the fixture's immediate invoke raced activation and failed withResourceConflictException(two consecutive live failures on 2026-09-01). A boundedaws lambda wait function-active-v2between deploy and invoke removes the race without changing what the fixture proves.
Recently Implemented (2026-08-28):
A parameter's DEFAULT is now bound by its declared
Type, like a user-supplied value has always been -- so a defaultedCommaDelimitedListreaches consumers as a LIST, and anAWS::SSM::Parameter::Value<List<String>>does too (issue #2367) --src/deployment/intrinsic-function-resolver.ts,src/utils/parameter-types.ts,src/synthesis/macro-expander.ts,tests/unit/deployment/resolve-parameters-default-coercion.test.ts(new),docs/changelog-cdkd.md. THE COUNT WAS THE BUG.resolveParameterswritesparameters[name]at THREE sites and only ONE -- the user-supplied value -- asked the type coercion anything. The other two were the LITERALDefaultand the SSM-resolved default, so a parameter declaredType: CommaDelimitedListwithDefault: "a,b,c"and no CLI override reached every consumer as the raw string. This did not need issue #2347's widening to bite: it hitsCommaDelimitedListandList<Number>, the two list types theswitchrecognised all along, which makes it the older and more reachable half.Fn::Selectover such a parameter threwFn::Select: list must be an array, got stringand the deploy hard-failed;Fn::Joinrefused it the same way; a bareRefwas the SILENT arm, handing a provider a comma-joined scalar where the resource schema declares a list. CloudFormation's own worked example inparameters-section-structure.htmlis exactly this pairing --VpcAzs: {Type: CommaDelimitedList, Default: "us-west-2a, us-west-2b, us-west-2c"}read withFn::Select. ONLY A STRING DEFAULT IS COERCED, and the shapes were MEASURED rather than assumed.ParameterDefinition.Defaultis typedunknownbecause it is whatever the template parser produced, and both parsers cdkd feeds this from pass the literal through:aws-cdk-lib'sCfnParameter._toCloudFormationemitsDefault: this.defaultwith no conversion, so{type:'Number',default:42}synthesizes the JSON NUMBER42and a list-typed default may synthesize a JSON ARRAY;parseCfnTemplate(src/cli/yaml-cfn.ts, the import / export path) yields a number, a string, an array or a boolean per the YAML scalar. A non-string default is ALREADY in the shape its declared type calls for, so coercing it could only damage it --String(['a,b','c'])is'a,b,c', which the split then shreds into THREE elements. Hence the guard, not aString()normalization. THE SSM SITE NEEDED THE INNER TYPE, NOT THE DECLARED ONE, which is why it is not the same one-line edit.isListParameterTypedeliberately answersfalsefor everyAWS::SSM::Parameter::Value<...>spelling -- right, because the value SUPPLIED for such a parameter is a Parameter Store KEY and splitting a key on,would shred it -- so coercing the resolved value against the declared outer type is a silent NO-OP that fixes nothing. The peel moved into the SHAREDsrc/utils/parameter-types.tsasssmResolvedValueType, besideisListParameterType, andsrc/synthesis/macro-expander.ts-- which had the only other peel, written inline -- now calls it too. Writing a second one in the deployment layer was the first cut and it reproduced the EXACT shape issue #2347 had just deleted from this same module pair: one question, two spellings, and they disagreed (the resolver's required a closing>and a non-empty inner shape; the synthesis one required neither). The shared peel is the stricter of the two, and the synthesis site keeps its own handling of a malformed spelling -- the scalar placeholder, not the generic warn -- so nothing about macro expansion changes. Two AWS-published contracts bear on the coercion itself:cloudformation-supplied-parameter-types.htmldefinesValue<List<String>>/Value<CommaDelimitedList>as "a Systems Manager parameter whose value is a list of strings ... corresponds to theStringListparameter type in Parameter Store" and describes the supplied value in the SINGULAR throughout ("you must specify a Parameter Store key", "you must provide the parameter name"), which READS as one Parameter Store key per parameter -- see the caveat below before relying on that; the SSM service model typesParameter.Valueasstringand states "if type isStringList, the system returns a comma-separated string with no spaces between commas", so a split is the ONLY route to a list and the coercion's space-trim is a no-op against that wire shape rather than a divergence from CloudFormation. The scalarValue<...>forms peel to a scalar inner type and coerce to themselves, soValue<String>is untouched. The change sits STRICTLY AFTER the issue #1002referencedNamesskip, whichcontinues, so it cannot make an unreferenced parameter resolvable again -- pinned by its own case. Off the documented type space,Value<Number>newly coerces toNumber(resolved)--NaNfor a non-numeric Parameter Store value -- where it used to pass through; Parameter Store has no numeric type, so no valid template reaches it. WHAT THE SUPPLIED VALUE MEANS IS LEFT OPEN, AND THE DISAGREEMENT IS RECORDED RATHER THAN DECIDED. The reading above -- one key per parameter, whichaws-cdk-lib's ownStringListParameter.fromListParameterAttributesalso emits (default: attrs.parameterName, a single name, for aValue<List<String>>parameter) -- CONTRADICTS a comment already in this repo.src/synthesis/macro-expander.tsstates the supplied value forValue<List<*>>/Value<CommaDelimitedList>forms is a comma-delimited list of SSM parameter NAMES, and it states it from a LIVE CloudFormation observation: a single-string placeholder against such a type "would reject the changeset withParameter ... must be a list" (the CR-MJ3 fix). A live observation outranks a documentation read, and this change measured neither, so that comment is left exactly as it stands and is NOT overwritten. The two may well both be true of different things -- CloudFormation's pre-macro changeset VALIDATOR demanding a list-shaped literal while the runtime resolves one key -- but that reconciliation is plausible and UNMEASURED, so it is written down as unresolved inssmResolvedValueType's docblock. Nothing in this change depends on the answer: the synthesis site is choosing a placeholder for a validator, and the deployment site is coercing a valueGetParameterhas ALREADY returned, which is downstream of whatever the supplied key meant.resolveSSMParameter's existing singleGetParameteris untouched either way. 24 unit cases walking the WHOLE declared type space, not only the list types, asserted through the CONSUMERS (Fn::Select/Fn::Join/Fn::Split/ bareRef) because a test that only checksresolveParametersreturned something passes on the broken code. Four mutations, each restored and re-verified bygrep -c: reverting theDefaultsite reds 9 cases including the literalFn::Select: list must be an array, got string; reverting the SSM site reds 5, a DISJOINT set, which is the evidence the two arms are independently fenced; replacing the string guard withString(defaultValue)reds exactly the non-string case, onexpected [ 'a', 'b', 'c' ] to deeply equal [ 'a,b', 'c' ]-- the shredding the guard exists to prevent; and leaking the SSM inner peel into the USER-SUPPLIED site reds exactly the carve-out case, onexpected [ '/app/a', '/app/b' ] to be '/app/a,/app/b', which is a Parameter Store KEY shredded on a comma.Fn::Splitover such a parameter is NOW REFUSED, and that is a behaviour change worth stating. A template that wroteFn::Splitover a defaulted list-typed parameter was working around this very bug and SUCCEEDED before, because the parameter WAS a string; the parameter is now already a list, soresolveSplit'sALREADY a listarm refuses it -- matching CloudFormation, which rejectsFn::Splitover a list too, and matching what a user-supplied value has always done. A case pins the refusal AND itssource.kind === 'ref'remedy text, since that text is the only thing telling such a user what to do instead. TWO CASES ARE LABELLED AS NON-DISCRIMINATING RATHER THAN COUNTED AS COVERAGE, because a sentence asserting a fence that cannot fail is worse than no sentence. An earlier draft of this entry claimedNumber"carries both polarities"; it does not. The NUMERIC-default case is green under every mutation by construction (Number(String(42)) === 42), so it documents the shape CDK emits rather than fencing anything -- the discriminatingNumberpolarity is the STRING default, and what fences the guard is the comma-bearing array. The malformed-Value<...>-spelling case is the same:ValuefailsstartsWithwhatever else changes, andValue<>peels to'', which is not list-shaped, so dropping the non-empty check reaches the same passthrough. Both are kept as regression guards and both say so in place. UPGRADE CONSEQUENCE -- THE FIRST DEPLOY AFTER UPGRADING CAN REPLACE A RESOURCE, and it is not merely a cosmetic diff. A state record written by an older binary holds the STRING for a defaulted list parameter, while the new desired side is an ARRAY, soResourceState.propertiesfor any resource fed by such a parameter changes shape. That is a real change, not a display artifact: the nextcdkd deployissues an UPDATE, and where the parameter feeds a create-only / immutable property it is a REPLACEMENT --deploy-engine.tssetsneedsReplacementfromchange.propertyChanges?.some((pc) => pc.requiresReplacement), and a string-to-array change on such a property satisfies it. For a STATEFUL type the stateful guard on that path still applies, so the destructive case is refused rather than executed silently, but the general case is a resource being replaced on an upgrade deploy. Reviewcdkd diffbefore the first deploy after upgrading if any template declares a defaultedCommaDelimitedList/List<...>/AWS::SSM::Parameter::Value<List<...>>parameter. The convergence is one-time -- once the array is persisted the two sides agree -- but calling that "self-healing" would understate it, and an earlier draft of this entry did. A pre-existing divergence is deliberately NOT changed and is recorded instead: CloudFormation documents aRefto aList<Number>as returning a list of STRINGS ("80,20"->["80","20"]), whilecoerceParameterTypedValuereturns numbers. That is the coercion's own long-standing behaviour on the path that was already correct, it is pinned by an existing test, and changing it belongs with its own issue rather than smuggled into a call-site fix.A
List<AWS::EC2::Subnet::Id>(or any otherList<...>) nested-stack child parameter resolves to a LIST instead of a comma-joined string, because the two contradictory answers to "is this ParameterTypea list" are now one shared predicate (issue #2347) --src/utils/parameter-types.ts(new),src/deployment/intrinsic-function-resolver.ts,src/synthesis/macro-expander.ts,src/provisioning/providers/nested-stack-provider.ts,src/cli/commands/diff-recursive.ts(comments only),tests/unit/deployment/parameter-type-list-coercion.test.ts(new),tests/unit/deployment/intrinsic-typed-list-parameter.test.ts(new),tests/unit/deployment/intrinsic-resolver-inherited-parameter-secrets.test.ts,tests/unit/provisioning/nested-stack-provider.test.ts,tests/unit/cli/diff-recursive-nested-stack-secret.test.ts,tests/unit/synthesis/macro-expander.test.ts,docs/cli-reference.md. cdkd held TWO views.coerceParameterTypedValuenamed exactly two list types in aswitch(List<Number>,CommaDelimitedList), so the nineList<AWS::...>types CloudFormation defines fell todefaultand came back as the raw comma-joined STRING;stringifyParamDefaultinsrc/synthesis/macro-expander.tstestedinner.startsWith('List<') || inner === 'CommaDelimitedList', the wider and correct view.aws-cdk-lib's ownCfnParameter.isListTypeagrees with the second, so a CDK app could legally declareList<AWS::EC2::Subnet::Id>, callvalueAsList(), and have cdkd hand the child a string. Both sites now callisListParameterType. THE CONSEQUENCE WAS NOT COSMETIC.Fn::Selectover such a parameter -- the pattern AWS's own Parameters documentation gives for list types -- failed the deploy withFn::Select: list must be an array, got string, andFn::Joinrefused it the same way. A bareRefused as a property value was the silent arm: a provider whose schema declaresSubnetIdsa list received one scalar string.Fn::Splitover one moves the OTHER way and is now REFUSED as "ALREADY a list", matching CloudFormation and the existingCommaDelimitedListbehaviour; a template that split such a parameter was working around this bug. The AWS enumeration was measured, not inferred from the CDK.parameters-section-structure.htmllists the baseTypevalues asString,Number,List<Number>,CommaDelimitedListplus the AWS-specific and Systems Manager families -- a bareList<String>is not among them, and appears only as the inner shape ofAWS::SSM::Parameter::Value<List<String>>. It is nevertheless coerced here, because the CDK accepts it and cdkd deploys such a template without CloudFormation ever seeing it. The predicate isstartsWith('List<') && endsWith('>')rather thanaws-cdk-lib'sindexOf, which would also match the Systems Manager OUTER formAWS::SSM::Parameter::Value<List<String>>-- and that must NOT coerce, since its value is a Parameter Store KEY rather than the resolved list.NestedStackProvider.extractParametershad to move with it, and this was the widening's one BREAKING consequence. It accepted only string / number / boolean and threw "Parameters must be scalars" otherwise, blaming the resolver -- so a parent passingParameters: { SubnetIds: {Ref: SubnetIds} }for a list-typed parameter went from deploying to dying. It now joins an ARRAY with,, copying the shaperesolveChildImportParametersinsrc/cli/commands/export.tsalready used for the same wire question, down toString()-ing each element so aList<Number>array survives. The ELEMENTS are checked, which is the one place it deliberately does NOT copyexport.ts: an uncheckedString(e)turns a nested unresolved intrinsic into the literal[object Object]and ships it to the child deploy -- the exact outcome the top-level refusal exists to prevent, arriving one level down. A non-scalar element is refused by the SAME throw, naming the INDEX, so the top-level arm and the element arm cannot drift apart.export.tskeeps the unchecked form because a failure there degrades the parameter tostillSkippedand warns rather than promising loudness; a comment on the provider says so, to stop the next reader "restoring parity" by copying the hole back. The EMPTY array is an asymmetry, recorded rather than fixed:[]joins to''and the child re-splits it to['']-- one empty string, not an empty list.''is nonetheless the right wire value, because a CloudFormation Parameter is a string with no spelling for "no elements", and CFn's ownCommaDelimitedListhas the same hole. So the round-trip is lossless for a NON-EMPTY array whose elements carry no comma. Two further behaviour changes are asserted rather than suppressed. A secret dynamic reference bound to a list-typed child parameter is now MEASURED byrefuseCoercedInheritedSecretlikeCommaDelimitedListalready was, so a comma-bearing secret in one is refused by name while a comma-free one still resolves with the secret element intact. AndFn::Equalsover such a parameter changes answer --{Ref: Envs}against'prod'was'prod' === 'prod', and is now["prod"]vs"prod"-- which is the CORRECT answer for a list-typedRef; the cost is thatfilterResourcesByConditionprunes a condition-gated resource state still holds, so the next deploy deletes it, bounded bycdkd diffpreviewing that delete. The predicate lives insrc/utils/, not beside either consumer: hosting it insrc/deployment/gave the tree its firstsrc/synthesis/** -> src/deployment/**import, inverting the documented layer order.src/utils/ip-protocol.tsis the precedent, hosted there for the same two-consumers-in-different-layers reason and saying so in the same place. Nine prose sites that enumerated types instead of naming the family were corrected with it, counted on the final tree rather than estimated. SIX insrc/deployment/intrinsic-function-resolver.tsnamed "CommaDelimitedList / List" as the list-source SET -- three of them user-facing message text ( Fn::Join's refusal, and bothFn::Splitremedies, the second of which a user NOW hits with aList<AWS::EC2::Subnet::Id>) and three comments or docblocks; a seventh docblock (inheritedSecretsCarriedBy) said "aCommaDelimitedListparameter arrives as an array" and was widened in the same pass. TWO more --docs/cli-reference.mdandtokenValueForComparison's docblock insrc/cli/commands/diff-recursive.ts-- were written DURING this change and were already incomplete on arrival: they spelled out "CommaDelimitedList, the nineList<AWS::...>types andList<String>", which omitsList<AWS::EC2::KeyPair::KeyName>and the nested spellings that this change's own tests assert do coerce. An enumeration in prose is what went stale the first time, so every one of the nine now names the FAMILY (any List<...> type or CommaDelimitedList), restatingisListParameterType's own definition; where examples appear they are marked as examples, not as the population. A DIFFERENTIAL WALK fences the classifier, since hand-picked cases cannot: 67 parameter types (the full AWS enumeration plus malformed and nested spellings) x 5 probe values against a verbatim transcription of the pre-fix coercion, each differing cell classified by RECOMPUTING the expected value rather than shape-checking it, with ABSOLUTE floors. Both weaker forms were probed and found green before being fixed -- floors derived from the arrays they guarded survived emptying those arrays, and a shape-only class survived mutating the widened arm toreturn [type]. NOT closed here: the SSM default path atresolveParametersassigned the resolved value without calling the coercion at all, so anAWS::SSM::Parameter::Value<List<String>>parameter still resolved to a string. Different root cause, filed as issue #2367 -- which found the LITERALDefaultpath uncoerced too, and closed both (see that entry). The count was the point:resolveParameterswritesparameters[name]at THREE sites and this change touched the ONE that already called the coercion. No live coverage either: no integ fixture declares anyList<...>parameter type (measured), so the widened family has no real-AWS arm and authoring one is new-fixture work.Three more state-bucket delete sites no longer leave the object body readable as a NONCURRENT VERSION (issue #2346, sites 4, 6 and 7 of seven) --
src/state/s3-state-backend.ts,src/cli/commands/bootstrap-destroy.ts,src/cli/upload-cfn-template.ts,docs/state-management.md,docs/import.md,tests/unit/state/rollback-journal-version-purge.test.ts(new),tests/unit/cli/upload-cfn-template-cleanup-destroy.test.ts(new),tests/unit/cli/upload-cfn-template.test.ts,tests/unit/cli/bootstrap-destroy.test.ts,tests/unit/cli/export-templateurl-upload.test.ts,tests/unit/cli/retire-cfn-stack.test.ts,tests/integration/rollback-command/verify.sh,tests/integration/gc-custom-asset-names/verify.sh,src/state/s3-noncurrent-version-purge.ts,src/provisioning/providers/custom-resource-provider.ts,src/cli/commands/gc.ts,tests/unit/synthesis/macro-expander.test.ts,tests/unit/state/purge-object-description-per-caller.test.ts(new).deleteRollbackJournalis the site that mattered, and the only one with a MEASURED disclosure.cdkd bootstrapturns versioning ON for the state bucket, so its bareDeleteObjectwrote a delete marker and left every earlier journal body readable throughGetObjectwith aVersionId.failedOperations[].attemptedPropertiesis the properties of the FAILED write verbatim: measured 2026-08-20 onCdkdDeletionPolicySnapshotHeavyExampleas four surviving versions each carrying a literal"MasterUserPassword": "Cdkdcf2f..."after cdkd reported the state deleted. It now rides the sharedpurgeNoncurrentKeyVersionsthroughS3StateBackend.purgeNoncurrentVersions, on all three of its delete paths (successful deploy, cleancdkd rollback,cdkd destroy/cdkd state destroy).The purge runs even when the delete FAILED, and that is deliberate. The pre-fix code returned early from the
NoSuchKeyarm, which would have skipped it. A not-found CURRENT object says nothing about the key's history -- on a versioned bucket an earlier delete leaves a marker as current with every body still readable -- and the helper'sIsLatestfilter makes the outright-failure case safe too: a key whose delete failed keeps its current version and loses only its history.state.jsonwas deliberately NOT touched (sites 1-3). Its noncurrent versions ARE the state-recovery capability versioning is enabled for; purging them would close a disclosure path and destroy a recovery path in the same edit. That trade-off is unsettled and stays open on the issue. Note thatstate-migrate.ts, which the umbrella lists alongside them, is NOT an unfixed disclosure site at all: it already deletes every version of the keys it removes, so calling it "out of scope" understated it.The journal purge is not free, and the site-5 argument cuts both ways.
deleteRollbackJournalruns on every SUCCESSFUL deploy, so cdkd now issues aListObjectVersionsper deploy that it did not before, and a role still on the pre-#2340 four-action policy gets one warning per deploy until it adds the two grants. That is a version of the cost argument used below to excludelock.json, and it is stated rather than presented as decisive: what separates them is DISCLOSURE. The journal's measured content is a plaintext master password, so a per-deploy list is worth paying;lock.jsonholdsowner/timestamp/expiresAt/operationand nothing else, so the same cost buys only bucket tidiness.lock-manager.deleteLock(site 5) was assessed and deliberately left alone. SUPERSEDED on 2026-09-02 — see the site-5 entry at the top of this file; two of the three claims below were wrong.lock.jsoncarries no secret (owner/timestamp/expiresAt/operation), so the case there is storage cost, not disclosure -- and it is the fastest-accumulating key in the bucket (452 versions on one measured key), on the hot path of every cdkd command. Purging there would add aListObjectVersions+DeleteObjectsround trip to every lock release and requires3:ListBucketVersions/s3:DeleteObjectVersionfor ordinary use rather than only for the destroy and cleanup paths. The issue itself proposes a bucket lifecycle rule on noncurrent versions as the better answer; that call is not settled here.Two sibling suites were asserting the PRE-FIX wire sequence and would have gone green either way.
export-templateurl-upload.test.tsandretire-cfn-stack.test.tsmock@aws-sdk/client-s3with onlyPutObjectCommand/DeleteObjectCommand; the shared helper catches its own construction failure and warns, so the purge would have silently no-opped there whiletoEqual(['PutObject', 'DeleteObject'])kept passing. Both mocks now carryListObjectVersionsCommand/DeleteObjectsCommandand both assertions name the full four-command sequence.The shared purge's warning now names the CALLER's object, because it had become FALSE at three of its four sites. Its parenthetical was hard-coded to "a custom-resource response object that is the handler's full response body, including
Data" -- true while the sidecar was the only caller, and wrong the moment the rollback journal, the bootstrap marker and the transient CFn template joined: a user chasing a warning about a bootstrap marker was being sent to look for a handler response body that does not exist on that path.NoncurrentVersionPurgeOptionsgained an optionalobjectDescription, threaded throughS3StateBackend.purgeNoncurrentVersions(whose own could-not-start warning carries it too) and passed by all five call sites -- one phrase per OBJECT, so the provider's cleanup andcdkd gcshare the sidecar's. Widening the sentence into something vague enough to cover all four was the alternative and is worse: the caller knows what it just failed to purge. The ACTIONABLE half -- the two IAM grants and the by-hand remedy -- is correct everywhere and is deliberately NOT parameterised, which is pinned.tests/unit/state/purge-object-description-per-caller.test.tsdrives two REAL call sites and asserts the parentheticals DIFFER; with the option wired to a constant (the shape the defect had) three of its four tests go red, while a test that merely asserted "a warning was emitted" would pass.The real-AWS arms had to be BUILT, because every existing probe passes with the fix reverted.
rollback-command's step 4b andgc-custom-asset-names's Phase 4b both prove the object is gone withhead-object, which on a versioned bucket answers 404 the moment a delete marker exists -- the exact blindness this issue is about. Both fixtures now assert the key is down toall == 1, noncurrent == 0: exactly one surviving row, which can only be the CURRENT delete marker cdkd's own delete wrote, since the purge filters onIsLatestand can never remove it. That is a POSITIVE marker rather than an absence, so it also fails for a run that died before writing the object and for one whose teardown sweep had already run. Each arm proves its own PREMISE in the phase before it (the object existed with at least one version row) so0 versions and 0 leakscannot read as a pass, and each carries a NEGATIVE CONTROL onlock.json, whose history must SURVIVE -- without it every positive assertion would be equally satisfied by a purge that fired on everything. Measured by executing the helper's JMESPath under the aws-cli's own jmespath against four fixture shapes: fixedall=1 noncurrent=0(passes), revertedall=3 noncurrent=2(fails), died-earlyall=2 noncurrent=1(fails), already-sweptall=0(fails).rollback-commandalso gained the success-path version sweep it never had. Sourcings3-versions.shput it in the population oftests/unit/scripts/integ-s3-versions-harness.test.ts, which immediately failed it for disarming its EXIT trap without a followings3_assert_versions_swept-- correctly, because its step 8 removed the sidecars withaws s3 rmand proved it withaws s3 ls, the vacuous shape issue #2096 was raised about. It now purgesalland asserts zero for both of its stacks after the disarm.Site 7's live arm is in
migrate-from-cfn, whose LARGE variant exists for exactly this path. An earlier revision of this entry claimed no fixture touched the transient upload at all; that was FALSE and is recorded here rather than quietly dropped, because it was derived by greppingverify.shfiles while this fixture drives fromrun.sh.bin/app.tsnames "the large (>51,200B TemplateURL) path" andlib/migrate-large-stack.tsputs the template at "about 67-69 KB in practice". Its existingassert_migrate_tmp_emptyusesaws s3 ls, which reports the prefix empty as soon as a DELETE MARKER exists — the version-blind shape this issue is about — so it passes with the purge reverted. The new arm measures DELTAS around the migrate (the prefix is not cleaned bypreflight_clean, so absolute counts would be measuring older runs) and assertsdelete markers +1 or more, noncurrent +0. The positive marker is delete markers rather than all rows because onlycleanup()'s ownDeleteObjectcan create one: measured against four fixture shapes, an all-rows test PASSES on a run that uploaded and then died before deleting, and the delete-marker test fails it. Theexportfixture was checked and is NOT a second candidate — ~15 resources with a ~1.6 KB inline Lambda payload, nowhere near the ceiling.Why the arm uses a local counter instead of
s3-versions.sh. The shareds3_count_versionscannot answer this question: its_s3v_check_prefixguard hard-requires acdkd/<stack>/<region>/shape and refusescdkd-migrate-tmp/, which lives outside the state prefix by design. Its key-scoped twin needs an exact key, and this key is minted at run time and already delete-markered by the time the assertion runs. Sourcing the helper for the negative control alone would additionally enrol this fixture in the harness fence's caller population and move three more committed counts for no coverage gained.A connection-pool leak was introduced and fixed inside this change, and is now pinned. The first revision of
uploadCfnTemplate'scleanup()placeds3.destroy()after the purge'scatcharm rather than in afinallyunder it, so a throw raised inside that catch skipped the destroy -- reachable only when something had already failed.tests/unit/cli/upload-cfn-template-cleanup-destroy.test.tsreproduces it by making both the version listing and the logger'swarnthrow.CloudControlProviderconfirms the state record's region before EVERY mutation, not only on theNotFoundbranch (issue #2301) --src/provisioning/cloud-control-provider.ts,src/provisioning/region-check.ts,src/types/resource.ts,src/deployment/deploy-engine.ts,src/deployment/rollback-executor.ts,src/cli/commands/destroy-runner.ts,src/cli/commands/drift.ts,src/provisioning/providers/s3-bucket-provider.ts(comment),tests/unit/provisioning/cloud-control-region-guard-2301.test.ts(new),tests/unit/provisioning/region-check.test.ts,tests/unit/provisioning/cloud-control-s3-delete-identity-2283.test.ts,tests/unit/cli/destroy-runner-interrupt-not-found.test.ts,tests/unit/deployment/deploy-engine-delete-interrupt-not-found.test.ts,tests/unit/deployment/rollback-executor.test.ts,tests/unit/deployment/deploy-engine-provider-secret-masker.test.ts,tests/unit/cli/drift-cross-region-secret.test.ts,docs/provider-development.md,docs/architecture.md. Issue #2283 added a per-type bucket-identity probe todelete(), keyed onAWS::S3::Bucket. The GENERAL check underneath it --assertRegionMatch, comparing the CLIENT's region against the STATE record's -- still ran in ONE place: theNotFoundcatch arm. A wrong-region call usually never REACHES that arm, because a Cloud ControlIdentifieris normally a NAME and the same name commonly exists in the client's region too (the same stack deployed to two regions; cdkd's ownresource-name.tsderiving an identical name from an identical stack plus logical id), so the call SUCCEEDS against the wrong resource instead of erroring.delete()andupdate()now run that comparison unconditionally, for every Cloud-Control-routed type, ahead of every mutating step -- the--remove-protectionflips, the SDK delegations, the #2283 probe, and theDescribeTypebehind the update path's write-only-property lookup -- and refuse non-retryably.update()gained thecontext?: UpdateContextparameter it never declared (the interface has carried it since issue #1732), andUpdateContextgainedexpectedRegion, threaded by the deploy engine, both rollback replay arms anddrift --revertfrom the same values those callers already pass to their delete sites. User-visible change oncdkd drift --revert, and it is deliberate. That command pins its AWS clients ONCE and then loops over stacks in whatever regions state holds, so a revert for a stack outside the ambient region previously issued its write through the ambient region's client -- addressed by a physical id read out of the record, in the wrong region. A Cloud-Control-routed resource in that position now REFUSES with a message naming both regions. Same-region reverts, which is every ordinary run, are unchanged. The result on a cross-region--all --revertis MIXED: only CC-routed resources refuse, while SDK-routed ones still write where the ambient clients point (issue #1981, unchanged here), and the SDK providers' ownupdate()region hole is issue #2245. The refusal is fenced against being read as "already deleted", which is the third member of a family. Bothdestroy-runner.tsand the deploy engine's template-removal DELETE decide a failed delete means the resource is already gone by SUBSTRING-matching the error MESSAGE. This refusal interpolates the LOGICAL ID, so a construct namedHandleNotFoundException-- or a CFn logical id carried in by--migrate-from-cloudformation-- would have made the guard's own throw read as success, dropping the state row over a LIVE foreign-region resource. Both classifiers now checkisMarkedNonRetryablefirst, alongside the existing typed guards for a final-snapshot failure (issue #1352) and a user abort (issues #2053 / #1952); the substring match cannot be made safe on its own, because any needle can appear in a user-chosen name. One comparison, one normalisation.assertRegionMatchgrew aRegionCheckPhasethat changes only the MESSAGE ('not-found'remains the default, so every existing provider call site is untouched), and all three uses inCloudControlProvider-- both pre-flights and theNotFoundarm -- now go through a single private helper that trims and case-folds both sides. Until they shared it, the arm compared RAW: a client region ofUS-EAST-1, reachable from a profile'sregion = US-EAST-1(whichfoldRegionOptiondoes not fold), passed the pre-flight and was then falsely refused by the arm below. Three-way behaviour on an unknown region, fenced in both directions: a state record with NO region (a pre-v2 record) or an empty / whitespace-only one PROCEEDS without even resolving the client's region, a matching region proceeds silently, and a mismatch or an unresolvable client region refuses. Item 3 of the issue is deliberately NOT included and the issue stays open. When the #2283 identity probe cannot answer -- the case a bucket policy denyings3:GetBucketLocationproduces -- cdkd still proceeds with alogger.warnand nothing durable. Routing that into thedeployments/events store needs a NEWDeploymentEventType, which is a persisted user contract with its owncdkd eventsrendering, and the issue settles neither the name nor whether it counts in the destroy summary.The custom-resource RESPONSE SIDECAR no longer survives its own cleanup as a readable noncurrent version, on EITHER of the two paths that delete one (issue #2340) --
src/state/s3-noncurrent-version-purge.ts(new),src/state/s3-state-backend.ts,src/provisioning/providers/custom-resource-provider.ts,src/cli/commands/gc.ts,tests/integration/s3-versions.sh(prose),tests/unit/state/s3-noncurrent-version-purge.test.ts(new),tests/unit/state/custom-resource-response-version-purge-sharing.test.ts(new),tests/unit/cli/gc-custom-resource-responses.test.ts,tests/unit/provisioning/custom-resource-response-version-purge.test.ts(new),tests/unit/provisioning/custom-resource-provider.test.ts,tests/unit/provisioning/custom-resource-provider-response-bucket-region.test.ts. A Lambda-backed custom resource replies through the pre-signed ResponseURL by PUTting its FULL cfn-response body --Dataincluded -- to<responsePrefix>/<requestId>.jsonin cdkd's state bucket.cleanupResponseObjectthen issued a bareDeleteObject.cdkd bootstrapenables VERSIONING on that bucket, so the delete writes a DELETE MARKER and every prior version stays readable throughGetObjectwith aVersionId: cdkd reported the response object cleaned up while a handler-minted secret (a generated password, an issued API key) remained retrievable by anyone holdings3:GetObjectVersion. Both paths that delete one of these objects now purge its NONCURRENT versions.cdkd gcis the SECOND path, and fixing only the first would have shipped the same over-stated invariant the issue is about.gccollects the ABANDONED placeholders -- the ones no run ever polled -- throughstateBackend.deleteRawObjects, which sendsDeleteObjectswith{ Key }and noVersionId. So a changelog line claiming cdkd purges the response object's noncurrent versions would have been FALSE for the sweeper whose whole job is collecting them.gcnow calls the purge after its delete. ONE implementation, called from both, and the fence over it PARSES rather than scanning text — with its blind spots ENUMERATED rather than claimed away.purgeNoncurrentKeyVersionsis a leaf module undersrc/state/: the provider calls it directly, andgcreaches it through the newS3StateBackend.purgeNoncurrentVersions, which supplies the bucket, the region-corrected client andExpectedBucketOwner. A leaf module rather than a method on the backend because the provider is one of the two callers andsrc/provisioning/**has no RUNTIME edge to the state backend today. That fence was defeated by review in four consecutive rounds, and what finally ended it was narrowing the CLAIM, not widening the query. Revision 1 was a substring scan a COMMENT could satisfy. Revision 2 added a hand-rolled comment stripper, which fell to a regex literal ending in//(measured: only theintrinsic-function-resolver.ts:4115shape breaks it —types/assembly.ts:165andappsync-provider.ts:1867carry the same\/\/without the failure), a char class/[/*]/, and a backtick inside a string inside a template. Revision 3 replaced the classifier with a real parse —typescript-v6, the sameparseDiagnosticsdiscipline asscripts/check-provider-error-cause.ts— since comments are trivia and absent from the AST. But revision 3 was STRICTLY WEAKER than the stripper on this repo's dominant idiom: it read only direct properties of the argument literal, so...(cond && { Prefix: p })— aSpreadAssignment, with no.name— was invisible, while the substring scan had seen it. That idiom is 960 sites across 119 files, two of them inside the purge module itself, counted by AST rather than by text (aSpreadAssignmentwhose parenthesis-unwrapped expression is aBinaryExpressionon&&with anObjectLiteralExpressionright operand, over the 331 filesgit ls-files 'src/*.ts' 'src/**/*.ts'returns). Three text rules bracket it at 925 / 936 / 967 by miscounting multi-line and nested forms, which is why the rule is published beside the number instead of the number alone. Verified in an isolated tree, both directions, on the same planted open-coded copy: revision 3 passes 13/13, revision 4 reds 2. Revision 4 walks the argument subtree and resolves a namespace-imported callee. The doc now enumerates what it cannot see — a generickeys: string[]deleter, a re-exported binding, a dynamic-import destructure, a local rebinding, and (the consequential one) an ALIASED delete command, which escapes the deleter set entirely so neither assertion runs. Each blind spot is pinned by an executable case, so a later widening flips a test and forces the list to be updated in the same edit. Bounding the risk, measured:src/contains ZEROimport * as X from '@aws-sdk/...'and ZEROexport { ... } from '@aws-sdk/...'. The header says plainly that the response to a further spelling is one more line in that list, not another query. It is deliberately NOT folded intodeleteRawObjects, whose six call sites (measured:grep -rn '\.deleteRawObjects(' src/) include four indeployment-events-store.ts;tests/integration/s3-versions.shrecords those objects as deliberately NOT delete-markered bycdkd destroy, surviving as CURRENT objects. A blanket purge there would change that behaviour and widen the IAM every caller needs. The purge is opt-in, andgc's response sweep is today's one opt-in. The purge is scoped to a KEY SET and to what is notIsLatest, and both halves are load-bearing.CUSTOM_RESOURCE_RESPONSE_PREFIXis a TOP-LEVEL prefix -- a SIBLING ofcdkd/, not a sidecar under a stack's owncdkd/<stack>/<region>/-- so every stack deploying into the region writes into it concurrently, and a prefix-wide purge would take another deploy's live response object.ListObjectVersionstakes aPrefix, not an exact key, so every returned entry is re-checked against the requested set before anything is deleted; that is what letsgcpass ONE covering prefix for thousands of candidate keys while still deleting only what it collected -- a trade rather than a strict win, since the shared prefix also returns every concurrent deploy's in-flight objects and on a busy account the walk can cost more requests than a handful of per-key lookups. An UNVERSIONED bucket answers with the live object carryingIsLatest: true, which the same filter drops, while a'null'VERSION id is deliberately NOT filtered out on its own -- a bucket whose versioning was SUSPENDED can carry a genuine noncurrent'null'version holding the response body. The purge is CONDITIONAL on two IAM grants, and the recommended policy did not carry them -- so the doc is part of the fix, not a follow-up. It needss3:ListBucketVersions(bucket-level) ands3:DeleteObjectVersion(object-level).docs/state-management.md's "Bucket Policy with Least Privilege" granted exactly four actions --s3:GetObject/s3:PutObject/s3:DeleteObject/s3:ListBucket-- and neither of the two. Because the purge fails SOFT by design (it warns and never aborts), a user on that policy would have gotten a warning and kept the readable secret, which would have made this entry's headline FALSE for the configuration cdkd itself recommends -- the same over-stated-invariant failure the issue is about, one level out. Both actions are now in that block, each with a line saying which capability it unlocks and which of the two ARNs already inResourcecovers it, plus a paragraph on what happens without them. The existing ARNs needed no change (verified by parsing the block: the bare bucket ARN covers the bucket-level action exactly as it already did fors3:ListBucket, and the/*ARN covers the object-level one exactly as fors3:DeleteObject). The same conditionality is recorded intests/integration/s3-versions.sh, where it matters for a different reason: a fixture asserting the purge on a CI role that lacks the grants would be VACUOUS, since the run goes green either way. Two other permission blocks were examined and deliberately left alone --docs/troubleshooting.md's asset-bucket policy (a different bucket the purge never touches) and itss3:*catch-all (already covers both) -- as weredocs/cross-stack-references.md's read-only cross-account consumer policy anddocs/testing.md's CDK fixture snippet. Nothing insrc/mints a GRANT: the only policy cdkd writes isbuildDenyExternalAccessPolicy, a pureDenyofs3:*to principals outside the owning account, which needs no change and is checked from the code rather than inferred from an empty grep. Never throwing is a property of the MECHANISM, not of each call site — and "did not throw" is not the same as "worked". The provider runs cleanup fromfinallyarms and from the timeout arm;gcruns the purge after a collection that has already succeeded, and its surrounding comment is explicit that a partial failure must not describe itself as total. So the helper collects failures and WARNS rather than propagating. Two things review had to force before that warning was worth anything. First,DeleteObjectsreports per-key failures inresponse.Errorsinstead of throwing, and withQuiet: truethat array is the only signal there is — an earlier revision discarded the response, so a principal holdings3:ListBucketVersionsbut NOTs3:DeleteObjectVersion(exactly the reader who adds one of the two doc bullets and not the other) saw✓ Deleted N placeholder(s), no warning, and every version still readable. That is this change's own defect, reintroduced with the warning suppressed; Object Lock, aDenySCP andSlowDownreach the same state. The repo already documented the trap one file over, ondeleteRawObjects. Second, the warning counted listing PREFIXES, not keys: 3000 unpurgedgckeys reported1 key(s)and named the prefix, and the docs quoted that sample as if it were per-key. Failures are now keyed by object key, named up to five at a time with an "(and N more)" tail, and both the docs and this entry quote the real shape. Thegccall also moved into afinally, becausedeleteRawObjectsthrows on ANY per-key failure — a partial delete used to skip the purge entirely and leave the successfully-deleted bodies readable with no version warning at all. The false half of the prose is corrected in the same change, and it was the more dangerous half.tests/integration/s3-versions.shasserted as settled fact thatcustom-resource-responses/<id>.json"is a sidecar under a stack's OWN prefix, so the prefix sweep DOES reach it", and that only one fixture in the swept set uses a custom resource and it carries no secret. Both sentences were false --s3_purge_prefix_versionscannot even be pointed at that prefix, since_s3v_check_prefixrefuses anything not shapedcdkd/<stack>/<region>/-- and an over-stated invariant stops the next reader looking. It is now a THIRD BLIND SPOT paragraph with the same shape as the exports-index one, and it deliberately claims NOTHING about which fixtures invoke a custom resource, since that was not audited. Sixty-five unit cases across five files, and THIRTY-ONE mutations, each applied one at a time with the marker grepped before the result was read and the restore verified after. The assertions are on the S3 COMMAND STREAM — which command, with whichBucket/Prefix/KeyMarker/ExpectedBucketOwner/Delete.Objects— never on "cleanup did not throw", which the pre-fix code satisfies equally well. Thirty of the thirty-one red; the one survivor is asymmetry rather than a gap, and the JSDoc says so: RAISINGDELETE_BATCH_SIZEis green becausestalecomes from a SINGLEListObjectVersionspage andMaxKeyscaps that at 1000, so nothing reaches a second chunk — while LOWERING it to 500 is RED. The constant is fenced from below and not from above. Two earlier claims about it (that the loop was reachable, on the strength of a 1001-entry single-page fixture S3 cannot return; and then that no test discriminated it at all) are both replaced by that measurement. One thing was deliberately left undone. The newsrc/state/module is not added to the.claude/rules/layout-misc.mdinventory: that corpus measures 899,880 B against a 900,000 B ceiling (tests/unit/scripts/rule-file-payload.test.ts), so any useful entry reds the suite and the only way to fit one is to cut text another lane added. Nothing enforces the inventory mechanically andlayout-misc.mdalready coverssrc/state/**at directory granularity, so what is lost is a granularity level, not a guarantee. The ceiling is issue #2310's. A wider sibling class is enumerated but NOT fixed here, and it is not one mechanical sweep — purging noncurrentstate.jsonversions would destroy the state-recovery capability versioning is enabled FOR. Seven sites remain, not the six an earlier draft counted:s3-state-backend.ts's legacy-key migration delete and bothdeleteStatearms,deleteRollbackJournal(whose journal storesattemptedPropertiesverbatim —s3-versions.shrecords a measured literal"MasterUserPassword": "Cdkdcf2f..."across four versions of one),lock-manager.ts'sdeleteLock,upload-cfn-template.ts's transient template body, and — the one the first count missed —bootstrap-destroy.ts:753, which deletes the bootstrap MARKER from the state bucket throughdeleteRawObjectswith no purge. That file does containListObjectVersionsCommand, but foremptyBucketAllVersionson the ASSET bucket, which is why a grep for the remedy skipped over it. The marker carries no secret, so its severity is low and the umbrella says so, to keep the next lane from over-prioritising it.A LIST-valued nested-stack child leaf keeps its OWN
{{resolve:...}}expression on BOTH the persist and the diff side, so twoCommaDelimitedListparameters resolving to one plaintext no longer collapse onto a single reference (issue #2327) --src/deployment/secret-redaction.ts,src/deployment/intrinsic-function-resolver.ts,src/cli/commands/diff-recursive.ts,tests/unit/deployment/secret-redaction-nested-parameter-list.test.ts(new),tests/unit/deployment/intrinsic-resolver-inherited-parameter-secrets.test.ts,tests/unit/cli/diff-recursive-nested-stack-secret.test.ts,tests/unit/deployment/deploy-engine-nested-stack-shared-parameter-plaintext.test.ts,tests/unit/deployment/secret-redaction-nested-parameter-source.test.ts,tests/integration/nested-stack-secret/**,docs/cli-reference.md. Issue #2291 closed this collapse for a child parameter declaredType: String.docs/cli-reference.mdnamesCommaDelimitedListas an equally ALLOWED spelling for a secret-bearing nested-stack parameter, andcoerceParameterTypedValueturns such a parameter into an ARRAY before any redaction runs -- so three string-only sites, not the two the issue named, answered wrongly for it. The persist side: the child's leaf reachedredactByPathas an array beside an intrinsic OBJECT ({Ref: <Param>}), a shape NO arm matched, so it fell to the plaintext-keyed value scan and BOTH members of a coinciding pair took the SURVIVOR's expression.resolveReplayPropsre-resolves what is persisted, so a rollback -- orcdkd drift --revertover a baseline projected from that record, whichlistStacksenumerates as an ordinary stack -- pushed the WRONG secret version to the live resource. SCOPED TO THE PATHS THAT CARRY A SOURCE, which is what this change closes: the deploy's persist choke point, the rollback replay underSTATE_DERIVED_RULES, and the diff. The paths with NOsourceProperties--cdkd state refresh-observedandcdkd drift --accept-- are NOT closed and are not this issue: with an empty bag a one-element list leaf has no identity key, the anchor pass refuses, and the leaf keeps the plaintext. Probed during review and identical before and after this change, so it is the standing issue #2012 / #2036 residual rather than a regression here, anddrift.ts'srevertBaseline = observedProperties ?? propertiesmeans such a baseline can still carry it. POSITION for a list element is NOT the INDEX, which is the question that killed the earlier attempt in issue #2012. There is no source ARRAY to align against -- a list leaf's source is ONE intrinsic standing for the whole list -- so an index pairing would have nothing on the other side and inventing one is exactly the fabrication that review refused. What certifies an element is its OWN VALUE: it must be a whole plaintext this pass recorded, and the association the recorder stored beside this leaf identity must name that same plaintext. Order is therefore irrelevant. Nothing is fabricated: the output array keeps the input's length and order, every element is either its own certified expression or the value-scan answer this module already produced for it, and no element is added, dropped, reordered or copied from the source -- so there is no baseline content here thatcdkd drift --revertcould push to AWS but AWS never reported. The three conditions now live in ONE place and both halves CALL it, rather than each spelling its own.certifiedExpressionForLeafowns the question;certifiedListForLeafapplies it element-wise;positionByCrossStackSource(persist, scalar),positionListByCrossStackSource(persist, list) andinheritedParameterExpression(diff, both) all delegate. Sharing is load-bearing rather than tidy: the two halves must produce the same expression for the same leaf or the desired side of the next diff never matches what was persisted, and two spellings agreeing on every case but one would reintroduce the perpetual UPDATE at that one case. The DIFF side had to move with it, and it is what this change would otherwise have REGRESSED.inheritedParameterExpressionnow returnsstring | unknown[] | undefinedand answers per element for a list. Its second caller changed with it:IntrinsicFunctionResolver.recordInheritedParameterSecretswrites aMap<string, string>keyed by PLAINTEXT, so it now asks per carried plaintext rather than per parameter VALUE. The old spelling gated the override onplaintext === value, which can never hold once the value is an ARRAY -- it typechecked but could not FIRE, so a list-typed parameter kept the collapsed survivor there too. The scalar answer is unchanged by the move, because the recorder's condition 2 already subsumes the old gate: the association's recorded plaintext IS the parameter's whole resolved value, so it can only equal a carried plaintext the old gate also accepted.cdkd diff --recursivewas wrong for this shape independently of all of the above, and its comment is why it stayed wrong:tokenParameterNamesrestored every token-valued child parameter to the RAW string, justified as "exactly what the child's state holds for such a parameter". That holds only for a SCALAR one -- aCommaDelimitedListparameter's state leaf is an ARRAY, so the raw token compared a string against a list and reported a phantom change on every run. A newtokenValueForComparisonbinds the token to the shape state holds, MEASURED from the real coercion rather than enumerated beside it (the disciplineparameterTypeMayLoseSecretIdentityadopted after a hand-kept type list was found wrong about one of its own three entries): an unchanged value, a non-array, or an array carrying a non-string all keep the token, soStringandNumber/List<Number>are untouched whileCommaDelimitedListsplits.List<String>was NOT among them at the time, and read as though it should be: measured againstcoerceParameterTypedValueAS IT THEN STOOD, itsswitchNAMED onlyNumber,List<Number>andCommaDelimitedList--Stringwas grouped withdefault, and every other type fell there too -- soList<String>returned the unchanged STRING, which was what state held for it, so the shim was right about it. Whether cdkd and CloudFormation AGREED on that was filed as a separate question, issue #2347, and has SINCE BEEN ANSWERED -- see that entry below: the coercion now splits the wholeList<...>family, soList<String>no longer returns a string andtokenValueForComparisonsplits it. This paragraph describes the state of the tree at #2327 and is left in the past tense for that reason. TheparameterTypeMayLoseSecretIdentitywarning's tail was corrected with it -- it claimed the diff "keeps the unresolved reference rather than coercing it", which is now false for exactly the type the warning most often names.recordNestedStackParameterExpressions's string-only refusal 1 was NOT widened, contrary to the issue's premise, and the measurement now lives beside it.NestedStackProvider.extractParametersrefuses a non-scalar parent-side parameter value outright ("Parameters must be scalars"), and it runs immediately after that recorder -- so an arrayresolvedValuethere can never reach a child engine. The parent always hands the child a STRING; the array is produced INSIDE the child by its ownTypecoercion. Widening the refusal would certify a shape production cannot deliver, which is why the finding is a comment on the guard rather than a line in a report. A REVIEW ROUND FOUND THE ARM REINTRODUCING THE DEFECT ONE SHAPE OVER, and the fix is a fourth refusal at WRITE time. Condition 3 reads the bag's VALUES throughplaintextIndexOf, and the two readers hold DIFFERENT bags -- the diff side the parent's map, the persist side the issue #2087-scoped per-resource child bag. So the same association could pass on one side and fail on the other, and did: withEXPR_Aalso recorded against a different plaintext (the issue #1933 two-regions shape), the persist side certifiedEXPR_Awhile the diff side refused and fell back to the survivor. Measured over a sweep of 16 bag configurations, of which 4 diverged. The consequence is #2327's own failure mode one shape over -- the child persists the ONE expression the pass has direct evidence resolves elsewhere, andresolveReplayPropsre-resolves it.recordNestedStackParameterExpressionsnow applies that same test at WRITE time, against the parent's map (the bag that WATCHED the resolution, and the only one that can answer the question), so both readers see one table and degrade together to the value scan. Re-measured over that same sweep: 1 divergent, and that row is a member of the BASELINE set -- the arm disabled AND refusal 4 absent also gives 4, but a DISJOINT four, which is what makes "removes three, introduces none" a fact about the sets rather than a coincidence of counts. The surviving row is the value-scan FALL-THROUGH reading a different bag on each side, which is older than this arm, unchanged by it, and now PINNED by a residual case rather than left to be rediscovered. It is reached by TWO shapes and issue #2349 was filed on the narrower description of one: an EMBEDDING element thatcrossStackSourceKeycan never key, AND a BARE element whose parameter refusal 4 refused while its SIBLING's association survived -- the second measured with no embedding anywhere in the bag, and a strict baseline member like the first. Both shapes are PINNED by their own case, the second asserting the divergence POSITIVELY (a change making both sides wrong together must not satisfy it) plus the property that makes it a baseline member: NEITHER side certified, so the divergence belongs to the fall-through alone. Nineteen new unit cases insecret-redaction-nested-parameter-list.test.ts, two inintrinsic-resolver-inherited-parameter-secrets.test.ts(25 total) and two indiff-recursive-nested-stack-secret.test.ts(10 total). The load-bearing ones are the two PARITY cases: both halves over one leaf, same expression per leaf, and the two leaves differing -- "the persist side is correct" is satisfied by a broken diff side too -- and the two-regions case above. Refusal 4 has TWO discriminating cases rather than one: dropping it reds both, while a variant comparing only STRINGS -- ignoringplaintextIndexOf'sCONFLICTING_PLAINTEXTsymbol -- reds only the second, which is what makes it discrimination rather than duplication. They feed the two halves the DIFFERENT bags production feeds them, built the wayrecordInheritedParameterSecretsbuilds them; the helper used to hand the persist sidenew Map(parent), a bag production never produces, and that is what hid the blocker. The KEYS-subset guarantee is stated for what it actually covers: it makes condition 1 answer identically on both sides and says nothing about condition 3 or the fall-through. Seventeen mutations, each applied alone with the marker grepped before the run and every restore verified bydiffagainst a pristine copy, all red. Three of this lane's own tests were found VACUOUS by those sweeps and repaired: the skeleton-refusal case (it refused for the skeleton's reason, not the arm's, and now registers the expression first); the unchanged-resource case (toEqualover an identity claim, nowtoBe); and the empty-string case, whose "premise" assertion was a secondundefined-- the same verdict as the absence it claimed to exclude, so deleting the recorder call left the file green -- now established by a POSITIVE observation on a sibling parameter recorded in the same call. The USER-FACING doc carried the same over-stated invariant one level out, and it said "deliberate".docs/cli-reference.md's--recursivesection told a reader the redacted token is left uncoerced rather than cast by the child parameter's declaredType, which after this change is false for a list type -- and the parenthetical namingType: Number/NaNwas the TRUE half that had to survive. It now states the NARROWING: the token is cast only where every part of it stays a string, soCommaDelimitedListsplits (matching the ARRAY state holds) whileList<String>,NumberandList<Number>stay raw, and the split falls there because cdkd's secret redaction is string-keyed end to end. The doc's paraphrase of thecdkd diffwarning was corrected with the warning itself -- it said deploy "refuses it outright", now true only when the coercion actually destroys the plaintext. The "Secret parameters must beType: String" subsection was UNDER-stating in the same direction: it namedCommaDelimitedListas allowed with no caveat, while a comma-bearing secret in one IS refused (fenced byintrinsic-resolver-inherited-parameter-secrets.test.ts's JSON-blob case), which is the dominant Secrets Manager shape. The integ arm is a LIST-typed pair ontests/integration/nested-stack-secret: ONEAWS::Events::Rulein the child whoseEventPattern.detailcarrieslistA/listB(twoCommaDelimitedListparameters, one plaintext under a newlistsecret key) pluslistPublicas a NEGATIVE CONTROL -- same declared type, same{Ref: <Param>}source, same resource, same walk, and a public value condition 1 must refuse. ONE resource rather than two is sharper here than it is for #2291: two resources would still discriminate TODAY, but only because the #2291 override could not fire for an array, and fixing that override in this same change makes a two-resource shape vacuous.
Recently Implemented (2026-08-27):
One required Parameter no longer discards the
Defaultvalues its SIBLINGS declare duringcdkd import, so aFn::Subbuilt from a defaulted parameter is resolved instead of persisted verbatim (issues #2321 and #2335) --src/cli/commands/import.ts,src/deployment/intrinsic-function-resolver.ts(comments only),tests/unit/cli/import.test.ts.resolveParametersis ALL-OR-NOTHING: it throws on the FIRST parameter declared with noDefaultand no supplied value, andcdkd importsupplies none and has no flag that could.resolveImportedPropertiescaught that throw and continued with an EMPTY bag, so a template mixingVpcId: {Type: String}withStage: {Type: String, Default: 'dev'}lostStage's default too and recorded{'Fn::Sub': 'app-${Stage}-topic'}into the imported resource'spropertiesas the literal STRINGapp-${Stage}-topic-- which then becomes the desired bag of the nextcdkd deployand the bagcdkd destroyhands a provider. (Note the parameter NAMES are reused across the two entries in opposite roles: hereStageis the DEFAULTED parameter andVpcIdthe required one, matching issue #2321's own repro, while the issue #2285 entry below usesStagefor the REQUIRED parameter, matching that change's tests. The names are the issues'; the roles are not the same.) Issue #2285's structuralFn::Subrefusal cannot close this and must not be widened to:StageHAS aDefault, soisUnboundTemplateParametercorrectly answersfalseand refusing there would newly reject a placeholder whose value the template itself declares. The remedy is in the CALLER, which is where the discarded information lives. On that catch,resolveImportedPropertiesnow RETRIESresolveParametersover aDefault-only VIEW of the Parameters section (newdefaultOnlyParameterTemplatehelper) -- every parameter carrying aDefault, nothing else. The retry cannot hit the same throw by construction, sinceisUnboundTemplateParameteris false for exactly the population that survives the filter; re-entering the SAME method rather than hand-binding eachDefaultkeeps the SSM-typed (AWS::SSM::Parameter::Value<...>) lookup and its unreferenced-parameter skip identical to the happy path instead of a paraphrase that can drift; and everything outsideParametersis carried through untouched becauseresolveParameterswalksResourcesto decide which SSM defaults are worth aGetParametercall. ADefault-only retry that fails on its own terms (an SSM default whoseGetParameteris rejected) falls back to the pre-fix empty bag rather than aborting an import that already succeeded against AWS -- the resources are adopted on the AWS side whatever happens here, so a throw would lose the state write for work that cannot be undone. That fallback is COARSER than it needs to be, and the residual is recorded rather than papered over: one unresolvable SSM-typed default discards the PLAIN defaults too, so this bug returns for that template. A third pass over the non-SSM defaults would close it; it is left because this arm is also the one place the resolver'sDefaultexclusion is still load-bearing (see below), so the two have to move together. Issue #2285's refusal and its warn arm are unaffected in BOTH directions, verified by reading rather than assumed.isUnboundTemplateParameteranswersfalsefor aDefault-carrying parameter BEFORE it consults the bound bag, so binding those defaults cannot move a parameter into or out of the unbound population:${VpcId}is still refused and still the only name in the warn'sdeclares parameter(s) with no 'Default' that an import cannot bind (...)clause, while${Stage}now resolves todev. Neither of the two call sites is affected either -- bothawait resolveImportedProperties(...)for itsvoidmutation ofstackState.resourcesand never observe the parameter bag. Seven unit cases beside the existing issue #2285 arms (the file goes 86 -> 93), and the five added in review are the ones that make the DESIGN claims falsifiable rather than merely stated. The first two: the two-parameter template recordsapp-dev-topic, and a second template pairs that with a resource built from${VpcId}as a NEGATIVE CONTROL, asserting the rawFn::Subobject still reaches state and parsing the warn clause toVpcIdexactly (a substring match would pass on a list that grew toVpcId, Stage). The four added:Default: ''must recordapp--topicand a PRESENTDefault: undefinedmust recordapp-undefined-topic, which together fence the'Default' in definitionSPELLING -- review measured that BOTHBoolean(d.Default)andd.Default !== undefinedpassed all 88 cases before them, and the first re-opens this very bug for the standard CFn optional-String idiom; anAWS::SSM::Parameter::Value<String>default must record the LOOKED-UP value withssmSendactually called, which fences the{ ...template, Parameters: defaulted }spread and with it the whole reason for re-enteringresolveParametersinstead of hand-binding; a rejectedGetParametermust still reachsaveState, which fences the inner catch; and a NESTED CHILD template carrying the same mixed parameter shape must recordchild-dev-bucket, covering the SECOND call site (every other case drives only the root one). Seven mutations, applied one at a time with each restore verified by digest against the fixed file: reverting the fix reds 6 (including the nested case);Boolean(d.Default)reds 2;d.Default !== undefinedreds exactly 1 -- theundefined-key case, which is what isolates it; dropping the spread reds 2, onexpected 'app-${ImageId}-topic' to be 'app-ami-0abc-topic'and on the fallback case, whose residual assertion depends on the lookup actually being ATTEMPTED;throw defaultsErrin place of the empty-bag fallback reds 1 withError: process.exit-mock, i.e. the import ABORTS and the state write for already-adopted AWS resources is lost; binding every declared parameter to a placeholder reds 8, including the pre-existing issue #2285 arm; and widening the warn clause's population reds ONLY the negative-control case, onexpected 'VpcId, Stage' to be 'VpcId'. Every one of the seven is reddened by at least one mutation. One is deliberately NOT reddened by the full revert, and saying so is the point: the fallback case asserts a VERBATIMapp-${Stage}-topic, which is also what the pre-fix code produces, so only the targeted mutations discriminate it -- a reader who assumed the revert covers everything would mis-read that case as unfenced. The shared predicate's own rationale is repaired here, because this change falsifies it (issue #2335) -- and the first repair was itself wrong. Two JSDoc paragraphs onisUnboundTemplateParameterdescribed the live caller in the present tense as one that "continues with an EMPTY bag", and the second was the stated JUSTIFICATION for excluding aDefault-carrying parameter from the unbound population: "the only way to reach the resolver with one unbound is to have discarded the whole bag". A reader was being told the exclusion is safe because of a caller behaviour this change removes. The obvious repair -- that the exclusion now "describes a population that no live path produces" -- is FALSE, and the counter-example is inside this same commit, which is why it is recorded rather than quietly replaced. What #2321 fixes is the retry's SUCCESS path. On its FAILURE path the caller falls back to an empty bag,import.tsomits theparameterskey entirely when the bag is empty, so the context arrives withparameters: undefined, and the predicate answers'Default' in definitionBEFORE it ever consults the bound bag -- so aDefault-carrying parameter is unbound, unrefused, and itsFn::Subis written verbatim. The exclusion is therefore PRESENT-TENSE LOAD-BEARING. What removing it would DO downstream is deliberately NOT asserted -- a review probe measured that the refusal is swallowed by the per-resource catch inimport.ts, which warns and persists the raw intrinsic rather than aborting, so the obvious "hard failure" wording was a third false claim in this same paragraph. The shipped text names the fallback arm as the surviving producer, and the residual is PINNED by the test above rather than left as prose. The THIRD site naming the same caller was ALSO corrected, after review found two of its assertions had gone stale with the others: it still named the caller--migrate-from-cloudformationand still carried the same unproved hard-fail consequence. Two further corrections went in with it: the caller iscdkd importin EVERY mode, not only--migrate-from-cloudformation(resolveImportedPropertiessits onimportCommand's unconditional flow, and the new tests reach it with no migrate flag), and the resolver JSDoc's example parameter is renamed${Tier}becauseimport.ts's #2321 comments use${Stage}for the OPPOSITE role -- the one that DOES carry aDefault-- which is a collision this commit created. This paragraph has now been wrong twice in two rounds, in adjacent sentences. It is the one artifact in the change with no compiler and no test behind it, and it is written last, under the momentum of having just fixed the thing; that is the shape to expect, not carelessness.The
CreateBucketLocationConstraintcast is SETTLED as a deliberate stale-enum widening, and all FOUR cast sites are now fenced by tests rather than only described (issue #2322) --src/provisioning/providers/s3-bucket-provider.ts(comment only),tests/unit/provisioning/s3-bucket-provider-location-constraint-case.test.ts,tests/unit/assets/asset-storage.test.ts,tests/unit/cli/bootstrap.test.ts,tests/unit/cli/state-migrate.test.ts.S3BucketProvider.createsendsLocationConstraint: canonicalRegion as BucketLocationConstraint(:6227), and theasasserts a membership the value need not have. No runtime behavior changes anywhere in this PR -- the only non-test edit is comments in the provider; the three sibling source files are untouched and only gain coverage. Both alternatives the issue proposed were MEASURED and both were rejected. (1) Take the SDK's newer type -- the SDK has NOT widened it: against this repo's@aws-sdk/client-s33.1018.0,CreateBucketConfigurationatdist-types/models/models_0.d.ts:1581still declaresLocationConstraint?: BucketLocationConstraint | undefined. (2) Widen the local parameter type tostringand drop the assertion -- this does not compile. With the local bag's field typedstringand theasremoved,tscfails atnew CreateBucketCommand(createParams)withTS2769("Type 'string' is not assignable to type 'BucketLocationConstraint | undefined'"), so the assertion does not disappear; it MOVES to the send site and widens from one FIELD to the whole parameter bag. The cast is therefore kept where it is narrowest. The failure the fence guards is a LOUD one, and an earlier revision of this entry got that wrong in three places at once. It said a membership filter would "silently omitCreateBucketConfiguration, which makes S3 create those buckets inus-east-1" -- a residency bug. That is FALSE, and the correction is recorded rather than quietly applied because the residency reading is the natural guess. The provider's client is REGION-BOUND (:1898this.s3Client = awsClients.s3;getRegion()readsthis.s3Client.config.region()), so aca-west-1create is issued against theca-west-1REGIONAL endpoint, where an omittedLocationConstraintanswersIllegalLocationConstraintException-- the deploy FAILS. Theus-east-1default is documented by the SDK as a property of the GLOBALs3.amazonaws.comendpoint (CreateBucketCommand.d.ts), which this path never uses;src/assets/asset-storage.ts:775already states the rule ("For regions other than us-east-1, LocationConstraint is required") and.claude/rules/asset-bucket-region.mdalready names the exception. Measured, closing the last escape hatch: a genuinely region-less client THROWS rather than resolving empty --new S3Client({}).config.region()withAWS_REGION/AWS_DEFAULT_REGION/AWS_PROFILEunset and both config files pointed at/dev/nullgivesError: Region is missing, sogetRegion()'s|| 'us-east-1'fallback (:1924) is unreachable by that route and the global-endpoint story has no instance. Noted because it is the same defect class this PR exists to fix, committed inside the PR that fixes it, in the same number of copies as the original "four": a spot-check written as a measurement. The fence is unchanged and still earns its place -- "a change that breaks deploys in thirteen regions" is worth pinning -- only the stated reason is corrected. The gap is THIRTEEN regions, not four, and the earlier number was a spot-check written as an enumeration. Issue #2282's entry above, its test file, and the provider comment each namedca-west-1,mx-central-1,ap-east-2andeusc-de-east-1; that list came from probing four names rather than from differencing the two tables. Cross-checking the WHOLE region list against the enum (aws-cdk-lib2.244.0's 46-regionRegionInfo.regionsminus the enum's 33 members) reports fourteen absent, of whichus-east-1is absent BY DESIGN. The other thirteen areap-east-2,ap-southeast-6,ap-southeast-7,ca-west-1,eusc-de-east-1,mx-central-1,eu-isoe-west-1,us-iso-east-1,us-iso-west-1,us-isob-east-1,us-isob-west-1,us-isof-east-1,us-isof-south-1. The one-line derivation is recorded beside the cast and in the test file so the next reader re-derives instead of trusting a list that both tables can move under. Only FIVE of the thirteen are commercial, and an earlier revision of this entry said six:RegionInfo.get('eusc-de-east-1').partitionisaws-eusc, its own partition, whichPARTITION_TABLEinsrc/utils/aws-partition.tslists beside theus-iso-/us-isob-/us-isof-/eu-isoe-prefixes. All thirteen are swept and the partition distinction is deleted rather than restated: the production gate is a singlecanonicalRegion !== 'us-east-1'with NO partition branch, so every one of them traverses byte-identical lines and a split would have been a classification to maintain that bought no coverage. What is measured is the TYPE claim -- no deploy was made to any of the thirteen, so nothing here asserts that S3 accepts each one, which an earlier revision of the provider comment over-claimed. The direction that needed fencing is not removal of the cast. Removing it fails to compile, as measured above, so it is self-fenced. What compiles is a future "soundness fix" that filters the region to enum members: everyus-east-1andeu-west-1case stays green whileCreateBucketConfigurationis omitted for the thirteen. Three of the four cast sites were effectively unfenced, which is why this PR is not provider-only.grep 'as BucketLocationConstraint'gives four: the provider (:6227),src/cli/commands/state-migrate.ts:311,src/cli/commands/bootstrap.ts:274,src/assets/asset-storage.ts:778. A contributor applying the membership filter and grepping those four would have had exactly one red. Measured per site with that filter applied one at a time:bootstrap.test.tsandstate-migrate.test.tshad NO row that reds at all -- bootstrap asserted only the NEGATIVE polarity (two rows requiring the field to be ABSENT, which between them cannot fail on a change that stops sending it), and state-migrate stubbedCreateBucketCommandwithout ever looking at its input.asset-storage.test.tsis the worst case because it looked covered: itspasses LocationConstraint for non-us-east-1 regionsrow pinsap-northeast-1, which IS an enum member, so it stays GREEN under the filter. It did red on one row --derives an INVALID bucket name and LocationConstraint from an upper-cased region-- but only INCIDENTALLY: that row exists to pin an upper-cased-region defect, and its value is enum-absent by accident of casing rather than by design, so it does not fence this class deliberately and nothing keeps it doing so. Each of the three now carries a row pinning an enum-ABSENT region (ca-west-1) on the wire, and state-migrate also gains theus-east-1negative control it lacked, so "always send the constraint" cannot pass either. The three sibling SOURCE files are not modified. Twenty new unit cases across four suites, counted by running them rather than by reading the source (it.eachexpands): the four files now report 25 / 112 / 23 / 13 (vp test run). In the provider suite, on top of issue #2282's nine: thirteen sweep rows requiring each enum-absent region to reach the wire verbatim, one requiring a mis-casedCA-West-1to arrive asca-west-1(the two issues meet there -- #2282's fold has to survive on a region #2322 says the enum omits), one asserting every pinned name is a REAL region, and one asserting the gap still EXISTS. The real-region row exists because the pinned list is otherwise a hand-written literal with nothing checking it: measured, substituting'totally-not-a-region'for'ap-east-2'left every row GREEN, because a bogus name still round-trips through the provider and is still absent from the enum -- so a typo would silently drop a real region's coverage while both its row and the floor kept passing. The gap row is a FLOOR (absent.length > 0), not an exact match, so the SDK catching up on one region does not red the file; it reds only once the enum has caught up on all of them, which is exactly when the cast and both comments should be re-derived. (Accepted limit, deliberate: if twelve of the thirteen joined the enum the floor would stay green while those rows stopped discriminating.) Both it and the real-region row carry a guard-the-guard pinning both polarities of their own lookup, without which an empty member set or an empty region table would satisfy them vacuously. The pre-existingus-east-1rows remain the negative control -- the field must still be OMITTED there.getRegion()'s|| 'us-east-1'fallback is documented as MEASURED DEAD rather than changed (:1924) -- a provider behaviour change wants its own PR. It is dead becauseconfig.region()rejects rather than resolving falsy; the comment records that, and records why it is still worth fencing: if a refactor ever caught that rejection, this line would place the bucket inus-east-1silently, which is the one outcome the gate below cannot catch. Eight mutations, applied one at a time and restored between, each restore verified by digest against a snapshot. Four are the membership filter applied at each cast site in turn, and this is the measurement that justifies the sweep: at the provider it reds 14 of 25 (all thirteen sweep rows plus the mis-cased row) while leaving every #2282 row green; atasset-storageit reds 2 of 112 (the new row, plus the incidental uppercase-region row described above); atbootstrap1 of 23; atstate-migrate1 of 13 -- where before this PR the last three were 1-incidental, 0 and 0. The other four pin the provider suite's own rows: sending the rawregioninstead ofcanonicalRegionreds 4 including the mis-cased row (which is how that row is shown to discriminate on its own axis rather than duplicating theeu-west-1rows); swapping the pinned list for regions that ARE in the enum reds the gap row; emptying its member set reds that row's guard-the-guard; and substituting a bogus region name reds the real-region row. Every new case is reddened by at least one mutation.Four THROWN error messages no longer write the caller's account, role and session into the persisted deployments store (issue #2302) --
src/utils/aws-failure-text.ts(new),src/assets/asset-storage.ts,src/provisioning/providers/s3-bucket-provider.ts,src/deployment/retryable-errors.ts,src/deployment/retry.ts,tests/unit/assets/asset-storage-thrown-identity.test.ts(new),tests/unit/provisioning/s3-bucket-provider-thrown-identity.test.ts(new).assertAssetBucketRegion'sASSET_STORAGE_FOREIGN_REGION_BUCKETrefusal andS3BucketProvider'screate/update/deletewraps each interpolated a raw AWS failure message into a THROWN error. That is a different surface from the terminal warns PR #2290 fixed:extractDeploymentEventErrorcaptures a thrown message intodeployments/{runId}.jsonl, which outlives the run and is explicitly restricted to error plus metadata. On the headline population -- a principal withouts3:GetBucketLocation/s3:CreateBucket, or a bucket policy thatDenys it, the latter settable by anyone holdings3:PutBucketPolicyon the target -- S3 words itsAccessDeniedasUser: arn:aws:sts::<account>:assumed-role/<role>/<session> is not authorized to perform: ..., so the caller's account id, role name and session name landed in that durable store. The split now happens BEFORE the throw, which is the one part of the sibling PR's answer that does not copy across: the CLASS goes in the thrown message with a--verbosepointer, and AWS's own text goes tologger.debug. Both halves are emitted, because a redaction that DISCARDS the message is its own defect -- AWS's wording is what separates a missing IAM grant from a bucket-policyDeny, and that distinction is the operator's next action. The redaction is NARROW, and the obvious wider polarity was measured to be a regression before it shipped.describeAwsFailurekeys on the smithy marker fields ($metadata/$fault/$response) -- the same AWS-shaped testextractDeploymentEventErroralready uses -- rather than on "cdkd did not author it, i.e. it is not aCdkdError". The four sites are BROADcatchblocks, ands3-bucket-provider.tsalone raises six cdkd-authored plainErrors inside them: theBucketEncryption/OwnershipControlsarray refusals, the EventBridge and inventoryEnabledrefusals, the destination-shape refusal, anddeleteBucketWithEmptyRetry's non-empty-bucket refusal, whose message IS the CloudFormation-parity remediationdocs/troubleshooting.mdquotes verbatim. Reducing those to the tokenErrorwould delete the remedy the refusal exists to deliver -- the same defect as dropping AWS's message, arriving from the other side. Two negative-control cases pin it, and a mutation widening the predicate to everyErrorreds exactly those two while leaving all ten identity assertions green. The redaction silently disarmed the RETRY classifiers, and that half is fixed here rather than deferred.withRetryclassifies by SUBSTRING over the TOP-LEVEL message, and the deploy engine uses the DEFAULT classifier -- so withholding AWS's wording withheld it from the retry decision too. Measured onS3BucketProvider.createbefore the fix:not authorized to performwent from retryable-on-the-DENSE-IAM-cadence to NON-retryable, and S3'sconflicting conditional operation(OperationAborted, a real race for cdkd's concurrent DAG) from retryable to non-retryable.retryClassificationTextjoins the message chain, and the classifiers read it instead of the top-levelmessage, which stays what thewarn/debuglines print -- the chain text is the union of exactly what the redaction withholds and must never be logged or thrown. This is the missing THIRD walk rather than a new idea:isMarkedNonRetryableandisThrottlingErroralready walk the same chain for the same stated reason, they now share oneMAX_CAUSE_CHAIN_DEPTHso text can never be read deeper than a marker is visible (it was 10 against the marker's 5, which left a refusal marked at depth 6-10 legible but unmarkable), and the marker check runs FIRST, so a cdkd refusal still cannot be resurrected into a retry. The join is OPT-IN, and the enumeration that forced that is the review's most load-bearing measurement. An earlier revision joined the chain unconditionally and justified it with the claim that every wrapper onmaincopies its cause's message. That claim is FALSE --custom-resource-provider.ts'sdescribeWaiterFailurealready withholds the raw waiter payload -- and the shape it was covering for is not rare. A sweep ofsrc/**for constructions that attach acausebut do NOT carry its text enumerates 18 sites, listed in full in the PR body; the decisive one isdeploy-engine.ts:3355, the outer per-resource wrap, whose whole message isFailed to <op> resource <id>and which is UNMARKED and sits on every resource failure in the tree. No denominator is published for that 18, deliberately. Two independently written counting rules -- a regex sweep over constructor argument lists, and the review's TypeScript-AST rule keyed on an object-literalcause:property or a constructor parameter namedcause-- agreed on the eighteen SITES and disagreed by eleven on how many cause-attaching constructions to divide them by. The sites are individually checkable and the ratio is not, so the ratio is withdrawn rather than picked; the load-bearing claim was never the fraction but the flip below. Measured on that wrap, an unconditional join flipsfalse -> truefor aDependencyViolationcause and for adoes not existcause -- a broad retry-semantics change with nothing to do with this redaction. So a redacting throw now STAMPS itself withmarkRedactedCause(the same non-enumerable-symbol idiom asmarkNonRetryable, chain-walked to the same depth) andretryClassificationTextreturns the top-level message for anything unstamped. The no-op is then a property of the mechanism rather than of a survey, and the two negative controls assert exactly that: an unstamped wrapper over a retryable cause stays terminal, and the SAME chain stamped does flip. A SECOND message classifier had to be converted, and it is not reached byretry.ts.destroy-runner.ts's delete-retry loop callsprovider.deleteDIRECTLY, so it classifies on its own and theretry.tsfix does not cover it. Both of its arms now read the chain text.OperationAbortedis HTTP 409 with a non-throttle name, soisThrottlingErrorandisTransientServerErrorboth miss it and the withheld substring was the only arm keeping it retryable; the'Too Many Requests'arm is worse still, since its own comment records that it exists for the case where the original 429$metadatais LOST across the wrap, leaving the message as its only carrier.dynamodb-delete-budget.ts'sisTerminalDeleteFailureis the third instance of the shape and deliberately stays on the top-level message, with the reason recorded there: it classifies only DynamoDB's own throws, none of which redact. 37 unit cases across three new files and two existing suites, counted by running them rather than by reading the source (it.eachexpands, so a source-levelit(count disagrees):vp test run <file>reports 9 + 6 + 12 fortests/unit/utils/aws-failure-text.test.ts,tests/unit/assets/asset-storage-thrown-identity.test.tsandtests/unit/provisioning/s3-bucket-provider-thrown-identity.test.ts, and the two existing suites add 7 and 3 undervp test run tests/unit/deployment/retryable-errors.test.ts -t retryClassificationTextandvp test run tests/unit/cli/destroy-runner-non-retryable.test.ts -t 'REDACTED|UNSTAMPED'. Every AWS fixture carries S3's REAL wording, identity and all: a fixture readingAccess Deniedcannot tell a message that prints the CLASS from one that prints the MESSAGE, which is how the sibling PR's fixture passed for the wrong reason. The account id, the role ARN, the role name and the session name are each asserted absent SEPARATELY, so a partial redaction cannot pass on the strength of the others;debugand the default levels (info/warn) are separate spies, so "logged somewhere" cannot stand in for "logged where it is not persisted"; and aThrottlingExceptionarm in both files stopsAccessDeniedfrom being satisfiable as a literal. Twenty mutations across two rounds, applied one at a time and restored between, each restore verified: un-redacting the AWS branch reds 6 of 12, widening the predicate to everyErrorreds the 2 negative controls, removing eitherdebugline reds 3 and 4, reverting the asset-storage throw reds 3, un-redacting the non-Errorbranch reds 1, revertingretryClassificationTextto the top-level message reds 2, revertingretry.ts's hand-off reds the wiring case, revertingdestroy-runner.ts's reds both of its arms, dropping eithermarkRedactedCausestamp reds 3 and 1, stamping unconditionally reds 1, making the join unconditional reds the 3 opt-in controls and making it never fire reds 8, raising the depth back to 10 reds the paired depth case, re-gating the non-Errorlink ondepth === 0reds 1, dropping the$fault/$responsearms reds one case each, and dropping the empty-namefallback reds 1. Two of those probes found defects in the tests themselves rather than confirming them, which is the reason each half is probed separately: revertingretry.ts's hand-off initially reddened NOTHING, because the classifier cases pinned the functions rather than the call site (awithRetrywiring case was added); and the'Too Many Requests'destroy-runner case first passed under its own revert, because its fixture carried a 429 on$metadataand was therefore satisfied byisThrottlingErrorrather than by the arm it claimed to pin -- the cause now carries no$metadataand a non-throttle name, so the withheld message is the only surviving signal. A third, theredactedgate on the asset-storagedebugline, was unfenced until a probe forcing it ON stayed green. Residue, measured rather than estimated. The shape-based sweep for thrown sites interpolating a failure message (.message/String(x)reaching athrow, comments and strings stripped first,logger.*excluded by construction) reports 448 sites tree-wide after this change, 415 of them in files that import@aws-sdk/*;s3-bucket-provider.tsis now at 0.src/provisioning/providers/s3-bucket-policy-provider.ts's threeFailed to {create,update,delete} S3 bucket policywraps are the nearest siblings and are deliberately out of this delta. The population is far too large for one PR, so the four named sites plus the shared helper land here and the sweep is left for an umbrella issue.CreateBucketnow compares AND sends the CANONICAL region, so a mis-cased profile region no longer kills the create (issue #2282) --src/provisioning/providers/s3-bucket-provider.ts,tests/unit/provisioning/s3-bucket-provider-location-constraint-case.test.ts(new),tests/unit/provisioning/s3-bucket-provider-us-east-1-preflight.test.ts.S3BucketProvider.createdecided whether to attach aCreateBucketConfigurationwith a case-SENSITIVEregion !== 'us-east-1'over the valuegetRegion()reports, so both halves of the decision were wrong on an unfolded region. The issue reported the GATE: a mis-casedUS-EAST-1took the branch and sent aLocationConstraintfor the one region where S3 requires the field to be OMITTED. The SEND side carried the same defect for every OTHER region and is fixed by the same line --EU-WEST-1interpolated the raw spelling too, so it sentLocationConstraint: 'EU-WEST-1'. Neither is a valid region NAME, so real S3 rejects theCreateBucketoutright and the deploy dies there, before anything else on the path runs. The fix folds once and sends what it compared (canonicalizeRegion, already imported in this file and already used by the guard code beside it), which is why the issue's gate-only framing did not survive the audit: one sentence -- compare and send the canonical region, never the raw one -- describes both sites, and fixing only the reported one would leave every non-us-east-1 region broken in the identical way. The reachable path was MEASURED, and an earlier revision of this entry named the wrong one. It said--region US-EAST-1; that flag cannot reach the gate raw, being folded byfoldRegionOptionatsrc/cli/commands/deploy.ts:182before the client bag is built and folded AGAIN byAwsClients' constructor atsrc/utils/aws-clients.ts:105. The genuine door is that the second fold is CONDITIONAL (...(config.region !== undefined && { region: foldRegion(config.region) })) and every command builds a region-LESS bag when the user named no region (new AwsClients({ ...(options.region && { region }) }), the shapesrc/cli/region-options.ts:142already documents) -- so the SDK's own chain answers from the selected profile'sregion =line, and nothing on that chain folds. Measured against this repo's@aws-sdk/client-s3(3.1018.0), a profile holdingregion = US-EAST-1givesconfig.region() === 'US-EAST-1', which is exactly whatgetRegion()reads. So the population is a mis-cased~/.aws/configon a command that names no region, plus library callers constructingAwsClientswith no region at all -- not users passing--region.AWS_DEFAULT_REGIONis NOT a second door despitenamedCliRegiondeclining to read it: measured, the JS SDK v3 region chain does not read it either (AWS_DEFAULT_REGION=EU-WEST-1resolved to the ambient profile'sus-east-1, identical to the no-env baseline), so a mis-cased value there reaches nothing. Theas BucketLocationConstraintcast is untouched and still unsound, and canonicalization did not make it safe -- worth saying because "not a member of the enum" was this entry's earlier, wrong explanation of why S3 rejects the raw value. Measured: the enum has 33 members, excludesus-east-1by design, and ALSO excludes the real regionsca-west-1,mx-central-1,ap-east-2andeusc-de-east-1, every one of which the cast still sends today and S3 still accepts. The cast is a pre-existing stale-SDK-enum workaround; the reason S3 rejects'US-EAST-1'is that it is not a valid region NAME, which is a different test from enum membership. No consumer depends on the raw spelling reaching the wire:createParamsis acreate()-local literal built at one site, mutated twice and sent once (grep -n createParamsover the provider gives exactly four lines),LocationConstraintis not a CFn property ofAWS::S3::Bucketso it never enters state,effectiveProperties, drift or a readback, and the only test in the tree asserting an uppercaseLocationConstraint(tests/unit/assets/asset-storage.test.ts) pins the SIBLING defect insrc/assets/asset-storage.tsrather than this provider.regionis deliberately left RAW rather than reassigned, and that is the one non-obvious shape here: the issue #2241 pre-flight gate ~45 lines below folds it at its OWN site, and its unit case (recognises us-east-1 through a raw --region US-EAST-1 spelling) is what watches that fold -- canonicalizing the shared binding would have left the fold, and the case fencing it, unable to fail. Measured: removingcanonicalizeRegionfrom the pre-flight gate alone still reds that case and leaves the new file green, so the two folds are independently fenced. That case's own comment called itself HALF-REAL because production died atCreateBucketbefore reaching the steps it exercises; the caveat is now retired and replaced by an assertion that the rawUS-EAST-1create carries NOCreateBucketConfiguration, so the premise is pinned rather than asserted in prose. Nine unit cases, every one asserting the input the stubbedCreateBucketCommandACTUALLY RECEIVED rather than that a helper was called: three us-east-1 spellings (us-east-1/US-EAST-1/US-east-1) that must send noCreateBucketConfiguration, three eu-west-1 spellings that must all send the canonicaleu-west-1, a sweep over three further regions asserting no uppercase letter reaches the wire for any spelling -- which fails on a region the file does not name, unlike the six rows between them -- and two pinninggetRegion()'s|| 'us-east-1'fallback, which a reviewer found completely unfenced and which was re-measured here: withreturn region || 'us-east-1'replaced by a barereturn region, the WHOLE oftests/unit/provisioning/reds exactly one file and exactly two cases -- these two -- with the other 413 files green, so before them a future||->??would have shippedLocationConstraint: ''undetected. That last pair fences a CONTRACT rather than a live path, and the file says so:@smithy/config-resolver'scheckRegionthrows rather than resolving empty, so no real SDK client reaches the fallback today. The mockedconfig.region()is what makes an unfolded spelling expressible at all -- it stands in for the SDK chain resolving a mis-cased profile region, the only way this provider ever sees one. The two LOWERCASE rows are negative controls and are what refuse the obvious wrong fixes. The file primes no*Onceresponses at all: the us-east-1 rows issue a pre-flightGetBucketLocationthe eu-west-1 rows never do, so the mock routes by COMMAND and one arrangement is correct for every row. Seven mutations, applied one at a time to the real provider and restored between, with the restore verified by digest each time: reverting both halves reds 5 of 9 (and the pre-flight case), reverting the SEND alone reds 3, reverting the GATE alone reds 2 (and the pre-flight case), deleting the block outright reds 4 -- the negative-control direction -- inverting the comparison reds all 9, droppinggetRegion()'s fallback reds the 2 new fallback rows, and removing the pre-flight's own fold reds the pre-flight case with the new file untouched. Every case is reddened by at least one mutation and every mutation reds at least one case. Known siblings, and only ONE of the three is reachable -- an earlier revision of this entry listed all three as live, which was wrong. The rawregion !== 'us-east-1'PATTERN appears atsrc/assets/asset-storage.ts:736,src/cli/commands/bootstrap.ts:272andsrc/cli/commands/state-migrate.ts:309, but only asset-storage can be handed a raw value:bootstrap.ts:392passes itrawRegion ?? regiondeliberately, and its uppercase-region defect is already documented and pinned there as a reachability measurement (derives an INVALID bucket name and LocationConstraint from an upper-cased region) rather than as correct behaviour. The other two are already safe --bootstrap.ts's ownregioniseffective.regionfromresolveEffectiveRegion, which canonicalizes on every branch INCLUDING the profile one (region-options.ts:195), and its comment at:82-87names this exact defect class as the reason it scoped the raw spelling to that one argument;state-migrate.ts's comes fromnamedCliRegion(region-options.ts:102), which canonicalizes. Fixing asset-storage's constraint alone would still not make that flow work, because it derives the bucket NAME from the raw region too, which is issue #1820's lane rather than this provider's.An
Fn::Subover an UNBOUND template Parameter refuses instead of keeping the literal${Name}(issue #2285, the parameter residual of issue #2270) --src/deployment/intrinsic-function-resolver.ts,src/cli/commands/import.ts,tests/unit/deployment/intrinsic-sub-unbound-parameter.test.ts(new, 15 cases) and two cases intests/unit/cli/import.test.ts. #2270 stoppedresolveSublaundering a structural failure into literal text, but its predicate asked only about RESOURCES; a placeholder naming a template Parameter that is DECLARED, carries noDefault, and has no bound value still fell through to warn-and-keep, so${Stage}was written verbatim into the resolved properties of a live resource. Two premises of the issue were FALSE and are corrected here. The issue said "there is no upfront required-parameter validation anywhere in the CLI (grepped: none)" and proposed adding one as its shape 2.IntrinsicFunctionResolver.resolveParametershas always raisedParameter <name> is required but no value was provided and no default existsfor exactly this population, anddeploy-engine.tscalls it unguarded at its step 2.5 -- ahead of every resolver context the deploy builds. So a plaincdkd deploynever reachesresolveSubwith an unbound parameter at all: it has already failed, loudly, naming every parameter one at a time. Shape 2 is not a change to make; it is the behaviour that ships. The issue's second false premise follows from the first: it justified excluding aDefault-carrying parameter by "those deploys succeed today, and refusing them would be a hard-failure regression".resolveParametersmerges everyDefaultit sees, so no deploy reaches the resolver with aDefault-carrying parameter unbound either -- the exclusion is still right, but for a different reason (see below), not because deploys depend on it. What the change actually covers is the caller that CATCHES that error and resolves anyway.cdkd import --migrate-from-cloudformationis the live one:resolveImportedProperties(src/cli/commands/import.ts) logs the parameter-resolution failure at debug and continues with an EMPTY parameter bag, on a resolver context that is NOTbestEffort. EveryFn::Subover a template parameter then kept its placeholder, and the resulting${Stage}was PERSISTED into the imported resource'spropertiesinstate.json-- which is the desired bag a subsequentcdkd deploydiffs against and the bagcdkd destroyhands to a provider. That is the path by which the literal reaches AWS; it is two commands, not one, which is why the single-deploy repro the issue describes could not be reproduced. ONE predicate, shared verbatim, because the two sites ask the same question.isUnboundTemplateParameter(name, template, boundParameters)-- declared undertemplate.Parameters, noDefaultkey, and no bound value -- is now consulted by BOTHresolveParameters's required-parameter throw (hoisted above the branches that used to define the population implicitly) and by the renamedsubPlaceholderNamesADeclaredTemplateEntity, which is whatrethrowStructuralSubFailurecalls. Writing a second spelling was the alternative and it disagrees at a real edge: a key PRESENT in the bag with anundefinedVALUE is not a binding --resolveParametersfalls such a key through to theDefaultcheck -- so the obvious!(name in bound)paraphrase would have made the two sites answer differently for it. That edge has its own case. ADefault-carrying parameter the caller never merged is deliberately still tolerated: reaching the resolver with one means the whole bag was discarded, and refusing there would newly hard-fail input cdkd accepts today for a parameter whose value the template itself declares.bestEffort(cdkd diff/cdkd scrub) stays exempt on the same terms #2270 set, and the ORIGINAL error is re-thrown unchanged --Ref Stage not found, still carrying itsmarkNonRetryable-- rather than wrapped, so the substring-matching retry classifiers see what they saw before. The one caller this actually reaches now names the right remedy.resolveImportedProperties' per-resource catch (import.ts) was written for the sibling-not-yet-imported cause and offered "re-import once every referenced sibling is in state" pluscdkd state orphan. Both are useless for an unbound parameter, andcdkd importhas no parameter flag at all, so the operator was being pointed at an action that does not exist. A parameter-shaped clause is now APPENDED (the sibling guidance is untouched -- both causes reach this catch) naming the unbindable parameters, and it is gated by the SAMEisUnboundTemplateParameterrather than by re-reading the error text, so the message cannot disagree with the refusal that produced it. That import also makes the export load-bearing. 15 unit cases on the resolver plus 2 on the import command, and the INPUT is part of the discriminator: every refusing case declaresStageunderParameterswhileResourcesholds only an unrelatedBucket, because a resource of that name would make #2270's existing arm refuse on its own and the case vacuous. Eight mutations, applied one at a time with the file restored and re-verified by digest between each. Removing the parameter arm reds four resolver cases and leaves all eleven controls green -- and, re-measured against the import suite, reds the end-to-end case withexpected 'topic-${Stage}' to deeply equal { 'Fn::Sub': 'topic-${Stage}' }, which is the literal that used to be persisted; removing theDefaultexclusion reds two -- one on EACH site, which is the direct evidence the predicate is genuinely shared, not merely named once; ignoring boundness reds two, again one per site; dropping theundefined-value edge reds two, also one per site; removing the upfrontresolveParametersthrow reds the two agreement cases; dropping thebestEffortearly return reds the exemption case; making the predicate refuse EVERY unresolvable placeholder reds three, which is what fences the over-tightening direction; and suppressing the new import clause reds the import case while leaving its state assertion green. Twelve of the fifteen resolver cases are reddened by at least one mutation. The three that are not -- a bound parameter in the bare${Stage}form, the 2-arg variable-map form, and the escaped${!Stage}-- all short-circuit BEFOREresolveSub's catch, so no mutation of this change can reach them; they are regression guards on paths this change does not touch, and naming them is more honest than implying the whole file is probe-covered. Two controls exist only because a probe showed the obvious one does not discriminate. "Still resolves a parameter that IS bound" passes on the broken predicate too: a bound parameter resolves inresolveRefand never reaches the refusal at all. The DOTTED${Stage.Value}is the only shape that reaches the predicate with a head that is bound (Stage: 'prod', must keep) or key-present-but-undefined(must refuse), so those two carry the boundness clause and theundefinededge respectively. A related pre-existing behaviour is deliberately NOT changed: for the BARE${Stage},resolveReftestslogicalId in context.parametersand so returns theundefinedVALUE rather than throwing, and the placeholder becomes the literal textundefined. That isresolveRef's own contract, on a hot path, and out of scope here.A Cloud-Control-routed
AWS::S3::Bucketdelete confirms the bucket's region before issuing it (issue #2283) --src/provisioning/cloud-control-provider.ts,tests/unit/provisioning/cloud-control-s3-delete-identity-2283.test.ts,tests/integration/s3-lifecycle/. The issues #2227 / #2245 guards live inS3BucketProvider, on the SDK route. A bucket recorded asprovisionedBy: 'cc-api'never reaches that provider --ProviderRegistry.getProviderForstep 2 (the sticky rule) hands it toCloudControlProviderBEFORE the SDK provider is consulted -- and that provider's only region check is theassertRegionMatchon itsNotFoundbranch, which S3 is not expected to produce here because it follows the region redirect for a body-bearing operation. A state record naming a bucket that is ours but lives elsewhere therefore had itsDeleteResourceland on the live bucket in the other region, unrecoverably.delete()now issues oneGetBucketLocationbefore every mutating step -- ahead of the--remove-protectionflips and the SDK delegations too, since a protection flip against the wrong resource is already damage -- and refuses, non-retryably, when the answer disagrees with the state's region. The comparand's fallback population was MEASURED, and an earlier revision of this entry stated it wrongly. It said the fallback covers "the type-onlygetProvidercall sites (destroy / drift / state-refresh)"; all three of those in fact usegetProviderForWITHprovisionedBy(destroy-runner.ts:1284,drift.ts:2122/:3930,state.ts:2340/:2394), and drift / state-refresh never calldelete()at all -- the realgetProvider(sites areimport.ts,deploy-engine.tsandcanonicalize-properties.ts. The genuine population is (a)destroy-runner.ts:1336, which spreadsexpectedRegiononly whenstate.region !== undefined, so a PRE-v2 record (whereregionwas not yet part of the key layout) arrives with none, and (b) any caller threading an EMPTY region string --deploy-engine.tstypes itsstackRegionasstring, so''reaches the provider as a DEFINED value. That second case is why the resolution normalizes empty-to-absent instead of using a bare??:''is neithernullnorundefined, so??would have accepted it, skipped the client fallback, and landed in a warning whose text ("neither the stack state nor the AWS client reports a region") would then be false, because the client was never asked. The probe has three outcomes and all three are distinct. Answered-and-matching proceeds silently; answered-and-differing refuses non-retryably -- the marker is honoured by BOTH loops that wrap adelete(), the destroy path's own loop atdestroy-runner.ts:1328(FOUR attempts --attemptruns 0..maxAttemptsandmaxAttemptsis 3 at:1326-- with the marker gating both retryable arms at:1372) and the deploy engine'swithRetry(retry.ts:332) on the replacement-delete path; a probe that CANNOT answer proceeds but warns at default verbosity, because refusing would strand destroys for least-privilege roles while proceeding silently would let a bucket policy denyings3:GetBucketLocation-- settable by anyone holdings3:PutBucketPolicyon the target -- disable the guard with output identical to a normal destroy. An unresolvable SDK region chain gets that same treatment (the resolution is inside thetry), so the guard cannot fail CLOSED on one undeterminable input while failing open on every other. An ABSENT bucket is a fourth case and is deliberately not the warning one: it falls through to the existingNotFound/assertRegionMatchhandling, so an ordinary re-run of a finished destroy stays quiet. Absence is read from the wire CODE alone, never a message, so a bare 404 from a proxy or an S3-compatible gateway cannot be mistaken for a positive statement about a bucket's region. The probe deliberately omitsExpectedBucketOwner, unlikestate.tsandutils/aws-region-resolver.tswhich both pass it: those ask "is this MY bucket", while this one has to HEAR the foreign answer to refuse, so restoring the convention would turn every cross-account collision from a refusal into a 403 and thence into warn-and-proceed. The refusal's remedy names correcting the state record rather than re-running with--region, because the comparand is the region stored in state and the flag does not change it. 31 unit cases. Both polarities of the routing decision are asserted, and the split is exact rather than uniform:AWS::S3::Bucketplus TWO control types (AWS::SQS::Queue,AWS::DynamoDB::Table) are driven throughdelete()end to end and must issueDeleteResourcewith no probe and no new IAM dependency, while FIVE further control types (AWS::Lambda::Function,AWS::EC2::Instance,AWS::AutoScaling::AutoScalingGroup,AWS::S3::BucketPolicy,AWS::S3Express::DirectoryBucket) are asserted against the exported predicate only. The us-east-1 fold is pinned across all THREE wire spellings -- an ABSENT field,'', andnull-- because AWS's own API documentation calls a us-east-1 bucket's constraintnull(verbatim in@aws-sdk/client-s3'smodels_0.d.ts:6806, and what the CLI renders) -- but that sentence is the doc comment ON the field, and the DECLARED type four lines BELOW it, atmodels_0.d.ts:6810, isGetBucketLocationOutput.LocationConstraint?: BucketLocationConstraint | undefined.nullis not in that union, so a v3 client can present the same fact as an absent field. (An earlier revision of this entry cited:1581, which isCreateBucketConfiguration-- the CreateBucket INPUT -- and placed it "above" rather than below.) A fixture that can only expressnullshares its premise with the production code, which is the one thing a mutation probe cannot falsify. Every fold gets both polarities, so an OVERfold is caught as well as an underfold (eu-central-1must not read as the legacyEUalias). Twenty-three mutations, applied one at a time and restored between, and the counts below were re-measured in one batch on the exact tree this entry ships with -- the two files as committed here,shasum(SHA-1)c3f449d5b323...forcloud-control-provider.tsandef6f2822692d...for the test -- rather than patched from an earlier round -- every count in an earlier revision of this sentence had drifted low, because four cases were added after it was written. Every mutation reds at least one case, none is a no-op, and all 31 cases are reddened by at least one mutation (computed as a set difference over the run logs, not by inspection). Emptying the type set reds 24, removing the guard call 23, inverting the comparison 22, moving the guard AFTER the delete is issued 8, dropping the us-east-1 fold 6, preferring the client region over the state's 5, widening the type set to two control types 3, gating the guard onremoveProtection !== true3, downgrading the indeterminate warn todebug2, treating anullconstraint as the only absent spelling 2, and 1 each for: moving the guard BELOW all three--remove-protectionblocks, making the injected protection entry inert, droppingmarkNonRetryable, collapsing absent into indeterminate, matching absence by message instead of wire code, dropping theEUfold, overfoldingeuby prefix, dropping the region canonicalization, dropping the empty-region normalization, hoisting the client-regionawaitout of itstry, dropping either.trim()(one each), and reverting the remedy wording. One ordering claim was asserted, probed, and found FALSE before shipping. An earlier revision said the guard's placement ahead of the--remove-protectionflips was "fenced by a unit case"; moving the call below all three protection blocks left the suite fully green -- as it still does on any tree without the injected case described next. With the production tables the order is unobservable --AWS::S3::Buckethas nocc-protection-properties.tsentry and is neither SDK-delegating type, so nothing mutating precedes the probe and "zero Cloud Control traffic on a refusal" fences only a mutation that SKIPS the guard. The test now injects accProtectionPropertyentry for the bucket type (test-side only, no production routing changed) so the delete has a realUpdateResourceCommandto issue first, plus a guard-the-guard case proving the injection reaches the provider; the ordering mutation now reds. The two SDK delegations remain unfenced, deliberately -- fencing them would mean putting a delegating type into the checked set, which is a routing change rather than a test. Live arm:tests/integration/s3-lifecyclephase 0c plants two hand-written single-resource state records -- the defect's actual premise, a record written before the guards existed -- one naming a bucket really in this region (the negative control, which must still delete through the Cloud Control route, and without which the other arm would pass on any malformed-state failure) and one naming a per-run unique bucket in another region, asserted on the refusal text AND on the bucket still standing afterwards. Both bucket names are per-run unique because a name that has existed in one region answersOperationAbortedfor well over ten minutes if re-created in another. The arm has RUN against real AWS. This sentence deliberately cites NO ledger row, and that is the fix rather than an omission. The ledger holds ONE row per test, so any timestamp named here is superseded the next time any lane runss3-lifecycle-- andchangelog-ledger-citation.test.tscorrectly refuses a stale one, which means a re-derivation every time. This citation was re-derived three times (the issue #2283 lane's own 2026-08-27 run, then the issue #3032 lane's, then the go-to-k/cdkd#3309 follow-up lane's, twice) before the churn was recognised as structural. The row moving is the one-row-per-test invariant doing its job; the arm has never changed. The durable claim is the one below -- what the arm asserts and what every run of it has found -- and it is checkable againstdocs/_generated/integ-last-run.tsv's currents3-lifecyclerow without this prose naming a timestamp that will be wrong by the next run. The verdicts have been identical across every run of it: the XR arm refuses with rc=2 naming both regions and the us-west-2 bucket survives, while the OK control arm still deletes through the Cloud Control route, so the guard is not a blanket refusal. Cleanup was clean on each: 7 deleted, 0 errors, an account-widestate.jsoncount of 0 and no leftover buckets in either region. Accepted residual, with its mechanism CORRECTED:CloudControlProvider.update()is NOT guarded. An earlier revision of this entry saidResourceProvider.update"carries noDeleteContext, so the state's region is not available there at all" -- the conclusion holds but the reason was false, and it overstated the cost of ever fixing it.src/types/resource.ts:793-800DOES giveupdate()a sixthcontext?: UpdateContextparameter (this provider's ownupdatemerely does not declare it); what is missing is the FIELD, sinceUpdateContext(resource.ts:502, which also extendsSecretMaskingContext) carries no region field at all whileexpectedRegionis declared onDeleteContextalone (region-check.ts:27). The remedy is therefore one optional field plus threading it from callers that already holdstate.region, not a signature change across every provider -- a separate change with its own review, filed as issue #2301 together with the non-S3 CC delete types. The delete path is taken first because its consequence is unrecoverable where a misapplied configuration is not.A literal layer-version ARN now PARSES in all eight partitions instead of three, and its partition must agree with its region (issue #2143) --
src/local/lambda-resolver.ts,tests/unit/local/lambda-layer-arn-partitions.test.ts(new),tests/integration/local-invoke-layers/{lib/local-invoke-layers-stack.ts,verify.sh},docs/local-emulation.md.parseLayerVersionArnmatched/^arn:(aws|aws-cn|aws-us-gov):lambda:([a-z]{2}-(?:[a-z]+-){1,2}\d+):(\d{12}):layer:([A-Za-z0-9_-]+):(\d+)$/, which carried TWO independent defects. (1) The partition alternation listed three of eight, so a layer ARN inaws-iso,aws-iso-b,aws-iso-e,aws-iso-foraws-euscdid not parse -- and the caller HARD-THROWS on an unparsed ARN, socdkd local invoke/local start-apion any function with a literal-ARN layer failed AT RESOLUTION in five partitions. (2) The region group required a first token of exactly two letters, the same^[a-z]{2}shape issue #2001 fixed instate-file-keys.ts, which rejects the European Sovereign Cloud partition's four-lettereusc-de-east-1; it also capped the interior<word>-chunks at two, an independent bound. Neither defect subsumes the other: fixing the partition list alone still rejectseusc-de-east-1, and fixing the region shape alone still rejectsarn:aws-iso:lambda:us-iso-east-1:.... The partition is now DERIVED from the region throughderivePartitionAndUrlSuffix(src/utils/aws-partition.ts) rather than hand-listed a fourth time, so the list cannot go stale here independently -- and that also makes the pair SELF-CONSISTENT, which no alternation can:arn:aws-cn:lambda:us-east-1:...used to parse, pairing China's partition with a commercial region. The region group stays SHAPE-based ([a-z]{2,}(?:-[a-z]+)+-\d+) rather than becoming a charset, becausederivePartitionAndUrlSuffixanswersawsfor a region it does not recognise: a charset would makearn:aws:lambda:notaregion:...parse. It is still much wider than the set of real regions --garbage-junk-1matches where the old pattern refused it (measured) -- and that is deliberate, since the segment's POSITION inside anarn:string already establishes it is the region field, so unlike a state-key segment there is no second interpretation for a loose match to steal. The commercial fallback is otherwise the direction that must keep working -- a brand-new COMMERCIAL region still resolves before the table hears about it, while a region in a future partition is refused rather than mis-attributed to commercial, the same trade every other consumer of that table makes. Deliberately NOTisClientSafeRegionfromsrc/deployment/intrinsic-function-resolver.ts: that predicate is charset-based because its job is keeping a value inside a hostname label, and the note it carries already says so. SCOPE: this fixes the PARSE, and the download behind it is still commercial-only -- so "eight partitions" is a claim about resolution, not about end-to-end support.materializeLayerFromArn(src/local/layer-arn-materializer.ts) is a one-line shim re-exporting cdk-local's implementation, and that implementation rebuilds the ARN with a hardcodedawsbefore the SDK call --node_modules/cdk-local/dist/local-studio-BBtUAVNy.js:15214,const command = await buildGetLayerVersionCommand(`arn:aws:lambda:${layer.region}:${layer.accountId}:layer:${layer.name}`, Number(layer.version)). So a layer in ANY of the seven non-commercial partitions still fails atlambda:GetLayerVersion--aws-cnandaws-us-govincluded, and those two are no better off than before, since they already parsed under the old alternation. What this change moves is confined to the FIVE that previously did not parse (aws-iso,aws-iso-b,aws-iso-e,aws-iso-f,aws-eusc): for them the failure shifts from cdkd refusing to read a perfectly valid ARN to an AWS-side error naming the real blocker. Nothing in this repo can close the rest: it is filed as go-to-k/cdk-local#575, which also records that cdk-local's ownderivePartitionAndUrlSuffixknows only four of the eight prefixes. UPGRADE NOTE -- one shape that used to work now hard-throws. A literal layer ARN whose partition disagrees with its region --arn:aws-cn:lambda:us-east-1:...,arn:aws:lambda:cn-north-1:...,arn:aws-us-gov:lambda:us-east-1:...-- parsed before and is now refused withcdkd cannot resolve locally. This is deliberate and was asked for by the issue's own direction ("which also makes the pair self-consistent"). It costs nothing real: because of the commercial-ARN rebuild above, such a layer could never have been fetched from the partition its ARN named, so the refusal only moves an inevitable failure earlier and gives it a message that says what is wrong. A correctly-paired ARN in any partition is unaffected. The refusal message gained one sentence for exactly this class, and only for it -- every other rejection is visible in the ARN a user is looking at (a missing version, a 13-digit account,functionwherelayerbelongs) whereas a mismatch looks ordinary. The sentence has TWO arms, becausederivePartitionAndUrlSuffixanswersawsboth for a genuinely commercial region and for one noPARTITION_TABLEprefix matches: on a table HIT it states membership as fact (...which is in partition 'aws-eusc'.), and on a MISS it states the RESOLUTION instead (...no partition prefix matches that region, so cdkd resolves it to the commercial partition 'aws'.). A single assertive arm would tell a user thatarn:aws-iso-g:lambda:us-isog-east-1:..."is in partition 'aws'", which is a guess asserted as fact. Which arm is which is easy to get backwards, and the first cut of this split did:PARTITION_TABLEholds seven rows and none of them isaws-- the commercial partition is the fallbackreturn, not a row -- so the MISS arm is the arm every genuinely commercial region takes, and the HIT arm can never renderawsat all. Its first wording, "no partition prefix cdkd knows matches that region", was true of the mechanism and read as cdkd failing to recogniseus-east-1, which is both the commonest AWS region and the canonical example this entry, the docs and the integ fixture all use. Review caught it; the wording now leads with the prefix test and ends on the resolution, and a unit negative pins the old phrasing out. Tests treat this as the CLASSIFIER it is. Hand-picked cases cannot fence an accept/reject function -- a pattern widened until it accepts everything satisfies every positive assertion ever written -- so the new suite runs a DIFFERENTIAL fence: the pre-fix regex, transcribed fromorigin/mainrather than from memory, is run against the shipped code over 352 generated inputs (10 partition strings x 28 region shapes, plus 72 structural malformations of four self-consistent bases), and every difference must fall in an enumerated intended class. Cells are classified by the value the NEW code returns, not by the input's shape, so a total regression lands wholly in one bucket and fails that bucket's predicate instead of being sorted into "expected" ones; the predicate itself is an INDEPENDENT oracle built fromsplit(':')/split('-')plus a hand-written prefix table (pinned againstPARTITION_TABLE), so it cannot agree with the implementation by construction. Each class carries a floor -- all five previously-missing partitions present among the newly accepted, at least oneeusc-region, at least one region past the old{1,2}cap, at least 10 newly-rejected mismatches (measured: 11 newly accepted, 40 newly rejected, 0 parsed-field drift, 13 unchanged accepts, 288 unchanged rejects) -- so a pool that stops covering a class cannot pass as "no regressions". On top of it: one case per partition pluseusc-de-east-1, all driven through the REAL CALLER (resolveLambdaLayers) so what is asserted is the hard-throw, and 16 negative controls that must still be REJECTED. Mutation-probed in three directions, each half separately, because a single all-or-nothing revert cannot show the two are independently fenced: reverting the PARTITION half alone reds 10 (the five per-partition cases, the eusc pair, all three mismatch controls, and the fence's newly-accepted predicate); reverting the REGION half alone reds 4 (the eusc pair, the three-hyphen-group case, and the fence's partition floor); widening the region to[^:]+with no derivation check -- the "accepts everything" direction -- reds 10, nine of them negative controls. Three more probe the message: collapsing the two arms into the assertive one reds 2, emitting a hint for a shape rejection reds 1, and unwiring the hint reds 3. Structurally, ONE exportedclassifyLayerVersionArnnow returns either the segments or a TYPED rejection, and both the resolved layer's fields and the error message read that one verdict -- so the message cannot describe a different verdict than the parse reached. The first cut had the message re-run the pattern in a separate helper, which left an arm -- "the partitions agree" -- that the sole call site could never reach, since it only asked after the parse had already refused; one classifier removes the arm rather than justifying it, and runs the regex once. The oldparseLayerVersionArnexport is GONE rather than kept as a wrapper, and the reason is worth recording because the repo's own critic found it: once the resolver moved to the classifier that wrapper had nosrc/caller left, andscripts/check-local-reachability.ts(shipped days earlier by #2293) reported it as an orphaned export -- 7 of its 39 cases red. Its opt-out tag would have been a FALSE statement here, since it asserts cdk-local owns the live implementation and cdkd owns this one, so the wrapper was deleted and theT | undefinedshape its cases were written against became a three-line adapter insidetests/unit/local/lambda-resolver.test.ts. Naming that tag in a JSDoc comment while explaining the decision then reported a STALE annotation on a live symbol, which is the same critic working correctly from the other side; the comment now describes the tag instead of spelling it. Thelocal-invoke-layersinteg fixture gains aMismatchedArnLayerHandlercarryingarn:aws-cn:lambda:us-east-1:...and a fourthverify.shtest asserting cdkd refuses it. That is the half of #2143 an integ CAN exercise -- the five previously-unsupported partitions have no endpoint reachable from a normal dev account, whereas a mismatch is a purely local verdict with no network call -- and what it adds over the unit matrix is that it runs the SHIPPEDdist/bundle, where a brokensrc/local->src/utilsimport would show up. It is discriminating rather than decorative: pre-#2143 that ARN parsed and cdkd went on to attempt alambda:GetLayerVersiondownload, and the test asserts the refusal names the DERIVATION rather than merely refusing -- a bundle whose layer parse broke wholesale would also refuse.docs/local-emulation.mdis corrected as part of this, and the correction predates this issue: its "Lambda Layers" section still listed literal-ARN entries under "Out of scope (v1) -- hard-errors", and its v1-scope table still deferred them to a "Future PR", both of which stopped being true when issue #448 shipped literal-ARN resolution. They now describe what ships -- the download-and-merge flow, the partition rules with the commercial-only download limitation called out beside them, and--layer-role-arn, which existed as a flag but appeared in no documentation at all and is now in both thelocal invokeand thelocal start-apiflag tables.
- The secret-mask critic's masker recognition was receiver-blind, file-wide
scoped, and blind to a bare logger call (issue
#2269) —
scripts/check-provider-secret-mask.ts,.claude/rules/layout-scripts.md. Three findings and five nits from the review rounds on PR #2265 (issue #2178), all on one predicate: what the critic accepts as "the value reached the project's masker". No shipped exposure — the real tree classified 83 files / 41 sites / 37 masked / 4 exempt / 108 derived masker names before and after, bit for bit. (1) RECEIVER-BLIND. Every masker position accepted any property access whose FINAL name landed in the derived set, so with ~108 derived names tree-wideconst junk = { maskLeaf: (t) => t }bought bothmaskDeep(p, junk.maskLeaf)andjunk.maskLeaf(p)amaskedverdict; the fence really read "the value reached something whose last identifier collides with a masker name", which is not what KNOWN BOUND (4) claimed. Now receiver-checked — the CONTRACT's ownmaskSecretson any receiver (that IS the bound, and stays self-probed), a DERIVED name only offthisor a chain rooted at it — and the bound is restated in both artifacts. Free on the real tree, whose only property-access maskers arethis.maskErrorMessage(18) andthis.maskedRetryLogger(5). (2) FILE-WIDE IDENTITIES.identitieswas one name pool, so a class whosecreate()bindsconst mask = maskerOrIdentity(context?.maskSecrets)and whosedelete()bindsconst mask = maskerOrIdentity(undefined)had BOTH arms readraw— a false POSITIVE on the correct sibling, dodged in #2265 only by spelling the delete armDELETE_PATH_UNMASKED. Refusals are now recorded with the function that owns them; a module-scope one still reds everything. Scoping ALONE would have traded that for a false NEGATIVE, sincenamesstays a file-wide pool, somaskersAtsubtracts the refusals in scope at a site from the names in scope at it and rule 1 records a wrapper at its own lexical scope — both directions carry a probe. (3) A barelogger.warn(JSON.stringify(x))reaches the same sink as a site and was neither classified NOR counted, because the concat counter fences only+operands. It now gets bound (1)'s treatment as KNOWN BOUND (5) (MAX_BARE_SINK_SITES). A LEVEL call is matched RECEIVER-first with its own refuse twin; a FACTORY call is matched by NAME (wrapError/wrapUpdateError/handleError), which is a REVIEW-ROUND correction of exactly the over-claim this critic exists to prevent: the first cut requirednew, so the 33 messages this corpus throws throughthis.wrapError(...)(24) andthis.wrapUpdateError(...)(9) sat outside the count while the prose called the zero measured, andthrow this.wrapError(JSON.stringify(properties))ran at exit 0. Two receivers were missed the same way —options.warn(...)andgetLogger().child('SNSTopicProvider').warn(...), the latter putting a CallExpression in the receiver position — so the receiver reader now WALKS the chain. The zero is now measured against the WHOLE population: 331JSON.stringifycalls in the corpus = 41 interpolated sites + 0 concat operands + 0 bare message sinks + 290 residue, and the residue is entirely non-message (228 deep-equality comparisons, 41?:value arms, 10returns, 6 arrow bodies, one??, one=, oneMessage: JSON.stringify(request)on an SNSPublishCommandatcustom-resource-provider.ts:2182, onecreateHash('sha256').update(...)atcloudwatch-anomaly-detector-provider.ts:421and oneBuffer.from(...)). The widening's COUNTER-DIRECTION was then measured and closed, because a guard that reds correct code is worse than the miss it replaced:throw this.wrapError(JSON.stringify(maskDeep(v, m)))had the value already through the project's masker and failed CI, so a MASKED bare sink is no longer counted — decided byisMasked, the same predicate the interpolated-site path uses, so the two populations cannot drift. The failure now LOCATES each site and names which FORM matched (<file>:<line> (logger call | error factory | error constructor)), the same argument that addedtruncatedFiles. Two more escapes were closed — a cast argument (logger.warn(JSON.stringify(v) as string)) counted zero because the parent walk stripped parentheses only whileunwrapstrips casts everywhere else, andwrapDeleteErrorescaped the NAME list, now matched by a^wrap[A-Za-z]*Error$pattern whose durable half is an ENUMERATING test over everythrow <call>(...)callee in the corpus that fails on a name neither the predicate nor an audited not-a-factory list knows. Three remaining loosenesses are STATED rather than left to be found, all fail-closed with zero live hits: the receiver reader matches any name in a chain, the factory arm ignores the receiver, and an aliased logger (const sink = this.logger; sink.warn(...)) escapes. The nits:maskDeep(v, m, 1)classified raw because EVERY argument past 0 had to be a masker (index 1 still must be, indexes past it may be a CONSTANT option, somaskDeep(v, undefined)stays refused);maskDeep(v, flag ? a : b)classified raw for want of aConditionalExpressionarm (both arms required, unlike??, whose left-arm rule exists for the contract's own fallback); the fixpoint cap truncated SILENTLY and now fails the run;file.startsWith(REPO_ROOT)matched a sibling by prefix and now matches on a path segment, which matters because that branch mints the stringEXEMPTis keyed on; and the deadreport?.filesScannedis gone with the scan-failure path returning through one shared reporter. A FOURTH route to a masker that masks nothing surfaced from the differential rather than the finding list and is closed here too:const mask = maskerOrIdentity(undefined); const maskLeaf = (v) => maskDeep(v, mask)mademaskLeafa masker on BOTH sides of the change, becausewrapsFirstParameterread only the callee. Verified by a DIFFERENTIAL fence, because probes are cases chosen by whoever chose the rule — which is how the receiver-blindness shipped past 50 of them.tests/unit/scripts/provider-secret-mask-recognition-2269.test.tsruns the frozenorigin/mainclassifier (tests/unit/scripts/fixtures/secret-mask-baseline-2269.ts, verbatim, never re-synced) beside the live one over the 83-file real corpus plus a CROSS-PRODUCT pool, and requires every disagreeing cell to land in an enumerated class whose declared transition it actually shows. Both directions are watched — a cell a class CLAIMS and that no longer moves fails too — and each class carries a FLOOR, with the coverage guards written as EXACT products rather than floors (a floor let a dropped generator arm slide while the class floors still passed). Measured: 98 cells, 39 differing (receiver-narrowed 15, identity-scoped 8, wrapper-launders-identity 6, option-argument 6, conditional-masker 4), ZERO differences over the real corpus. 36 new self-probes (50 -> 86) and 30 new unit tests plus 4 new spawn probes on the existing suite, each fix mutation-proven ONE AT A TIME — 31 probes: one per fix, a partial-revert twin for four, and nineteen more across three review rounds. Bound (5)'s own looseness is stated with the DIRECTION of each item (one fail-CLOSED, three fail-OPEN with zero live hits) rather than as a blanket fail-closed claim, which would have been this critic's own defect committed inside its remedy; and the two spellings of the transparent-wrapper question —unwrap's strip and the parent-chain test — are bound together by a test that parses thets.isX(guards out of both bodies and requires the sets to be equal, since their DIVERGENCE was the cast escape this round closed. The twins are what found the sink RECEIVER check unfenced (theBuffer.fromrefuse case rejects on the METHOD name and never reaches it) and thewrapsFirstParametercopy of the trailing-option guard unfenced (relaxing it left all 92 tests green while makingconst maskLeaf = (v) => maskDeep(v, undefined)a masker — a wrapper laundering a no-op).--jsonstdout is now pure data: the human summary moves to stderr under that flag, matching the sibling criticcheck-local-reachability.ts, so--json | jqno longer fails on trailing prose. - Two nested-stack
Parametersresolving to ONE plaintext now keep DISTINCT{{resolve:...}}expressions across the parent -> child handoff (issue #2291) --src/deployment/secret-redaction.tsandsrc/deployment/deploy-engine.ts, plustests/unit/deployment/secret-redaction-nested-parameter-source.test.ts,tests/unit/deployment/deploy-engine-nested-stack-shared-parameter-plaintext.test.tsand a new arm ontests/integration/nested-stack-secret/. A parent stack resolves its child'sParametersblock, so the child engine receives PLAINTEXT while the child's own template spells the consumption as{Ref: <ParamName>}. Two references to one secret whose values coincide -- the same secret and JSON key at two version-stage spellings, where...:handoff::and...:handoff:AWSCURRENT:resolve identically -- therefore collapsed for TWO independent reasons: the parent'sRecordedSecretValuesbag is keyed by PLAINTEXT, so the pair became a single entry BEFORE the child engine was constructed, and the child's intrinsic-object source leaf carries no expression for the position pass to certify against. Both child leaves persisted whichever expression the parent recorded LAST, so a leaf held its SIBLING's version stage -- andresolveReplayPropsre-resolves what is persisted, socdkd drift --revert/ rollback would have pushed the WRONG secret version to the live resource. The parent now records, per child PARAMETER NAME, which expression that parameter was resolved from (recordNestedStackParameterExpressions, at the deploy engine's CREATE and UPDATE call sites for anAWS::CloudFormation::Stackrow) -- derived from the POSITION pass over the parent's own UNRESOLVED template, which is the only uncollapsed source left once the bag has collapsed. Those per-parameter entries are copied onto each child resolver context's bag as ordinary{Ref: <Param>}position associations, so the existing three-condition cross-stack reader answers for them with no new arm. The DIFF side moves with the persist side (redactParametersForDiffnow answers per parameter first): without it the losing parameter's desired side would keep the survivor's expression forever and its resource would report a spurious UPDATE on every deploy. Every refusal degrades to the plaintext-keyed value scan, i.e. to the previous behaviour, so no non-nested path changes. Three findings from this lane's own review rounds are worth recording, because each is a trap for the next change to this module. (a) Making only the DIFF side per-parameter left the two halves DISAGREEING for every EMBEDDING shape:crossStackSourceKeyrefuses a non-dottedFn::Subplaceholder, soFn::Sub "postgres://u:${LoserParam}@host"-- the dominant CDK connection-string shape -- still took the collapsed survivor on the persist side while the desired side computed the loser's, i.e. a perpetual UPDATE (a perpetual REPLACEMENT on a create-only property), which is issue #2087's symptom through a different door.IntrinsicFunctionResolver.recordInheritedParameterSecretsnow takes the parameter NAME and records THAT parameter's own expression, so the value scan agrees with the diff side; the residual is named in the code and, in round 3, MEASURED: a resource consuming AND embedding BOTH colliding parameters keeps one expression, which is genuinely inherent to a plaintext-keyed bag -- but the neighbouring MIXED shape (one EMBEDDED leaf plus one WHOLE-VALUE leaf, in one resource) is a NEW disagreement this PR introduces rather than one it merely fails to close. Measured againstmain(f56c2cf9) and against this branch: onmainboth halves take the collapsed survivor, so they MATCH (on the wrong expression -- that is #2291 itself -- but they match, so nothing perpetually updates); here the diff side is per-parameter while the embedded leaf reads the one plaintext-keyed bag entry, which is whicheverRefresolved LAST, so the two halves diverge and the resource reports an UPDATE on every deploy (a REPLACEMENT on a create-only property). It is order-dependent -- reversing the two properties makes them agree again -- and needs both leaves in ONE resource, sinceperResourceSecretsis keyed by logical id. Closing it needs a placeholder-SPAN position arm, which is a new positioning concept rather than an arm beside the existing ones, so it is deferred to issue #2320 with the measurement. Nothing live covers the mixed shape: the new integ arm deliberately gives itsFn::Subleaf its OWN resource so its expected value does not depend on property iteration order. (b) The rollback REPLAY had none of it:rollback-executor.ts's three arms (reverse-replacement,revert,--revert-failed) hand a nested child the same kind of bag, so a rollback silently rewrote correct state back into the #2291 shape and acdkd drift --revertinside that window pushed the WRONG secret version to the live child resource. They now record from the JOURNAL (the uncollapsed side) underSTATE_DERIVED_RULES, and only from the DESIRED side, which is the generationNestedStackProviderforwards as the child'sParameters. (c) A fourth refusal was added after a probe showed the recorder could not tell a CERTIFIED leaf from a value-scan FALLBACK: anssmreference whoseSecureStringverdict is unpinned takes the public-reference branch underTEMPLATE_DERIVED_RULESand value-scanned its way to the sibling's expression, so the losing parameter of an unpinned ssm pair was stored against the WRONG reference.
Recently Implemented (2026-08-26):
The custom-resource poll no longer debug-logs the first 200 bytes of the response body, so a generated secret in
Datanever reaches--verboseoutput (issue #2250) --src/provisioning/providers/custom-resource-provider.ts, plustests/unit/provisioning/custom-resource-provider-response-body-log.test.ts. Each poll of the pre-signed S3 response key used to logbody.substring(0, 200)of the CloudFormation custom-resource response document.Datais the documented place a handler returns a GENERATED VALUE -- including a generated secret -- so behind a shortPhysicalResourceIdthose values landed inside the 200-character window and reached the terminal and, in CI, the retained build log. It fired for EVERY custom-resource response, gated only by--verbose. The line now renders the non-sensitive ENVELOPE instead:Status,PhysicalResourceId(already persisted tostate.json, so not a new channel) andObject.keys(Data)-- never aDatavalue, and neverReason, which is free-form handler text that can quote them. Every one of those fields is HANDLER-CONTROLLED, so each goes throughdisplaySafe(issue #2170) and a 200-character cap before it is rendered -- both regressions this change introduced and a review caught. The line it replaced printed raw WIRE json, where the encoder had already escaped control characters and wheresubstring(0, 200)bounded the output by construction; parsing first UNDOES the escaping, so an ESC and a newline reach the terminal as real bytes and anyone holding the pre-signed response URL could clear the screen and print a forgedERROR [cdkd]line into a CI transcript. Dropping the substring removed the bound as well: a 5000-character id with 300Datakeys rendered a 19,714-character line, re-emitted on EVERY poll of a resource that can run for an hour. The body stays UNTRUSTED input: the parse is hoisted above the log so one result feeds both the summary and the terminal-status check, but the log still fires for a body that does not parse -- reporting the body's LENGTH only -- because a malformed response is exactly when the diagnostic is worth most; a body that parses to a JSON scalar / array /nullgets its own summary rather than an envelope-shaped read of it. An earlier write-to-every-reader trace concluded this log line was the ONLY reader emitting the payload and that the body is never written tostate.json; the second half is FALSE and was relayed here without being re-derived.cfnResponse.Databecomesattributes(custom-resource-provider.ts:1128/:1208) and is persisted intoResourceState.attributes, so a generated secret ALSO sits in plaintext in the state record -- a durable channel strictly worse than this transient one, filed as #2274. What this entry closes is the--verbosechannel, not the exposure as a whole. gc's placeholder sweep really is metadata-only. The four new cases assert the RENDERED line (captured at theconsole.debugboundary of a REALConsoleLoggerat debug level, not avi.fn()that would record any argument handed to it), covering the envelope, an unparseable body, a non-object JSON body and an object missing the protocol fields.cdkd drift --jsonnow puts the payload and nothing else on stdout, so--accept/--revertoutput parses (issue #2230) --src/utils/logger.ts,src/cli/commands/drift.ts,docs/cli-reference.md, plustests/unit/cli/drift-json-stream.test.tsandtests/unit/utils/logger-stdout-reservation.test.ts.logger.info/logger.debugresolve toconsole.info/console.debug, which write to STDOUT -- the streamwriteJsonReportprints the payload on -- so every human-facing line of the remediation path interleaved with the document. Measured against the real binary before the fix:cdkd drift <stack> --json --acceptover a stack with one unreadable resource emitted 692 bytes on stdout andJSON.parserejected it withUnexpected non-whitespace character after JSON at position 331; after, stdout is 331 bytes and parses, with the prose on stderr. The failure is silent in the worst direction, because the terminal shows exactly the same text either way. Two mechanisms, because ONE could not cover both populations.reserveStdoutForPayload()(new, opt-in, module-level inlogger.ts) routesinfo/debugtoconsole.errorfor the rest of the process;drift.tscalls it once whenoptions.json, BEFOREapplyRoleArnIfSet. It is module-level rather than an instance field becausechild()hands out freshChildLoggerinstances, and the lines a per-call-site fix indrift.tsstructurally CANNOT reach live in exactly those:applyRoleArnIfSet'sAssumed role ...(src/utils/role-arn.ts:286, unconditional on any--role-arnrun) andS3StateBackend.saveState's legacy-migration notice (src/state/s3-state-backend.ts:448, reachable on--acceptover a pre-v2 record). The plan printers and the confirmation prompt callprocess.stdout.write/readlinedirectly, which no logger flag reaches, so they take a per-call-siteHumanTextSinkinstead -- 13process.stdout.writecalls inprintAcceptPlan/printRevertPlanplusconfirmPrompt's readlineoutput. The lines are MOVED, never suppressed: an operator still sees the plan, the prompt, the--dry-runnotice,No drift detected -- nothing to accept., theComparison INCOMPLETEblock (issue #2208),State updatedandRevert summary, and--verbosedebug output. Dropping them would satisfy a "stdout parses" assertion while losing what the confirmation is asking about, so every case asserts the line ARRIVED on stderr rather than merely left stdout. The reservation is OPT-IN and onlydriftopts in, so no other command's output contract moves --cdkd diffalready solved its own instance differently (diff.ts:89-101demotes the logger towarnunder--json, which SUPPRESSES rather than moves). The newdriftsuite deliberately does NOT mock the logger, unlike the four siblingdriftsuites: avi.fn()standing in forgetLogger()answers the stream question by construction, which is whydrift.test.ts's two existing--jsoncases pass over the defect. Vitest also interceptsconsole, so its output reaches neitherprocess.stdout.writenorprocess.stderr.write(probed); the suite therefore spies the console methods and funnels them into an ordered fd-1 / fd-2 transcript. Mutation-probed in both halves separately: disabling the logger route reddens 8 of 10 drift cases (the headline one with the issue's ownSyntaxError) while both no---jsoncontrols stay green, and forcing the sink to stdout reddens the 5 plan-bearing cases while the 3 logger-only ones stay green. An audit of the other--jsoncommands found the same mixing on six more subcommands, left unfixed here as a cross-command output-contract change, and filed as issue go-to-k/cdkd#2280:cdkd state list/state resources/state show/state info(--verboseun-gates helper debug onto stdout, androle-arn.ts:286fires unconditionally),cdkd list(worst --src/synthesis/app-executor.ts:165re-emits the CDK app's stderr at info level on a DEFAULT run), andcdkd events(--json -vonly).cdkd diffis clean.cdkd destroy/cdkd state destroyno longer HANG at the per-stack confirmation prompt on an EOF stdin (issue #2259) --src/cli/commands/destroy-runner.ts,docs/cli-reference.md, plus cases intests/unit/cli/destroy-runner-sigint.test.ts. The per-stack prompt was a bareawait rl.question(prompt)with neither a non-TTY guard nor an abort signal. It is NOT the only such prompt in the repo --rollback.ts, BOTH ofstate.ts's (thestate rmprompt andconfirmRefresh),orphan.ts,import.ts,export.ts,drift.ts,retire-cfn-stack.tsandstate-migrate.ts-- NINE sites -- all still take a barerl.questionwith no guard, and this entry does not close that class. The sharedconfirm-prompt.tsis deliberately NOT among them despite being a bare prompt: its only caller (deploy.ts) short-circuits on!process.stdin.isTTYbefore reaching it, so it cannot hang on EOF today -- the same reasonevents.ts's helper is excluded. It is the one that gates a DESTROY, which is why it is fixed first; the rest are filed as #2275.rl.questionnever settles once stdin is at EOF, and EOF delivers no signal at all, so the abort arm the entry above added for the BATCH prompt could not help here: there was nothing to abort ON.cdkd destroy <stack>,cdkd destroy --allandcdkd state destroy <stack>without--yes/--forcetherefore parked FOREVER in CI on nothing more than an absent stdin, burning the job's whole timeout budget instead of failing. Measured on Node 24.15.0, the version.node-versionpins (and 24.19 before it) against realnode:readline/promises:echo y |resolves"y", whileprintf 'y' |(a real answer with no trailing newline) and< /dev/nullboth stay pending indefinitely. That is exactly the gap the #2117 follow-up entry above left open and pointed here: its narrow reason -- the per-stack prompt sits in the watch's PRE-REGISTRATION window where the watch already force-quits, so a SIGNAL there is an exit rather than a hang -- is correct for a signal and says nothing about EOF. The fix applies the same non-interactive REFUSAL the batch prompt one layer up now uses:process.stdin.isTTY !== true->CdkdError(..., 'NON_INTERACTIVE_CONFIRM'), thrown BEFOREcreateInterfaceso there is no window a never-settling question can be awaited in. Two decisions were settled rather than assumed. REFUSE, not auto-confirm:deploy.tstakes the other branch (!process.stdin.isTTY-> proceed) and five prompts (gc.ts,bootstrap-destroy.ts,recreate-confirm-prompt.ts,prefix-migration-check.ts,migrate-command.ts) refuse, and a destroy belongs with the refusers -- a deploy that assumes "yes" is recoverable, whereas silently answering "yes" for an absent operator here deletes every resource in the stack; it also makes the two layers of the same command agree. And the ERROR SHAPE matches the only TWO of those five that carry a code --gc.tsandbootstrap-destroy.tsthrowCdkdErrorwithNON_INTERACTIVE_CONFIRM, while the other three throw a bareErroror aLocalMigrateError-- so CI can branch on it. (An earlier revision of the sibling entry claimed all five carried the code; that was measured false and is not repeated here.) POSITION is load-bearing and is fenced separately: the guard sits AFTER the--yes/--forceshort-circuit, so a non-interactive run that already passed a flag never consults stdin at all, and after the resource banner, so the refusal still names what would have been destroyed. Hoisting it above the short-circuit reds the--yescase AND all seven pre-existing SIGINT cases (they runskipConfirmation: trueon vitest's non-TTY stdin), which is the same CI population. Both entry points reach this one prompt (destroy.tsandstate.tseach callrunDestroyForStack), so fencing the runner covers both; nested-stack children are unaffected, sinceNestedStackProvider.deleterecurses withskipConfirmation: true-- the parent already confirmed the cascade -- andstate destroy --allpasses it too, its batch prompt having already been answered. The probe reproduces the PRODUCTION SYMPTOM rather than a proxy for it, the way the batch prompt's twin was fenced: removing the guard makes the case fail as a 5014 ms TIMEOUT, not as an assertion about a mock. Negative controls that stay green under it: a TTY run still prompts and still destroys, a TTY user answeringnstill cancels and deletes nothing, and a--yes/--forcerun never consults stdin even on a non-TTY. It IS a behaviour break AND a new exit-code contract, documented as an upgrade note rather than silently:printf 'y\n' | cdkd destroy MyStacksucceeded before (piped stdin does settlerl.questionwhen the input ends in a newline) and now exits 1 withNON_INTERACTIVE_CONFIRM(CdkdErrorcarries noexitCode, sohandleErrormaps it to 1 -- worth naming, since a destroy also emits 2 for a partial failure and "non-zero" alone does not let CI tell the two apart).--yes/-y(or-f/--forceoncdkd destroy) is the supported non-interactive path -- the same tradecdkd gcandcdkd state destroy --alleach made in the two entries above.Three readback shapes POSITION could not certify now redact, when the positions themselves corroborate the pairing (issue #2012) --
src/deployment/secret-redaction.ts, plus a newtests/unit/deployment/secret-redaction-anchor-pairing.test.ts, three flipped assertions and two new residual proofs intests/unit/cli/state-refresh-observed.test.ts, and one flipped bound plus two repaired fixtures intests/unit/deployment/secret-redaction-array-identity.test.ts. On the readback paths (cdkd state refresh-observed,cdkd drift's baselines, and a plaincdkd deploythroughdrainObservedCaptures) the secrets map is EMPTY by construction, so the value scan has no needle and POSITION is the only mechanism left. WhereidentityKeyForfound no identity field there was no position either, and an array-nested secret reachedstate.jsonand the drift baseline holding the DECRYPTED value --['--pw', '{{resolve:...}}']and[{Field, Val: '{{resolve:...}}'}], both pinned as residual LEAK rows.refuseUncertifiedReadbackPositionsnow pairs two such containers ANCHOR-wise, under four conditions: the key sets (objects) or index counts (arrays) must match; every position whose SOURCE carries no dynamic reference must be deep-equal on both sides; every reference-bearing ELEMENT must carry its own distinguishing anchor (a non-empty string), where a bare reference leaf -- having no interior to carry one -- may instead lean on the array's non-reference-bearing FRAME; and no two reference-bearing elements may share an ORDER-INSENSITIVE anchor signature. Substitution then happens only at the reference-bearing positions. Anchors are what make the pairing evidence rather than a guess -- a position AWS did not rewrite proves the two containers describe the same element -- which answers thedescendArrays: falseorder objection on its own terms: a REORDERED list normally stops matching its anchors and is refused exactly as before. The relaxation cannot buy baseline content, which is the constraint that killed the first attempt at these rows (taking the SOURCE array wholesale, rejected by the issue #1915 fences for rewriting{Name:'', Value:'an-unrelated-literal'}onto an expression and inventing aNamefield AWS never reported, both of whichcdkd drift --revertpushes to the live resource): every substitution is a STRING leaf at a position the bag already has, so no key, element, or scalar-over-container can be added. The yield cost is stated rather than discovered -- one normalised sibling field in the same container drops it to zero, so this closes a SUBSET of each row and the remainder stays a refusal. A THIRD shape closes for the same structural reason and is worth naming because it was documented as a permanent BOUND rather than as one of the issue's rows: an array of ARRAYS (Matrix: [[{Name, Value}]]), whose OUTER elements have no identity field to key on.identityKeyForstill refuses it -- that half of the bound is unchanged -- but refusing a keyed pairing is no longer the end of the readback walk, since the inner elements' own anchors can vouch for the outer alignment. Its old assertion pinned a LEAK and is flipped; the order-assumption bound it carried is restated on a REORDERED fixture whose inner elements are KEYLESS, so only the anchor pass can refuse it (a first cut kept{Name, Value}inside and was therefore INERT -- the inner keyed descent refused it under every mutation, including the unmodified pre-change source). One #1915 fence was repaired rather than flipped:does not key on a field outside ARRAY_IDENTITY_KEYSused a single-element fixture that the anchor arm pairs, so it produced the same output whether or notValuesat in the list -- it now uses a two-element reordered array where a widenedARRAY_IDENTITY_KEYSmis-assigns and anchors refuse, which discriminates the rule it names.ARRAY_IDENTITY_KEYSitself is unchanged. Three defects the review round MEASURED are worth recording, because the first cut of this pass shipped past all of them and each has its own fixture. The distinguishing-anchor counter was scoped per ARRAY, so one element's anchor licensed a sibling with none and an unrelated literal took the sibling's expression -- the same false redaction the #1915 fences rejected the first attempt over, arriving through the counter's SCOPE rather than its definition. The anchor projection carried no UNIQUENESS requirement, so two elements the anchors describe identically could be swapped by AWS invisibly:['--pw', <exprA>, '--pw', <exprB>]matched every anchor at every index against a readback holding the two values the other way round, pinning each secret's reference at the OTHER secret's position.AWS::AmazonMQ::Broker.Usersis that shape in the field -- noName/Key, bothUsernameandPasswordrendered throughsecretValueFromJson, andGroups: ['admin']equal on every element -- so aDescribeBrokerreturning the users reordered recorded the admin credential at the app user's position, whichcdkd drift --revertpushes to the live broker.isUniquelyKeyedBy, the bar this pass claims to match, demands the identity be non-empty AND unique across elements; the first cut kept only the first half. The uniqueness rule then had to be made order-INSENSITIVE as well, which a second review round measured on the same shape: signing only sorted object KEYS left list order significant, so two users whoseGroupswere['admin','ops']and['ops','admin']signed differently, satisfied the uniqueness rule, still deep-equalled position for position, and reinstated the misattribution through a different door. The signature now sorts list elements too, so a projection is compared as a MULTISET -- fail-closed, since sorting can only make two signatures collide that previously differed, and an extra collision is a missed closure rather than a leak. Deep-equality itself stays order-SENSITIVE on lists, deliberately: it asks whether AWS returned THIS position unchanged, and a reordered list is a changed position. AnddeepEqualJsonValueequated anyDatewith{}and with any otherDate, sinceisPlainObjectadmits class instances andObject.keys(new Date())is[]-- reachable rather than theoretical, because an AWS SDK v3 readback reachingdrainObservedCapturesis pre-JSON and carriesLastModified/CreationDate; it now requires a plain prototype on both sides, so aDateanchor corroborates nothing and its element refuses. The two rows with no position to anchor against at all remain open on issue #2012, and each is now pinned by a case the anchor arm would answer DIFFERENTLY if it reached them, rather than by a comment: an UNPAIRED element beside a paired one (some element pairs by identity, soidentityKeyForanswers and the anchor arm never runs) and an observed KEY the source does not carry (nested in a keyless array the anchor arm does see it, and refuses on the differing key sets). A real-AWS arm ridestests/integration/secrets-array-nested, behindCDKD_INTEG_ANCHOR_ARM=1so the OFF polarity synthesizes byte-for-byte what that fixture shipped before: the existing rows all rideEnvironment[], which carriesName, soidentityKeyForanswers and the anchor gate is never consulted -- the new code had no live coverage at all. Ananchorprobecontainer adds two UNKEYED arrays (lists of plain strings, noName/Keyon any element) with opposite expected verdicts on the same unchanged redeploy:Command: ['-c', <expr>, '-v']must PAIR, so the captured baseline holds the EXPRESSION at index 1 -- the POSITIVE marker, since absence of plaintext alone is also satisfied by an arm that never ran -- whileEntryPoint: ['-p', <exprA>, '-p', <exprB>]is a NEGATIVE CONTROL that must be REFUSED by rule 3 and left holding exactly what AWS reported, without which a gate that paired EVERYTHING would satisfy every positive assertion. The premise is asserted rather than assumed at each step: that the references reached AWS RESOLVED, that AWS echoed the anchors back unchanged (so a refusal is attributable to rule 3 rather than to a normalised sibling), and that both arrays are genuinely unkeyed -- the identity probe answerstrueon this same fixture'sEnvironment[], so it is not vacuous.An
Fn::Subover a nested stack's output no longer ships the literal${Child.Outputs.Foo}to AWS (issue #2270) --src/deployment/intrinsic-function-resolver.ts,src/deployment/secret-redaction.ts,.claude/rules/architecture.md, plustests/unit/deployment/intrinsic-sub-nested-stack-outputs.test.ts(new, 17 cases), updates totests/unit/deployment/intrinsic-split-list-value.test.tsandtests/unit/deployment/secret-redaction-cross-stack-source.test.ts, and a live arm ontests/integration/nested-stack-secret/. Found by the security review of PR #2266, pre-existing rather than a regression from it. TWO separable changes, and the issue asked for both. (1)resolveGetAtt's STRING spelling split on EVERY dot and rejected anything but two segments, so"Child.Outputs.Foo"-- CloudFormation's own parse of which is["Child", "Outputs.Foo"], and which the ARRAY spelling has resolved since #2055 / #2266 -- threwInvalid Fn::GetAtt format. It now splits on the FIRST dot, which is the same splitsrc/analyzer/template-parser.tsalready applied to the identicalFn::Subplaceholder to draw its DAG edge: before this the dependency graph and the resolver DISAGREED, the edge being drawn for a reference the resolver would then refuse. Both ends must still be non-empty, soMyResource,.AttrandMyResource.are rejected exactly as the arity test rejected them. Attribute names containing dots are not special-cased toOutputs.--Cluster.Endpoint.Addresstakes the same parse. (2) INDEPENDENTLY,Fn::Sub's catch no longer launders a STRUCTURAL failure into a literal. Issue #1740 had made the DELIBERATE refusals loud by re-raisingIntrinsicResolutionRefusalError, butInvalid Fn::GetAtt formatandResource X not found for Fn::GetAttare plainErrors, so the class test could not see them -- and inFn::GetAttposition that throw was loud while insideFn::Subit became a warn line plus a resource deployed holding the placeholder TEXT. The new test is STRUCTURAL rather than class-based: when the placeholder's head segment (everything before the first dot) names a resource incontext.resourcesOR in the template'sResourcesblock, the placeholder is a REFERENCE and not ordinary text, so the failure re-throws. The template half is the load-bearing one -- a resource that is in state normally resolves, so acontext.resources-only test would have left the reported shape unfenced. The ORIGINAL error is re-thrown UNCHANGED, neither wrapped nor re-worded, becauseretryable-errors.tsclassifies by message SUBSTRING andmarkNonRetryablerides the error OBJECT, so a new message could silently flip a transient SDK failure (one surfacing out of the nested-stack output re-resolution) to terminal, or a terminal one to retryable via a template-controlled logical id. The accepted-input set was established BEFORE narrowing anything, and the counter-cases are half the new file: ordinary text whose head names nothing (${some_shell_var},${config.value}), the${!Literal}escape (asserted against a head that IS a declared resource, so the escape cannot fall into the refusal), the empty${}, pseudo parameters, parameter refs, aRefto a resource in state, the 2-arg variable map (which short-circuits above the refusal), and everybestEffortcaller.bestEffortis EXEMPT by name: it marks the diff /cdkd scrubpaths whose documented expected case (issue #1017) is a reference to a resource this same deploy will CREATE -- precisely the declared-but-not-in-state shape this refuses -- and those callers already catch resolution failures and keep the raw intrinsic. Parameters are deliberately NOT in the structural test, and the FIRST published reason for that was wrong: it cited "the routinecdkd scrubcase (it takes no--parameters)", butscrub.ts'sresolverContextfactory setsbestEffort: truein the same object literal that bindstemplateandresources, so scrub short-circuits before the predicate is consulted and can neither benefit nor suffer. The REAL reason is a regression risk on working templates: the only thing atemplate.Parametersarm would newly refuse is a placeholder naming a DECLARED parameter with no bound value, and that includes one carrying aDefaultthe caller never merged intocontext.parameters-- deploys that succeed today would start hard-failing. The residual is real and tracked separately: such a placeholder still ships${Stage}as literal text. Every half was mutation-probed SEPARATELY, because an all-or-nothing revert cannot tell them apart. Reverting the first-dot split AT THE RESOLVER CALL SITE (leaving the shared helper and the key arm sharing it) reds 11 cases across THREE files -- eight intests/unit/deployment/intrinsic-sub-nested-stack-outputs.test.ts, two intests/unit/deployment/secret-redaction-cross-stack-source.test.ts, andintrinsic-split-list-value.test.ts's dotted-attribute rendering -- while every structural-refusal case stays GREEN. An earlier revision of this entry said "6 cases" and named only one other file, omitting the redaction suite; it also predated the round-3 cases. Note this is a DIFFERENT probe from reverting the shared helper itself, which necessarily reverts BOTH call sites at once and reds more. Reverting only the structural predicate reds exactly the refusal cases and leaves every resolution case green. That file'srenders a dotted ATTRIBUTE wholecase had PINNED the old unreachability with a note saying it would red the day nested-pathFn::GetAttlanded -- it landed, the rendering was found already correct, and the case now drives the string spelling through the live path instead of the throw. The integ arm adds a NON-secret child output (ChildPlainOutput) and a fifth stack-owned parameter (SubConsumer) reading it throughFn::Sub; it asserts the LIVE SSM parameter, not state, because a fix that resolved at persist time but not on the wire would pass a state-only check, and it reads a non-secret output so a failure there cannot be confused with a redaction failure. SSM accepts any string, so the pre-fix deploy exited 0 with the placeholder text in the parameter -- the value is the only witness. The fixture's literal is asserted NOT to overlap any other needle in that file, the mirror of the #2087 arm's assertion that its literal DOES. A THIRD site held the same assumption and is fixed in the same PR rather than filed, because it is not a second defect:crossStackSourceKeyinsrc/deployment/secret-redaction.tshad RE-SPELLED the resolver's arity rule (parts.length !== 2, under a comment reading "the string form only at exactly two dot-separated segments,resolveGetAttthrows otherwise"), so widening the resolver alone would have left the two disagreeing in the direction that matters -- the resolver keying a leaf the persist path refuses, which silently degrades issue #2059's per-leaf positioning to the plaintext-keyed value scan for exactly the nested-stack OUTPUT references that positioning exists for. Not a plaintext leak (the scan still redacts) but a WRONG-EXPRESSION one: a child exportingCurandPrevof one rotating secret has both outputs resolve EQUAL during theAWSPENDINGwindow, so both parent properties persist the survivor's expression andresolveReplayPropsapplies the wrong stage on a rollback or acdkd drift --revert. The two sites now call ONE exported function,splitGetAttStringForm-- not a copy and not a paraphrase, since a paraphrase is what produced this. It lives insecret-redaction.tsbecause that module is a LEAF BY DESIGN (its header states it imports nothing, both the resolver and the deploy engine consuming it) and the resolver already imports eleven symbols from it, so this is the only direction that does not create a cycle. Fenced end to end rather than at the key function:keeps each leaf on ITS OWN expression through the STRING spelling toodrives the resolver over two child outputs whose plaintexts COINCIDE and asserts each redacts back to ITS OWN expression. The per-leaf equalities are the discriminator and thenot.toContain(SHARED)beside them is deliberately not -- the value-scan collapse also satisfies "no plaintext", the survivor still being an expression, so an absence assertion would pass with the defect intact. The key-level case that PINNED the old refusal (crossStackSourceKey({'Fn::GetAtt': 'Child.Outputs.CurrentPw'})toBeUndefined) is replaced by one asserting the string and array spellings produce the SAME key, with a sibling-attribute inequality beside it so an equality holding by both sides beingundefinedcannot pass. ROUND 3 -- four defects independent review found in the round-2 fix, one of them CREATED by it. (1) THE BLOCKER: making${Child.Outputs.X}resolve created a secret-COLLAPSE population. Before it that leaf was literal text carrying no secret; after it the leaf resolves to a plaintext and needed positioning, and had none --crossStackSourceKeyrefused everyFn::Sub, andintrinsicSkeletonPatterncannot position it either (its[^}]*wildcard cannot cross a{{resolve:...}}token's own}}). Zero candidates, so both leaves fell to the plaintext-keyed value scan; measured end to end, aCURRENTleaf redacted to the SIBLING's:AWSPREVIOUSexpression, whichresolveReplayPropsthen applies to the LIVE resource on rollback /cdkd drift --revert-- issue #2059's exact failure.crossStackSourceKeygains anFn::Subarm that normalizes a bare-string template of EXACTLY ONE non-escaped placeholder to theFn::GetAttkey viasplitGetAttStringForm. Keying it asFn::Subwould have been useless: the WRITER isresolveGetAtt, whichresolveSubcalls with the bare placeholder text and which never sees theFn::Subwrapper, so the two halves would never have met. Surrounding text, two placeholders, the${!Literal}escape, a${Ref}form, a pseudo parameter and the 2-arg form all still refuse. (2) The escaping errors could be classified RETRYABLE:Resource X not found for Fn::GetAtt/Ref X not found/Invalid Fn::GetAtt formatinterpolate a template-controlled logical id, andisRetryableTransientErrormatches by BARE SUBSTRING over patterns includingThrottling/SlowDown/DependencyViolation, so a construct namedThrottlingmade a deterministic refusal read as transient -- and escapingFn::Sub's catch is exactly what put it inside the parent'swithRetryviaNestedStackProvider.create. All three are nowmarkNonRetryableAT THE THROW SITE, which needs no rewording (the re-throw must hand back the error object untouched) and leaves a transient SDK error surfacing fromreresolveCrossStackValueretryable. (3) A missing nested-stack output shipped a synthetic ARN:constructAttributehas noAWS::CloudFormation::Stackcase, so it fell toguardedPhysicalIdFallback, whose ARN-shape test is!physicalId.startsWith('arn:')-- and a nested stack's placeholder id DOES start witharn:, so the #1103 guard passed andarn:cdkd-local:...shipped into a free-text property. A child's outputs are known exactly, so an absentOutputs.attribute now refuses, naming the outputs that DO exist. (4) The string form drew no DAG edge:template-parser.tsanddag-builder.tsboth requiredArray.isArray(getAtt), so the spelling this PR made resolve could RACE its producer -- a silent race where there had been a loud failure. Both now use the shared helper's head, so the graph accepts exactly the set the resolver resolves. The two graph sites are fenced SEPARATELY:dag-builder.ts's parser is reached fromaddCustomResourcePolicyEdges, not fromextractDependencies, so thebuildGraphcase does not exercise it and neutering it alone reddened nothing until its own case existed. Also closed: thecontext.resourcesarm of the structural predicate was UNFENCED (every refusal case used an emptyresources, so only the template arm ever ran, and neutering it reddened 0 of 1678 cases in that directory) -- a case with an EMPTYResourcesand a populatedresourcesnow drives it alone. The integ arm gains the half it could not see:SubSecretPairreads two expressions of ONE secret that resolve EQUAL (:shared::versus:shared:AWSCURRENT:, since an empty version-stage defaults toAWSCURRENT), each through its own single-placeholderFn::Sub, and each must persist ITS OWN expression -- with the plaintext and expression counts read AS A PAIR, because "no plaintext" alone is also true of an arm that did nothing. Two properties of that arm are load-bearing and were both learned the hard way on a real-AWS run. BOTH LEAVES SIT IN ONE RESOURCE (the parameter'sValueand itsDescription):perResourceSecretsis keyed by logical id, so two separate resources get two separate bags each holding a single pair, and each would redact correctly with or without the fix -- the arm would prove nothing. And THE PAIR USES ITS OWN SECRET JSON KEY rather than sharingstage: the first cut fed a second spelling ofstagedown through the child'sParameters, which made the pre-existingStageParamleaf stop being the only one carrying its plaintext and broke the unrelated #1903 assertion. The mechanism is worth recording because it is not the one a reader would guess: the parent'sinheritedSecretsbag isMap<plaintext, expression>(RecordedSecretValues,secret-redaction.ts:48), so two expressions resolving to one plaintext collapse to a single entry IN THE PARENT before the child engine is ever constructed, and the child's{Ref: <Param>}source leaves carry no expression for the position pass to certify against. For the same reason the shared pair is handed to the child as LITERAL output values it resolves itself, not asParameters: a whole-token source leaf is one the position pass can certify per leaf, which is what keeps the child's two outputs distinct. ROUND 4, from a four-reviewer delta pass. The 2-ARGFn::SubFORM carried the same collapse through a narrower spelling, found independently by the security and code reviewers:{'Fn::Sub': ['${Child.Outputs.Cur}', {X: 1}]}leaves the placeholder UNBOUND, soresolveSubfalls through its variable map to the same-stack lookup, reachesresolveGetAttand IS keyed by the writer -- while the reader refused the whole form ontypeof template !== 'string'and fell to the plaintext-keyed scan. Same consequence as the one-arg blocker, and this PR created that population too (pre-#2270 the placeholder stayed literal). The reader now accepts the 2-arg form whenArray.isArray(raw) && raw.length === 2 && isPlainObject(raw[1])AND the placeholder name is NOT an own key of the variable map. ThehasOwnhalf is not optional: a BOUND variable wins inresolveSuband therefore never reachesresolveGetAtt, so the writer recorded nothing for it, and certifying it from the source alone would attach another leaf's expression to the value -- over-redaction, which on this path is the worse direction becauseresolveReplayPropsre-resolves the persisted expression andcdkd drift --revertPUSHES that baseline to AWS. A malformed 2-arg form (wrong arity, non-object second element) stays refused, matchingresolveSub, which throws on it. TheNESTED_STACK_RESOURCE_TYPEconjunct of the new refusal was unfenced -- deleting it reddened 0 of 1689 -- so anOutputs.-prefixed attribute on ANY type would have hard-failed, reachable through aCustom::resource whoseDatacarries such a key. Its counter-case must use an ABSENT attribute: the first cut used a PRESENT one, which the flat-key lookup returns before the refusal is ever reached, so it passed with the conjunct deleted. The fixture's collision guard omitted its own two new literals against each other, the class that broke this fixture's first real-AWS run; it now iterates by VARIABLE NAME rather than by value, because a self-comparison guard keyed on equal VALUES would skip precisely the case it exists to catch, and the pre-existing three stay inner-only so the #2087 arm's REQUIRED overlap is never flagged. The separateSHARED_PW_VALUE != SECRET_STAGE_VALUEassertion is dropped as strictly subsumed (substring-both-ways implies inequality), its rationale moved into the loop's comment rather than left as a second spelling of one question. Finallysrc/utils/error-handler.tsis corrected twice: the group-1 enumeration gains the nested-stack missing-output refusal as its FIFTH site -- the JSDoc beside it warns that a stale enumeration is how the #1730 site went unlisted for three releases, so leaving it would have been that same failure committed by the PR that reads the comment -- and the two bare counts ("all six", now seven marked sites of eight constructions) are replaced with "each" rather than re-numbered, since a count in prose is what goes stale on the next lane. KNOWN RESIDUAL, filed separately rather than fixed: mixed-text and multi-placeholderFn::Sub(pre-${Child.Outputs.Cur}-post) keep the same positioning gap, architecturally out of reach here becauserecordCrossStackExpressionis whole-token only, andFn::Joincarries the identical residual onmaintoday. No plaintext leaks in that case -- the value scan still redacts.A COMPLETED destroy no longer reports itself unfinished, and
cdkd state destroy --allno longer hangs at its batch prompt (follow-up to issue #2117, shipped in 0.284.55) --src/cli/commands/destroy.ts,src/cli/commands/state.ts,src/utils/interrupt-signals.ts,src/provisioning/interrupt-watch.ts,src/cli/commands/destroy-runner.ts,docs/cli-reference.md,.claude/rules/layout-provisioning.md, plus cases in the five interrupt suites. Two REGRESSIONS that the #2117 fix itself introduced, both found by independent review of the merged PR and both reproduced by execution before being fixed. (1) The terminalif (runInterrupted())is a LIVE read, so a signal landing in the TAIL window -- after the last (or only) stack returned cleanly, duringeventRecorder.finalize/purgeEventsAfterDestroy, both real S3 round-trips -- threwDestroy interrupted by Ctrl-C. State preserved -- re-run 'cdkd destroy' to finish.and exited 2 over a destroy that had fully completed, telling the user to re-run work that was already done and showing CI a failure that was not one. Before #2117 that site read the once-sampledresult.interrupted, which is false there. The fix gives the question ONE owner per scope and has every consumer read that owner. PER-RUN, "did this run leave a stack unprocessed?", is the terminal verdict's own: astoppedEarlyflag set ONLY where abreakgenuinely leaves a target unprocessed -- unconditionally at the pre-dispatch guard (THIS stack is the one left undestroyed, whether or not any follow it), and at the end-of-body / inner-region / outer-stack breaks only when something real remains.state.ts's outer guard tests the remaining names againststateRefsrather than counting INDEXES, because under--stack-regiona later name whose only record is in another region is warn-and-skipped and was never going to be destroyed;destroy.tsneeds no counterpart, itscandidateStacksalready being pre-filtered by the set of names that have state. The terminal read isinterrupted || (interruptWatch.interrupted() && stoppedEarly), and the loopbreaks KEEP the live read -- that question is still "has the user asked to stop". PER-STACK, "did THIS stack finish?", isDestroyRunnerResult.interrupted's, and it had the SAME defect one frame inward:destroy-runner.ts's outerfinallyre-syncedresult.interrupted ||= drainingAFTERdeleteState, so a signal duringrenderer.stop()/ thesaveChainflush / the realdeleteStateround-trip /releaseLockflipped the per-stack flag true over a stack whose state file was already GONE -- and the caller ORs it unconditionally into the terminal condition, so the reviewer measuredexitCalls=[[2]]plus that same false sentence on a single-stack--all --yesrun. That re-sync is now gated onstatePreserved, published from thepreserveStatedecision BEFORE the branch acts on it, so the fenced invariant is: a stack whose state was deleted never reportsinterrupted. ROUND 3 made that sentence TRUE, and it was not before: a THIRD write to the same flag sat in theresourceCount === 0early-return branch (result.interrupted ||= emptyInterrupted), ~950 lines ABOVE the gate and outside thetry/finallythat carries it, so the identical symptom survived at the one branch that ALWAYS deletes the state record -- exit 2 withState preserved -- re-run 'cdkd destroy' to finishover a stack whose state file was gone, plus a skipped--purge-eventsfor a stack with no state left to post-mortem. The repo's own GREEN test pinned that defect as the intended behaviour. The write is DELETED rather than gated (nothing is preserved on that branch, so a gate would be permanently false), and the property it protected -- a first Ctrl-C on an empty-state stack still stops an--allrun -- is served by the command-scopedwatchCommandInterruptboth loops read live, now with a case proving it. Because the enumeration was short in all THREE rounds,tests/unit/cli/destroy-result-interrupted-write-sites.test.tsreplaces the human enumeration with a mechanical one: it derives the constructing files from the source, matches<identifier>.interruptedfollowed by any assignment operator (so a rename of the local cannot dodge it), and fails any write not covered by an allow-list of (statement, rationale) pairs. Floors on both the population and the find count, probed in both directions -- a stray ungated write names the offending line, and a needle broken to match nothing fails the count floor. With the per-stack flag accurate,destroy.tshandspurgeEventsAfterDestroythat flag rather than the command-level read -- a per-stack consumer asking a per-stack question. The command-level read there had disagreed with the exit code in the commonest shape: a tail signal on the LAST stack skipped the purge while the run exited 0, so full success was reported with the event history left behind. Round 3 added the SECOND way a stack comes back with its resources standing, which readinginterruptedalone missed: a user answeringnat the per-stack prompt makes the runner return{cancelled: true, deletedCount: 0}, and witherrorCountandskippedCountboth 0 the run maps toSUCCEEDEDwithinterruptedfalse -- socdkd destroy --purge-eventsDELETED the event history of a stack that still exists (probed: two declined stacks,pruneRunscalled twice). The gate now readsstackInterrupted || stackCancelled, fenced in both directions. (2)state.ts's--allbatch promptawait rl.question(...)took no abort signal. Because #2117 installed a command-level SIGINT listener, Node's default terminate no longer fires there, and the handler's between-stacks branch only records and returns -- so a single SIGTERM (forwarded to SIGINT) from CI orkillHUNG FOREVER where it previously exited 130. At a TTY readline intercepts^Citself, so the reachable population is the piped / non-interactive shape, i.e. exactly the population issue #1342 exists for.watchCommandInterruptnow holds anAbortController, aborts it in every branch that records a signal, and exposesreadonly signal;state.tspasses it torl.question(prompt, { signal })and treats the abort as cancel + exit 130.rl.close()runs in afinallyfor every path that UNWINDS, and explicitly before theprocess.exit(130)as well, becauseprocess.exitis synchronous and never unwinds -- so on the one path this fix adds, thefinallydoes not run at all. The same prompt also gained an EOF path:rl.questionnever settles when stdin ends (measured still pending after 1500 ms under both< /dev/nullandecho -n "" |), socdkd state destroy --allwithout--yeshung in CI on nothing more than an absent stdin, with no signal for the abort path to catch. Round 2 raced the question against the interface's owncloseevent so EOF read as "N"; ROUND 3 WITHDREW that race in favour of this repo's existing non-interactive convention -- guard onprocess.stdin.isTTY !== trueBEFORE prompting and throwCdkdError(..., 'NON_INTERACTIVE_CONFIRM'), as five sibling prompts already do (gc.ts,bootstrap-destroy.ts,recreate-confirm-prompt.ts,prefix-migration-check.ts,migrate-command.tsall testisTTYbefore creating the interface) -- though onlygc.tsandbootstrap-destroy.tsshare theCdkdError+NON_INTERACTIVE_CONFIRMSHAPE this one copies, the other three throwing a bareErroror aLocalMigrateError. The race lost three ways, each measured against realnode:readline/promiseson Node 24.15.0:printf 'y' |(a real answer with no trailing newline) DECLINED, silently discarding the answer, becauserl.lineis''at close;(sleep 0.3; echo y) |DECLINED, a delayed answer simply losing the race; and at a TTY readline consumes^Citself and callsclose(), so the INTERACTIVE Ctrl-C landed on the EOF arm and exited 0 with "stdin closed" instead of the 130 + "Destroy cancelled" the abort arm 15 lines above deliberately produces -- one user action with two spellings, the one a human actually hits being the wrong one. Exit 0 on a piped CI run is also success-shaped over a destroy that did nothing. The existing control could not see any of this because its mock resolves an ALREADY-SETTLED promise, so the question arm always won. A refusal has none of those failure modes: no answer can be discarded, the TTY path is untouched, and--yesshort-circuits above the check so it never consults stdin. It IS a behaviour break, and an upgrade note rather than a silent one:printf 'y\n' | cdkd state destroy --allsucceeded before (piped stdin does settlerl.questionwhen the input ends in a newline) and now exits non-zero withNON_INTERACTIVE_CONFIRM.--yesis the supported non-interactive path, the same tradecdkd gcmade one entry above. The SIGINT/abort arm from round 2 is correct and is KEPT.destroy.tsowns no prompt;destroy.tsowns no prompt, anddestroy-runner.ts's per-stack prompt is left alone HERE with the reason stated narrowly: for a SIGNAL it sits in the watch's pre-registration window where the watch already force-quits, so that case is an exit rather than a hang. That argument does not cover EOF, which delivers no signal --cdkd destroy <stack>without--yeson an absent stdin still parks there, the same shape one layer below, anddeploy.tsalready auto-confirms on a non-TTY so destroy is the outlier. Filed as #2259 rather than fixed in this diff: it is pre-existing, it is a behaviour change on a second command, and taking it at review round four is how a fifth round happens. Also closed here: thestate.tsrunStackbracket was INERT-able with all 63 cases green -- the exact hole #2117's own round 2 closed fordestroy.ts-- which left the stranded-lock regression of #1348 unfenced on the state side; the three new throw-guard branches (catch -> dispose(); throw,catch -> endCommandInterruptScope()) had zero coverage against their own round-2 regression;interrupt-watch.ts's residual-population claim named six commands that provably cannot reach the code (none callsforwardSigtermToSigint(), so the scope never opens) and is replaced by the measured answer, that the force-quit has NO live population today, stated as a structural guarantee with the condition that would make it reachable again; the between-stacks force-quit re-prints the hedgedcdkd force-unlock <stack-name>line, becausedestroy-runner.tscatches a failingreleaseLockand only warns, so "no lock is held" is false exactly where recovery matters most; the pre-registration force-quit's flat "Nothing was deleted and no stack lock is held" is hedged the same way, since on an--allrun whose earlier stacks completed BOTH halves are false; and the stale Route53 attribution is corrected at all FOUR sites that carried it (src/utils/interrupt-signals.tsx2,src/provisioning/interrupt-watch.ts,src/cli/commands/destroy-runner.ts), each now naming thegrep -rn "process.on('SIGINT'" src/provisioning/providers/that regenerates the closed set -- todaycustom-resource-provider.ts,cloudfront-distribution-provider.tsandacm-certificate-provider.ts, and Route53's provider registers none.isPromptAbortErrorgains a case driving the REAL rejection out of an abortedreadline/promisesquestion (Node 24.15.0:name === 'AbortError'ANDcode === 'ABORT_ERR') plus one case per arm, since every hand-built fixture carried both and removing either arm reddened nothing. Every fix carries a case that fails without it, applied and measured ONE AT A TIME -- the S3-window gap above existed precisely because one site's probe was masked by another's. Two reproduce their production symptom rather than a proxy for it: the dropped{ signal }fails as a 5003 ms TIMEOUT, and so does removing the non-TTY refusal (5000 ms) -- the CI hang itself, not a proxy for it.The shared integ version-sweep helper is now EXECUTED under a fake AWS CLI, the version-purge mode follows the DESTROY rather than the script,
rollback-cross-region-secretjoins the sweeping fixtures, andgenerateSecretStringis recorded as a measured NON-seeding shape (issues #2106 / #2225 / #2212) --tests/unit/scripts/integ-s3-versions-harness.test.ts(new, 65 cases),tests/unit/scripts/integ-secret-fixture-sweep.test.ts,tests/integration/s3-versions.sh,tests/integration/local-run-task-from-state/verify.sh,tests/integration/rollback-cross-region-secret/verify.sh,tests/integration/cross-stack-secret-import/verify.sh,tests/unit/scripts/integ-s3-versions-helper.test.ts,tests/unit/scripts/integ-aws-commands.test.ts,.claude/rules/testing.md. Test-only; nosrc/change. (1) #2106 -- the helper is now executed, not just reviewed.tests/integration/s3-versions.shis sourced by sixteen fixtures and nothing in CI ever RAN it: the only automated coverage wasbash -nplus a static bash-4-ism scan, leaving its three documented silent-partial traps fenced by review alone. The new harness sources the real helper under/bin/bashagainst a fakeawsonPATH, backed by a local version store that replays realListObjectVersionsshapes and applies--queryper page -- not a detail, since trap 3 IS that per-page application, and a fake applying the query once would make the trap unreachable. The fake is calibrated against the real-AWS numbers recorded in the helper's own header (all 1189 / noncurrent 1064 / latest-only 125 over a multi-page prefix, the1000\n189two-linelength()output, and the unparenthesised flatten reporting 0 where the parenthesised form reports 347) before it is trusted to judge the helper. Cases: zero / one / many versions, a forced page boundary at page size 50 across 2300 entries, an injectedSlowDownlisting failure (which must FAIL rather than read as zero), thenoncurrent/allmode split, delete-marker-latest, exact-key scoping againststate.json.bak/.tmpsiblings, aNoneversion id, per-objectDeleteObjectserrors underQuiet:true, and six malformed prefixes. Each trap is reintroduced as a MUTATION PROBE and watched going red -- including two findings the probes produced rather than confirmed: trap 3's real consequence is not a crash but a vacuous green (the[builtin rejects a two-line operand, returns non-zero, theifis therefore false, and the assertion announcesOK: 0 surviving object versionswhile 1189 survive), and removing EITHER of trap 2's two guards alone still sweeps correctly, so only removing BOTH exposes it. It is a SIBLING of the existinginteg-s3-versions-helper.test.tsrather than a replacement: that file owns the prefix guard and the stronger property that the guard fires BEFORE any AWS call. The harness is hermetic by construction -- its child environment is built from an allow-list rather than by spreadingprocess.env, pinningPATH/HOME/TMPDIR/BASH_ENV/ENV/LC_ALL/LANG/TZ/ cwd and forwarding noAWS_*at all. Every pin is READ BACK from inside a real child and asserted by value, so deleting any one of them fromchildEnvfails by name; PATH, theHOME/BASH_ENV/ENVtrio and theAWS_*omission additionally carry adversarial probes that plant a decoyawson the ambient PATH, a hostile.bashrcdefining anawsfunction, andAWS_DEFAULT_OUTPUT=yaml. That by-value layer was added after review measured the earlier claim UNEARNED: deletingHOME,BASH_ENV,ENV,LC_ALL,LANGorTZwas green, onlyPATHandTMPDIRwere load-bearing, and the decoyawswas created but never placed on any PATH. Measured negatives record that the helper shells out to nojq/python3/date. Its scratch root honoursCDKD_TEST_SCRATCH_DIRand prints a receipt naming the directory it used. (2) #2225 -- the purge mode now follows the destroy.local-run-task-from-state/verify.shran its destroy as... | tail -5 || trueand then chose the version-purge mode fromrc, the SCRIPT's status on entry tocleanup. A run whose assertions all passed and whose destroy then FAILED therefore took theallbranch and deleted the currentstate.json-- the one record a latercdkd state destroywould have worked from, leaving orphan resources unreachable. It now capturesdestroy_rc=${PIPESTATUS[0]}(not$?: the pipe totailis what hid the failure, so the wrong status would reproduce the bug inside its own fix) and gates on both. The rule is documented ins3-versions.shbeside the existing mode comment so it stops being per-fixture folklore, and is enforced by a lint scoped tocleanup()-- the other fixtures purgeallonly on the success path after an unpipedset -e-guarded destroy and need no capture, so an unscoped rule was measured at a 93% false-positive rate. The lint's invocation classifier is enumerated from the tree (${CDKD}/${CLI}/node "${LOCAL_DIST}"/dist/cli.js), excludes the AWS CDK CLI's owncdk destroy, and reports an unrecognised spelling AS SUCH rather than as a missing guard. (3) #2212 -- shipped as a FENCED EXEMPTION, not as the proposed new pattern. The issue asked for agenerateSecretStringentry inSECRET_MATERIAL; traced against the tree, its premise ("cdkd resolves and persists that value into state.json exactly like a hand-written one") does not hold.SecretsManagerSecretProvidermints the value locally from a CSPRNG, hands it toCreateSecret, returnsattributes: { Id }only, never issuesGetSecretValue, and lists bothSecretStringandGenerateSecretStringingetDriftUnknownPaths(); all four candidate fixtures reference their secret by ARN alone. Adding the pattern would have made four fixtures sweep for a value that is not there and forced three of them --composite-stack,event-driven,full-stack-demo, none of which has averify.sh-- to grow one for a non-problem. The shape is instead recorded in a newEXEMPT_SHAPESlist in which each premise is fenced against the provider source, so the exemption fails loudly if any of them stops being true, plus a conditional that revokes it for any fixture that also consumes a secret's VALUE into a template property. (4) The one real exposure the #2212 audit did find is closed here too.rollback-cross-region-secretresolves{{resolve:ssm:...}}against a SecureString -- the shape that makes cdkd issue a realGetSecretValueon the deploy path -- and was the only one of the six fixtures in that class not sweeping. Today's redaction keeps the plaintext out of state, which is a reason to sweep rather than to skip: the redaction is a src-side invariant one bug away from failing, and object versions are forever. It now sources the helper and purges both its cross-region prefixes, with the mode gated onrcplus BOTH per-region destroy statuses, and the lint enforces the rule for the whole class rather than leaving it a soft note. (5) A review round then found that twenty of these fences asserted a SPELLING rather than a PROPERTY, six of them measured green with the guarded thing deleted outright, and the fix was to the shape rather than the instances. The purge-mode guard required.everycaptured destroy status rather than.some-- with.some, dropping half the guard from a two-stack fixture was green while a run whose consumer destroy succeeded and whose producer failed would delete the live producer state and every version of it. Destroy recognition became per-invocation rather than a singlesawDestroyflag, its marker list gained the unbraced$CDKD/$CLI/$LOCAL_DISTforms (unbraced$CDKDis live inacm-certificate), dropped the dead${BIN}, and is now floored by a tree-wide census that requires every destroy-looking line to classify as either cdkd or the AWS CDK CLI. ThegenerateSecretStringexemption fence moved from watching one assignment spelling to asserting the create/update RETURN SHAPE against an allow-list, becauseeffectiveProperties: { ...properties, SecretString: secretString }-- a field seven other providers populate and the engine records verbatim -- left all four of its conjuncts green.rollback-cross-region-secret'snoncurrentarm was INERT: two unconditionalaws s3 rm --recursivecalls above the branch delete-markered the live state.json, demoting it to a noncurrent version the safe arm then deleted, so they moved inside the success branch and a new rule catches the shape anywhere. Three harness cases were vacuous and now assert what the helper REQUESTED via a recorded delete log (theNonecase also gained the realistic literal"null"version id, which must be deleted rather than skipped);s3_purge_key_versions'snoncurrentarm -- the ONLY mode any real call site uses -- gained coverage, as did the quoted-key single-object fallback and the empty-key refusal.cross-stack-secret-importnow sweeps the shared exports indexcdkd/_index/<region>/exports.json-- a sibling prefix nos3_stack_prefixreaches, holding RESOLVED Output values -- key-scoped andnoncurrentonly so a concurrent lane's live index survives, fromsweep()so it runs on EVERY path rather than only after the trap is disarmed, and ASSERTED via a news3_assert_key_versions_sweptwhose mode defaults tononcurrent(demanding zero versions of a SHARED key would demand a state no correct run can reach). A further round then found the same name-vs-identity class in four more places and fixed the MODEL rather than the instances: destroy statuses are tracked as records with a line rather than a list of names, so two destroys copy-pastingdestroy_rc=$?are reported as a CLOBBER instead of satisfying.every; the purge's guard is the depth-aware ENCLOSINGif, so anallpurge after a closedif … fiis unguarded rather than adopting that block's condition; theGetSecretValuepremise matches the call rather than one constructor spelling;attributes.Idis pinned to the physical-id EXPRESSION rather than to the key name; theaws s3 rmcheck keys on the state BUCKET rather than a literalcdkd/path; each falsified-claim record carries its own retired sample; andchildEnv's pins are read back from a derived list and cannot be shadowed by a per-call extra. Finally, three prose claims this lane had corrected per-FILE were found still standing in other files, so the correction is now a FENCE: a tree-wide residual check overdocs/**,.claude/rules/**and the test corpus fails if any falsified claim reappears anywhere.cdkd no longer acts on an S3 bucket it has not confirmed is the one it means, at three sites (issues #2241 and #2245) --
src/provisioning/providers/s3-bucket-provider.ts, plus two new suites (tests/unit/provisioning/s3-bucket-provider-us-east-1-preflight.test.ts, 15 cases;tests/unit/provisioning/s3-bucket-provider-state-region-guard.test.ts, 18 cases) and premise repairs in four existing S3 suites. Same family as the create-path adopt guard of issue #2227, which stopped NEW poisoning and reached neither of these.#2241, the
us-east-1legacy 200. The partial-create cleanup of issue #376 is gated oncreatedNewBucket, which was set from a 200 onCreateBucket. That is proof in every region but one:@aws-sdk/client-s3dist-types/models/errors.d.tsdocuments onBucketAlreadyOwnedByYouthat S3 "returns this error in all Amazon Web Services Regions except in the North Virginia Region. For legacy compatibility, if you re-create an existing bucket that you already own in the North Virginia Region, Amazon S3 returns 200 OK and resets the bucket access control lists (ACLs)." So inus-east-1an ADOPTED bucket was marked as created, and a later sub-config failure (a malformed lifecycle rule, a policy the account rejects, a throttle) firedDeleteBucketCommandat a PRE-EXISTING user bucket -- precisely the outcome #376's gate exists to prevent.DeleteBucketrefuses a non-empty bucket, so a bucket holding objects survived; an EMPTY one did not. It is not an explicit-BucketNameedge case:generateResourceNameproduces{stackName}-{logicalId}with no region or account in it.create()now runs a pre-flightGetBucketLocationONLY when the target region canonicalizes tous-east-1, so every other region keeps its hot path unchanged and no extra round trip is spent where the 200 already proves the create. Three outcomes, deliberately kept distinct: the name is FREE (a 404) so the 200 proves creation and the self-heal still runs; the name is TAKEN socreatedNewBucketstays false and a warning says the bucket was ADOPTED and that the legacy 200 has just reset its ACLs -- the second, smaller effect of the same adopt, and one a SUCCEEDING deploy gives the user no other signal of; or the probe could not answer, in which case the cleanup is withheld and a warning at the moment it would have run namesaws s3api delete-bucketfor the orphan it declined to delete. Collapsing "could not answer" into "absent" is exactly what would restore the delete this fixes, so the two are separate variants of one type rather than a nullable region. The probe informs the cleanup gate ONLY and never replaces theCreateBucketcall:CreateBucketis the authoritative OWNERSHIP oracle (a bucket held by another account fails it withBucketAlreadyExists) whileGetBucketLocationcan succeed against a foreign-owned bucket whose policy allows it, so skipping the create on a positive probe would have traded this bug for a worse one. This NARROWS the defect rather than closing it, and the residual is accepted deliberately: the probe and theCreateBucketare two calls, so a name that is free when asked can be taken before it is used, and inus-east-1the legacy 200 then adopts that bucket withcreatedNewBucket === true— the original bug, through a window now measured in milliseconds instead of standing open for every deploy. Closing it entirely needs an atomic create-or-tell-me-who-owns-it that S3 does not offer in this region; aHeadBucketretry after the 200 would only move the window. One further branch is a fail-CLOSED floor rather than a live fix: if a 200 ever arrives over a bucket the pre-flight placed in ANOTHER region, cdkd raises the identicalRefusing to adopt existing S3 bucketerror the 409 path raises instead of warning and configuring it. Not producible against AWS as documented (the legacy 200 is scoped to a bucket you own IN N. Virginia, and a cross-region owned bucket answers 409 — measured), and kept because the previous behaviour there was a warning whose own text contradicted the region it named.#2245, an already-poisoned state record.
assertExistingBucketRegionis on the CREATE path only, so a record written by any build predating it -- naming a bucket this account owns in another region -- was never detected, and two paths acted on it.update()had no region check at all and applied the stack's whole bucket configuration to whateverphysicalIdthe record named.delete()'sassertRegionMatchfires only from theNoSuchBucketbranch, which a cross-regionDeleteBucketnever reaches: SDK v3's region-redirect middleware FOLLOWS the 301 for body-bearing operations, so the delete simply SUCCEEDED against the other region's bucket. Both now REFUSE on a determinate mismatch, with amarkNonRetryableerror naming both regions and what the operation would have done. The REMEDY is per-operation, not shared: cdkd's state is region-keyed (cdkd/{stack}/{region}/state.json), so "rerun this stack against the bucket's region" -- correct for an update -- is wrong twice over for a destroy, since that run finds no record of the stack and, if one existed, the advice would be to go and destroy the very bucket the guard just refused to touch. The delete branch instead says to delete the bucket deliberately in its own region if it is genuinely yours, or to drop the stale record withcdkd state orphan <stack>. Refuse rather than heal on update, because healing means rewriting a recorded physical id -- a state mutation whose blast radius exceeds the misconfiguration it repairs; refuse rather than proceed on delete, because a stranded destroy needs a human while deleting a live bucket in another region cannot be undone. The delete guard sits AHEAD of the auto-empty, not beside theDeleteBucket: emptying a foreign-region bucket destroys the data whether or not the delete that follows succeeds.What the guards deliberately do NOT do, and the new IAM dependency that creates. Both PROCEED when the probe reports absence or cannot answer. The hazard needs a bucket that EXISTS and is owned by this account, and for exactly that bucket
GetBucketLocationanswers -- from any endpoint in the same partition (measured 2026-08-13, recorded insrc/utils/aws-region-resolver.ts) -- so the determinate case covers the whole defect, while failing closed on an unanswered probe would strand every update and destroy for a least-privilege role, with no per-resource override to force one through. A principal that cannot calls3:GetBucketLocationon the target is NOT an unrelated population -- an earlier revision of this entry said it was, and that is wrong. It is the same population, merely unprobeable: such a principal can hold a poisoned record like any other, and for it the issue #2245 guard is INERT on every call, permanently. So this change adds an IAM dependency: the guard is only as good ass3:GetBucketLocationon the target bucket, which a bucket POLICY can alsoDeny-- and aDenyis indistinguishable on the wire from a missing grant, so refusing on 403 alone cannot separate the two. Because the guard can therefore be switched off by its own subject, the delete-side degrade logs atwarn, notdebug: cdkd's default level isinfo, so the previous debug line made the one path where failing open destroys data look byte-identical to a healthy destroy. The warning names the check that did not run, the consequence ("this destroy will delete THAT bucket"), and both ways out, including that a bucket policy can be the cause -- an operator who checks only IAM finds the grant present and stops looking. The update-side degrade stays atdebug, and that asymmetry is asserted rather than assumed: a misapplied bucket configuration is re-appliable. Not retried on throttling, deliberately -- a retry does nothing for the hostile-Denypath (a 403 carries neither a throttling name nor a retryable status), and the provider has no injected sleep seam here, so a bounded retry would add wall-clock to every throttled destroy or need a new timer seam plus fixtures to control it. The delete guard is additionally gated onDeleteContext.expectedRegionbeing present, which isassertRegionMatch's own documented back-compat rule: with no recorded region there is no expectation to compare against, so a pre-v2 record keeps its previous behaviour rather than paying a round trip for a comparison it cannot make. That cohort now WARNS too, and it is the one that most needed it. A record with no region is the likeliest to predate the #2227 create-path guard and therefore the likeliest carrier of a foreign-region physical id, yet it was the one population that got no probe, no refusal and no output at all -- the destroy's console was byte-identical to a healthy one. It gets a default-level warning with a remedy specific to ITS cause (re-deploy so the record gains a region), not the probe-failure one, since nothing is missing from that caller's IAM. Still warn-only: refusing there would strand every legacy destroy with no IAM remedy available at all, and defaultingexpectedRegionto the client region indestroy-runnerwould fabricate an expectation the record never made while also changingassertRegionMatchon theNoSuchBucketpath. Cost on a real destroy is therefore one read-onlyGetBucketLocationper bucket whose state record carries a region, and the outcome for every in-region bucket is unchanged.One predicate, not three spellings. A FAILED probe can still carry the answer, and is no longer discarded -- but ONLY from the two shapes where the header describes the BUCKET: the 301 redirect (
PermanentRedirect) andAuthorizationHeaderMalformed, the SigV4 mismatch reporting the region you should have signed for. Every other failure -- a 403, a throttle, a 404 -- is answered by the endpoint that was ASKED, so itsx-amz-bucket-regionnames where the QUESTION went. Reading it off ANY failure, which an earlier revision of this change did, INVERTED both guards built on it and is worth recording as a defect the fix itself introduced: on delete, a 403 whose header echoed the deploy's own region compared EQUAL, so the guard returned silently and the warning written for the hostile bucket-policyDenypopulation never fired; on create, it promoted an unanswerable probe to "the bucket is already there", producing a false ADOPTED-and-ACLs-reset warning over a genuinely NEW bucket and suppressing the orphan-cleanup warning as well.carriesBucketRegionHeaderis now the single statement of which shapes count, and both polarities are fenced (a 403 header is read neither as a match nor as a mismatch). The absence check still runs FIRST: a 404 is answered by the endpoint that was ASKED, and "the name is free" has to win. All three sites answer "where does this bucket live" through the sameresolveOwnedBucketRegionthe #2227 guard already used -- itscreateErrorparameter is now optional, because a PRE-FLIGHT caller has no AWS error to readx-amz-bucket-regionoff and only theGetBucketLocationhalf applies -- and the two #2245 sites share ONEassertStateBucketRegion, differing only in the operation-specific clause. The empty / nullLocationConstraintfold tous-east-1and the legacyEUfold toeu-west-1therefore cannot diverge between them.Wire shapes are MEASURED, not inferred from a command's declared throw list. An earlier revision of this entry claimed
GetBucketLocationdoes not deserialize its 404 into the modeledNoSuchBucketclass "the wayDeleteBucket's is", reasoning fromGetBucketLocationCommand.d.tsdeclaring only@throws {@link S3ServiceException}. That is false, measured 2026-08-26 by feeding canned 404 XML through the real client: both operations yieldctor=NoSuchBucket,name=NoSuchBucket,instanceof NoSuchBucket === true,status=404.DeleteBucketCommand.d.tsdeclares the same lone throw, and errors resolve through a per-NAMESPACE schema registry rather than a per-operation one, so the declared list never decided this. The predicate shipped was right for the wrong reason; the reason is corrected at all five in-tree sites rather than quietly dropped, because the wrong one would have justified the wrong fixture in the next provider that copied it -- and the modeled class is now the DEFAULT fixture everywhere, the hand-built variants remaining only where they fence an individual arm. The predicate itself is now the wire CODE alone. A bare HTTP 404 is deliberately NOT absence: on the create pathabsentis the answer that ENABLES the cleanup'sDeleteBucket, so a 404 from a corporate proxy, anAWS_ENDPOINT_URLoverride or an S3-compatible gateway must not license a delete on the strength of a response S3 never sent; narrowing costs only that such a 404 becomesindeterminate, which is every caller's non-destructive branch. Aninstanceof NoSuchBucketarm was written and then REMOVED: the generated class assignsname = 'NoSuchBucket'on the instance, so no case could ever make it the deciding one, and a branch nothing can make decide is a branch nothing protects.The guards are SDK-ROUTE-ONLY, and a sixth door bypasses all of them.
AWS::S3::Bucketdeclares silent-drop properties (AccessControl, which CDK's L1 still emits foraccessControl:), soprovider-registry.tsauto-routes such a bucket toCloudControlProviderand pinsprovisionedBy: 'cc-api'stickily -- the type is not inSTICKY_CC_MIGRATION_EXEMPT. On that routeS3BucketProvider.deletenever runs: no probe, no refusal, no warning at all, andCloudControlProviderdoes only the client-regionassertRegionMatch, which cannot see a physical id naming a bucket elsewhere. Tracked as issue #2283 and deliberately NOT addressed here (the fix is in routing, not in this provider); recorded because the guards would otherwise read as full coverage for the type.Every default-level warning on these paths names the error CLASS, never AWS's message -- all four of them. The guard's fail-open line, the create path's declined-cleanup line, and the create path's failed-
DeleteBucketline (which predates this work, issue #376) each carried an AWS message; an earlier revision of this entry claimed the invariant while only the first had been fixed, because the sweep was done by reading the diff instead of grepping every reader ofreason. On the very population these warnings exist for -- a bucket policy denyings3:GetBucketLocation-- S3's text readsUser: arn:aws:sts::<account>:assumed-role/<role>/<session> is not authorized to perform: s3:GetBucketLocation on resource: ..., so an earlier revision printed an account id, a role name and a session name to the terminal and to CI logs for exactly that cohort: making the guard visible had also made the CALLER visible. AWS's own text moves todebugand the line points at--verbose, the answerdynamodb-index-busy-delete.tsalready reached for the same class. Terminal-only, so the issue #2179 redaction surface is not in play.carriesBucketRegionHeaderwas tightened the same way in the same pass: it matches the wire error CODE alone and no longer accepts a barehttpStatusCode === 301, since a status S3 never NAMED is not S3 speaking -- a gateway answering 301 with the deploy's own region would have compared EQUAL and returned silently, which is the 403 defect arriving through the status instead of the header.A cdkd refusal is not an AWS error to classify. The create path's
catchnow re-throws aProvisioningErrorbefore anything else, so the classifier keying oncreateError.name === 'BucketAlreadyOwnedByYou' || message.includes('you already own it')can never swallow the foreign-region refusal raised in the sametryinto the "already owned" arm and configure that bucket -- the #2227 outcome, reachable by nothing more than a reword of the refusal's English. Inert today, and it is fenced at SOURCE level rather than behaviourally for exactly that reason: no input can distinguish the two orderings while the wording does not match, so the fence reads the code and asserts the pass-through precedes the classifier. Same unguarded-wrap class already fixed forupdate().Four existing suites had their PREMISE repaired rather than their assertions relaxed, because the
us-east-1mock they shared encoded a combination AWS cannot produce.s3-bucket-provider-already-owned-region.test.tsands3-bucket-provider-partial-create-cleanup.test.tsnow mockeu-west-1: aus-east-1client receivesBucketAlreadyOwnedByYouONLY for a bucket that lives elsewhere, so three cases pairing that 409 with aus-east-1client and aus-east-1readback were unreachable. The ADOPT side of the empty-LocationConstraintfold moved to the new pre-flight suite, which is the reachableus-east-1path to it; the REFUSING side stays where it was.s3-bucket-provider-container-shape.test.tsanswersGetBucketLocationwith its own client region rather than the blanket{}(an absentLocationConstraintIS a valid us-east-1 answer, so the blanket mock told the update guard the bucket was elsewhere), ands3-bucket-provider-per-item-string-replay.test.tsanswers the pre-flight with a 404 so its creates are genuinely NEW buckets. The onePublicAccessBlockConfiguration: present -> absent issues NO call at allcase asserts the WHOLE command list rather than subtracting a name, so its CFn-parity claim keeps its force. Retiring the impossible combination was right, but the blanket region flip also moved a REACHABLE one -- a us-east-1 client DOES receiveBucketAlreadyOwnedByYoufor a bucket of yours in another region, the legacy-200 exception covering only a bucket you own IN N. Virginia -- and that is precisely where issues #2241 and #2227 meet. The pre-flight suite now covers it in both shapes, including the twoGetBucketLocationcalls a headerless 409 costs (the pre-flight resolves the region, the #2227 readback asks again), asserted so the count is a decision on record rather than an unexplained diff.Each guard was mutation-probed separately and the probes are one-to-one: reverting the #2241 cleanup gate reddens 7 pre-flight cases; comparing the region raw instead of canonicalized reddens only the
--region US-EAST-1case; removing the update guard reddens 5; removing the delete guard reddens 7 while leavings3-bucket-provider-delete-data-guard.test.tsgreen (it passes noexpectedRegion); makingabsentorindeterminaterefuse reddens exactly the over-tightening cases written for them.cdkd local start-service/start-albno longer echo a secret's plaintext when a:json-key:reference points at a non-JSON secret -- the fix reaches the BUNDLED resolver, which is the one those commands run (cdk-local PR #558) --package.json,pnpm-workspace.yaml,pnpm-lock.yamlonly; nosrc/**change. Issue #2189 fixed cdkd's ownsrc/local/ecs-secrets-resolver.ts, and the entry below correctly notes that copy IS live -- but only forcdkd local run-task.src/cli/commands/ecs-service-emulator.tsimportsrunEcsServiceEmulatorfromcdk-local/internal, sostart-service/start-albdelegate wholly to cdk-local and kept executing the leaking shape: the samecatchinterpolatingJSON.parse's own message, into which V8 embeds a prefix of the PARSED INPUT -- here the secret plaintext this resolver just fetched from Secrets Manager. cdk-local shipped the identical remediation in 0.147.7; cdkd said^0.147.6while the lockfile pinned 0.147.6 andpnpm-workspace.yaml'sminimumReleaseAgeExcludepinnedcdk-local@0.147.6exactly, so users were served the leaking bundle. Both pins now name 0.147.7, the exclude staying an EXACT pin rather than a range so the min-release-age bypass does not become standing. Verified against the installed tree, in both directions. Grep ofnode_modules/cdk-local/dist/*.jsbefore the bump: 1 hit forbut the secret value is not valid JSON:(the interpolating form) and 0 for the withheld-detail wording; after: 0 and 1. Live probe driving the shipped bundle'sresolveEcsSecretswith a stubbed Secrets Manager client -- no AWS resources created -- against a 14-character secret, the worst case a prefix-only assertion misses, since V8 quotes an input of 20 characters or fewer IN FULL: 0.147.6 answeredContainer 'app' secret 'DB_PASSWORD' specified json-key 'password' but the secret value is not valid JSON: Unexpected token 'P', "PLNTXT-hunter2" is not valid JSON, 0.147.7 answers... but the secret value is not valid JSON (SyntaxError). The parser detail is withheld because it would echo the secret plaintext.The pre-bump run is the negative control that proves the probe REACHES the branch, and the assertion pairs the absence of the plaintext with positives on the container, env var and requested json-key, because "the secret is absent" alone is satisfied by any unrelated rejection.resolveEcsSecretsis not re-exported fromcdk-local/internal, so the probe imported the shipped chunk byte-for-byte with one appendedexportline and nothing else edited. The version delta is exactly this one change: a text diff of the two published tarballs shows identical file sets, identicaldependencies/peerDependencies, an unchanged lockfile entry count (1014 both sides, no new transitive package), an identicaldocker-cmd-*.jschunk, and in the main chunk two changed lines -- theEcsSecretsResolutionErrorthrow and its newconst kind-- plus the content-hash rename and the CLI version string.The gc placeholder sweeper's fences now fence, and a CI-visible consent change is documented (follow-up to issue #2052, shipped in 0.284.54) --
src/cli/commands/gc.ts,src/state/s3-state-backend.ts,docs/cli-reference.md, plustests/unit/cli/gc-custom-resource-responses.test.ts,tests/unit/state/s3-state-backend.test.tsandtests/unit/state/custom-resource-response-prefix-sync.test.ts. Independent review of the merged PR found the SWEEP itself correct in both directions -- a 5,000,000-sample probe of the realgetResponseKeyoutput found zero misses, and a corpus of 7 colliding--state-prefixvalues x 5 stack names x all 8 state key families classified 0 for DELETE -- but found four FENCES that did not fence, which on a sweeper is the dangerous class, since under-collection and a clean bucket are indistinguishable. (1)listRawObjectsnever asserted itsPrefix: deletingPrefix: keyPrefixpassed 136/136, and unscoped the sweep would list the whole state bucket with the leaf regex as the only remaining guard -- the same class as the--state-prefixcollision the PR had just fixed. The pagination case now asserts it on EVERY page, since a continuation call that dropped it would widen page 2 alone. (2) The prefix-sync fence derived its population fromgit ls-files 'src/**/*.ts', which returns 326 of 328 tracked files -- git wildmatch needs a literal/for the**/segment, sosrc/index.tsandsrc/version.ts, one of them the library ENTRYPOINT, were exempt; planting the literal insrc/index.tsleft the fence green, and its> 200floor cannot see a 2-file gap. The pathspec is now a pair and carries explicit membership floors on both files. (3) A DECOY needle made a guard test vacuous: it assertednot.toContain('placeholder(s) from'), a literal that exists only in the ERROR message and never reacheslogger.info, so removing the very guard it claimed to fence passed 136/136. It now anchors on the literal the success line actually renders, with a positive assertion in front so the negative is not a confluence point. (4) The consent prompt -- which the PR body itself calls the consent surface for a widened blast radius -- was ungrepped by any test; reverting its wording passed 136/136. Also: the reclaim totals were unfenced in both the plan and the final line (now pinned with deliberately different per-arm byte counts, since a single-armed fixture cannot tell a dropped term from a printed one); the partial-delete error reported the ATTEMPTED count in its wrapper while the wrapped error reported the ACTUAL one, contradicting itself and both asset arms;listRawObjectsdropped entries missingLastModified/Sizesilently, which is the right DECISION but the invisible failure this feature is about, and now debug-logs which field was missing; and two JSDoc blocks had been orphaned by symbols inserted between them and the functions they document. The published mutation matrix is re-measured rather than patched: two rows were stale, and the marker-reorder mutation's failure set was a strict SUBSET of the delete-removal set -- an isolating--dry-runcase now separates them. The empty-random-suffix frequency claim is corrected by exhaustive enumeration rather than sampling (0 hits in 5,000,000 calls says nothing): V8'sMath.random()returnsk * 2^-52, and counting all 36^5 five-digit base-36 fractions gives 14,921,970 reachable inputs, a rate of 3.3e-9, roughly 1 in 3.0e8 -- four orders of magnitude commoner than the review's sampled estimate, so the*quantifier decision is better supported than it was stated to be. Recorded in theRESPONSE_PLACEHOLDER_KEYJSDoc so it need not be re-derived.docs/cli-reference.mdgains an upgrade note for CI: a non-interactivecdkd gcwithout-ythat used to exit 0 on an account with nothing to collect now hard-errorsNON_INTERACTIVE_CONFIRMonce placeholders have accumulated, because "zero candidates" now includes them and the pre-prompt early return no longer fires.cdkd bootstrapno longer adopts an asset bucket this account owns in ANOTHER region (issue #2240) --src/assets/asset-storage.ts, plustests/unit/assets/asset-storage.test.ts. S3 bucket names are globally unique while a bucket is regional, soBucketAlreadyOwnedByYou(409) and a cross-regionHeadBucketredirect both report ACCOUNT ownership, not the bucket's region. (The same conflation inS3BucketProvider.createis issue #2227, still open and NOT onmainat the time of writing -- this change shares its shape, not its code.) Only the 409 actually proves ownership: a redirect is emitted by the routing layer BEFOREExpectedBucketOwneris evaluated, so the refusal says the name "resolves to a bucket in X" rather than claiming cdkd owns it. It read as structurally unreachable here becausegetCdkdAssetBucketNameembeds the region, but that is only the DEFAULT:cdkd bootstrap --asset-bucket <name>takes a caller-chosen, region-free name, so bootstrapping two regions under one custom name reaches it. Measured against real S3 on 2026-08-26, which CORRECTS the issue's own mechanism: theBucketAlreadyOwnedByYouswallow is NOT how the ordinary case arrives, because theHeadBucketpre-check runs first and a cross-region owned bucket makes it fail with the SDK's syntheticUnknown/UnknownErrorat status301. So the ordinary case already failed CLOSED -- but vianormalizeAwsError, whose 301 wording ("cdkd resolves this automatically; if you see this message, please report it") is true for the STATE bucket and FALSE here, sending a user with a self-inflicted naming collision to file a bug; andverifyAssetStorageExistsdid not even reach that, rethrowing the bareUnknownErrorwith no context at all. The 409 swallow remains reachable through aHeadBucket-then-CreateBucketrace, and would have run this region's encryption / public-access-block / deny-external-account PUTs against the foreign-region bucket. A second measurement makes that arm ordinary rather than exotic: a same-region re-create answers200only on theus-east-1legacy endpoint, whileap-northeast-1andus-west-2both answerBucketAlreadyOwnedByYou-- so in every region but one a plain same-region race arrives as a 409, and the guard's region EQUALITY check, not the error name, is what lets it through. All three sites now route through oneassertAssetBucketRegionguard that refuses withASSET_STORAGE_FOREIGN_REGION_BUCKET, naming both regions and both remedies. The redirect predicate is DERIVED from the AWS SDK's own (regionRedirectMiddleware: header present AND status301, or400withIllegalLocationConstraintException/ aHeadBucketcommand) and is deliberately wider in two places -- it accepts a301with no header at all (theGetBucketLocationfallback resolves that case, and requiring the header there was a regression caught in review) and it drops the SDK's inner conjunct on the400arm, because that conjunct decides whether to silently RETRY elsewhere while the only thing done here is refuse. A status-301-only gate silently misses the400form; a bare header-present gate would read a500carrying a stray region header as a redirect, so both halves are pinned. The region is read fromx-amz-bucket-regionon the error itself (measured present on both the 301 and the 409 -- no extra call, no extra IAM), falling back toGetBucketLocationwith the empty / legacy-EUconstraints folded tous-east-1/eu-west-1; deliberately NOTresolveBucketRegion, which never throws and returns itsfallbackRegionon a failed probe and would turn a fail-CLOSED guard fail-OPEN -- an undeterminable region refuses too.--forcedoes not license it:--forcemeans re-apply configuration to the bucket you INTENDED, never write to one in another region. Contrast the STATE bucket, whichbootstrap.tsdeliberately re-points at the bucket's own region viarebuildClientForBucketRegionbecause one state bucket serves the whole account; asset storage is per-region by design. The sibling-site sweep found the same conflation on the TEARDOWN path:emptyAndDeleteBucket(src/cli/commands/bootstrap-destroy.ts) probes with the sameHeadBucket, so a cross-region asset bucket died on the same misleading 301 wording. It now takes an OPT-INexpectedRegionand routes a 301 through the same guard -- opt-in rather than always-on because its two callers have OPPOSITE polarity: the asset bucket is per-region, while the STATE bucket legitimately lives anywhere and its caller already pre-resolves the client withrebuildClientForBucketRegion, so a blanket refusal would turn--include-state-bucketinto a false refusal whenever that pre-resolution degrades to the ambient client.cdkd gc's site was checked and left alone on measurement rather than reasoning: it probes withListObjectsV2, whose 301 carries a real XML body, so the SDK raises a genuinePermanentRedirectwith AWS's own actionable text rather than the syntheticUnknownError-- andnormalizeAwsErrorpasses a non-synthetic error through untouched. Seventeen unit arms across two files, each half of the fix mutation-probed RED separately (the redirect arm, the 409 swallow, theverifyAssetStorageExistsarm, and the teardown arm), plus three deliberate controls that must stay GREEN: a SAME-region 409 race still proceeds and still writes the marker,EU/ empty location constraints fold rather than refuse, and the state bucket still gets the generic 301 rendering -- making the guard always-on reds that last one alone, which is what proves the opt-in is load-bearing rather than decorative. Four reviewers then found five further mutations the first suite let through -- a header reader that ignored the header's KEY (its fixture carried a single header, so in production it would have returned a date string as the region), an emptyLocationConstraintfolding to the TARGET region rather thanus-east-1(fail-OPEN, and the original arm could not see it because its target WASus-east-1), a same-region redirect being read as "verification passed", the region canonicalization on the comparison'sactualside, and the400spelling above. All five now go RED.An optional
DeploymentCircuitBreakermember dropped from an ECS service template is now RESET to its AWS default instead of silently retained (issue #1861) --src/provisioning/providers/ecs-provider.ts, plustests/unit/provisioning/ecs-deployment-configuration-subfield.test.ts, a live arm intests/integration/ecs-service-update-props/, and the §2a checklist indocs/provider-development.md. This is the #1160 absent-field-removal class one level DOWN -- the #1225 shape -- and the opposite polarity to #1802: there cdkd is too PERMISSIVE (it deploys a template CloudFormation refuses), here it was too STICKY (it failed to apply a removal CloudFormation applies).AWS::ECS::Service.DeploymentConfigurationis forwarded toUpdateServiceas a recursive PascalCase -> camelCase flip, and forDeploymentCircuitBreaker's two OPTIONAL members the ECS API RETAINS an omitted value: a user who deletedResetOnHealthyTaskfrom their template kept the stale livefalseundercdkd deploywhilecdk deploygave them AWS'strue, withcdkd diffreporting nothing. Measured us-east-1 2026-08-13 from an identical baseline withRollbackflipped in the same call so the update demonstrably applied, service identity captured on both sides so a replacement could not be mistaken for a reset: cdkd leftfalse/{COUNT, 7}intact where CloudFormation producedtrue/{BOUNDED_PERCENT, 50}. The fix keys on the semantic that measurement actually supports, which is narrower than "materialize the defaults": a member the template NEVER declared is LEFT ALONE by CloudFormation -- an out-of-band value survived a CFn update that changed onlyMinimumHealthyPercent, and survived again when a SIBLING was flipped INSIDE the same block, the arm that excludes the competing reading "CFn re-serializes a nested struct whenever its declared content changed". So this is a previous-present / current-absent REMOVAL, and the newECSProvider.resolveDeploymentConfigurationreuses the sharedclearOnUpdateRemovalhelper rather than re-spelling the predicate; only the DEPTH is new. Scope is deliberately narrow and each boundary is measured rather than reasoned about: only those two members (the required siblingRollbackreads as REPLACED, so one block holds members with different semantics); NOT the depth-1 siblingsLinearConfiguration/CanaryConfiguration/ a hook'sTimeoutConfiguration, which are already PARITY because the ECS API itself replaces the struct and default-fills the omitted member (PR #1848) so a reset there would BE the divergence; and only while BOTH template sides still declare theDeploymentCircuitBreakerblock, since removing the wholeDeploymentConfigurationresets nothing under either engine (#1805) and the parent block vanishing entirely was never measured. THIRTEEN unit shapes replace the two that pinned the old pass-through, each written against what the REGRESSION would emit rather than against a confluence point. (Thirteen is the count of#1861arms inecs-deployment-configuration-subfield.test.ts, derived by listing the file'sit(titles rather than from recall -- an earlier draft of this entry said ten, which was the count before the last review round added three.) Both removal halves (a scalar member and a whole nested BLOCK, since a fix could plausibly handle one and miss the other); the NEVER-declared arm that stops the fix degenerating into always-send-defaults; the ROLLBACK replay direction with previous/desired SWAPPED (issue #1609 -- a fix keyed on "the sides differ" would discard the user's value on the way back); the whole-block-gone boundary; and the CREATE path, which must never synthesize a member. Four more came out of review, and the first of them closed a real hole: every one of the arms above is ONE-SIDED (previous-only or current-only), so a resolver reading the PREVIOUS side alone --previous['ResetOnHealthyTask'] !== undefined ? DEFAULT : desired-- passed all of them while sending AWS'strueon every deploy of a template declaringResetOnHealthyTask: false, silently discarding the user's value. The BOTH-SIDES-declared arm and its CHANGED-value polarity twin close it. The third pins that the reset value is a FRESH object per call rather than the shared module constant (ECSProvideris a singleton serving concurrent resources, so a by-reference default would be one mutable object across every in-flightUpdateService; the constant is nowObject.freezed and spread at the use site). The fourth pins the LIMIT of the presence test rather than a desirable behavior:clearOnUpdateRemovalasks whether the previous BAG carried the member, which equals "the previous TEMPLATE declared it" only on the deploy path -- atdrift --revertthe previous bag is an AWS READBACK, so a member no template declared reads as a removal. That exposure is shared by everyclearOnUpdateRemovalcall site in the codebase -- 78 of them across 14 provider files before this PR, 80 with this resolver's two (grepped, not estimated;asg-provider.tsandlambda-function-provider.tscarry 13 each) -- and belongs to that shared contract rather than to this resolver, so it is documented and pinned rather than fixed here. A later round added THREE more. One covers the circuit breaker DISABLED (Enable: false): every other unit fixture AND every integ phase usedEnable: true, so a reset gated onenable === truepassed both, silently reinstating the divergence for anyone who turns the breaker off without deleting its configuration. The other two cover MALFORMED shapes, which is where the guard changed from truthiness to "is a plain object": a non-object breaker used to take the rebuild arm, so'oops'spread to{0:'o',1:'o',2:'p',3:'s'}and then gained both synthesized defaults. The matching PREVIOUS-side guard is DEFENSIVE ONLY and is labelled as such in both the code and the test rather than left to look load-bearing -- relaxing it leaves the file green, and while an array carrying a NAMEDResetOnHealthyTaskproperty does discriminate it, no previous bag reachable fromJSON.parsed state, resolved template properties or an SDK readback can be one. FOURTEEN distinct mutations were probed across the three rounds -- thirteen go red, and the fourteenth SURVIVES and is reported rather than hidden. (Both numbers were derived by counting the entries in the list that follows, not from recall; an earlier draft claimed nine while listing ten, and a first attempt at this correction said fifteen/fourteen by double-counting the by-reference mutation, which is measured in both freeze states inside a single entry.) The thirteen that red: reverting to the pass-through reds four; an always-send-defaults fill reds five including the never-declared arm; a symmetric "sides differ" key reds the rollback arm; widening past the still-declared guard reds the boundary arm; moving the fill into the shared converter reds eight including both CREATE pins; the one-sided previous-only key reds the two both-sides arms; a!xpolarity slip reds three; handing the shared constant out by reference reds the aliasing arm; removing ONLY theObject.freezewhile keeping the copy also reds it, which it did NOT before review, because nothing assertedObject.isFrozen(the leak into a neighbouring test that an earlier draft of this entry described happens only when the freeze is removed TOO -- with the shipped freeze in place the by-reference mutation is a singleTypeError, no leak); replacing the conditional spreads with plain assignments reds the two removal arms -- which it did NOT before review, because the key-set assertion had been sitting on the never-declared arm, the one case that takes an identity early-return and never builds the object at all; deleting the...desiredspread from the rebuild reds the removal arm (that mutation drops every SIBLING top-level member fromUpdateServiceon any deploy that removes a circuit-breaker member, and ECS's server-side merge then keeps the old values silently -- it was CI-invisible until the removal fixture gained siblings); gating the reset onenable === truereds the DISABLED arm; and relaxing the desired-side plain-object guard back to truthiness reds the malformed arm. The survivor is relaxing the PREVIOUS-side plain-object guard, which leaves all 36 green -- recorded as inert rather than pinned by a contrived fixture. Theecs-service-update-propsinteg gains BOTH live arms on the fixture it already had. Phase 1 now declares the two members with NON-DEFAULT values (false/{COUNT, 7}) and asserts they LANDED, because a baseline equal to the AWS default would make the phase-2 assertion vacuous; phase 2 drops them from a still-declared block and asserts AWS reports the defaults, tolerating either the explicit form or an OMITTED member (both mean "at its default") while still failing on the stale phase-1 value, which is a third distinguishable state. A new phase 2c is the NEVER-DECLARED arm, and it is the one that DISCRIMINATES the removal rule from "re-serialize the struct whenever its declared content changed": with the members never-declared since phase 2, verify.sh sets them OUT OF BAND, asserts the write landed, then runs a cdkd deploy whose only change isRollbackflipped back on INSIDE the same block, asserts that flip actually applied, and requires the out-of-bandfalse/{COUNT, 9}to SURVIVE -- clobbering them would be a divergence in the opposite direction, destroying a value CloudFormation deliberately preserves. Numeric reads go through--output jsonplus anawknumeric compare rather than a--output textstring compare, so a7.0rendering cannot fail a correct run; every read expression was executed against fixed, broken and omitted payloads before shipping. No newtrapis registered: the out-of-band edit lands on the service the existingcleanuphandler already tears down, and a secondtrap ... EXITwould REPLACE the first rather than chain, stranding the stack. A shape sweep of every other nested struct forwarded whole on an update path found roughly a dozen further candidate clusters (CognitoPoliciesand the API Gateway v2 Stage blocks are the two whose AWS-retains half is already live-probed, with the CloudFormation arm still missing); they are NOT swept here, since the ECS block is itself the proof that members of one struct carry different semantics and each needs its own A/B.docs/provider-development.md§2a gains the generalizable step the audit was missing -- walk the DEPTH-1 members, not just the top-level properties -- with the never-declared and rollback-replay traps named.Every declining arm of
cdkd scrub's cross-stack pre-pass now names itself, andcdkd drift's accept summary and accept plan stopped claiming writes they did not make (issues #2163 and #1958) --src/cli/commands/scrub.ts,src/cli/commands/drift.ts,src/deployment/outputs-export-alias.ts, plustests/unit/cli/commands/scrub-import-value-secret.test.ts,tests/unit/cli/commands/scrub-export-name-collision.test.tstests/unit/cli/drift-secret-redaction.test.tsandtests/integration/secrets-dynamic-ref/verify.sh.makeCrossStackPrePass'sreadOnedecides whether a stack may be reported clean, and FIVE of its outcomes returned without logging anything:!producer(the resolver recorded no cdkd cross-stack read, i.e. a CloudFormationListExportsfallback), theproducerTemplatesmiss (the producer is not a stack of this app), anoverdict (the producer's template declares no{{resolve:...}}expression for that key),!stored(the producer's record could not be read or does not carry the key), and the healthycarriesDynamicReference(stored)arm. Issue #2163 named the first three and reasoned from "every arm that classifies and then declines logs a debug line" that a failing real-AWS run must have taken one of them; that premise was false for the other two, so the absence of a debug line ruled nothing out. All five now emit alogger.debugnaming the reference, the producer and the reason -- with both identifiers MASKED, which the review round made the blocking finding:producer.keyisrecordedImports.at(-1).exportName, the POST-resolution export name, andresolveImportValuesets it fromresolveValue(importValueArg), so anFn::ImportValueover anFn::Sub/Fn::Join/ bare string assembling a{{resolve:secretsmanager:...}}makes it a resolved secret verbatim. The refusal path in the same file already masked both and records whymaskSecretsInErroratscrubStack's boundary is not sufficient (allRecordedSecretsDROPS every needle belowMIN_NEEDLE_LENGTHwhile the whole-value arm ofmaskSecretsInTextmatches at any length); alogger.debugline has no boundary net at all.producer.stackis masked because it is REACHABLE too, which the first cut of this change got wrong and a later review round corrected: it justified that mask as UNIFORMITY on the reasoning that a producer name comes from a state record and is never resolved. That holds forFn::ImportValue, whose producer name comes from the state-bucket listing, and is FALSE forFn::GetStackOutput--recordOutputRead(context, stackName, ...)is fedawait this.resolveValue(args['StackName'], context), so anFn::Sub/Fn::Joinassembling a{{resolve:...}}intoStackNamemakes the recorded producer name a resolved secret exactly the way the export key can be. No value ever leaked -- both identifiers were masked at every site throughout -- so what shipped wrong was the RATIONALE, and the same wrong sentence sat on the refusal builder's twin comment, where it had been since issue #2133. A wrong rationale is worse here than none: "never a resolved value" reads as licence to drop the mask.producer.regionIS the identifier that cannot carry one, and the comment now says why rather than asserting it --Fn::GetStackOutputgates its region throughisClientSafeRegion(/^[a-z0-9][a-z0-9-]{0,30}$/, which no{{resolve:...}}token satisfies) and throws rather than falling back, whileFn::ImportValuereads it from the same state-bucket listing. A dangling{@link}introduced by the same round is fixed too: the symbol isplaintextProducerCrossStackReadError, notcrossStackProducerPlaintextError. The mask that was being reasoned about now has a TEST, which it did not before -- measured, replacingloggedStackwithproducer.stackleft every test intests/unit/cli/commands/green, so the fence nobody could remove by accident was in fact removable in silence. Two cases pin it: anFn::GetStackOutputwhoseStackNameis itself a{{resolve:...}}reference, declining through theproducerTemplatesmiss, and the REFUSAL twin. The second uses a THREE-CHARACTER stack name and that is the whole point of it rather than a detail -- a refusal passes throughmaskSecretsInErroratscrubStack's boundary on its way out, so with a 28-character name removing the builder's own mask left the suite green;allRecordedSecretsfilters toMIN_NEEDLE_LENGTH(4) while the whole-value arm ofmaskSecretsInTextmatches at ANY length, which is exactly the asymmetry the comment beside the mask asserts, and the short needle is what makes that assertion falsifiable rather than decorative. The HEALTHY declining arm's export-name mask is fenced on the same pass (a decline that reports good news is the one whose masking nobody re-checks), andexportAliasCollisionScrubWarningnow masks the OWNING output key as well as the exported name: both reach it from the same place -- the collision fires because the export name matched a DECLARED output name -- so the reachable shape puts the plaintext on whichever of the two does the exporting, and masking one while printing its neighbour raw is the mask-one-argument-leave-its-neighbour shape #2176 found in the providers, one line apart instead of two files. The--acceptsummary's count also gained an arithmetic fence: replacingacceptedResourceCount++with= 1survived all 268 cases across the five drift suites, since only zero-vs-nonzero was pinned, so a two-accepting-resource case and a MIXED case (one resource accepts a public path, its neighbour drifts only at the refused secret path) now pin the number itself. Every one of these was mutation-probed and reds exactly its own case. The two pre-existing sites that named the producer raw are masked on the same pass, and theWHERE THE READ ISstring is hoisted above thetryso all nine sites share one spelling instead of four re-spelled copies. Pinned by anFn::ImportValuewhose export name resolves out of a secret, over two arms; mutation-probed by removing the mask at each site in turn, each reddening exactly its own case. On thedriftside: the--acceptsummary counted drifted OUTCOMES rather than accepted changes, so a resource whose every change hitacceptRefusalReasonstill reportedaccepted drift on N resource(s)while the per-change warnings just above said the opposite -- it now counts resources that actually RECORDED something and says0 resource(s) acceptedexplicitly, since the write still happens for the positioned re-redaction. The count is taken AFTER the not-recorded check, not ataccepted.length: there is a SECOND refusal site one step further along -- the positioned re-redaction winning over an accepted value at a public{{resolve:...}}reference, which the comment beside it calls a real hole rather than a hypothetical one -- and counting before it reproduced the same summary-contradicts-the-warnings-above shape one site later. The summary also no longer asserts that "every drifted change was refused above", which the code does not establish (an outcome whoselogicalIdis absent fromstate.resourcesalso contributes zero, silently); it says no drifted change was recorded, which is exactly what the count measures;printAcceptPlanannouncedupdate cdkd state for <stack>over a body that could be nothing butSKIPPEDlines, and now saysno accepted values will be written ... (the run still writes the positioned re-redaction)in that case while still listing each resource and its refusal reason. Deliberately NOTNOTHING will be written, which the first cut said and which a--dry-runreader takes to meanstate.jsonis untouched -- the real run over the same input takes the lock, bumpslastModified, rotates the ETag and can rewrite the stored bag. Both messages also have a real-AWS arm now:tests/integration/secrets-dynamic-ref/already drove--acceptinto the all-refused state -- one drifted resource whose single changeacceptRefusalReasondeclines -- but asserted nothing about either string, so a green run over it proved only that nothing else had broken. It now pins that premise first (exactly one drifted resource carrying exactly one change, since an arm whose premise silently fails reports a reassuring zero), then asserts the plan saysno accepted values will be writtenand names the positioned re-redaction without printing the bareupdate cdkd state forheader, and that the summary says0 resource(s) acceptedbeneath aState updatedline proving the write still happened. Each half was mutation-probed on its own: reverting only the plan wording reds the plan assertion, reverting only the summary count reds the summary assertion. Thescrubdebug lines are NOT covered there -- reaching them needs--verboseplus a cross-stack read this fixture does not have, so they stay on the unit suite and belong intests/integration/cross-stack-secret-import/if they ever get a live arm. The plan message and the run message are pinned together by one test over one input, because the two are written thousands of lines apart and are the only two statements cdkd makes about what an all-refused--acceptdoes; the revert plan's two mirroredif (cannotMaskKeys ...)/if (!cannotMaskKeys ...)blocks became theif / else ifits sibling twenty lines below already used; and four shipped comments that described review-round history, or over-claimed that an array-nested leaf "reached neither pass" / "cannot be positioned" (keyed descent has reached an ECSContainerDefinitions[].Environment[]since #1944), now describe the code. Three test anchors were tightened:toContain('withheld')matched both withhold notes and is now pinned to the tag block's uniqueAWS-authored tag(s) will be preserved; the--revert PLAN masks an AWS-authored keycase now proves the unbaselined block printed instead of asserting onenot.toContain; and the{{resolve:...}}-shaped-substring case pins that the resolve actually happened -- with BOTH a call count and the absence of thecould not resolvefailure wording, because the call count alone does not catch a mock that is called and rejects. The review round tightened one more: the--revert PLAN masks an AWS-authored keycase asserted a baretoContain(SECRET_MASK), which the drift line above it satisfies regardless (Environment.Variables.SECRET_PASSWORD: *** -> ***), so it pinned nothing -- measured, with the printer's mask removed and the secret leaking into the plan, that assertion stays GREEN. It is now the path-shapedEnvironment.Variables.***, which only the unbaselined-key list can emit, and that reds. This entry also closes issue #1958's last two items. Item 9:exportAliasCollisionScrubWarning's masking comment no longer reads as a guarantee. What bounds the printed string is the COLLISION TEST upstream --scrubStackwarns only for a name matching a DECLARED output name, andcollectDeclaredOutputNamesisObject.keys(template.Outputs)-- so the mask is a belt, not the hazard it was justified by; it is REACHABLE, but only through a template that NAMES an output with the secret plaintext, which the suite's end-to-end case builds. The stale "BEST-EFFORT resolved intrinsic" claim the issue quotes turned out to live in that test's comment rather than in the source, and is corrected there. A CONTROL case was added for the polarity nothing pinned -- that the belt stays OFF a name carrying no secret -- since the three assertions that call the builder as their own expected value are tautological with respect to masking; measured, a builder returning the bare mask for every name is green across the whole file except that case. Item 10 (cdkd state showprinting output bag KEYS unstripped) was already fixed and fenced by earlier work, verified here by mutation: droppingstripControlCharsfrom the Outputs key redsstate-show.test.ts'sSTRIPS control characters from an Outputs row, key AND value. Note #2163's underlying real-AWS divergence is NOT fixed here and the issue stays open: the shape it describes -- anFn::Ifexport whose selected branch carries the secret, imported through anFn::Join-- refuses correctly against the templatesaws-cdk-libactually synthesizes fortests/integration/cross-stack-secret-import/and a deploy-shaped v9 producer record, which the new tests pin; and thefrom indexlines the issue reads its evidence from cannot come fromcdkd scrub, which supplies noexportIndexby design.The pre-stringify secret walk is now a mechanical check, and eighteen raw sites were closed (issue #2178) --
scripts/check-provider-secret-mask.tsplustests/unit/scripts/provider-secret-mask.test.ts, theaudit:provider-secret-mask:checktask invite.config.ts, itsci.ymlstep, and the masker threading inappsync-provider.ts,budgets-budget-provider.ts,cloudwatch-anomaly-detector-provider.ts,custom-resource-provider.ts,kinesis-provider.ts,lambda-function-provider.tsandsns-topic-provider.ts..claude/rules/provider-masking.mdhas required since issue #1932 that a provider interpolating aproperties-derived value into a message mask it, and since issue #2176 that the walk run BEFOREJSON.stringify-- masking the finished message cannot recover the value, sinceJSON.stringifyescapes"/\/ newlines so the secret no longer occurs in the line, and a message is always longer than the value inside it so onlymaskSecretsInText's >= 4-character substring arm is reachable. The rule was prose and was violated anyway inside files already hardened for it, so the remedy is a check rather than another sentence. The naive syntactic rule does not work: it false-positives onasg-provider.tsandcloudfront-distribution-provider.ts, which mask UPSTREAM of the stringify. The critic is therefore dataflow-aware, deriving the masker set per file (aliasedmaskDeepimport,SecretMasker/MaskerFnannotations,maskerOrIdentity,context?.maskSecrets) and growing it to a fixpoint through local wrappers and through helper parameters every call site threads. An IDENTITY masker is REFUSED:maskerOrIdentity(undefined)masks nothing, so accepting it let a raw site be silenced at exit 0 while COUNTING toward the masked floor -- issue #2007 records why a masker fencing nothing is worse than none, since its presence stops the next author looking. The refusal is scoped to a BINDING (a parameter's identity DEFAULT is the contract's back-compatible answer and stays legitimate) and folds??/?:only when every arm is identity, so the contract's owncontext?.maskSecrets ?? ((t) => t)is still a mask. It is enforced in BOTH positions a masker occupies -- the derivation root AND the shared walk's masker ARGUMENT (maskDeep(value, M)) -- because the root refusal alone is inert where the callee is itself a root, which review caught by injecting the named-constant spelling and watching sites go 41/37 to 42/38 at exit 0 with the masker-name count unmoved. The argument position is an ALLOW-LIST rather than a deny-list of no-op spellings: review defeated two successive deny-lists (a hand-rolledfunction, then a cast and an object property), so the argument now accepts only what resolves to the derived capability and refuses everything else. The fence's honest strength is therefore "the value reached something DECLARED to be the project's masker": a no-op annotatedMaskerFn, or a property literally namedmaskSecrets, is still believed, which is recorded as a known bound and pinned by self-probes rather than left to be discovered. The mask module's specifier is RESOLVED against the importing file rather than suffix-matched, so a./my-masked-retry-logger.jssibling cannot lend itsmaskDeepthe shared walk's provenance. Measured population, by a paren-matching AST walk rather than a line regex: 41 interpolated sites across 18 files, 19 masked before this change, 37 after, plus 4 exempt (3 import-path sites whereImportInputcarries no masker by contract, and 1 delete-path site recorded against issue #2007, which owns that root cause). Nine of those 37 were EXEMPT in an earlier revision of this change, held open only because issue #2177's DynamoDB and S3 lanes owneddynamodb-table-provider.ts,dynamodb-globaltable-provider.tsands3-bucket-provider.ts; both lanes merged (PRs #2248 / #2251), so those sites were FIXED at the source and their rows DROPPED rather than left standing behind a green check -- an exemption that outlives its reason being exactly the rot the verdict check above exists to catch. The delete-path entry iscustom-resource-provider.ts's payload refusal, SPLIT into a masked create / update arm and an unmasked DELETE arm so the pathDeleteContextgives no masker for is counted and re-audited rather than hidden behind an identity default. Every exemption is keyed by expression rather than by line and re-audited on each run against its site's current VERDICT, so it fails the moment its site disappears, becomes MASKED upstream with the same expression text, or gains a same-spelled sibling -- the middle arm being the retirement path the issue #2177 entries are actually waiting on. Known bound (1) is fenced rather than stated: zero string-CONCAT sites is measured every run and anything above zero fails. Its own two failure modes are defended separately: five population floors plus a hard parse-diagnostic failure against collapse toward zero, and 50 self-probes with known verdicts including raw ones against collapse toward green -- one accept probe and one refuse probe per accept arm, since an accept probe alone dies only when the arm is deleted and a refuse probe alone only when it degrades toreturn true. The collapse-toward-green channel was ITSELF unfenced until review: the entrypoint didfailures.push(...runSelfProbes())and nothing SPAWNED the binary to check it, so deleting that one line left the CLI at exit 0 with a byte-identical success line while every unit test stayed green (the suite called the EXPORT). Measured, then closed with a seam fenced in both directions:runSelfProbesnow reports how many probes it EVALUATED (counted inside the loop, so a constant cannot satisfy it),maintakes an injectable runner so a probe FAILURE can be proven to reach the exit code, aMIN_SELF_PROBESfloor refuses a run that evaluated almost none, and the count rides--jsonasselfProbesRunfor a SPAWNED assertion. One independent guard did already cover part of it and is recorded rather than claimed away:auditExemptionsreads each site's VERDICT, so anisMasked -> truecollapse makes all four exempt sites report "is now MASKED" and fails the run even with the self-probe call deleted -- measured, but it covers only that one collapse shape. The seven providers listed above gained the masker on the paths that quote a resolved value back at the user;kinesis,budgets,cloudwatch-anomaly-detector,custom-resource,appsyncandlambda-functionalso gained theCreateContext/UpdateContextparameter the contract threads. No behavior change for a caller that supplies no masker -- absent still means unmasked, the back-compatible default.A name held during an asynchronous delete is now retried on the ORDINARY create path, and the Step Functions spelling is recognised at all (issue #2116) --
src/deployment/retryable-errors.ts, plustests/unit/deployment/retryable-errors.test.ts, a re-pointed discriminator intests/unit/deployment/deploy-engine-named-replacement-collision.test.ts, and a live arm intests/integration/custom-resource-provider/. PreviouslyisNameCooldownErrormatched two spellings, both SQS (QueueDeletedRecently,wait 60 seconds), and only the delete-then-re-create sites consulted it. Step Functions holds a deleting state machine's NAME until the delete completes -- measured at ~23s on an idle machine -- and answers a same-nameCreateStateMachinewithStateMachineDeleting: State Machine is being deleted, which matched nothing: acdkd destroyfollowed by a promptcdkd deployof any stack carrying acustom_resources.Providerwaiter state machine failed hard and rolled back (26 resources created and torn down again on the run that filed the issue) where CloudFormation converges. Two changes. (1) The spellings moved into an exportedNAME_COOLDOWN_ERROR_MESSAGE_PATTERNSlist and gained both Step Functions forms plus S3'sconflicting conditional operation, which had been retryable on an ordinary create but invisible to the re-create sites -- five spellings, one source of truth. (2) That list is now composed intoRETRYABLE_ERROR_MESSAGE_PATTERNS, so a name cooldown is retryable on the ORDINARY create path too, which is the reachable case: a fresh deploy process has no idea a prior run deleted anything. (3) The class gets its own BACKOFF GRID on the default schedule (src/deployment/retry.ts: 2s/4s/8s then 10s, 64s total across the generic 8 retries), because inheriting the generic 47s would NOT have fixed the reported case -- the longest window in this class names its own duration, SQS's sentence being "You must wait 60 seconds", so a 47s budget does not converge, it just fails 47s later. The numbers are precedent rather than a fresh guess: they are the delete-then-re-create sites' existing budget, chosen for this same window, so both paths now ride it identically and whether a cooldown is survivable no longer depends on which call site met it. An exhausted cooldown wait also now SAYS so in the give-up summary instead of rethrowing the raw AWS sentence -- issue #2018's lesson applied to this class, with a per-class exhaustion conjunct so a single 2s cooldown retry inside a mixed sequence cannot report "the full name-cooldown budget". The three sites that nest a default-schedulewithRetryinside their own outer loop compound with it: total sleep on a cooldown there goes ~487s -> ~640s (8.1 -> 10.7 min), still inside the 30-minute per-resource deadline, and the number is recorded in the JSDoc rather than left to be re-derived.dynamodb-index-busy-delete.tspasses all four schedule knobs, so its own deadline arithmetic is untouched. A window longer than 64s still fails, with the same message as before. Before this, the two consumers held DIFFERENT spellings of the identical SQS error, so whether the same AWS condition was survivable depended on which spelling the SDK surfaced. A sibling sweep of every provider that deletes asynchronously deliberately EXCLUDED ELBv2'sDuplicateLoadBalancerNameand DynamoDB's create-side refusal: AWS raises both for a resource that merely EXISTS, so recognising them would turn a terminal collision into a wasted retry budget ending in the same failure. Both are pinned as negative rows in the classifier table.tests/integration/custom-resource-provider/verify.shphase 6 previously polled the state machine to gone before redeploying, as a documented workaround for this issue; that wait is deleted and its absence is the live arm. The phase splits its two questions: a FATAL fence (exit 0 plusDeployment completed successfully, which a regression cannot pass because the create would fail and roll back) and a non-fatal COVERAGE verdict driven by evidence from the run itself -- the redeploy runs--verboseand the phase greps for a retry line quoting the AWS cooldown message, printingOK: COVEREDorINCONCLUSIVE. Exit 0 alone is a confluence point: a fully-closed window produces it too. Taggedordinary-create-name-cooldownin the scenario taxonomy, distinct from the existingrollback-reverse-replacement-name-cooldownwhich covers the sites where cdkd itself just deleted the name holder.A
--remove-protectiondestroy no longer strips a DynamoDB table's deletion guard when the retry sequence outlives the reuse window (issue #2211), and no longer claims a table is LIVE when it is gone (issue #2224) --src/provisioning/providers/dynamodb-delete-budget.ts,dynamodb-table-provider.ts,dynamodb-globaltable-provider.ts, plus new cases intests/unit/provisioning/providers/dynamodb-remove-protection-compensate.test.ts. #1978 added a compensating re-enable ofDeletionProtectionEnabledwhen a--remove-protectiondelete fails terminally, latched per table inProtectionFlipRegistry. That registry measured an entry's age from FIRST acquisition, so a--resource-timeoutovershoot past the 30-minuteDELETE_BUDGET_REUSE_WINDOW_MSlet a long retry sequence age out its OWN record mid-flight:acquiredropped it, handed back a freshflippedOffByThisRun: false, and the next attempt's pre-flipDescribeTablesaw the guard already off -- because cdkd itself had turned it off on attempt 1 -- so the latch stayed false and nothing compensated, reproducing #1978's residue (a live table with its deletion guard silently stripped) through the mechanism added to prevent it. The window is now SLIDING: every reuse restarts the stopwatch, so what is bounded is IDLE time since last use rather than total lifetime. The window itself is kept deliberately -- dropping it would let a much later destroy of the same table re-enable a guard it never touched, the inverse hazard #1978's registry JSDoc names -- and bumping the constant was rejected as bounding nothing, since the wall clock is fixed while the retry sequence is not. Separately, on a region-mismatch raceassertRegionMatchthrows insidedelete()'s ResourceNotFound branch BEFORE that branch reaches itsprotectionFlips.release, so the record is still latched and the compensation runs against a table that is already gone; itsUpdateTablethen got its ownResourceNotFoundExceptionand the catch logged at ERROR that "that table is LIVE with its deletion protection still off", which is false and pointed the operator at a table that does not exist. That one case now reports atwarnwith the claim dropped rather than aterrorwith it asserted, and deliberately NOT atdebug: DynamoDB returnsResourceNotFoundExceptionfromUpdateTableboth for a table that is gone AND for one whose status is merely notACTIVE(its own SDK model says so), and the second is reachable -- aGlobalTablewhosewaitForReplicaGonetimes out is still live, still unprotected, anddeleteAcceptedis still false, so silencing it at debug would hide exactly the case the line exists to report. The line now names the ambiguity and gives adescribe-tablecheck before the restore command. Every other compensation failure keeps the ERROR line. Both fixes are mutation-probed in both directions, including a control proving a non-ResourceNotFound compensation failure still logs at error, and a case proving a REUSED registry record still ages out -- without which a window made permanent-on-reuse passed the whole suite, which is the forbidden "drop the window" fix arriving by the back door. The sliding window's bound is stated rather than overclaimed:acquireruns once per attempt, so what it measures is the previous attempt's own duration plus the loop backoff, and the window is sized like one attempt's worst case -- it removes the accumulation case go-to-k/cdkd#2211 reported, not every instance of the class.cdkd deployno longer adopts an already-owned S3 bucket that lives in another region (issue #2227) --src/provisioning/providers/s3-bucket-provider.ts, plus a newtests/unit/provisioning/s3-bucket-provider-already-owned-region.test.ts(16 cases) and an updated primer ins3-bucket-provider-partial-create-cleanup.test.ts, plus a Phase 0 / Phase 0b arm pair intests/integration/s3-lifecycle/verify.sh.CreateBucketanswersBucketAlreadyOwnedByYouon OWNERSHIP, which is account-global, while a bucket is regional -- so the error fires just as readily for a bucket of yours somewhere else. The provider short-circuited it to an idempotent-create success unconditionally, so a deploy whose bucket name collided with one of the account's buckets in a DIFFERENT region reported success, applied this stack's whole bucket configuration (versioning, encryption, policy, notifications, lifecycle) to that foreign-region bucket, and recorded a physical id denoting no bucket in its own region. This is NOT an explicit-BucketNameedge case:generateResourceNameproduces{stackName}-{logicalId}with no region or account, so one stack deployed to two regions collides by construction. It also closes a data-loss path on property-driven replacement, where create-first "succeeded" against the foreign bucket and the destroy step then deleted the real in-region one. The guard now takes the bucket's region from thex-amz-bucket-regionheader on the 409 ITSELF -- no extra API call, no extra IAM -- falling back toGetBucketLocation, folding an empty/nullLocationConstrainttous-east-1and the legacyEUtoeu-west-1, comparing throughcanonicalizeRegion, and refusing with amarkNonRetryableerror naming both regions and the two remedies. Deliberately NOTHeadBucket: it 301s cross-region and SDK v3 turns the empty-body HEAD response into a syntheticUnknownError(src/utils/aws-region-resolver.tsalready recorded this); the first cut used it, its seven mutation-probed unit tests passed because they mocked the AWS CLI's redirect-following shape, and only the real-AWS integ arm caught that the guard could not fire. Deliberately not that module'sresolveBucketRegioneither -- it never throws and returns a fallback region, which would turn this fail-closed guard fail-open. The refusal is also worded to avoid the literaldoes not exist, a member ofOTHER_TRANSIENT_ERROR_MESSAGE_PATTERNS, which would otherwise have it retried for the full budget. Measured on real AWS: a bucket created inus-west-2answersBucketAlreadyOwnedByYouto aCreateBucketineu-west-1,us-east-1andap-northeast-1, always carryingx-amz-bucket-region: us-west-2. The same measurements FALSIFY the mid-delete mechanism the issue was filed for: aDeleteBucketfollowed immediately by aCreateBucketof the same name SUCCEEDS outright, so there is no window in which this error can mean "being deleted"; that correction is recorded on the issue. Sibling-site sweep (grep -rn "AlreadyExists\|AlreadyOwned" src/provisioning --include=*.ts-> 47 hits across 13 files):AWS::S3::Bucketis the only affected type, every other hit being either a regional namespace or a global namespace over a global resource, andCloudControlProviderhaving no adopt arm at all.src/assets/asset-storage.ts:548carries the same short-circuit and is tracked separately in issue #2240 -- an earlier revision of this entry called it structurally unreachable because the default name embeds the region, which is wrong:cdkd bootstrap --asset-bucket <name>supplies a region-free name. A separate pre-existing hazard in the same method, whereus-east-1's legacyCreateBucket200 OK marks an ADOPTED bucket as created and so lets the issue #376 partial-create cleanup delete a pre-existing user bucket, is filed as issue #2241; the guard here does not reach it, because the legacy 200 never enters theBucketAlreadyOwnedByYoucatch.cdkd drift --accept/--revertno longer report "No drift detected" over a stack they never compared (issue #2208) --src/cli/commands/drift.tsanddocs/cli-reference.md, plus new cases intests/unit/cli/drift-per-resource-failure.test.tsandtests/unit/cli/drift-cross-region-secret.test.ts.anyIncompletewas computed for every run but read only on the detection-only path, so a remediation run whose every read threw -- an IAM denial, a throttle -- printedNo drift detected — nothing to accept.and exited0. Nothing drifted only because nothing was read: the false reassurance #2135 madenotCompareda variant to prevent, surviving on the one path #2135 did not touch, and worse on a remediation command than on a detection one because the user ran it to CHANGE something. The fix is the MESSAGE, deliberately, and the exit code stays0: the remediation modes' exit codes are a documented contract that #2108 scoped its2away from on purpose ("changing them would alter what a remediation run means"), the population is anyone whose CI runs--accepton a stack that can hit a throttle, and--accept/--revertalready correctly leave an uncompared resource alone (both iterate the drifted outcomes only), so the state and AWS were never wrong -- only the sentence was. That path now printsComparison INCOMPLETE — nothing to accept, and that is NOT a clean bill of health: N of M resource(s) could not be compared (...), so cdkd does not know whether they drifted., followed by a pointer at the detection-only run -- the mode whose exit code DOES report it, as2. The TRIGGER and the COUNT are deliberately different populations, and collapsing them was the defect review round 1 found in the first cut. The trigger isoutcomeExitSignal'sincomplete, which is narrow on purpose: a stack whose only uncompared resource holds a{{resolve:ssm-secure:...}}token must not shout on every run about a comparison no action of the user's can clear -- the CI-forever hazard that cause is kept out of the exit code on. The COUNT, once the line fires, covers EVERY uncompared resource, each named by its OWN reason:Mis the total outcome count andNis everything not compared, sounsupportedandskippedland inNwith their own phrase rather than silently inflating only the denominator. That makesNWIDER than the report'sNOT fully comparedheading (which counts the reference / read population and reportsdrift unknownseparately) and matches the question #2154's glyph asks, "was everything actually compared". Counting only the clearable reasons printed1 of 3on a stack the report a few lines above called2 resource(s) NOT fully compared-- two lines disagreeing about one run, the newer one quieter, inside the fix for quiet reassurance. The per-reason phrases live in an exhaustiveRecord<NotComparedCause | 'unsupported' | 'skipped', { kind; phrase }>(the shapenotComparedReasonalready uses), so a cause added later is a COMPILE ERROR rather than anelsebranch describing it as a dynamic-reference problem -- which matters here becauseoutcomeExitSignalroutes every new cause to the incomplete side, i.e. straight into this message. The UNCERTAINTY CLAUSE is per-cause rather than one blanket tail, which is thekindhalf of that record. The line used to end..., so cdkd does not know whether they driftedfor everything inN, which is true of a throttled read and FALSE of aCustom::*resource: issue #323's position there is that drift is not APPLICABLE, not that cdkd is unsure -- so a stack with one throttle and fiveCustom::S3AutoDeleteObjectsclaimed uncertainty about all six.readFailed/refused/unresolvedTokennow report undercdkd does not know whether these drifted, andunsupported/skippedunderNot drift-checked by cdkd at all, which is a coverage limit rather than uncertainty, with no uncertainty claim attached. #323's SILENCE is deliberately not honoured on this path, and the JSDoc says so rather than leaving the next reader to read the new line as a regression of it: #323 governs the detection REPORT, a per-resource listing where aCustom::*entry every run is noise, while this is a single sentence explaining why a command asked to CHANGE something changed nothing -- and a sentence of that kind has to account for the whole stack or it is quietly reassuring again. Nothing about the detection report moves.docs/cli-reference.md's exit-0row is split in two, because the old single row claimed "cdkd left no comparison incomplete" for BOTH modes -- true of detection, false of remediation -- and the new--accept/--revertrow says so, points CI gates at the detection-only run or at--json'snotCompared[].cause, and records why the exit code was not changed. Fifteen mutation probes, one per BRANCH rather than one per pair -- round 2 caught exactly that shortcut here, where a single row reading "droppingunsupported/skippedfrom the tally" had only ever exercised theunsupportedhalf while BOTHskippedbranches sat green at 66/66, an unearned claim in the changelog of the PR that exists to stop unearned claims. Each row is now its own measurement: removing the branch reds 8, dropping the pointer line 2, blanking thereadFailedphrase 5, blanking the refusal phrase 2 (both in the cross-region suite, the only harness that can produce arefusedoutcome -- every fixture in the per-resource suite producesreadFailed, so that arm would otherwise be unfenced), widening the TRIGGER to the wholenotComparedroll-up 1, triggering unconditionally 2, hoisting the branch above the drift check 1 (drift still outranks it, so a stack that both drifted and threw is still accepted), counting from the incomplete subset instead of the uncompared population 1, no-op'ing theunsupportedtally bump 1, blanking theunsupportedphrase 1, no-op'ing theskippedtally bump 1, blanking theskippedphrase 1, movingskipped'skindtounknown1, giving the by-design lead the uncertainty wording 1, mislabelling the permanent token cause with the refusal wording 1, miscounting the denominator 2, and hard-coding the mode word 2. Theskippedarm is fenced by aCustom::S3AutoDeleteObjectsmember in the five-fates mixed fixture -- via that CDK helper it is the COMMONEST member ofNin real stacks, and it was the one nothing checked. The exhaustiveness fence is TYPE-level rather than probe-level, and that distinction is load-bearing: adding a fourthNotComparedCauseleavesvp test runreportingType Errors no errorswith every test passing, and onlyvp run typecheckreds it (measured:TS2741: Property 'probeCause' is missing ... in type 'Record<UncomparedReason, { kind; phrase }>'). A green test run does not cover it.cdkd gcnow collects abandoned custom-resource response placeholders from the state bucket (issue #2052) --src/state/state-prefix.ts,src/cli/commands/state-file-keys.ts,src/state/s3-state-backend.ts(newlistRawObjects),src/cli/commands/gc.ts,src/provisioning/providers/custom-resource-provider.ts,docs/cli-reference.md, plustests/unit/cli/gc-custom-resource-responses.test.tsandtests/unit/state/custom-resource-response-prefix-sync.test.ts.CustomResourceProviderPUTs an empty object atcustom-resource-responses/{requestId}.jsonin the STATE bucket before each invocation so the handler has a pre-signed URL to write to.cdkd gcscanned the ASSET bucket and contained zero references to that key family, so nothing swept the abandoned ones: an interrupted deploy between the PUT and any cleanup, a throw on a path that reaches no cleanup call, and -- the only shape with CONTENT -- a LATE handler PUT landing after cdkd stopped polling, which writes a real CloudFormationDatapayload to a key nothing collects, making it a data-retention question rather than only a storage leak. The open scope question the issue carried is settled the way its own Direction section named as durable: gc learns the prefix, rather than the provider taking a best-effort sweep of its own stack's stale keys, because gc already holds the whole-bucket view and the two guards this needs. Staleness adds no clock of its own -- the LOCK guard refuses the entire run while any stack holds a lock, and every deploy that can write one of these keys holds one for its duration, so a concurrent run's key is unreachable regardless of age;--older-than(default 30d) then applies to the object's ownLastModified, inclusive-KEEP at the boundary. The lock guard moved AHEAD of the bootstrap-marker check for this: the placeholders live in the state bucket, which exists independently of whether a region opted in to cdkd ASSET storage, so the "not opted in" early return would have made the sweep unreachable for exactly the accounts that have placeholders and no asset marker -- and the guard now covering that case is the safe direction. The prefix is ONE binding shared by producer and collector, homed insrc/state/state-prefix.tsfor the layering reason that file already records and re-exported fromstate-file-keys.ts, with a fence that greps the source for a re-spelled literal and carries a floor so "found nothing" cannot pass as "everything matches" -- a sweeper pointed at the wrong prefix finds nothing, which is indistinguishable from a clean bucket. Every positive test asserts the DELETE naming the key rather than an exit code, and each of the three halves is mutation-probed to a distinct failure set: removing the delete reds 3 cases, disabling the age guard reds a different 3, and restoring the pre-fix marker return reds exactly 1.cdkd destroy --all/cdkd state destroy --allnow stop on a Ctrl-C that lands in a window the per-stack runner does not span (issue #2117) --src/utils/interrupt-signals.ts(newwatchCommandInterrupt),src/cli/commands/destroy.ts,src/cli/commands/state.ts, plustests/unit/utils/command-interrupt-watch.test.ts,tests/unit/cli/destroy-command-sigint.test.tsandtests/unit/cli/state-destroy-command-sigint.test.ts. Previously neither destroy command registered a SIGINT handler of its own --deploy.tshas had one since issue #1348 -- so the ONLY channel carrying "the user interrupted" fromrunDestroyForStackback to the multi-stack loop wasDestroyRunnerResult.interrupted, a boolean assigned once inside the runner'stryafter its level loop. The runner's outerfinallyre-syncs it (result.interrupted ||= draining) and that line is marked TACTICAL in its own source: it narrows the window rather than closing it. Two gaps survived and neither is reachable from inside the runner at all -- the runner removes its SIGINT listener BEFORE that re-sync and itsreturn, so a signal there is seen by nobody; and everything the loop itself does between two stacks (RUN_FINISHED,eventRecorder.finalize,purgeEventsAfterDestroy) runs with no runner handler armed. In both,--allwent on to delete the NEXT stack after the user pressed Ctrl-C. A command-scoped handler closes the class rather than an instance, because it is armed in every window instead of the ones the runner happens to span. The two flags are both kept and answer different questions:result.interruptedstays the PER-STACK outcome (it decides that stack's state preservation, its summary line and its--purge-eventsskip), while the new one is the COMMAND-level "stop" the loop and the exit code read -- live, at each decision point, since assigning it to a local would re-create the sampled-once channel this issue is about. Escalation is deliberately split: while a per-stack destroy is in flight the watch stays silent and lets the runner's handler own the second-Ctrl-C force-quit, because only that one can release the stack lock best-effort and print the region-qualifiedcdkd force-unlockrecovery command; outside that bracket no runner handler exists and no stack lock is held, so the watch takes the force-quit itself. A guard also runs immediately before each dispatch, so a signal arriving during a stack's own preparation (state read, per-stack prompt, event-recorder start) stops the run before any delete is issued rather than one stack later. The provider-side interrupt watch's last-listener force-quit is unaffected in its remaining population --import/export/scrub/orphan/drift/state refresh-observedstill register no handler -- but it no longer fires during a destroy, which is the improvement: the old behaviour there exited 130 with the lock stranded and without stopping the loop gracefully. Each half is mutation-probed: reverting the loop's live read reds the two between-stacks cases while the runner-reported-interrupt control stays green, and removing only the pre-dispatch guard reds exactly the before-first-stack case.$util.parseJsonno longer echoes a prefix of the input it failed to parse -- in cdkd's MIRROR of the VTL engine (issue #2203) --src/local/vtl-engine.ts, plus tests. The site carried the shape fixed in issue #2189 -- acatchinterpolating the parser's own message, into which V8 embeds a prefix of the PARSED INPUT -- over a population #2189 scoped out. The leak IS closed forcdkd local start-api-- but not by the edit to this file.src/local/http-server.tsis a shim re-exportingstartApiServerfromcdk-local/internal, so the command runs cdk-local's implementation;evaluateVtlhere is reached only from cdkd's ownrest-v1-integrations.ts, whose sole live import iswarnSsrfRiskyUri. The user-facing fix is cdk-local'ssrc/local/vtl-engine.ts(cdk-local PR #556), shipped in cdk-local 0.147.6, and this change bumps cdkd'scdk-localdependency to it -- so the closure lands for cdkd users with this release, and the edit to cdkd's own copy only keeps the fork from drifting back to the leaking shape. Demonstrated rather than asserted: against the built CLI on 2026-08-26,cdkd local start-apiserving a MOCK REST v1 request template that calls$util.parseJsonon a header holdinghunter2-my-passwordanswered502with"reason":"$util.parseJson: the argument is not valid JSON (SyntaxError; argument length 19). The parser detail is withheld..."-- nohunter2, noUnexpected token, in the response body or the server log. The same request before the bump returned the whole password. The premise was proved in its own step first (a VALID JSON header answered200), so the negative is not a confluence point. That cdkd's fork is unreachable at all is a separate root cause, tracked as issue #2228. The distinction is narrower than "cdkd'ssrc/local/is dead code", and the difference matters:ecs-task-runner.tsis a REAL implementation imported live bysrc/cli/commands/local-run-task.ts, so #2189's own fix inecs-secrets-resolver.tsIS on the shipped path. Only the start-api / HTTP dispatch chain is shimmed. In the live copy the argument of$util.parseJson(...)is routinely a value the caller sent --$input.body, or a header via$input.params(...)-- andvtlFailurecopies the message into the 502 RESPONSE BODY, so the prefix travels back over the wire rather than merely reaching a terminal; measured against cdk-local, a 9-character non-JSON header came back whole, because V8 appends...only past its ~10-character window and quotes a SHORT input in FULL. The message now carrieserr.nameplus the argument LENGTH and says outright that the parser detail is withheld, so a later contributor does not helpfully restore it. Reading that length exposed a latent hole the same review caught:coerceis typed=> stringbut RETURNEDundefined, becauseJSON.stringifyreturnsundefined(it does not throw) for a function, a symbol, or an object whosetoJSONyields undefined, and TypeScript hides it becauseJSON.stringify(unknown)selects theanyoverload. It is reachable from an ordinary template --$input.json/$input.path/$input.paramsare own-property FUNCTIONS on the objectbuildVtlInputreturns, so$util.parseJson($input.params)with the call parens forgotten threw aTypeErrorinstead of the diagnosis, and what escapedevaluateVtlwas then not theVtlEvaluationErrorcallers match on.?? ''closes it incoerceand insafeStringify, which carried the same hole. Tests: the two leak shapes (long input quoted as a prefix, short input quoted in full), a table over all four empty-coercion shapes asserting the throw is still aVtlEvaluationError, and the reachable template-level spelling. Both leak cases also assert the parser's PHRASING is absent, closing a mutation that strips only the quoted segment while still leaking the first character and the offset. A whole-input negative from the first cut was DELETED rather than kept: at that length it held with the fix reverted, and a vacuous assertion reads as protection that is not there. Count needles are anchored on their trailing), sinceargument length 4is a substring ofargument length 40, confirmed by a probe that multiplies the reported length by ten.The RIE streaming prelude parse echoes function OUTPUT, not protocol framing -- and only the
JSON.parsefailure is suppressed (issue #2203) --src/local/rie-client.ts, plus tests. The issue proposed leaving this site alone with a comment, on the premise that its prelude parse consumes cdkd's own wire protocol rather than application data. That premise is false, which is what flipped the issue's decision from its option 2 to option 3. The split above the parse scans the response for an 8-NUL run, and the#664block above it records that the commonest handler shape in the wild --streamifyResponseplussetContentType/write, never callingHttpResponseStream.from-- emits no framing at all, so what is scanned is raw function OUTPUT. An 8-NUL run inside binary output therefore matches by COINCIDENCE and handspreludeBytesa slice of application data. Reproduced on the pinned Node 24.15 with a tar stream, whose 512-byte member header NUL-pads everything after the 100-byte name field: the first run sits immediately after the file name, and the parser answersUnexpected token 'c', "customer-d"... is not valid JSON. Like the sibling entry above, this file is cdkd's MIRROR --invokeRieStreaminghas no caller in cdkd'ssrc/(issue #2228) -- so the shipped fix is cdk-local PR #556, released as cdk-local 0.147.6 and consumed by the dependency bump in this same change; this copy is kept in step so the fork cannot drift back. Only theJSON.parsefailure is suppressed, and getting that wrong was a real regression the review caught:parseStreamingPreludethrows four ways, and the other three (empty prelude,prelude is not a JSON object,statusCode must be a number (got <typeof>)) are input-INDEPENDENT. Suppressing them bought no privacy at all while telling a correctly-framed handler emitting{"foo":1}that its valid JSON is "not valid JSON", plus advice to callHttpResponseStream.fromwhich it had already called. The throw is now gated onerr instanceof SyntaxErrorand the other three stay verbatim, with three cases at theinvokeRieStreaminglevel fencing the gate -- they have to be at that level, because the suppression lives at the CALLER and the pre-existingparseStreamingPreludecases cannot see it. The message keeps the byte count before the separator, and leads with the MISREAD rather than blaming the handler, because the unframed shape is one the#664block deliberately SUPPORTS: framing is offered as a way to rule the misread out, not as the diagnosis. Two further review findings are folded in: "scans the WHOLE response" overstated the scan, which stops atSTREAM_PRELUDE_MAX_BYTES(1 MiB) and fails with its own error past that; and the reported size was unfenced between BYTES and UTF-16 code units, since both fixtures were pure ASCII -- four 3-byte characters (12 bytes, 4 code units) now separate them. Count needles are anchored on both sides, since26 bytes beforeis a substring of126 bytes before. Eleven mutation probes red, including the gate probe (wideninginstanceof SyntaxErrortoinstanceof Errorreds all three legibility cases) and the inflated-count probe. The remediation assertion needed anchoring to its whole clause: a baretoContain('HttpResponseStream.from')stayed GREEN with the hint deleted, because the diagnosis sentence above it names the same symbol.A nested stack's secret flow is redacted in BOTH directions — parameters IN and outputs OUT (issues #1903 / #2055) —
src/deployment/deploy-engine.ts,src/provisioning/providers/nested-stack-provider.ts,src/deployment/intrinsic-function-resolver.ts,src/cli/commands/diff-recursive.ts. Four GHSA-p5qg-v9gv-hc7w follow-ups at the same boundary — issues #2086 and #2087 were found by reviewing the first two and cannot be verified apart from them, so all four ship together. Parameters IN (#1903, the actual disclosure). cdkd's redaction rests on the resolver recordingplaintext -> {{resolve:...}} expressionand the deploy engine reading that at its state-save choke point. A nested stack broke the chain: the PARENT resolves the child'sParametersblock, so the child engine received already-resolved PLAINTEXT and its own template spells the consumption as{Ref: <ParamName>}— an intrinsic OBJECT, never a{{resolve:string — so the child'srecordedSecretValuescame out empty and the child'sstate.jsonpersisted the decrypted secret with no expression to redact back to. The #1904 / #1900 PATH-based pass structurally could not help, because it copies a source leaf that IS an expression string and here there is none. The fix is the opposite direction:NestedStackProviderhands the child engine a SEED map (DeployEngineOptions.inheritedSecrets) — the parent's own per-resource bag, which is exactly the secrets resolved while resolving thatAWS::CloudFormation::Stackrow'sParameters. The child engine seeds it into every freshrecordedSecretValues, so the ordinary VALUE-based redaction finds the plaintext wherever the parameter landed, including EMBEDDED in anFn::Sub-built connection string. The map reaches the provider through anAsyncLocalStoragein its own leaf modulesrc/deployment/resource-secrets-scope.ts(withCurrentResourceSecrets/getCurrentResourceSecrets) rather than a new field onCreateContext, deliberately:.claude/rules/providers.mdalready records whySecretMaskingContextcarries a masking FUNCTION and not the bag — a plaintext-keyed map on the shared context makes all ~130 providers a place[...secrets.keys()]can leak from — and exactly one provider needs the pairs. Scoped per RESOURCE, not per stack (#2087). The child engine does NOT pre-seed every resource's redaction map with the parent's bag, which is what the first cut did:redactSecretsForStatesubstring-matches at or aboveMIN_NEEDLE_LENGTH, so a child resource that never referenced the parameter but happened to spellmy-production-bucketwhile the secret wasproductionhadmy-{{resolve:...}}-bucketpersisted — a rewrite the desired side never mirrors (redactParametersForDiffrewrites only the PARAMETERS), so the stack acquired a perpetual UPDATE, or a perpetual REPLACEMENT on a create-only property. The pair is instead recorded at RESOLUTION time, byIntrinsicFunctionResolver.recordInheritedParameterSecrets, when a resource's own{Ref: <Param>}resolves to a value carrying the plaintext — whole-value at any length, or a substring at or above the same floor the redactor uses. That is exactly the set of resources that can carry the plaintext into their persisted state, and it reproduces the parent's own scoping, whereperResourceSecretsis keyed by logical id. The rollback executor binds the same store (#2086).src/deployment/rollback-executor.tsdrives the same providers on the recovery path and bound the seed nowhere, so a rollback that reverted a nested-stack row re-persisted the child's plaintext — a recovery path restoring the pre-fix behaviour is a hole, not a baseline. All four of its provider call sites are now wrapped: both branches ofupdateWithRollbackRetry(the choke point for all four rollback update arms) and the two reverse-replacement replay-CREATEs, each INSIDE the retry thunk so every attempt is scoped.resolveReplayPropshas already re-resolved the journal's{{resolve:...}}back to plaintext at every one of them, so the exact bag was in hand. Standalonecdkd rollbackis unaffected: it builds a destroy-mode context, sorequireDeployContextthrows before any child engine is built. The diff half had to land WITH it, not before.cdkd diff --recursive'sresolveChildStackParametersset neitherskipDynamicReferencesnorrecordedSecretValues, so it DECRYPTED the reference at plan time and printed the plaintext; it now sets the flag. Adding that flag ALONE would have been wrong — the deploy path persisting plaintext was what made the comparison self-consistent, so skipping only on the diff side compares expression-vs-plaintext and reports a spurious perpetual change every run. The same coupling shows up inside the child engine, one layer down: the child's own DIFF context now binds the REDACTED parameter bag (redactParametersForDiff), because once its state holds the expression a comparison that still resolves{Ref: Param}to plaintext reports an UPDATE on every deploy. The PROVISIONING and CONDITION-evaluation contexts deliberately keep the real values — that is what reaches AWS, and substituting an expression into anFn::Equalswould flip a condition. Outputs OUT (#2055). The mirror image, and a third reader of the redacted bag in the #1934 class:NestedStackProviderreads the CHILD's persisted outputs and surfaces each as the parent'sOutputs.<Name>attribute, so a parent property spelled{"Fn::GetAtt": ["Child", "Outputs.DbPassword"]}reached AWS as the literal{{resolve:secretsmanager:...}}token. Re-resolution reuses #1934'sreresolveCrossStackValue/resolverForProducerRegionrather than a fourth copy of the walk, and it lives at the READ site —IntrinsicFunctionResolver.resolveGetAtt's flat-key attribute hit — NOT inbuildOutputsAttributes. That seam choice is load-bearing rather than stylistic: re-resolving at attribute-BUILD time (the easy option, sincereadChildOutputsAsAttributesis already async) puts the plaintext into the parent's recordedattributes, and every CONSUMER resource reading it throughFn::GetAttthen has no entry in its OWNrecordedSecretValues, so the save choke point has nothing to redact back and the consumer'sstate.jsonpersists the decrypted secret — trading one bug for a wider one. At the read site the consumer's context is in hand, so the plaintext is recorded and both the parent's nested-stack row and every consumer's record stay redacted. The producer region is the CHILD's, read back from thearn:cdkd-local:<childRegion>:...physicalIdNestedStackProvidersynthesizes (a secret NAME is regional, so the consumer's region can answer with a different secret); the child's own state record cannot supply it, because its region is part of its state KEY and reading the record to learn the region is circular. Equal to the parent's region today, named apart so cross-region nested stacks inherit the right rule. UnderskipDynamicReferencesthe token is returned untouched, so the parent's diff keeps comparing expression-vs-expression. Tests:tests/unit/deployment/deploy-engine-nested-stack-inherited-secrets.test.ts(persist + embedded-secret + the DIFF-vs-CONDITION context split, with the un-seeded child kept as the discriminator),tests/unit/provisioning/nested-stack-provider-inherited-secrets.test.ts(forwarding on create AND update, absent / empty store, child-region ARN, and that the outputs attribute stays a token),tests/unit/deployment/nested-stack-output-secret-reresolve.test.ts(realAwsClientswith only the leaf SDK client faked, so "which region answered" is observable — two regions holding different values behind one reference),tests/unit/cli/diff-recursive-nested-stack-secret.test.ts(NO_CHANGE on a freshly-deployed tree with a secret fetch made a hard failure, a genuine-change regression guard, and the two arms where a redacted token must not be reasoned about as a VALUE — it is left uncoerced rather than becomingNaNunder aType: Numberchild parameter, and it disables the condition-pruning pass rather than pruning by a verdict deploy will not reach),tests/unit/deployment/intrinsic-resolver-inherited-parameter-secrets.test.ts(the REAL resolver: both recording arms, theMIN_NEEDLE_LENGTHfloor bound to the exported constant,Fn::Sub/Fn::Join/CommaDelimitedListreach, and the #2087 scope control that resolving a DIFFERENT parameter records nothing) andtests/unit/deployment/rollback-executor-nested-stack-secret-scope.test.ts(all four rollback binding sites, the single-shotdisableOuterRetrybranch separately from the retried one because that is the branchNestedStackProvideractually takes). FOUR MORE ESCAPES FROM THE STRING-KEYED MODEL, all closed at the same seam (review round 2). cdkd's secret model is keyed by plaintext STRING end to end —RecordedSecretValuesis a plaintext-keyed map, the recording scan walks strings and string array elements, andredactSecretsForStaterewrites string LEAVES — and two paths escaped it. (a)resolveParametersCOERCES by declaredType, so aType: Number/List<Number>child parameter fed a secret became a JS number before any of that ran: nothing was recorded, nothing was rewritten, and the child'sstate.jsonkept the DECRYPTED value verbatim withcdkd diff --recursivereporting a change forever. It is now REFUSED, naming the parameter (IntrinsicResolutionRefusalError, codeNESTED_STACK_SECRET_PARAMETER_TYPE), rather than patched around: recording on the pre-coercion string would additionally require the redactor to rewrite a NUMBER leaf into an expression string matched byString(n) === plaintext, which both under-covers ("007"round-trips to"7") and over-covers (a numeric secret like8080whole-value-matches every unrelated port, #2087's class on a path whereMIN_NEEDLE_LENGTHdoes not apply) — and a remedy that can silently under-cover is the wrong one for a disclosure path. CDK synthesizes every nested-stack cross-reference parameter asType: String, so no CDK app is affected;cdkd diffWARNS instead of throwing, since a plan must stay best-effort. (b) The child engine is the ONLY placecontext.parametersholds decrypted plaintext, and the resolver's two parameter debug lines print a parameter VALUE —Resolved Ref to parameter(which runs BEFORErecordInheritedParameterSecretsputs anything in the bag) andParameter <name>: using user-provided value.stringifyParameterForLogredacts only on the template author'sNoEcho, which a CDK-synthesized nested-stack parameter never carries, socdkd deploy --verboseprinted the secret.maskSecretsForLognow masks againstcontext.inheritedSecretsas well asrecordedSecretValues, which covers every log seam in the child resolver rather than the two that were found, andresolveParametersmasks its own three lines against the same bag. (c)crossStackSourceKeygained anFn::GetAtt <logicalId> <attributeName>arm, so the nested-stack-output read site above records a #2059 POSITION association instead of passingundefined: without a key its leaves fell to the plaintext-keyed value scan, and a child exportingCur(:AWSCURRENT) andPrev(:AWSPREVIOUS) of one ROTATING secret has both outputs resolve EQUAL during theAWSPENDINGwindow, so both parent properties persisted the survivor's expression andresolveReplayPropsapplied the WRONG stage to the live resource on a rollback. Both sides compute the key from the RAW leaf, and a non-literal attribute name refuses and degrades to today's behaviour. (d)cdkd diff --recursiveskipped condition evaluation whenever ANY bound parameter was token-valued. That is not merely conservative: with the condition map left undefined,resolveIfwarns and takes the FALSE branch for everyFn::Ifin every property value, so a condition-true property diffs as a spurious UPDATE (perpetual, and--failexits 1) on top of the phantom CREATEs. The skip is now scoped to conditions that reference a token parameter. A chained{Condition: X}shape needs no walk of its own and does not get one: the referenced condition is an entry in the sameConditionsmap and the scan visits every entry, which was measured rather than assumed (the first cut's transitive closure was removed once a probe showed deleting it changed no answer). Also corrected:AttributeFetcher.cacheFallbackinsrc/analyzer/orphan-rewriter.tsis a SECOND reader of a cached attribute that may legitimately hold an unresolved{{resolve:...}}since #2055 — it has no resolver context, so undercdkd orphan --forceit splices the token verbatim and now WARNS about exactly that, andbuildOutputsAttributes's "there is exactly one reader" note is corrected to "one RESOLUTION path". Real-AWS arm: the newtests/integration/nested-stack-secret/fixture, whose child carries an unrelated literal CONTAINING the secret plaintext as the #2087 discriminator (an overlap the script now ASSERTS rather than syncing by comment), which runs THREE deploys so the third reachesNestedStackProvider.update— the arm #1903's own comment names, and the one a no-op second deploy never touched — proving from AWS that the update ran before drawing any conclusion from the resulting state, which scans both deploys'--verboseoutput for either plaintext, and which checks the perpetual-UPDATE class by the EXIT CODE ofcdkd diff --recursive --failrather than by its text.The
--remove-protectionflip record is now dropped when the delete fails TERMINALLY, so a later destroy in the same process cannot re-enable a guard it never touched (issue #2244) —src/provisioning/providers/dynamodb-table-provider.ts,src/provisioning/providers/dynamodb-globaltable-provider.ts,src/provisioning/providers/dynamodb-delete-budget.ts,tests/unit/provisioning/providers/dynamodb-flip-record-terminal-release.test.ts,tests/unit/provisioning/providers/dynamodb-remove-protection-compensate.test.ts,tests/unit/utils/elapsed-budget.test.ts.ProtectionFlipRegistryentries are RETAINED on a throw by design — that is what carries "an earlier attempt already flipped the guard off" across the re-entereddelete()issue #1978's round 2 fixed — and until now the ONLY things that dropped one wererelease()on the success path and on the NotFound branch. A TERMINAL failure therefore left a record behind for a full reuse window, and issue #2211's SLIDE moved that window's clock from the FIRST acquire to the LAST, lengthening exactly this retention. Inside the window a second delete of the sameregion\0namekey IN THE SAME PROCESS — a rollback, a sibling stack, a re-run destroy, or two state records naming ONE physical table after acdkd importadopted it into a second stack, all reaching the same provider INSTANCE through theProviderRegistrythat served them (destroy-runner.tsbuilds a FRESH registry when a stack's region differs, so the sharing is per registry rather than per process, which is also why the key is region-qualified) — inheritedflippedOffByThisRun: trueand answered its own terminal failure with anUpdateTable(DeletionProtectionEnabled: true)the user never asked for. The fix is one guardedrelease()indelete()'scatch, gated onisTerminalDeleteFailure(error)AND on what the compensation reported. The predicate is the same one the compensation consults — the one that MIRRORSdestroy-runner.ts's re-entry condition, so it is already an assertion that no second attempt is coming — but not the same GATE: the compensation ANDs it withflippedOffByThisRunand!deleteAccepted, so the release also fires on the arms where the compensation early-returned having done nothing, which is intended (a record with nothing to undo has nothing left to serve). The release sits after acatchwhose outcome defaults tofailed, so an unknown outcome RETAINS the record. It is acatchrather than afinallybecause afinallyREPLACED the delete error on its way out when a logger threw inside the compensation, sothrow errorwas never reached and the caller saw the logger's message instead of the delete's; measured with a probe that madelogger.warnandlogger.errorthrow. The three routes out of thatcatchanswer differently, and only one of them is obvious. RETRYABLE (a throttle, and the attempt-cap exhaustion that ends a long sequence of them): the predicate is false, nothing is released, and the retained-on-throw contract stands unchanged — known narrowing 1 still leaves the guard off when the outer loop's cap gives up, because the provider cannot see which attempt is the last. INTERRUPT (Ctrl-C):isInterruptedWaitErrormakes it terminal, so the release fires once the compensation has landed, and this is the route that most needs it — the compensation has just put the guard BACK ON, so a retained latch describes a world that no longer exists while the process can still outlive the abort long enough for a rollback to reach the same key. It now has its own case; a claim in a comment that no test composes is one a refactor can quietly drop. DEADLINE (--resource-timeout): the release does NOT fire and CANNOT, becausesrc/deployment/resource-deadline.tsrejects the OUTER promise without cancelling what it wraps, so noResourceTimeoutErrorever enters thiscatch(known narrowing 2, the same non-cancelling shape issue #1955 documents); the record stays latched behind an attempt that is still polling, which is correct, and that attempt's eventual settle reaches either the success release or this one. The record is NOT released when the compensatingUpdateTableitself FAILED, and that arm is why the release reads the compensation's OUTCOME (not-applicable/restored/failed) rather than only the predicate. There the guard really is off and cdkd really is the one that turned it off, so the retained record is the only in-process memory that a re-enable is still owed — and a later delete of the same key retrying it is the action cdkd OWES, not the unrequested one this change prevents, which is about a guard already put BACK. Releasing there would make that later delete observe the guard already off, recordflippedOffByThisRun: falseand compensate nothing, leaving a table cdkd stripped still stripped; and it is reachable by exactly the route that justifies the release itself, since two state records can name one physical table oncecdkd importhas adopted it into a second stack. The delete ALLOWANCE is deliberately left alone, and NOT because a re-entry might still spend it — this arm's premise is that no re-entry is coming. It is out of scope here: the two registries differ in what an inherited entry can DO (an inherited allowance can only make a later delete give up sooner, never issue a write nobody asked for), andElapsedBudgetRegistry's window is FIXED rather than sliding, so it ages out from its own creation regardless. Applied to BOTH providers. The lines this change ADDS are byte-identical on the two halves, but the wrappers themselves are not and never were — theGlobalTableone carries a twelve-line header of its own, callsdeleteGlobalTableResource, and passestypeLabel: 'GlobalTable'— so what is shared is the mechanism, kept in step deliberately: the compensation suite is adescribe.eachover the pair, and a one-sided fix is the half-applied twin issue #1978 asked specifically to avoid. Two paths in the same mechanism gained tests (the issue's second item). Attempt-cap exhaustion now pins that the registry holds ONE record per key rather than one per attempt, that nothing compensates between retries, and that the post-cap record is not INHERITED by a later delete once the window has elapsed. Four further cases in the same suite close the gaps a review found in the first revision: the Ctrl-C route (the production comment calls it "the route that most needs it", and nothing composedisInterruptedWaitError-> release), a REGION-QUALIFIED key (every other case leftexpectedRegionundefined, so a wrong region source in the release stayed green), a throttle sequence that ENDS terminally and must still re-enable (the over-release direction, fenced black-box inside this file rather than only by its white-box registry-size line), and a compensation that FAILED, whose record must be retained so a later delete can retry the re-enable. The suite lives intests/unit/provisioning/providers/, beside the sibling it shares its subject with. (It is NEW against main; an intra-branchgit mvis why the diff shows no deletion.)ElapsedBudgetRegistry'sreuseWithinMsgains four cases in the primitive's own suite, the load-bearing one asserting that this window measures from CREATION and does NOT slide — the deliberate divergence fromProtectionFlipRegistry, which went sliding for #2211 and is otherwise a look-alike with the same key and the same window constant. One claim in the issue is FALSE against the tree and is corrected rather than inherited:reuseWithinMswas not untested —tests/unit/provisioning/dynamodb-delete-budget.test.ts("a RETAINED allowance goes stale once its deadline has certainly fired") passes it and would already red on a slide. What was missing is a fence in the file someone EDITINGsrc/utils/elapsed-budget.tsopens, since a contract tested only through one consumer's suite is one a refactor of the primitive can break without ever reading it. Every new case is mutation-proven, and the discriminator is never "the delete threw" — that is true of the broken code too. Four probes on the release itself, each run against both providers and each restored with the restore verified in the same command. DELETING the release reds all 12 cases in the new suite. Dropping the TERMINAL predicate (if (outcome !== 'failed')) reds 4 there — the attempt-cap case's registry size (expected +0 to be 1) and, black-box, the throttle-sequence case that must still re-enable (expected [] to have a length of 1 but got +0) — plus 4 indynamodb-remove-protection-compensate.test.ts, which is the #1978 round-2 regression caught from the other side. Dropping the OUTCOME gate (the shape this change's own first revision shipped, releasing even after a failed re-enable) reds exactly the two new failed-compensation cases black-box (expected [ UpdateTableCommand ] to have a length of 2 but got 1) and the two #2224 registry assertions in the sibling suite. Keying the release WITHOUT the region reds only the region-qualified case, black-box (2 re-enables where 1 was expected) — until it was added, every case passedexpectedRegion: undefinedand a wrong region source in the release stayed green. A fifth probe, indynamodb-delete-budget.ts, makes the ResourceNotFound arm reportnot-applicableinstead offailedand reds 3: both providers' #2224 registry assertions and the direct-unit outcome assertion. What is NOT probed, and is said rather than implied: thecatchplus the'failed'default is defence in depth with no reachable red for the RELEASE, becausecompensateRemovedDeletionProtection's whole contract is that it does not throw — the structure exists so "unknown outcome" keeps meaning "retain" if that ever changes. Making the reuse window permanent reds the not-inherited assertion; and the fourreuseWithinMscases red under, respectively, making the bound non-optional, removing the window, flipping its boundary to>=, and unifying it into a sliding window.
Recently Implemented (2026-08-25):
cdkd drift: a per-resource read or comparison failure no longer sinks the whole run, and a stack in which nothing was compared no longer prints a green check (issues #2151, #1945, #2154) --src/cli/commands/drift.ts, plustests/unit/cli/drift-per-resource-failure.test.tsand updates to three existing drift suites. Previously a throw from ANY per-resource call in the detection loop propagated out of the loop and out of the command: no summary line, no per-resource report, and every other resource in the stack -- under--allevery remaining STACK -- left unchecked, over one bad resource. Measured live on 2026-08-21 against the committedcloudwatch-anomaly-detectorfixture, where the Cloud Control fallback threwUnsupportedActionExceptionand the stack's ordinary SQS queue was never looked at, with exit1-- the same code that means "drift detected", so a CI gate reported drift on a run that compared nothing. The two issues reported two sites (#2151 the Cloud Control fallback, #1945calculateResourceDrift), but the defect is a CLASS: the reachable sites areprovider.readCurrentState,ccApiFallback.readCurrentState,getDriftUnknownPaths/getDriftUnorderedPaths,canonicalizePrincipalUniqueIds,canonicalizeIpProtocols,provider.canonicalizeDriftProperties,calculateResourceDriftandredactDriftChanges, so ONE guard wraps the whole per-resource body rather than a catch per reported call. This restores the symmetry the surrounding code had already chosen -- the provider lookup, the deny-list short-circuit and (since #1914) the dynamic-reference resolution all already degraded to a per-resource outcome and continued.NotComparedCausegainsreadFailed, and the taxonomy question #2151 raised (read-failure vs no-read-path) is settled by whether a RE-RUN CAN CLEAR IT:isNoReadHandlerErrorroutesUnsupportedActionException/TypeNotFoundException-- matched through the cause chain, sinceCloudControlProvider.handleErrorre-wraps them, and bounded at depth 10 so a self-referentialcausecannot hang the guard -- to the existingunsupportedoutcome at exit0, because that is the same condition the fallback otherwise signals by returningundefinedand AWS picks which spelling it sends; everything else isreadFailedat exit2, distinct from the1that means drift.outcomeExitSignalis rewritten as an EXCLUSION ofunresolvedTokenrather than an inclusion list, so a cause added later defaults to the non-zero side -- the silent-default #2135 made impossible for a VARIANT was still reachable one level down at the CAUSE.DriftComparisonRefusedErroris renamedDriftComparisonIncompleteError(private; no import site outside the file) sincerefusedis no longer its only cause.--jsonnotComparedentries gain acausekey (refused/unresolvedToken/readFailed) and theirreferencesUnresolvedstops being the literaltrue-- it isfalsefor areadFailedentry, whose references are beside the point;notCompared.length === 0remains the documented "everything was actually checked" predicate and is unaffected.notComparedOutcomesnow returns the cause beside each outcome, because it is where the invariant is established and every reader otherwise needs an unreachable??default. The human report's block heading stays BYTE-FOR-BYTE identical when noreadFailedentry is present and only switches wording when one is, so the widened population does not move output for stacks that do not have it.--accept/--revertneed no filter: both iterate the drifted outcomes only, so areadFailedresource is excluded from remediation by its outcome kind -- #1945's second open question, answered structurally. Separately, #2154: the glyph follows "was everything actually compared", so a stack withchecked === 0and at least one outcome now prints⚠ ... NOTHING was comparedinstead of✓; this covers theskipped-only (all-Custom::*) stack, which #2154 flagged as its own user-visible call, and the EXIT CODE is untouched -- flipping it would fail such a stack's CI forever, the hazardunresolvedTokenis already excluded on. A stack with NO resources keeps the✓. Every fence in the new suite is mutation-probed: removing the guard reds 8 of 12, forcingisNoReadHandlerErrorfalse reds 2 and true reds 6, mappingreadFailedtononein the exit signal reds 4, disabling the #2154 branch reds 1 here and 1 indrift.test.ts, dropping the empty-stack conjunct reds 1, and removing the cause-chain walk reds 1.AWS::Regionand theFn::GetAZsregion filter now fold at their SOURCE, closing the two region reads that could be wrong SILENTLY (issues #1882 and #1887) --src/deployment/intrinsic-function-resolver.ts, plus tests.effectiveAccountInfoRegionreturnedoverrideRegion || process.env['AWS_REGION'] || 'us-east-1'unfolded, and it is the source ofaccountInfo.region-- which is whatAWS::Regionreturns, what everyFn::Subin a USER template interpolates, and whatresolveGetAZssends to EC2 as aregion-namefilter when the template names no region. It now folds throughcanonicalizeRegiononce, at the read, rather than at each of those three consumers. The decision issue #1882 was blocked on was settled by measurement rather than by argument, and the measurement dissolved the question instead of answering it. That issue heldAWS::Regionraw pending a live CloudFormation A/B, on the reasoning that it is CFn's own passthrough and folding changes a value the template author reads back directly. The A/B was run on 2026-08-25: a non-canonical region never reaches CloudFormation at all, because SigV4 scopes a credential to the region STRING and the service compares it case-sensitively --STSClient({region:'US-EAST-1'}).send(GetCallerIdentity)andCloudFormationClient({region:'US-EAST-1'}).send(ListStacks)are both refused withSignatureDoesNotMatch: Credential should be scoped to a valid region, as is the mixed-caseUs-East-1. So the two obvious routes are closed BEFORE this function ----region/AWS_REGIONfold at the CLI boundary (issue #2065), and a CDK app declaringenv: { region: 'US-EAST-1' }fails atapp.synth()withCloudAssemblyError: Unable to parse environment specification, sinceEnvironmentUtils.parseis case-sensitive (measured on aws-cdk-lib 2.244.0). What is NOT closed is the route that makes this fold more than tidiness, and an earlier draft of this entry wrongly called it unreachable: cdkd's ownparseEnvironmentaccepts any region text, so a Cloud Assembly carrying a raw region -- hand-authored, from a non-CDK toolchain, or acdk.outleft by a synth that threw AFTER writing the manifest -- reachesstackInfo.regionunfolded anddeploy.tspasses it on as the resolver's region. That deploy SUCCEEDS, becauseAwsClients' constructor folds the region its clients sign with, so SigV4 never sees the raw spelling; every${AWS::Region}a user'sFn::Subinterpolates then inherits it, producingarn:aws:s3:US-EAST-1:..., which no IAM policy matches, and persisting it beside the canonical ARNs cdkd itself builds since issue #1850. Folding removes that self-contradiction. Upgrade consequence, stated because #1850's own entry states it for its fold: a stack deployed that way keeps the raw spelling in its recorded properties, so the next diff of a property interpolating${AWS::Region}sees a change, and where that property is create-only it classifies as a REPLACEMENT -- deliberate, since the recorded value is unusable and converging it is the point. State KEYS are unaffected; they are built fromstackRegion, which this does not touch. The EC2 filter is the more dangerous of the two consumers because it fails SILENTLY rather than loudly: EC2 matchesregion-nameliterally and answers a non-matching filter with success and an EMPTY list, measured live the same day asregion-name=us-east-1 -> 6 AZsagainstregion-name=US-EAST-1 -> 0 AZs, and the wrong filter therefore yields no zones at all. What happens NEXT needs stating precisely, because issue #1887's own wording is out of date and this lane repeated it before checking: since issue #1957 an empty answer is REFUSED with a throw rather than propagating into an out-of-rangeFn::Selector a silently empty subnet list. The surviving defect is thus not silence but a WRONG FAILURE -- a deploy that should have resolved the region's zones instead dies, blaming an unenabled region or a foreign endpoint, neither of which is what happened. The same value keyscachedAvailabilityZones, so two spellings were two entries for one region. The consumer-sidecanonicalizeRegioncalls this subsumes --constructAttribute's destructure fold,s3-endpoints.ts's entry fold -- are deliberately LEFT in place, since double-folding is a no-op. Onlys3-endpoints.ts's is still reachable from a caller that does not come through here (S3BucketProvider.buildAttributeshands its3Client.config.region());constructAttributehas a single caller which always passesgetAccountInfo(this.resolverRegion), so its fold is now genuinely redundant and is kept as defense in depth. An earlier draft claimed reachability for both. TWO sibling issues were audited and found ALREADY FIXED rather than fixed again, and are closed with that evidence alongside this change: issue #1881 (six SDK providers building ARNs fromclient.config.region()) and issue #1888 (the S3CreateBucketLocationConstraintcomparison, viagetRegion()->s3Client.config.region()). Both read the SDK CLIENT's own resolved region, andAwsClients' constructor folds that for a bag CONFIGURED with a region --tests/unit/utils/aws-clients-region-fold.test.tsfences it down to the CONSTRUCTED client rather than only the reported config. The carve-out matters, and an earlier draft of this sentence dropped it: a region-LESS bag resolves from the SDK's own chain instead, so for that shape the coverage comes from the CLI boundary folding the env vars (issue #2065) rather than fromAwsClients, andcdkd gcdeliberately skips that boundary. That draft also said THREE while naming two, having counted issue #1887, which this change CLOSES rather than finds already fixed. Tests: the pre-existing case pinningAWS::RegionRAW is flipped to assert the fold, with the measurement recorded beside it; both polarities (a mis-cased region folds, an already-canonical one is byte-identical); theFn::Subpath asserted separately from the bareRef, sinceFn::Subsubstitutes rather than returns and a fold covering only theRefwould leave issue #1882's reported symptom broken; and the EC2 filter asserted on the input cdkd SENDS rather than on the AZ names it gets back, which come from the mock and would look correct under either spelling. All mutation-probed against removal of the fold. Three review rounds' findings are folded in rather than deferred. The fold is written over the wholeoverrideRegion || AWS_REGIONexpression, so it has TWO input paths, and every case written with the fix supplied an override -- the resolver handsresolverRegionin explicitly, and that is captured in the constructor and always non-empty. Two reviewers independently measured the consequence: mutating the source to fold only the override left 400 tests green. The env arm is reachable (CustomResourceProvider.resolveSyntheticStackIdpassesundefinedfor a region-less client bag, andcdkd gcdeliberately skipsfoldRegionOption), and reaching it in a test needs a directgetAccountInfo()with no argument, since a region-less RESOLVER still routes through the override. Review also found the fold REGRESSING a guard:Fn::GetStackOutput's self-reference refusal comparesregion-- folded whenever the template names one, and{"Ref": "AWS::Region"}is such a case -- againstresolverRegion, which keeps the caller's spelling because it keysgetState/saveStateand must not move. For a mis-cased resolver region the test became'us-east-1' === 'US-EAST-1', the refusal stopped firing, and acfnFallbackread could land on a same-named CloudFormation stack. Round 2 folded ONE side, which was not enough -- see the round-3 paragraph below. AndresolveSyntheticStackId(src/provisioning/providers/custom-resource-provider.ts) turned out to be the ONLY reader ofaccountInfo.regionin the repo with no localcanonicalizeRegion-- the cloud-control, SSM and AppSync ARN builders all fold at the interpolation -- so theStackIdhanded to a user's Custom Resource handler, which routinely parses it for the region, was the one place the raw value escaped to user code; both its arms (pinned bag, ambient env) now have cases, because a fold written over only the first leaves the second raw. Four source comments in three files that calledaccountInfo.region"folded nowhere" and claimedcdkd deploy --region US-EAST-1SUCCEEDS are corrected in place. A THIRD round then found that the guard fix had reproduced its own bug, mirrored, and the correction had shipped a second false claim -- both worth recording because the pattern is the point.regionreaches that comparison folded when the template names aRegionand RAW when it does not (it defaults toresolverRegion), so folding one operand repaired the first branch and broke the second: a mis-cased resolver region with noRegionargument resolved its OWN stack's output instead of refusing, reproduced live by two independent reviewers who then DISAGREED on the remedy. Tracing the variable settled it --regionis passed on togetSameAccountStackState/getCrossAccountStackState/lookupCfnStackOutputs, where it is a state-key segment, so folding it at the initializer (one reviewer's proposal) would move the key. Both operands now fold AT THE COMPARISON, which is the only place that normalizes without changing what is looked up. A three-way probe proves the pair of cases is complementary rather than redundant: folding the right operand alone reds only the no-Regioncase, folding neither reds only theRegioncase, and folding the left alone reds both. The comment correction, meanwhile, replaced "folded nowhere" with "dies at the first AWS call" -- also false, and contradicted by the JSDoc one commit earlier saying such a deploy SUCCEEDS becauseAwsClientsfolds first. The flag is CANONICAL before it reaches anything (foldRegionOption, issue #2065); it never dies because a raw spelling never gets that far. Round 1's defect class, re-shipped inside the fix for it. One self-inflicted test bug is recorded for the same reason: making the AZ-cache assertion discriminate by priming twomockResolvedValueOnceanswers left the SECOND unconsumed -- precisely because a cache hit means there is no second call -- and it was consumed by an unrelated later test, the leakvp run test:once-leakexists to catch (issue #1618). One primer, consumed by the one call, asserting the positive marker. The standing hazard behind all of it --resolverRegionraw by design while everything derived from it folds, with nothing making that visible at a comparison site -- is issue #2209 rather than a fourth round here, since taking a structural fix late in a cascade is how a fourth round happens. A fourth review round then confirmed the cascade was over -- it enumerated all eight remaining region-shaped comparisons in the resolver and found only one still carrying the guard's shape, pre-existing and informational-only, which is folded into #2209 as a checklist row rather than minted as its own issue. It also found a FOURTH copy of the corrected claim, incloud-control-provider.ts: the round-3 sweep had grepped the exact phrase the three known copies used rather than the SHAPE, so a differently-worded copy survived -- the repo's own "grep for a shape, not a name" lesson landing inside the fix for the same class. And it found the one thing none of the new cases fenced: the DESIGN DECISION the fix rests on. Folding at the initializer instead, with a one-sided comparison, satisfies both self-reference cases and kept the full suite green at 777 files / 16009 tests while silently moving the state key for a mis-cased resolver region fromcdkd/<stack>/US-EAST-1/state.jsonto.../us-east-1/. A non-self-reference case now asserts the producer is looked up under the UNFOLDED spelling -- self-reference throws before the lookup, so the guard's own cases structurally cannot observe which key it would have used -- and it is the single case that reds under that mutation. Scope stated precisely, because the first draft of this entry over-claimed it as "the last unfolded region read": about twenty-five provider sites still readprocess.env['AWS_REGION']raw, but every one of them feeds SDK CLIENT construction (private readonly providerRegion, and the|| process.env['AWS_REGION'] || 'us-east-1'tail of two ARN builders, reachable only when the client resolved no region at all). A raw region there fails LOUDLY at the first call -- that is what the SigV4 measurement above shows -- so it is a different class from the two fixed here, which produce a plausible wrong VALUE and no error. Those provider-built clients are the same population issue #2081 describes from a different angle (they escape agetAwsClientsmock), but #2081 is about test isolation and says nothing about region case, so it is a pointer to the population and not a claim that it tracks this defect. The first cut of the twoFn::GetAZscases was VACUOUS and the probe is what caught it: they wroteprocess.env['AWS_REGION']around an already-constructed resolver, butresolverRegionis captured once in the constructor and then passed togetAccountInfoas an explicit override, which short-circuits the env read -- so 230/230 passed with the fold deleted. Handing the mis-cased region to the constructor is what made them discriminate.cdkd local run-taskno longer echoes a secret's plaintext prefix when a:json-key:reference points at a non-JSON secret (issue #2189) --src/local/ecs-secrets-resolver.ts, plus tests. ThejsonKeybranch interpolated the rawJSON.parsefailure into its own error message, and V8 embeds a prefix of the PARSED INPUT inSyntaxError.message-- the parsed input being the secret itself. Reproduced on the pinned Node 24.15:JSON.parse('supersecretpassword12345')yieldsUnexpected token 's', "supersecre"... is not valid JSON, so the first ten characters reached stderr and any surrounding log capture. A short secret is a second, distinct leak shape the issue did not name: V8 appends...only past its prefix window, soshortpwwas quoted in FULL. Reaching it needs no hostile input -- a plain-string secret plus a:json-key:ValueFromis an ordinary user mistake. The message now carrieserr.name(input-independent) instead oferr.message, and says outright that the parser detail is withheld so a later contributor does not helpfully restore it; the container, env var and requested json-key already make it actionable, which is also the shapesrc/deployment/intrinsic-function-resolver.tshad already settled on for the equivalent{{resolve:secretsmanager:...:SecretString:jsonKey}}path. The sweep the issue asked for is recorded rather than assumed: secret material enterssrc/local/in exactly two files (ecs-secrets-resolver.tsandecr-puller.ts), and a broad grep over every catch-bound.message/.stack/.stderr/String(err)interpolation went 58 sites to 57 -- one site removed, the rest verified false positives with the reason recorded per site. The two SDK-send interpolations in the same file are deliberately untouched: they consume an ARN or a parameter name, no secret value exists on their failure path, and they are load-bearing IAM / network diagnostics.ecr-puller.ts'sdocker loginfailure was checked againstsrc/utils/docker-cmd.tsrather than assumed -- the password goes over--password-stdinandSpawnError.messageis built from child stderr/stdout only.rie-client.tsandvtl-engine.tscarry the same SHAPE over a different population (a Lambda streaming-response prelude and a VTL$util.parseJsonargument -- application payload, not secret material cdkd resolved), so they are deliberately out of this fix's one-root-cause scope; tracked separately. Tests: two cases, each pairing the negative assertion with positives on the context that must survive, since a bare "the secret is absent" is satisfied by any unrelated rejection. Note the negative had to target the ten-character PREFIX rather than the whole secret --not.toContain(fullSecret)passes WITHOUT the fix, because V8 only ever emits the prefix. Both mutation-probed: restoringerr.messagereds both and reverting restores green. A live arm was added too, because no fixture reached the branch --EnvTaskDefreferences the whole secret (no json-key) andMySecret's value IS valid JSON, so both halves of the precondition were missing and any integ run would have been a regression net for a different issue. The arm asserts the POSITIVE markers first (a non-zero exit, and a message naming the container, the env var and the requested key) because "the plaintext is absent" alone is a confluence point any early death satisfies; the premise is proved in its own step, so an edit that makes the secret parseable fails loudly rather than silently disarming the arm. Real AWS, us-east-1: rc=1, no plaintext, destroy 15 deleted / 0 errors, 0 surviving object versions. The first real-AWS run found a bug in the arm itself -- this fixture'scleanup()ends withexit "${rc}", so calling it from the success path terminated the script and the state-version sweep never ran; the log simply stopped after the destroy. The sweep now lives insidecleanup, which also widens it to every exit path. Review of the arm then found the helper was sourced RELATIVELY before thecd, so the repo-root invocation the script's own header documents aborted at that line; it is now sourced by absolute path, asdocdb-neptunealready does for the same reason. Two assertions were strengthened: the container-leftover count is taken BEFORE thedocker rm -f(counting after it cannot fail), and the refusal now has to match the whole contiguous message rather than four loose tokens that occur elsewhere. Follow-up: the sweep lint has nogenerateSecretStringpattern, so four other fixtures seed generated credentials into versioned state unswept (issue #2212); this fixture was in that class until this PR closed it incidentally. A second follow-up, #2225, records a house-wide convention bug the review surfaced: the purge mode is gated on the SCRIPT rc while the destroy inside cleanup is piped throughtailwith|| true, so a run whose assertions passed and whose destroy then failed takes theallbranch and deletes the state.json a latercdkd state destroywould need.cdkd --versionno longer imports the command tree, dropping it from ~1020 ms to ~50 ms (measured 46-52 ms across builds) (issue #2002) --src/version.ts(new),src/cli/index.ts,src/cli/program.ts,src/state/deployment-events-store.ts,.claude/rules/layout-misc.md, plus tests. Issue #2002 was filed as a test flake:tests/unit/cli/version.test.tsspawns the built CLI and was measured at ~3.96 s against vitest's 5 s default timeout, so it failed on the first full-suite run after a build -- the moment acheckmarker is about to be recorded, and the hardest shape to attribute because the testskipIfs itself whendist/is absent. PR #2186 had already raised that one test's budget to 30 s, which removes the red without touching the cost; the issue explicitly asked for the measurement FIRST ("~4 seconds is a long time forcdkd --version... worth knowing before the timeout is simply raised"), and this is that measurement plus the fix it pointed at. Measured on the pinned Node 24.15 with a phase-instrumented build: ~1020 ms total, of which ~48 ms is Node startup and ~3 ms isbuildProgram()-- the remaining ~970 ms is spent before any cdkd statement executes. A CPU profile attributes it to Node's module loader (readFileUtf8336 ms,wrapSafe/compile 296 ms,internalModuleStat183 ms,readPackageJSON112 ms,detectModuleFormat86 ms), not to cdkd doing work at import time:src/cli/program.tsstatically imports all 20 command modules, anddeps.neverBundleinvite.config.tskeeps@aws-sdk/*external, so resolving and compiling that graph is the whole cost and every command pays it.index.tsnow answers a bare version flag from the dependency-freesrc/version.tsBEFOREawait import('./program.js'); the dynamic import is load-bearing, since a static one is hoisted and would evaluate the same graph regardless of what the function decides. The built entry chunk goes from 4.1 MB to 2.5 KB. The predicate is deliberately narrower than "the argv contains a version flag", and the reason is conservatism rather than a measured disagreement: an earlier draft of this entry claimed commander would consume a standalone flag as an option value, and a review measured the opposite -- against commander 12.1.0,cdkd -c --version,cdkd deploy -c --versionandcdkd --profile -Vall print the version and exit 0. The narrow rule is still right for two reasons that survive the correction: it does not DEPEND on that precedence, which is commander's behaviour and can change across a major, and a wider rule would be a second spelling of commander's parse -- the two-spellings-of-one-question failure this repo has hit repeatedly. Every other shape falls through to the unchanged commander parse, verified byte-identical across--version/-V/deploy --version/--version deploy/local --version/state --version/--help; only the first two take the fast path, andcdkd deploy --helpis unchanged at ~1000 ms (the broader lazy-loading of the command tree, which would move that number too, is NOT in this change). Thetypeof __CDKD_VERSION__guard had been spelled three times in two different forms (=== 'string'inprogram.ts,!== 'undefined'indeployment-events-store.ts) with two copies of the'0.0.0-dev'fallback;src/version.tsis now the single spelling and the other two delegate. Tests: 14 unit cases on the predicate and the fallback, an ARTIFACT fence asserting the entry chunk reaches the command tree only through a dynamic import and stays under 64 KB of BYTES (statSync().size, notentry.length-- the chunk carries non-ASCII), and a WIRING fence that is the one actually covering the branch this change adds. The first two do not fence the fix, and a review measured that: deleting theifblock fromindex.tswhile KEEPING the dynamic import left every test green, because the chunk stays small and the predicate's own tests constrain a function nothing is obliged to call -- the shipped behaviour change had no fence at all. The wiring fence copies the built entry plus its static chunk into a tmpdir, replaces the dynamically-imported command-tree chunk with a module that THROWS on evaluation, and asserts--versionstill answers the right version while--helpfails with that throw -- the positive marker plus the negative control that proves the stub was really wired in, since a sandbox that silently resolved the real chunk would otherwise look identical. Three wrong fences were tried and ruled out, and are recorded at the test: an elapsed-time assertion cannot separate ~47 ms from ~1020 ms on a loaded machine; a source regex forisVersionOnlyInvocationfails because rolldown renames the exports and a name match would not prove the branch PRECEDES the import; and importingsrc/cli/index.tsfrom a unit test runsmain()as a side effect. Five mutations probed RED and restored GREEN: the naiveargv.some(isVersionFlag)spelling reds 5 predicate cases, widening the accepted flag set reds the exact-set case, dropping thetypeofguard reds the sentinel case, restoring the static import reds the artifact fence, and deleting the fast-path branch while keeping the dynamic import reds the wiring fence. A second review round then found that fence was INERT IN CI, which is the same defect one layer out:.github/workflows/ci.ymlranvp run testBEFOREvp run build,dist/is gitignored and there is no artifact restore, so the whole file green-SKIPPED on every PR (measured withdist/absent:Test Files 1 skipped (1) / Tests 3 skipped (3)) -- and the wiring fence is the only coverage of the shipped branch. CI now builds first, AND the test asserts loudly rather than skipping whenCDKD_EXPECT_DISTis set, whichci.ymlsets on the build job's test step ALONE, so a future reorder cannot silently re-disarm it. Keying that on a bareCIwas the first attempt and it reddenedonce-leak-detectandruntime-compat, which are separate jobs that legitimately never build --CIsays "a CI job", and what the assertion needs to know is "a build ran"; the comment and the assertion are deliberately redundant, because the comment alone is what failed here. Three smaller findings from the same round: the sandbox now pins{"type":"module"}rather than relying on Node's unflagged ESM syntax detection (under--no-experimental-detect-module, whichruntime-compatexercises, it otherwise fails to load); a claim that rolldown renamesisVersionOnlyInvocationin the entry was wrong (only the chunk's own exports are minified -- the name survives twice, and the argument rests on the branch ORDER instead); and areaddirSync(sandbox)guard labelled anti-vacuity was itself vacuous, sincewriteFileSynchad created the file two statements above, so it is replaced by a note pointing at the stderr check that actually does that job. A final test review then added two things it surfaced: the flag-set-but-unbuilt failure now names the CAUSE (a step-order regression in ci.yml) instead of surfacing a bare ENOENT from inside a test body, and a comment records that the sandbox living intmpdir()-- specifically its ABSENCE of a node_modules -- is load-bearing. That absence is what makes the fence also catch "somebody added a static import to index.ts": measured, adding one left the entry chunk at 2.5 KB, well under the byte bound, and regressed--versionto ~0.4-0.7 s, with only this test going red.- A
--remove-protectionflip is now UNDONE when the delete then fails terminally, so a failed destroy no longer leaves a live DynamoDB table with its deletion guard silently stripped (issue #1978) --src/provisioning/providers/dynamodb-delete-budget.ts,src/provisioning/providers/dynamodb-{table,globaltable}-provider.ts,docs/cli-reference.md, plus tests.cdkd destroy --remove-protectionflippedDeletionProtectionEnabledtofalse, waited for ACTIVE (swallowing wait failures by design), then issuedDeleteTable. When AWS refused that delete terminally the run ended loudly -- and the SIDE EFFECT was silent: the table was still there with the guard the user had opted into now removed, and the error named the delete, not the protection. This is not data loss; it is the removal of the control that prevents data loss, left in place after the operation that justified it did not happen. Each provider'sdelete()is now a thin wrapper around the original body, holding aProtectionFlipRecordand calling one sharedcompensateRemovedDeletionProtectionfrom itscatchbefore re-throwing. Three properties are load-bearing. It never masks the original error: the re-enable sits in its owntry, never throws, and a failure goes tologger.errornaming the physical id and the exactaws dynamodb update-table --deletion-protection-enabledcommand, while theProvisioningErroris re-thrown byte-identical -- deliberately un-annotated, becauseisRetryableTransientErrorSUBSTRING-matches the message and splicing text into it can flip a terminal failure into a retryable one. It compensates only what THIS run changed: aDescribeTableruns BEFORE the flip andflippedOffByThisRunis LATCHED only after AWS accepts theUpdateTableand only when the observation saw the guard ON, so a table whose protection was already off is left alone; if the observing describe fails, the record staysfalse(do not know, so do not touch). Reading the pre-flip value from state was rejected -- state can be stale, and the issue asks for the OBSERVED value. The record SURVIVES a re-entereddelete(), which is the issue's retry-then-fail case and the one a per-call record structurally cannot see:destroy-runner.tsre-invokesdelete()for a retryable failure (up to 4 calls under one per-resource deadline), and attempt 2's pre-flip describe reports the guard already OFF because attempt 1 turned it off -- so a fresh record read "nothing to undo" on exactly the attempt that ends the delete, and a two-call probe measureddeleteCalls=2, reEnableCount=0. The record therefore comes from aProtectionFlipRegistrykeyed with the samedeleteBudgetKey(physicalId, expectedRegion)and the same reuse window as the neighbouring delete allowance -- the identical re-entry the #1955 budget already spans -- and it LATCHES rather than assigns, since a later attempt's observation would otherwise erase the earlier one's; it is released on the success and NotFound paths beside the budget. A provider INSTANCE FIELD was rejected for the budget's own reason (providers are singletons serving a level's tables concurrently), and||=at the assignment alone changes nothing while the record is still per-call. It never re-guards a table AWS is already deleting:deleteAcceptedis recorded the instantDeleteTableresolves, because a throw after that point is a WAIT giving up --waitForTableGone'sTable X did not disappear within Nsmatches no retryable pattern and is therefore TERMINAL by this module's own predicate, so ungated the compensation issued anUpdateTable(true)against aDELETINGtable and logged that it was "LIVE with its deletion protection still off", both false. The Ctrl-C route recorded on the issue is covered by the same mechanism, because the compensation hangs off the METHOD boundary rather than off theDeleteTabletry: any throw out of the post-flip region reaches it, including the interruptible waits go-to-k/cdkd#2053 introduced.isTerminalDeleteFailuretestsisInterruptedWaitErrorFIRST and that clause is not redundant -- the interrupt message embeds user-chosen names, andDependencyViolationis both a legal table-name fragment and aRETRYABLE_ERROR_MESSAGE_PATTERNSentry, so a purely message-based classification reads a Ctrl-C as a throttle and skips the compensation. The terminal-only gate mirrorsdestroy-runner.ts's re-entry condition exactly so the two cannot disagree; three known narrowings are documented in code: a genuinely retryable failure that exhausts the outer loop's attempt cap still ends with the guard off (the provider cannot see which attempt is the last, and compensating on every attempt would mean anUpdateTablepair per retry against a table AWS is already throttling); the per-resource DEADLINE route is unreachable by design, sinceresource-deadline.tsrejects the OUTER promise without CANCELLING what it wraps, so noResourceTimeoutErrorever entersdelete()'s catch -- fixing that means making the deadline cancel, a change every provider shares, so it is recorded rather than attempted here; and the compensatingUpdateTableis deliberately left UNBOUNDED, since it is one control-plane round trip with no polling, the SDK's own timeout config applies to it, and a timeout short enough to be felt during a Ctrl-C teardown would mostly convert successful restores into the "could NOT re-enable" line. Deferring the flip to immediately beforeDeleteTablewas considered and NOT taken: it narrows the window rather than closing it (the ACTIVE wait inside it is itself interruptible, which is the recorded Ctrl-C route), on GlobalTable it would have to move past the replica teardown -- a real destroy-path behaviour change whose AWS semantics are unverifiable without a live run -- and the budget arithmetic indynamodb-delete-budget.tsprices the flip's ACTIVE wait as the first term on the path. Both providers are one root cause with two sites and share one helper rather than two symmetric copies; a single suite runs every case against both viadescribe.each, so a half-applied fix fails visibly. Tests: 10 cases x 2 providers, each asserting WHICHUpdateTablewent out with WHAT input (this path issuesUpdateTablefor the flip-off, the replica teardown and the compensation, so "an UpdateTable happened" is true of every case including the ones that must not compensate), plus one GlobalTable-only case for the accepted-delete gate (the siblingTableprovider has no step after an acceptedDeleteTablethat can throw --deleteTableWithIndexBusyRetry's body ends there -- so that half is fenced type-independently by four directProtectionFlipRegistry/compensateRemovedDeletionProtectioncases instead). The re-entry regression is driven as two realdelete()calls on one provider instance rather than by reaching into the registry, since the re-entry IS the thing under test. Sixteen mutation probes, every one of them executed and its verdict read: replacing the keyed record with a per-call one and replacing the latch with an assignment each red the re-entry case per provider (4); settingobservedProtectionOn = truein the failed-observation catch, and dropping the observation gate outright, each red the failed-observation case per provider (4); dropping thedeleteAcceptedassignment or its gate reds the accepted-delete case (2); moving theflippedOffByThisRunwrite above the flipsendreds the rejected-flip case per provider (2); adding an unconditionalDescribeTableto either delete path reds the per-provider describe-count assertion (2) -- which is what makes the "the pre-flip observing DescribeTable must not appear either" comment an assertion rather than a claim, and it additionally reds the cases whose fixture keys behaviour off the describe INDEX, which is expected coupling rather than a second finding; and dropping the compensating call reds 11 of the 25 when the drop isopts.reEnable()INSIDE the shared helper (10 when it is dropped at the two provider call sites instead -- stated because a bare count is unreproducible without saying where the mutation went). One further gap is known and filed rather than fixed: the flip registry's reuse window is FIXED rather than sliding, so a retry sequence that outlivesDELETE_BUDGET_REUSE_WINDOW_MS-- reachable with a--resource-timeoutpast 30 minutes -- drops the entry and reproduces this issue's residue through the very mechanism added to prevent it (issue #2211). A second, cosmetic residue is filed as #2224: on a region-mismatch RNF race the compensation fires against an already-deleted table and logs at error level that the table is LIVE.resolveDynamoDbDeleteBudgetClock()is threaded intoprotectionFlips.acquirefor that fix's benefit: without the injected clock the window cannot be driven from a test. The one probe that does NOT red is stated rather than hidden: removingflip.deleteAccepted = truefrom theTableprovider leaves the suite green, because nothing on that type can throw after an accepted delete today -- it is carried for symmetry with the sibling and becomes reachable the moment that provider grows a post-delete wait, which its twin already has. The retryable-errors check also caught a live test bug: the first terminal-refusal fixture used anAccessDeniedExceptionreadingnot authorized to perform, which IS aRETRYABLE_ERROR_MESSAGE_PATTERNSentry, so the test was exercising the wrong arm; it was replaced with the issue's ownResourceInUseExceptionexample, verified against the real pattern list. Follow-up: the same uncompensated flip exists on ~10 other providers plus the generic Cloud Control protection patch, tracked separately. - The exports index and the
Fn::ImportValuestate scan now bind only to names a producer actually EXPORTS, so a same-named plain Output in another stack can no longer shadow a real export (issue #2193) --src/types/state.ts(schema v9),src/deployment/deploy-engine.ts,src/state/export-index-store.ts,src/deployment/intrinsic-function-resolver.ts,src/cli/commands/local-state-loader.ts,src/cli/commands/import.ts,src/cli/commands/destroy-runner.ts,docs/state-management.md,docs/cross-stack-references.md,.claude/rules/state-schema.md,tests/integration/schema-v8-to-v9-migration/**(new).state.outputshas always held plain Output names ANDExport.Namealiases in one bag, and nothing in the record said which was which -- so the exports index (updateForStack, fed the whole bag by the deploy engine, andrebuildFromStateBackend) the resolver'sstate.jsonfallback scan (exportName in state.outputs), and thecdkd localcommands'--from-statefallback scan inlocal-state-loader.ts(the same spelling) treated EVERY key as an export. A plainCfnOutput('VpcId')in an unrelated stack was indexed as the producer of exportVpcId,applyStackUpdateoverwrote the real producer's entry with no check and no log (last writer wins), and a consumer'sFn::ImportValue: VpcIdbound to whichever stack deployed most recently: a deterministic silent wrong value, and a value CloudFormation would never hand out, since its export namespace is separate from its output names and it refuses a second producer of one name. The intra-stack version of the collision was already refused by issue #1919'sisExportAliasCollision; the cross-stack version had no guard, and no reader COULD have one, because the record did not carry the information. Schema v9 addsStackState.exportNames-- exactly the alias keysresolveOutputswrote, deduplicated -- and ONE predicate,importableOutputKeys(state), that all four readers now go through. The discriminator is the FIELD rather than the version:undefined(a pre-v9 record, or a v9 partial save carrying a pre-v9 bag forward) keeps the legacy every-key rule so no existing reference breaks on upgrade, and[]means the stack is known to export nothing -- so unlikeimports/outputReadsan empty array is WRITTEN, never omitted. A save that re-resolves outputs (success path, no-change refresh) always writes the set; the five failure-path saves that carryoutputs: currentState.outputsforward spreadexportNamesCarriedFrom(currentState)beside it, and the existing source-scan fence indeploy-engine-cross-stack-read-writers.test.tsnow derives BOTH rules from the file rather than trusting an enumeration. The no-change deploy path persists the set and re-feeds the index with the exports only whenever the EFFECTIVE export set changed while the outputs values did not (animportableOutputKeys(currentState)-vs-resolved-set comparison, kept out of the outputs-value-changed branch) -- this both evicts the plain-name entries a pre-v9 deploy published (a producer whose template never changes would otherwise pollute the index forever) AND handles a self-named export toggled on a v9 record, whereExport.Nameequal to the output key rewrites the same key with the same value so the bag is byte-equal butexportNamesflips (the #2194 review blocker: without it, adding such an export never lands and the consumer's Fn::ImportValue hard-fails; removing it leaves a phantom export served forever). Rebuild keeps the higher-lastModifiedproducer on a name collision so the duplicate-producer warning's "deployed last" wording holds on that arm too. Two stacks both exporting one name keep the index's latest-writer policy but now WARN on update and on rebuild (the warning could not exist before v9: it would have fired on every plain-output collision). The version bump rather than a bare optional field is deliberate: a v8 binary rewriting a v9 record would silently DROP the field and regress the stack to the every-key rule, and the bump makes it fail loudly instead, which is the contract every prior bump relied on.cdkd importover an existing record carries the set with the outputs it already carried. Tests: a v8→v9 unit contract intests/unit/state/schema-v8-to-v9-migration.test.ts; the index store's ingestion + collision-warning cases; the resolver scan's and the local loader scan's decoy-first / plain-name-refused / legacy-still-served cases; the destroy snapshot'sexportNames: []beside its emptied bag; the deploy engine's persisted set, no-change backfill, and failure-path carry (absent stays absent, never[]); six existing collision tests re-pinned to "a refused alias publishes NOTHING to the index". The newschema-v8-to-v9-migrationinteg deploys Producer (exportsX), Decoy (plain output namedX, different value) and Consumer (Fn::ImportValue X) under the last v8 binary and asserts the Consumer received the DECOY's value -- the bug on real AWS -- then re-deploys under v9 and assertsversion: 9, the producer's set, the decoy's[], the transitional warning, and the Consumer rebound to the PRODUCER's value.
Recently Implemented (2026-08-24):
cdkd local run-task: a refused secret name is now always legible in the warning, and the good-name rule is one pattern instead of a clause list (follow-up to issue #2183) --src/utils/docker-cmd.ts,src/local/ecs-task-runner.ts,src/local/ecs-network.ts,src/local/docker-runner.ts,src/cli/commands/local-run-task.ts, plus tests and three integ fixtures. The collision warnings rendered each dropped key with a barejoin(', '), so an EMPTY key rendered as nothing and the message named no secret at all -- the exact "opaque error naming no secret" outcome the empty-key guard was added to prevent; all three warning sites (ecs-task-runner.ts,ecs-network.ts, anddocker-runner.tssince issue #2184) now render keys withJSON.stringify(which also escapes control characters, so a hostile secret NAME can no longer forge a log line break). Only the CONTAINER site is fenced -- reverting it leaves a test red; the SIDECAR warning is unreachable by construction (sidecarEnvcarries four hardcoded literals andSENSITIVE_ENV_KEYSis disjoint from the denylist, which a constants test pins), so nothing asserts it and reverting that half stays green. Stated rather than implied.isMalformedEnvKeyis now literally the complement of a singleWELL_FORMED_ENV_KEY = /^[^=\0]+$/rather than three||-ed bad-shape clauses, so a further bad spelling needs no further clause (the repo's enumerate-bad-shapes-loses-the-race lesson, which the previous JSDoc claimed while the body still enumerated); behaviour is unchanged, verified equivalent across empty /=/ NUL / newline / carriage-return / leading- and trailing-space names. The positive rule makes a NEW failure direction possible that the bad-shape clauses structurally could not have -- OVER-refusal -- so a table of legitimate names (lowercase, dotted, hyphenated, mixed-case, leading-digit) now asserts they are DELIVERED: a tightened rule such as/^[A-Z_][A-Z0-9_]*$/silently dropped every one of them with the whole suite green before that table existed. The sidecar warning deliberately does NOT name the malformed cause:partitionSensitiveEnvonly collides keys in the sensitive set, and the sidecar passesSENSITIVE_ENV_KEYS(three well-formed AWS credential names), so a malformed name is unreachable there. Records thelocal-run-task-from-statereal-AWS run that gated issue #2183's merge in the integ ledger.cdkd local run-taskno longer leaks AWS credentials or resolved Secrets Manager / SSM secret values on thedocker runargv (issue #2183) --src/local/ecs-task-runner.ts,src/local/ecs-network.ts,src/utils/docker-cmd.ts, plus tests. The ECS local-run path wrote every sensitive env var as-e KEY=value, exposing it in/proc/<pid>/cmdlineto any local process; the LambdarunDetachedpath already avoided this via docker's value-less-e KEYform (value read from the spawn env). Sensitive keys -- the AWS credential set plus each resolved secret NAME -- are now emitted value-less and the value travels through the spawnenv.--env-filewas rejected because docker truncates a multi-line value (PEM keys) at the first newline while the env channel preserves it.dockerSpawnEnvWithSensitivekeeps the docker client's own critical vars authoritative (matched CASE-INSENSITIVELY for Windows), so a user-controlled secret NAME cannot hijack the client: the exact denylist covers connection / TLS / proxy vars,PATH/HOME/USERPROFILE, the trust vars (SSL_CERT_FILE/SSL_CERT_DIR/GODEBUG), the non-prefixed loader vars (GLIBC_TUNABLES/GCONV_PATH/BASH_ENV), the closed ssh exec/trust set enumerated EXACTLY (SSH_AUTH_SOCK/SSH_ASKPASS/SSH_ASKPASS_REQUIRE/SSH_SK_HELPER/SSH_SK_PROVIDER/SSH_PKCS11_HELPER/SSH_AGENT_PID— anSSH_PREFIX was the #2186 round-3 blocker, since it dropped realistic secrets like GitLab CI'sSSH_PRIVATE_KEY), and the AWS credential-helper family the client'sdocker-credential-ecr-loginreads with the operator's real credentials in scope (AWS_ENDPOINT_URL/AWS_CA_BUNDLE/AWS_PROFILE/AWS_CONFIG_FILE/AWS_SHARED_CREDENTIALS_FILE/AWS_WEB_IDENTITY_TOKEN_FILE/AWS_CONTAINER_CREDENTIALS_FULL_URI/AWS_ROLE_ARN/AWS_EC2_METADATA_SERVICE_ENDPOINT); theLD_*/DYLD_*dynamic-loader families and the per-serviceAWS_ENDPOINT_URL_*endpoint family (aws-sdk-go-v2 honours it, soAWS_ENDPOINT_URL_ECRwould walk around the exact entry — #2186 round 4) are additionally caught by PREFIX inisDockerClientEnvKey, fail-closed on the whole prefix. A sensitive key of a MALFORMED shape (empty, or containing=/ NUL —isMalformedEnvKey, defined POSITIVELY as the complement of a good env name rather than by enumerating bad spellings) is refused fail-closed on the same collision path (#2186 rounds 4-5): the denylist matches the whole key while the OS parses the environ NAME as everything before the first=, so a secret namedPATH=/tmp/evil:would otherwise poison the docker client's ownPATH(the poisoned duplicate wins — measured) and hand code execution to whatever the client execs off it, and an empty key would emit an opaque-e '';dockerSpawnEnvWithSensitivecarries a belt-and-braces copy of the guard, which the follow-up #2187 is set to adopt forrunDetached(issue #2184 — that path spread its passthrough env directly until issue #2184 closed it). Case-insensitive Windows env handling (a case-differing host alias of a passthrough key; two sensitive keys differing only by case) is deliberately NOT done here — it needs the Windows-critical vars on the denylist first and a real Windows execution path (CI is ubuntu-only), tracked in issue #2190. The argv split lives in the sharedpartitionSensitiveEnv, which -- per the #2186 review blocker -- drops a colliding key ENTIRELY (no-eflag at all): emitting a value-less-e DOCKER_HOSTfor a key the spawn env refuses to set would make docker resolve it against the client's own env and hand the container the HOST's value (the host'sHTTPS_PROXYcredential, or a macOSPATHinside a Linux image). The dropped key is reported incollisionsand WARNED. Tests: per-container secret isolation (two containers, two secrets, each carries only its own -- a cross-wired lookup was previously invisible), the collision-drops-entirely path against a container secret namedDOCKER_HOST, a table-driven membership+behaviour fence over all 47DOCKER_CLIENT_ENV_KEYSspelled as LITERALS (a set-driven table cannot detect a deletion) plus the same literal fence overDOCKER_CLIENT_ENV_PREFIXES(exported for exactly this — a hardcoded copy made the anti-shadowing fence one-directional), the collisionlogger.warn, and confluence-point controls; all mutation-probed. Follow-ups: the siblingrunDetachedhijack (issue #2184) and the podman / containerd connection vars (issue #2188).cdkd local invoke(runDetached) closes the same docker-client hijack as go-to-k/cdkd#2183, for the Lambda / AgentCore path (issue #2184) --src/local/docker-runner.ts, plus tests.runDetachedalready kept secret VALUES off the argv (value-less-e KEY+ spawn env), but a template-controlled SecureString NAME (--from-cfn-stack) colliding with a docker-client var (DOCKER_HOSTetc.) still emitted a value-less-e KEYthat docker resolved against the client's OWN env, handing the container the host's value. It now partitions through the sharedpartitionSensitiveEnv(from go-to-k/cdkd#2183), which drops a colliding key ENTIRELY and reports it incollisionsfor the warn. Tests: asensitiveEnvKeys-extended run with aDOCKER_HOSTsecret asserts no-eflag for it, the client's ownDOCKER_HOSTstays in the spawn env, a non-colliding secret still reaches the container, and the drop is WARNED; mutation-probed.
Recently Implemented (2026-08-23):
- The provider-side secret walk is now ONE shared function, the composite-id refusal masks the value it quotes, and the cross-provider audit issue #2176 asked for is recorded (issue #2176) --
src/provisioning/masked-retry-logger.ts,src/provisioning/composite-id.ts,src/provisioning/providers/{ssm-parameter,dynamodb-table,sns-topic,cognito,elbv2}-provider.ts,docs/provider-development.md,.claude/rules/providers.md. cdkd resolves{{resolve:secretsmanager:...}}and{{resolve:ssm:...}}(including an SSMSecureString, which is classified secret by parameter TYPE rather than by spelling) BEFORE calling a provider, so a property value is PLAINTEXT by the time a provider interpolates it into a message. The literalssm-secure:spelling is deliberately NOT resolved by cdkd and is out of scope -- verified live during this work, where anssm-securereference loggedUnsupported dynamic reference serviceand reached the provider unresolved, matchingdocs/scenario-coverage.md.DeployEnginemasks its three sinks -- the error line, the durabledeployments/{runId}.jsonlevent viamaskSecretsInEvent, and the re-thrown cause viamaskSecretsInError-- but only at the MESSAGE level, and that has exactly two holes, both measured on this tree rather than argued:JSON.stringifyescapes"/\/ newlines, so{"user":"admin","pw":"hunter2"}interpolated as${JSON.stringify(value)}came through a message-level mask COMPLETELY unchanged; and a finished message is longer than the value inside it, so it reaches onlymaskSecretsInText's SUBSTRING arm and a 3-character secret survivedValue 'abc' at 'pin' failed ...intact. Masking the leaves first closes both. The walk that does that had been copied SIX times (elbv2,cognito,sns-topic,dynamodb-table,dynamodb-globaltable,apigatewayv2) --elbv2-provider.tscarried a standing note that "a THIRD site is the point at which this should move into../masked-retry-logger.ts", which had fired and been missed -- and FOUR of the six had already DIVERGED by losing the depth cap. All six now delegate to one exportedmaskDeep, whose cap SUBSTITUTES a mask marker for a subtree it declines to descend into rather than returning it raw -- returning it raw is silent disclosure, substituting is silent truncation. Two of the six were nearly missed a second time inside this very fix, because the sweep grepped for the known copies' spellings (maskDeep,MASK_WALK_MAX_DEPTH) rather than the walk's SHAPE; the survivors are spelledmaskLeaf/maskLeafValueand declare no named constant. Three independent reviewers caught that, which is the same failure mode #2176 names ("a per-provider fix that misses siblings just moves the hole") landing inside the fix for it.compositeIdSeparatorRefusal/packCompositeIdquote the offending segment VALUE back, on a THROWN arm reaching the durable store and anonRefusalarm reaching the terminal; they now take a masker and mask that value RAW (reaching the whole-value arm, which has no length floor) -- fixed once rather than per caller, and THREADED at all 15 deploy-path call sites across seven providers (glue,route53,s3-tables,appsync,ec2,apigateway,lambda-event-invoke-config). The remaining 7 of the 22 sites areimport()paths, which have noCreateContextto thread and no secret bag to build one from; they keep the back-compatible identity default. Two of the 15 (ec2-provider.ts'screateRoute/createSecurityGroupIngress) were threaded a review round later than their siblings and were briefly mis-described as import paths -- their helpers take a CALLBACK rather than a context, so the thread stopped one layer short of the dispatcher that holds one, andcreateSecurityGroupIngress'sipProtocolis the one segment the file documents as accepting an arbitrary template string. Their UPDATE twins are threaded too, via a newUpdateContextonEC2Provider.update: those arms delete-and-RECREATE, so the masker has to reach the re-create that packs the id, and.claude/rules/providers.mdnotes the update arm is the one a multi-destination bag actually reaches -- closing only the create half would have left the reachable half open.ssm-parameter-provider.tsis threaded as the reference for the one-masked-sink shape, chosen because it is the provider whoseValueproperty IS the secret (that property reaches no message site, and neither doessecretsmanager-secret-provider.ts'sSecretString; both were checked specifically).dynamodb-table-provider.tshad a singlewarncall masking one argument and leaving the one beside it raw -- the drift a per-site rule produces, which is why the docs now prescribe a sink. Stated precisely because the first draft over-claimed it: that particular argument is a no-op BY CONSTRUCTION (coerceWarmThroughputadmits onlytoFiniteNumberresults, so the walk can never change it), and it is masked for consistency and future-proofing rather than to close a leak. The audit itself is the part the issue called the actual work: everycreate()/update()message site across all ~80 providers was traced back to thepropertiesbag, giving ~256 sites in the provider's ownthis.logger.*(which NO engine sink covers), ~50 thrown sites (message-level covered, residual only) and ~244 AWS-echo sites; the per-site remainder is issue #2177 with its counts and the honest risk note that almost all of them are resource names rather than values users put a dynamic reference in. A mechanical fence was drafted and CALIBRATED against the tree, which is what showed the naive syntactic rule false-positives on correct code (asg-provider.tsandcloudfront-distribution-provider.tsmask each element UPSTREAM of theJSON.stringifycall); it needs the dataflow-aware compiler-API shape the two existing critics use, tracked as issue #2178. Also corrected: #2176's premise named ACM as persisting a resolved secret todeployments/*.jsonl, and ACM'screate()interpolates no property value at all --perResourceSecretsis populated BEFOREresolve()and before the provider call, so that event IS message-level masked and the residual there is the sub-4-character case only. Tests: 19 new cases acrosstests/unit/provisioning/masked-retry-logger-mask-deep.test.ts,tests/unit/provisioning/composite-id-secret-masking.test.tsandtests/unit/provisioning/ssm-parameter-provider-masked-warn.test.ts, each asserting the POSITIVE mask marker rather than only the plaintext's absence (which any failure also satisfies), with a negative control that a non-secret value is left alone; both mutation-probed -- removing the composite-id mask reds 4 of 6, disablingmaskDeep's leaf masking reds 18 cases across the converged providers' own existing suites, reverting the depth cap to failing open reds 2, and reinstating the exactupdate()sink bypass a reviewer found reds the SSM fence written for it. - An ACM certificate whose
ISSUEDwait fails is now DELETED instead of orphaned, so repeated attempts stop accumulating certificates (issue #2169) --src/provisioning/providers/acm-certificate-provider.ts,src/provisioning/providers/idempotency-token.ts,tests/integration/acm-certificate/verify.sh,docs/{troubleshooting,cli-reference,provider-development}.md,.claude/rules/providers.md.RequestCertificatematerializes the certificate immediately andcreate()then polls forISSUED(60 x 10s), a wait a DNS-validated certificate whose validation records are not live yet exhausts by construction. Both throw paths threw a plainErrornaming the ARN only in the message TEXT, andcreate()'s catch passedundefinedforphysicalId, so the certificate stayed in AWS with nothing naming it: absent from state, invisible tocdkd state show, unreachable bycdkd destroy, and re-requested from scratch by the nextcdkd deploy-- one more orphan per attempt.create()now retires the certificate it requested in its own catch and re-throws the ORIGINAL error, with the ARN on it asphysicalId(the reporter's own ask, and a pathcreate()'s wrap used to drop for a non-CdkdErrorfailure after the request). A cleanup that FAILS appends a line naming the survivor and the exactaws acm delete-certificatecommand rather than replacing the diagnosis. The replacement path is covered by the same change, sinceupdate()re-creates through thiscreate(): a replacement whose wait fails now aborts with the OLD certificate still live, still in state, and nothing orphaned. Deleting rather than KEEPING the remnant is the load-bearing decision, and it was the reverse of this fix's first shape. Recording the certificate in state (the shape the issue's own wording suggests) does not work: the recorded properties ARE the template's, so the next deploy diffs the resourceNO_CHANGE,cdkd deployprints "No changes detected" and exits 0 over a certificate that is still unusable, and the CloudFront / ALB consuming it fails with no explanation -- a loud failure turned silent, which is worse than the orphan. Three independent review agents caught that before it shipped. Deleting is safe for ACM specifically, from AWS's own docs rather than by inference: the DNS validation CNAME is derived from the domain and the account, not from the certificate, and you can "replace a deleted certificate" without repeating validation -- so the CNAMEs a user adds after the failure validate the retry's certificate. A user who WANTS the certificate to outlive a failed deploy already hasCDKD_NO_WAIT=true, which returns success with it recorded in state. Separately,RequestCertificatenow sends a retry-stableIdempotencyToken(#2039's helper, which gained acharset: 'alphanumeric'option because ACM documentsPattern: \w+and a 32-char max that the defaultcdkd-<hex>spelling violates), closing the in-process window where a transient 5xx whose request had landed madewithRetrymint a second certificate; the token is RELEASED when the cleanup deleted the certificate (ACM would otherwise answer the retry with the deleted ARN) and KEPT when the cleanup failed (there the survivor is what a retry should get). One latent bug had to be fixed for the delete to be safe, and it is the kind that only becomes critical in combination:waitForCertificateIssuedlatchedvalidationOptionsLoggedon having CALLED the printer, butlogValidationOptionsemits a record line only for a domain that already has aResourceRecord. The firstDescribeCertificatefires seconds afterRequestCertificate, when ACM commonly has not filled that in yet -- so the header printed with nothing under it, the latch closed, and the CNAMEs were never shown on any later poll. That was survivable while the certificate outlived the failed deploy (the user read the records from the ACM console); with the certificate now deleted, the poll output is the ONLY copy of them and losing it is a dead end. The printer now reports whether it emitted anything and the latch follows that. Related message fix: the timeout error used to advise raising--resource-timeout, which cannot make this wait longer -- it is the engine's deadline wrapped around the provider and can only cut the 10-minute poll cap short. It now namesCDKD_ACM_POLL_ATTEMPTS/CDKD_ACM_POLL_INTERVAL_MS, anddocs/troubleshooting.mdsays so too. Tests: 23 new cases intests/unit/provisioning/acm-certificate-provider.test.tsand 2 intests/unit/provisioning/idempotency-token.test.ts, each mutation-probed (17 mutations across three review rounds; the round-2 pass found the latch fix above shipping unfenced and aphysicalIdassertion that passed with the fix reverted); plus a new phase 0 in the ACM integ that forces a real timeout in seconds viaCDKD_ACM_POLL_ATTEMPTS/CDKD_ACM_POLL_INTERVAL_MSand asserts the ACCOUNT holds zero fixture certificates after one failed deploy and still zero after two -- counting the account rather than reading state on purpose, since state is exactly what the pre-fix code failed to write. - The stack lock is now RENEWED while an operation runs, and released conditionally, so an operation outliving its TTL no longer loses mutual exclusion (issue #2168) --
src/state/lock-manager.ts,docs/state-management.md,docs/troubleshooting.md,tests/unit/state/lock-renewal.test.ts(new),tests/integration/stack-lock-renewal/**(new). The lock's TTL was stamped once at acquisition and never updated --grepfound no heartbeat on any path -- so the 30-minute default measured how long the OPERATION had been running rather than how long its owner had been silent. Any operation slower than that silently stopped being mutually exclusive while it was still running, which needs nothing exotic to reach:AWS::FSx::FileSystem,AWS::EMR::Clusterand Custom Resources each wait up to an hour on their own, and a large enough stack exceeds 30 minutes in aggregate regardless of resource type. Worse, the damage cascaded:acquireLocktreats an expired foreign lock as free and reaps it, so a second process took over while the first was still writing; the first then finished and, becausereleaseLockwas an owner-blind unconditionalDeleteObject, deleted the SECOND process's lock, letting a third in. The holder now re-writesexpiresAtat most every 2 minutes (or every quarter of the TTL, whichever is shorter) via a conditionalPutObjectcarryingIfMatchwith the ETag it last wrote, so the default TTL tolerates fourteen consecutive missed renewals while a process that has already LOST the lock cannot resurrect its own expiry on top of the new owner's; the heartbeat isunrefed, so it can never hold acdkdprocess open.releaseLockcarries the sameIfMatch, and aPreconditionFailedthere means the lock present is somebody else's -- cdkd leaves it and warns rather than raising, since the operation has already finished and the caller has nothing to do about it. Any other failure falls back to the unconditional delete, so a bucket policy denying thes3:GetObjectan ETag-conditional delete requires cannot strand a lock;cdkd force-unlockstays unconditional by contract, since it exists to remove a lock the caller does not own. The expired-lock takeover inacquireLockis now conditional too, on the exact bytes it judged expired -- with renewal live, an unconditional delete there would discard a lock whose renewal merely lost a race with the read -- and it logs atwarnnaming the previous owner, because a lock that reaches its deadline now genuinely means an absent owner and, on the remaining chance it does not, two writers are about to share a stack. All 20 added guards are individually mutation-probed; three came back GREEN on the first pass and each was a real gap rather than a missing assertion (two guards whose effect was visible only on the TIMER, and one probe that was itself a no-op becauseforceReleaseLockhad already dropped the map entry the mutation read). Independent review then found that the first cut of the conditional release re-opened the hole it closes: it dropped the condition for any failure that was not 412/404, and S3 answers a concurrent operation on the key with409while a503may mean the conditional delete already SUCCEEDED with the response lost -- so an unconditional retry there deletes whichever lock exists by then, which under load is the NEW owner's. The fallback is now confined to the one class that says nothing about ownership: the endpoint or the policy will not EVALUATE the condition (403, since an ETag-conditional delete additionally needss3:GetObject;501from an S3-compatible endpoint that has not implemented the header). Everything else raises, as release always has. Five further defects of the same family came out of that round:isForeignLockErrormatched only the error NAME, so a bareS3ServiceExceptioncarrying 412 skipped "leave it alone" and reached the destructive fallback;isGoneErroracceptedNoSuchBucket-- also a 404 -- as "the lock is gone", so one bucket-level error would kill a heartbeat and then refuse to release a lock still held; a SECONDreleaseLockfor the same key was owner-blind (reachable, because the force-quit paths fire an un-awaited release while the mainfinallymay be mid-release), and the entry is now tombstoned rather than dropped; a renewal returning no ETag CLEARED the cached one, silently making the next release unconditional -- the opposite of what its own comment claimed -- and now re-reads to recover the handle and otherwise keeps the last known-good value; andisLockExpiredtrusted anexpiresAtthat arrives from the bucket unvalidated, soInfinity/NaN/ a string pinned the stack permanently (a non-finite deadline now counts as expired, which grants no new power and is the recoverable direction). A renewal 412 is also no longer taken at face value -- a conditional PUT S3 applied but whose response was lost leaves the cached ETag one version behind, so cdkd reads once and adopts the object when it is byte-for-byte its own write, rather than declaring a lock it still owns lost. Across both rounds all 45 added guards are individually mutation-probed; the second round's own probes caught four clauses of one conjunction that a single "somebody else" fixture left unfenced -- this repo's own recorded failure of a fixture that trips every clause at once fencing none.
A third round on that fix delta found one more regression of its own making and seven smaller ones. Narrowing the fallback meant releaseLock now RAISES where it used to silently succeed -- and destroy-runner's main finally was the one call site of thirteen that did not wrap it, so a throttled release would have replaced a real destroy error and, on a successful destroy, aborted a cdkd destroy --all run at the first stack over a lock that lapses on its own. It now warns like its three siblings and deploy-engine.ts, and the pre-existing test that pinned the old propagate-the-throw contract was moved to assert what it was written to fence (the SIGINT handler not leaking) rather than a throw that no longer happens. The 403 branch also re-checks ownership by hand before dropping the condition, because S3 authorizes BEFORE it evaluates a precondition: a policy scoping s3:GetObject away from lock.json turns a genuine 412 into a 403, so the escape hatch would have deleted the lock of whoever took over -- it now skips the read while its own deadline is still ahead and refuses only on a lock it can read AND positively attribute to somebody else. The remaining six: the expired-lock takeover delete had no such escape hatch at all, so the very policy the predicate exists for made an expired lock unreclaimable except via force-unlock; the 412-status backstop had been applied to one of three call sites; the release tombstone was claimed AFTER two awaits (so it did not close the force-quit race it was added for) and BEFORE the delete (so a FAILED release recorded itself as done), and is now an in-flight promise claimed synchronously, which gives a second caller the first one's actual outcome; the keep-stale-ETag path blamed "another cdkd process" for a conflict with this process's OWN write; adoptOwnWrite compared a displaySafe-sanitized owner against a raw one, so a $USER or hostname carrying a stripped codepoint would make adoption permanently impossible and the process would disown its own lock; and warnedPastExpiry never re-armed, so a second expiry episode was silent. Test review then found that isConditionUnsupportedError's five clauses were all unfenced for the same reason adoptOwnWrite's four had been -- every fixture tripped several clauses at once -- on the one predicate that authorizes dropping the ownership condition; each clause now has a fixture that trips only it. That failure mode recurred three times in this work, which is why the disjoint-fixture helper is now named and commented as the rule rather than the exception. All 63 guards across the three rounds are individually mutation-probed.
A fourth round on that delta found four more, three of them in the guard the third round had just added. The ownership re-check skipped its read while the process's own deadline was still ahead -- unsound, because forceReleaseLock deletes REGARDLESS of expiry (that is its contract), so a user running cdkd force-unlock mid-operation is a legitimate takeover no deadline can rule out, and cross-machine clock skew reaches the same state with nobody running anything. It also answered "proceed" when the read FAILED, which left it inert in its own primary case: the documented trigger for the 403 is a policy granting s3:DeleteObject without the s3:GetObject a conditional delete needs, under which the check's own read fails too. And the escape hatch the third round added to the expired-lock TAKEOVER was removed again: the IfMatch there is what makes concurrent reaping safe -- first wins, second gets a 412 and reports contention -- so dropping it lets BOTH reapers delete-then-acquire and leaves two holders. Separately, releaseLock had dropped async when it gained its synchronous claim, which let a throw out on the CALLER's stack; ten call sites attach only a .catch(), two of them void releaseLock(...).catch(...) inside a SIGINT force-quit handler where a synchronous throw is an uncaughtException that kills the handler before its recovery banner prints -- and stdout is a measured synchronous EPIPE source in this repo under --verbose | head. An async body runs synchronously to its first await, so restoring async keeps the claim synchronous while turning every throw into a rejection. Test review then found that the two tests rewritten in round 3 had been GUTTED by round 3's own fix: with the release caught, nothing in that inner try could throw, so the finally holding the SIGINT-handler removal became indistinguishable from code inlined at the end of the try -- a mutation doing exactly that passed all 2827 tests/unit/cli tests. It is now fenced through the EPIPE path, which is the only live one left.
The new stack-lock-renewal integ has two arms because neither substitutes for the other: a probe drives cdkd's own LockManager against the real VERSIONED state bucket, which is what settles the S3-side premises the unit suite has to mock away (a PutObject ETag being accepted verbatim as an If-Match, and If-Match on DeleteObject being evaluated against the current version of a versioned object), reproduces the cascade itself, and settles empirically what S3 answers for a conditional delete against an object that is already gone (NoSuchKey / 404, not 412 -- the SDK model documents that outcome for IfMatchLastModifiedTime / IfMatchSize but not for plain IfMatch); and a real cdkd deploy whose custom resource sleeps past the renewal cap, with the lock polled throughout, settles the CLI-side premises no probe can -- that the CLI renews during a deploy, and that the process still EXITS afterwards.
Recently Implemented (2026-08-22):
The lock-contention refusal now names WHO holds the lock, and its recovery command resolves to the lock it is actually about (issues #2170 / #2171) --
src/state/lock-contention-message.ts(new),src/cli/commands/destroy-runner.ts,src/cli/commands/import.ts,src/cli/commands/orphan.ts,src/cli/commands/state.ts,src/cli/commands/drift.ts,src/cli/commands/export.ts,src/deployment/deploy-engine.ts,docs/troubleshooting.md. Follow-up to #2161, which made nine sites refuse on a held lock but left the refusal itself thin. Three things were wrong with it and only the first is cosmetic. The message asked the user to decide "is another process active?" while printing no owner, no operation and no expiry -- and the population that reaches it makes that consequential, becauseacquireLockreaps an EXPIRED foreign lock and retries, so every message a user sees describes a LIVE lock, in practice a runningcdkd deploy; the likeliest response,force-unlockthen re-run, reproduces #2161's exact harm by hand. The recovery command carried only--stack-region, butcdkd force-unlockre-resolves the state bucket from the AMBIENT profile, so aftercdkd destroy --profile prodthe suggested command resolved the DEFAULT profile's account and would force-delete a same-named stack's lock in the wrong AWS account entirely. And the nine sites had drifted to three spellings of the same sentence, so a user grepping CI logs for one found two of three. All nine now build their text in one place, which is also what makes the holder lookup a single best-effortgetLockInforather than nine of them; a failed or absent read degrades to the previous evidence-free wording rather than replacing the contention with an S3 error. #2171 closes three pre-existing siblings of the ignored-lock family that a security-lens review of #2165 turned up:destroydeleted a 0-resource stack's state record with NO lock at all -- a record reads as empty for exactly one interval that is not idle, the start of a concurrent deploy -- and now takes the lock and RE-READS, since emptiness has to be re-established under the lock rather than inherited from the caller's snapshot;cdkd state orphanforce-releases whatever lock is present, which stays deliberate (a stuck lock must never make a state record unremovable) but now WARNS naming the owner when the one it destroys is still live; anddeploy-engine.tsstill had the EPIPE lock-strand that #2161 fixed indestroy-runner.ts, whererenderer.start()sat after the acquire and outside the releasingtry, socdkd deploy | headstranded the lock for its full TTL. Thedrift --revertwindow between its acquire and itstryis closed the same way. Every guard is fenced by a mutation-probed test, and the #1348 handler-ordering pin was widened from FILE to SITE -- it caught this change's own first cut, which added a lock acquisition with no SIGINT handler, and a file-levelindexOfwould have gone quiet again as soon as the two sites happened to be ordered coincidentally.cdkd destroy/import/orphan/drift --accept/drift --revert/state refresh-observednow FAIL FAST when another process holds the stack lock, instead of running under it and then deleting that process's lock (issue #2161) --src/cli/commands/destroy-runner.ts,src/cli/commands/import.ts,src/cli/commands/orphan.ts,src/cli/commands/state.ts,src/cli/commands/drift.ts,src/cli/commands/export.ts,docs/troubleshooting.md.LockManager.acquireLockreturnsPromise<boolean>and returnsfalse-- it does NOT throw -- when a live, non-expired lock is already held by another owner. Seven call sitesawaited it and DISCARDED the boolean, so the surroundingtry/catchnever fired and each command proceeded as though it had acquired. BecausereleaseLockis an owner-blind unconditionalDeleteObject, the command then deleted the OTHER process's lock on the way out. The worst case wasdestroy: with a concurrentcdkd deployholding the lock, it deleted the live AWS resources, deletedstate.json, and deleted the deploy's lock -- reproduced against real AWS in the issue.cdkd deploywas never affected (it usesacquireLockWithRetry, which checks the boolean and throws), and top-levelcdkd exportwas already correct. Each site now checks the boolean and throws before anylockHeldflag, lock-releasingtry, state write, or provider call, so the foreign lock is left untouched; the message namescdkd force-unlock <stack> --stack-region <region>, the region-qualified form, because a first-time import and a<parent>~<Child>nested child have no state record forforce-unlockto infer the region from. Two consequences worth stating:cdkd import --dry-runnow aborts on contention (import deliberately takes the lock even in dry-run so its plan cannot lie about live AWS, unlikeorphan --dry-runwhich skips acquisition), anddestroy --allstops at the first contended stack rather than continuing. Separately, the fix maderunDestroyForStackthrow EARLIER than thetry/finallythat restored the process-globalAWS_REGION/ AWS clients for a cross-region stack, which exposed a family of pre-existing leaks at every exit before thatfinally; all of them now route through one idempotentrestoreBaseRegionAndClients()helper (cross-region setup failure, lock-acquisition failure, strong-ref refusal, a rejectingreleaseLock, a throwingdestroy(), and a throwingrenderer.start()), each fenced by a mutation-probed test in the newtests/unit/cli/destroy-runner-lock-contention.test.ts. Contributed by @nix-tkobayashi.
Recently Implemented (2026-08-21):
cdkd scrubno longer refuses two classes of secret reference it can now handle: an ASSEMBLED one, and a producer that declares an expression in anFn::Ifbranch the deployment did not take (issues #2157 and #2150) --src/cli/commands/scrub.ts. Both refusals were correct when they shipped and both had become over-refusals; neither was ever silent, but both were UNBYPASSABLE (exit 2, no flag), so a single affected leaf made a whole stack unscrubbable while itsstate.jsonstill held plaintext. #2157 -- the assembled-reference guard now DEFERS instead of refusing.resolveForeignRegionTokens's pre-pass detects a leaf that OPENS more secret references than the shared scan found complete tokens of, or a whole token still carrying anFn::Subplaceholder; that detection is unchanged (it is now the named predicateisAssembledSecretReference, carrying the same four measured rows), but a hit returns the leaf BY IDENTITY so the PRIMARY resolver classifies the reference onceresolveSub/resolveJoinhave assembled it. Issue #2134 is what made that safe: the region question is now asked insideresolveDynamicReferences, over the COMPLETE expression, so the property the refusal bought -- a reference whose region cannot be established is never resolved in the stack's own region -- is bought downstream over strictly more information. Three of the four previously-refusing shapes still refuse, now with the resolver's ownDYNAMIC_REFERENCE_REGION_AMBIGUOUSand naming the ASSEMBLED secret (prod-db, which the pre-pass could never see); the fourth, an ARN form, is routed to the region the ARN NAMES. The unlocked shape is the one that cost something:{"Fn::Sub": ["{{resolve:secretsmanager:${SecretArn}:SecretString:password}}", {"SecretArn": "<foreign ARN>"}]}refused onmainand now scrubs, fetching from the producer's endpoint and never the consumer's. Deferring costs one property the refusal had, and it is FILED rather than fixed here (issue #2166). The refusal fired BEFORE any lookup; deferring moves the lookup insidescrubStack's best-effortcatch { logger.debug }, so a producer region answering AccessDenied -- or anFn::Subplaceholder scrub cannot evaluate, whichresolveSubwarn-and-KEEPS without throwing at all -- becomes a verbose-only line under aNo plaintext secrets foundsummary. The wrong-REGION property is genuinely preserved, and over more information; LOUDNESS on a failed lookup is not, and the complete-token spelling keeps its loudSCRUB_CROSS_REGION_SECRET_UNRESOLVED. Three rounds of review were spent trying to close it inside this change and each attempt was withdrawn, which is why the issue carries the measurements rather than the code: every attempt detected "this reference went unresolved" from OUTSIDE the resolver with a proxy, and every proxy was wrong in BOTH directions. Keying on "the resolution threw" missed the warn-and-keep shape and over-reported an unrelatedReffailure sharing the bag. Keying on "the raw leaf text survived" missed a leaf a downstream intrinsic rewrote without resolving (measured: anssm-secure:reference assembled from a resolvableFn::Subplaceholder reports zero findings while loggingUnsupported dynamic reference service), broke on JSON escaping, and fired PERMANENTLY on prose that merely mentions{{resolve:secretsmanager:-- the unactionable-refusal classSECRET_REFERENCE_OPENINGS' own doc records as unacceptable, re-opened one level out. Only the resolver knows which references it declined, so the fix belongs there and gets its own PR. Shipping without it opens no new silent class:classifyReplaySecretRegionverdicts an ARN-form tokennamed-regionregardless of evidence, so a stack with no cross-stack read on record already reached the same outcome for the same leaf. The deferral is unconditional on evidence where the refusal was gated on a foreign producer region being on record, which also removes an inconsistency rather than adding one:classifyReplaySecretRegionverdicts an ARN-form tokennamed-regionwhatever evidence it holds, so the identical leaf already resolved silently in a stack with no cross-stack read recorded -- one shape now has one behaviour. TheSCRUB_SECRET_REFERENCE_UNCLASSIFIABLEcode is retired with its only throw site,foreignProducerRegionsleavesCrossRegionSecretContext(its last reader was that gate), and theleafPathargument leavesresolveForeignRegionTokens(its only consumer was that message). #2150 --producerPublishesSecretExpression's literal scan now makes the sameFn::Ifselection its hop walk does. The scan wasJSON.stringify(subject).includes('{{resolve:'), which sees BOTH arms, so a producer whose output is{"Fn::If": ["IsProd", "{{resolve:...}}", "plain"]}deployed with the condition false verdicteddeclared; the discriminator then read the stored valueplain, found no expression, and raisedSCRUB_CROSS_STACK_PRODUCER_PLAINTEXT-- unclearable, because nocdkd scrubof that producer can turnplaininto an expression. Issue #2146 had fixed the sibling instance incollectReExportHopsand deliberately left this one, so the two halves of one question disagreed. They now share ONEselectTakenConditionalBranches, which rebuilds ontoObject.create(null)for the__proto__hazard the three sibling walks in this file already answer that way (a JSON-parsed template carries__proto__as an OWN key, and assigning it onto an object literal walks the prototype setter, dropping the key and any{{resolve:beneath it -- fail-OPEN). Applied ONCE per subject and passed to both: FALSE branch of a well-formed 3-tuple (mirroringresolveIf's unknown-condition arm, since every condition on this path belongs to another stack scrub cannot evaluate), NO branch of a malformed one. One site rather than two per consumer is load-bearing and was measured: a per-site copy insidecollectReExportHopscould be deleted with the entire suite still green, because its caller had already pruned. The residual both halves now accept is stated in code: a secret reachable only through the TRUE branch is not detected, which is that reference's pre-#2133 outcome. Tests:scrub-cross-region-secret.test.tsre-points the four cases that pinned the pre-pass refusal at the post-assembly behaviour rather than deleting them (three now assert the resolver's code and the assembled name, the ARN one asserts WHICH region was asked), adds the unlockedFn::Sub-assembled-foreign-ARN case, and re-points one negative assertion that #2157 made vacuous (it pinned the absence of a code that no longer exists, so it now asserts the pre-pass performed no lookup instead);scrub-import-value-secret.test.tsadds the #2150 trio -- untaken branch does not refuse, TAKEN branch still refuses, malformed claims no branch. Every half was mutation-probed separately against the real suite: reverting #2157 alone reds 5 cases, reverting the #2150 selection alone reds 5 (2 new plus the 3 pre-existing hop-walk ones, which is what proves thecollectReExportHopsrefactor is behaviour-identical). Four clauses that a first round of tests left INERT were then measured and fenced, each by a case that reds only under its own mutation: the predicate's COUNT clause (short-circuiting it tofalsehad left 15 496 of 15 496 green -- every other assembled fixture yields ZERO tokens, which reaches the same identity return through the empty-verdictspath, so only a leaf splicing a WHOLE token beside a split opening can tell the two apart), theObject.create(null)rebuild, the selection's ARRAY arm, and its recursion into the selected branch. A fifth case was DELETED rather than reworded: "the PRE-PASS did not fire" had pinned the absence of a code #2157 retires, and its replacement could not fail independently either, since the leaf it uses carries no{{resolve:opening and the pre-pass can never issue a lookup for it -- the case above it already discriminates, the pre-pass's own ambiguity refusal carrying a different code. Live arms, one per half, each on an EXISTING fixture. #2150 ridestests/integration/cross-stack-secret-import/: the producer gains a conditional export whoseFn::Ifcondition is a literal-falseFn::Equals(so the deployed value is always the plain branch, on every account, with no parameter to set) and the consumer reads it through its existing parameter'sDescription-- deliberately not a second resource, since every assertion there counts RESOURCE records. Step 8, which has nothing to do with conditionals, is the discriminator: onmainit exits 2 withSCRUB_CROSS_STACK_PRODUCER_PLAINTEXTand the consumer's real seeded secret is stranded. Two premise assertions guard it against going inert in either direction -- the producer's STORED value must be the untaken branch (an expression there makes the discriminator return early) and the template'sifTruearm must actually carry a{{resolve:secretsmanager:...}}expression (nothing for a both-arms scan to have found otherwise). #2157 ridestests/integration/dynamic-ref-cross-region/, which gains a THIRD stack carrying ONE assembled foreign-ARN SecureString reference and no region-less one: scrub's pre-pass refuses over the whole leaf set, so onceoutputReadsis seeded with a foreign region -- required, since the relaxed guard was gated on exactly that evidence -- the existing stacks' region-less leaves would classifyambiguousand refuse first, and the arm would measure the wrong refusal in both polarities. The new phase seeds the legacy plaintext plus that evidence, then requires rc=0 ANDScrubbed 1 resource record(s)AND the record back on its expression: rc=0 alone is what an early return also produces, and a resolution answered by the WRONG region would not match the seeded plaintext, so the count line is simultaneously the region assertion.cdkdnow decides WHICH REGION answers for a{{resolve:...}}reference AFTER the reference is assembled, so an ASSEMBLED cross-region reference is no longer fetched against the wrong regional endpoint (issue #2134) --src/deployment/intrinsic-function-resolver.ts,src/deployment/secret-region-classification.ts(new),src/cli/commands/scrub.ts. The decision used to be a PRE-PASS incdkd scrubover the RAW template leaf. A reference whose opening or whose tail is contributed by aRef/Fn::Sub/Fn::Join/Fn::FindInMappart does not EXIST in that text -- the shared scan is\{\{resolve:[^}]+\}\}, a class that cannot cross a}-- so the pre-pass found nothing to classify and returned the leaf by identity;resolveSub/resolveJointhen re-enteredresolveDynamicReferenceswith the ASSEMBLED expression on the PRIMARY resolver. A foreign ARN was therefore fetched against the stack's own regional endpoint. What that costs depends on the SPELLING, and the measurement corrected the issue's framing: for an ARN-form reference it is a HARD FAILURE, not a silent miss -- a real-AWS probe with the fix reverted gotIncorrect region in: arn:aws:ssm:us-west-2:...and the resource failed to create, because SSM validates the ARN's region against the endpoint. So the ARN half of this change turns a cross-region reference from unusable into working, rather than from wrong into right. The SILENT-miss shape the issue describes is the region-LESS spelling, where nothing in the expression says which region owns it and a same-named secret in the wrong region answers successfully -- that is the half theambiguousrefusal covers. (Measured forssm; thesecretsmanagerendpoint's behaviour on a foreign ARN was not measured here, and the unit matrix covers its ROUTING with fakes rather than its rejection semantics.)resolveDynamicReferencesnow classifies each matched token itself, which is the first point at which the complete expression exists. Anamed-regionverdict (the id is anarn:naming a different region) is delegated -- the single TOKEN, not the whole string -- toresolverForProducerRegion, the pre-existing per-region sibling cache that marks its siblingsproducerRegionGuestso a foreign region cannot pin anssmtype verdict in the process-global store (the #1933 hazard). Exactly ONE level of recursion, provably: the sibling is pinned to that region, so the same classifier compares the ARN's region against its own and returnslocal. TheambiguousREFUSAL is opt-in via a newResolverContext.producerRegions, andcdkd deploydeliberately does not supply it -- the evidence is per-STACK rather than per-reference, so arming it there would refuse the ordinary CDKsecretValueFromJsonshape in any stack that also holds one cross-region import, and templates that deploy today would stop deploying.cdkd scrubsupplies it, because for scrub a wrong-region answer is a silent miss and failing closed is the point;cdkd driftand the rollback replay keep their own pre-existing per-token classification. The ARN half needs no evidence and is therefore always armed, deploy included. The classifier moved to a LEAF module because the dependency had to flip:rollback-executor.ts(its old home) imports the resolver, so importing it back would close a cycle; every name is re-exported there, so its four importers are untouched.cdkd scrub's own pre-pass guard is KEPT but is now conservative rather than necessary -- it only over-refuses, and relaxing it is issue #2157. Two REVIEW-ROUND blockers are part of this change rather than follow-ups, because both were regressions it introduced. (a) The refusal was raised INSIDEresolveDynamicReferences, andscrubwraps each resolution pass in a best-effortcatch { logger.debug }-- so the refusal was downgraded to a verbose line, no needle was recorded, and the command reportedNo plaintext secrets foundand exited 0 over state that still held the plaintext. That is strictly WORSE than not refusing: pre-change the same shape resolved locally, produced a needle, and WAS scrubbed. It is now aDynamicReferenceRegionAmbiguousErrorre-raised by cause-chain walk at all three catches, mirroring how the pre-pass refusals are placed outside them. (b)reresolveCrossStackValuehanded the CONSUMER's context to a producer-pinned sibling, so with two or more producer regions on record the sibling re-judged a reference whose origin cdkd had just PROVEN and verdictedambiguous; asiblingContexthelper now strips the evidence at both delegation sites. Both were found by review, not by the suite -- every test drove the RESOLVER directly, so nothing exercisedscrubStack, and a reviewer's probe showed that deleting scrub's wiring entirely passed every suite. Review round 2 then found that (b)'s fix closed only half the leak, and the miss is the instructive part: it stripped the evidence by RESOLVER IDENTITY, but a producer in the CONSUMER's OWN region yields the same resolver -- so the strip did not happen and the LOCAL producer refused while the cross-region one worked, backwards, and after (a) that aborted the whole stack's scrub instead of logging a debug line. The through-line behind all three is one shape:producerRegionsis a per-STACK signal, and every defect came from consulting it where the reference's origin was ALREADY known. The gate is therefore now the real question -- is the origin known? -- rather than a proxy for it, soreresolveCrossStackValuestrips whenever it was handed a producer region and keeps the evidence only on the arm where the region genuinely is a guess. Round 3 confirmed the shape and found the last instance of it: the gate spelled that question!== undefinedwhileresolverForProducerRegionspells it!producerRegion, so the two disagreed on''-- on the fail-OPEN side, since the resolver treats an empty region as unknown and answers from the CONSUMER's region while the gate called the origin known and stripped the refusal's evidence. Reachable rather than theoretical, through either of two distinct paths in the exports index:loadIndexparses the stored file with an uncheckedJSON.parse, so aproducerRegionthe file holds arrives verbatim, andrebuildwritesref.region ?? this.region, where??passes an empty string through rather than replacing it. Both sites now use the same predicate verbatim, and a test pins it. Command-level tests now fence both, and the classifier's PLACEMENT below theskipDynamicReferencesarm is fenced too (moving it above previously survived every case). Fenced by a JOINT-distribution matrix (shape x region-form x evidence) asserting WHICH REGION WAS ASKED via the constructor-region discriminator, with two independent mutation probes -- disabling the routing reds 5 cases, disabling the refusal reds 2, disjoint sets -- plus a LOCAL control proving that the two-fetch count on a doubledFn::Joinpart is pre-existing behaviour rather than anything the delegation introduces.cdkd drift's "N resources checked" no longer counts a resource nothing was read for (issue #2141) --src/cli/commands/drift.ts. A resource whose provider does not implement the optionalreadCurrentStateis reportedunsupported(? <logicalId> (<type>), "drift unknown"), and no comparison is attempted for it at all -- yet it incrementedinspectedCount, so a stack whose single resource is unsupported printed✓ TestStack (us-east-1): no drift detected (1 resource checked, 1 unsupported). The count stated a read that never happened. This was main's pre-existing arithmetic (inspected = outcomes.length - skippedCountincluded it too), preserved deliberately by #2135 rather than changed under cover of a refactor whose PR claimed byte-equivalence with the old formula. Of the issue's two candidate remedies -- exclude the resource from the count, or keep it and rename the label to "inspected" -- the FIRST is what shipped: the wordcheckedis the one CI users grep for and it is not the half that is wrong, and--jsonalready reported the resource under its ownnotSupportedarray rather than as anything checked, so excluding it is what makes the two renderings agree. Theunsupportedarm of the exhaustivematchOutcomepass now increments nothing, joiningskipped(#323, where drift is not actionable) for the same reason from the opposite direction -- no read is POSSIBLE there, rather than no read being useful -- soinspectedCountcounts exactly the outcomes a comparison was attempted for. User-visible number change, both renderings: the stack above now printsno drift detected (0 resources checked, 1 unsupported), and the partially-compared spelling's denominator drops the same resource (1 of 3 resources fully checked, not1 of 4). Nothing is lost -- the total is still printed on the same line fromunsupported.length, and--jsonis unchanged. The⚠spelling is also REGROUPED, because excludingunsupportedbroke its arithmetic: that line states a denominator, so its parenthetical reads as a partition of it, and1 of 3 resources fully checked (2 only partially compared, 1 unsupported)sums to 4 against a stated total of 3.unsupportednow sits outside the parens --1 of 3 resources fully checked (2 only partially compared), 1 unsupported-- so the parenthetical accounts for exactly theinspected - checkedgap. The✓spelling keeps both counts inside its parens, since it states no total for them to contradict. Both render sites are fenced by a test asserting the EXACT rendered fragment, and a mutation probe restoring the increment reds both (the✓branch renders1 resource checked, the⚠branch2 of 4). The⚠test pins the WHOLE line rather than fragments, which is what fences the GROUPING: measured, reverting the regroup alone left all 32 tests in that file green, because everytoContainfragment survives it.cdkd scrubnow follows a RE-EXPORT CHAIN when deciding whether an imported export is secret-bearing, so a single-stack scrub at the end of one can no longer report success over surviving plaintext (issue #2146) --src/cli/commands/scrub.ts. This SUPERSEDES the one-template gate described in the #2133 entry below, whose account of that gate as a prior condition on the producer's own template -- and of the verdict as three-valued -- records what THAT PR shipped rather than current behaviour: the #2133 refusal gated onproducerPublishesSecretExpression, which asked ONE template -- the direct producer's -- for a literal{{resolve:. A MIDDLE stack that re-exports someone else's export has none: its output IS theFn::ImportValue. So for stack C importing B's re-export of A's secret-bearing export, the verdict wasno, the pre-pass returned early, and with B's ownstate.outputsstill holding the pre-#1899 plaintext,cdkd scrub CprintedNo plaintext secrets foundand exited 0 while C's record still held that plaintext -- the #2133 silent success reached through a re-export instead of a direct import.cdkd scrub --allmasked it, becauseorderScrubTargetsscrubs producers first and heals the chain before C is reached; the reachable population is the documented single-stackcdkd scrub <name>, which is exactly what a user runs when told to scrub one stack. The gate is now a BREADTH-FIRST WALK over(stack, key)pairs: a matched output that carries no expression is followed through itsFn::ImportValue(via an export-name -> owning-stackS index built once per scrub, keeping EVERY owner rather than the first:inferCrossStackStackDepskeeps one because it is choosing a deploy edge and either choice orders the graph, while here following the wrong twin of a duplicated export name returnsnoand reports a stack clean over surviving plaintext) and itsFn::GetStackOutput(literalStackName/OutputName) into the next producer's template, walking the WHOLE value node so a re-export wrapped in anFn::Joinis still found. TERMINATION IS A VISITED SET, not a hop cap: each pair expands deterministically so a second visit can yield nothing new, and the pair space is finite (stacks x outputs), which halts a CYCLE (two stacks re-exporting each other -- no deploy can produce one, a template can express one) without losing reach. A depth cap was rejected because it is lossy in the one direction that matters -- a chain longer than the cap returnsno, which is the silent success this walk exists to kill. WIDENING STAYS ROOT-ONLY: the all-outputs fallback fires for the direct producer alone (where the key may match nothing because that producer'sExport.Nameis an intrinsic scrub cannot reproduce); one hop up the key is a literal name read out of a template, so a miss means that template genuinely does not declare it, and widening there would refuse a consumer over an unrelated secret two hops away in a refusal no scrub of anything could clear. The verdict gained a fourth value,chained, carrying the stacks crossed, so the refusal message says the producerpublishes '<key>' by RE-EXPORTING a value that '<head>' declares from a {{resolve:...}} expressioninstead of asserting a declaration that template does not make -- and the REMEDY is now the whole chain in scrub order ('cdkd scrub <head>', then 'cdkd scrub <middle>'), because the middle stack cannot store the expression until its own producer has been scrubbed and a one-command remedy would send the operator into a second refusal. Review round added three things the first cut got wrong. (1) The hop walk descended into BOTH arms of anFn::If, breaking the mirror ofresolveIfthe pre-pass keeps deliberately -- a producer whose output is{"Fn::If": ["IsProd", {"Fn::ImportValue": "ProdSecret"}, {"Fn::ImportValue": "DevPlain"}]}, deployed in dev with a benign stored value, then refused with achainedverdict that NOcdkd scrubcan clear (nothing turns that producer's non-secret value into an expression, and there is no bypass flag), where main answeredno. The walk now takes the FALSE branch only, which isresolveIf's unknown-condition arm and the only defensible one here since every condition belongs to ANOTHER stack whose parameters scrub does not have, and a malformedFn::Ifyields no hop at all. (2) Thewidenedverdict DISCARDED its chain, so the message claimed the producer "publishes at least one output from a{{resolve:...}}expression" in exactly the case the walk had proven none of them does, and the remedy fell back to the single command the chain remedy exists to replace;viais now carried onwidenedtoo and its wording says the output RE-EXPORTS one, naming the stack that declares it. (3) The "WIDENING IS ROOT-ONLY" rationale contradicted the code, which does expand a widened root's subjects into hops: the expansion is KEPT (dropping it would make an intrinsic-Export.Nameproducer that re-exports a secret undetectable, reintroducing the silent success for the one population the widening exists for) and the comment now states the trade -- it extends #2133's accepted over-approximation by one hop, in the direction that rule calls safe. Also from the round: the verdict is memoized per(producer, key)for the whole stack scrub, sincereadOneruns once per reference OCCURRENCE and a widened root re-walked the reachable space each time. A delta review then took three more: the message wording moved intoscrubRefusalWording, where the CLAIM keys onkind(only awidenedverdict may say "could not say which", and achainedone with an emptyviano longer borrows that hedge) while WHAT it names keys on the chain; andviais de-duplicated order-preservingly — keeping the FIRST occurrence for the claim so the declaring stack stays last, and the LAST for the remedy so the direct producer stays at the end — because a chain that returns to the producer under another output key (A -> B -> A, or a self-import) is a real walk result, the visited set being keyed by(stack, key), and it rendered a remedy runningcdkd scrub Atwo or three times. Residuals, unchanged in kind and each a possible false CLEAN rather than a refusal: a producer that publishes a secret without a literal{{resolve:AND without a followable re-export (aRefto a parameter, or a re-export of an export produced outside this app); a non-literalFn::ImportValueargument or a non-literal / foreignFn::GetStackOutputslot, which cannot be resolved statically; anFn::GetStackOutputnaming this app's stack in ANOTHER region, since the index andproducerTemplatesare keyed by stack name alone; and a secret reachable only through the TRUE branch of anFn::If, per the mirror above. Each keeps the pre-#2133 outcome for that one reference. Tests:tests/unit/cli/commands/scrub-import-value-secret.test.ts-- 30 cases asserting WHICH values became needles and WHICH refusal wording was emitted, never that the command exited 0. Fourteen run the whole command over the real resolver (chain refusal naming the head and the scrub order; the same chain RESOLVING when the middle stack is scrubbed, at one hop and at two, so a blanket-refuse implementation fails half the pairs; a three-hop chain pinning thethrough 'A', 'B'list and the four-command remedy; bothFn::Ifpolarities, since the branch scrub takes must still refuse; anFn::GetStackOutputre-export; a cycle that terminates AND still finds the secret behind it; a cycle with no secret that terminates and does NOT refuse; a direct import still classifieddeclared; a widened root naming the stack up the chain that does declare the expression; and an upstream export outside the app as the documented residual). Nine call the walk DIRECTLY and seven call the refusal WORDING directly, because several of their properties are unreachable from the command surface at a sane cost: TERMINATION could otherwise only be pinned by HANGING (the walk is synchronous, so removing the visited set wedges the worker instead of failing a test, and no vitest timeout can fire -- a budgeted templates map turns that into a red assertion in 4 ms that names the defect), and theFn::GetStackOutputhop plus the non-root NO-WIDENING rule need a producer template naming a stack and output directly, the second being reachable only through the first. Mutation-probed fifteen ways across three rounds; each named defect reds a named case in milliseconds: hops disabled (the pre-#2146 gate) reds 12, the visited set removed reds both budgeted cycles, walking bothFn::Ifarms reds the untaken-branch case while leaving the taken-branch one green, droppingviafromwidenedreds the widened case, first-writer-wins reds the duplicate-export case, widening at a hop reds the no-widening case, deleting theFn::GetStackOutputbranch reds two, refusing on everychainedverdict reds the resolve case, treating anyFn::ImportValueas evidence reds the two over-approximation controls, labelling a root matchchainedreds the direct-import case, dropping either de-duplication reds a repeating-chain case, letting achainedverdict fall through to the widened hedge reds the empty-viacase, walking a malformedFn::If's children reds the malformed-shapes case, and keying the memo by producer alone reds the two-exports-one-producer case. Live arm:tests/integration/cross-stack-secret-import/grew a THIRD stack -- the existing consumer now re-exports what it imports, making it the middle link, andCdkdCrossStackSecretChainConsumerimports that re-export two hops from the secret -- plus a phase that scrubs the chain consumer ALONE in both polarities (healthy chain resolves and rewrites a seeded plaintext; middle stack'sstate.outputsseeded back to the plaintext must exit 2 with the re-export wording and the chain remedy, and must NOT be thedeclaredverdict, which would mean the middle template grew an expression and the phase had silently become a duplicate of the #2133 one).- An
Fn::ImportValue/Fn::GetStackOutputsource leaf can now be POSITION-certified, so two cross-stack reads whose plaintexts coincide no longer collapse onto one expression (issue #2059) --src/deployment/secret-redaction.ts+src/deployment/intrinsic-function-resolver.ts.intrinsicSkeletonPatternrecognises onlyFn::JoinandFn::Sub, so a cross-stack source leaf could not be positioned at all and fell to the plaintext-keyed value scan. Two leaves importing the:AWSCURRENTand:AWSPREVIOUSexports of one secret are momentarily equal during a rotation, so both were persisted holding whichever expression was recorded last, andresolveReplayPropsthen re-resolved the WRONG reference against the live resource on a rollback or acdkd drift --revert. Extending the skeleton pass could not close it, which the issue thread measured twice before this change: the skeleton is a TEXT matcher and neither intrinsic carries any text about its expression (an export NAME bears no relation to the producer's{{resolve:...}}string), whileSKELETON_WILDCARDis[^}]*and cannot cross a token's own}}, so a pure-wildcard skeleton matches zero candidates and always refuses. What was missing was a RECORDING SEAM:reresolveCrossStackValueis the only point holding both the consumer's source leaf and the whole{{resolve:...}}token the producer stored, so it now recordscanonicalKey -> expressionintorecordedCrossStackExpressions, a process-wide store owned bysecret-redaction.tsfor the same reasonrecordedSecretExpressionsis (that module is the LEAF, the resolver already imports it, and the reverse edge would close a cycle). The key comes fromcrossStackSourceKey, called by BOTH sides over the RAW intrinsic --Fn::ImportValue <exportName>andFn::GetStackOutput <stackName> <outputName> <region> <roleArn>-- so the writer's key and the persist path's key are byte-identical by construction rather than by a proof that two spellings agree; deriving the writer's from the RESOLVED names would look equivalent and is not, since the persist path holds only the unresolved template. A slot that is itself an intrinsic ({"Fn::ImportValue": {"Fn::Sub": ...}}) has no key, and the redaction path then falls back to the skeleton pass and the value scan exactly as before.redactByPath's intrinsic-object arm consults the store BEFOREpositionByIntrinsicSkeleton, under three conditions: the same condition 1 (the bag leaf's WHOLE value must be a plaintext this pass recorded); a PAIRING check that the plaintext the WRITER recorded beside the expression equals this bag; and the same condition 3 (an association THIS PASS can SEE resolved to a different plaintext is refused, which fences a bag/source misalignment on a readback walk); the extractedplaintextIndexOfis now shared by both arms so the conflicting-plaintext poisoning has one definition. The associations live in aWeakMapkeyed by the resolution pass's OWNrecordedSecretValuesbag, so a foreign entry is not merely refused — it cannot be REACHED. That scope is the safety argument, and it took two rounds to reach: a PROCESS-WIDE store is unsound here because the key is not region-qualified (anFn::ImportValuekey carries no region and anFn::GetStackOutputthat omitsRegionkeys it empty), so onecdkd deploy --allputs two stacks in two regions on ONE key —deploy.tsbuilds a resolver per stack region. A second stack whose producer still holds the PLAINTEXT (the #2133 / #2146 population, wherecarriesDynamicReferenceshort-circuits so nothing is recorded AND no conflict can be seen) was then certified with the first stack's region-pinned expression — a case the value scan gets RIGHT, so it was a NEW wrong answer rather than a missed improvement. Pairing each entry with its plaintext narrowed that and could not close it: two regions holding the SAME value (a Secrets Manager multi-region replica, a shared API key) pair happily, and "correct only while replication holds" is a property nobody declared and nothing enforces. The bag was already per-pass and already travelled from the resolver context to the redaction path (DeployEngine.perResourceSecrets,cdkd scrub'sperResourceSecrets,rollback-executor.ts'ssecrets, each storing the very object the resolver mutated), so this is a scope change rather than new plumbing and no call site grew a parameter; a caller handing the redaction path a DIFFERENT bag from the one it resolved with —cdkd state refresh-observed, whose map is empty by construction — finds no associations and falls back to the value scan, which is the direction a mismatch must fail in. TheWeakMapalso means the PLAINTEXTS these entries hold die with the pass, which retired an explicit clear that production never called. The plaintext pairing is KEPT rather than deleted now that scope covers the foreign case: against another pass it is belt-and-braces, but inside ONE pass it is still the only guard against a bag/source MISALIGNMENT, where a readback bag holds a different resource's secret while the source leaf still spells this import.recordCrossStackExpressionadditionally REFUSES anexpressionthat is not a whole{{resolve:...}}token — the two payload parameters are bothstring, so the type system cannot see a SWAPPED call, and the reader returnsexpressionto be persisted, so a swap would write a SECRET intostate.json; the invariant narrows that from "any secret" to "a token-shaped secret", which is as far as a shape test can go while issue #1917 means a plaintext can look like a token. The colliding pair this fix exists for is unaffected throughout, because both leaves are recorded by the SAME pass against the SAME plaintext, which is what "their values coincide" means. This is a POSITION certification for exactly two intrinsic spellings, never a widening of what the value scan may assume -- it never lets the scan take a source subtree, so the issue #1915 fences still hold ({Name: '', Value: 'an-unrelated-literal'}is untouched, in both its scalar and array shapes). The seam is gated on TWO tests, and neither is sufficient alone. Presence inrecordedSecretValuesproves the pass resolved this token to a usable needle — and is what stops askipDynamicReferencescomparison resolve, which leaves a known secret UNRESOLVED, from recording the token as its own "plaintext" and POISONING the key for a deploy resolve that REUSES THE SAME BAG.isSecretExpressionByVerdictOrSpellingis the test about THIS token (secretsmanagerby spelling, or anssmreference PROVEN to be aSecureString), and it is required becauserecordedSecretValuesis shared across the whole pass: a PUBLIC{{resolve:ssm:/x}}whose value coincides with any secret already recorded passes a presence test, and persisting its expression is issue #1901's perpetual-UPDATE class — coinciding plaintexts being this issue's own premise rather than a contrived path. A key recorded against a different (expression, plaintext) PAIR is POISONED at write time rather than overwritten; either half differing is enough, since two expressions under one key is the shared-key case above and one expression under two plaintexts is the same reference answering differently in two regions (the #1933 shape). 25 unit cases intests/unit/deployment/secret-redaction-cross-stack-source.test.ts. Every case asserting the arm FIRES drives the RESOLVER rather than calling the writer from the test -- a store no writer populates is a guard that cannot change an answer -- with one exception whose subject IS the write-side dedup; the seven hand-filled cases are all REFUSALS, where granting the store a more favourable entry than a resolver pass would produce only strengthens the assertion. Each firing case pairs two leaves whose expressions differ while their plaintexts do not, since a single-leaf case passes with the collapse fully intact. cdkd scrubcan now redact a secret that arrived throughFn::ImportValue/Fn::GetStackOutput, and REFUSES rather than reporting success when such a read cannot be resolved (issue #2133) --src/cli/commands/scrub.ts. Every resolve contextscrubStackbuilt omittedstateBackend, which bothresolveImportValueandgetSameAccountStackStaterequire, so a cross-stack read threw for want of a dependency; the throw landed in the per-item best-effortcatch { logger.debug }that exists so a partially-resolvable template still gets scrubbed for everything else. The leaf was therefore silently treated as unresolvable: its plaintext was never fetched, so it never enteredrecordedSecretValues, so the value-based scan had NO NEEDLE for it, so nothing removed it -- and scrub exited reporting no plaintext found over astate.jsonthat may still have held it. This was pre-existing, not a regression from #2109, and it is the same class of silent success reached through a different input rather than a different region. The wiring is now ONE factory insidescrubStackrather than three (in fact four -- theevaluateConditionscontext had the same omission and is not named in the issue) inline literals, so a further context cannot be written without it, which is how the original four came to lack it.exportIndexis deliberately NOT supplied, because supplying it would let the scan arm callexportIndex.patchEntry-- an S3 WRITE from a command documented to perform no AWS mutation,--dry-runincluded. The scan fallback is NOT quite "equally correct", as an earlier revision of this entry claimed: it takes the first matching record across ALL regions while the index is keyed by name alone, so two regions publishing one export name can resolve a different producer than the deploy did. Recorded in the code rather than fixed -- the tie-break lives in the resolver's scan, and the only alternative costs the no-AWS-mutation property. The best-effortcatchKEEPS swallowing; the cross-stack class is lifted out of it instead by a pre-pass (resolveCrossStackReads, run over the resourceProperties, intrinsicExport.Nameand outputValuebags) that refuses withSCRUB_CROSS_STACK_READ_UNRESOLVED(exit code 2, cause masked throughmaskSecretsInText) -- the same placement #2109 uses, and for the same reason: a refusal a debug line swallowed IS the reports-success-over-surviving-plaintext outcome. Making the catch itself refuse was rejected because it cannot tell "aRefto a resource not in state" (what it exists for) from "the producer's state could not be read", so it would break partial scrubbing; lifting only the cross-stack class out preserves it and makes everything the catch still swallows provably not a cross-stack read. Three deliberate properties: each node is resolved as{ [key]: node[key] }rather than the enclosing object, so a sibling key cannot make the pass resolve something else, and WHICH key of a multi-key node is eligible is decided by a mirror of the resolver's own dispatch order (isolation stops the pass resolving something ELSE; it does not stop it resolving something EXTRA, and a node carrying a higher-precedenceRefreally would have made the pre-pass perform -- and possibly refuse over -- a read the main resolution never makes); the bag is NOT rewritten (the main resolution re-resolves the reference; the SECRET is fetched once, and the state READS behind it are shared with the pre-pass by a memoizing view of the state backend -- before that view every reference cost a fulllistStacksplus up to NgetStateTWICE), which buys back the hazard of a resolved cross-stack value that is itself reference-shaped being re-interpreted by the stack's own resolver; andFn::Ifis walked the wayresolveIfwalks it -- selected branch only, unknown condition selecting the FALSE branch -- so a conditional import of a not-yet-deployed producer cannot refuse the whole stack, unbypassably, over a reference the deploy never read. Review round added four things the first cut got wrong. (1) The resolver'sResolved Fn::ImportValue/Resolved Fn::GetStackOutputlines interpolated the resolved VALUE at DEFAULT verbosity, defended by "what a producer stores for a secret-bearing export is the{{resolve:...}}EXPRESSION" -- true of post-#1934 state and false by construction for the population scrub exists for, which holds the PLAINTEXT, and which reaches those lines only because of this change. They now name the reference, the producer and a non-disclosing SHAPE note, and the resolved export / stack / output NAMES (which come back throughresolveValue, so anFn::Sub-assembled name can itself be a resolved secret) go throughmaskSecretsForLoglike every other resolved value in that file. (2) A read that SUCCEEDS but returns no dynamic reference produced no needle, so a consumer importing from a producer whose own state is still unscrubbed was reported CLEAN over surviving plaintext -- the same silent success one step later. It now refuses withSCRUB_CROSS_STACK_PRODUCER_PLAINTEXT, naming the producer and the working remedy (scrub the producer first). The gate is a DIRECT READ of the producer's stored value --state.outputs[<the key the read matched>], on the producer the pre-pass's ownrecordedImports/recordedOutputReadsentry names -- with the producer's TEMPLATE declaring that export from a{{resolve:...}}expression as a prior condition so an ordinary import of a bucket name is untouched. The template alone is NOT sufficient and neither is anything else visible from the consumer's side:reresolveCrossStackValueresolves a stored expression to plaintext before returning it (#1934), so a healthy producer and an unscrubbed one both arrive holding a plaintext and both satisfy the template test. Three cuts each inferred the difference from the consumer and each was wrong differently --carriesDynamicReference(resolved)inverted; the template test true of BOTH (which refused every consumer of a secret-bearing export, caught by the real-AWS arm rather than by any of the 39 unit tests then in the file, because the two signals were perfectly correlated across that matrix); and a NEEDLE-COUNT test wrong in both directions, since the needle map is keyed by the resolved secret SUBSTRING -- a COMPOSITE export (postgres://u:{{resolve:...}}@h, whatsecretValueFromJsonproduces) makessecrets.has(resolved)false for the whole leaf and refused a healthy producer on its second reference, a NON-STRING resolved value could not be tested at all, and a needle recorded while resolving the reference's own ARGUMENT satisfied the count and SUPPRESSED a real refusal. Reading the producer's stored value settles it directly, is exact for all four shapes, and costs no extra AWS call (the same memoizedgetStatepromise). The recorded entry is picked by the intrinsic's own FAMILY and with.at(-1), because a cross-stack read nested in the argument is recorded first and may land in the other bag. The template verdict is three-valued (declared/widened/no), so a refusal whose verdict came from the widened all-outputs scan says so instead of asserting what was never checked for that key. Resolving the producer's expression recordsplaintext -> {{resolve:...}}, which is the mapping scrub needs to rewrite the consumer's leaf; an already-plaintext producer records nothing.scrub --allnow orders producers before consumers (orderScrubTargets, usingdependencyNamesplusinferCrossStackStackDeps) so one real run normally resolves the expression instead of hitting the refusal at all. (3)CrossAccountSecretRefusalError-- the cross-accountFn::GetStackOutputof a redacted value, which cdkd declines BY DESIGN, and a new SUBCLASS ofIntrinsicResolutionRefusalErrorprecisely so this treatment cannot capture that class's five USER-FIXABLE siblings (a stale placeholder ARN, a fabricated account, an unenrichedFn::GetAtt,--strict-getatt, a malformedFn::Split), each reachable here whenever a cross-stack node's argument is anFn::Sub/Fn::GetAtt/Fn::SplitsinceresolveSubre-raises the class -- was being converted into a whole-stack refusal with a remedy ("deploy the producer stack") that cannot fix it and no bypass, stranding every other secret in that stack permanently; it is now an unremediable FINDING like a secret-bearing state KEY (reported, counted,--failexits non-zero, the rest of the stack still scrubbed). The rule applied: a read that FAILED for a reason a user can fix (deleted producer, unassumable role, missingcloudformation:ListExports) refuses, because a re-run then works; a read cdkd will never perform degrades. (4) A CONDITION-SUPPRESSED output could refuse the whole stack in dev over a prod-only import; suppressed outputs are now resolved for their needles but cannot refuse -- for EITHER refusal, and thecanRefusetest runs ahead of the by-design branch so such a position records no permanent FINDING either -- and a MALFORMEDFn::Ifis walked with refusals disarmed instead of falling through to the plain object walk. Suppression is decided by the condition AND by state, sinceconditionscomes from template parameter DEFAULTS (scrub takes no--parameters) and degrades to{}on failure: a key present instate.outputsproves the deploy wrote that output. Three more identifier / key sites on this path stopped printing unmasked values: the re-resolution's ownRe-resolving dynamic reference(s) in <origin>debug line (whoseoriginembeds the RESOLVED export / output / stack name), theDescribeStacksfallback warn (which prints at default verbosity and had never been given acontextto mask against), and theAvailable outputs:enumeration in anFn::GetStackOutputnot-found ERROR, which is now masked and capped at ten keys because those are the PRODUCER's output keys and a key can itself hold plaintext. The per-stackNo plaintext secrets found in <stack>line is now gated onunverifiableReads === 0as well, so it cannot contradict the warning above it. Tests:tests/unit/cli/commands/scrub-import-value-secret.test.ts-- 39 cases over a real resolver, asserting WHICH values became needles, WHICH stack's state was read and WHICH region's client answered rather than that the command exited 0; each pre-pass call site fenced individually so the refusal is not an unfenced disjunction; both polarities of every new refusal; and negative controls (an unresolvableRefalone, a resolvable import beside one, an unreadable import in a not-takenFn::Ifbranch, an ordinary import resolving to a plain string, a producer outside the app, an ordinary error where a by-design refusal is handled) so a refusal firing on everything cannot satisfy the positive assertions. Every case was mutation-probed red, including the two that are only red when BOTH of their redundant guards are removed. Live arm ontests/integration/cross-stack-secret-import/.cdkd driftmodels "this resource was not compared" as a FIRST-CLASS outcome instead ofcleanplus a boolean, so a consumer that forgets to ask cannot report an unchecked resource as a clean one (issue #2135) --src/cli/commands/drift.ts. The per-resource outcome union wasdrifted/clean/unsupported/skipped, and a resource whose dynamic references cdkd could not -- or refused to -- resolve landed incleancarryingreferencesUnresolved: true(plus a secondcomparisonRefusedflag for the narrower exit-code population). Every consumer therefore had to REMEMBER to consult the flag, and the default behaviour of forgetting was to report a resource cdkd never checked as one with no drift; the same root cause surfaced twice in successive review rounds on the #2108 lane (round 1:--jsonreported it as plainclean; round 2: the exit code read the refusal as a pass). The union now carries anotComparedmember holding a singlenotComparedCauseof'refused'(cdkd declined -- exit2) or'unresolvedToken'(a surviving{{resolve:ssm-secure:...}}token, a permanent pre-existing population deliberately kept out of the exit code),cleancarries no completeness marker at all, and adriftedoutcome carries the same cause orundefined. All eight consumers -- thenotComparedroll-up, the exit-code decision,runAccept,runRevert, both--dry-runplans, the--jsonwriter and the human report with its "N of M fully checked" counter -- route through onematchOutcomehelper whose handler record is a mapped type overDriftOutcome['kind'], so a new variant is a COMPILE error at every one of them rather than a silent wrong answer (proven by adding a temporary sixth variant: all eight failed to compile). User-visible payload delta,--jsononly: a not-compared resource now appears undernotComparedand NOT underclean,notComparedentries carryreferencesUnresolved: true, andcleanentries arereferencesUnresolved: falseby construction. No key changed name or meaning, and the exit codes, the human report and thePARTIALLY comparedblock are unchanged. Fenced bytests/unit/cli/drift-outcome.test-d.ts(the mapped type must stay total;cleanmust reject a completeness rider) plus three runtime tests asserting the counter, the human block, the--jsonroll-up and the exit code all answer from the same variant.- A Ctrl-C during
cdkd rollback's lock release no longer strands the lock for its full 30-minute TTL (issue #2118) --src/cli/commands/rollback.ts,src/deployment/deploy-engine.tsandsrc/provisioning/interrupt-watch.ts(comments only),.claude/rules/code-layout.md,docs/provider-development.md.rollbackCommand'sfinallytore down every signal path BEFORE releasing the lock:unforwardSigterm(), thenprocess.removeListener('SIGINT', sigintHandler), thenreleaseLock. Between the removals and the release the process held the stack lock with ZERO SIGINT listeners, so a Ctrl-C landing in that S3 round-trip took Node's DEFAULT terminate -- the release never completed and the nextcdkd rollback/deploy/destroyon that stack blocked until the 30-minute TTL expired (recoverable withcdkd force-unlock, which is why this is a blocked command rather than lost data). This is the MIRROR of issue #1348, which fixed the other end of the lock's life by registering the handler before ACQUIRING; the rule both need is the onedestroy-runner.tsalready states on its strong-ref refusal path -- "Release FIRST, remove the listener LAST: while the release round-trip is in flight the handler stays armed." Both unregistrations now live in a nestedfinally, because the.catch()on the release covers a REJECTION and not a synchronous throw -- defence in depth rather than a live leak, sincereleaseLockisasyncand nothing inLockManagerreaches that shape today, but the leak it would cause is per-command and permanent. The first cut of this change also claimed the ORDER within the teardown pair was load-bearing, in four places, and it is not -- the review round that measured it is the reason the claim is not shipping. The stated mechanism was thatunforwardSigterm()(which since issues #2053 / #1952 also closes the interrupt-watch scope and removes that module's shared SIGINT listener) could empty the listener set beforesigintHandlerwas reached. It cannot: the command's own handler is still registered at that point, and the two calls are adjacent and synchronous, so no signal can be delivered between them. What IS load-bearing, and is what the SIGTERM half of the test pins, is thatunforwardSigterm()must not run BEFORE THE RELEASE -- CI cancellation delivers SIGTERM, so with the forwarder gone that signal strands the lock on its own, and a fix that reordered only the SIGINT half would look correct. The acquire-failure catch above is REORDERED to match, so the file no longer reads as contradicting its own comment 300 lines down. It stays correct either way -- no lock is held there (acquireLockWithRetryreturns on the first successful PUT, so every throw path leaves the lock not ours) and both statements are synchronous, so the end state is identical. Marginally safer in the new order: ifunforwardSigterm()ever threw, the command's own handler is already gone. An earlier draft of this entry called that catch "unchanged", which its own diff contradicted. A repo-wide sweep of the other lock-holding commands found no further instance --drift/scrub/export/import/orphanandstate refresh(which releases a lock instate.tsbut registers no handler at all) have nothing to unregister,destroy-runner.tswas corrected in the #2053 / #1952 lane, anddeploy-engine.tsis now the ONLY site that still unregisters first, which is safe for a reason the others did not have and which its call site documents:deploy.tsregisters a top-level handler that outlives the whole method. That call-site comment's forward reference to this issue is de-staled in the same PR, as are THREE further copies of the same claim --.claude/rules/code-layout.md,docs/provider-development.md, andsrc/provisioning/interrupt-watch.ts's force-quit arm, which said outright that "no window remains" was true of that handler but false of the codebase becauserollback.tsstill unregistered first. That fourth copy is the highest-signal one and the original sweep missed it; a review round found it, which is also how the enumeration in an earlier draft of this entry came to be wrong. Its replacement then had to be scoped twice more, because the obvious rewrite is also false: what holds is that no lock-holding command THAT REGISTERS A SIGINT HANDLER unregisters it before releasing, withdeploy-engine.tsas the one deliberate exception. It is NOT true that no stranding window remains anywhere --import/export/scrub/orphan/driftandstate refresh-observedregister no handler at all, so each holds its lock with zero listeners for its ENTIRE duration, a wider window than the one this issue closes and a separate defect. Tests:tests/unit/cli/rollback-lock-release-ordering.test.ts, the behavioural twin ofdestroy-runner-lock-release-ordering.test.ts-- it observes the SIGINT and SIGTERM listener sets from INSIDEreleaseLock, which is the only vantage point that can see the window the defect lived in. Both signals are measured separately on purpose: a fix that reordered only the SIGINT half would leaveunforwardSigterm()running before the release, and CI cancellation delivers SIGTERM, so that half strands the lock on its own. A merge-gate review round then found one more gap and one more overclaim: the release's REJECTION arm was unfenced (deleting the.catchpassed all seven cases, while the twin fixture has carried that arm all along), so a regression masking the rollback's real error with the S3 failure would have gone unnoticed -- now pinned; and the SIGTERM case's own comment called itself the fence for the order WITHIN the teardown pair, contradicting the file header three lines up. Two cases came out of the review rounds: the ordering is also driven on the SUCCESS path (a replayed journal), which is the only path where the provider-side interrupt watch is ever armed, and a case recording as DELIBERATE that a Ctrl-C landing during the release is swallowed. That last one is a consequence this fix CREATES, and the twin command answered it the other way: arming the handler for longer moves the window a signal can arrive in, which is exactly the round-4 regressiondestroy-runner.tshit when a late flip leftresult.interruptedfalse anddestroy --alldeleted the next stack.rollbackhas no such consumer --interruptedis read only inside thetry,rollbackCommandreturns void, and its one call site iswithErrorHandling-- and by that point every operation has replayed, the journal is popped and state is saved, so raising aPartialFailureErrorwould report exit 2 for a rollback that fully succeeded. The case exists to stop a later change adopting the destroy-runner precedent as if it were binding. A second review round found that case was VACUOUS as first written: it asserted only that the command resolved cleanly, which is equally true when the handler has already been unregistered and there is nothing left to fire -- so it went green under the very ordering regression its siblings catch. It now captures stderr and asserts the handler's own interrupt notice was written during the release, which is the only observable proof it ran; with that assertion it reds under the ordering mutation alongside the other three. Two comment claims from the same round are corrected rather than left: the success-path case's rationale said the replay is "the only path on which the provider-side interrupt watch is ever armed", which is not what that case exercises (its provider is a stub, and exactly one SIGINT listener is live at the release), and a note claiming the fixture leaves "the harness's own SIGINT handling untouched" described a loop that in fact invokes every registered listener. Mutation-probed in both directions: restoring the old ordering reds the two armed-during-release cases AND the success-path one, and dropping the nestedfinallyreds the synchronous-throw case -- plus an inverted control on the acquire-failure path, which releases nothing and must still leak nothing. cdkd gcandcdkd bootstrap --destroynow name the STACK for a European Sovereign Cloud state key, instead of reporting the region as the stack name (issue #2001) --src/cli/commands/state-file-keys.ts,src/cli/commands/gc.ts,src/cli/commands/bootstrap-destroy.ts,src/cli/commands/bootstrap.ts,src/deployment/intrinsic-function-resolver.ts(comment only).REGION_SEGMENT, the pattern that decides whether the last segment of{prefix}/{stack}/{region}/state.jsonis a region, required a prefix of EXACTLY two letters (^[a-z]{2}(-[a-z]+)+-\d+$). Every commercial / GovCloud / China / ISO region has one, but the European Sovereign Cloud partition's is FOUR -- soeusc-de-east-1was rejected and the whole partition mis-parsed. The audit the issue asked for is what makes this more than a one-line swap, and the answer is worse than a cosmetic label:describeStateKeyfalls back to the LAST segment, which for a region-prefixed key IS the region, so a rejection does not degrade to "region omitted" -- it reports the region string AS the stack name. Three of the four call sites (bootstrap-destroy.ts's marker-reference scan and its state-bucket teardown refusal,gc.ts's lock guard) then name a stack that does not exist while listing what blocks a teardown. The fourth is not display at all:gc.ts's corrupt-state abort re-parses the described string with/^(\S+) \((\S+)\)$/to BUILD a recovery command, which stopped matching -- so the user was handedcdkd state show eusc-de-east-1(no--stack-region, and a stack name that is really a region) at exactly the moment their state file was unreadable and they needed to inspect the real one. No state-key MIGRATION is implied: the pattern only classifies keys that already exist, and never writes one. The fix went through three revisions, and what it converged on is narrower than any of them. The pattern's first token is now[a-z]{2}OR the literaleusc-- enumerated fromaws-cdk-lib/region-info'sAWS_REGIONSand@aws-sdk/util-endpoints'partitions.json: 46 REAL regions, first tokens all two letters oreusc, none three. (An earlier draft said 53, which counts theaws-global/aws-iso-*-globalpseudo-regions whose first token isaws-- they carry no trailing-<digits>so both the old and the new pattern reject them, but at 53 the claim is false as stated.) The obvious fix, relaxing the bound to{2,4}, was written first and shipped a worse defect than the one it fixed. Widening a length class widens what the pattern mistakes for a region in the OTHER direction, and that direction bites far more often: the pre-fix hazard needed a stack nameddb-/qa--something, while{2,4}captures the idiomatic first tokens --api-prod-1,demo-app-1,prod-api-2,data-eu-west-2,core-api-1all flipped. In the legacy region-less layout a stack name sits exactly where a region would, so each of those was described with the key's PREFIX as the stack. A length class cannot separateeuscfromdemo; only naming the partition can. The cost of enumerating is that a future non-two-letter partition needs this line updated -- a one-token change that fails in the recoverable direction, unlike corrupting ordinary stack names. For a legacy region-less key those are described ascdkd (demo-app-1), reporting the state PREFIX as the stack name, andgcthen emitscdkd state show cdkd --stack-region demo-app-1-- the #2001 defect reintroduced from the other side, wrong in both arguments. SodescribeStateKeyno longer guesses on the ONE layout where depth can settle it: it takes an optionalknownPrefix, and a key with exactly one segment under that prefix is the legacy{stack}layout by construction, whatever the stack is named. It deliberately does NOT claim the two-segment case, and a first cut did -- on the reasoning that at that depth the last segment must be the region. Review found--state-prefix cdkd/team-a, which nests INSIDE the default prefix, socdkd/team-a/MyStack/state.jsonis a legacy key two segments deep and would have been split asteam-a (MyStack)wheremaincorrectly saidMyStack: the fix would have shipped a fresh instance of the very class it removes. Depth cannot tell that from a real{stack}/{region}and only the tail's shape can, so the two rules end up with disjoint jobs rather than overlapping ones.gcandbootstrap --destroyboth configure their own state backend with the default prefix, so both pass it, and a new sharedDEFAULT_STATE_PREFIXinstate-file-keys.tsreplaces the copy each command held -- a drifted copy would not error, it would silently demote every key back to the guess.bootstrap.ts,options.ts(the--state-prefixDEFAULT, which is what actually decides where a user's state lands) andmigrate-command.tsare pointed at it too, since changing any of those alone is exactly the silent demotion. The shape rule remains for keys under a FOREIGN prefix, which is not a fallback that can be removed: the listings deliberately span the whole bucket so a stack deployed with a custom--state-prefixcannot slip past a teardown guard, and for those keys the depth is genuinely unknown. The pattern stays SHAPE-based and deliberately does not reuseisClientSafeRegion, whose job is the opposite one (keeping a value inside a hostname label, so it accepts nearly any stack name); that function's own comment claimed the sibling "rejectseusc-de-east-1" and is corrected here to give the structural reason instead. The same audit swept the repo for sibling region-shaped patterns:gc.ts:356andecr-uri.ts:225are permissive and clean,scripts/andtests/have none, andsrc/local/lambda-resolver.ts:878carries this defect plus a wider one (its partition alternation lists three of eight, so layer ARNs in all four ISO partitions fail too) -- filed as issue #2143 rather than folded in, sincesrc/local/**sits behind theinteg-localmerge gate and would pull a real-Docker cycle onto a PR in another command family. Tests:tests/unit/cli/state-file-keys.test.ts-- 12 real regions across every partition; five negatives, of which review established that only ONE (the over-long prefix) actually fences the widening while the others guard the case rule and the-\d+$anchor, so their comments now say which; and a depth-branch block covering the region-shaped legacy stack names, a region spelling the shape rule refuses (proving depth does not consult it), lock keys in both layouts, and the foreign-prefix fallback. Plus consumer cases that drive the real commands end to end: two intests/unit/cli/gc.test.tsasserting the recovery hint readscdkd state show PaymentsApi --stack-region eusc-de-east-1, and two intests/unit/cli/bootstrap-destroy.test.tson the reference scan -- the one call site that prints no raw key beside the descriptor, so a wrong descriptor is all the user gets. Mutation-probed in three directions: restoring{2}reds seven cases including bothgcconsumers, widening to{2,}reds the upper-bound negative alone, and disabling the depth branch reds seven state-file-keys cases plus thebootstrap-destroylegacy one. The eusc reference-scan case deliberately survives that third probe -- it fences the widening, not the depth rule. Three successive review rounds each found the same structural defect -- a classification rule asserting it covered more cases than it does -- and the third found it in the fix for the second. Round 2 found that three of the four CALL-SITE tests could not fail, which is the more interesting result: each used a region-shaped REGION segment, and that is precisely the input where the depth rule and the shape heuristic AGREE, so deleting theDEFAULT_STATE_PREFIXargument fromgc.ts's two sites orbootstrap-destroy.ts's teardown listing left the suite green -- the threading could have gone inert with nothing to say so. Each site now also drives a LEGACY region-shaped stack name, where the two rules disagree, and stripping the argument from all four sites reds exactly one case per site. Two nits from the same round are fixed rather than recorded: aknownPrefixspelled with a trailing slash silently demoted every key back to the guess (the exact failure the parameter exists to prevent, arriving through a spelling instead of a drift -- now tolerated, and fenced by its own probe), andsrc/cli/commands/bootstrap.tsstill spelled the prefix as a literal in two places, which is the "create side" the new doc comments cite BY NAME, so it is unified onto the shared constant too.cdkd scrubnow resolves a foreign-region secret reference in the region the reference NAMES, and REFUSES rather than reporting success when that region cannot be established (issue #2109) --src/cli/commands/scrub.ts. scrub learns which plaintexts to look for by re-resolving today's template through oneIntrinsicFunctionResolverbuilt from the stack's own region, andresolveSecretsManagerReferencebuilds its client from that region while passing the SECRET_ID through as an opaque string -- the SDK's endpoint ruleset has no ARN-derived endpoint rule, so a reference naming another region's ARN went to this stack's regional endpoint. Both halves failed at once: the plaintext scrub exists to remove was never found (the needle was the wrong region's value), so the command reported the stack CLEAN over state that still held the secret; and the foreign value is a real string, so scanning for it could rewrite an unrelated stored literal onto a{{resolve:...}}expression the stack never had. The region split is issue #2057's, imported fromrollback-executor.tsrather than re-spelled:producerRegionsFromStatereads the per-stack evidence (state.imports[].sourceRegion+state.outputReads[].sourceRegion) andclassifyReplaySecretRegionreturnslocal(the stack's own region answers),named-region(an ARN names a FOREIGN region, so a resolver pinned to it answers) orambiguous(a region-LESS name form with a foreign producer region on record -- refused). The refusal is aCdkdErrorthat leavesscrubStackuncaught, so it is a NON-ZERO EXIT with the message printed, never a debug line under a "nothing to scrub" summary; it names the reference, both regions and the ARN remedy, and leaks neither region's value. Anamed-regionreference whose own region cannot answer is refused too rather than retried locally. The known over-refusal is inherited with the classifier and stated in code: a name-form reference in a stack with ANY foreign producer region on record is refused even when it is the stack's own purely-local secret, because the evidence is per-STACK, not per-reference. Unit coverage:tests/unit/cli/commands/scrub-cross-region-secret.test.tsfakes only the leaf SDK client classes (real resolver, realAwsClients) with the CONSTRUCTOR region as the discriminator, and primes the SAME reference with DIFFERENT values in the two regions so "which region answered" is observable at all. Four review-round hardenings ship with it. (1) A reference the intrinsics ASSEMBLE is refused rather than silently unclassified. The token scan runs on the RAW template leaf and its[^}]+class cannot cross a}, so an assembled reference is not the one whole token a literal one is, and the THREE shapes fail in TWO different ways -- all four rows measured. A MID-stringFn::Subplaceholder ({{resolve:secretsmanager:${Env}-db:SecretString:password}}) and anFn::Joinsplit ('{{resolve:secretsmanager:'as one part) yield ZERO tokens while plainly OPENING a reference; a TRAILING placeholder ({{resolve:secretsmanager:x:SecretString:${Field}}}) instead closes the match ONE BRACE SHORT and yields exactly one token per opening, so a count can never see it. In every case the leaf came back by identity andresolveSub/resolveJointhen handed the assembled expression to the PRIMARY resolver, which is this issue verbatim with the refusal never firing. Two tests now raiseSCRUB_SECRET_REFERENCE_UNCLASSIFIABLE, naming the leaf path and the remedy: openings-vs-tokens for the first two shapes, and "a whole token still contains${" for the third (no Secrets Manager secret name or SSM parameter name may contain$or{, so the only false positive it can produce is a JSON key literally spelled${...}outside anFn::Sub). The ARN form of that third shape was previously safe only by luck -- it classified asnamed-region, so the producer region WAS asked with a secret id still carrying a literal${Field}, and safety came from that lookup happening to fail; it is now refused before any lookup, which the suite asserts against a PRIMED producer region so the luck cannot come back. The count is over openings followed by a SECRET SERVICE (secretsmanager:/ssm:/ssm-secure:, mirroringrollback-executor.ts'sREPLAY_SECRET_SERVICES), not over the bare{{resolve:the first draft counted: measured,Use the {{resolve: prefix for dynamic references,prefix {{resolve:and{{resolve:}}each count one opening and zero tokens, so any description, IAM policy document, UserData script or environment variable that merely MENTIONS the syntax made its stack permanently unscrubbable (exit 2, no bypass flag) with a remedy -- "spell the reference as one complete literal" -- that is unactionable for prose. The tokens are filtered to the same spellings, so a complete reference to a non-secret service cannot make the two sides disagree either. Both tests are gated on a foreign producer region being on record: with none there is no cross-region question to get wrong, and refusing there would reject ordinaryFn::Subtemplates. Classifying AFTER assembly inside the resolver is the structurally complete fix and is out of this change's scope, so the residuals are stated in code rather than implied away, and the scope text of issue #2134 names them: an assembled FOREIGN-ARN reference in a stack with no cross-stack read on record; anFn::Jointhat splits BEFORE the service name; and -- the one that bounds the whole pre-pass -- a leaf whose{{resolve:opening is CONTRIBUTED by aRef/ parameterDefault/Fn::FindInMaprather than present in the template text, which the walk returns by identity so neither the region split nor this guard ever sees it, foreign producer region on record or not. (2) Under--alla refusal is caught PER STACK -- the stacks after it are still scrubbed, each failure is logged WITH ITS CAUSE CHAIN (a provider or AWS failure is routinely a generic sentence over the link that names the denied action, and thislogger.erroris the only place a per-stack reason is rendered), the final error NAMES every failed stack without restating the reasons that were already printed, and the run still ends non-zero. Under--dry-run --failthe REFUSAL (exit 2) outranks the FINDING (exit 1) -- they call for opposite responses, so the precedence is asserted rather than left to statement order. Previously one refusal abandoned every later stack while the earlier ones had already been rewritten, which the rollback replay's per-op twin never did; no summary line claims success over a stack the run could not examine. (3) Every scrub refusal carriesexitCode = 2, since1is already spent on--fail'sScrubNeededError("plaintext found") and a CI gate reading the code alone must not confuse "refused to look" with "looked and found a leak". (4) Every error text this command emits is masked against what it recorded -- the unresolvable-region refusal masks the cause message it echoes, and so do the three sites that interpolate a resolver error into a log line (theExport.Namewarn at DEFAULT verbosity plus the two verbose-only partial-resolution debug lines), each of which is handed a bagpinCrossRegionSecretsmay already have SUBSTITUTED a foreign plaintext into. Per-site masking is not sufficient on its own, so every error that ESCAPESscrubStackalso passes throughmaskSecretsInErrorat that boundary:formatErrorrenders aCdkdError's cause asCaused by: <message>and the CLI's top-levelconsole.errorwalks the whole chain and every link'sstackthroughutil.inspect, which read the error OBJECT and no log-site mask can reach. No resolver error carries a plaintext today, but this is the command whose subject is a leaked plaintext.cdkd scrub's outputs redaction converged ontoTEMPLATE_SOURCED_RULES, so the twostate.outputswriters agree again (issue #2099) --src/cli/commands/scrub.ts,src/deployment/secret-redaction.ts.DeployEngine.redactOutputsmoved off the defaultTEMPLATE_DERIVED_RULESfor issue #1943; scrub'spositionedOutputscall kept the default on the premise that the two constants' only difference (descendArrays) could not fire here, "because CloudFormation requires an OutputValueto be a string or an intrinsic OBJECT". CloudFormation does require that; cdkd does not enforce it --TemplateOutput.Valueis typedunknown, the resolver walks an array elementwise with no string coercion, andStackState.outputsis deliberately not string-coerced -- so a list-valued output (an escape hatch, a hand-written or imported template) puts an array on BOTH sides and the arm IS reachable. The pair is the one positional descent is least sound for: the bag is a PREVIOUS generation's persisted array and the source is today's template array, so a stored literal at indexiwas rewritten to today's expression at indexi(redactByPathreturns a known-secret source leaf verbatim), andstate.outputsis re-applied VERBATIM to consumer stacks by the exports index and byFn::ImportValue. The trade is stated in place rather than re-decided: turningdescendArraysoff gives up a legitimate positional descent, and although this call site's bag is always cross-generation, the swap is not free -- when state happens to be current the refused descent would have rewritten an element whose plaintext this run did not record (a value rotated away since the deploy), which the value scan cannot identify. ThePathSourceRulesgeneration table'scdkd scruboutputsrow and its "the two OUTPUTS rows disagree" paragraph were updated to match. Unit coverage:tests/unit/cli/commands/scrub-outputs-array-generation.test.ts, including the capability the swap KEEPS (a whole-value plaintext match in a list element is still repaired by the value scan).cdkd driftno longer re-resolves a cross-region secret reference in the CONSUMER's region ----revertcould write a foreign region's same-named secret to a LIVE resource (issue #2108, thedrift.tshalf of #2057) --src/cli/commands/drift.ts. Since issue #1934 a cross-region cross-stack consumer re-resolves a redacted producer value in the PRODUCER's region and records the PRODUCER's region-less spelling of the{{resolve:...}}expression into its ownstate.json.resolveStateSecretExpressionsre-resolved that spelling through resolvers built from the CONSUMER's region with no region check at all, at BOTH of its call sites, and a Secrets Manager secret (or an SSMSecureString) of the same NAME in two regions is two independent values. Three consequences, in severity order: (1)--reverthandeddesiredPropertiesstraight toprovider.update, so the foreign region's secret was WRITTEN to a live resource -- reachable from an ordinary drift run rather than only after a failed deploy, and measured under a reverted fix as a Lambda env var receivingtokyo-password-2108where the deploy had appliedireland-password-2108; (2) detection baselined against a plaintext AWS can never hold, so the property reported drift forever and no--accept/--revertcould converge it; (3) thesecretsneedle map held the WRONG plaintext, so value-based redaction both missed the real secret and rewrote an unrelated public literal coinciding with the foreign one. Every reference is now classified byclassifyReplaySecretRegion-- IMPORTED fromrollback-executor.tsrather than re-derived, so the two commands cannot answer differently -- and an ARN naming a foreign region resolves through a resolver PINNED there (DriftSecretResolvers, one instance per stack because the resolved-value cache lives on the resolver instance since #1933), while a region-less name form in a stack with a foreign producer region on record is REFUSED withDRIFT_SECRET_REGION_AMBIGUOUSbefore any lookup. The foreign-region evidence isproducerRegionsFromState(state)read straight off the record the command already loaded (state.imports[].sourceRegion/outputReads[].sourceRegion) -- the rollback lane had to plumb that through its context; drift needs no plumbing, which is why this fix could be exact where #2057 had to settle. Both refusal paths degrade fail-closed through catches that already existed: detection falls back to the unresolved baseline, whose{{resolve:...}}leaves the comparator SKIPS (not compared, rather than compared against a foreign plaintext), and revert counts the resource unresolvable and returns beforeprovider.update. Same-region behaviour is byte-for-byte unchanged. Three costs are ACCEPTED rather than incidental, and are recorded here because each is a behaviour a user can observe. (a) The refusal fires on ANY name-formsecretsmanager/ssmreference in a stack that has ANY foreign producer region on record -- including a purely LOCAL secret, which is the shape CDK's ordinarySecretValue.secretsManager('name')/secretValueFromJsonemits -- because the evidence is per-STACK, not per-reference; the same known over-refusal #2057 accepted, and the remedy is the same (spell the reference as a full ARN, which names its region). (b) On that refusal path the per-resource secrets map is CLEARED, so only the offline positionalseededSecretPathsmasks that resource. Pre-#2108 an over-refused local reference resolved fine and the map held the CORRECT plaintext, which value-based redaction used to mask that value wherever it appeared -- including at paths whose state side carries no{{resolve:for the positional seed to find. Kept anyway: the alternative is resolving the reference to build the needle, which is the wrong-region fetch this change exists to refuse. (c) A refused resource is reported CLEAN (the comparator skips its{{resolve:...}}leaves), where pre-#2108 it reported -- wrongly, but visibly -- as drifted.cdkd drift --jsontherefore gainedreferencesUnresolvedon everydriftedANDcleanentry plus anotComparedroll-up, and the human report gained aPARTIALLY comparedblock, so a CI job gating ondrifted.length === 0cannot read a SKIPPED comparison as a passing one. All three keys are additive; no existing key changed meaning. And so does the EXIT CODE, which is the signal most CI gates actually read and the layer round 1 of this change left alone: a detection-only run that drifts nowhere but REFUSED at least one comparison now exits2(this repo's "work completed but something was SKIPPED" code) instead of0. That direction PRESERVES what CI consumers had -- pre-#2108 that population reported phantom drift and exited1, so0would have been the silent downgrade -- and drift still WINS, so a run that both detects drift and refuses a comparison exits1exactly as before and a gate keyed on=== 1loses nothing.--accept/--revertkeep their documented codes. The human summary follows the ROLL-UP rather than the exit code (⚠instead of✓, and<n> of <m> resources fully checked, since counting a not-compared resource ascheckedcontradicted thePARTIALLY comparedblock printed two lines below it -- and keying the glyph on the narrower exit subset would reintroduce exactly that contradiction for thessm-securepopulation). The exit trigger is a STRICT SUBSET of thenotComparedroll-up, via a second outcome fieldcomparisonRefusedset only from the THROWN half ofreferencesUnresolved. The other half -- a surviving{{resolve:ssm-secure:...}}token, which cdkd resolves for nobody because CloudFormation resolves it server-side -- is a large PRE-EXISTING population unrelated to this issue and permanent by construction, so driving the exit off the combined flag would have failed those users'cdkd driftin CI forever over a defect this change did not introduce. Such a stack is still listed undernotComparedand in thePARTIALLY comparedblock, and still exits0. As information both populations were genuinely not compared; only the exit code, the thing CI keys on, is confined to the one this change created. The two flags are the shape issue #2135 exists to fix structurally. (d) A deliberate refusal is now told from a failed READ by CLASS rather than by an enumerated error code: both refusals are raised asDriftSecretRefusalErrorand both message sites askisDriftSecretRefusal(err). The two sites originally compared againstDRIFT_SECRET_REGION_AMBIGUOUSalone, soDRIFT_SECRET_TOKEN_SCAN_MISMATCH-- whose own message says "Refusing rather than resolving" -- printed the read-failure wording and sent the reader hunting for an IAM grant that is not missing. Pre-v8 residual, the same limitation #2057 carries: a cross-regionFn::GetStackOutputon a stack last deployed under schema v7 has nooutputReadsat all, soproducerRegionsFromStatereturns empty, every name-form reference classifieslocal, and the wrong-region resolution persists until the stack's next deploy writes a v8 record. An ARN-form reference is unaffected (the expression settles the question itself). Tests:tests/unit/cli/drift-cross-region-secret.test.ts(24 cases at both polarities, faking the leafSecretsManagerClient/SSMClientso each assertion names WHICH region's client answered rather than merely that a value came back, with the two regions primed to DIFFERENT values for one secret name; the MIXED-leaf cases assert the FULL rebuilt string, since a whole-token leaf leaves the segment-rebuild loop's two splices dead) including the full truth table of the facts the exit code decides on -- refused-only, drifted-only, clean, both at once, and the case the narrowing exists for: a survivingssm-securetoken with NO refusal, which must be reported and must still exit0),tests/unit/cli/drift-secret-refusal-is-positive.test.ts(a source-level fence that no branch enumerates a refusal CODE and that every refusal is raised as the class -- source-level because the token-scan refusal is an internal-invariant guard no input reaches while the scanner is correct), andtests/unit/cli/drift-leaf-region-walk-mirrors-replay.test.ts(the anti-drift fence for the deliberate second implementation: it READSrollback-executor.tsand asserts the two leaf walks are the same program once the declared identifier aliases and the two command-specific throw texts are normalized). Integ:tests/integration/rollback-cross-region-secret/phases 2b / 2b2 / 2c, which assert both exit codes against real AWS and tamper a SECOND, non-secret property (Description) so the revert actually runs -- tampering only the secret-bearingValueleft the resourceclean, sorunRevertreturned early and the phase exercised no revert code at all.- A FAILED deploy now persists the cross-stack reads it actually made, closing a destroy-time strong-reference hole (found while fixing issue #2057) --
src/deployment/deploy-engine.ts,docs/cross-stack-references.md. Only the SUCCESS path persistedrecordedImports/recordedOutputReads; every other save wrote the PRE-deployimports[]/outputReads[]snapshot beside POST-deploy resource records, with a comment saying so deliberately -- the diff-clean no-change save, the per-resource partial save, the pre-rollback save, the TWO post-rollback saves (primary and ETag retry) andpersistStateAfterOutputFailure, which looks like a success save because provisioning WAS clean, yet writes a rollback journal segment and rethrows. Two consequences, and the second is independent of #2057 and was onmain: (1) the region evidence #2057's refusal needs is absent on exactly the deploy that introduces a cross-region read, so the refusal could not fire where it mattered -- a green fixture coexisted with an inert fix until a review round caught it; (2) a consumer deploy that ADDED anFn::ImportValueand then failed recorded the consumer's new resources with NO import, sofindActiveImportConsumers(destroy-runner.ts, the pre-flight that refuses to destroy a producer while a consumer imports from it) found nothing andcdkd destroy <producer>proceeded against a live importing consumer. Unlike the documented v3-to-v4 gradual-activation gap this never healed on its own, because nothing re-recorded the import until the consumer's next SUCCESSFUL deploy.crossStackReadsForPartialSave/unionCrossStackReadsnow union this session's reads at every save except the terminal success one. The count in this entry was wrong twice while the lane was open (an "ALL FIVE" that missedpersistStateAfterOutputFailure, and a "three post-rollback saves" that is two), so the enumeration is no longer maintained in prose:tests/unit/deployment/deploy-engine-cross-stack-read-writers.test.tsscansdeploy-engine.tsfor directimports:/outputReads:writes and fails on any that is not the allow-listed success-path one, with a positive control proving the scan sees a violation. A union never drops, so a stack that stops reading cross-stack keeps a stale entry until its next successful deploy -- an over-refusal that names the consumer and is overridable, chosen over the direction that deletes a producer out from under a live consumer. Covered bytests/unit/deployment/deploy-engine-partial-save-cross-stack-reads.test.ts, and by the new arm B oftests/integration/rollback-cross-region-secret-- one deploy that BOTH introduces the read and fails, which is the shape arm A (a successful deploy establishing the read first) structurally cannot reach. cdkd bootstrap/gc/bootstrap --destroynow resolve their region from your AWS profile, and hold existing storage rather than stranding it (issues #2029 / #1820) — new resolver + reconciliation insrc/cli/region-options.ts, wired throughsrc/cli/commands/{bootstrap,gc,bootstrap-destroy}.ts. This removes an incoherence rather than adding a preference, and the incoherence was MEASURED rather than read. cdkd already consulted the profile: every command builds its pre-flight bag asnew AwsClients({ ...(options.region && { region }) }), so with no flag the bag is region-less and the SDK resolves the profile. Only the region VALUE fell back to theus-east-1literal — and that value keys the bootstrap marker, keys the state file and answersAWS::Regionin every user template. Withregion = ap-northeast-1in the profile, no--regionand noAWS_REGION, on an env-agnostic stack:State bucket '...' is in 'us-east-1' (client was 'ap-northeast-1'); rebuilding S3 client./Getting state for stack: CdkdBasicExample (us-east-1)/Resolved Ref to pseudo parameter: AWS::Region -> us-east-1. One command, two regions — the pre-flight talked to the profile's region while the resources, their ARNs and their state landed in us-east-1. Not "cdkd ignores your profile", which would at least be consistent; it half-honoured it. Resolution is now--region->AWS_REGION->AWS_DEFAULT_REGION-> the SDK's own chain (the profile) ->us-east-1.AWS_DEFAULT_REGIONis read even though the JS SDK itself does NOT (measured: with only that variable set,STSClient({}).config.region()returns the PROFILE's region) — the AWS CLI honours it, so reading it is what stops cdkd disagreeing with a CLI command the user just ran.resolveEffectiveRegionreturns the SOURCE alongside the region, and that distinction is the entire safety mechanism: only an INFERRED region is subject to the reconciliation. The reconciliation is why this is shippable at all. Changing what a bare command targets moves the key its storage lives under, so an inferred region yields to an existing opt-in under the old default and SAYS so (cdkd asset storage exists in us-east-1, but your AWS profile resolves eu-west-1. Continuing to use us-east-1 so the existing storage is not orphaned. Pass '--region eu-west-1' ...), a NAMED region is obeyed as given, and a first bootstrap goes to the profile's region because nothing exists to strand. A probe that THROWS counts as "no marker" — on a first bootstrap the state bucket does not exist yet — which is also the safe direction, since it can only send the command toward the profile's region and never away from storage that exists. All three commands go through ONE function, which is the point: a previous attempt at #2029 (in the PR that shipped the #2065 fold) moved the READ side alone and had to be reverted, becausecdkd bootstrapwrites the keygcandbootstrap --destroyread — for a user with a non-us-east-1 profile that made gc report "not opted in" and the teardown "nothing to delete" while the asset bucket and ECR repo stayed alive and billing, the failure directionbootstrap-destroy.ts's own header calls the worse one. The pairing is now structural rather than three call sites agreeing. The sequencing each site records: the reconciliation must ask "does a marker exist under the old default?", which needs the state backend, which needs the bucket, which needs the account id — so the account lookup runs on a bag built from the UNRECONCILED region, which is safe becauseGetCallerIdentityis region-agnostic and the state backend re-resolves the account-scoped state bucket's own region itself. Only genuinely region-specific clients (ECR, the asset-S3 baglistS3Candidates/deleteS3Candidatesuse) are built afterwards. Live-verified against real AWS, both polarities, non-destructively: with profileap-northeast-1and a marker there,cdkd gc --dry-runcollected in ap-northeast-1 and found 20.6 MiB of garbage that the pre-fix binary would have left untouched while looking at us-east-1; with profileeu-west-1and no marker there, it printed the hold message and operated on us-east-1. Tests:tests/unit/cli/region-effective.test.ts(13 cases on the resolver + both reconciliations, every one mutation-probed — removing the profile step or the hold reds them), plus new per-command cases. One defect in this very change was caught by probing rather than by the green suite:gc-region-resolution.test.tsandbootstrap-destroy.test.tsdid not mock@aws-sdk/client-sts, which the resolver builds to ask the SDK chain what the profile resolves — so they read the DEVELOPER'S real~/.aws/config, green on a us-east-1 machine and red on any other. Found by re-running them underAWS_CONFIG_FILE=<a config with region = ap-northeast-1>, which failed 4 cases; both now mock it and all 131 cases across the four suites pass under either config. The state-key family (deployand friends) deliberately still uses the literal — same migration question, its own integ, tracked as issue #2100.--region US-EAST-1no longer breaks every non-localcommand, andcdkd gcstops pinning aus-east-1literal over the profile region (issues #2065 / #2029) — newsrc/cli/region-options.ts, 18 files undersrc/cli/commands/,src/utils/aws-clients.ts. Issue #1795 folded--regionat the boundary of the fourcdkd local *commands for a reason that was never specific to them: SDK endpoint resolution, SigV4's credential scope, this repo's ownPARTITION_TABLEprefix walk and every ARN segment built from the value are all case-SENSITIVE. The commands it skipped kept the raw spelling, and #2065 measured what that cost —cdkd deploy --region US-EAST-1died at the state-bucket preflight before doing anything (AuthorizationHeaderMalformed: the region 'US-EAST-1' is wrong; expecting 'us-east-1'), which is also why the #2021 lane recorded its own deploy-path harm narrative as UNREACHABLE. The defect was one SHAPE, not one site:options.region || process.env['AWS_REGION'] || 'us-east-1'appeared 23 times undersrc/cli/, 19 of them unfolded. All 19 now route throughfoldRegionOption(options)at the handler entry (aboveapplyRoleArnIfSet, because STS rejects a non-canonical region too —SignatureDoesNotMatch: Credential should be scoped to a valid region) plusnamedCliRegion(...)at the read. The fold covers the two ENV vars as well as the flag, and that half is the one that matters most: a handler buildingnew AwsClients({})with no region hands resolution to the SDK's own chain, which readsAWS_REGION/AWS_DEFAULT_REGIONDIRECTLY — no fold at a cdkd read site can reach that read, soAWS_REGION=US-EAST-1 cdkd deploywas broken independently of the flag. It also canonicalizes the ~50providerRegion = process.env['AWS_REGION']captures acrosssrc/provisioning/providers/**at one point, which is the layer issue #1881 argued for.AwsClients' constructor folds its configured region as a last line of defence, soclient.config.region()is canonical for every CONFIGURED bag including a LIBRARY caller that never runs a CLI handler. The raw spelling is deliberately preserved where one consumer still needs it: the bootstrap marker's second probe exists to find a key an unfoldedcdkd bootstrapwrote (issue #1820), sodeploy.ts/publish-assets.tscapturerawCliRegion(...)on the line ABOVE the fold and hand only that toAssetModeResolver.resolve;gc.tsandbootstrap-destroy.tskeep therawRegion/regionpair they already had. #2029 is the second half, and what it turned out to BE is worth recording, because a first cut got it wrong. gc resolved the literal into a value and passed it tonew AwsClients({ region })unconditionally, so a user who named no region at all — no--region, noAWS_REGION, but a configured~/.aws/configregion — gotus-east-1pinned OVER their profile, silently in both directions because us-east-1 is a valid region: gc evaluated (and could DELETE in) a region they never mentioned while reporting success about the one they work in. The reported symptom is aus-east-1literal winning over the profile region; the DEFECT underneath it is that the literal was materialised into a value while the CLIENTS were built from a different resolution, so the two could disagree.cdkd gcandcdkd bootstrap --destroynow derive both from ONE value —aws-clients.tsis explicit that a region-less bag's lazy members need not agree with each other, so reusing an unconfigured bag would have reintroduced the divergence somewhere subtler. Its region is ALSO a value (it keys the marker and names the asset bucket / ECR repo), so client region and marker key are one value by construction; fixing only the client half would have read one region's marker and deleted against another region's endpoints, which is worse than the bug. The literal itself is deliberately NOT swapped for the profile region, and a first cut of this change did exactly that before round 2 caught it.cdkd bootstrapWRITES the marker under the same?? 'us-east-1'default (issue #1820), so a read side resolving the profile instead stops finding the marker its own create side wrote: for a user with a non-us-east-1 profile and no flags,cdkd gcwould report "not opted in" andcdkd bootstrap --destroywould report "nothing to delete" — while the asset bucket and the ECR repo stayed alive and billing, which is the worse failure direction on both commands and the exact onebootstrap-destroy.ts's own header warns about. The read side cannot move until the write side moves with it. So #2029's reported SYMPTOM stays open, tracked with its mechanism in #2100, while the divergence that caused it is fixed here. gc's delete plan (and its--dry-runplan) now also NAME the region, which was inferrable only by accident before — the default storage name embeds the region, but a bootstrap run with--asset-bucketprints a custom name with no region anywhere, so-ydeleted in a region the user was never shown. The same literal-vs-profile change is deliberately NOT made anywhere else: fordeployand friends theregionvalue keys the state file, so moving it would strand existing state at the old key — that is issue #1820's migration question, not a bug fix, and issue #2100 carries the remainder with its site list re-derived by grep in both directions (the 18 sites that carry the defect, AND the sites where the literal is CORRECT and must not be "fixed":aws-region-resolver.ts's decoding of S3's emptyLocationConstraint, and the global IAM client's signing region).
Three findings from this lane's own review rounds are worth recording, because none was visible to any test that existed. cdkd bootstrap --destroy is dispatched from a DIFFERENT handler than cdkd bootstrap, so the first cut's fold — placed in bootstrapCommand — never ran for it at all; it carried BOTH defects, and it deletes. And cdkd bootstrap's own region is now deliberately the one VALUE this change leaves raw: it feeds ensureAssetStorage, whose existing-marker READ is paired with its marker WRITE, and folding it silently moved the write key so a user holding a raw-key marker would stop having their recorded custom asset names reused. And the FIX for that second one over-applied: it assigned the raw spelling to bootstrap.ts's region wholesale, which reaches the CreateBucket guard at region !== 'us-east-1' — re-creating issue #1888's defect inside cdkd bootstrap, where an upper-cased region would send a LocationConstraint for the one region S3 forbids it in — plus three SDK clients that would then sign for a region SigV4 rejects. The raw spelling is now scoped to the single ensureAssetStorage({ region }) argument that needs it. All three are pinned by tests; the second is why every claim that cdkd bootstrap still writes a verbatim key remains TRUE after this PR, and the third is why round 2 exists at all — it is a defect that did not exist when round 1 ran. One residual is PINNED rather than fixed: cdkd local start-api folds the flag but not the env half, so an upper-cased AWS_REGION still reaches every Lambda container it starts (issue #2103, found BY the scanner below). src/cli/commands/local-*.ts sits behind the integ-local merge gate, so folding it here would pull a real-Docker run onto a PR in a different command family. The scanner carries it in a KNOWN-violations list with an exact count, so the count can only SHRINK, a new violation in that file fails, and fixing #2103 fails the test until the entry is deleted — the opposite of a path allow-list, which goes inert silently.
Tests: tests/unit/cli/cli-region-fold.test.ts (130 cases — helper polarities, plus a SCANNER over every file in src/cli/commands/ that fails on any unfolded occurrence of the shape, exempting canonicalizeRegion(...)-wrapped and raw*-bound spellings BY SHAPE rather than by a path allow-list, with a floor so "found nothing" cannot pass as "all clean"; calibrated against the pre-fix tree, where it reports exactly 19 violations and no false positives; plus an ORDER fence that the raw capture precedes the fold precedes the first AWS call, since swapping those two lines fails nothing and silently collapses the marker's second probe onto its first), tests/unit/cli/gc-region-resolution.test.ts (8 cases), tests/unit/utils/aws-clients-region-fold.test.ts (7 cases, including one asserting the fold reaches the CONSTRUCTED client rather than only the reported config). Every case mutation-probed. The last of those files also fences a constraint that had never been written down and that this lane tripped over: src/utils/aws-clients.ts is imported as '../src/utils/aws-clients.ts' by scripts/audit-provider-coverage.ts, which runs under node with native type stripping and resolves relative specifiers LITERALLY — so the obvious import { canonicalizeRegion } from './aws-partition.js' turned 32 gen-nested-key-coverage cases red, several files from its cause. That file therefore inlines the one-line fold and carries a test pinning it byte-equivalent to canonicalizeRegion plus a test that the file gains no relative import at all.
- The substring redaction becomes ONE rule over the whole leaf, so a recorded secret occurring inside a
{{resolve:...}}token stops being spliced into it, and the outputs walk stops claiming its bag is this generation's (issues #1935 / #1943) -src/deployment/secret-redaction.ts,src/deployment/deploy-engine.ts. #1935:redactSecretsForState's sourceless walk guarded a leaf that IS a complete{{resolve:...}}token, but a MIXED leaf holding a token inside a larger string fell through to a blanketvalue.replace(regex, ...). So a plaintext that happens to occur inside the TOKEN'S OWN TEXT was spliced into the reference: deploy 1 persistsjdbc://appdb:{{resolve:secretsmanager:appdb/creds:SecretString:password}}@host, deploy 2 records an ssm SecureString whose plaintext isappdb, and the walk writes{{resolve:secretsmanager:{{resolve:ssm:/app/dbname}}/creds:...}}. The rollback executor'sresolveReplayPropsscans with([^}]+), which stops at the first}, so the replay asks Secrets Manager for the secret id{{resolve:ssm:/app/dbname- rollback blocked, or garbage applied to a live resource;cdkd scrubwrites the same wreckage intopropertiesandobservedProperties. Both obvious fixes are wrong, and the second one shipped in this lane's first round before the security review caught it - the correction is the interesting part. "Mask only OUTSIDE the spans" stops redacting a secret whose resolved PLAINTEXT is itself a token (the #1917 shape embedded in a larger leaf), trading a mangling bug for a disclosure. Adding "...unless the span IS a recorded plaintext" fixes that and still loses a plaintext that STRADDLES a span boundary or CONTAINS a whole span - it belongs to neither half, so it was persisted IN THE CLEAR where the pre-fix code had redacted it. Measured on both sides with one probe: for a recorded plaintext ofpw{{resolve:ssm:/a/b}}tail, the pre-fix code produced the expression and the two-rule form producedjdbc://user:pw{{resolve:ssm:/a/b}}tail@host. If a secret's value can BE a reference (#1917), it can CONTAIN one. The shipped form is therefore ONE predicate over the whole leaf - replace every recorded-plaintext match EXCEPT one lying STRICTLY INSIDE a complete{{resolve:...}}span (contained by it and shorter than it) - so a match that is coextensive with a span, contains one, straddles one, or is disjoint from all of them is replaced. The single pass also preserves needle PRECEDENCE:buildNeedleRegexsorts alternatives longest-first, which only holds WITHIN one scan, so the split form let a short needle in the tail beat a long straddling one and write the WRONG expression (the #1910 class, re-applied by the replay). Spans come from the existingDYNAMIC_REFERENCE_TOKEN_SCANrather than a new token grammar (issue #1936 keeps one spelling), read withmatchAllafter an explicitlastIndexreset -matchAlldoes not MUTATE the shared constant but it SEEDS its clone fromlastIndex, so a dirty value would silently skip leading spans and restore the splice. It lands in the SHARED arm, so all fivePathSourceRulesconstants and every sourceless walk - the journal'spreviousState,attributes, andredactByPath's four fallbacks (a key the source lacks / a diverged shape / an unpaired element / a public-reference leaf, one case each) - inherit it from one place. Two residuals are pinned by tests rather than left as prose: an UNTERMINATED{{resolve:opener forms no span, so a needle after it is still replaced and the result reads as a reference to a bogus secret id (identical to the pre-fix behavior - treating a bare opener as protected would leave PLAINTEXT behind two characters any string can contain); and a value ALREADY mangled by an older cdkd parses as a valid span now, so it is neither made worse nor repaired, andcdkd scrubcannot repair it either - affected records need the resource redeployed or the key edited out of state. The whole-token early-out is kept as a cheap early-out for the re-scrub-of-clean-state path and is now an EQUIVALENT mutant, which the tests say out loud. Two rationale comments that the fix made false are corrected in the same pass (PathSourceRules' whole-value note andredactByPath's, plus the twin sentence in.claude/rules/code-layout.md): the scan no longer splices, so the whole-value fallback and the full scan now agree for that shape by two independent mechanisms rather than one. #1943 item 1:DeployEngine.redactOutputspassed the defaultTEMPLATE_DERIVED_RULES, whosedescendArrays: trueasserts the bag was PRODUCED by resolving the source. Two of its three call sites cannot say that -redactStateForPersistredacts whateverstate.outputsholds, and the no-change path persistspersistedOutputs, the PREVIOUS deploy's bag, whenever a resolution failure keeps today's from landing - and the consequence is not merely a missed redaction:redactByPathreturns a known-secret SOURCE leaf VERBATIM, so a carried literal at indexiis rewritten to today's expression at indexi, a value the stack never held, persisted intostate.outputsand re-applied to consumers by the exports index. It now passesTEMPLATE_SOURCED_RULES. Reachable rather than theoretical, narrowly:TemplateOutput.Valueisunknownand cdkd does not enforce CloudFormation's string-valued-output rule, so a list-valued output (an escape hatch, an imported template) puts an array on BOTH sides, andStackState.outputsis explicitly not string-coerced. The issue's premise was stale and is corrected here: it saidcdkd scrubhad already moved its outputs call onto a TEMPLATE_SOURCED constant. It had not - PR #1944 moved scrub'spropertiescall and deliberately kept the default for outputs, on a measurement ("the array arm is never reached") that rests on CloudFormation's rule rather than on what cdkd enforces. Converging the scrub twin is issue #2099; until it lands the generation table carries the two outputs writers as SEPARATE rows, because one row could only be wrong about one of them. #1943 item 2 needed no code: PR #2025 already moved all three walk accumulators toObject.create(null), verified by reading them. What was missing is a per-walk fence at this module's own level - the value scan had one, the path pass and the readback refusal were covered only jointly and only through the CLI - sosecret-redaction-proto-key-walks.test.tsadds one case per walk, each probed by mutating that accumulator alone. Tests: 27 new cases across three files (secret-redaction-mixed-leaf-spans.test.ts,secret-redaction-proto-key-walks.test.ts,deploy-engine-outputs-generation-rules.test.ts), one case per POSITION a match can take plus the precedence case and both residuals, every one mutation-probed: restoring the blanket replace reds 13, collapsing to the two-rule form reds the 5 cases that form got wrong (both #1917 counter-cases plus straddle / contains / precedence), dropping the coextensive exception reds the 2 #1917 cases, seeding the shared scan'slastIndexreds the leading-span case, and reverting the constant swap reds the engine case with the fabricated:AWSCURRENTexpression it would persist. The per-constant matrix is reported at the strength it has and no more - test review measured therulesargument to be INERT on the source-lacks-key path, so those five rows pin "no constant BYPASSES the shared walk", not per-constant behavior. - A rollback replay now resolves a secret reference in the region it NAMES, refuses a region-ambiguous one, and a failed deploy stops erasing the cross-stack reads it just made (issue #2057) —
src/deployment/rollback-executor.ts,src/deployment/deploy-engine.ts,src/cli/commands/rollback.ts. Behaviour change: a rollback that used to succeed while writing the WRONG secret to a live resource now either resolves the right one or fails loudly. Since issue #1934 a cross-stack consumer re-resolves a redacted producer value in the PRODUCER's region and records the PRODUCER's spelling of the expression into its ownstate.json— the right thing to persist, and region-less.replayRollbackrebuilt its resolver fromctx.regionalone, soresolveReplayPropsre-resolved that reference against the CONSUMER's region and handed a provider whatever a same-named secret holds there; a Secrets Manager secret (or an SSMSecureString) of the same NAME in two regions is two independent values, so the replay applied a different credential than the deploy did — silently, on the recovery path, to a resource that is live.classifyReplaySecretRegion(new, exported) now returns one of three verdicts per reference, which is issue #1957's "a named region binds; never substitute a guess" spelled out:named-region— the SECRET_ID / parameter name is an ARN naming another region, so a resolver pinned to THAT region answers (ReplayResolvers.forRegion, cached per region); cdkd would otherwise send it to the stack's own endpoint, sinceresolveSecretsManagerReference/resolveSSMReferencebuild their client fromthis.explicitRegionand pass the id through opaquely.ambiguous— the reference names no region AND the stack has a foreign producer region on record, so the replay throwsROLLBACK_SECRET_REGION_AMBIGUOUSBEFORE any lookup, naming the logical id, the property path, the secret, the consumer region, the producer region(s) and the remedy.local— everything else, resolved exactly as before: every service cdkd cannot resolve at all, every same-region ARN, and every region-less reference in a stack with no foreign producer region. A leaf mixing a local reference with a foreign-ARN one is rebuilt segment by segment so each is resolved by its own region; a leaf with no foreign reference still goes toresolveDynamicReferencesWHOLE, so that method's substitution semantics (including the #1917 token-shaped-plaintext guard) are untouched. The evidence half is where the protection was nearly inert, and fixing it also fixes a pre-existing bug of its own. The foreign-region evidence is the newRollbackExecutorContext.importedProducerRegions, derived by the new exportedproducerRegionsFromState(state)fromStackState.imports[].sourceRegion+outputReads[].sourceRegion— but a rollback journal exists ONLY after a failed deploy, and all five non-success saves indeploy-engine.tspersistedcurrentState.imports/currentState.outputReads, the PRE-deploy snapshot, beside the POST-deploynewResources. So the deploy that INTRODUCES a cross-region read never recorded it, the list came back empty, and the verdict waslocal. Those saves now persistcrossStackReadsForPartialSave— the union of the snapshot with this session'srecordedImports/recordedOutputReads, deduplicated on a canonicalized region — andDeployEngine.rollbackExecutorContext(previousState)passes the same union, so the in-process automatic rollback has strictly more evidence thancdkd rollbackcan derive. Independently of #2057, that same gap silently downgraded a fresh strong reference to no reference:findActiveImportConsumers(src/cli/commands/destroy-runner.ts) scans consumers' persistedimports[]to refuse destroying a producer while a consumer imports from it, so after a failed consumer deploy the consumer's resource was recorded live while its import was not, andcdkd destroyon the producer sailed through the pre-flight;findDownstreamConsumersunder-reportedoutputReads[]the same way. The union never drops a record, so a stack that STOPS reading across a region keeps the stale entry until its next SUCCESSFUL deploy replaces the list wholesale — a deliberate fail-closed residual, recorded in the helper's own doc. Two review-round corrections worth naming because both rested on a false premise: anssmdynamic reference CAN name a full ARN (resolveSSMReferencerebuilds the name asparts.slice(1).join(':')), so the classifier mirrors that split instead of takingsplit(':')[1], which used to yield the literal'arn'and refuse a reference that names its own region; and the segment-rebuild's "token not found" guard now THROWS (ROLLBACK_SECRET_TOKEN_SCAN_MISMATCH) rather than falling back to the stack's own resolver, which would have reintroduced the defect inside the guard against it. Tests:tests/unit/deployment/rollback-executor-cross-region-secret.test.ts(fakes the leaf SDK client so the CONSTRUCTOR region of the client that answered is the assertion target, and primes DIFFERENT values in two regions so "which region answered" is observable at all),tests/unit/deployment/deploy-engine-partial-save-cross-stack-reads.test.ts, and the real-AWS fixturetests/integration/rollback-cross-region-secret— whose second rollback phase introduces the cross-region read in the FAILING deploy itself, the arm that would have caught the inert-evidence gap. - BREAKING: a custom resource whose delete handler REPORTS
FAILEDis no longer recorded as deleted (issue #2054) —src/provisioning/providers/custom-resource-provider.ts. The terminalStatus === 'FAILED'arm ofCustomResourceProvider.deletewarned and fell through toreturn undefined, whichdeleteSkipReasonreads as DELETED: the state record was dropped, the row printed asdeleted, andcdkd destroyexited 0 over a resource whose handler had EXPLICITLY said it did not delete it — with the physical id needed to reach it discarded in the same breath. It is the silent-orphan class issue #2033 removed from the sibling THROW arm and issue #1752 removed from the two guard arms above it, reached through the handler's RESPONSE instead. The arm now returns{ outcome: 'skipped', reason: CR_DELETE_HANDLER_FAILED_SKIP_REASON }, so the record is KEPT, the row prints asskipped (...)and the run exits 2. UNCONDITIONAL, with no already-gone classifier: the handler'sReasonis free text a user writes, so any classifier is a guess and a wrong guess re-introduces the orphan — the maintainer's explicit call between the issue's three options. The reason is a FIXED constant and the handler's own text goes out on thelogger.warnbeside it, because areasonis rendered into theErrorthe deploy-side replacement sites throw and their catch classifies "already deleted" by SUBSTRING. Compatibility break: a destroy whose delete handler reports FAILED now exits 2 where it used to exit 0. The two callers have DIFFERENT escape hatches and only one is a flag —cdkd deployaccepts--allow-unaddressed(issue #1960), whilecdkd destroyhas none and raisesPartialFailureErrorunconditionally, so the remedy there is the one its own summary names: confirm the resource is gone, then drop the record withcdkd state orphan <stack>. Tests:tests/unit/provisioning/custom-resource-delete-failed-response.test.ts(the skip is asserted THROUGHdeleteSkipReason, i.e. as a statement about the record, with the SUCCESS polarity and the already-gone-reason case both pinned). - The custom-resource synthetic
StackIdnow carries the real account, region and partition (issue #1866) —src/provisioning/providers/custom-resource-provider.ts. Every handler receivedarn:aws:cloudformation:us-east-1:000000000000:stack/cdkd-<logicalId>/cdkdregardless of where the deploy ran, so a CloudFormation-authored handler readingevent.StackIdto re-derive its account / region, to build an ARN, or to name a log stream got a coherent-looking ARN addressing nothing.syntheticStackIdnow takes the wholeAwsAccountInfo— deriving ONE segment in isolation is worse than deriving none, which is why issue #1815 deliberately left the hardcodedarn:aws:prefix alone — andCustomResourceProvider.resolveSyntheticStackIdresolves it throughgetAccountInfo(), keyed on the region the provider's own client bag was pinned to (captured in the constructor beside the clients, so--stack-concurrency's process-global swap cannot hand a sibling stack's region). When STS cannot answer, the account falls back to the ALL-ZERO placeholder rather thangetAccountInfo's own123456789012:StackIdis a REQUIRED member so omission — the Cloud Control enrichment answer — is unavailable, and between two wrong strings the honest one is the one a handler cannot mistake for a live account; a warning names it. The resolution deliberately runs AFTER the invocation's SIGINT watch is installed, so the addedawaitnever leaves Ctrl-C dead during the 47.75s pre-delivery backoff. ConsumesgetAccountInfo, so it inherits that function's own known defect (issue #1730) for the fabricated case. Tests:tests/unit/provisioning/custom-resource-synthetic-stackid.test.ts(per-segment, plus the create / update / delete parity and the watch-ordering fence). - A THROWN delete during an SNS subscription replacement now ABORTS instead of creating anyway (issue #1967) —
src/provisioning/providers/sns-subscription-provider.ts.SNSSubscriptionProvider.updatereplaces DELETE-first and its SKIP arm already aborted; thecatcharound that delete only warned and fell through tocreate(), so a two-arm failure had a one-arm guard. One rule now covers both: cdkd creates the replacement only when the old subscription is PROVEN gone. The refusal ismarkNonRetryable-marked and keeps the AWS text out of its message, matching the skip arm. What this does NOT fix, measured against real SNS and re-verified in review — the issue's premise was wrong. SNS enforces uniqueness on (topic, protocol, endpoint): a repeatedSubscribewith identical attributes returns the SAMESubscriptionArnand leaves ONE subscription, and with DIFFERENT attributes it is refused (InvalidParameter ... Subscription already exists with different attributes). Those three fields are exactly thecreateOnlyPropertiesofAWS::SNS::Subscription, and a createOnly change routes to the deploy engine's OWN replacement branch — the engine's onlyprovider.update()call site is in theelseof thatif— so anything reaching this method leaves all three identical and the "two live subscriptions delivering every message twice" outcome cannot occur here. So this is not a compatibility break: the pre-fix path already failed, just with SNS's confusingalready exists with different attributesraised by the create. The delta is error QUALITY — an accurate, actionable, non-retryable refusal that names the failed DELETE (the thing the user must act on) instead of a downstreamSubscriberejection — plus the case where the duplicate IS reachable:cloudformation:DescribeTypebeing unavailable degrades an endpoint change into an in-place update (create-only-properties.tswarns about exactly this), and there the endpoint differs and two subscriptions are possible. Tests:tests/unit/provisioning/sns-subscription-thrown-delete.test.ts(the discriminator is thatSubscribewas NOT called, with the successful-delete polarity pinned beside it);tests/unit/provisioning/replace-path-delete-skip-outcome.test.ts's throw-arm case is INVERTED from #1778, which had asserted the defect. Integ:tests/integration/sns-subscription-update/— the first real-AWS coverage ofSNSSubscriptionProvider.update()at all. - BREAKING: a retried 500 no longer duplicates a CloudFront distribution — it fails loudly and names the orphan (issue #2079) —
src/provisioning/providers/cloudfront-distribution-provider.ts.CallerReferenceIS CloudFront's idempotency key and was minted afresh insidecreate()fromDate.now()+Math.random()— the worst shape of all, because the call LOOKS idempotent while behaving exactly as if it had none. The deploy engine wrapscreate()in its outer transient-error retry and issue #2026 made HTTP 500 / 502 / 504 retryable, so a 500 whoseCreateDistributionhad actually SUCCEEDED server-side re-invokedcreate(), minted a fresh reference, and CloudFront created a SECOND distribution: no state record, invisible tocdkd destroy, billing indefinitely. The reference now comes fromacquireIdempotencyToken(issue #2039), stable across every attempt of one logical create and released — generation-bumped — only once the distribution is settled, so a--replacere-create is not handed the reference of the resource it just tore down. Unlike the Route 53 site it has NO adopt path, and that is measured rather than assumed:DistributionSummarycarriesCommentbut NOTCallerReference, andGetDistributionneeds theIdthe lost response was carrying. So the replay is refused withDistributionAlreadyExistsand cdkd turns that into a message naming the orphan and how to find it —aws cloudfront list-distributions, matched on the origin domain and comment, the two fields that ARE listed. Writing a cdkd marker intoCommentto make it searchable was considered and REJECTED (Commentis capped at 128 characters, so a marker can break a create that works today, andmergeUpdateConfigdocumentsCommentas fully template-owned). Compatibility break: loud-and-orphaned replaces silent-and-orphaned — the orphan existed either way, and only now does the user learn about it. Tests:tests/unit/provisioning/cloudfront-caller-reference.test.ts(identity across two attempts of one create, difference after a release, and the orphan message's contents). - Every
withRetryundersrc/provisioning/**now honours Ctrl-C, and a mechanical critic keeps it that way (issues #2053 / #1952) —src/provisioning/providers/{dynamodb-globaltable,elbv2,servicediscovery}-provider.ts,src/provisioning/dynamodb-index-busy-delete.ts,src/provisioning/interrupt-watch.ts(new),src/deployment/deploy-engine.ts,src/cli/commands/destroy-runner.ts,src/utils/interrupt-signals.ts,scripts/check-withretry-interrupt.ts.docs/provider-development.mdstates as MANDATORY that a newwithRetrythreadisInterrupted/onInterrupted, and it is structural rather than stylistic:withRetryis the only wait in cdkd that consults an interrupt DURING a backoff (it probes once a second while sleeping), while the deploy engine,destroy-runner.tsandrollback-executor.tsall poll only BETWEEN operations. Measured when #2053 was filed: 11 call sites underproviders/**andisInterruptedin exactly ONE of them — the site issue #2033 had just added. A convention one site in eleven honours is not a convention; it teaches the next author the wrong pattern. All ten are threaded now (4 inapplyAutoScalingDiff, 2 ELBv2ModifyListenerAttributes, 4 ServiceDiscovery —DeleteNamespaceplus three attribute calls), plus the twelfth site indynamodb-index-busy-delete.ts, which is #1952: the index-busyDeleteTableloop kept re-issuing deletes for the better part of twenty minutes after a Ctrl-C, on the DESTROY path wheredestroy-runner.ts'swithResourceTimeouthad ALREADY abandoned the promise — so the work continued detached behind a run the user was told had ended. #1952's stated blocker did not survive contact with #2033's fix and no interface changed. The issue said providers cannot see interruption because there is noisInterruptedon theResourceProviderdelete contract; #2033 answered that by watching the PROCESS SIGINT signal — the same source the engine's own handler reads — through a watch created per WAIT, never onthis. That distinction is load-bearing and is why a flag on the provider would have been wrong: providers are registered as SINGLETONS serving concurrent resources, so provider-level state is some other resource's. The watch now lives in ONE module,src/provisioning/interrupt-watch.ts(issue #2104, filed and closed inside this change). It started as four module-local copies, and review found that shape was not merely duplicative but WRONG in two ways that only consolidation could fix, because a singledelete()traverses more than one module. (1) One error type.deploy-engine.tsdecides whether to ROLL BACK by asking what the failure was, and itsInterruptedErroris module-private — no provider can produce one. The first cut'sonInterruptedreturned a bareError, providers wrapped it in aProvisioningError, and the engine took the ROLLBACK branch: Ctrl-C during a deploy triggered an automatic rollback of the whole stack, where the same Ctrl-C previously exited with "partial state saved". On the create paths it was worse still — the interrupt landed in the partial-create cleanup arms, which issueDeleteListener/DeleteService, so Ctrl-C meant "delete what you just made, then roll back".onInterruptednow returns an exportedInterruptedWaitError; the engine matches it throughisInterruptedWaitError, acausewalk bounded at depth 5 (every provider catch re-wraps, so a plaininstanceofwould have been a placebo); and both cleanup arms re-throw ahead of their delete. The same bare-Errorshape shipped in PR #2033's own site and is fixed here too, so this closes that residual rather than leaving the original unfixed while fixing its ten copies. The rollback gate is the load-bearing half and its classifier walks thecausechain with avisitedset and NO depth ceiling: a ceiling has to be sized against the deepest real chain, and that chain GROWS — flat is 2,DagExecutoradds none (it collects rather than wraps), butdeploy-engine.tsadds oneProvisioningErrorPER NESTED-STACK LEVEL, so a depth cap of 5 missed at four levels of nesting and reinstated the automatic rollback there. (1b) The partial-create cleanup arms still clean up. The first cut made them re-throw ahead of theirDeleteListener/DeleteService, on the reading that a Ctrl-C must not delete what the user just made. That reading assumes the resource is TRACKED and it is not:create()is throwing, sonewResources[logicalId]is never set, the journal recordsphysicalId: undefined, and the rollback executor classifies itskip-failed-unknown. Nothing holds the physical id, so the real choice is delete vs orphan forever — a preserved listener fails every later deploy withDuplicateListenerwhilecdkd rollbackskips it andcdkd destroyhas no record of it, and an orphaned Cloud Map service additionally blocks its namespace's deletion withResourceInUse, all while the engine prints "run deploy again to resume,cdkd rollbackto revert, or destroy to clean up". Both arms clean up, and each now prints the physical id and a manual delete command BEFORE attempting it, so a process killed mid-cleanup still leaves a handle. (1c) An interrupt is no longer read as "already deleted".destroy-runner.tsanddeploy-engine.tsboth decide a failed delete means the resource is gone by SUBSTRING-matching the message, and an interrupt's message embeds a name the USER chose — so a logical id likeHandleNotFoundExceptionmade an interrupted delete report success and DROP a live resource's state row. Both now checkisInterruptedWaitErrorahead of the match, mirroring theisFinalSnapshotErrorguard already there for the same class of mistake. (2) One STICKY latch. A watch started after the signal must already be interrupted. The first cut cleared the latch when the last watch was disposed, which SEQUENTIAL waits always do: aGlobalTabledelete runs the #1521 gate, then the index-busy loop (~18 min), then the gone-wait (~12 min), each disposing before the next begins — so a Ctrl-C during the gate left both later waits deaf, which is issue #1952's own scenario surviving its own fix. Only a COMMAND clears it now (forwardSigtermToSigint()), and the process listener is installed once and never removed for the same reason: one torn down between two waits cannot record a signal landing in the gap. (3) It arms only inside a command that OWNS interrupt handling. Any listener disables Node's default terminate, andcdkd drift --revertreachesprovider.updatewhile installing none of its own — so an armed watch would have stopped that command exiting on Ctrl-C and left it writing to AWS. Gating onprocess.listenerCount('SIGINT') > 0was the first cut and is defeated by the very case it targets:driftrunsprovider.updateat concurrency 4, and a concurrent CloudFront / ACM / Route53 wait installs a TRANSIENT SIGINT listener, so a wait starting inside that window saw a non-zero count and armed permanently. The gate is now an explicit scope opened byforwardSigtermToSigint(), whichdeploy/destroy/rollback/statecall anddriftdoes not. (4) The handler force-quits when it is the LAST SIGINT listener. A command scope is not the same as a live graceful path:destroy.tsregisters no SIGINT handler anddestroy-runner.tsremoves its own in afinally, so between two stacks of a multi-stack destroy the shared watch is the ONLY listener — and merely latching there SWALLOWED the Ctrl-C, leavingdrainingunset,result.interruptedfalse, and the loop free to delete the NEXT stack after the user asked to stop. Alone, the handler now does exactly what Node would have done with no listener at all — plus acdkd force-unlockhint it cannot verify but cannot afford to omit; a second graceful path was rejected because it would duplicatedestroy-runner.ts's drain and have to be kept in sync with it. (4b) ...andrunDestroyForStacknow releases the lock BEFORE unregistering its handler. Property 4 made a SECOND window reachable, in the runner's ownfinally, which removed its SIGINT handler first and released the lock last:destroy.ts/state.tsregister no handler of their own, so from that removal until the release resolved the command held the lock with no handler at all, the force-quit fired, and the release never ran — the lock stranded for its full 30-minute TTL, on every stack of a--allrun once any earlier stack had armed the watch. That is the issue #1348 class the file already claimed to have closed, and the same file states the correct rule verbatim for its strong-ref refusal path ("Release FIRST, remove the listener LAST"). Both paths now agree, the removal sits in its ownfinallyso a throwing release cannot leak the handler, andtests/unit/cli/destroy-runner-lock-release-ordering.test.tspins it by capturing which listeners are registered at the momentreleaseLockis entered — mutation-probed toexpected 0 to be 1under the old ordering. (4b-ii) ...and the reorder MOVED the windowresult.interruptedis read in.destroy-runner.tsassigns it once, inside thetry, after the level loop; keeping the handler armed across the renderer teardown, the state flush and the release put a whole class of first-Ctrl-C signals AFTER that read, sodrainingflipped too late and the flag stayed false.destroy.tsregisters no SIGINT handler of its own (deploy.tsdoes — which is why the deploy path has no equivalent hole) and reads exactly that flag, socdkd destroy --allwent on to delete the NEXT stack after the user asked it to stop. Pre-reorder the same signal hit the force-quit and exited 130: lock stranded, stack B untouched — so the reorder had traded a 30-minute TTL for a destroyed stack, the worse side. Fixed withresult.interrupted ||= drainingat the end of thefinally(thereturnis after it, so the caller sees the corrected value), and marked TACTICAL in the code: that flag being the ONLY channel to the--allloop is the real defect, filed as #2117 rather than fixed here, because this was round 5 and each of the previous four rounds' fixes had created the next round's blocker. The nestedfinallyalso now opens atrenderer.stop()rather than at the release, andstop()itself is caught: it writes to stdout, so an EPIPE on a closed pipe (cdkd destroy | head) previously skipped the release AND leaked the handler — measuredthrew=EPIPE releaseLock=0 leakedListeners=1. The enclosingfinallyprotects the listener; only the catch protects the lock. (4c) Command scopes NEST. The gate is a depth counter rather than a boolean: under a boolean an inner scope's close removed the shared listener and cleared the OUTER command's sticky latch mid-run, so a signal in the re-arm gap went unrecorded — property 2's own bug, through a door property 2 does not watch. Unreachable on today's one-command-per-process CLI, but the module offers itself to a host that runs more than one. One shared listener also keeps a--concurrency 10run under Node's ten-listener warning ceiling, which onlydestroy-runner.tsraises. #1952 also asked whether the other bounded waits on the delete path are owed the same treatment, "since interrupting only one of them buys little" — they are, and they got it:waitForIndexesSettled(which serves BOTH the #1521 pre-delete gate and the retry re-arm, on both DynamoDB types, so threading it needed no call-site change),waitForReplicaGoneandwaitForTableGone. The two gone-waits answer the signal DIFFERENTLY, mirroring the throw/warn split they already carried for budget exhaustion:waitForReplicaGoneTHROWS (it runs beforeDeleteTable, which AWS refuses while a replica lives, so nothing has been accepted),waitForTableGonewarns and RETURNS (AWS accepted the delete, so throwing would turn an accepted delete into a reported failure with state preserved), andwaitForIndexesSettledstops waiting without throwing, which is the contract every other give-up arm in it already keeps. The regression guard isvp run audit:withretry-interrupt:check, wired into CI besideaudit:provider-error-cause:checkandcache: falsefor the same reason — a green that replays from cache is a checker reporting "all threaded" without having looked. It parsessrc/provisioning/**with the TypeScript AST and checks PROVENANCE, not presence: both options must be direct properties of an options OBJECT LITERAL, must read off the SAME identifier, and that identifier must be bound fromstartInterruptWatchimported frominterrupt-watch.jsin that file. Presence alone was the first cut and a reviewer broke it in one line —isInterrupted: () => falsedeclares both names and disables the mechanism, so the checker was verifying its own spelling rather than its own effect. Half a pair is still a defect (withoutonInterrupted,withRetrythrows a bareError('Interrupted')naming no resource; withoutisInterruptedthe other half is never called), a conditional spread does not count, and an options bag it cannot READ isopaquerather than skipped. A SECOND rule runs per file: every watch must bedispose()d from inside afinally— the highest-value shape now that the latch is sticky, since a leaked watch stays live for the process and makes every later wait abort instantly after one Ctrl-C. That rule took three rounds to get right, each round a constructed false-clear rather than a hypothetical: a name-only key let one method's correctfinallycredit a sibling method's leak (found by the break-test, which reported nothing at all); keying on name-plus-enclosing-FUNCTION then merged two same-named watches in sibling BLOCKS of one function; and afinally { if (flag) watch.dispose(); }or adispose()inside an unrelated nested callback'sfinallyboth passed while releasing nothing reliably. The dispose is now credited to a DECLARATION resolved by lexical scope, must be an unconditional statement of thefinally, and must sit in the same function as the declaration. All three cases are permanent probes. Import ALIASES are resolved on both sides —import { withRetry as retryOp }used to yield ZERO sites silently, the exact "found nothing passes as everything matches" shape the floors exist to prevent and which they could not see, because they count per file. FLOORS on files scanned / sites found / sites threaded / files carrying a site / watch bindings, each with its OWN probe (a one-file tree trips them all at once, so none had been exercised alone). Eleven self-probes with known verdicts INCLUDING failing ones run before the tree is read, and the one exemption (describe-type.ts, an analysis-path schema fetch unreachable from any provider operation) is re-audited so an entry whose subject no longer has awithRetryfails instead of going quietly inert. Tests:tests/unit/provisioning/interrupt-watch.test.ts(17 cases on the shared module itself — both polarities of the arming rule against the REALprocess.listenerCountpredicate, the sticky latch across three sequential waits, TEN overlapping watches sharing one listener, and every arm of the bounded cause walk including a cyclic chain and a look-alike whose message andnamematch but whose type does not),tests/unit/scripts/withretry-interrupt.test.ts(31 cases, every failure probe taken against a scratch COPY so none touchessrc/) plus four behaviour suites —elbv2-listener-attributes-interrupt,servicediscovery-interrupt,dynamodb-index-busy-delete-interrupt,dynamodb-globaltable-delete-interrupt. Every case fires the signal during a NAMED attempt and pins the ATTEMPT COUNT, because an un-threaded site also throws eventually — that is the whole complaint — so a test asserting only "it rejected" passes with the fix deleted. Each suite is mutation-probed: dropping the threading turnsexpected 9 to be 2on the auto-scaling teardown, a 47s schedule and an AWS-worded rejection where the interrupt error belongs on ELBv2, four failures on ServiceDiscovery, and 120s test timeouts on both 600-poll gone-waits. Each suite also carries the opposite polarity — an ordinary retry with nothing interrupting — so a watch stuck reporting "interrupted" cannot pass; an independent probe confirmed the counts really are the discriminators (isInterrupted: () => falsefails all 11 interrupt cases with all 5 controls green,() => truefails all 4 controls). Three further cases came out of review: the two partial-create suites now assert the cleanup delete does NOT fire, the gone-wait's "AWS ACCEPTED the DeleteTable" warn is pinned on its TEXT (deleting that warn left the suite 3/3 green, because only the early return was pinned, and that line is the one thing telling a user their delete will still complete), and a provider-level latch case fires the signal inside the #1521 gate and asserts the gone-wait after it issues ZERODescribeTablecalls rather than its 600. - All four spellings of the dynamic-reference token pattern now derive from one constant, and the strictest one stops persisting plaintext (issue #1936) —
src/deployment/secret-redaction.ts,src/cli/commands/drift.ts.IntrinsicFunctionResolver.resolveDynamicReferencesscans with/\{\{resolve:([^}]+)\}\}/g, which is the AUTHORITY on what cdkd will actually try to resolve. Three predicates downstream answered a different question:secret-redaction.ts'sisSingleDynamicReferenceTokenanddrift.ts'sisWholeDynamicReferenceboth spelled the inner class[^{}]*, whiledrift.ts'ssurvivingDynamicReferencesspelled it[^}]+— with a comment arguing the split was principled. It was not. For a reference whose inner text carries a{(a Secrets Manager JSON key or secret name, e.g.{{resolve:secretsmanager:app/db:SecretString:my{key}}) the resolver resolved it fine while the strict spelling said it was not a single token, soredactByPath's whole-token source arm refused it — and on an EMPTY-secrets-map path (cdkd scrub's cross-generation observed walk, whose value scan has no needles by construction) the RESOLVED PLAINTEXT was persisted verbatim intostate.json. Narrow, pre-existing, and a disclosure. The{-exclusion bought nothing: the concatenated / spliced shapes it seemed to guard ({{resolve:a}}{{resolve:b}}) were already refused, because an ANCHORED[^}]+cannot cross the first}either — a claim to the contrary circulated in review and is false.DYNAMIC_REFERENCE_INNER/WHOLE_DYNAMIC_REFERENCE_PATTERN/ the now-exportedisSingleDynamicReferenceTokenare the single source of truth;SKELETON_WILDCARDand this module's own fourth spelling (dynamicReferenceTokens, previously[^{}]*global) derive from the same character class, anddrift.tsimports the predicate under its own name instead of carrying a hand-copied twin. That the twin stayed byte-identical to its sibling is exactly why the copy was not the problem: being identical to the wrong answer is not agreement. Two user-visible deltas beyond the disclosure:--revertno longer pushes a literal braced token over a livessm-securevalue (preserveLiveValuesAtUnresolvedTokensnow recognizes it as a whole token, preserves the live value AND registers it with the maskers), and a MIXED leaf embedding a braced PUBLIC{{resolve:ssm:token is now classified by the same #1901 rule as every other token rather than being invisible and unconditionally over-redacted.{{resolve:}}(empty inner text) is no longer called a token by anything, matching the resolver. Tests:tests/unit/deployment/secret-redaction-dynamic-reference-pattern.test.ts— including the fence the issue asked for, which READS the resolver's own source and asserts its scan capture is the same STRING as the shared constant (a hand-copied literal in the test would have been a fourth spelling), a fence that neither consumer carries an inline character class any more, and end-to-endcdkd scrub/--revertcases asserting WHICH string each bag ends up holding. Every case is mutation-probed. The issue #2088 review then folded in the rest of that review's findings: the assembled PATTERN was still byte-duplicated across the two files (only the character CLASS had been shared), sodynamicReferenceTokensis now EXPORTED anddrift.tscalls it; the per-callnew RegExpat both sites was justified by alastIndexclaim that is FALSE for this use (measured:String.prototype.matchwith a/gpattern setslastIndexto 0 on entry and leaves it 0), so the pattern is a module constant and the comment now says which methods DO advance it; the no-inline-spelling fence enumerated ONE bad spelling, and widening it to three did not survive review either -- a fourth (re-duplicating the ASSEMBLED pattern from the shared class) and a fifth (a.+?class, which never opens with[^) both passed, so the fence now states the GOOD condition positively: a regex matching a{{resolve:token must escape both braces, and only the resolver (once, as the authority) andsecret-redaction.ts(twice) may write one, ZERO anywhere else undersrc/**. Writing the resolver's own site into that table was not planned -- the fence FLAGGED it, which is the rule working, and an exempt-by-name list would have hidden a second spelling appearing in the one file where it matters most. The hoisted scan'sgflag is pinned too (.global === true, the missing half of its sibling's.global === false) plus a two-token case at the unit that owns it, since droppinggsilently makes.matchfirst-match-only and would let anssm-securetoken that is not FIRST go unreported bycdkd drift; and the leak SCOPING was incomplete --cdkd scrubleaks on BOTH its walks, thepropertiesone underTEMPLATE_SOURCED_RULESas well as the cross-generation observed one, because it resolves best-effort and records the position source unconditionally while recording the secrets map only when non-empty. A second security review then caught the constant's own JSDoc claimingmixedLeafMayCarryPublicReferencewas its ONLY reader, one paragraph below the text addingdrift.tsas a second one -- the sentence a later editor uses to bound the blast radius of touching the class, so it now names both. - An upper-cased region no longer misses the bootstrap marker, and the canonical-then-raw marker read becomes ONE shared helper (issue #2021) —
src/assets/asset-storage.ts,src/cli/commands/{gc,bootstrap-destroy,local-state-loader}.ts. What is and is not reachable today was MEASURED against real AWS while building the live-test arm, and the issue's own deploy-path harm narrative did not survive that measurement — so this entry deliberately does not repeat it. SigV4 rejects a non-canonical region at every service cdkd calls (AuthorizationHeaderMalformedfrom S3,InvalidSignatureExceptionfrom Lambda / ECR,SignatureDoesNotMatchfrom STS), anddeploy.tsdoes NOT canonicalize wheregc.ts:943andbootstrap-destroy.ts:338do — socdkd deploy --region US-EAST-1dies at the state-bucket preflight and never reaches the marker read at all. Thebody === nullarm the issue describes needs a CANONICAL client region paired with a RAW key spelling, and on the deploy path those are the same string; the "silent downgrade then collected bycdk gc" could not publish anything either, since the legacy destinationcdk-hnb659fds-assets-<acct>-US-EAST-1is not a legal S3 bucket name. That deploy-boundary fold is issue #2065, filed with the measurement; until it lands, the reachable value of this change is the two DESTRUCTIVE callers (cdkd gc,cdkd bootstrap --destroy), which do fold and do reach the helper under an upper-cased region, plus the shared-helper consolidation and the cache-slot fix below. Third instance of the region-case defect fixed forcdkd gcandcdkd bootstrap --destroyin #1995, and the one whose failure surfaces LATEST. Both deploy-time callers derive the asset region asoptions.region || AWS_REGION || 'us-east-1'and thenstack.region || baseRegion, so an env-agnostic stack — the CDK default — undercdkd deploy --region US-EAST-1(orAWS_REGION=US-EAST-1) handedAssetModeResolver.doResolvethe upper-cased spelling, which looked forcdkd-bootstrap/US-EAST-1.json, missed the markercdkd bootstrapwrote atcdkd-bootstrap/us-east-1.json, and took thebody === nullarm. A stack whoseenv.regionis pinned in CDK was unaffected (stack.regioncomes from the Cloud Assembly and is canonical). Nothing failed at deploy time and the legacy notice even fired — but the whole point ofcdkd bootstrap(#1002) is thatcdk gccannot reach cdkd's asset storage, and the legacy destination is precisely whatcdk gcDOES collect, so the failure surfaced later as assets vanishing from under deployed stacks.AssetModeResolver.resolvenow canonicalizes at its own boundary rather than at each caller, which fixes a second, quieter bug in the same line: the resolver caches by region, sous-east-1andUS-EAST-1occupied two slots and each re-probed S3. The RAW spelling still travels through to the marker read alone, because the WRITE side does not fold (cdkd bootstrapderives its region verbatim — issue #1820) and a fold-and-stop read would MISS a marker the pre-fold read HIT; everything else THE RESOLVER builds — its verification clients, its auto-create call and its notice text — now uses the canonical region. The scope stops at the resolver, deliberately:deploy.tskeeps handing the RAWassetRegiontobuildAssetRedirectMapandaddAssetsToGraph, so the publish-time S3 / ECR clients still see the raw spelling. That is self-consistent (the redirect map's region is raw both when built and when applied, so there is no split brain) and out of this lane's scope, but it does mean the#1795/#1820case-sensitive endpoint hazard is now reachable on acdkd-assets-mode publish that previously could only have run in legacy mode. The canonical-then-raw probe is now ONE shared helper,readBootstrapMarkerBody(stateBackend, rawRegion)insrc/assets/asset-storage.ts, returning{ body, resolvedKey }— the key ACTUALLY read, which every caller's error message, plan line and delete target follows. There were three hand-written copies (local-state-loader.ts,gc.ts,bootstrap-destroy.ts) before this lane needed a fourth, and they already differed in ways that are easy to get wrong. The helper deliberately does NOT catch, so each caller keeps its own policy unchanged on top of it:gc/bootstrap --destroystill translateNoSuchBucketinto their never-bootstrapped message and hard-error on anything else, whileloadBootstrapContainerRepostays best-effort and falls back to the conventional asset-repo names.bootstrap --destroyadditionally still DELETESresolvedKeyrather than the canonical key — deleting the canonical one when the body came from the raw one orphans the marker, a real review catch on #1995's PR that a naive fold would have regressed.ensureAssetStorageis deliberately NOT folded, with the reason recorded at the call site: its marker READ is paired with its marker WRITE and both use the same rawregion, so folding the read alone would break that pairing — a bootstrap underUS-EAST-1would read the canonical key and write the raw one, and a custom name recorded by an earlier run would stop being reused while a conflicting one would stop being refused. Aligning the WRITE side (and with it the asset bucket / ECR repo NAMES the same variable builds) is #1820's lane. Covered by 12 new cases intests/unit/assets/asset-storage.test.ts— 5 on the helper (one probe when canonical, canonical-first-and-stop, the raw fallback reporting the raw key, the canonical key reported on a double miss, and that it does not catch) and 7 on the resolver (an upper-cased region resolvingcdkd-assetsinstead of legacy, canonical-region verification clients, ONE cache slot across both spellings asserted on the PROBE COUNT rather than the returned mode, the raw-key marker still reachable,parseBootstrapMarkernaming the key actually read, the notice naming the canonical region so its remediation command works, and auto-create using the canonical name and key). Every one was mutation-probed; the three legacy callers' existing #1836 / #1995 suites go red against a de-canonicalized helper, which is what proves they really route through it now. - The ELBv2 LoadBalancer arm and all six ServiceDiscovery namespace arms adopt the
maskSecretscontract, andpollOperation— the site no issue named — stops leaking on arms that already had it (issues #2058 / #2063) —src/provisioning/providers/elbv2-provider.ts,src/provisioning/providers/servicediscovery-provider.ts. #2058's own cited site needed no change, and this PR closes that issue by VERIFYING rather than by fixing: theconvertTargetsdrop-warn atelbv2-provider.ts:1771-1773was already threaded by the merged #2067 (#2050) lane, which masks the value walk BEFOREJSON.stringifyand passes the masker from all three call sites — confirmed againstorigin/mainrather than assumed. What this PR fixes is #2063 plus the sites its own audit found. Issue #2050 threadedCreateContext.maskSecrets/UpdateContext.maskSecretsinto these two providers' Listener / TargetGroup / Service arms; the arms it left out kept interpolating a RESOLVED property value — plaintext by the time a provider sees it — into aProvisioningErrorthe deploy engine prints at ERROR, i.e. at DEFAULT verbosity. ELBv2:createLoadBalancernow takes the masker (its outer catch is the ONLY disclosure surface for a non-retryableCreateLoadBalancerrejection, since nothing on that path runs throughwithRetryand there is no give-up summary behind it), and its partial-create cleanupwarnis masked for uniformity with the sibling Listener / TargetGroup paths.updateLoadBalancerneeds no parameter for its FAILURE surface — it carries no try/catch, so itsModifyLoadBalancerAttributes/SetSubnets/SetSecurityGroups/AddTagsrejections propagate RAW toupdate()'s outer catch, which is where the mask belongs (the sibling arms wrap their own errors first, so theCdkdErrorpassthrough short-circuits them and that frame masks only what actually escapes unwrapped) — but it takes one anyway for a surface that frame cannot reach, and finding that is what the review round bought. Three SUCCESS-pathlogger.debuglines interpolate a resolved property value with no mask, two on create (EnforceSecurityGroupInboundRulesOnPrivateLinkTraffic,MinimumLoadBalancerCapacity.CapacityUnits) and one on update (the capacity twin). They are reached when the AWS call SUCCEEDS, so no catch anywhere downstream ever sees them. Debug level is not an exemption — #1997 shipped this exact shape as a live leak (a resolved ASG name in a debug line) — and the capacity one is the more reachable of the three: a{{resolve:secretsmanager:...}}resolving to a numeric string is accepted byNumber()and by AWS, so the plaintext prints under--verbose. TheNumber()coercion is not a sanitizer either, since a number stringifies back to the same digits. All three now mask the RAW value before interpolation. ServiceDiscovery: all six namespace arms (three kinds x create/update — the issue body says "four"; its own review comment corrects the count) take the masker, covering both theCreate*Namespace/Update*Namespacepayload and theTagResourcecallsyncNamespaceTagsmakes with resolved tag VALUES.pollOperationis the site neither issue named, and it is the one that also affects arms #2050 had already fixed. Cloud Map's create and update are OPERATION-BASED, so the submit call usually succeeds and AWS reports its rejection asynchronously inOperation.ErrorMessage, quoting the offending value back — which makes a FAILED operation the NORMAL rejection path for these arms rather than an edge case. Every namespace arm ANDupdateServiceopens its catch withif (error instanceof ProvisioningError) throw error;, so the error built there is re-thrown VERBATIM and masking the arm does nothing for it; the mask therefore had to be applied at the point of construction, on the rawErrorMessage(before interpolation, so it reachesmaskSecretsInText's whole-value arm at any length rather than only the substring arm's 4-character floor). Threaded from every CREATE / UPDATE caller — seven of the eight,updateService's included (that arm looked done after #2050 and was not). The eighth,deleteNamespace, stays unthreaded BY CONTRACT rather than by oversight:DeleteContextcarries no masker (issue #2007) and its payload is a physical id, not a resolved property bag. The docs added here sayevery create/update callerfor the same reason — an absolute would send a future PR trying to thread a masker delete-side. Two sites are audited and left UNMASKED with the structural reason recorded in-code rather than a mask added for tidiness:resolveNamespaceArn/ the post-createGetNamespacedebug andwaitForCapacityReservationProvisionedeach send only an AWS-issued identifier, so no resolved template value is in the payload for AWS to echo back; the delete arms are out of scope by contract (DeleteContextcarries nomaskSecrets). Tests: two new suites (tests/unit/provisioning/elbv2-loadbalancer-masking.test.ts,tests/unit/provisioning/servicediscovery-namespace-masking.test.ts, 40 cases) asserting the STRING that escaped — the thrown error'smessageor the logger's argument — never that a masker was passed, with an identity-fallback case per arm pinning the "absent means unmasked" half of the contract. The fixture secret is a Secrets Manager JSON DOCUMENT per #2063's acceptance item 4, which is also what caught the first draft's own non-vacuity check walking into the gap that item is about:JSON.stringifyescapes the document's quotes, soJSON.stringify(input).includes(secret)reported the plaintext ABSENT from a payload that carried it, and the check now walks the request's string LEAVES instead. Sixteen mutation probes, one per threaded site plus two dispatch-threading probes, each verified to red exactly the tests written for it. A round-2 review scoped to the FIX DELTA then found that the fix for the debug lines had introduced a leak of its own, which is the reason that round exists: the UPDATE-path capacity line maskedString(newCapacityUnits)— theNumber()-COERCED value — where its create twin masks the raw property. A masker matches by literal occurrence, so any valueNumber()does not round-trip printed unmasked ('0471'->471,'1e5'->100000,' 4071'->4071, a 20-digit value losing its tail to float precision), and the in-code comment beside it asserted the OPPOSITE — that a number stringifies to the same digits, so the coercion is not a sanitizer. True of4071, false of every value above. It now masks the raw value like the create twin, and the comment says which. The fixture that catches it is'07', chosen to fence BOTH properties at once: sub-MIN_NEEDLE_LENGTH(so only the whole-value arm can reach it, which reds a mask relocated onto the assembled sentence) and non-round-tripping throughNumber()(which reds the coerced-value mask). Both probes verified red; the earlier'4071'/'on-4071'fixtures were ≥ 4 characters and could fence neither. The review round's own probe is the one worth recording: relocatingpollOperation's mask onto the ASSEMBLED sentence left all 14 of its cases GREEN, because every fixture quoted a long secret inside an AWS sentence, which the substring arm handles either way. That is what the JSDoc had over-claimed —maskSecretsInText's whole-value arm fires only whensecrets.has(text), i.e. when the text IS the secret entire, so masking raw buys coverage BELOW the 4-characterMIN_NEEDLE_LENGTHfloor for a whole-valueErrorMessageand nothing at all for an embedded one. The comment now says exactly that, and a case whoseErrorMessageIS a 3-character secret fences it (verified to red under the relocation, which is the probe the 14 could not fail). - The reverse-replacement replay-CREATE retries an IAM-propagation error instead of rethrowing it on attempt 0, and an opt-out provider is now single-shot on both of that path's retry loops (issue #2032) —
src/deployment/rollback-executor.ts. Both arms of thereverse-replacementreplay (the create-first attempt and the post-delete-new-first retry) calledcreateProvider.create(...)DIRECTLY inside awithRetrycarryingRECREATE_RETRY_SCHEDULEplus a customisRetryable(isNameCooldownError/isRecreateRetryableError). Two independent mechanisms inretry.tsthen made the IAM-propagation class unreachable: a caller-suppliedisRetryableREPLACESisRetryableTransientErroroutright, and ANY explicit schedule knob setsdefaultSchedule = false, which is the gate on the dense 26-retry / 47.75s propagation path. So a rollback that re-created a Lambda whose execution role had been re-created moments earlier in the SAME rollback tookInvalidParameterValueException: The role defined for the function cannot be assumed by Lambda., retried ZERO times, and left the resource absent from BOTH AWS and state. A single local helper,createWithRollbackRetry(the create-side twin of the existingupdateWithRollbackRetry), now owns BOTH loops for both arms: an inner call passing no schedule knobs and no classifier — sodefaultScheduleis true and the dense path is live — nested inside the outer name-release loop, with the masking retry logger (#2038) and theisInterrupted/onInterruptedpair threaded into each. Nesting rather than hoisting the propagation check intoretry.tsis the load-bearing decision:withRetrynever receives the provider, so it structurally cannot honourResourceProvider.disableOuterRetry— re-runningCustomResourceProvider.create()mints a fresh pre-signed S3 URL and RequestId and strands the previous attempt at a key nobody polls, and re-runningNestedStackProvider.create()re-creates child stacks and child state files — and a hoist would additionally have overridden the documented "any explicit knob means the caller owns the cadence" invariant for all 8 custom-isRetryablecall sites, including four throttling-only ones inintrinsic-function-resolver.ts/export.ts/describe-type.ts/dynamodb-index-busy-delete.ts. ThedisableOuterRetryguard is hoisted to cover the OUTER loop as well, closing a pre-existing gap the nest made worth fixing in the same pass: the outer schedule had no guard at all, so an opt-out provider on this path measured 9create()calls for a cooldown and 10 for a collision — the exact hazard the flag exists for, arriving through the outer loop instead. A single-shot call still lets a name collision reach the caller's catch (which sits outside the helper), so the delete-new-first fallback fires on attempt 0 for an opt-out provider exactly as it does for a normal one. The precedent claim is stated precisely rather than generalized: the two deploy-engine delete-then-re-create sites are the twin of arm 2 only; arm 1's analogue is the property-driven create-first atdeploy-engine.ts:3745, which has one default-schedule loop and NO outer custom-classifier loop, so arm 1 is the SUM of both precedents. The neighbouring "if it fails the new resource survives untouched" comment was corrected in the same pass — with an inner retry that holds only when the create-first attempt fails with something other than a name collision, since a provider leaving a NAMED orphan after a transient failure now collides on an inner retry and routes into the destructive fallback (the same class the deploy engine's--replacefallback already accepts). The name-collision fallback is unchanged, and that was MEASURED rather than assumed:isNameCollisionError's signature (already exist(s)/AlreadyExists) is deliberately absent fromRETRYABLE_ERROR_MESSAGE_PATTERNS, so the inner default classifier rejects a collision on attempt 0. The SQS same-name cooldown IS matched by the inner classifier (the generic table carrieswait 60 seconds), which is the same division of labour the deploy engine's named-replacement site already documents — the inner ~47s budget absorbs most of the 60s window and the outer ~64s one covers the tail — at the cost of a longer worst case when the cooldown never clears. Covered bytests/unit/deployment/rollback-executor-replay-propagation-retry.test.ts(12 cases running the REALwithRetrywith itssleeprecorded rather than merely zeroed, so the classification AND both schedules are the subject: the measured classifier answers for all three message classes, both arms retrying propagation, a 9-failure case the outer 8-retry schedule alone cannot clear, a case asserting the inner CADENCE is the dense 250/500/1000/2000 one — a count cannot see this, since{ maxRetries: 26 }yields the identical attempt count on the generic schedule — the collision arm pinned by exact call ORDER before and after, a 12-failure cooldown proving the outer loop still re-enters, and three opt-out cases covering propagation, cooldown and collision), plus a new case intests/unit/deployment/rollback-executor-log-masking.test.tspinning the mask on the inner loop's give-up summary. That summary is a NEW sink at these sites — before this change neither replay-CREATE could emitretry.ts's give-up line at all (both counters were inert under a custom classifier), so the nested loop is the first thing here that prints at DEFAULT verbosity with the AWS message interpolated verbatim, which is the shape GHSA-p5qg-v9gv-hc7w was. Six mutation probes were run against the real file, each emitting a receipt proving the mutation landed: unwrapping arm 1 reds 7 cases and arm 2 reds only its own; removing the shared opt-out guard reds all three opt-out cases; narrowing that guard to the inner loop alone reds them too (the cooldown case showing the pre-fix 9 calls); routing the inner give-upwarnto the raw logger while leavingdebugmasked reds the new masking case naming the plaintext (all 146 sibling cases stay green, which is what makes it worth having); and setting the inner opts to{ maxRetries: 26 }reds ONLY the cadence case while all 11 count assertions stay green — the proof that the counts alone were not fencing the schedule. Two pre-existing suites were updated for the new call shape rather than the new behaviour:rollback-executor.test.ts's #1206 case now selects the last OUTERwithRetrycall by "carries a custom classifier" instead of.at(-1)(which is now the inner call), androllback-executor-log-masking.test.ts's create-first non-vacuity count moves from 9 to 81 (9 outer x 9 inner attempts) with the arithmetic spelled out in place. - Provider
catchsites that drop the AWS error are now mechanically refused, and the audit that decided the size is recorded (issue #2040) —scripts/check-provider-error-cause.ts(new),vite.config.ts,.github/workflows/ci.yml. cdkd's three transient-error classifiers —isTransientServerError(issue #2026),isThrottlingError,isMarkedNonRetryable— all find their signal by walking the error's.causechain, so a provider that wraps an AWS failure in aProvisioningErrorWITHOUT threading the caught value makes all three INERT for that call: the identical AWS failure is retryable in one provider and terminal in another, for no reason a user could predict. The issue was filed on two review estimates that disagreed by two orders of magnitude (6-of-363 and ~325-of-719, depending on how sites were matched), so the audit was the first deliverable, not a premise. Measured across all 81 files ofsrc/provisioning/providers/**, over the 21CdkdErrorsubclasses actually constructed there: 766 constructions, of which 392 have a caught value in scope and all 392 thread it (387 inside a lexicalcatch, 5 inside awrapErrorhelper), 374 are validation / precondition throws with no cause to thread, and 0 drop a cause. The ~325 figure is reproduced by the token-level heuristic it came from — 338 of the 720new ProvisioningError(sites carry nocauseargument — and every one of those 338 is a validation throw; the 6 figure is reproduced by widening the scan pastproviders/(see the follow-up below). Two independent methods (a TypeScript-compiler-API catch-scope walk and a brace-matching textual pass) were run against each other and their 13-site disagreement was read line by line: all 13 thread inline aserror instanceof Error ? error : undefined, which the token heuristic cannot see. So there was nothing to sweep, and the shipped change is the mechanism that keeps it that way — which makes the critic itself the deliverable, and its blind spots the thing worth spending review on. Three were found by independent review and closed, each MEASURED against the real tree rather than argued: (1) helper indirection — the fivewrapError/wrapUpdateErrorhelpers (appsync, lambda-microvm-image, and the three rds-dbproxy files) build their error OUTSIDE any lexical catch, so a purely lexical rule cleared all of them; deleting thecauseargument fromrds-dbproxy-provider.ts's helper un-threads 7 throw sites at once and used to exit 0. Call sites are now resolved to a FIXPOINT, so a catch handing its binding to a helper — or a helper forwarding it to another helper — propagates the caught-value binding, and those 5 constructions (serving 33 throw sites) are checked. (2) an unguarded class allowlist — a hardcoded 5-class table silently omittedHostedZoneNameNotFoundError, declared insideroute53-provider.tsand extendingProvisioningError; a catch-sited construction of it that dropped its cause passed with counts unchanged. The table is now DERIVED fromsrc/utils/error-handler.ts(every class transitively extendingCdkdError, with its cause position read off its own constructor or inherited from its base) and extended per-file with provider-LOCAL subclasses, which is what covers the route53 case. Note the positional index needed no fencing: adding a parameter beforecausemakes every site of that class readdropped, which is loud — it is the class SET that failed quietly. (3) a name-based threading test — the old check asked "does this expression mention the binding?", soconst cause = new Error(error.message)passed while being exactly as inert as dropping it (the derivedErrorcarries no$metadataand no marker), andresult.errorwas credited from a property name. The check is now STRUCTURAL: a bare identifier resolving to the caught binding, optionally through parentheses / anascast / a!/ anundefined-guarded conditional /??/||, and through local bindings resolved to the declaration NEAREST the use (a sibling block'sconst causeno longer credits an unrelated site). A property access, a call, anew, an object or a string is refused. The critic's own two failure modes are now defended separately, because they need different mechanisms and an earlier revision of its header wrongly credited the floors with both: collapse toward zero (a parse that yields nothing) is caught by per-shape FLOORS — files, constructions, catch-sited, helper-sited, threaded, derived-class count — plus a hard failure on any file with parse diagnostics, which is the real fix since the floors carry enough slack to hide up to three whole unparseable provider files; collapse toward green (the classifier degrades so everything reads as threaded) is caught by a SELF-PROBE of 13 fixed sources with known verdicts, five of them expectingdropped, run before the tree is read — makingisThreadedreturn true unconditionally leaves every count and the printed line byte-identical but now fails 7 self-probes. Two entrypoint mechanics that both ended in a silent green were fixed: the main-module guard usedimport.meta.url === \file://${process.argv[1]}`, which exits 0 having done nothing when the script is invoked through a symlink (Node resolves the main module to its realpath whileargv[1]keeps the link) or through any path needing percent-encoding, andprocess.exit()truncated a piped--jsonreport at 131072 of 167543 bytes — invalid JSON for any consumer. Registered as the Vite+ taskaudit:provider-error-cause:checkwithcache: false, for the reasontypecheckcarries it, and **invoked by an explicitci.ymlstep** like all six siblingaudit:*:checkguards — registered-but-uninvoked is how a task's own command string goes unexercised everywhere.tests/unit/scripts/provider-error-cause.test.ts(44 cases) takes every failure probe through the--providers-dir=seam against a scratch copy so no probe writes tosrc/, and pins both thecache: falseentry and the CI step. Break-tested in BOTH directions against the REAL tree: the unmutated copy exits 0, while removing acauseargument, passing an explicitundefinedthere, passing an unrelatednew Error(), deriving a newErrorfrom the caught one, un-threading awrapErrorhelper, dropping the cause on a provider-local subclass, and making one file unparseable each exit 1 naming the site.tests/unit/provisioning/provider-error-cause-classification.test.tscovers the behaviour rather than the shape: it drives one AWS-shaped error — a realSQSServiceException/IAMServiceExceptioncarrying$metadata.httpStatusCode, which is the ONLY signal an empty-bodied 500 leaves, not a hand-rolled stub — through two independent real providers (SQSQueuePolicyProvider, the issue's named reference site, andIAMRoleProvider) and asserts all four classifier verdicts agree, for a 500, a throttle, and a cdkdmarkNonRetryablemarker. Its DISCRIMINATOR is the classifier's verdict, not that an error was thrown — the pre-fix code throws too — so a negative control pins that the same wrapper with the cause dropped flipsretryablefromtruetofalse, and mutation-probing a real provider back to the dropping shape fails 4 of its 8 cases. **Known scope bound, filed as issue [#2075](https://github.com/go-to-k/cdkd/issues/2075):** the same defect DOES exist outsidesrc/provisioning/providers/**— 4 sites insrc/assets/docker-asset-publisher.tsand 2 insrc/state/s3-state-backend.ts`, which is where the 6-of-363 estimate came from — and those files were out of this change's edit scope. The asymmetry is worth naming rather than leaving implicit: the set with ZERO defects is regression-guarded and the set with SIX is not. The critic scans whatever root it is given, so widening its default once those six are fixed is a one-constant change plus a floor re-calibration, and that is what #2075 asks for. - A retried 500 no longer duplicates a non-idempotent EC2 / Route 53 create, and a replayed
CreateAccessKeyno longer strands an ACTIVE key (issue #2039) —src/provisioning/providers/idempotency-token.ts(new),ec2-provider.ts,route53-provider.ts,iam-access-key-provider.ts. The deploy engine wraps everyprovider.create()in an outer transient-error retry, and issue #2026 made HTTP 500 / 502 / 504 retryable for the default classifier, so a 500 whose request had actually SUCCEEDED server-side re-invokedcreate()and provisioned a SECOND resource — with no state entry, invisible tocdkd destroy, billing indefinitely.RunInstances,CreateNatGateway,CreateRouteTableandCreateNetworkAclnow carry aClientTokenfrom the new shared helper, which is memoised per(scope, region, stackName, logicalId)so every attempt of one create sends the SAME value, mixes a per-process nonce so no latercdkdrun can collide, and is RELEASED on success — and on the wiring-failure path, but only once the compensatingTerminateInstanceshas actually SUCCEEDED, because a terminate that fails leaves the instance running and the retry should be handed that same instance rather than launching a second one beside it — so a--replacere-create is not handed back the resource the deploy just destroyed — EC2 keeps aRunInstancestoken for ~24h and would otherwise answer with the terminated instance.AWS::Route53::HostedZonewas the worst shape found by the audit: it already passed aCallerReference, but derived from${logicalId}-${Date.now()}, i.e. regenerated per attempt — a call site that LOOKS idempotent while Route 53 happily creates a second hosted zone (and a second NS delegation set) for the same domain. It now takes the stable token, and because a repeated caller reference is REFUSED rather than deduped, the replay is recovered by looking the zone up by its caller reference (ListHostedZonesByName, name-scoped) and adopting it,GetHostedZoneincluded so the adopted zone returns the sameNameServersa first-attempt create would.CreateAccessKeyhas no token member at all anddisableOuterRetrywas rejected (it would make the provider single-shot for the IAM-propagation window between a siblingAWS::IAM::Userbeing created and being visible), so it serializes creates per IAM user in-process and reconciles from the FAILED attempt's own catch, deleting a key only when it is absent from a baseline taken moments earlier under that lock, AND was created at or after the attempt started, AND is not one cdkd itself created — anything unattributable is reported and left alone, because deleting a live credential that state still advertises is worse than the untracked one this fixes — the orphan's secret went down with the lost response, so adopting it is impossible and deleting it is the only remedy; a failed baseline read warns and disarms rather than failing the deploy. Unit tests model AWS-side dedup and assert what SURVIVES (exactly one instance / zone / access key), driven through the realwithRetry; theec2-instanceinteg fixture gained a real-AWS arm that reads the live instance'sClientTokenback and replaysRunInstanceswith it, failing (and terminating the leak) if EC2 launches a second instance. Two findings are worth keeping because neither is reconstructable from the diff. First, the audit's most dangerous category was not "no token" but a token REGENERATED PER ATTEMPT —route53-provider.ts(fixed here) andcloudfront-distribution-provider.ts(issue #2079, unfixable the same way becauseListDistributionsdoes not returnCallerReference) both passed one, so both call sites read as idempotent while duplicating exactly as if they had none; of the six providers that carry a token at all, only three (EFS, FSx, CloudFront OAI) were stable. Second, the unit probes initially reported a FALSE NEGATIVE: with the retry's sleep stubbed to a no-op, both attempts landed in the same millisecond, aDate.now()-derived token coincidentally matched, and the pre-fix implementation went GREEN through the probe written to catch it. The fixtures now fakeDateand advance the clock inside the sleep, which is what makes the probe discriminate (measured: the pre-fix Route 53 derivation fails 2 of 4 cases after the change, 0 of 4 before it). Third, the integ arm failed its first real-AWS run for a reason worth recording next to the second:aws ec2 run-instanceswas invoked with--min-count 1 --max-count 1, which are the API's parameter names (MinCount/MaxCount) and not the CLI v2 spelling (a single--count, which also acceptsmin:max). The CLI rejected the command outright withUnknown optionsand rc=255, so the replay never reached EC2. This is the same lesson as the JMESPath expressions that WERE validated against real output, applied to the other half of a command: an invocation written from the API shape and never executed. Everyawsflag the fixture uses is now checked against the real CLI with--dry-run. What kept this from shipping green is the vacuity guard: the primary assertion is a count of live instances carrying the token, and it would have passed here — there WAS exactly one, because the replay never ran — so only the exit-code branch, added to prove the replay reached the idempotency machinery at all, turned it into a FAIL. The same run also lost the tail of its own diagnostic, because a CLI auto-prompt redraw put a CR in the captured output and the terminal overwrote everything after it; the fixture now strips control bytes before interpolating command output into a message. The audit behind the change is recorded on issue #2080: 3 provider files fixed here, the remaining ~25 vulnerable creates across 16 providers enumerated in issue #2080 for a follow-up sweep, and the unit-test hazard that produced the false AWS call in issue #2081. - A cross-stack import of a REDACTED secret no longer ships the literal
{{resolve:...}}token to AWS, and two lookups stop building their own clients (issues #1934 / #1994) —src/deployment/intrinsic-function-resolver.ts. Since PR #1899 a secret-bearing output is PERSISTED as its unresolved expression, which is the whole point of the GHSA-p5qg-v9gv-hc7w fix — but a CONSUMER resolvingFn::ImportValuegot that string back verbatim from the exports index (or from thestate.jsonscan) and handed the literal token to the provider as a property value. The same shape had already been fixed twice for readers of a redacted bag (resolveReplayPropsfor the rollback replay, #1914 forcdkd drift --revert); the cross-stack edge was missed because the redaction and the consumption live in different stacks and usually different runs. It is not merely a broken value: a{{resolve:...}}string landing in a password field is a PREDICTABLE credential. The three cross-stack reads the intrinsic resolver owns now re-resolve before returning —Fn::ImportValue's index arm and scan arm, and theFn::GetStackOutputsibling read path the issue asked to be audited in the same pass, which reads the samestate.outputsbag and carried the same defect. Two OTHER readers of that same redacted bag live outside this file and are NOT fixed here, so the scope is deliberately not "every cross-stack read": a nested-stack child's outputs reach the parent verbatim throughnested-stack-provider.ts(issue #2055, the same AWS-facing defect via a third route), andlocal-state-loader.ts's own--from-statecross-stack resolver hands a container the token (issue #2056, local-emulation fidelity rather than a disclosure). Both were found by this lane's spec review and filed rather than folded in, because each needs a verification cycle this lane does not pay for. Which region resolves it is the decision this settles, consistently with #1957's family: the PRODUCER's (entry.producerRegion, the state ref's region, or the reference's ownRegion), because a Secrets Manager secret or an SSM parameter of the same NAME in two regions is two independent values (#1933) and only the producer's region reproduces what the producer exported. The index hit andFn::GetStackOutputcarry that region as recorded data; the index-miss scan is the one arm that can still fall back to the consumer's region, and only for a PRE-V2 state record carrying noregionfield — the same guess the state read itself just used, so the re-resolution cannot disagree with the record it was handed. That is done with a resolver PINNED to the producer region rather than a client bag, becausecachedDynamicReferencesis keyed by expression alone and is sound only while one resolver stands for one stack in one region — resolving a foreign region's expression inside the consumer's resolver would put a foreign answer under a key the consumer's own lookups read, re-opening exactly the leak #1933 closed. Credentials are the consumer's, which are the producer's too on every path that reaches there (the exports index and the state bucket are account-scoped). Cross-accountFn::GetStackOutputREFUSES such a value instead — anIntrinsicResolutionRefusalError, marked non-retryable — because the only credentials in hand are the consumer's and resolving under them would answer from a same-named secret in the WRONG account, the #1957 disclosure shape; the assumedRoleArnis scoped to a state read and carries nosecretsmanager:GetSecretValuepromise, so widening it is a permission-model change rather than a silent fallback. Both CloudFormation fallbacks (ListExports,DescribeStacksoutputs) are deliberately NOT re-resolved: those values never passed through cdkd's redaction, so a token there is a literal the producer chose to publish. The consumer's own state stays redacted (the resolved plaintext is recorded intorecordedSecretValues, so the deploy engine's save choke point rewrites it back),cdkd diffstill compares expression-vs-expression (skipDynamicReferencesis honoured), and a value carrying no{{resolve:is returned BY IDENTITY, so every ordinary import is untouched and pays no walk. TheFn::ImportValuelog line is deliberately emitted BEFORE the re-resolution so it keeps printing the expression rather than the secret. Bundled #1994 in the same lane because its two sites are in this file: theAWS::EC2::VPCIpv6CidrBlocksandAWS::ServiceDiscovery::*NamespaceHostedZoneIdlookups each built a fresh SDK client per CALL — never destroyed, so one client and socket pool per lookup for the life of the process — fromresolverRegion, whoseAWS_REGION→us-east-1substitution is the FAIL-OPEN shape #1957 removed from the dynamic-reference lookups, and without the ambient--profile/ credentials. The EC2 site now readsclientsForRegion(explicitRegion).ec2like itsDescribeInstances/DescribeLaunchTemplatessiblings. ServiceDiscovery has noAwsClientsmember (adding one would put a static@aws-sdk/client-servicediscoveryimport into a module every command loads), so the REGION DECISION is stillclientsForRegion's — its ambient-reuse rule and its refusal of a non-client-safe region included — and only the construction is local, memoized per region as a PROMISE and evicted on a rejected import. Covered bytests/unit/deployment/cross-stack-secret-reresolve.test.ts(20 cases against the realAwsClientswith region-capturing SDK fakes: the resolved value, the producer-vs-consumer region discriminator, the scan arm, the diff path, both CFn-fallback verbatim arms, identity return, the deep walk over list / object / embedded-token / own-__proto__shapes, both failed-lookup surfacing arms, the log lines carrying the expression rather than the plaintext, the producer-region binding when the consumer resolver was given no region, and the guest not pinning a verdict into the process-global store), 5 new cases intests/unit/deployment/intrinsic-getstackoutput-cross-account.test.ts(the refusal, an ordinary cross-account value still resolving, the refusal being non-retryable, its re-raise out of anFn::Sub, and the diff path NOT refusing), 1 intests/unit/deployment/intrinsic-refusal-non-retryable.test.ts(a source-level enumeration, so a refusal site added later cannot arrive unclassified), and 5 intests/unit/deployment/dynamic-reference-region-scoped-clients.test.tsfor #1994. 22 mutation probes were run against the real file, each emitting a receipt proving the mutation landed; 21 red the expected cases, and the 22nd is recorded in-code and in the test: dispatching ten namespace lookups together produces ONE client whether the promise or the client is memoized, because they serialize ongetAccountInfo's in-flight promise, so the promise memoization is defensive rather than fenced. - The retry classifier and the rollback executor are now inside both integ gates' scope, and the two hand-duplicated lists behind them are fenced by a test (issue #2042) —
.markgate.yml,.claude/hooks/integ-broad-gate.sh,.claude/hooks/integ-destroy-gate.sh,.claude/skills/{verify-pr,pick-integ}/SKILL.md,CLAUDE.md,tests/unit/scripts/cross-cutting-list-sync.test.ts(new).src/deployment/retry.tsandsrc/deployment/retryable-errors.tssat in NO integ gate whilewithRetrywrapped every provider'screate/update/deleteanddestroy-runner.tsconsultedisRetryableTransientErrordirectly — so a change deciding which AWS errors are retryable reached every mutating call cdkd makes and triggered neitherinteg-destroynorinteg-broad. Both are now ininteg-destroy.includeand inCROSS_CUTTING_REGEX.src/deployment/rollback-executor.tswas found unscoped in the same pass and was NOT named by that issue: its reverse-replacement path callsprovider.delete()on the new physical resource and re-creates the old one, which is delete logic by any reading. The.markgate.ymlhalf alone would have been inert, which is the non-obvious part:integ-destroy-gate.shcarries its OWN activation patterns and passes a PR through before ever consulting the marker, so a scoped-but-unmatched file produced an invalidated marker plus a gate that never reads it. The three files joined the hook'sstrict_deletegroup (any change triggers) rather than the hunk-filtered one, because a real change here adds an HTTP status code or an error name — text carrying none of thedelete/rollback/ENIvocabulary the hunk filter greps for. Auditing that asymmetry mechanically then found the INVERSE gap live on main, which is the worse of the two:src/cli/commands/destroy-runner.tsandsrc/provisioning/region-check.tsboth ACTIVATED the hook while sitting outsideinteg-destroy.include, so the gate blocked, ranmarkgate verify, and got a 0 back — the marker'shash: diffdigest never saw those files — and the merge proceeded with no destroy verification at all. A gate that has silently stopped gating is indistinguishable from a working one. Both are now in the include, and the two halves are compared in BOTH directions by the new test, which names the fail-open and the inert case separately with the remedy for each. Theinteg-destroyentry inCLAUDE.mdgained a prose copy of that scope in this same PR (the entry previously named a scope without saying the two lists must agree) — a third hand-copy, added inside the change whose thesis is that hand-copies drift, so it is fenced against both executable halves rather than left to the same "keep in sync" convention this PR exists to replace. The audit was mechanical (expand the hook's three EREs, diff against the include) rather than by eye, and it reported exactly those two and nothing else. Enforcement of the sync itself: the cross-cutting file list is hand-duplicated in FIVE spellings (the hook regex,/verify-prstep 6's bullet list AND its detection-snippet copy of the same regex, theCLAUDE.mdinteg-broadentry, and/pick-integ's changed-path table) and the broad-set test-name list in SEVEN. Both had already drifted: all five file-list copies were missing the three files above, and the broad-set list stood at 9 entries everywhere except the hook's own header comment, which had 8 and omittedexport(fixed here). The new unit test compares every copy against the executable one, expands the regex and brace spellings rather than skipping them, asserts a per-extractor floor so a blinded parser cannot report green having compared nothing, and checks that every listed path and fixture still exists..claude/skills/work-issues/SKILL.md's triage list is deliberately excluded and the test says why — it is a different list for a different purpose and already diverges in both directions. Hook coverage: 3 activation cases plus 2 near-miss pass cases ininteg-broad-gate.test.sh, and 3 activation cases plus 2 controls ininteg-destroy-gate.test.sh(which required the suite's first fixture repo carrying a realrefs/remotes/origin/main, since every pre-existing case there deliberately had none and so never exercised the diff filter at all). Each activation case was verified against the pre-change hook and FAILS there. Four mutation probes were run against the sync test, including a replay of the realexportdrift.
Recently Implemented (2026-08-19):
- The
maskSecretsprovider contract is adopted by the five remaining providers that log a RESOLVED property value (issue #1997) —src/provisioning/providers/{apigatewayv2,asg,dynamodb-globaltable,dynamodb-table,sns-topic}-provider.ts,tests/unit/provisioning/{apigatewayv2-provider,asg-provider,dynamodb-provider,sns-topic-provider}-masked-warn.test.ts(4 new). Issue #1932 item 3 added the contract —SecretMaskingContext.maskSecrets, inherited byCreateContextandUpdateContext— and wired exactly ONE consumer,CognitoUserPoolProvider. Every other provider interpolating a resolved property value into its ownlogger.warnstayed outside cdkd's masking boundary:propertiesreaches a provider already RESOLVED, so a{{resolve:secretsmanager:...}}scalar is plaintext by then, and the other two boundaries (the deploy engine's error / reason text, the resolver's debug line) do not cover a provider's own log line. Same risk class as theEnabledMfascase that motivated the contract, and log-only for the same reason (these values do not reachdeployments/*.jsonl), which is why they were deliberately deferred rather than overlooked. Shape, copied frombuildMfaConfigRequestrather than reinvented: the masker is taken as a trailingSecretMaskerparameter defaulting to IDENTITY (so the diff / import paths and every existing test are unchanged), and each function installs ONE masked sink —const warn = (m) => this.logger.warn(maskSecrets(m))— rather than masking at each call, so a warning added later is masked by construction. The type is imported fromsrc/types/resource.ts, which re-exports it for exactly this reason;src/provisioning/**still imports nothing fromsrc/deployment/**. BOTH layers, per the contract's own correction: the sink is the OUTER one and can only reachmaskSecretsInText's SUBSTRING arm, which ignores needles belowMIN_NEEDLE_LENGTH(4) and — more importantly — never matches onceJSON.stringifyhas escaped a", a\or a newline inside the value. Each site therefore also masks the raw value BEFORE stringifying: amaskLeafwalk for API Gateway v2'sDestination/Source, a per-ARN.mapfor ASG's expected / observed target-group sets, and a shared module-levelmaskLeafValuewalk in each DynamoDB provider (a walk, not a top-leveltypeoftest, because the arm that fires is precisely the one where the value is NOT a usable string — an array or an object with the secret nested inside). BOTHcreate()andupdate()per provider, which is what makes it a fix rather than a hole in the shape of whichever path a deploy takes:apigatewayv2gainedcontexton both (threaded tocreateIntegration/updateIntegration→toSdkResponseParameters),sns-topicgained it onupdate()(create already had one) and threads it tobuildDeliveryStatusAttributeMap's desired-side walk, and both DynamoDB providers gained it onupdate()and install sinks increate()andupdate()plus the warm-throughput / GSI / auto-scaling helpers those two reach.asgcovers both paths too — an earlier revision claimed its create path had nothing to mask, which review disproved:AutoScalingGroupNamefalls back to a generated name only when ABSENT, so a DECLARED name is a resolved property value and it is interpolated into two create-path debug lines. Debug level is not an exemption (this provider already masks its convergence-poll debug line), socreate()takes the masker as well, and a test still pins that the create path emits ZERO warnings so a future create-side warning fails rather than shipping unmasked. The issue's site list was re-derived, not trusted. All 11 named sites are real (line numbers had drifted by up to 48). The scan found FIVE it missed, all fixed here: the GlobalTable per-indexWarmThroughputrefusal, its auto-scalingMinCapacity/MaxCapacityrefusal, its dropped replica-override and live-only-index warnings, and its immutable-GSI warning.delete()has the same exposure and is deliberately NOT fixed here — both DynamoDB delete paths interpolatephysicalId, which for those types IS the resolvedTableName— becauseDeleteContext(src/provisioning/region-check.ts) carries no masker by design; the contract states why binding one to the executor's re-resolved PREVIOUS-generation map would fence nothing, and defers it to issue #2007. The six delete-path callers ofapplyAutoScalingDiffcorrectly take the identity default. Tests: 78 across 4 new files, each verified by a mutation probe against the real source — 16 probes in the first round and 16 in the review round, all RED. Two of those probes changed the tests rather than confirming them: reverting the SNS and ASG warn sinks left the suite GREEN, because every fixture put its secret where the inner leaf mask also caught it, so each provider gained a case that ONLY the sink can reach (a NUMERIC value, which the leaf walk declines by design since a number cannot be a whole-value needle; and for ASG the resolvedAutoScalingGroupNamein the message'sphysicalIdposition). The API Gateway v2 status-code key mask needed the same treatment at the length where the sink cannot help — a 3-character HTTP status code sits below the 4-character needle floor. Every suite also pins the DEFAULT identity path emitting unmasked text unchanged, a non-secret value surviving verbatim, and a context carrying no masker not throwing. An independent spec + security review round then found one BLOCKER and six smaller defects, every one of them a place the FIRST round's own rule was applied unevenly rather than a disagreement about the rule. The blocker:warmThroughputOpFormaskedindexNamewhere it built two helperscopestrings and then interpolated the SAME value RAW into its own decrease warning and its already-matches debug line, so a resolved secret asIndexNameprinted in plaintext at DEFAULT verbosity oncdkd deploy— no test reached that method, which is why the first probe sweep missed it. It now builds real sinks and masks the name ONCE into asafeIndexNamethe four sites share. The others: API Gateway v2's inner mask was a scalar test rather than a walk, in the one arm that fires PRECISELY because the value is not a string (so the realistic{ Ref: '<secret>' }shape escaped both layers — confirmed leaking against the pre-fix code);DynamoDBTableProvider.create()installed a masker but built no sink, leaving its partial-create rollback warning unmasked; the SNS'throw'arms were left unmasked on a rationale that was FALSE (the deploy engine maskserror.messageonly on its events-store path — its CLI path logs it raw), so all three throw sites now mask and the comment says why; four further create/update helpers were carrying unthreaded sinks (applyCrossRegionReplicaTagsDiff,waitForTableActiveAfterUpdateincluding its ProvisioningError,applyKinesisStreamingDestination, and SNS's create cleanup); and four comments claiming "ONE masked sink" were inline-wrapping a single call, which is the shape that lets the NEXT warning ship unmasked — they are now real sinks. Writing the missing tests found two further leaks review had not named, both caught because an assertion read the debug stream rather than the warn stream: a second ASG create-path line (Successfully created AutoScalingGroup ... ${groupName}) and BOTH DynamoDB providers'update()entry lines (Updating DynamoDB table ${logicalId}: ${physicalId}), each printing a resolved name raw. The review round's own probes changed three tests rather than confirming them, all the same way the first round's did: when a fixture's secret sits where BOTH the sink and the inner per-value mask catch it, neither layer is fenced. Each layer now has a case only it can reach — a NUMERIC value or a long needle for the sink, a JSON-escaped or sub-4-character value for the walk (3 characters is not contrived here: a DynamoDB index name and table name may both be 3 characters, and an HTTP status code always is). No CLI flag, dependency, doc or state-schema change:docs/provider-development.mdand.claude/rules/providers.mdstill describebuildMfaConfigRequestas the reference implementation, which this change does not falsify. cdkd scrubcan now repair a DELETED output's stored plaintext, which is the population it was documented as the remedy for (issue #2005) —src/cli/commands/scrub.ts.outputSecretsis built from today's DECLARED outputs and the outputs redaction was gated on it being non-empty, so an output removed in an ordinary refactor contributed no needle: the plaintext sitting instate.outputssurvived a scrub that REPORTED SUCCESS, and that is exactly the recordcdkd diffwithholds from display (#1948) and points at scrub to fix. The outputs bag is now repaired in TWO passes. A stored key today's template can still NAME — a declared output, or anExport.Namethis run could FULLY compute (literal, or an intrinsic whose best-effort resolution landed) — keeps the pre-existing pass, which POSITIONS it against the template; a name that did NOT fully resolve is excluded from the accounted set, sincedeploy-engine.tswrites an alias key ontypeof exportName === 'string'with no unresolved-value test, so a warn-and-kept${Foo}really is a key some deploy wrote and accounting for it would SUPPRESS the repair.redactUnaccountedOutputsthen repairs every OTHER key by value match alone, againstallRecordedSecrets(the union of the outputs map and EVERY resource's, filtered to plaintexts at or abovesecret-redaction.ts'sMIN_NEEDLE_LENGTH, now exported for exactly this: its no-source arm whole-value-matches at ANY length, which is sound for a position-scoped bag and not for a union with no position source at all) — the usual shape after deleting an output is that only a RESOURCE still references the secret, so the union is what makes the needle reachable. That union is built in exactly one place and never handed to the resource walk, because the two bags are separate precisely so one resource's secret cannot rewrite another's coinciding literal. Each unaccounted key is scanned ONCE, from the STORED value rather than from the first pass's output: re-scanning an already-positioned MIXED leaf splices a union needle INTO the expression pass 1 just inserted (postgres://admin:{{resolve:secretsmanager:{{resolve:ssm:/app/env}}/db:SecretString:password}}@app-db), which is the corruptionsecret-redaction.ts's token guard exists to prevent, and the single union scan subsumes pass 1 for these keys anyway. TWO refusals bound the widening, both becausestate.outputsis re-applied VERBATIM to CONSUMER stacks — cdkd's exports index (src/state/export-index-store.ts) and everyFn::ImportValue/Fn::GetStackOutputread it — so a FABRICATED redaction ships a literal{{resolve:...}}token into a consumer's own AWS call: nothing is rewritten unless its value genuinely MATCHES a recorded plaintext (redactSecretsForState's value scan with no source), so an unrecoverable needle — secret deleted, rotated away, or the reference gone from the template too — leaves the value untouched and invents / removes no key (delivered by no single guard, measured rather than assumed:scrubStack'stotalSecrets === 0early return,redactUnaccountedOutputs's own empty-map guard, and the fact that an empty secrets map makes every scan the identity are three REDUNDANT reasons — delete any one and the record still comes out byte-identical, so none of them may be cited as the fence; the empty-map guard is additionally reachable on its own, sincetotalSecretscounts the raw maps while the union is filtered toMIN_NEEDLE_LENGTH, so a stack whose every recorded plaintext is sub-floor walks past the early return); and an ACCOUNTED key is never scanned against the union, or a resource's secret value would rewrite a declared output's coinciding literal into that resource's expression. Two residuals are stated rather than implied: a DECLARED output whose template value no longer resolves a secret but whose STORED value is still a stale plaintext is not repaired here either (a redeploy rewrites it), and the repaired population is WIDER than "an output you deleted" — a parameterizedExport.Nameleaves the deploy's real alias key unaccounted on EVERY run, so those keys take cross-resource value matching as a standing condition. The--failKEY scan deliberately keeps readingoutputSecretsrather than the union: it feeds an exit code, and widening it would start failing builds over a finding no scrub can remedy (a key is never rewritten; the remedy is anExport.Namechange plus a redeploy, per #1919). Thecdkd diffrefusal is unchanged — it still cannot decide from a stored string alone whether a value is a plaintext. Covered bytests/unit/cli/commands/scrub-deleted-output.test.ts, every case mutation-probed.- A custom resource's own SDK calls are no longer single-shot, so a mid-propagation authz denial stops failing the deploy on attempt 0 (issue #2033) —
src/provisioning/providers/custom-resource-provider.ts.CustomResourceProvidersetsdisableOuterRetry, and its JSDoc justified that by saying it retries internally instead. That claim was backed by code for exactly ONE error shape:invokeCustomResourceWithRetry's predicate keys on the handler's RETURNEDcfnResponse.Status === 'FAILED', and the loop body contained notry/catchat any point, so a THROWN exception leftcreate()directly. Every AWS call the provider itself made was therefore single-shot with no retry anywhere in the stack — anAccessDeniedExceptiononlambda:InvokeFunctionwhile the deploying principal's freshly-attached policy was still propagating failed the deploy immediately, while every other resource type got 26 retries over 47.75s for the same wording. The loop body is now wrapped, and a thrown error is replayed only when three things hold: the request was not delivered, the PRE-DELIVERY budget is not spent, and the newisTransientAuthzThrow(IAM_PROPAGATION_ERROR_MESSAGE_PATTERNSplusCR_THROWN_AUTHZ_EXTRA_SIGNALS, the two spellings the shared list carries in no form) classifies it. That budget is its OWN, and review is what made it so: the first cut shared the FAILED-response arm'stransientAuthzMaxRetries(2), i.e. 250ms + 500ms of coverage against an IAM-propagation window this repo has measured at 7-12s, so the issue's own scenario still failed with the fix in place. The pre-delivery arm now carriesIAM_PROPAGATION_MAX_RETRIES(26 retries / 47.75s, the same dense schedule every other resource type gets) and the two are counted separately so neither can spend the other's. The reason the big budget is affordable there and not on the FAILED-response arm is thedeliveredfence: this arm fires only when the handler has been invoked ZERO times, so a replay re-runs nothing.CDKD_CR_AUTHZ_MAX_RETRIESaccordingly bounds handler RE-INVOCATIONS only (and is now clamped to 10 —1e9passed the old finite />= 0gate and re-invoked until the 1h per-resource deadline).sendRequestgained anonDeliveredcallback fired the instantInvoke/PublishRETURNS, and that flag — not the wording — is what makes a post-delivery throw un-retryable: the handler is running and will write to THIS attempt's pre-signed URL, which is the hazarddisableOuterRetryexists for.isRetryableTransientErroris deliberately NOT used, because a throttle / 5xx / socket timeout can arrive after acceptance and replaying would invoke a non-idempotent handler twice; a test pins that. The placeholder S3PutObjectinstead gets its own full-schedulewithRetry, since it is idempotent, pre-delivery, and touches no response-URL lifecycle. Two premises in the issue were CORRECTED by measurement rather than assumed:waitUntilFunctionActiveV2/UpdatedV2are not single-shot — the SDK's generatedcheckStatecatches every exception and returnsRETRY, polling tomaxWaitTime(600s) — and theGetFunctiondelete-path probe already fails OPEN, so retrying it would only delay fall-through by up to 47.75s on a genuine denial. TheCR_TRANSIENT_AUTHZ_SIGNALS-vs-IAM_PROPAGATION_*narrowing the issue asked about is INTENDED and stays: the narrow set is matched against handler-authored text where a match buys a re-INVOKE of the user'sCreate(with the documented risk of a Provider-frameworkonEventcreating a second physical resource), while most shared-list entries describe a DOWNSTREAM call the handler made, where a re-invoke is not the remedy. It is a NARROWING and not a strict subset, which the first cut's prose got wrong: three of the six CR entries are in the shared list in no form, and its bareis unable to assumeis there only ANCHORED — so the thrown-error classifier takes the shared list plus the two genuinely-missing spellings rather than the whole CR list, or it would re-introduce un-anchored what the shared list refuses on purpose (a permanent explicit-deny would burn 47.75s). Stale prose fixed alongside: thedisableOuterRetry/transientAuthzMaxRetries/invokeCustomResourceWithRetry/sendRequestJSDoc,.claude/rules/providers.md, anddocs/troubleshooting.md's "invoked exactly once" bullet, which told users aCustom::*create is never retried. Four further defects came out of the same review round and are fixed here rather than filed. (a) The readiness waiters were being REPLAYED:@smithy/util-waiterserializesobservedResponsesinto its TIMEOUT message and those keys read403: … not authorized to perform: lambda:GetFunction …, so the classifier matched and a PERMANENT denial cost 27 x 600s instead of 600s. The fence ismarkNonRetryableon the wrap plus anisMarkedNonRetryablerefusal in the classifier — a property of the error object, so unlike a wording test it cannot be defeated by AWS rephrasing the denial; the exhausted placeholderPutObjectis marked the same way, which is what stops it getting the loop's budget on top of its own (measured: 27 x 27 PUTs before the fix). (b) The waiter's non-TIMEOUT arm serialized the ENTIREGetFunctionresponse —Configuration.Environment.Variablesincluded — into a message thatextractDeploymentEventErrorpersists todeployments/{runId}.jsonl, a durable store contractually free of resource properties; that arm now reports the error NAME plus the function's ownState/StateReason/StateReasonCodeand withholds the payload. (c)delete()'s lenientcatchswallowed the throw and returnedundefined, whichdeleteSkipReasonreads as DELETED — so a permanentlambda:InvokeFunctiondenial printed✓ … deleted, dropped the state record and exited 0 over a handler that never received aDelete, silently orphaning everything it manages; it now returns{ outcome: 'skipped' }with a FIXED reason (the AWS message stays in the warning, since a reason is rendered into theErrorthe deploy-side replacement sites throw and their catch classifies already-deleted by SUBSTRING). (d) The classifier now walks.cause(the issue #2040 class) and the new wait sites are interruptible perdocs/provider-development.md— one SIGINT watch per invocation, threaded into thewithRetryasisInterrupted/onInterruptedand polled by the hand-rolled backoff — while each abandoned response placeholder is swept before its retry, sincecdkd gcscans the ASSET bucket and not this prefix. Covered bytests/unit/provisioning/custom-resource-provider-thrown-retry.test.ts(25 cases, no*Onceprimers); 14 mutation probes were run and every one failed a distinct assertion — including one that caught a new test failing to discriminate: deleting thePutObjectwithRetryleft it green because the loop-level arm absorbed the same throw and issued the same three PUTs, so it was rewritten around what actually differs (one re-PUT key and no sweep, versus three signed keys and two swept). - The rollback replay no longer prints or PERSISTS a resolved secret when its AWS error quotes the value back (issues #2038 / #2031) —
src/deployment/rollback-executor.ts,src/deployment/deploy-engine.ts.resolveReplayPropsre-resolves the journaled operation's properties, so the payload handed toprovider.update/provider.createcarries Secrets Manager / SSMSecureStringPLAINTEXT; AWS validation errors routinely quote the offending value back (Value '<secret>' at 'password' failed to satisfy constraint ...), and this file imported nomaskSecretsInTextat all. FIVE sinks were open — three on the rollback path plus two the audit of the deploy path turned up. (1) ThewithRetryoptions threaded the RAWLogger, soretry.ts's give-up summary printed the verbatim message atwarn— DEFAULT verbosity. (2) TheRollback failed for ...warns printed it directly. (3) The one that matters most, found by the trace #2031 required rather than by the report:extractDeploymentEventErrorcopieserr.messageverbatim into the durabledeployments/{runId}.jsonlS3 sink, and the two rollback entry points did not agree — the standalonecdkd rollbackcommand wiredrecordEventwith no masking at all, while a deploy's in-process auto-rollback masked with the DEPLOY'sperResourceSecrets, a different generation from the one the replay resolved out of the journal (so a rotated secret, or a reference only the previous generation carried, was missed). The fix installs the establisheddrift.tsshape — aRetryLoggermasking the CONCATENATED string, which is whyRetryLogger.warnis optional in the first place — as a localmaskingRetryLogger, plusmaskedRollbackEventErrorfor the eventreason/errorfields;name/awsErrorCode/requestIdare left alone as AWS enum-shaped identifiers. The per-opsecretsbag is HOISTED above thetryinreplaySingleand in thereplayFailedOperationsloop body: it was declared inside each case block, so the sharedcatchthat logs and persists the message structurally could not see it. Masking at the shared executor makes both callers equal and double-masking is a no-op. (4) The audit of the deploy path found the SAME class one caller over and at a strictly HIGHER log level:DeployEngine.provisionResource'sFailed to <op> <logicalId>: <AWS message>line logged the raw message aterrorwhile therecordEventone statement below it was already masked — so the durable sink was clean and the terminal printed the secret. (5) Found while verifying (4), and the one no log-site fix could have closed: the raw provider error is attached as theProvisioningError's CAUSE,formatErrorrenders aCdkdError's cause asCaused by: <cause.message>, andhandleErrorlogs that aterrorlevel for anything escaping the command — so the plaintext reached the CLI boundary even with both log sites masked, because that sink reads the error OBJECT rather than any string cdkd formatted. The newmaskSecretsInError(insecret-redaction.ts, which stays a no-import leaf) masks the message of EVERY link in thecauseCHAIN, cloning each withObject.getOwnPropertyDescriptorssomarkNonRetryable's non-enumerable symbol marker,$metadata/Codeandnameall survive forisMarkedNonRetryable/isThrottlingError/isTransientServerErrorandextractDeploymentEventError; it returns the original by identity only when nothing ANYWHERE in the chain changed. The chain rather than the top link is a review-round correction, and the direction it was wrong in is the instructive part: a provider that wraps an AWS failure in a generic sentence leaves the plaintext one link DOWN, where a top-level-only mask takes its identity-return, reports nothing to mask and hands back an object still carrying it — so the narrow fix was weakest in exactly the shape a caller would trust it for.formatErrorwalking a single level today does not make that safe; it makes it one edit from re-opening. A CYCLIC chain terminates via a visited-set plus a depth cap, andcauseis rewired to the CLONE in a second pass so a cycle is rebuilt among the masked copies rather than re-entering the originals.extractDeploymentEventErrorneeded no change: it copies only the TOP link's message and walks the chain solely for$metadata.requestId/Code. Do not repeat the claim an earlier revision of this entry's code comments made, that the engine "already masked its own error text"; it masked the EVENT only. The two--replacewrap messages that feed that line (Failed to delete old resource .../Failed to re-create ... after the --replace delete-first fallback) are masked at CONSTRUCTION as well, so the plaintext never exists inside a thrownError'scausechain. #2038's acceptance item 3 (thewithRetrycall-site audit) is discharged in the PR body and extended the fix toDeployEngine.withRetryplus the two direct--replacere-create sites, whereperResourceSecretsis populated before the provider call and so is in scope at every retried call; a resource with no recorded secret forwards byte-identically. The maskingRetryLoggerobject itself is now ONE definition in the newsrc/deployment/masking-retry-logger.ts—rollback-executor.tsanddrift.tscarried byte-identical eager copies, and it cannot live insecret-redaction.tsbecause that module is a documented no-import leaf while the helper needsRetryLogger. The deploy engine keeps a second, LAZY variant for its genericwithRetrywrapper, whose call sites (DELETE, the observed-capture drain, the Outputs pass) hold no bag; every site that DOES hold one binds it eagerly, so the file no longer states one rule about looking a bag up by logical id and then breaks it three lines on. The provider-layer sites (elbv2-provider.ts,servicediscovery-provider.ts) carry a resolved payload but hold themaskSecretsCAPABILITY rather than a secrets map — a different mechanism needingCreateContext/UpdateContextthreading, filed as #2050 rather than invented here. Covered bytests/unit/deployment/rollback-executor-log-masking.test.tsandtests/unit/deployment/deploy-engine-retry-log-masking.test.ts, both mocking onlyretry.js's sleep so assertions land on production's actual give-up summary; every new mask site is pinned by a case verified to FAIL against the pre-fix form, and every suite carries a non-redaction control so a blanket-redaction bug fails too. Two fences exist because the review round found them missing rather than because the shape is obvious: the deploy suite captureserroras well aswarn(it discardederrorwith a() => {}stub, which is exactly why sink (4) survived the first cut), and a two-resource case asserts one resource's give-up summary is NOT rewritten with a SIBLING's expression — replacingperResourceSecrets.get(logicalId)with a session-wide union of every bag passed the entire deployment suite, i.e. the per-resource scoping the JSDoc calls load-bearing was unfenced and would have re-opened the over-redaction class of #1912 / #1918. - A transient HTTP 500 no longer terminates an IAM-propagation retry at 12% of its budget (issues #2026 / #2018) —
src/deployment/retryable-errors.ts,src/deployment/retry.ts. The reporting added for #2018 immediately paid for itself: it showed a healthy propagation sequence being ended by ONE unclassifiable error after 5 clean retries, at 5.75s of a 47.75s budget the sequence needed roughly 10s of.withRetrycomputes retryability per attempt from that attempt's message alone, so a single unreadable error ended the sequence regardless of how much budget remained or how many attempts had already classified cleanly. The issue deliberately did NOT prescribe a fix, because the failing error's HTTP status had never been captured and three different remedies followed from three possible values. That capture is now part of the give-up line — it carries[name=... http=... requestId=...], the two fields the classifier actually decides on plus the id AWS support needs — and it selected the branch on the FIRST recurrence:tests/integration/iam-propagation-stressreproduced on round 11 of 11 real-AWS runs (us-east-1, 2026-08-19 08:57:30Z) withUnknownError [name=InternalFailure http=500 requestId=ebf581cc-6072-5ffc-943a-e33312488615]. SQS answered a mid-propagationSetQueueAttributeswith HTTP 500 and an empty body, so the SDK substituted itsUnknownErrorplaceholder, no message pattern could match, and 500 was in no status set.isRetryableTransientErrornow also consultsisTransientServerError— HTTP 500 / 502 / 503 / 504, mirroring@smithy/service-error-classification's ownTRANSIENT_ERROR_STATUS_CODES— ahead of the message patterns, because it is the only check that still works when the response carried no message. The four statuses live in a NEW set rather than being added toRETRYABLE_HTTP_STATUS_CODES: that one is read byisThrottlingError, which SEVENisRetryablecall sites across four files (describe-type.ts:67,dynamodb-globaltable-provider.tsx4,export.ts:1744,intrinsic-function-resolver.ts:5216) pass as a deliberately NARROW classifier —describe-type.tsstates the intent outright ("retry ONLY throttle-shaped failures") — so widening the shared set would have silently converted every one of them into "retry throttles and server errors"; three further sites call it as a bare classification (drift.ts:518,export.ts:1755,dynamodb-index-busy-delete.ts:381) and make the case stronger, sincedrift.tswould have begun reporting "cannot compare" for a resource whose read merely 500'd. 501 stays terminal: the rule is the SDK's four transient statuses, not "5xx". Because a second retryable class would otherwise be INVISIBLE — a sequence spending its whole budget on 5xx rethrew the raw error with nothing printed, the exact silence #2018 removed for propagation — the give-up line now also counts transient-server retries (gave up after 8 transient server-error retries (HTTP 5xx)), and renders byte-identically to #2018's when only propagation retries were spent. Two accepted risks are recorded rather than left to be discovered: a retried 500 can duplicate a non-idempotent create (#2039), and the classifier stays inert for provider catch sites that dropcause(#2040). The issue's more invasive third option (letting a sequence tolerate a bounded number of unreadable errors) was implemented and then REMOVED once the measurement landed, because the issue gates it on the first two branches not covering the observed case and this one does — it can mask genuine terminal errors, and nothing now justifies that risk. Two existing tests asserting 500 as non-retryable were inverted rather than deleted: neither documented a rationale for 500 specifically, both used it as a stand-in for "a status outside the set", and the fence they provided is re-pinned on 501 so the boundary is still tested from inside the 5xx range. This discharges #2018's acceptance item 2, which its own lane handed to this one, and kills its branch 1 (an insufficient budget) on 11 runs of evidence — propagation never consumed more than 11.75s of 47.75s. #2018 stays OPEN deliberately: its own closing criteria require either that this reproduce THE REPORT or that the reporter's failing resource type be obtained, and neither holds — the capture is an SQSSetQueueAttributes, while the report is a LambdaCreateFunctionwhose message (role ... cannot be assumed) classifies fine, so an unreadable-message fix does not explain it. That residue is external input. Covered bytests/unit/deployment/retry-transient-server-error.test.ts(5 cases, built from the captured error shape, 2 verified to fail without the fix) andtests/unit/deployment/retry-classification-signals.test.ts(7 cases, 1 verified to fail without the instrumentation). - The IAM-propagation retry now REPORTS itself, so a propagation failure is diagnosable from the run alone (issue #2018) —
src/deployment/retry.ts,tests/integration/iam-propagation-stress/verify.sh. A real deployment failed withThe role defined for the function cannot be assumed by Lambda.even though the dense propagation retry that absorbs exactly that error was already in place and that path had not changed since. What made it undiagnosable was not the retry but its SILENCE: an exhausted sequence rethrew the raw AWS error, so at default verbosity a run that retried 27 times over 47.75s printed output byte-identical to a build carrying no retry at all — establishing that cdkd had retried required reading the source and diffing two releases, and the three candidate explanations (budget too short / budget exhausted / the retry never engaged) were indistinguishable from outside. A give-up now emits ONEwarnline —MyFn: gave up after 26 IAM-propagation retries over 47.75s of propagation backoff (the full propagation budget) - <AWS message>— and each per-attemptdebugline carries the running total (attempt 15/26, 25.75s backoff through this attempt), so--verboseyields a measurement rather than an inference.warnis the load-bearing choice: the point is surviving a run without--verbose; the per-attempt lines stay at debug because 27 of them is a flood. The summary is gated on having actually retried, so the overwhelmingly common fast-fail prints nothing, and it fires on the non-retryable exit too (a propagation sequence ending in an explicit deny reports the budget it already spent, which is what tells a fast deny apart from one that surfaced after a wait).RetryLogger.warnis OPTIONAL becausedrift.ts's revert threads a MASKING logger (issue 1914), so a requiredwarnwould have been silently satisfied by an unmaskedlogger.warn; that caller now threads a maskedwarntoo, having been the one production caller that genuinely lacked it. Review also corrected two defects in the first cut: the exhaustion note keyed on the retry COUNT, which under-reported a genuine exhaustion whenever a throttle consumed an attempt without advancing the counter, and now keys on the loop's own exit condition; and the counters advanced BEFORE the sleep, so the first debug line claimed 0.25s slept while nothing had been slept yet. No retry BEHAVIOR changed — both counters are reporting-only and feed no control decision, which is what kept this off theinteg-broad/ schema paths. The investigation narrowed the issue's third branch FOR A PLAIN, SDK-ROUTED RESOURCE, by execution as well as by reading (it does NOT settle the reporter's case, whose failing resource type is unrecorded -- a custom-resource or nested-stack handler sets disableOuterRetry and is single-shot by design): the propagation patterns are a structural subset of the retryable table (RETRYABLE_ERROR_MESSAGE_PATTERNSspreadsIAM_PROPAGATION_ERROR_MESSAGE_PATTERNS),ProvisioningErrorembeds the AWS message so the classifier matches, Cloud Control's failure path embedsStatusMessageso the CC route classifies too,ProvisioningErroris not self-marked terminal (onlyResourceUpdateNotSupportedErroris), and the 30-minute per-resource deadline cannot race a 47.75s budget — leaving branches 1/2 as the live hypothesis for that shape, which is what the fixture now measures -- and the fixture's own runs then contradicted branch 1 in THIS account (propagation completed in 7-12s of a 47.75s budget), which is evidence about cdkd's account rather than the reporter's. Covered bytests/unit/deployment/retry-propagation-legibility.test.ts(8 cases: the exhausted summary naming both numbers, its warn LEVEL, silence on a recovered race, silence on a never-propagation fast-fail, the budget-already-spent report when a sequence ends on a different non-retryable error, singular/plural, the cumulative figure on each debug line, and a generic transient retry NOT being annotated with a propagation budget — 5 of the 8 verified to fail without the fix).tests/integration/iam-propagation-stress/verify.shnow deploys with--verboseand prints a[measure]block (wall clock, retries observed, deepest slept budget of the 47.75s allowed, whether the budget was exhausted), recorded whether the run passes or fails — because a passing run has not proved the race is absent, only that this run won it, and it says so explicitly when zero retries fired. - BEHAVIOR CHANGE:
cdkd deploynow exits 2 when it leaves a resource unaddressed (issue #1960) —src/cli/commands/deploy.ts,src/cli/options.ts.cdkd destroyhas treated "cdkd could not address this resource, so it may still be alive in AWS" as a partial failure (exit 2) since issue #1752;cdkd deployprinted the same finding and exited 0, for BOTH of its equivalents — a skipped DELETE (Skipped (not deleted): N, issue #1762) and a replacement whose predecessor survived (of which left an orphaned predecessor: N, issue #1819). A pipeline reading only the exit code was told the template had been applied when it had not. Both cases now feed one run-level counter, summed across every stack, and raisePartialFailureError(exit 2) after the work graph drains — placed there so a genuine deploy failure still wins the exit code, mirroring howdestroy.tscheckstotalErrorsbeforetotalSkipped. This can turn a currently-green pipeline red. The new--allow-unaddressedflag restores exit 0, taking the run-levelPartialFailureErrormessage with it (that message is the only place the run-level remediation text and the cancelled-stack count appear); the summary rows, the per-resource warnings, the⚠banner and theRunCounts.skippedfigure incdkd eventsare untouched. The flag exists because the orphaned-predecessor case has a real not-yet-fixable window — an ACM certificate replacement rejected while a CloudFront consumer still references the old certificate clears itself onceDescribeCertificate.InUseByempties — and because a shell|| [ $? -eq 2 ]wrapper is NOT an equivalent workaround:cdkd deployalso exits 2 forMacroExpansionErrorandResourceUpdateNotSupportedError, so the wrapper would swallow unrelated real failures. Note the two cases differ in recoverability and the docs now say so rather than repeating the issue's framing that both leave the resource "no longer in state": a skipped DELETE deliberately KEEPS its state record and self-heals on the next deploy, while a replacement's survivor is untracked and never retried. Two further surfaces said the run had succeeded and were changed with it, because fixing only the exit code would have left the mis-report half-shipped. (a) The console banner: a run that left a resource alive no longer prints✓ Deployment completed successfully, switching to a⚠ Stack X deployed, but N resource(s) were left unaddressedwarning that mirrors destroy's⚠ Stack X partially destroyed. A pipeline that greps the log for that success string breaks too, independently of the exit code — and note the line also moves from stdout to stderr, since it is now alogger.warn, so a scraper reading only stdout loses it entirely rather than merely failing to match. (b) The events store:RUN_FINISHEDnow recordsresult: 'FAILED'for such a run, matching whatcdkd destroyhas recorded for the identical outcome since #1752 — recordingSUCCEEDEDwhile the same run returned 2 is a split verdict rather than a nuance. That is the third pipeline-visible break in this entry: a consumer filteringcdkd eventsonresultsees these runs flip from SUCCEEDED to FAILED.--allow-unaddresseddoes not touch either: the banner still warns and the run is still recordedFAILED, because the events store records what happened, not what the operator chose to tolerate.recordRunSucceededwas accordingly replaced byrecordRunOutcome(same counts payload, caller-chosen result);recordRunFailedcould not serve it, since it carries error metadata and no counts and theskippedfigure is the only thing in the summary saying a resource survived. Covered bytests/unit/cli/deploy-unaddressed-exit.test.ts, which drives the real commander command and asserts onprocess.exit(20 cases: the clean run staying 0 with its banner intact, each case alone exiting 2, both summed across a two-stack run, per-stack counts not leaking the run total into a clean stack's banner, the flag forcing 0 while the warning and banner switch survive it, and a genuinely failing stack keeping exit 1 with and without the flag; plus both polarities of theRUN_FINISHEDresult and of the cancellation note, and--dry-runcounting nothing). A 21st case intests/unit/cli/deployment-events-run.test.tspins theresultat the layer that PERSISTS it — the deploy-side casesvi.mockthat module, so they assert what deploy passes and never what the store records, and hard-coding'SUCCEEDED'insiderecordRunOutcomepassed the whole unit suite until it existed. A stack the user CANCELLED (declined prefix-migration gate, or an interrupt before it started) unwinds with a barereturn, so its work-graph node completes and the run reaches this throw — the message now says how many stacks never deployed, since naming only the survivors would read as "everything else was applied". - A provider log line can no longer echo a RESOLVED secret in plaintext:
CreateContext/UpdateContextnow carry amaskSecretscapability (issue #1932 item 3) —src/types/resource.ts,src/deployment/secret-redaction.ts,src/deployment/deploy-engine.ts,src/deployment/rollback-executor.ts,src/cli/commands/drift.ts,src/provisioning/providers/cognito-provider.ts,tests/unit/deployment/{secret-redaction-masker,deploy-engine-provider-secret-masker,rollback-executor-secret-masker}.test.ts,tests/unit/provisioning/cognito-provider-masked-warn.test.ts,tests/unit/cli/drift-secret-redaction.test.ts,docs/provider-development.md,.claude/rules/{providers,code-layout}.md. The bug:CognitoUserPoolProvider's dropped-factor warning interpolates a resolved property value straight intologger.warn(${JSON.stringify(properties['EnabledMfas'])} (not a list)). cdkd's secret masking lived at TWO boundaries only — the deploy engine's error / reason text and the intrinsic resolver's own debug line — so a provider's OWN log line sat outside all of it, and a{{resolve:secretsmanager:...}}scalar printed in plaintext at warn level. A mis-shaped value is exactly the input that warning exists to surface, so the unhappy path is the one that prints. Bounded honestly: the exposure is LOG-ONLY. The value does not reachdeployments/*.jsonl(only thrown errors do, and those already pass throughmaskSecretsInText), so this was log exposure, not persisted exposure. Why it needed more than a one-line mask:maskSecretsInTextrequires aRecordedSecretValuesbag as its second argument, and no provider undersrc/provisioning/could reach the one its caller's resolution pass produced — so the fix is a new provider-facing CONTRACT, not a call. The contract: a sharedSecretMaskingContextbase with one optional field,maskSecrets?: (text: string) => string, which BOTHCreateContextandUpdateContextextend. Declared once and inherited rather than written twice, becausecreate()andupdate()receive the same resolved bag from the same callers — a masker on one path only is not a partial fix, it is a fix with a hole in the shape of whichever path a given deploy takes. Optional, so none of the ~130 registered providers needed editing and a provider that ignores it behaves exactly as before. A FUNCTION, not the bag, and the alternatives were rejected on record:RecordedSecretValuesis keyed by PLAINTEXT, so handing it over would make every provider a place a[...secrets.keys()]can leak from — strictly worse than the leak being fixed; the function also keepssrc/provisioning/**free of asrc/deployment/secret-redaction.tsimport, and can be WIDENED later (to coverNoEchoparameters) by changing the callers alone. The codebase already had this shape:drift.tshandswithRetrya maskinglogger.debugrather than the bag. A masked LOGGER was rejected too — providers are registered as SINGLETONS serving concurrent resources, so there is no per-call logger seam and nowhere safe to stash one; the masker is per-CALL for the same reason, and a provider must never cache it onthis. NO LENGTH CAP, deliberately: the issue offered one as an alternative, and it buys nothing here. A cap is not a confidentiality control — a SHORT secret survives it untouched (the realistic content of an MFA-factor enum field), while a LONG value it truncates is one the masker already judged not to be a secret — and its real cost is this warning's whole job, naming the offending entry. Threaded at all THREE external callers that hand a provider a resolved bag —deploy-engine.ts,rollback-executor.tsandcdkd drift --revert; the five providers that re-create inside their ownupdate()pass no context, since they forward the outer call'spropertiesand have none of their own. Thedrift --reverthalf was found by review, and it is the same class as the rollback one:resolveStateSecretExpressions(its own doc calls it "the counterpart of the rollback replay'sresolveReplayProps") re-resolves the state record back to plaintext, so a state record holdingEnabledMfas: "{{resolve:secretsmanager:...}}"re-resolves to a plaintext string, tripsnot a list, and printed the secret on the one command a user reaches for when something is already wrong. Per caller: the deploy engine's CREATE, UPDATE, and all four replacement creates (each bound to that resource's OWN resolution pass, matching the per-resource scoping the persisted-state redaction already has), plus the rollback executor's two reverse-replacement re-creates and both UPDATE arms. The rollback half is not optional:resolveReplayPropsdeliberately re-resolves every redacted{{resolve:...}}expression back to plaintext before the provider call, so a replayed bag is GUARANTEED to carry the concrete secret whenever the resource has one — threading only the deploy engine would have left the contract applied at one caller and absent at the one whose bag is provably plaintext. The rollback re-create arms spread the sharedREPLAYING_STATE_CREATE_CONTEXTconstant rather than mutating it, sincemaskSecretsis per-op and that object is shared by every op in a run.DeleteContextdoes NOT get the capability, and the first rationale for that was FALSE. It claimed everydelete()bag is read from cdkd state, which holds the redacted expression. Review's counter-trace is right: a CREATE writes resolved PLAINTEXT into the IN-MEMORYstateResources, redaction happens at the save choke point on a COPY (redactStateForPersist→scrubResourceRecord), and the in-process rollback hands that same in-memory map toreplayRollback, whose delete arms readcurrent.propertiesstraight off it. A delete bag CAN carry a secret. What is true is narrower and is now what the code says: no providerdelete()interpolates a property value today. The capability was still not added, on correctness rather than effort grounds — that plaintext was resolved by the DEPLOY, whose bag lives inDeployEngine.perResourceSecrets, whileRollbackExecutorContexthas no secrets field and the executor's own per-op map is re-resolved from the PREVIOUS generation, so a masker bound to it would miss exactly the value it exists to catch whenever the generations differ: protection that fences nothing, which is worse than none because it stops the next author looking. Doing it properly needs a new field onRollbackExecutorContextplumbed from the engine, filed separately. The contract now states it as a MUST for the next author: thread the capability BEFORE adding any delete-side line that names a property value. Scope of what it masks, stated because the issue overstated it: cdkd's dynamic-reference secret model only. ANoEcho: truetemplate PARAMETER is OUTSIDE that model by construction — the resolver redacts it in its own debug line (stringifyParameterForLog) but never RECORDS the value — soEnabledMfas: {Ref: SomeNoEchoParam}is NOT masked by this, and no masker built from aRecordedSecretValuesbag could be: that map exists to rewrite a plaintext back onto the{{resolve:...}}expression it came from, and aRefhas none. RecordingNoEchovalues into it would also change whatredactSecretsForStatePERSISTS, so it is filed separately (issue #1998) rather than smuggled in here. That residual is persisted, not log-only — aNoEchovalue quoted back inside an AWS error reachesdeployments/*.jsonl, since the event masker reads this same bag — which is the opposite of the dynamic-reference case fixed here, whose errors already pass throughmaskSecretsInText; the two must not share a framing. Masking the finished LINE is not sufficient, and that is the correction that mattered most. The first draft masked only the assembled message, which review probed live and found defeated byJSON.stringify: masking matches by literal occurrence, and stringification escapes",\and newlines, sosuper"secret-plaintext-valuecame through completely unchanged — i.e. every Secrets Manager JSON document, the commonest real secret shape. A second, independent reason points the same way:maskSecretsInTextmasks an exact whole-value match at any length but only SCANS for substrings of at leastMIN_NEEDLE_LENGTH(4), and a message is always longer than the value inside it, so a 1-3 character secret survives a message-level mask too. The contract's guidance said "applying it to the whole line is never worse than applying it to the parts" — backwards on both counts, and it is that paragraph every future provider copies. It now states the rule: mask the VALUE before it is stringified or interpolated; the message is a fallback. In the provider:buildMfaConfigRequesttakes the masker (typed as the contract's ownSecretMasker, re-exported fromsrc/types/resource.tsthe wayDeleteContextis, so providers still import only fromtypes/and never name the secrets bag — an earlier structural re-declaration bought nothing oncetypes/resource.tsalready imported the alias, and only created room to drift from the contract) and applies BOTH layers: a boundedmaskDeepwalk masks every string leaf and key beforeJSON.stringify, and ONE masked sink —const warn = (m) => logger?.warn(maskSecrets(m))— carries all three of its warnings, so a warning added later is masked by construction. The walk is a walk rather than a top-leveltypeof v === 'string'test because a secret nested in an object leaf is escaped identically.CognitoUserPoolProvider.update()gains acontext?: UpdateContextparameter it did not have; it readsmaskSecretsalone and deliberately does NOT consultdesiredFromAwsReadback(no motivating shape, and theMfaConfigurationrefusal stays unconditionally downgraded). Tests: 29 added — 28 across 4 new files (6 masker / 13 Cognito / 5 deploy-engine / 4 rollback-executor) plus 1 in the drift redaction suite, every one mutation-verified against the real source rather than assumed. Dropping the mask from the provider's warn sink fails 4; dropping ONLY the update-side forward fails exactly the 2 update cases while the create cases stay green (the half-applied-twin probe); removing the pre-stringify walk fails 4 (the escaped-secret, nested-secret and two short-secret cases); reducing that walk to a top-level string test fails exactly the nested case; binding the CREATE-path masker to an empty bag fails 2 engine cases; removing the UPDATE-path context fails 1; dropping the masker at thedrift --revertcall fails 1; sharing one stack-wide bag across resources on the UPDATE path fails the per-resource isolation twin; and injectingreplayingState: trueat the main CREATE site fails 3 of the tightened fences. Cross-resource isolation is fenced on BOTH the create and update paths — a wrong-bag binding is the highest-severity failure this contract can have. Back-compat is pinned in both directions — no context at all still warns UNMASKED (byte-identical to before), a context without a masker does not throw, and a non-secret value is never mangled. One test holds up a property nothing else does:createSecretMaskercaptures its bag BY REFERENCE with nosize === 0short-circuit, and adding one passes every other test in the repo (measured), because every caller today happens to fill its bag before binding. Fence updates rather than deletions, including two that were nearly downgraded in silence: the three NAMED#1463inverse fences assertedcall.length === 3, which this change makes impossible; they now assert the context's key set is EXACTLY['maskSecrets'], keeping the original power (still failing onreplayingStateand on any other field a future edit adds), and their titles were corrected — they no longer claim "passes NO CreateContext", which had stopped being true. Review then caught thattoHaveBeenCalledWith(a, b, c)is itself ARITY-STRICT, so five further assertions were IMPLICIT#1463fences; the first pass relaxed them toexpect.objectContaining({ maskSecrets }), which would have admittedreplayingState: true— and those five were the only cover over the main CREATE site and the property-driven replacement, the two most-travelled paths. They are now EXACT{ maskSecrets: expect.any(Function) }, verified by injectingreplayingState: trueat the main CREATE site and watching exactly those fences red. One weak negative found on the way —not.toHaveBeenCalledWith(...)inrollback.test.tsis arity-sensitive and would have gone vacuously true — was rewritten to assert on the logical ids instead. No CLI flag, dependency, or state-schema change. -
ELBv2Provider/ServiceDiscoveryProviderno longer emit a RESOLVED secret in plaintext, on either their retry-logging or their error-throwing path (issue #2050) —src/provisioning/providers/elbv2-provider.ts,src/provisioning/providers/servicediscovery-provider.ts, newsrc/provisioning/masked-retry-logger.ts. Found by the #2038 acceptance-item-3 audit, and a DIFFERENT mechanism from the one that issue describes. Two surfaces, and the second is the wider one. (a) FivewithRetrycall sites threaded the rawthis.logger: twoModifyListenerAttributescalls whoseAttributescome fromListenerAttributes(create + update), andUpdateServiceAttributes/DeleteServiceAttributeswhose payload comes fromServiceAttributes(create + both update arms).withRetryinterpolates the AWS message VERBATIM into its per-attemptdebugline and into the give-upwarnsummary added for #2018, and that summary prints WITHOUT--verbose. (b)withRetrythen rethrows the RAW error, and all four of these providers' create / update methods interpolatederror.messageinto the wrappingProvisioningError, whichdeploy-engine.tsprints at ERROR — default verbosity again. So masking only (a) left the identical text unmasked one line later, which is what a second review round caught by execution. (b) is also the ONLY surface for a NON-RETRYABLE rejection: the give-up summary is gated onpropagationRetries > 0 || serverErrorRetries > 0, so a validation error failing on attempt 0 logs nothing at all and can escape only through the throw. Both are now masked on the Listener AND TargetGroup arms of both providers, plus three lower-risk lines in the sametryblocks (the partial-create cleanup warns) and two warns that name RESOLVED map data:convertTargets' malformed-entry line, which stringifies a whole rejectedTargetselement, and the TargetGroup attribute-removal line, which names an attribute KEY — a key can itself be a resolved secret, the same question the ServiceDiscovery side already answered forDeleteServiceAttributes, and the two files must not disagree about it. TheTargetsline masks BEFOREJSON.stringify, not after, and that ordering is the fix rather than a detail:maskSecretsInTextmatches by literal occurrence andstringifyescapes",\and newlines, so a Secrets Manager JSON DOCUMENT — the commonest real secret shape — no longer occurs in the stringified text and passes a later mask through verbatim. This is gap 1 of the threesecret-redaction.tsdocuments ("Mask before you stringify"). The first cut masked after, and its scalar-secret test passed under both orderings; only a JSON-document fixture discriminates them, which is what the test now uses. By the time a provider is called a{{resolve:secretsmanager:...}}scalar is already plaintext, so the value AWS quotes back IS the secret. The obstacle was plumbing, not masking: the #1932 item-3 capability (CreateContext.maskSecrets/UpdateContext.maskSecrets, exactly the(msg: string) => stringshapeRetryLoggerneeds) could not reach these sites becauseELBv2Provider.createdeclared nocontextparameter at all. Both providers' publiccreate()/update()now accept the contract's optional context and thread the capability down to the listener / target-group / service methods; absent stays unmasked, so the import path,cdkd drift --revert, and every existing test are unaffected.warnis threaded, not omitted — dropping it would have silenced the give-up summary on these paths only, trading a disclosure for the reporting hole #2018 closed. The masker is applied toerror.messagerather than to the assembled sentence, because only the raw value can reachmaskSecretsInText's WHOLE-VALUE arm (any length); a longer sentence reaches only the substring arm, which ignores needles under 4 characters. Thecausechain is deliberately left untouched soisRetryableTransientError's$metadatawalk still classifies identically — asserted by a test, not just by reading. On the issue's acceptance item 3 (shouldRetryLoggertake the capability directly instead of each caller hand-rolling the object): NO.RetryLoggeris a structural type any caller already satisfies with a masking object —drift.ts's revert has done exactly that since #1914 — so amaskSecretsoption would be a SECOND spelling of one intent across ~50 existing call sites, and two spellings is how a later author reaches for the unmasked one. The first cut instead put one private factory in EACH provider, which review correctly called out as the thing that lets the two FILES drift; the factory now lives once in the leaf modulesrc/provisioning/masked-retry-logger.tsand both providers call it. Scope is these two providers only — the generic provider-call retries indeploy-engine.tsandrollback-executor.tsare the same mechanism one layer up and are NOT addressed here. Covered bytests/unit/provisioning/elbv2-listener-attributes-retry-masking.test.ts(20 cases) andtests/unit/provisioning/servicediscovery-service-attributes-retry-masking.test.ts(12 cases), which assert the STRING that escaped — the logger's argument, or the thrown error'smessage— because a presence-only assertion is satisfied by the very code this issue is about. Each carries its non-vacuity controls (the submitted payload really did contain the plaintext; the retry really did exhaust so the default-verbosity summary really was emitted; and for the non-retryable cases, thatwithRetrylogged NOTHING, so the throw really was the only surface) and a no-masker back-compat arm on every create AND update path proving the identity fallback still emits, unmasked, exactly as before. Every site was mutation-proven independently: reverting any ONE of the five retry sites, either of the four throw sites, or theTargetswarn reds only the test that fences it; reverting the shared factory reds three tests in EACH provider suite, which is what shows the extraction is load-bearing for both; and reverting theTargetsline to mask-after-stringify reds the JSON-document case while leaving the scalar case green, which is the evidence that the ordering — not merely the presence of a mask — is what closes it. Still out of scope and NOT fixed here:createLoadBalancerand the four ServiceDiscovery namespace arms receive no masker at all, and the generic provider-call retries indeploy-engine.ts/rollback-executor.tsare the same mechanism one layer up; both are filed separately. No CLI flag, dependency, or state-schema change. - BEHAVIOR CHANGE: two Cognito user-pool MFA combinations AWS rejects 100% of the time are now refused BEFORE the first API call, instead of partial-applying on the update path (issues #1977 / #1975) —
src/provisioning/providers/cognito-provider.ts. Both combinations reached AWS and were refused there, and on the UPDATE path that refusal arrives too late:UpdateUserPoollands FIRST, so the pool keeps the NEWPolicies/ mutable fields whileSetUserPoolMfaConfigis rejected andupdate()throws with no provider-side unwind. The half that applies is the one LOOSENING authentication and the half that does not is the MFA configuration — a partial apply on an auth surface, unrecoverable by retry until the user reverse-engineers an AWS-worded error naming a field they did not think they were changing.describeUnsupportedMfaCombinationis one pre-flight seam running ahead of every mutating call on BOTH paths: increate()it sits outside the try and beforeCreateUserPool; inupdate()beforereadLiveMfaConfiguration, i.e. before any AWS call at all. Rule 1 (#1977): the value cdkd would SEND forMfaConfigurationresolves to OFF while theSetUserPoolMfaConfigrequest carries a factor sub-block (hasAnyMfaFactorBlock— exactly{Sms, SoftwareToken, Email}MfaConfiguration, deliberately NOTWebAuthnConfiguration, which rides beside OFF on every passkey-only pool). MEASURED us-east-1 2026-08-19 (#1968) as a 100% rejection includingSoftwareTokenMfaConfiguration {Enabled: false}, which is what settles the key on the block's PRESENCE. Rule 2 (#1975): the same request resolvesMfaConfigurationto ON whilePolicies.SignInPolicy.AllowedFirstAuthFactorsallowsEMAIL_OTPorSMS_OTP. Both members and both MFA modes are MEASURED (us-east-1, 2026-08-19):[PASSWORD, EMAIL_OTP]and[PASSWORD, SMS_OTP]are each REJECTED under ON and ACCEPTED under OPTIONAL — so scoping the rule to=== 'ON'is measured-correct rather than merely conservative, and widening it to!== 'OFF'would refuse a working deploy. TheSMS_OTPprobe needed a pool with a validSmsConfiguration(SNS-caller IAM role + ExternalId): without one,CreateUserPoolrefuses the factor outright with a different error, which is why that arm had gone unmeasured. It is a DENY-list even though the AWS message states an allow-list (Only PASSWORD and WEB_AUTHN (if configured) ...), for the same reason the unrecognized-EnabledMfascase warns rather than throws: an allow-list refuses a factor AWS adds later on the day it starts working. The two members ARE today's wholeAuthFactorTypeenum outside the accepted pair, and the constant isas const satisfies AuthFactorType[]so an SDK rename is a compile error rather than a silently-never-matching list. SCOPE LIMIT, deliberate: rule 2 fires only when aSetUserPoolMfaConfigrequest is actually BUILT. With no MFA-routed property,MfaConfigurationrides on the SINGLECreateUserPool/UpdateUserPoolcall, so there is no two-call window and structurally nothing to partly apply — and refusing there would be an over-refusal on a shape AWS ACCEPTS (measured: a pool created withAllowedFirstAuthFactors: [PASSWORD, EMAIL_OTP]and no MFA call comes up fine; only the laterSetUserPoolMfaConfig(ON)fails). CloudFormation parity is settled by A/B rather than assumed, which both issues made an explicit precondition: CFn hits the same two rejections and reachesROLLBACK_COMPLETEon both shapes ({OFF, EnabledMfas: [SOFTWARE_TOKEN_MFA]}and{ON, EnabledMfas: [SOFTWARE_TOKEN_MFA], AllowedFirstAuthFactors: [PASSWORD, EMAIL_OTP]}on ESSENTIALS). So the refusal is PARITY-PRESERVING, not a divergence — cdkd refuses the same SET of templates CloudFormation refuses, only EARLIER and in its own wording; nothing that deploys under CloudFormation is blocked. Both rules read the sent value through ONEbuildMfaConfigRequestcall (with no logger, so the pre-flight is silent andapplyMfaConfig's warnings still fire exactly once), so the refusal and the request cannot disagree. The refusal is UNCONDITIONAL — a STATED EXCEPTION to.claude/rules/providers.md's replay-downgrade rule, recorded at the refusal itself: that rule presupposes the replay could otherwise SUCCEED, and here it cannot, so downgrading would trade a clear cdkd-worded refusal for the identical AWS failure arriving later with a pool to roll back or a partial apply to unwind. Each error names BOTH offending properties, quotes AWS's own sentence, and leads with the remedy that KEEPS MFA on — the MFA-disabling alternative is demoted to a parenthetical rather than offered as co-equal. KNOWN LIMIT, recorded at the refusal: the pre-flight reads the TEMPLATE, never the live pool, so two edits reach the same partial apply without tripping it — deleting theSignInPolicyblock while settingMfaConfiguration: ON, and omittingMfaConfigurationwhile addingEMAIL_OTPto a pool whose live MFA is already ON. Both need a live-state guard and are tracked in #2051. The #1977 warning is REPLACED, not supplemented: theMfaConfiguration is pinned to OFFwarn added by #1932 and reworded by #1968 is gone, and so is the dropped-entry consequence arm that predicted the same rejection — with the pre-flight in front, OFF at that point really does imply no factor block, which makes the survivingdeploys with MFA DISABLEDarm true again rather than merely usually true. The suite grows from 118 to 143 runtime cases intests/unit/provisioning/cognito-provider.test.ts(21 newit/it.eachblocks, 7 removed or rewritten), including EIGHT must-still-deploy negatives that fence the rules against over-refusal (PASSWORD + WEB_AUTHNunder ON,EMAIL_OTPunder OFF,EMAIL_OTPunder the ACCEPTED OPTIONAL,EMAIL_OTP + ONwith noSetUserPoolMfaConfigon both the create and the update path, a factor cdkd does not know, a passkey-only pool under OFF, and seven mis-shapedPoliciescontainers). Every case is mutation-proven against the real provider: removing either seam, deleting either rule, widening rule 2 to!== 'OFF', dropping its scope limit, keying rule 1 on the call rather than the factor block, countingWebAuthnConfigurationas a factor block, rewriting rule 2 from the allow-list, droppingSMS_OTPfrom the deny-list, naming only the first offending factor, dropping theSmsMfaConfigurationarm of the named-block list, moving either seam after its mutating call, and each surviving shape guard inreadAllowedFirstAuthFactorsall turn the intended cases RED. Two comments were corrected to say only what a test holds, since a false fence claim suppresses the retest:readAllowedFirstAuthFactorstraverses with?.rather than thetypeof === 'object'guardconfig-shape.tsuses because the guarded version was MEASURED inert (no template value indexes to anything at those keys), and its string filter is documented as an INERT type narrowing —includesis SameValueZero, so a non-string can never match a deny-list member and deleting the filter fails no test and could not be made to. ItsArray.isArraysibling IS a runtime guard and IS fenced.
Recently Implemented (2026-08-18):
- The DynamoDB DELETE path spends ONE wall-clock allowance instead of stacking three independently-capped waits inside one per-resource deadline (issue #1955) —
src/utils/elapsed-budget.ts(new),src/provisioning/providers/dynamodb-delete-budget.ts(new),src/provisioning/providers/dynamodb-globaltable-provider.ts,dynamodb-table-provider.ts,src/provisioning/dynamodb-index-busy-delete.ts,tests/unit/utils/elapsed-budget.test.ts(new),tests/unit/provisioning/dynamodb-delete-budget.test.ts(new),tests/unit/provisioning/dynamodb-index-busy-delete.test.ts. The gap, in three terms. Every wait on the delete path was individually capped and individually justified, and the caps did not know about each other:waitForReplicaGoneat 600 polls (~12 min) per NON-LOCAL replica, the #1521 pre-delete index-settle gate at 900 polls (~18 min), the #1830 / #1931 index-busy retry loop (~10.4 min at this type's budget of 8), andwaitForTableGoneat 600 polls (~12 min) on a late loop SUCCESS.destroy-runner.tsruns the whole thing under ONEDEFAULT_RESOURCE_TIMEOUT_MS(30 min), so a replicated GlobalTable whose index was transitioning reached ~40 min and a single-region one reached ~40.4 min on the late-success path (1).withResourceDeadlinedoes NOT cancel what it wraps, so an overshoot both replaced AWS's own actionableCannot delete table while indexes are being ...sentence with a genericResourceTimeoutErrorthat never mentions indexes AND left the polling loop running behind a run that had already reported failure (2). AnddeleteTableWithIndexBusyRetry's innerisRetryableis index-busy-ONLY by design (the classifier is a single message regex, which is the whole safety argument for retrying aResourceInUseExceptionat all), so a THROTTLEDDeleteTableescaped todestroy-runner.ts's outer loop — which DOES class throttles as retryable and re-entersdelete()from the top inside the SAME deadline, paying the whole settle-and-retry sequence a second time (2 x ~19.6 min ≈ ~39 min on theAWS::DynamoDB::Tablepath after issue #1950 raised that type's retry budget to 14) (3). What shipped. A shared elapsed budget, acquired once per physical table at the top ofdelete()and drawn on by every wait below it: the #1521 gate,waitForReplicaGone, the per-retry re-arm,waitForTableGone, and (on theTableside) the--remove-protectionACTIVE wait each askpollsWithinDeleteBudget(ownCap, budget)for the SMALLER of their own constant and what the allowance can still afford, so the path spends the deadline once rather than three or four times (1).deleteTableWithIndexBusyRetrygained an optionalshouldKeepRetryingpredicate, ANDed with the index-busy classifier so a terminal error stays terminal regardless of budget; both providers pass one that goes false when the allowance is spent, which makes AWS's refusal terminal and surfaces ITS message rather than the deadline's, with nothing still polling afterwards (2). The budget lives in anElapsedBudgetRegistrykeyed by physical id rather than being created per call, so the outer loop's re-entry CONTINUES the same clock instead of restarting it — it is released on a terminal success and on the already-gone skip arm, and deliberately RETAINED on a throw, which is exactly the compounding case (3). Sizing.DYNAMODB_DELETE_BUDGET_MSis 26 min, one number for both types (the two per-type RETRY budgets are unchanged and still decide how the allowance is SPENT; this decides how much there is). It covers theTablesingle pass (~19.6 min) outright and clamps the GlobalTable single-region exhausted-budget case (~28.4 min) by ~2.4 min — which costs nothing, since that path ends by throwing AWS's own index-busy sentence either way, just sooner. Every other overshooting shape (the late-success gone-wait, any replicated table, and both types' throttle-compounded double pass) previously ended in the genericResourceTimeoutErrorwith the loop still running, so a bounded stop is a strict improvement there rather than a trade. Both providers now declaregetMinResourceTimeoutMs()returning26 min + a 4 min margin= 30 min, which EQUALS today'sDEFAULT_RESOURCE_TIMEOUT_MS, so at default settings nothing moves (max(30m, 30m)); what it buys is that a lowered global--resource-timeoutcan no longer re-create the crossing, while a per-type override still wins as the documented escape hatch. The margin absorbs what the poll arithmetic does not price: theDeleteTable/UpdateTablecalls themselves, the outer loop's own 5 + 10 + 20s backoff (which elapses betweendelete()calls and therefore inside the budget's clock but not inside any wait), and the one-poll floor each wait is granted so it never reports "did not settle" without having looked. Review round (four reviewers) added five more edges of the same design. (1) The GlobalTable--remove-protectionACTIVE wait was MEASURED by the allowance but not CLAMPED by it — it runs FIRST and its predicate is ACTIVE and no transitional index, so oncdkd destroy --remove-protectionagainst a table with a building GSI it spent its full ~12 min at t=0, and on a throttle re-entry it started already drained and polled another 600 times unclamped. ItsTabletwin had been budgeted; both now are. (2) The GlobalTable auto-scaling teardown — table-level plus one call per GSI, per non-local replica — runs before the first budgeted poll and is not poll-bounded at all; each call retries account-wide application-autoscaling throttles onwithRetry's ~47s schedule, so a two-replica twenty-index table could spend over half an hour there. Its RETRY count is now bought down by the remaining allowance while every call is still ISSUED, because skipping leaks a scalable target a future table of the same name inherits (PR #403). (3) A clamped wait reported its CLAMPED count as though it were the wait AWS was given (did not disappear within 1safter cdkd had been deleting for twenty-six minutes); every such message now names which clock ended the wait, via one shareddeleteBudgetExhaustedNoteand aclampedFromCapfield onwaitForIndexesSettled. (4) Worse, a budget-clampedwaitForTableGoneturned a SUCCESSFUL delete into a reported failure —DeleteTablehad already returned 200, so the deletion was no longer in cdkd's hands. A budget-clamped gone-wait now WARNS and returns; a genuinely exhausted 600-poll cap still throws, unchanged. (5) A replica-wait failure inherited the enclosingcatch'sFailed to describe ... before deletewrapper — a describe failure that never happened — so an already-actionableProvisioningErrornow passes through verbatim. Plus four smaller ones:attemptsWithinfailed OPEN on a non-finite per-attempt cost (returning the full cap, i.e. the pre-fix behaviour) and now fails closed; the registry key is region-qualified, since a DynamoDB table name is unique per region; the default clock isperformance.now()rather thanDate.now(), because a forward NTP correction on a wall clock drains the allowance instantly mid-delete; and bothgetMinResourceTimeoutMsJSDocs plusdocs/cli-reference.mdnow state that the self-report is per TYPE, so--resource-timeout 5mfor fail-fast CI does not shorten these types' CREATE / UPDATE deadline either (the per-type override is the escape hatch). The test reviewer also found two of the new tests VACUOUS — both release-semantics arms passed with therelease()calls deleted, because the stub left the table gone and the seconddelete()took the skip arm before any budgeted wait — and five paths with no coverage at all (waitForReplicaGone, whose stub only ever had a LOCAL replica; theTableretain-on-throw arm, i.e. the one type #1955's third term was actually filed against; both flip waits; the providers' registry ARGUMENT as opposed to the registry's own keying; and a floor-vs-ceil rounding case whose fixtures divided evenly). All are now covered with paired polarities, and each was re-run under the exact mutation that motivated it to confirm it reds.
Second review round turned the allowance from a PREDICTION into a bound, and corrected one of the first round's fixes. (a) Every wait computed its poll count once at entry from attemptsWithin, which prices a poll at the measured ~1.2s and then treated that prediction as a bound. It is not one: under sustained DescribeTable throttling the SDK's internal retries push a poll to ~3s, so a wait entered with 12.1 min of allowance left was granted its full 600 polls and ran ~30 min — past the allowance AND the margin, with withResourceDeadline firing behind it, i.e. term (2) re-entering through the mechanism built to remove it. The grant is still computed at entry (it is what gives a caller a sensible cap and a truthful polls-granted figure) but every loop now re-checks the allowance per iteration through a DeleteWait object, and the check sits AFTER the first probe so the one-poll floor survives. (b) The first round's auto-scaling clamp was inert: the claim that the teardown inherits withRetry's default schedule was false — both withRetry sites in applyAutoScalingDiff are in the REGISTER branch, and every delete-path caller passes newSettings: undefined, which takes the bare-send teardown branch. Rather than delete the parameter, the teardown was wrapped in the same throttle-only retry, because the asymmetry it exposed is a real gap: the register branch retries so a swallowed throttle cannot leave a target silently UNregistered, and the teardown had no retry while a swallowed throttle there leaves one silently REGISTERED — the PR #403 leak a future table of the same name inherits. The retries are funded from the SURPLUS above a 20-minute reserve, so a throttled burst cannot spend the allowance the delete itself needs (bounding it by the whole remaining allowance would have converted a silent leak into a destroy that fails on the first index-busy refusal), and at zero retries withRetry still runs each call once, so the leak-prevention never depends on the retry. (c) The warn-versus-throw split on waitForTableGone was decided by attempts < cap, i.e. by arithmetic: with ~14 min of the allowance spent the wait is still granted ~500 polls — ten real minutes of DescribeTable — and that warned and dropped state while the code called twelve minutes "something is actually wrong". A named DELETE_SHORT_WAIT_POLLS (60 polls, ~72s, twice the top of the provider's own recorded 5-30s typical delete) now decides both the split and whether the strong exhaustion note is emitted at all; above the floor the note degrades to a factual capped-at clause with no "re-run the destroy" advice. (d) The note quoted the shipped constant rather than budget.totalMs, so under the test seam it claimed 26 minutes for a 60-second budget. (e) Registry entries are retained on a throw by design, but "retained" now means "until the deadline this allowance was sized against has certainly fired" — acquire takes a reuse window, so a provider instance shared across operations (the deploy engine hands the same one to rollback-executor.ts) cannot hand a fresh delete a spent allowance. (f) The 600-poll waits were described as "~10 min" in the providers and "~12 min" in the budget arithmetic; 12 is right at the measured ~1.2s per poll and all sites now agree. Each of the eight changes was re-run under the exact mutation that motivated it — including removing the in-loop check, moving the exhaustion check ahead of the probe, and unwrapping the teardown — and all eight red.
Deliberately NOT done: the raw caps are unchanged — nothing was shrunk, so a table that genuinely needs the #1521 gate's 900 polls still gets them when the allowance can afford it; the fix bounds the PATH, not the individual waits. Tests: the new elapsed-budget.test.ts pins the primitive (draining, the backwards-clock clamp, the one-poll floor and its cap-clamp, registry REUSE on re-acquire, per-key isolation for the concurrent-sibling case, release); dynamodb-delete-budget.test.ts drives REAL delete() calls through an injected clock — real time cannot drain a 26-minute allowance and vi.useFakeTimers() would freeze the providers' own polling loops — with one block per term, plus the discriminating halves (the gate keeps its FULL 900 while the budget can afford it; a budget with time left still spends the full retry count) so the fences cannot pass by simply switching the waits off. Each CI-blocking assertion was verified to fail against the pre-fix behaviour by mutating the real providers: dropping the gate's budget reds 3 tests, dropping shouldKeepRetrying reds 2, releasing the budget on every entry reds the compounding test, and dropping the gone-wait's budget reds its own. The existing #1950 fence in dynamodb-index-busy-delete.test.ts asserted the ~40.4 min late-success total as a TRACKED overshoot and said in-code that a fix bringing it back under the deadline should red it; it does not, because the fix bounds the path rather than the caps — so that fence now asserts BOTH halves (the raw caps still sum past the deadline, AND the allowance the path may actually spend is under it with margin), which is what stops a change deleting the budget from leaving the file green.
- A dynamic-reference lookup now runs against the STACK's region instead of whatever region the process-global client singleton last held (issue #1957) —
src/deployment/intrinsic-function-resolver.ts,src/utils/aws-clients.ts,tests/unit/deployment/dynamic-reference-region-scoped-clients.test.ts,tests/unit/utils/aws-clients-region-scoped.test.ts. The gap: issue #1933 closed the CACHE as a cross-region carrier, but every AWS lookup in the resolver readgetAwsClients()— the process-global singleton — so a resolver constructed for region B could still make its FIRST lookup against region A's client, with no cache involved. Three commands reach it:cdkd deploydefaults to--stack-concurrency 4and re-points the singleton per stack, so two stacks in different regions race for one mutable global and stack B'sGetSecretValue/GetParametercan execute against stack A's client (the value is redacted on its way into state, so nothing downstream shows which region answered);cdkd scrub --allinstalls its clients ONCE while resolving per-stack regions, so a region-BSecureStringwhose region-A namesake is a plainStringwas classified PUBLIC and its plaintext left instate.json— the GHSA-p5qg-v9gv-hc7w disclosure class rather than merely a wrong value; andcdkd drift --revertWRITES the resolved value to a live resource, where a wrong-region read is a wrong write. The fix is one mechanism in the resolver rather than a patch at each of the tensetAwsClientscall sites, because the resolver already fixes its region at construction (#1933's instance scoping) and every construction site already passes the per-stack region — what was ambient was never the region it KNOWS, only the clients it reached for.AwsClientsgainedconfiguredRegion(the EXPLICITLY configured region and nothing else),credentialConfig(theprofile+ explicitcredentialshalf, deliberately WITHOUT the region, and CLONED so a caller cannot mutate the source instance's credentials through it) andwithRegion(region); the resolver'sclientsForRegionderives and caches one sibling per foreign region. The ambient bag is reused only when its region is CONFIGURED, and getting that condition right took three attempts, each of which looked sufficient. Readingprocess.envwas the first and is worse than not knowing: the SDK memoizes a region-less client's region at its first resolution (@smithy/node-config-provider'sloadConfigwraps the chain inmemoize) whileswitchRegionkeeps mutatingAWS_REGIONper stack, so an env-derived answer can report region X for a client long since pinned to P. Asking the SDK (ssm.config.region()) was the second and is still wrong:clientOptionsOMITSregionfrom an unconfigured instance, and the getters are lazy, so the bag is not a bag of clients but a bag of DEFERRED constructions — each member resolves and memoizes its own region at its own instant, andssmpinningus-west-2says nothing about asecretsManagerconstructed a moment later, after a sibling stack'sfinallyrestoredAWS_REGION, which pinsus-east-1. That is Site 1 of this very issue surviving inside the arm meant to fix it. Only a CONFIGURED bag passesregionto every member and is therefore internally consistent, andwithRegionalways sets one — so an unconfigured ambient is not "of unknown region" but "of not-yet-decided region", and it SCOPES. With no region left to await, the seam is synchronous again. What keeps the ~260 suites that stubgetAwsClients()with a plain object green is now a separate, explicitly named arm — an object that cannot DERIVE a sibling (withRegionabsent) is a test double and is used as-is — rather than a side effect of the region guard, so the security arm no longer has a testing job to do. On credentials, one framing in the first revision of this entry was wrong and is retracted:--profileDOES reach a freshly constructed client on its own, becausesrc/cli/program.tssetsprocess.env.AWS_PROFILEin apreActionhook for every command, so carryingprofileis belt-and-braces rather than what stands between a user and the wrong account. What genuinely has no environment path is an explicitcredentialsobject, plus any library caller that constructsAwsClientsdirectly and never runs that hook.--role-arnneeds nothing carried at all, sinceapplyRoleArnIfSetexports the threeAWS_*credential variables. Rebound sites: both dynamic-reference lookups ({{resolve:secretsmanager:...}}/{{resolve:ssm:...}}), theAWS::SSM::Parameter::Value<>template-defaultGetParameter, the EC2DescribeInstances/DescribeLaunchTemplatesattribute lookups (a foreign-region client answersNotFound, degrading to the physical-id fallback those branches exist to avoid), andFn::GetAZs— which additionally fixes an explicit foreign region (Fn::GetAZs: us-west-2from a us-east-1 deploy), sinceDescribeAvailabilityZoneslists the AZs of the region the CLIENT points at and itsregion-namefilter narrows rather than redirects, so the call returned an EMPTY list and cached it as the resolved value. Two template-derived regions are additionally GATED, because a region stopped being only a filter value and started selecting an ENDPOINT.Fn::GetAZsis the first. Its argument is TEMPLATE-derived and can arrive through anFn::ImportValueor a parameter, and the SDK substitutes a region into the service hostname —evil.example.com#yieldshttps://ssm.evil.example.com/#.amazonaws.com, a SigV4-SIGNED request (access key id + signature) to an attacker-controlled host — so a value that is not region-shaped is now REFUSED before it can reachwithRegion(charset-based: lowercase alphanumerics and hyphens, which cannot express a host delimiter, and deliberately not an AWS region registry, sinceeusc-de-east-1and friends keep arriving;clientsForRegionkeeps a softer backstop for any future caller). And an EMPTY availability-zone list is now refused rather than cached: every enabled region has at least one AZ, so an empty answer means the wrong region's endpoint replied or the region is opt-in and not enabled, andcachedAvailabilityZonesis module-global so one degenerate answer would be replayed for the rest of the process. The second gated path isFn::GetStackOutput'sRegion, which is PRE-EXISTING rather than introduced here but has two trusted sinks and is one call away from the same gate: aCloudFormationClientregion (a SigV4-signedDescribeStacksto an attacker-named host) AND an S3 STATE-KEY segment (cdkd/{stack}/{region}/state.json, where a traversal reads a key the template never named). It now refuses a non-region-shaped value and passes the CANONICAL form to both sinks so they agree on one spelling. An unsafe region reachingclientsForRegionitself now THROWS rather than falling back to the ambient: forscrub/drift/import, whose region is state-derived, falling back means reading ANOTHER region — which is the disclosure, so a stopped command is strictly better. TheGetCallerIdentityinresolveAccountIdentityis deliberately NOT rebound — it resolves the ACCOUNT and is region-agnostic by construction. BEHAVIOUR CHANGE worth calling out, accepted deliberately: for a REGION-AGNOSTIC stack (noenv.region) run with neither--regionnorAWS_REGION, the region cdkd computes is its hard-codedus-east-1fallback, so the lookup now goes there — where before it followed the ambient clients to whatever~/.aws/configsaid. The new behaviour is the consistent one, sinceus-east-1is already the region cdkd keys that stack's state file, lock and export index under, so the resolved value and the record storing it finally agree; previously they did not, which is this issue's own defect one layer up. A stack WITH an explicitenv.regionis unaffected — every caller prefers it. Tests: 32 units across two new files. The resolver file fakes the SDK client CLASSES rather thangetAwsClients(), which is exactly what makes the bug observable — the usual plain-object fake has no region, so "which region answered?" cannot be asked of it — and asserts the region AND the profile the client was BUILT with, plus the acceptance-criterion-3 case (a region-BSecureStringrecorded as secret while region A holds aStringnamesake) and four negative cases pinning that the common path constructs no extra client. TheAwsClientsfile uses no mocks at all, reading the resolved region and credentials back off REAL SDK clients. Both were verified against mutations: disabling the seam reds the positive resolver cases while the negative ones correctly stay green, removing theFn::GetAZsregion gate at BOTH layers reds the refusal case with the fake client recording a construction forevil.example.com#, reverting each of the two EC2 rebinds, theFn::GetAZsfallback arm, the empty-AZ refusal and the client cache's region key reds exactly one case each, deletingcanonicalizeRegionfrom the seam reds the case-folding test, removing theFn::GetStackOutputgate letsUS-WEST-2reach the S3 state key verbatim and stopsevil.example.com#being refused, and restoring the one-member short-circuit reds the divergent-bag case with the resolver forus-west-2receivingus-east-1's password — the disclosure itself, reproduced. Live coverage extendstests/integration/dynamic-ref-cross-regionwith a fourth arm and a new phase: a MIXED-TYPE source parameter (one shared name that is a plainStringin region A and aSecureStringin region B — the only arm whose failure mode is a disclosure rather than a wrong value, since secret-ness is decided by the TYPE), and acdkd scrub --allphase run from region A against state deliberately seeded to hold region B's plaintext the way a pre-GHSA-p5qg-v9gv-hc7w binary would have left it. Scrub is the right vehicle for the issue's acceptance criterion 3 because it installs its clients ONCE and then resolves stacks in several regions, so the wrong-region read is STRUCTURAL rather than timing-dependent — pre-fix the injected plaintext is classified against region A'sStringnamesake, judged public, and survives the scrub. The issue's acceptance criterion 2 (a cross-region deploy at the DEFAULT--stack-concurrency) is deliberately NOT covered and cannot be, yet: the same process-global singleton is read by 42 provider files, most of them at CALL time, andswitchRegionmutatesprocess.env.AWS_REGIONfor the whole process, so a lost race at the default concurrency of 4 CREATES resources in the wrong region — a billed orphan invisible to region-keyed cleanup. That provisioning half is filed as issue #1981; until it lands, a cross-region deploy at the default concurrency is unsafe to run at all, and the fixture's header records that decision so the arm is not "helpfully" added later. - The resolved dynamic-reference cache can no longer carry one region's secret into another region's resource, nor make a later stack's secret scan come up empty (issue #1933, PARTIAL — see below) —
src/deployment/intrinsic-function-resolver.ts.{{resolve:secretsmanager:...}}/{{resolve:ssm:...}}values are REGIONAL, but the cache was a process-global map keyed by the expression STRING alone and never reset between stacks, so the first region to resolve an expression won it for every later stack in every other region, andcdkd scrub --allcould report a later stack clean because its cache hit re-recorded nothing into that stack's secrets map. The cache now lives on the RESOLVER INSTANCE (one per stack, each pinned to its own region), which settles both dimensions at once; a key change alone would have left the cross-stack half open. Each entry also carries the secret VERDICT that produced it, so another region's resolver retracting the process-global memo cannot stop this one redacting its own secret — and the cache-hit arm reads that verdict ALONE, since honouring the global store there would re-admit a foreign resolver's answer and could turn a PUBLIC value into a redaction needle. An ssm value whoseTypeis not a definitiveSecureStringis no longer cached at all, so the next pass re-asks instead of inheriting a transient verdict; both lookups gained throttle-only retries and a per-expression warn dedupe to absorb the extra call volume, androllback-executor.tsnow builds ONE resolver per replay rather than one per op. The other half — the lookups themselves running through the process-ambientgetAwsClients()singleton — was issue #1957 and is now closed too (see the entry above it). Live-covered by a newtests/integration/dynamic-ref-cross-regionfixture (two regions, one shared parameter name,StringandSecureStringarms, asserting each stack resolves its own region's value and that state holds the unresolved expression and neither plaintext). -
AWS::Cognito::UserPoolMFA: an enable-on-update no longer hits AWS's refusal, a malformedMfaConfigurationis refused instead of silently disabling MFA, and three silent cases now warn (issues #1925 / #1932) —src/provisioning/providers/cognito-provider.ts.update()now withholdsMfaConfigurationfromUpdateUserPoolwhen an MFA factor is declared, the same gatecreate()already had: measured against real AWS,UpdateUserPoolwithONand no factor enabled is REJECTED (InvalidParameterException: Cannot turn MFA functionality ON, once the user pool has been created— a message that reads like a blanket prohibition but is in fact conditional on the factor existing), so a template first enabling MFA used to fail the deploy. The value still lands via the laterSetUserPoolMfaConfig. Withholding is safe becauseUpdateUserPooldoes NOT reset an omittedMfaConfiguration— also measured, with controls proving the same call DOES reset an omittedAutoVerifiedAttributes, i.e. that API's "unspecified attributes take their default" rule holds field by field rather than API-wide. A declarednull/ non-string now goes throughrequireConfigString(refuse on a template create, warn on replay / update) rather than??-defaulting, which could DISABLE MFA; a blank or whitespace-only value warns; a recognized factor pinned toMfaConfiguration: OFFwarns; and every substituted value is reported aseffectivePropertiesso the substitution cannot read back as permanent drift. Live-covered by a newCDKD_TEST_UPDATEphase intests/integration/cognitoexercising both transitions (OFF -> ON, and an undeclared downgrade announced against the liveOPTIONAL). - The index-busy
DeleteTableretry budget is PER TYPE, andAWS::DynamoDB::Table's covers more than the FLOOR case (issue #1950) —src/provisioning/dynamodb-index-busy-delete.ts,dynamodb-table-provider.ts,dynamodb-globaltable-provider.ts,tests/unit/provisioning/dynamodb-index-busy-delete.test.ts,dynamodb-table-provider-delete-index-busy.test.ts,dynamodb-globaltable-provider-delete-retry-warm-throughput.test.ts. The gap: the retry that absorbs AWS'sCannot delete table while indexes are being created, updated, or deletedshipped atwithRetry's default of 8 retries, shared by both DynamoDB types. The livedynamodb-gsi-updaterun on 2026-08-18 measured a FIVE-item table's GSI create consuming 7 of those 8 — five items is about as small as a backfill gets, so those ~8 minutes are AWS's roughly fixed index-create latency rather than a data-proportional cost, and the budget cleared only the floor of the condition it exists to absorb. The change: the budget became a PARAMETER of the shared entry point, with one derivation and two constants —TABLE_DELETE_INDEX_BUSY_MAX_RETRIES= 14 (raised) andGLOBAL_TABLE_DELETE_INDEX_BUSY_MAX_RETRIES= 8 (unchanged, exactly what that type has shipped since #1830). A re-arm poll costs ~1.2s (INDEX_SETTLE_POLL_INTERVAL_MSof sleep plus aDescribeTableround trip — the live per-attempt gaps were 73/74/77/80/80/80s), so a 60-poll re-arm is ~72s and the loop isN x 72 + (8N - 17)seconds: ~10.4 min at 8 (the~8.8 minpreviously quoted everywhere priced a poll at the bare 1s interval, which the live run disproved) and ~18.4 min at 14. Why per type: what each provider has already spent out of the same 30-minDEFAULT_RESOURCE_TIMEOUT_MSby the time it reaches the loop differs.Tablehas spent at most a<=60-poll ACTIVE wait (~72s), so loop(14) + that wait is ~19.6 min — two thirds of the deadline, leaving ~10.4 min.GlobalTablemay have spent the #1521 pre-delete gate's 900 polls (~18 min): on the EXHAUSTED-budget path — the one a budget bounds, and the one wherewaitForTableGoneprovably does not run because the loop throws — gate + loop(8) is ~28.4 min and fits, while gate + loop(14) would be ~36.4 min and does NOT — a crossing the raise would have CREATED on the common single-regionTableV2shape, whose user-visible result is the genericResourceTimeoutErrorthe bounded re-arm exists to prevent. (On the late-SUCCESS path the gone-wait's 600 polls DO run and the same shape reaches ~40.4 min, over the deadline at the current budget already — not a consequence of this calibration, and part of #1955.) So that type keeps 8, which also honors #1950's own point that moving a shipped type's wall-clock destroy behaviour deserves its own change. Nothing aboutGlobalTable's AWS-facing behaviour moves in this PR. Its replicated shape (gate + ~12 min per non-local replica + loop) is ~40 min, over the deadline before this change and unchanged by it — #1955. On theTableside, 16 ("just double it") does not survive the same arithmetic (~27.4 min if a poll costs 1.5s rather than the measured 1.2s) and 15 is already past the two-thirds fence, so 14 is the largest value with real margin — margin rather than "just under" becausewithResourceDeadlinedoes NOT cancel what it wraps, and an overshoot leaves the loop issuingDeleteTablein the background after the run reported failure. What it buys is ~2.3x the measured floor, NOT coverage of an arbitrary backfill, which is data-proportional and unbounded; past the budget the outcome is unchanged and still actionable (AWS's own sentence, and a re-run that succeeds once the index is ACTIVE). The warning line now states how long cdkd will not give up for, as a floor (will not give up for at least ~14 more minutes), derived from the caller's budget like its attempt count — phrased as patience rather than as a floor on the WAIT, since the re-arm returns the moment the index settles and a table clearing in 90s prints the same line. Its attempt count and minutes are clamped at zero, and its poll cadence is now derived fromINDEX_SETTLE_POLL_INTERVAL_MSinstead of a literal~1ssitting next to a derived total;waitForTableActiveAfterUpdatereads that same constant, so the ACTIVE-wait term of the arithmetic is denominated rather than asserted. Tests: the budget fences MEASURE the real loop — attempts, re-arms and every millisecondwithRetryasks to sleep, through a recording sleep seam — instead of restating a 47s backoff total that was itself derived from the old budget. One per type:Tablemust clear 2x the measured ~8 min floor (reds at 8: 623000 < 960000) and stay inside two thirds of the deadline (reds at 15: 1255000 > 1200000);GlobalTablemust fit the OBSERVED #1521 gate cap plus its loop under the deadline (reds at 9) AND still clear the same measured floor (reds at 1, which previously left all 40 tests green), with theTablebudget asserted as a premise not to fit there and the late-success total asserted as the tracked overshoot it is. TheTableprovider's re-arm wiring gained the binding fence its sibling already had — a 900-poll mutation there previously left every test green while the real worst case became ~3.5h — and the fence now IMPORTSTABLE_ACTIVE_WAIT_ATTEMPTS/TABLE_GONE_WAIT_ATTEMPTSrather than copying them, after a 60 -> 900 mutation of the first left all 51 tests green.waitForReplicaGoneandwaitForTableGonenow readINDEX_SETTLE_POLL_INTERVAL_MStoo, so every poll count this arithmetic prices is denominated in the unit it claims. - The stack-outputs key space is guarded at every writer: a colliding
Export.Nameis skipped, a suppressed output no longer reserves its name, and a secret-bearing export name is refused (issue #1919) —src/deployment/outputs-export-alias.ts(new),src/deployment/deploy-engine.ts,src/cli/commands/scrub.ts,tests/unit/deployment/deploy-engine-outputs-export-name-collision.test.ts,tests/unit/cli/commands/scrub-export-name-collision.test.ts,docs/cross-stack-references.md. Bug:state.outputsis keyed by output NAME and an output carryingExport:is ALSO aliased under its export name in the same bag, while a parallel bag holds each key's unresolved template value as the redaction POSITION source (issue #1910). Those two bags disagreed whenever anExport.Namewas spelled like another output's name: the alias put the exporting output's resolved value under the colliding key while the source pass put the OTHER output's unresolved value there, soredactByPathpositioned that leaf by a source belonging to a different output and persisted one output's{{resolve:...}}reference as the other's value intostate.outputsand the exports index — the wrong-reference class #1910 removes, one layer up. Order-dependent: it needed the exporting output to be iterated AFTER the colliding-name output.cdkd scrubhad the same two writers with the winner REVERSED (its alias write runs after the owner's, in one loop), which is worse: its bag is legacy state holding plaintext, and the command is the advisory's own remediation path, so it rewrote a CORRECT public output into a reference naming a different output's secret. Fix, in three parts. (1) The key-space rules and their user-facing messages move to a sharedoutputs-export-alias.tsso the deploy engine and scrub cannot drift again; an alias whose name is also a PUBLISHED output name is skipped and warned. (2) A condition-suppressed output no longer writes a position source and no longer reserves its name — it publishes no value, so reserving it would drop a WORKING export (and delete its exports-index entry on the next producer deploy) because an unrelated condition went false. That is sound becauseresolveOutputsruns at most once per deploy: its two call sites are mutually exclusive anddeploy()resets the bag, a property now pinned by a test since the decision rests on it. (3) AnExport.Namethat is an intrinsic can resolve to secret PLAINTEXT, and the resolved name becomes a state KEY while redaction walks VALUES only — so such an alias is refused, detected from the name's OWN resolution map (exact, rather than a containment scan that was wrong in both directions) and masked exhaustively in the warning, which omits the name entirely rather than claim a masking it could not perform. That name resolution's RECORDINGS are merged back into the pass: resolving warms the resolver's cache and its hit arm re-records only what it can still prove is secret, so discarding them left an unpinned ssm reference (#1901) invisible to every later output — a regression against the previous shared-context behavior, closed at both sites (the engine, and scrub's export-name loop, which runs before its value loop). Scrub additionally drops its whole outputs position source when an intrinsic export name cannot be resolved at all (the deploy keyed state under a name this run cannot reproduce, and it could be any output's), and REPORTS — never rewrites — a state KEY that already holds plaintext, so--dry-run --failstops calling such a state clean; the key is the export name consumers resolve by, so the remedy is a template change plus a redeploy, counted and reported SEPARATELY from the stacks actually rewritten so no summary claims a remediation it did not perform, and bounded at four characters like cdkd's own substring redaction so a degenerate short secret cannot fail the CI gate repo-wide. Every user-facing name this path prints — an export name, a state key, an output logical id — is stripped of control characters through the helpersrc/utils/regexp.tsnow shares withcdkd diff's renderer, since a resolvedExport.Namepasses no CloudFormation validator and can carry ANSI escapes into a CI log. The recording invariant behind all of this is stated once and applied at every exit: the resolver records and warms its module-global cache AS IT GOES, so every path that leaves a resolution early — acontinue, a discarded local, athrowfrom anFn::Joinsibling element — must still carry the recording out, or the next consumer cache-hits and records nothing. Scrub's collision remedy deliberately differs on two points, both forced by what it can know about state an earlier binary wrote: it cannot know which output a legacy value under the colliding key came from, so it drops that key's position source and falls back to the value scan; and it tests collisions against DECLARED names with conditions ignored — resolving an intrinsicExport.Namebest-effort for that test alone, since the legacy population is exactly the binaries that resolved such names into state keys — because its own condition evaluation is best-effort from template defaults and assumes false on failure, so trusting it would let scrub miss a real collision and write a wrong-secret reference (measured both ways in the test file). User-visible: where the alias previously won the key, a consumer'sFn::ImportValueon that name now resolves to the colliding output's value — a CloudFormation-parity divergence (CFn keeps exports in a separate namespace and publishes both), warned on the producer's deploy and documented indocs/cross-stack-references.md. A FOURTH writer of the same key space,analyzer/outputs-diff.ts, applies the same two refusals: it previews the bag the deploy persists, so publishing an alias the deploy refuses reported a phantom Outputs change on every run and keptcdkd diff --failred forever — and the anti-drift fence built for exactly that (#1928) stayed green through it, because it grepped for a literal alias write that survived inside the new guard'selsearm; the fence now pins the guard on both sides, for the COLLISION rule and the SECRET rule alike. Parity is row-by-row and documented as a table in the module, because making the twin refuse by SPELLING traded one divergence for two: a LITERAL name spelled as a{{resolve:...}}token is PUBLISHED by both (the deploy never substitutes a string name, so the key holds the expression state stores anyway), while a LITERAL name in a secret-resolving stack makes the diff SUPPRESS its Outputs delta (recording the alias key so the suppression notice cannot blame the wrong cause), since the deploy refuses it only when it contains the resolved plaintext and the preview never resolves one. The secret-bearing scan behind that decision walks the value DEEPLY:secret.secretValueFromJson(...)renders the ARN as aRef, so the value is anFn::Joinobject and a string-only test reported no secret for the dominant CDK shape, disarming both this guard and the pre-existing legacy-plaintext withholding that keeps a stored secret out of the rendered diff. The literal-name refusal is also no longer DECLARATION-ORDER dependent: the outputs pass is split into resolve-values then decide-aliases, so every alias is judged against the complete secrets map instead of whatever had been recorded so far — the same order-dependence class this issue's addendum flagged for the original defect, reappearing in its fix, and closed with no extra AWS calls. Unit matrix covers both collision polarities in the corrupting order with the owner/exporter NAME ORDER reversed between them (so an order-keyed guard fails one), an all-public collision, the two-secret shape, the reversed non-corrupting order as a control, the suppressed-name case, both intrinsic export-name cases, a non-colliding alias, self-export, the no-change re-check call site, and the single-resolution property; nine mutations were probed and each is caught by its intended case. -
AWS::DynamoDB::Tabledestroy absorbs the transient index-busyDeleteTablerefusal (issue #1931) —src/provisioning/dynamodb-index-busy-delete.ts(new),dynamodb-table-provider.ts,dynamodb-globaltable-provider.ts,tests/integration/dynamodb-gsi-update/verify.sh. The gap: the type'sdelete()issued a singleDeleteTable, so a table whose GSI was mid-transition when destroy reached it surfaced AWS'sAttempt to change a resource which is still in use: Cannot delete table while indexes are being created, updated, or deleted.as a hardPartialFailureErrorwith state preserved — for a condition that clears itself, and which application auto-scaling can start on any table with an autoscaled GSI. Issue #1830 / PR #1930 had fixed exactly this for the siblingAWS::DynamoDB::GlobalTableand leftTableuntouched. The change: rather than spell the classifier, the budget, the bounded re-arm and the warning a second time, they were LIFTED into a newsrc/provisioning/dynamodb-index-busy-delete.tsthat BOTH providers read — the waydynamodb-warm-throughput.tsalready holds the twoWarmThroughputrules. No AWS-FACING behaviour changed forGlobalTable— call order, budgets, the #1521 pre-delete gate and replica teardown are identical, and thedynamodb-globaltableinteg was re-run as the regression arm (12 deleted, 0 errors). Two of its LOG lines did change, deliberately: the unclassified settle-error arm now names only the error class at default verbosity (see Security below) and the retry warning was reworded so its own arithmetic reads consistently, so that provider's suite was updated to match rather than passing untouched. Why message-keyed: AWS reports the refusal as a plainResourceInUseException, the same NAME it uses for terminal conflicts, so a name-keyed predicate would spend ~47s of backoff before failing identically. Why a BOUNDED re-arm: the settle poll runs per retry, so the loop's wall clock is the product — at the #1521 gate's 15-minute default it would be ~2h againstdestroy-runner.ts's 30-minute per-resource deadline, turning an actionable AWS message into a genericResourceTimeoutError. That deadline wraps the runner's whole OUTER loop (up to 4delete()calls), and the budget survives it only because this refusal matches noRETRYABLE_ERROR_MESSAGE_PATTERNSentry (quoted as ~8.8 min at the time, with the surrounding ~2h and ~47s figures likewise computed at the then-current 8-retry budget and an idealized 1s poll; issue #1950 re-derived all three at the measured poll cost and raised the budget forAWS::DynamoDB::Tableonly — see the #1950 entry above) — verified against the real classifier for both AWS's raw sentence and the wrapped form. Security: the settle poll's unclassified-error arm logged AWS's RAW message at default verbosity; onAccessDeniedthat carries the account id, assumed-role ARN and session name, and the warning reaches the persisted deployment-events store as well as the terminal. It now names only the error CLASS and points at--verbose, with the raw message at debug; the arm still PROCEEDS, since a throw there would strand the resource. Live coverage ridestests/integration/dynamodb-gsi-update, which creates a GSI out of band immediately before the destroy so AWS really refuses — an index CREATE rather than a capacity change, because a capacity change on an empty index settles in under a second and let #1930's earlier design pass with the fix REVERTED. Verified live 2026-08-18: AWS refused and cdkd absorbed 7 retries before the delete succeeded, destroy 0 errors / 0 orphans. That run also measured the budget clearing only the FLOOR case (7 of 8 retries on a 5-item table), filed as #1950. The arm is attempted up to TWICE, since the window is a genuine race: a destroy that exits 0 with no refusal can only mean AWS never refused, because a reverted fix exits non-zero andset -o pipefailaborts at the destroy itself — so the re-arm absorbs a missed window but structurally cannot absorb a broken retry, and two missed arms still FAIL. The fixture'scleanupalso gained a re-entrancy latch: the INT / TERM traps call it and then exit, re-firing the EXIT trap, which paid ~20 min of teardown twice on every Ctrl-C. - A secret whose plaintext IS a
{{resolve:...}}string no longer escapes redaction, an array-nested secret is redacted on the UNCHANGED-resource path, and no state writer can rewrite a reference onto a DIFFERENT generation (issues #1917 / #1915) —src/deployment/secret-redaction.ts,src/cli/commands/scrub.ts,src/deployment/rollback-executor.ts,tests/unit/deployment/secret-redaction-provenance.test.ts,tests/unit/deployment/secret-redaction-array-identity.test.ts,tests/unit/cli/commands/secret-redaction-scrub-generation-skew.test.ts,tests/unit/deployment/rollback-executor-secret-redaction.test.ts,tests/integration/secrets-array-nested/,.claude/rules/code-layout.md,docs/cli-reference.md. Bug 1 (#1917):redactByPathopened by keeping any bag leaf that was already a complete{{resolve:...}}token verbatim. That guard is load-bearing — without it an edited-but-undeployed template rewrote state onto the undeployed expression, reportedrecordsChangedfor a record holding no plaintext, and made the next deploy compare expression-vs-expression, see NO_CHANGE, and never push the new reference. But a SECRET whose resolved plaintext literally IS such a string satisfied the same predicate, so it was kept verbatim and PERSISTED IN PLAINTEXT with no redaction on that leaf at all (the GHSA-p5qg-v9gv-hc7w class). Testingsecrets.has(bag)is not the fix: it is precisely the rewrite the guard prevents, one input over. Fix:PathSourceRulesgains a third rule,sourceIsSameGeneration, and the retention becomes a FALL-BACK TO A WHOLE-VALUE REDACTION rather than a verbatim return. Whole-value and NOT the full value scan: the scan's substring arm would splice a short recorded secret value occurring inside the token's own text into the reference (an ssm SecureString holdingprod, inside{{resolve:secretsmanager:prod/db:SecretString:pw}}), corrupting a persisted expression on everycdkd scrubover already-clean state. The same bound is spelled out in the generic walk, which is where a leaf reached with NO source (the journal'spreviousState, everyattributesbag) is protected. That is what makes the rule safe to set conservatively: a token-shaped plaintext the pass resolved is a key of the value map and is still rewritten onto its own expression, while a previous generation's expression is not a key and survives. A first draft keyed that rule on the CALLER and was wrong in a way worth recording, because the wrong version reads plausible: callers know what they INTEND their bag to be, not what it IS.DeployEngine.redactStateForPersistwalks EVERY record in the state map whileperResourceTemplatePropsis populated right after resolution and BEFORE the provider call, so a resource that merely ENTERED the create/update arm hands today's template to a record still holding the PREVIOUS generation — reachable through an intermediatesaveStateAfterResource, the pre/post-rollback saves, and Ctrl-C. Keyed on the caller, a rotation that FAILED and was rolled back to:AWSPREVIOUSwas persisted as:AWSCURRENT, so the next deploy saw NO_CHANGE and the rotation was silently never applied — invisible tocdkd driftbecause the baseline was rewritten in the same walk. The rule is therefore keyed on the SOURCE's generation: only a source that is THIS record's own persisted bag can certify it, which is true for exactlySTATE_DERIVED_RULES(the replay resolved its bag from the journaled record) andSTATE_SOURCED_READBACK_RULES(the #1900 observed walk projects from thepropertiesbeside it). The draft's separate constant for thecdkd scrubbag collapsed intoTEMPLATE_SOURCED_RULESonce the axis moved (every template source is a different generation, whichever command supplied the bag), and a newSTATE_SOURCED_CROSS_GENERATION_RULEScovers the one caller a derivation cannot serve —cdkd scrub'sobservedPropertieswalk, which repositionspropertiesonto today's template first, so its "own-record" source has already moved a generation.cdkd scrub's OUTPUTS call site was deliberately left on the default rules. It looks like it should move for the same reasonpropertiesdid — its bag is persisted state and its source is today's template — but the two constants differ ondescendArraysalone, and that flag cannot fire there: a template OutputValueis a string or an intrinsic OBJECT, never a literal array, so the array arm is unreachable however the persisted bag is shaped. Measured byte-identical across every shape a CloudFormation Output can take, so moving it would have put a third, inert, behavior-shaped change in a PR that ships two issues. The condition that would make it wrong —outputsTemplateSourcegaining a source whose value can be an array — is recorded at the call site. Bug 2 (#1915): a secret nested in an ARRAY stayed plaintext inobservedPropertieson the unchanged path, because positional descent is refused for an AWS readback (list order is not preserved) AND the fallback value scan has no needles there (the resource was never resolved this deploy, so itsperResourceSecretsentry is empty — the #1900 shape). Fix: a KEYED array descent pairing elements by an identity field (Name/Key, the shapecanonicalizeTagListsDeepalready keys on) with uniqueness required on both sides, so pairing is by string equality and cannot mis-align; a non-identity key refuses the whole array and an unpaired element falls to the value scan. It runs BEFORE positional descent, not only where positional is refused, becausedescendArraysrests on an order assumption the module states but cannot enforce — and an element the key does NOT pair takes its POSITIONAL partner whenever positional would have been exact for the whole array (descendArrays, equal lengths, and every pairing sitting at its own index — the last checking that the order assumption actually held rather than assuming it). The invariant is that keying must never pre-empt a positional descent that would have been exact: without it, keying ONE element sent every unpaired sibling to the value scan, which on a colliding pair writes a SIBLING's expression — the #1910 class, on the replay path that applies it to AWS. Where positional would NOT have been exact, an unpaired element stays on the value scan, because on the unchanged-resource path that scan has no needles and refusing the array until every element pairs would let one AWS-added element un-redact the secrets that did pair. Chosen over the issue's other candidate — seeding the value scan from the source bag's expression set — because a value scan needs PLAINTEXT needles and none exist on that path. Two shapes stay out of reach and fail closed, documented and pinned: an array whose IDENTITY FIELD itself holds a secret, and an array of ARRAYS. Tests: one unit case per WRITE SITE, plus caller-BINDING cases that pin which constant each writer passes rather than only what the constants do — the gap a probe found when swapping the rollback writer's constant left all 13546 tests green while re-opening #1917. 21 mutation probes, all caught. The newtests/integration/secrets-array-nestedfixture carries the secret two arrays deep in an ECS task definition, redeploys UNCHANGED, and asserts the task definition REVISION is unchanged so the fixture cannot pass by taking the update path instead. -
cdkd diffreports an Outputs-only change instead of "No changes detected" (issue #1921) —src/analyzer/outputs-diff.ts(new),src/cli/commands/diff-recursive.ts,tests/unit/analyzer/outputs-diff.test.ts,tests/unit/cli/diff-recursive.test.ts,tests/integration/outputs-only-export/verify.sh,docs/cli-reference.md,docs/architecture.md,.claude/rules/code-layout.md. Bug:cdkd deploylearned to persist an Outputs-only change in #875, butcdkd diffnever did —DiffCalculator.calculateDiffcompares onlycurrentState.resourcesvstemplate.Resources, and the deploy engine's compensating logic lived entirely in its own no-change branch. A stack whoseOutputsgained, changed, or lost an entry with a byte-identicalResourcessection still printed✓ No changes detectedandcdkd diff --failexited0, while the apply DID write new outputs and republish the exports index. In the motivating chain (a producer that gains anExport.Namebecause a downstream stack started referencing it) the preview actively steered the user away from the deploy that repairs the consumer'sFn::ImportValue; the reverse case — an export REMOVED, which can break a consumer — was hidden identically.cdk diffand a CFn change set both surface an Outputs delta, so this was also a CDK-CLI parity gap. Sharing strategy, decided up front because it changes the gate cost: the natural extract-and-share editssrc/deployment/deploy-engine.ts, which is in BOTH theinteg-broadCROSS_CUTTING_REGEXandinteg-destroy's scope, so a diff-only fix would have acquired two real-AWS merge gates — and that file was concurrently held by the livefix/1919lane, so editing it would have violated file-disjointness outright. This shipped diff-side only, and the resulting duplication is PAID FOR rather than merely accepted:computeOutputsDiffcompares bag KEY by bag KEY, which IS theoutputMapsEqualpredicate the deploy engine gates its persist on (no interpretive layer that could drift), and a unit anti-drift fence readsdeploy-engine.tsand asserts its three mirrored semantics — theExport.Namealias write, the condition-false skip, and the refusal to persist a partially-resolved bag — each verified to fire by a mutation probe against the real file. Fix:resolveTemplateOutputsrebuilds the exact bagDeployEngine.resolveOutputspersists (condition-false outputs skipped; anExport.Namestored as a SECOND key holding the same value, which is whatFn::ImportValueresolves against). Because the diff resolver is BEST-EFFORT — it returns the ORIGINAL value on failure rather than throwing, unlike the deploy side whose catch storesundefined— the unresolved detector is a DEEPcontainsIntrinsicwalk; a shallow check would pass a half-substitutedFn::Jointhrough as resolved and diff it against state as a phantom. A partially-resolved bag reports NO delta, mirroring the deploy engine declining to persist one, and nothing is lost because an output only fails to resolve when it references a resource this deploy has yet to CREATE — already on the resource side of the diff.computeStackDiffnow returns{changes, outputChanges}, computing the delta THERE rather than behind a second entry point, since the parameter binding / condition evaluation above it can issue SSM calls a second pass would pay for twice.nodeHasChangesis true for an Outputs-only delta (the arm that stops "No changes detected" and makes--failexit 1),renderOutputChangeLinesprints anOutputs:block, anddiffTreeToJsoncarriesoutputChanges(always present, likechildren, for key-set stability). Rows are keyed by the PERSISTED bag key, so an output with anExport.Nameshows TWO rows and the[export]-tagged one is the literal string a failing consumer needs; the Outputs counts go on their OWN summary line because an Outputs write drives no AWS resource operation and would otherwise read as "1 to update" for a stack whose resources are untouched.--recursiveis consistent — every child computes / renders / serializes its own delta, and a removed nested child reports its persisted outputs as REMOVE with no special case.src/cli/commands/diff.tsneeded no change (the gate and the renderer both live indiff-recursive.ts). Four defects found by the review round, each now pinned by a mutation probe: (1) the unresolved detector missedundefined—resolvereturns it WITHOUT throwing for a constructible-but-unknown attribute (StreamArn/PolicyId/VpcId/RoleId), and sinceJSON.stringifydrops anundefined-valued key, state can never hold one, so such an output was a PERMANENT phantomADDprintingnew: undefinedwith--failexiting 1 forever on a stack deploy considers clean; this is precisely deploy's own signal (some((v) => v === undefined)), which the diff twin had failed to mirror. (2) It also missedFn::Sub's laundered placeholder:resolveSubcatches a genuineRef/GetAttmiss, WARNS, and keeps the literal${Foo}in the output STRING — no throw, no intrinsic object — so a half-substituted value passed as resolved; the detector now also flags an unsubstituted${...}string, accepting that${!Literal}escapes are indistinguishable post-resolution and that the resulting false positive merely SUPPRESSES the section (pre-#1921 behavior) rather than reporting a phantom every run. The module's original rationale for the deep walk citedFn::Join, which was WRONG —resolveJoinpropagates the throw — and is corrected. (3) A top-levelFn::IfselectingRef: AWS::NoValuereturns a bare sentinel symbol (resolveValuestrips it only INSIDE containers), likewise unpersistable and likewise a permanent phantom. (4) Security: the new renderer prints the STORED previous value, andStackState.outputsis not guaranteed redacted for every schema version — a record written before the GHSA redaction holds resolved plaintext (the conditioncdkd scrubexists to repair), socdkd diff, a command routinely run in CI, would have put a secret in build logs.oldValueis now WITHHELD (replaced byoldValueRedacted: true) whenever the desired side is still a{{resolve:...}}expression and the stored side is not, in the text renderer and--jsonalike. Also from the review: a suppressed delta now WARNS (parity with deploy's twin warning) instead of being silently absent; template-controlled output / export NAMES are stripped of control characters before printing, since anExport.Nameis a RESOLVED value that never passed a CFn validator and can carry ANSI escapes; the anti-drift fence was re-pointed from the line that CONSUMES deploy's failure flag to the line that DEFINES it, because the original would have stayed green through defect 1; the unit resolver mock was corrected to THROW like the realIntrinsicFunctionResolver(computeStackDiffpasses it directly, not throughDiffCalculator.resolveBestEffort's returns-the-original wrapper), so the tests stop agreeing with a wrong contract; andINTRINSIC_KEYSis now exported fromdiff-calculator.tsand shared rather than copied. A SECOND review round found five more, all fixed and probed: (a) the${...}test applied to EVERY string, so an IAM policy body's${aws:username}or a UserData shell${VAR}would suppress the whole Outputs section for that stack forever — it is now consulted only for a value whose raw template source actually usedFn::Sub(templateUsesSub, walking nestedFn::Join/Fn::If), which confines the remaining false positive to the${!Literal}escape the trade actually admits. (b) The plaintext withholding fired only on the MODIFY arm, so a secret output that was condition-SKIPPED or DELETED from the template — neither of which has a desired side — still printed in full as a REMOVE row, andbuildDeletedSubtreedumps a removed nested child's WHOLE bag as REMOVEs. A second signal was added:secretSourceKeys, the keys the TEMPLATE declares as dynamic references, collected over every declared output including skipped ones. A hit on either signal now makes the whole record suspect — it was written by a pre-GHSA binary, so every value in it is unredacted — so the withholding is RECORD-level rather than per-key. (c) The new suppression warning fired on the ORDINARY case: unlike deploy (which keeps an unresolved key asundefined) this resolver DROPS it, so every failed key read as a phantom REMOVE and the warning triggered on the expected pending-resource path, including the first diff of a never-deployed stack;failedKeysis now returned and excluded before the warn is computed. (d) Control-char stripping reached names but not rendered VALUES, andJSON.stringifyescapes below 0x20 while passing C1 and bidi marks through — the class now covers C0/DEL/C1 plus the bidi overrides and is applied to values too, while--jsonis left byte-faithful on purpose (mutating a name a machine consumer matches on would be a correctness regression traded for a display concern). (e) The "mirrors the deploy engine declining to persist" claim was overstated — it holds for deploy's NO-CHANGE branch, while its changed-resources branch has no such gate (correctly, since by then every resource exists); code comments and docs now say so. Also fixed: the newINTRINSIC_KEYSconst had been inserted betweenCanonicalizePropertiesFn's JSDoc and its declaration, orphaning the doc, anddiff-recursive.tskept a third hand-maintained copy of the same key list, which now imports the shared one. Tests: 63 new unit cases — bag shape incl. the alias key, condition skip and unknown-condition keep, deep-intrinsic detection, intrinsicExport.Nameboth ways, non-throwing resolver failure, template non-mutation, the ADD/MODIFY/REMOVE matrix, key-order-independent structural equality, array order treated as significant, absent state bag, explicitnullvs absent key, the no-phantom case,treeHasChangestrue for an Outputs-only delta and still false for a clean stack, an unresolvable output suppressing the delta but NOT the resource CREATE, the human render incl. both summary lines, and the JSON projection incl. the omitted absent side per kind — plus the 3-assertion fence. Neutering thenodeHasChangesOutputs arm fails exactly the two user-visible tests. Live:tests/integration/outputs-only-export(the existing #875 fixture) gains the preview half — a positive arm BEFORE the redeploy requiring--failto exit 1, no "No changes detected", anOutputs:section naming[+] CdkdOutputsOnlyBucketArn [export], and the entry in--jsonoutputChangeswithexport: truewhile reporting ZERO resource changes; plus TWO negative arms. The phase-1 one (freshly-deployed producer diffs clean) stops a fix that always reports a delta, but on its own it only compares empty-vs-empty; the review noted that, so a second runs AFTER the export is persisted, where the output resolves from real state through anFn::GetAtt— that is the arm that actually discriminates a RESOLUTION disagreement between preview and apply, i.e. the whole phantom class defects 1-3 above belong to. Verified against real AWS 2026-08-18 (us-east-1), re-run after each review round: 13/13 checks, destroy clean, 0 orphans. No CLI flag, dependency, or state-schema change;outputChangesis an additive--jsonkey. -
cdkd driftreconciles the redacted state baseline with the resolved AWS snapshot, so it no longer prints secrets, misses real drift, or reverts the literal{{resolve:...}}token (issue #1914) —src/cli/commands/drift.ts,src/analyzer/drift-calculator.ts(comment),tests/unit/cli/drift-secret-redaction.test.ts,tests/integration/secrets-dynamic-ref/verify.sh,docs/cli-reference.md,.claude/rules/code-layout.md. Bug:drift.tsimported nothing fromsecret-redaction.ts. It was the one write-to-state surface the GHSA-p5qg-v9gv-hc7w work never reached, and worse than a plain write leak because it also FEEDS A LIVE AWS CALL. Since PR #1899 the drift baseline (observedProperties ?? properties) holds the unresolved expressions whilereadCurrentStatereturns the resolved plaintext, and nothing reconciled the two:--reverthanded that baseline toprovider.update, setting the live property to the literal token;--acceptpersisted the AWS-current plaintext intostate.json; and the comparison could not compare at all — PR #1899 had bought quiet by makingcalculateResourceDriftSKIP every state leaf holding a{{resolve:string, which also made a console edit of a secret-bearing property invisible tocdkd driftand unreachable by--revert. Fix:resolveStateSecretExpressionsre-resolves the secret references a STATE bag holds — the synth-free counterpart ofrollback-executor.ts'sresolveReplayProps. The comparison resolves the baseline FOR COMPARISON ONLY (observedProperties/propertiesuntouched) andrunRevertre-resolves before the provider call, so AWS receives the concrete secret. The resolved baseline no longer trips the #1899 skip, which becomes the FALLBACK for a reference cdkd cannot resolve. Redaction is keyed on the PATH as well as on the value, and the path half is the half a value-keyed map structurally cannot supply. The resolution records a per-resourceRecordedSecretValuesAND the set of paths that produced one;redactDriftChangesrewrites both sides of everyPropertyDriftat the moment the outcome is built — the single choke point behind the human report, the--jsonpayload, both plans, and the value--acceptpersists. The value pass restores a recorded plaintext to its own expression. The path pass answers what the map cannot: the map holds what the reference resolves to TODAY, so after a Secrets Manager rotation the deployed resource still carries the previous version, the path drifts, and the AWS side matches no key — a real secret that a value-scan-only redaction prints verbatim and--acceptwrites to state. At a known-secret path the only value ever shown is the expression itself; anything else becomes***(undefined/nullexcepted, since an absent key discloses nothing), and--acceptrefuses such a path with a warning naming it rather than persisting a mask that would corrupt the baseline. The refusal is per-PATH, so non-secret drifts on the same resource are still accepted.change.pathis masked too —redactSecretsForStatewalks values and never object KEYS, so a readback keyed by a secret rendered the plaintext into every printer's path segment; a masked path also counts as secret-bearing, and--acceptrefuses it through the SAMEacceptRefusalReasonpredicateprintAcceptPlanasks (so a--dry-runcannot promise a write the run refuses), becausesetAtPathwould otherwise invent a key literally named***and, with anundefinedvalue, INSERT it instead of removing the real one. The revert PLAN's two AWS-key lists (findRevertPreservedTagKeys/findRevertUnbaselinedAwsKeys) are masked where they print, since they build path names straight from the deliberately-unredactedawsProperties.carriesRecordedSecret's substring branch carries the same 4-character needle floorsecret-redaction.tsuses when it builds needles, so it errs toward NOT marking — a sub-threshold plaintext is one the redactor would not substitute anyway, and marking it would mask a PUBLIC reference that merely contains those characters. The path answer survives a failed lookup too:collectDynamicReferencePathsseeds a second path set OFFLINE from where the{{resolve:strings simply are, and the report uses the proven set when resolution succeeded and that seed when it did not. Without it the two failure modes compose into a leak — the comparator's{{resolve:skip only re-arms for a LEAF whose state side is a string, so a resource whoseobservedPropertieslack the secret's parent drifts at the ANCESTOR with the whole AWS subtree, plaintext included, asawsValue. Every failure mode of the new lookups is per-resource, never fatal.cdkd driftpreviously made no secret calls at all, so a deleted secret, a rotated-away version, or a least-privilege role lackingsecretsmanager:GetSecretValue/ssm:GetParameterwould have aborted the whole command — every remaining resource and, under--all, every remaining stack. Detection now warns per resource and falls back to the unresolved baseline (where the #1899 skip suppresses the phantom drift), and--revertreports it as a per-resource failure with its own message rather than a misleadingAWS update failed. A SURVIVING{{resolve:...}}token marks its path secret-bearing and is reported rather than treated as an error — failing would abandon every other drifted property on the resource and exit 2.ssm-secureis the one spelling for which "the token is already what AWS holds" is false: CloudFormation resolves it SERVER-side, so a record adopted bycdkd import --migrate-from-cloudformationcarries the literal in state while AWS holds the plaintext. Both halves follow — the path must be masked (diffAtnever descends arrays, so an ECSSecrets[]drift otherwise reports the whole array with the plaintext in it), andpreserveLiveValuesAtUnresolvedTokensruns beforeprovider.updateso a revert triggered by any SIBLING key leaves the live value at those positions untouched instead of overwriting a working secret with a literal string. Deciding by what AWS HOLDS rather than by provenance needs no state flag and is exact both ways: a cdkd-deployed record has the token live, so copying it back is the same no-op the old premise described. The report names only the TOKENS, never the leaf:resolveDynamicReferencessubstitutes token by token, so a mixedsecretsmanager+ssm-secureleaf comes back carrying real DECRYPTED plaintext, and interpolating it into the warning printed a secret from the command whose purpose is not to. For the same reason the detection failure arm masks its message BEFORE clearing the value map. The new IAM requirement is documented indocs/cli-reference.md. Every AWS error string a revert prints now goes throughmaskSecretsInText: the payload carried resolved secrets and AWS quotes the offending value, the fencedeploy-engine.tsalready applies to its own provider calls.--acceptkeeps its own positioned redaction (existing.properties,STATE_SOURCED_READBACK_RULES) and it is not a duplicate of the change-level pass: what it reaches is the untouched clone of the record's baseline, which is exactly where a plaintext survives for any user who ran--accepton a pre-fix binary — measured, that state round-trips the plaintext without it — and the positioned form additionally names a stored plaintext that a rotation has made unrecognisable to the map. Its old pinning hazard is gone now that a drifted secret-bearing path is refused before it can reach the bag. The revert's narrowing write (#1644) is positioned against the bag the revert RESOLVED rather than againstproperties, withSTATE_SOURCED_READBACK_RULESrather than the rollback executor'sSTATE_DERIVED_RULES, because the delta descends from a merge with the AWS-CURRENT snapshot and positional array descent over a reordered list would write a sibling's expression onto the wrong element. An ABSENTawsValueat a position known to hold a secret is dropped rather than reported — a write-only credential (MasterUserPasswordand friends; RDS / DocDB / Neptune / ElastiCache / Cognito declare nogetDriftUnknownPaths) is returned by no readback, and PR #1899's skip used to cover exactly that, stopping only because this PR makes the baseline arrive RESOLVED. Reporting it was three bugs at once: permanent exit 1,--acceptwritingundefinedand so DELETING the{{resolve:...}}reference out ofproperties, and--revertre-pushing the credential every run. AndpreserveLiveValuesAtUnresolvedTokensregisters the live values it pins — gated on the leaf being a WHOLE token, the spelling being secret by definition, and the value clearing the needle floor, since registering a partially resolved mixed leaf writes the resolvable half's plaintext into state and registering a short value makes a needle that rewrites unrelated data — into the secrets map before the update call, the narrowing redaction and the error masking — that map is empty by construction on the unresolvable path, so moving plaintext into the payload without registering it reopened the round-3 error leak through a fifth door and dropped an array-nested value straight intostate.json. The rule worth carrying: a mechanism that deliberately moves plaintext into a bag must also register it with everything that masks. The drop is scoped to the EXACT leaf, not to the ancestor match, so a whole subtree vanishing from AWS is still reported — and refused by--accept, which would otherwise delete the key and the{{resolve:...}}reference with it. Preservation is gated on the leaf being a WHOLE token, which is a disclosure boundary: copying a live value into a MIXED leaf would move thessm-secureplaintext into the payload while the registration guards correctly decline to register it, so the mechanism would create an exposure it cannot mask. Declining there restores the pre-#1914 behaviour for that narrow shape. A residual remains that this command's own bags do not create — a provider echoing its readback ineffectiveProperties, persisted by the #1644 narrowing with nothing able to recognise it; that write previously had no redaction at all, and closing it needs span-masking (issue #2102; it was filed against #1935 until that lane shipped and found the two are different mechanisms — #1935 reaches only a leaf the value scan can MATCH). Documented indrift.tsanddocs/cli-reference.md. Tests: 64 unit cases. Every mechanism mutation-probed, every red classified as targeted or cascading, and a length / call-count guard placed ahead of each payload index so a detection regression reds on an assertion rather than aTypeError. Thesecrets-dynamic-refinteg gains Phase 1d: clean drift on a fresh deploy with no plaintext; an injected console-style overwrite detected as EXACTLY one path (phantom drift on the untouched references fails it) with the injected value masked rather than printed;--acceptrefusing that path while leaving every secret leaf on its OWN expression and no mask instate.json; a PRE-FIX-shaped state record seeded straight into the state bucket (plaintext inobservedProperties, expression inproperties— the shape a user has after running--accepton an older binary, and one the CLI cannot be driven into) so the positioned--acceptredaction has real-AWS coverage rather than only a mutation probe;--revertleaving the live Lambda holding the RESOLVED value with every sibling reference still resolved; and a second--acceptrecording an ordinary out-of-band env addition. The phase restores the stack before Phase 1e (the rollback arm). A review-driven correction worth recording: the--acceptredaction was deleted mid-PR on the evidence of a zero-red mutation probe, and the probe was a false negative — no existing case covered the pre-fix-state shape. A zero-red probe measures the SUITE, not the code; "no test distinguishes it" only means unfenceable after the distinguishing case has actually been constructed.
Recently Implemented (2026-08-15):
- A colliding secret pair behind an
Fn::Join/Fn::Subleaf is separated by SHAPE, closing the position pass on the dominant CDK form (issue #1916) —src/deployment/secret-redaction.ts,src/deployment/intrinsic-function-resolver.ts,tests/unit/deployment/secret-redaction-intrinsic-source.test.ts,tests/integration/secrets-dynamic-ref/,.claude/rules/code-layout.md. Bug: the position pass #1904 / #1910 built persists a leaf's own{{resolve:...}}expression by copying the UNRESOLVED source leaf — which requires the source leaf to BE a{{resolve:...}}string. When it is an intrinsic OBJECT there is nothing to copy, so the leaf fell through to the value scan, and that map is keyed by the resolved PLAINTEXT, so a colliding pair collapsed exactly as it did before #1904. The module header documented the fall-through as intended; what was not appreciated is that this is the DOMINANT CDK shape. Any secret reached through an L2 token (secret.secretValueFromJson(...)) renders the secret's ARN as aRef, hence anFn::Join— so the fix worked for a hand-written template naming a literal secret and missed a typical CDK app. Measured against real AWS (us-east-1, 2026-08-15) by thesecrets-dynamic-reffixture once #1910 restored the collision it had been dodging: the live Lambda received both values correctly while state persisted the:AWSCURRENTspelling for BOTH leaves — a permanent spurious UPDATE, and on the rollback-journal replay path a re-resolution of the WRONG reference once the two version stages diverge. Fix, in two steps where the first is a prerequisite for the second. (1) The resolver records EVERY secret expression intorecordedSecretExpressions, not only thessmSecureString ones. Nothing had to ask the set about asecretsmanagerreference (spelling settles it), but the set is also the CANDIDATE LIST the redaction path needs, and holding only the ssm half made the losing member of a collapsed secretsmanager pair nameable NOWHERE. Recording is gated on the SPELLING rather than onisSecret: an ssm reference whoseTypecame back unclassifiable is secret for that resolution but deliberately unpinned so the next pass re-asks AWS (#1901), and recording it would pin it for the process — so that pair still falls back to the value scan. (2)positionByIntrinsicSkeletonbuilds an anchored pattern from the intrinsic — literal parts escaped, non-literal parts wildcarded as[^}]*so a wildcard cannot cross a token terminator, withFn::Sub's${!Literal}escape treated as the literal text it is — and matches it against those candidates. It persists a match only when THREE conditions hold, each removing a different way of being wrong: the bag leaf's WHOLE value is a recorded secret plaintext (an EMBEDDED secret is not this shape and must keep going to the substring scan, which would otherwise be replaced wholesale and destroy the surrounding text), EXACTLY ONE candidate matches (two means the skeleton genuinely cannot separate them, and guessing is the collapse this fix removes one step over), and the match is not DEMONSTRABLY another value's expression per the pass's own map (the fence against a bag/source misalignment). The wildcard COUNT is capped and adjacent wildcards are COLLAPSED, which is a correctness fix rather than tidiness:[^}]*[^}]*is semantically identical to[^}]*, but the engine must try every split between them, so N adjacent wildcards make a FAILING match exponential — and failing is the common case, since the pattern is tried against every recorded expression that is not this leaf's. The COLLAPSE alone closes only the ADJACENT arrangement -- two legal, CDK-emittable shapes produce those (anFn::Joinwith an EMPTY delimiter and consecutive non-string parts; anFn::Subwith adjacent${a}${b}) -- while wildcards separated by a literal cannot merge and backtrack identically, so the CAP (MAX_SKELETON_WILDCARDS) is what actually closes the class:Fn::Sub '${a}x${b}x...'took 17.7 s at six wildcards and did not finish in two minutes at eight, and a realisticFn::Join['-', 9 x {Ref}]against a hyphen-rich secret ARN cost 855 ms per candidate. Refusing is not free -- a four-wildcard join with substantive literals can position uniquely, and a refused leaf falls to the value scan, which for a colliding pair means the sibling's reference -- but the alternative at the top of that trade is a deploy that hangs after its AWS mutations. A companion boundMAX_SKELETON_CANDIDATE_LENGTH(512) caps the BASE the same way, since cost at the wildcard cap is polynomial in the template-authored candidate's length (an adversarial 3000-char candidate measured 4.1 s); it refuses the whole pass rather than SKIPPING the long candidate, because skipping would shrink the set condition 2 counts "exactly one" over and silently weaken the fence. This runs on the state-persist choke point, i.e. AFTER the AWS mutations and BEFORE state is written, so a hang there strands real resources. Measured on a ~120-char candidate before the collapse: 4 wildcards 39 ms, 5 ~1 s, 6 20 s, 8 did not finish in two minutes. Found by the code + security reviewers, who each measured it independently. Its fence asserts the built pattern'ssourcerather than timing the match, and that too is a measured decision: catastrophic backtracking is SYNCHRONOUS, so a timing-based case does not fail on vitest's timeout — it wedges the worker and the run never terminates (the first version of the fence hung for the full harness limit, which is a worse CI outcome than the defect and cannot be told from a slow machine). Every refusal degrades to the value scan, i.e. to the pre-#1916 behavior, so no case gets worse than it is today. There is deliberately NOisKnownSecretExpressiontest on this arm, and the asymmetry with the plain-string arm is principled: that arm's candidate is arbitrary TEMPLATE text and genuinely can be a public ssm reference that must stay resolved in state (#1901), while these candidates come only from stores the resolver populates on a proven-secret verdict — so the test could never answerfalse, and an unfalsifiable guard reads as protection while fencing nothing. Tests: 32 new unit cases, each mutation-probed against real code — disabling the skeleton arm, relaxing each of the three conditions, mishandling theFn::Subescape, removing the resolver's recording, and widening the recording gate toisSecreteach fail exactly the cases that claim to fence them. Eight of those probes initially did NOT fail and the fixtures were rebuilt: the ambiguity case had put the differingRefin a trailing position, where a wildcard cannot match a candidate's}}terminator at all, so ZERO candidates matched and ambiguity was never reached — and even once matched, a survivor among the matches makes "refuse and fall back" and "take the first match" produce the same string; the whole-leaf case had used the SURVIVOR as the match, where condition 3 fires first and the two rules become indistinguishable. Thesecrets-dynamic-refinteg needed no fixture work: it already reproduces the defect and already carries the failing assertions. - Every state writer now redacts by POSITION, so two secret references sharing one resolved value no longer collapse (issue #1910) —
src/deployment/secret-redaction.ts,src/deployment/intrinsic-function-resolver.ts,src/deployment/deploy-engine.ts,src/deployment/rollback-executor.ts,src/cli/commands/scrub.ts,src/cli/commands/import.ts,tests/unit/deployment/secret-redaction-ssm-collision.test.ts,tests/unit/deployment/deploy-engine-sibling-redaction-writers.test.ts,tests/unit/deployment/rollback-executor-secret-redaction.test.ts,tests/unit/cli/commands/scrub.test.ts,tests/unit/cli/import.test.ts,tests/integration/secrets-dynamic-ref/,.claude/rules/code-layout.md. Bug: PR #1912 gave the deploy engine's state-save choke point a POSITION source (the unresolved template bag) so a leaf is redacted back to ITS OWN{{resolve:...}}expression, closing issue #1904 — but four sibling writers still calledredactSecretsForState/scrubResourceRecordwith the value-keyed map ALONE, even though the unresolved bag was available at each call site. Since that map is keyed by the resolved PLAINTEXT, two expressions resolving to the same value collapse onto whichever was recorded last, so each of those writers persisted an expression the template does not have at that leaf. A fifth site the issue did not list,rollback-executor.ts'sredactRollbackRecord, had the same shape and is reachable only through a replay. Fix: each site now passes the source it already had — the rollback JOURNAL and the stack OUTPUTS indeploy-engine.ts(the outputs' unresolved values are captured into a newoutputsTemplateSourcefield, the sibling ofperResourceTemplateProps, and all three outputs-redaction sites route through oneredactOutputshelper so they cannot drift),cdkd scrub's synthesized template,cdkd import's pre-resolution bag (captured BEFOREresource.propertiesis overwritten), and the rollback executor's JOURNALED previous properties. The journal is the consequential one:resolveReplayPropsRE-RESOLVES it against AWS, so a leaf collapsed onto a sibling's expression does not merely report a spurious change — it ships the WRONG secret version to the live resource once two version stages diverge. Its two sides take DIFFERENT sources and conflating them would be a fresh defect:properties/attemptedPropertiesare this deploy's desired bags and take the TEMPLATE bag, whilepreviousStatewas read back from STATE and positions itself throughscrubResourceRecordwith no source (the #1900 fallback). The ssm/ssm residue the issue also names is closed by givingsecret-redaction.tsa module-levelrecordedSecretExpressionsSET, uncollapsed by value: asecretsmanagerreference is secret by SPELLING, but anssmone is secret only when its parameter is aSecureString, so the losing member of a colliding ssm pair was unanswerable from the map. The store is homed in the LEAF module (it IS the resolver's own SecureString verdict store, moved there) precisely soisKnownSecretExpressionreads it with no caller threading it — which is what kept the change to a source argument per site instead of a fifth parameter through six call sites and 47 resolver-mocking suites. Tests: 9 new unit cases across five files, each mutation-probed against real code (reverting the site under test must fail the case, and does). Thesecrets-dynamic-refinteg restored the collision it had been dodging: its version-stage reference read a different JSON key precisely to avoid this bug while it was unfixed, and now readspasswordagain, so the state-expression anddiff --failassertions are discriminating rather than vacuous; new assertions pin that neither leaf takes the other's spelling at deploy time, that the rollback replay re-resolves BOTH leaves to the live value, and that the post-rollback state keeps each leaf's own expression. A pre-existing GHSA test was found vacuous in the same pass and fixed:import.test.ts's "persists the expression, not the plaintext" case mocked a bare-string secret against a reference naming a JSON_KEY, so the resolver REFUSED,resolveImportedPropertiescaught it and persisted the RAW template shape — which equals the redacted form, so every assertion passed while redaction never ran. Itsexpect(smSend).toHaveBeenCalled()guard could not catch it (the fetch DID happen; the parse after it refused); the mock now returns valid JSON and the test additionally asserts no resolution-failure warning. Verified: deleting the import redaction now fails it, where before it passed. - A SecureString SSM parameter reached through the plain
{{resolve:ssm:...}}form is redacted out of persisted state (issue #1901, GHSA-p5qg-v9gv-hc7w follow-up) —src/deployment/intrinsic-function-resolver.ts,src/deployment/rollback-executor.ts(comment),tests/unit/deployment/dynamic-references.test.ts,tests/integration/secrets-dynamic-ref/,docs/cli-reference.md,docs/architecture.md,docs/supported-features.md,docs/troubleshooting.md,.claude/rules/state-schema.md,.claude/rules/code-layout.md. Bug: PR #1899 redacted{{resolve:secretsmanager:...}}plaintext out of every persisted bag, butresolveSSMReferencefetches withWithDecryption: true, so a plainssmreference to a SecureString parameter also resolves to a real secret — and it was NOT redacted. Same disclosure class as the reported advisory (the decrypted value landed instate.json, the rollback journal,cdkd state show/diff/driftoutput), just out of the advisory's scope. It was left as a documented "Known limitation" because the resolver'sisSecretgate keyed off the reference's SPELLING, and plainssmis the same spelling for both a publicStringparameter and a secretSecureStringone. Fix: decide by the parameter's TYPE, which is free —Parameter.Typerides on the sameGetParameterresponse that carries the value, so classifying a reference costs no extra API call.resolveSSMReferencenow returns{ value, secure }, and asecurereference is recorded intorecordedSecretValuesexactly like a secretsmanager one, so every existing choke point inherits it unchanged:redactStateForPersist/scrubResourceRecordstore the expression, the rollback-journal writer redacts the same way,maskSecretsInTextkeeps it out ofFn::Join/Fn::Subdebug lines,cdkd scrubcleans it out of state an older cdkd wrote, and the rollback replay'sresolveReplayPropsre-resolves it before handing the bag to the provider (its comment claimed plainssm"never appears as an expression in the journal", which this change makes false — the replay path was already correct, since it resolves whatever the resolver classifies). AString/StringListparameter is untouched and stays RESOLVED in state, or every parameter-backed property would become a perpetual spurious UPDATE. The diff / no-op path was the actual design problem, and is why the issue was filed as "not a one-line change":skipDynamicReferencesmust leave a SecureString reference unresolved so the comparison stays expression-vs-expression, but secret-ness is not knowable from the spelling. Resolved by fetching the TYPE without the value — the comparison path passesWithDecryption: false, which returns aSecureString's encrypted blob (never substituted, never cached, never persisted) whileString/StringListare unaffected by the flag and resolve exactly as before. The verdict is then remembered per expression in a module-levelsecureStringSsmReferencesset (cleared alongsidecachedDynamicReferencesbyresetAccountInfoCache), so a later comparison short-circuits with no AWS call at all and the cache-hit arm re-records the secret for each resource's own per-resource map. Tests: 10 new unit cases covering theSecureString/String/StringListsplit, theWithDecryptionpolarity per path, the ciphertext never being cached (a diff pass followed by a deploy pass must still hand the provider the plaintext), the no-call short-circuit, the per-resource re-record on a cache hit, anFn::Sub-embedded reference, and the end-to-endredactSecretsForStateround trip. Thesecrets-dynamic-refinteg gained a real-AWS arm:verify.shcreates aSecureStringparameter out of band (CloudFormation cannot create one) and asserts it is really aSecureStringbefore proceeding, the consumer Lambda references it through the plainssm:form, and the run asserts the live Lambda carries the DECRYPTED value while state holds the expression, theStringparameter beside it stays RESOLVED (the discriminator — a fix that redacted by spelling fails this half, one that redacts nothing fails the other), the decrypted value appears nowhere in the whole state document /diff/scruboutput, andcdkd diff --failexits 0 on the unchanged stack (the no-perpetual-UPDATE half, which the pre-existing guard only asserted in its comment). Destroy additionally asserts cdkd left the unmanaged parameter alone and then deletes it explicitly, so the run ends with no orphan. Thatcdkd diff --failassertion immediately earned its keep: its first real-AWS run FAILED, exposing a shipped defect independent of this change — the redaction map is keyed by the RESOLVED VALUE, so the fixture's two references to the same secret (:SecretString:passwordand:SecretString:password:AWSCURRENT) collapsed to one entry, state persisted the staged spelling for BOTH, and the stack took a permanent spurious UPDATE. The pre-existing guard only checked that the diff output carried no plaintext and never its exit code, which is why the fixture had been shipping green over it. Filed as issue #1904 (re-keying the map is not a one-liner: value-keying is what finds a secret embedded in anFn::Joinresult); the fixture's version-stage reference now reads a DIFFERENT JSON key so it stops tripping the collision while it is unfixed, with the reason recorded in-code so it is not tidied back. -
Fn::Splitover an already-list value names the situation and the remedy (issue #1874) —src/deployment/intrinsic-function-resolver.ts,tests/unit/deployment/intrinsic-split-list-value.test.ts,docs/supported-features.md.resolveSplitrefused any non-string value withFn::Split: value must be a string, got object, which named neither the situation nor the fix. The shape that hits it is specific:{"Fn::Split": [",", {"Fn::GetAtt": ["Zone", "NameServers"]}]}is exactly the workaround a user wrote against the pre-#1868 bug whereAWS::Route53::HostedZone.NameServersresolved to a comma-delimited STRING; once PR #1868 made it a real list, the workaround started failing on upgrade (a resource property fails the resource, a stack Output degrades to a warning and a dropped output). Any list-returningFn::GetAtt—AWS::EC2::VPC.Ipv6CidrBlocks, ... — reaches the same refusal. The BEHAVIOR is deliberately unchanged: an array is still refused, because real CloudFormation rejectsFn::Splitover a list too, so such a template was never valid CFn — accepting it would let cdkd deploy templates thatcdkd export/cdkd import --migrate-from-cloudformationthen cannot hand back to CloudFormation, breaking the bidirectional-migration guarantee, and the post-upgrade failure is a correct rejection rather than a regression. What changed is the MESSAGE. An ARRAY now gets its own refusal stating that the value is ALREADY a list (with the item count), that CloudFormation rejects the same template, and that the fix is to remove theFn::Splitand use the value directly. A non-array non-string keeps its own distinct refusal (and reportsnullrather thantypeof's misleadingobject), so the two cases can never be confused.ResolverContextcarries no referencing logical id and threading one through this cross-cutting file for a message would be out of proportion, but the UNRESOLVED value EXPRESSION is already in hand, so the message names it —(from Fn::GetAtt [Zone, NameServers])for both the list and the dotted!GetAttspellings (splitting on the FIRST dot only, so a nested stack'sChild.Outputs.Keyrenders[Child, Outputs.Key]the way CFn parses it; and rendering a non-string argument by its intrinsic NAME rather than as[object Object]), or(from Ref MyListParam)for aRefto aCommaDelimitedList/List<Number>parameter — the SECOND genuinely reachable array source, viacoerceParameterValue. The remedy is per-source: the Route 53 example and the #1868 workaround note are emitted ONLY when the value actually is anFn::GetAtt, a parameter reference gets the parameter remedy, and a literal array or any other intrinsic gets a neutral one — handing a hand-written literal a Route 53 story it has nothing to do with only misdirects. Both refusals throwIntrinsicResolutionRefusalErrorrather than a bareError, matching the deliberate refusals already in the file; the #1740 laundering path is NOT reachable here today (anFn::Sub${...}placeholder cannot contain anFn::Split, and the 2-arg variable-map form resolves values outside any catch), so the class changes no behavior — it keeps "deliberate refusal" a property of the THROW rather than of the one catch that inspects it. It is NOT a guarantee against a class-agnostic catch, and the comment says so rather than over-claiming: the same file'sevaluateConditionsabsorbs any failure per condition and downgrades it tofalse, so anFn::Split-over-a-list inside aConditionsentry is silently laundered today (by both classes alike — no regression). Five of the class's six throw sites are nowmarkNonRetryable(#1838) — bothFn::Splitrefusals plus the threeFn::Sub-reachable ones that were already shipping unmarked (guardedPhysicalIdFallback's ARN / URL hard-fail, the--strict-getattrejection,rejectPlaceholderArnAttribute). The criterion is the oneretryable-errors.tsdocuments — "can this ever succeed on a retry" — NOT "does today's wording collide with a pattern", which that same file calls insufficient ("Keeping the offending values OUT of the message narrows that surface but cannot close it") because the classifiers match by SUBSTRING and every one of these messages interpolates template-controlled text: an ordinary composite logical id likeMyDependencyViolationHandlerputsDependencyViolation(the table's only whitespace-free entry) into the message. Each of the five decides from an input a retry cannot change — a persisted state record, an attribute-name suffix, a CLI flag, an already-resolved value's type. Reachability of the retry loop is real even though resolution runs outsidewithRetryon the flat path, by two routes:NestedStackProvider.createruns a childDeployEngine.deploy()and re-throws, and the parent wrapscreate()inwithRetry, so inside a nested stack each retry re-runs a full child deploy plus rollback across the ~47s schedule; and under--strict-getattan OUTPUT-resolution failure was re-wrapped in a freshErrorwith nocause, which DROPPED the non-enumerable marker while inlining the refusal's full text (including the logical id) into the new message — sodeploy-engine.tsnow threads{ cause: error }there, whichisMarkedNonRetryable's.causewalk follows. Marked at each THROW rather than in the constructor because exactly ONE site can genuinely heal: the #1730 fabricated-account guard, wheregetAccountInfocaches a fabricated answer for only 10s so a later attempt succeeds.src/utils/error-handler.ts's JSDoc and.claude/rules/architecture.mdnow enumerate all six sites accurately, split by whether they areFn::Sub-reachable (the #1730 site was missing from both before this change) and by which are marked. The same defect class survives in two places this change deliberately does NOT touch: the resolver's terminal bareErrors (Resource ... not found for Fn::GetAtt,Ref ... not found) and three furthercause-dropping re-wraps indeploy-engine.ts— both need their own sweep and are tracked as issue #1889. The in-code comment also no longer over-claims that the error class prevents laundering: the same file'sevaluateConditionscatches everything per condition and downgrades it tofalse, so anFn::Split-over-a-list inside aConditionsentry IS silently absorbed today — by both classes alike, so nothing regresses, and a unit test now pins that behavior alongside theFn::Subvariable-map PROPAGATION the class rationale rests on. Tests: the array refusal (wording, item count, singular/plural), the real-worldNameServersshape against a list-valued state record, bothFn::GetAttspellings plus the literal case that omits the source clause, the non-array non-string cases (number/object/null) asserted NOT to claim "already a list", the ordinary string still splitting (including the supportedFn::Join-then-Fn::Splitroute), and the error class + code. - An IMPORTED hosted zone records the same
NameServersattribute a deployed one does (issue #1875) —src/provisioning/providers/route53-provider.ts,docs/import.md,tests/unit/provisioning/route53-provider-import-attributes.test.ts,tests/unit/provisioning/route53-provider.test.ts.importHostedZonereturnedattributes: {}from BOTH branches, so a zone adopted throughcdkd import/cdkd import --migrate-from-cloudformationhad noNameServersin state — and nothing downstream rescued it:IntrinsicFunctionResolver.resolveGetAtt's flat-attribute branch missed (taking PR #1868's legacy comma-string normalization with it, since that normalization lives in the same branch),constructAttributehas noAWS::Route53::HostedZonecase, and resolution fell through toguardedPhysicalIdFallback, which warns and hands back the zone id STRING. A downstreamFn::Join— what CDK'szone.hostedZoneNameServersemits — then threwFn::Join's second argument must be a list,resolveOutputscaught it per-output, and the Output was SILENTLY absent from a deploy that exited 0. That is the exact symptom #1868 fixed for the deploy-created case, still live for the imported one;provider.getAttributedoes return the list but is only reached from the orphan-rewriter path, never from ordinary template resolution. Fixed on the WRITE side rather than in the resolver, matching how the deploy-created case already works (the attribute is recorded at write time) and leavingconstructAttributeuntouched. The--resourceoverride branch pays NOTHING: itsGetHostedZoneverification response already carries the delegation set, so the same call now supplies the attributes. The NAME-lookup branch pays one extraGetHostedZone, becauseListHostedZonesByNamereturns no delegation set. Both normalize?? []— a LIST, never a comma string — exactly ascreateHostedZone/updateHostedZone/getHostedZoneAttributedo, so an imported zone and a deployed one resolve IDENTICALLY and no phantom drift is introduced; both also recordId, which is what the create path records. Both also recordId, which is what the create path records — STRIPPED of the/hostedzone/prefix with the samereplacecreateHostedZoneuses, because--resource MyZone=/hostedzone/Z123is accepted (the SDK'sidNormalizerMiddlewareremoves the prefix on the wire) and recording it verbatim would persist anIda deploy never produces. The extra read is BEST-EFFORT and never fails the import, matchingAppSyncProvider.childImportAttributes(issue #1728) andimportRecordSet's adopt-verbatim fallback:import.tsonly aborts when ZERO resources import, so a throw here would cost the row under--migrate-from-cloudformationat exactly the moment the CloudFormation stack is being retired, leaving the zone in NEITHER CloudFormation nor cdkd state. Its degraded answer is the EMPTY attribute map, not a partial one, and that is load-bearing:import.ts's carry-over is gated on the returned map being NON-EMPTY (a gate that exists because nearly every provider spellsattributes: {}explicitly), so a partial{ Id }would take the row branch and OVERWRITE a previously-recorded good map — a zone imported successfully once and re-imported whileroute53:GetHostedZoneis denied would LOSE its storedNameServers, i.e. state strictly worse than before this change.{}falls through and preserves it. The one failure that does NOT degrade isNoSuchHostedZonebetween theListHostedZonesByNameand the follow-up read: that row is DECLINED (skipped-not-found), matching what the override branch has always answered for the same condition, since adopting a zone AWS demonstrably no longer has is worse than declining it. The resolution failures themselves are unaffected: an ambiguous split-horizon name and a deniedListHostedZonesByNamestill propagate as afailedrow. The warning names a remedy that actually works — re-running the import for that row with--force, or a template change forcing an UPDATE — because a plaincdkd deploydoes NOT heal the record:deploy-engine.tscontinues onNO_CHANGE, so an unchanged zone never callsupdate(), andkickOffAutoRefreshObservedPropertiesrefreshesobservedProperties, a different field. Tests: a new file covering both branches x (populated / empty / absent delegation set), theNoSuchHostedZone->nullregression on BOTH branches, the degraded arm asserting adoption + an EMPTY map + the warning, animport.ts-level arm proving a priorNameServersSURVIVES a re-import whose read fails, the prefixed-overrideIdnormalization, a parity assertion that the imported and created attribute sets aretoEqual, and an END-TO-END block that feeds the import's OWN returned attributes to the realIntrinsicFunctionResolverand joins them — with the pre-fix{}shape kept beside it as a negative control. Six mutation probes (each branch reverted toattributes: {}; the?? []replaced by a comma string at both sites; the best-effort catch turned into a rethrow; the degrade arm restored to a partial{ Id }; theNoSuchHostedZonedecline removed) all RED; the--resource/auto-lookup assertions in the pre-existing provider test file were updated for the new return shape, priming the follow-upGetHostedZoneso those tests exercise the SUCCESS path rather than the catch. Live-tested by a newtests/integration/route53/verify.shPhase 2.5 against real AWS: the--resourcebranch re-adopts the deployed zone and a redeploy re-resolves BOTH Outputs (theFn::Joinand the bare list-valuedFn::GetAtt) identically to a deployed zone. Three things that arm had to get right, all found by running it (or by review) rather than by reading: the override is spelled with the/hostedzone/prefix so the recorded physical id CHANGES, which disablesimport.ts's attribute carry-over -- at the bare id the prior deploy's good map is preserved and the arm would pass with the fix reverted; the canonical id must be restored BEFORE the redeploy, because every RecordSet takes its immutableHostedZoneIdfrom aRefto the zone, so deploying while the prefixed id is recorded plans a replacement of all three records and create-first collides with the live RRSets; and the redeploy step DROPS the twoNameServersoutputs from state first, which is what makes it able to fail at all --cdkd importcarriesexistingState.outputsforward and the no-change deploy path keeps the PERSISTED map verbatim whenever resolution fails (resolutionFailed->outputs: persistedOutputs, a guard that exists so a partial resolve cannot clobber good outputs), so Phase 1's correct values would have satisfied every assertion and the arm was GREEN with the provider fix reverted until the deletion was added. With the keys removed a resolution failure leaves them absent (red) while success flipsoutputsChangedand re-persists them (green); proven by a mutation probe that blanks the imported zone'sattributesto{}-- the exact pre-fix record shape -- and REDs on the #1875 symptom message. The NAME-lookup branch has no live coverage in that fixture and the arm says so instead of implying otherwise: the zone name embeds the account, so the synthesizedNameis an unresolvedFn::Joinand auto mode correctly declines the row -- asserted as a decline, which doubles as a tripwire for issue #1897.
Recently Implemented (2026-08-13):
- The
handledPropertieswiring critic follows!-asserted reads, and now fails on evidence LOSS rather than only on a gap (issue #1842) —scripts/gen-handled-property-wiring.ts,vite.config.ts,tests/unit/scripts/gen-handled-property-wiring.test.ts. Two separable defects, both found by the #1808 reviewers. (1) The taint walk'sunwrappeeledas T/satisfies T/ parentheses but NOT the!non-null assertion, while its upward twinclimbDID — soproperties!['WarmThroughput']contributed NO evidence while the runtime-identicalproperties['WarmThroughput']contributed the full set. Both directions now share oneisTransparentWrapperpredicate over the five nodes that erase to nothing (parentheses,as T,satisfies T,!,<T>x); a node that can change, short-circuit or defer its operand (await,??) is deliberately excluded and pinned by a test. Optional chaining is NOT excluded:properties?.['X']IS credited, correctly, because the read happens when the bag exists — measured, and pinned with a counter-case that(properties ?? {})['X']is not. (2) The real problem:wiredis a floor of ONE surviving read, so--checkprinted its clean-treeOK — ... 0 gapsline byte-identically through a real degradation. MEASURED ondynamodb-table-provider.ts: respelling the ONE delegated read insidedeclaresWarmThroughput()dropsWarmThroughput'sdelegatedevidence plus itsgetDriftUnknownPaths/readCurrentStateseeds, with byte-identical output. #1808 rewrote that very read into theproperties !== undefined && ...spelling for exactly this reason, and its in-code comment says so — an independent confirmation. (Stated per-site because breadth matters: respelling EVERYWarmThroughputread instead removes the last evidence and is caught as an ordinarygap, proving nothing about this verdict.) The loss was visible only as a regenerated-file diff the SAME commit can carry — which is how #1808 shipped a degraded matrix through a fully green pipeline.--checknow also grades the fresh analysis against the COMMITTED matrix per (class, property) and fails when the evidence-shape set or the seeding-member set SHRANK, naming exactly what was lost. The WRITER refuses the same reduction, which is what makes the verdict reachable at all: regenerating otherwise moves the baseline underneath the comparison, and the writer is also the half CI exercises (the workflow writes before it checks). Both grading seams (--providers-dir=/--baseline=) are refused on the write path unless--out-dir=redirects the output SOMEWHERE ELSE, decided by directory IDENTITY (dev+ino) rather than by string equality — checking flag PRESENCE let--out-dir=docs/_generatedthrough, and comparingresolve()output still let through the two spellings that do not differ as paths at all: a case variant on case-insensitive APFS (docs/_GENERATED) and a SYMLINK, both measured writing the committed matrix at exit 0. An empty value is rejected too (resolve('')is the cwd, which dropped the matrix at the repo root). A verdict requires a baseline that could have failed. Grading against a file invites making the file stop being able to fail, and CI'sgit diff --quietstep covers only the UNSTAGED spelling of a deletion (the path stays in the index, so the re-created file reads as modified; a stagedgit rmmakes it untracked and the diff exits 0). Rather than a sixth guard, usability is defined POSITIVELY (assessBaseline) as three conditions: SELF-CONSISTENT (its WHOLEsummaryequal to the summary recomputed from its own classes — not two hand-picked count fields, so a field added later is constrained automatically), EVIDENCE-BEARING (everywiredproperty actually carryingevidenceandseededBy— the fields the comparison CONSUMES), and NON-VACUOUS (covering at least one (class, property) pair of the tree being graded — it must be able to FAIL). Review found five spellings, one per round: DELETED, TRUNCATED, EMPTY, SHRUNKEN (cut to one pair — it OVERLAPS, so non-vacuity waved it through) and STRIPPED (evidence/seededByblanked while every count stays intact, so the mitigation announcedgraded 1138/1138having graded nothing). The pattern behind all five is one bug: each round constrained something ADJACENT to the comparison — a pair count, then summary counts — and never the fields the comparison reads. Constraining exactly what the consumer consumes is the rule; the five spellings fall out of it. Free today: all 1136 wired properties carry both fields, and pair counting is by DISTINCTClass#Propertyso replication cannot fake a length. The threshold stays zero rather than a ratio because any positive number is a magic constant and legitimate drift (a PR adding a provider class) must not trip it; partial coverage is handled by visibility instead — EVERY run, writer included, printsgraded N/M pairsplus the total evidence DEPTH (evidence+seededByentries), and the suite pins that the committed matrix grades every pair that CAN fail (1136/1138; the 2 allow-listed properties carry no evidence to grade, so pinning 100% would have been pinning a falsehood). The residual bound is stated as exactly what those conditions ADMIT: a baseline that agrees with its own summary, whose labels match its evidence, and that overlaps this tree — so a disjoint or blanked one is refused, while a self-consistent SUBSET is usable and grades only what it covers. Four shapes are admitted and each now costs a visible number: a subset (one pair below the 1136/1138 ceiling), an OLDER committed matrix restored by a merge — accepting that is CORRECT, since it is a real baseline describing a real tree and reports real losses — and a property relabelled to a different VALID status with its evidence blanked. A fourth and minimal one, weakening an entry to a non-empty SUBSET of what the tree proves, moves no pair count at all, which is why the metric also reports DEPTH — it falls by exactly the number of entries removed (from the 5384 baseline: 5382 for the two seedsApiGatewayProvider#CloudWatchRoleArnloses, 5383 for trimmingACMCertificateProvider#CertificateAuthorityArnto one seed). Stated as a formula because three correct measurements disagree until the probe is named. The bound on that guarantee, documented rather than chased: depth is a global SUM and so COMPENSABLE — a tree losing one seed while gaining another, with the baseline hand-edited at both sites, produces byte-identical output; closing it needs a per-PAIR depth comparison, which is a different critic and not the accidental shape this one defends. A self-consistent SUBSET is therefore usable, grading only the pairs it covers.--checkpreviously printedOK — 0 evidence lossesat exit 0 with the matrix deleted, an unearned green for a comparison never made, and a checker must not borrow another CI step's ordering for its own honesty. The waiver is SPLIT in two because they suppress different artifacts:--accept-evidence-losswaives an ENUMERATED per-property reduction,--accept-missing-baselinewaives having no comparison at all (nothing to enumerate). Conflated, one corrupted byte was enough to write an ungraded matrix at exit 0 with the enumeration suppressed and the corruption overwritten. Both are writer-only and rejected on--check;--baseline=stays a test seam that selects WHAT is compared and can only weaken the loss comparison, never suppress the gap or stale verdicts. Escape hatch for a genuine, intended reduction:vp run gen:handled-property-wiring:accept-loss(--accept-evidence-loss), which prints every lost shape and seed before writing; the flag is REJECTED on--check, so the CI verdict can never be told to look away. Only pairs present on BOTH sides are compared, so retiring a property tounhandledByDesignis not a false failure. Probed against REAL code per the checker rule (a fixture built alongside the fix shares its blind spot): the pre-fix critic on the!-respelled provider printed the clean-tree line byte-for-byte undercmp, while the new verdict namesDynamoDBTableProvider#WarmThroughput — lost evidence [delegated] and seeded-by [getDriftUnknownPaths, readCurrentState]and exits 1; a second probe drives a degradation the FIXED parser still cannot follow (a computed key), so the verdict is shown standing on its own rather than only behind the!spelling. No matrix content changed: no provider spells a bag read with!today — #1808 shipped the!== undefinedworkaround instead — so the parser fix is a no-op on the current tree and a fence for the next one. -
cdkd local invoke --assume-roleand--stack-regionstop carrying a raw region case (issue #1836) —src/cli/commands/local-invoke.ts,src/cli/commands/local-state-loader.ts,src/cli/commands/local-state-source.ts,src/cli/commands/local-{start-api,run-task,invoke-agentcore}.ts,tests/unit/cli/local-region-case.test.ts,tests/unit/cli/local-state-loader{,-bootstrap-repo}.test.ts,tests/unit/cli/local-state-source-region-case.test.ts(new),tests/unit/cli/local-state-loader-cross-stack.test.ts,tests/integration/local-invoke/{verify.sh,lambda/index.js,.scenarios.json},tests/integration/local-invoke-from-state/{verify.sh,.scenarios.json},scripts/build-scenario-coverage-matrix.ts. The two sibling exposures #1814 verified present and deliberately left out of its scope — same quiet failure mode as #1795: an upper-cased region is structurally valid, AWS SDK endpoint resolution and the partition table are both case-SENSITIVE, so nothing rejects it and the call simply goes to the wrong partition's endpoint or a comparison silently misses. (1) The--assume-roleSTS chain.cdkd local invoke's credential block resolved its STS region from--regionthenAWS_REGIONthenAWS_DEFAULT_REGION; the handler-entry fold covers only the first link, so with--assume-roleandAWS_REGION=CN-NORTH-1and no--regionthe RAW value reached both the AssumeRoleSTSClient(commercialsts.CN-NORTH-1.amazonaws.com) and the container's ownAWS_REGION, i.e. every SDK client the handler builds. That second consumer is NOT--assume-role-only, which the first cut of this fix got wrong: the DEFAULT path copiesAWS_REGION/AWS_DEFAULT_REGIONinto the container throughforwardAwsEnv, so folding inside the assume-role arm alone made ONE command answer two different ways for the same shell (cn-north-1with--assume-role,CN-NORTH-1without) —forwardAwsEnvnow folds exactly those two entries and copies the credential triple verbatim (an AKID is case-sensitive) — and so doeslocal-invoke-agentcore.ts's own copy, the unfixed twin the second review round found: #1814 folded that command'sstsRegionbut left its default arm copyingAWS_REGIONverbatim, socdkd local invoke-agentcorewithAWS_REGION=CN-NORTH-1gave the containercn-north-1WITH--assume-roleandCN-NORTH-1without — the exact asymmetrylocal-invoke.ts's comment describes, and outside #1843's scope (which names onlylocal-start-api.ts), so it was neither fixed nor filed. Its test asserts the two arms AGREE for one shell, because the defect was neverthe value is wrongbutthe value depends on a flag that has nothing to do with the region. The whole chain now folds throughcanonicalizeRegion, and the block was extracted into an exportedapplyLambdaCredentialEnvmirroringapplyAgentCoreCredentialEnv(the shape #1814 fixed) so the STS client's region is asserted DIRECTLY by a unit test rather than at one remove — the issue's own description namedargs.region, which is the agentcore spelling; cdkd'slocal invokecopy readoptions.regioninline in the handler, so the flag link was already covered and the env-var fall-throughs were the whole live exposure. (2)--stack-region. It is not a chain link but a FLAG whose raw value was compared against a state record's region and forwarded to cdk-local as the CFn client's region. It now folds at the handler entry of all four commands that DECLARE it (invoke/start-api/run-task/invoke-agentcore), beside the existingoptions.regionfold — the one-normalization-point shape — and, because that fold structurally cannot reach the ECS / CloudFront / AgentCore ENGINE commands (start-service/start-alb/start-cloudfront/start-agentcoreinherit the flag from cdk-local and cdk-local owns their handler), ALSO at the--from-statefactory inlocal-state-source.ts, the only cdkd-owned point those four pass through. Double-folding is a no-op, which is what makes both places safe rather than redundant. The state-record compare needed more than a fold of the flag, and folding only the flag would have REGRESSED the mirror image: a region's case is not the flag's to decide, it is whatever spelling the deploy that wrote the record used, and nothing foldscdkd deploy --region— DNS is case-insensitive, so an upper-cased COMMERCIAL deploy succeeds and keys its statecdkd/{stack}/US-EAST-1/state.json. Solocal-state-loader.tsfolds BOTH sides for the comparison whiletargetRegion— which becomes the S3 key — keeps the RECORD's own spelling; a folded key would 404 on exactly the population a flag-only fix would have broken. Its sibling comparison against the synth-derived region takes the same treatment, since leaving one raw while folding the other is the drift the rule exists to prevent. The match is EXACT-first, case-insensitive second — a fold-onlyfindINTRODUCED a silent-wrong-state read.S3StateBackend.listStacksdedupes on the exact{stack}\0{region}pair, socdkd/MyStack/US-EAST-1/state.jsonandcdkd/MyStack/us-east-1/state.jsonare two DISTINCT refs returned in ListObjectsV2's ASCII order (upper-cased first); with both present, a fold-only lookup answered--stack-region us-east-1with the OTHER record, which the pre-fold===got right. So the fold is the RECOVERY for a case mismatch, never an override of a spelling that exists verbatim, and a canonical-equal collision is announced naming the record actually read. That rule was PRODUCTION-UNREACHABLE as first shipped, and the warning it printed was false — the second review round's finding. Every path into the compare folds--stack-regionfirst (each handler's entry fold, and the--from-statefactory), so the candidate was always canonical:exactcould only ever match an already-canonical record,--stack-region US-EAST-1read theus-east-1one, and the warning still called thatthe exact spelling. The user's RAW spelling is therefore captured at each handler entry BEFORE the fold (options.rawStackRegion), carried through the factory andS3LocalStateProvider, and consulted by exactly two things: this record-match compare andloadBootstrapContainerRepo's raw marker-key probe (which had the same reachability hole — the one flag that names the region explicitly could not reach the key an upper-casedcdkd bootstrapwrote). It never reaches an SDK client or an endpoint. It DOES reach one S3 key no state record spells — that marker probe'scdkd-bootstrap/{RAW}.jsonsecond attempt, deliberately, since the WRITE side does not fold — which the third round's own wording denied and the fourth round corrected; every STATE key is still a record's own spelling. The folded value still goes everywhere it went before, since it is what cdk-local's CFn client region needs. The warning now states which RULE decided the read (matches --stack-region 'X' exactlyvsno record spells --stack-region 'X' exactly, so this is a case-insensitive recovery) from the same binding the choice was made from, so it cannot claim a match the compare did not make. An ENGINE command has no handler capture point, so the factory derives the raw spelling from the still-raw flag it receives — which is how those four get the rule too. The same both-sides fold reaches one comparison further down the file:buildCrossStackResolver's same-region filter for theFn::ImportValueindex-miss scan, whoseconsumerRegionis the RECORD's spelling — which the fold above makes reachable upper-cased, so a raw!==would have skipped EVERY ref and dropped each cross-stack env var with only a per-key warning. The ref's own spelling is still what keysgetState, exactly astargetRegionis.local-run-task.tswas handing that helper a FOLDED chain value whilelocal-invoke.ts/local-invoke-agentcore.tspassedloaded.region(the second review round's finding, and the reverse of what the code comment beside the call claimed): the two commands keyed ONE index differently.consumerRegionbecomes the exports-index KEYcdkd/_index/{region}/exports.jsonAND the rawref.region === this.regionfilterExportIndexStore's index-miss REBUILD uses, so a spelling no state record carries yields ZERO refs and PUTs an EMPTY index — after which everyFn::ImportValuedegrades to the O(N) scan permanently, from a helper whose own header called itself read-only. Keyed by a record's spelling the filter always matches, so the worst case is a key miss plus a CORRECT rebuild. run-task now threads the loaded record's region out ofbuildEcsImageResolutionContextand prefers it, falling back to the folded env chain only when no record was loaded at all; that resolution moved into an exportedresolveEcsConsumerRegionso it has a BEHAVIORAL test rather than only the source-level co-occurrence pin (which acond ? canonicalizeRegion(a) : process.env['AWS_REGION']would also have satisfied).src/state/export-index-store.tsis deliberately NOT touched: deploy derives its own write key from--region/AWS_REGIONverbatim (deploy.ts), which already differs from the stack's region whenever a stack declaresenv.region, so a key miss is a case cdkd has to survive rather than one it can spell its way out of — and converging the write side is issue #1820's lane. The helper's header, which claimedread-only against state, now says what is true: no lock and nostate.jsonwrite, but anFn::ImportValuelookup CAN persist that one derived index key. The file's two region CHAINS fold as well, and BOTH of their first descriptions were wrong in a way the review caught.loadStateForStack's chain does NOT pick the S3 client's region — it is handed toresolveStateBucketWithDefault, which names the legacy default bucketcdkd-state-{acct}-{region}(a name S3 could never have accepted upper-cased, so folding can only ever resolve MORE buckets) and probes it through a client hardcoded tous-east-1; the CLIENT's region isopts.region, which now folds SEPARATELY at all three construction sites in the file (both loaders plusbuildCrossStackResolver), an ABSENT value staying absent so the SDK's own chain still resolves the profile's region — and a BLANK--region ''counting as absent too, which it did not: it passed the!== undefinedgate and arrived asregion: ''while the comment beside it claimed otherwise (AwsClientsdropped it one layer down on a truthiness test, so this makes the boundary say what the client already did; the--from-statefactory's two spreads take the same treatment). The unit assertions now observe theAwsClientsCONSTRUCTOR, not theS3StateBackendone — the second review round found the fold unfenced becauseS3StateBackendreads onlyprofile/credentialsout of the bag those tests asserted, so reverting the onenew AwsClients({ region })line left the whole suite green; all three sites now assert it, andloadBootstrapContainerRepo's client had no coverage at all. AndloadBootstrapContainerRepo's chain becomes thecdkd-bootstrap/{region}.jsonmarker KEY, whichcdkd bootstrapdoes NOT write from a canonical region: it derivesoptions.region || AWS_REGION || 'us-east-1'verbatim, soAWS_REGION=US-EAST-1 cdkd bootstrapreally wrote the upper-cased key and a read that folded and stopped would have MISSED a marker the pre-fold read HIT — a regression this change would have introduced. The read therefore probes the canonical key FIRST (what the write side should converge on) and falls back to the raw spelling, at no extra round trip for an already-canonical region. Aligning the write side belongs to issue #1820, whose lane ownsbootstrap.ts. Every assertion is paired with a commercial / already-canonical counter-case asserting byte-identical behavior, the record-spelling and STS-client cases additionally assert the FEARED shape ({ region: 'CN-NORTH-1' }, agetStatekeyed off the folded flag) rather than only the fixed one, a no-match case pins that the fold is not a wildcard, the exact-match rule is fenced END TO END from the--from-statefactory down to thegetStatekey by a dedicated suite (tests/unit/cli/local-from-state-stack-region.test.ts, which stubs only the AWS boundary) because a helper-level test is exactly what let an unreachable rule look covered — the helper-level rows now pass the option PAIR a real handler produces rather than a bare raw candidate no CLI invocation can emit — two wording-keyed negatives becameexpect(warnSpy).not.toHaveBeenCalled()(they would have gone vacuous on this round's own message rewrite), and the--stack-regionhandler-entry pin is DERIVED from the flag DECLARATION rather than from a read ofoptions.stackRegion—local-invoke.ts/local-start-api.tsnever name the field (they hand the whole options bag tocreateLocalStateProvider), so a read-based sweep would have checked two of four commands and reported green. That pin gains a sibling requiring the RAW capture to PRECEDE the fold: a capture placed after it captures the FOLDED value, which makes the exact-match rule inert again while every existing assertion still passes. A FOURTH chain of the item-(1) shape sat inlocal-run-task.tsand is fixed here too:consumerRegion, which becomes the exports-index keycdkd/_index/{region}/exports.jsonand the same-region filter of the index-miss scan, so a raw upper-cased env region read an object nothing writes and then matched no state record. Its pin is derived from the ENV-VAR READS in that ONE file (everyprocess.env['AWS_REGION']chain must sit inside acanonicalizeRegion(...)call, with a floor so a broken parse cannot pass vacuously) rather than tree-wide, becauselocal-start-api.ts's three chains are a deliberate open residual and a tree-wide sweep would fail on another lane's work. Every source change was binding-proved by reverting each in isolation (sed-swap, nevergit checkout) and watching exactly the corresponding rows fail — seven probes for the first review round (exact-match-first, the--regionfactory fold, the marker raw-spelling fallback,forwardAwsEnv's fold,consumerRegion's fold, the cross-stack same-region filter, and the threeclientRegionsites) and TWELVE for the second: the loader dropping the raw spelling, the factory dropping it, the handler capturing AFTER the fold instead of before, run-task dropping the record-spelling preference, the exports-index key getting folded, each of the threeAwsClientsregion sites individually, the agentcoreforwardAwsEnvfold, and the three blank-region guards — plus SEVEN for the fourth: the cross-stack bucket-name fold, theresolveGetStackOutputboth-sides recovery, that arm's exact-key-first guard, the two false doc claims (run-task's and the loader's), the factory's blankstackRegiongate, andresolveEcsConsumerRegion's||flag link. The two Docker / real-AWS fixtures gained the arms that actually discriminate the change, since neither did before (local-invokenever passed a non-canonical region, andlocal-invoke-from-statepinnedAWS_REGIONcanonical):local-invokeinvokes the echo handler with an upper-casedAWS_REGIONand asserts the ECHOED containerAWS_REGIONarrives canonical, paired with the canonical counter-case asserting byte-identical output — the handler echoesprocess.env.AWS_REGIONfor exactly this — andlocal-invoke-from-statere-reads the already-deployed stack with an upper-cased--stack-region, assertingBUCKET_NAMEstill resolves to the deployed bucket (pre-fix it silently came throughunset) at no extra deploy. Thelocal-invokearm deliberately stays Docker-only with NO--assume-rolearm — that would need a real assumable IAM role created outside any stack — and the fixture says so in a comment so the coverage limit is explicit rather than implied; the STS chain is covered by the unit feared-shape assertions on theSTSClient's own constructor region. Both fixtures carry the newlocal-region-case-foldscenario tag. Left open, filed separately:local-start-api.tscarries three chains of the same item-(1) shape whose env-var fall-throughs are still raw (the per-LambdastsRegion, the server'sdefaultRegion, and the WebSocket management-API containerAWS_REGION) — issue #1843, whose scope was widened with a FOURTH site the review-fix round surfaced: that file's ownforwardAwsEnvcopy, the exact twin of the one fixed here, which #1843 did not enumerate. A FOURTH review round then found thatconsumerRegionhas FOUR consumers, not the two the third round's comment named, and that two of them were still wrong. (a)buildCrossStackResolverhanded the RAW record spelling toresolveStateBucketWithDefault, unlike both sibling loaders which fold before their own bucket resolution — so on an account with no--state-bucket/CDKD_STATE_BUCKET/ cdk.json bucket, a record atcdkd/MyStack/US-EAST-1/state.jsonnamed the legacy defaultcdkd-state-<acct>-US-EAST-1, an upper-cased bucket name is not virtual-hostable so the request went path-style, S3 answered 400InvalidBucketName,probeBucketrethrew, and the resolver warn-and-dropped EVERYFn::ImportValue/Fn::GetStackOutputenv entry whileloadStateForStackread that same record fine — an unconditional failure for legacy-bucket accounts. The bucket resolution now folds while the index KEY and the scan filter keep the raw spelling, which is the whole reason the value stays raw at the boundary. (b)resolveGetStackOutputhad no case recovery at all, although it is reached with aproducerRegioncdkd does not control: cdk-local defaults it toSubstitutionContext.consumerRegionfor an intrinsic carrying no explicitRegion, so anUS-EAST-1consumer record referencing aus-east-1producer builtcdkd/Producer/US-EAST-1/state.json, 404'd, and the var was dropped. It now mirrorsresolveImport: EXACT key first (a record that exists at that key IS the producer, so a missing output there stays a genuine miss), then alistStackswalk folding BOTH sides, reading the found ref's own spelling. Two smaller items rode along:resolveEcsConsumerRegion's flag link became||so--region ''can no longer beatAWS_REGIONand win the chain as an empty index key / bucket region (the blank-is-absent rule this change adopted at the four client boundaries), and the--from-statefactory'sstackRegionspread is now blank-gated too, so the comment claiming parity with the raw half beside it is finally true (behavior-identical downstream, where the gate is a truthiness test — the claim was the defect). Three comments the code did not support were corrected in the same round, and because rounds 2 and 3 each shipped a false one about this exact field the corrections are PINNED: therawStackRegiondoc is now DERIVED against the code — the command that feedsloadBootstrapContainerRepomay not claim the record match is the only consumer, the three that do not call it must, and the loader's own copy must admit the raw marker key — asserted against the round-3 sentence verbatim so a revert of the wording fails the suite. -
AWS::RDS::DBSubnetGroup.DBSubnetGroupArnandAWS::SSM::Parameter.Arnare cached, so an outputFn::GetAtton either resolves instead of hard-failing (issue #1824) —src/provisioning/providers/{rds,ssm-parameter}-provider.ts,scripts/gen-sdk-attr-coverage.ts,docs/_generated/sdk-attr-coverage.{json,md},tests/unit/provisioning/uncached-arn-attributes-issue-1824.test.ts(new),tests/unit/scripts/gen-sdk-attr-coverage.test.ts. The #1800 fixture re-capture refreshed everytests/fixtures/cfn-schemas/*.jsonfrom a 2026-05-16 capture and surfaced two read-only ARN attributes AWS had added since, neither of which its provider recorded under its CFn name. This is the #1179 GetAtt-key class: an output / cross-resourceFn::GetAttreads the CACHEDresource.attributes[<CFnName>]inIntrinsicFunctionResolver.constructAttribute, which never calls a provider'sgetAttribute, and for an*Arnname the resolver's shape guard HARD-FAILS rather than degrading — both physicalIds here are NAMES (a subnet-group name, a parameter name), sonew cdk.CfnOutput(this, 'ParamArn', { value: param.attrArn })failed the deploy. The two differ in source and were fixed accordingly.DBSubnetGroupArnis read straight off theCreateDBSubnetGroupresponse (no extra call), and re-reported fromupdateoff theDescribeDBSubnetGroupsthe tag diff already issues — necessary because an update result'sattributesREPLACE the state record's rather than merging, so returning onlyDBSubnetGroupNamewould have WIPED the create-time ARN.AWS::SSM::Parameter.Arnhas no create-response source (PutParameterreports onlyVersion/Tier), and the recorded decision is a CONSTRUCTED ARN rather than a follow-upGetParameter: the read would be a brand-new round trip on every parameter create AND every update (parameters are among the most numerous resources in real stacks) for a value that is a pure function of data cdkd already holds, so it removes a round trip. It does NOT buy immunity from a transient failure on the update path, and the in-code note says so rather than claiming otherwise: an update result'sattributesREPLACE the state record's, so a degradation duringupdatedrops the create-time ARN whichever source is used — construction only swaps WHICH dependency can fail (STS plus the client's region resolver, instead of SSM). That degradation is a DECIDED trade-off with the reasoning recorded inbuildParameterArn's JSDoc and pinned by a test: the degraded arm returns the PARTIAL map (droppingArn, so the resolver's*Arnguard fails LOUDLY and the next real UPDATE re-records it) rather than returning NO attributes, which would make the engine carry the previous map forward and keep the ARN at the cost of also keeping a SUPERSEDEDType/Value— a silently wrong answer toFn::GetAtt [Param, Value]and acdkd driftbaseline AWS does not hold. A loud missing value beats a quiet wrong one, the same call thefabricated-account arm makes. Construction reuses the machinery this repo already has for "the create response carries no ARN" — the samegetAccountInfo+derivePartitionAndUrlSuffix+fabricated-refusal trio asAppSyncProvider.buildAppSyncArn: the partition is DERIVED, never hardcodedarn:aws:(the active #1794 / #1815 bug class), and afabricatedaccount (#1730 / #1746) REFUSES to record anything, since an ARN built from the placeholder123456789012carries no wildcard and is therefore invisible toisPlaceholderArn— degrading to the loud pre-fix guard failure rather than to a plausible-looking wrong value. Theimport()path records the ARN too, read off the existence-verificationGetParameterit already issues, so an ADOPTED parameter is not left with the same hard-fail. One further guard rides on the constructed form. The ARN's own REGION segment is canonicalized throughcanonicalizeRegion(the #1795 / #1814 class):derivePartitionAndUrlSuffixfolds case INTERNALLY, so the partition was already right for an upper-cased region while the segment was not, andcdkd deploy --region US-EAST-1is REACHABLE — DNS is case-insensitive, so the deploy SUCCEEDS and then recordsarn:aws:ssm:US-EAST-1:..., a value that matches no IAM policy, is rejected by every SDK call taking the ARN, and is persisted intostate.jsonwhere it outlives the deploy.cdkd importnow REFUSES a physical id SSM's write APIs cannot accept, and that replaces a round-2 guard that was production-unreachable.cdkd import --resource Param=arn:aws:ssm:...:parameter/fooverifies throughGetParameter, which ACCEPTS an ARN, soresolveExplicitPhysicalIdrecords the ARN AS the physicalId — and the damage lands later and elsewhere: the nextcdkd deployfails atPutParameter({Name: physicalId})with aValidationExceptionafter aValuechange, andcdkd destroyfails identically atDeleteParameter. Round 2 addressed the symptom instead, returning an ARN-shaped id verbatim from the ARN BUILDER — which can only ever run against a mock, becauseupdate()sendsPutParameteratssm-parameter-provider.tswell BEFORE it builds the ARN andcreate()likewise, andPutParameterRequest.Nameforbids an ARN outright ("You can't enter the Amazon Resource Name (ARN) for a parameter, only the parameter name itself"), as doesDeleteParameterRequest.Name. Worse, the test pinning it primedPutParameterto ACCEPT an ARN, i.e. endorsed a wire shape AWS rejects. That guard and that test are DELETED, and the refusal sits where the bad value ENTERS (--resource/Properties.Name), before any AWS call. The predicate is a COLON, which comes straight from the documented name charset (a-zA-Z0-9_.-plus/as the hierarchy separator) and therefore covers both shapesGetParameteradditionally accepts while every write API rejects: an ARN and a version / label selector ("Name": "name:version"). REFUSED rather than NORMALIZED to the name, because the ARN -> name mapping is genuinely ambiguous: the leading/is not recoverable, sincearn:...:parameter/foois the ARN of BOTH the simple namefooand the one-level hierarchical name/foo(aws-cdk-lib'sarnForParameterNamerenders the two to the identical string, which is exactly why CDK needs an explicitsimpleNameflag when the name is a token), and a parameter SHARED from another account has no name form at all ("For parameters shared with you from another account, you must use the full ARN") — so a derived name could silently address a DIFFERENT parameter, whereas a refusal is loud and fixable. The message names the remedy for the entry route actually used (the--resourceform, orProperties.Name) plus theaws ssm get-parameter --query Parameter.Namecommand that prints the name to pass; both remedy polarities are pinned, so a template-borne ARN cannot be answered with a flag the user never passed.AppSyncProvider.buildAppSyncArncarries the same latent region-verbatim shape and is filed as issue #1850 (its file is owned by another lane). BothSDK_ATTR_ALLOW_LISTKNOWN GAP entries are DELETED (the list is now empty), which is what verifies the fix:classifyTypetestscachedKeysbefore the allow-list, so a surviving entry would have gone silently inert while the critic still reported the types as debt —docs/_generated/sdk-attr-coverage.{json,md}moves both attributesallow-listed->cachedandknownGap2 -> 0. The new unit suite drives each provider's real create / update result into the resolver rather than asserting the attribute map, so it pins the end-to-end behavior; with the caching reverted, everyresolves Fn::GetAttcase fails with the resolver's ownIntrinsicResolutionRefusalError(... is not an ARN (arn:...)) — the actual pre-fix deploy failure — and the critic's new positivecachedfence fails alongside it. What that fence binds is the provider FILE, not a code path, and the entry says so because the obvious reading is wrong:collectStoredAttributeKeyspools object-literal keys per file, so any ONE of the create / update / import literals keeps the type classifiedcached— measured, neutralizing BOTH the create and update spreads leaves the critic green off theimport()occurrences alone, and only removing all three flips it togap. Per-path binding comes from the provider suite, which drives each path's real create / update / import result into the resolver, and it now covers the "omit the key, never writeundefined" arm on every path (probed: making the write unconditional at the RDS update / import and the SSM import sites left every pre-existing case green). The constructed ARN's PARTITION is fenced the #1745 / #1794 / #1815 way — acn-north-1and aus-gov-west-1case, each PAIRED with a byte-identical commercial counter-case — because replacing the derivation with a literal'aws'left all 15 first-cut tests green when every one of them usedus-east-1. REAL-AWS COVERAGE, because the SSM ARN is CONSTRUCTED and a green mocked suite would agree with a wrong wire assumption (construction and any unit assertion share one formula).tests/integration/getatt-fallback-guardgains a POSITIVEGUARD_PHASE=arn-resolvesphase whose Consumer value isFn::GetAtt [Probe, Arn]: the deploy must SUCCEED and the consumer parameter's live value must equalGetParameter'sParameter.ARNBYTE FOR BYTE, for a FLAT name AND a HIERARCHICAL (leading-/) one — the leading-slash fold into theparameter/separator is exactly where a constructed ARN goes wrong, and every pre-existing phase used a flat name only. A follow-onarn-resolves-updatephase rotates the probes' VALUES so each takes the UPDATE path, then assertsattributes.Arnin the REAL persistedstate.jsonis unchanged across it. The non-vacuity check that the update actually fired runs PER PROBE: keyed on the flat probe alone, the HIERARCHICAL before/after equality could pass withHierProbenever taking the UPDATE path at all — and the leading-slash arm is the one the phase exists for.assert_arn_matches's probe captures also emit aFAIL:line naming the compared values instead ofreturn 1, which under the fixture'sset -euo pipefailaborted the run with no diagnosis at all (the helper is called as a statement, so a non-zero return kills the script), leaving a throttled probe indistinguishable from a real ARN mismatch.tests/integration/rds-full-stackroutesFn::GetAtt(<DbSubnetGroup>, DBSubnetGroupArn)into a second SSM parameter — mirroring the pattern it already uses for the computed DB endpoint — and asserts it equals the livedescribe-db-subnet-groupsARN, which is what validates thatCreateDBSubnetGroupreally returnsDBSubnetGroup.DBSubnetGroupArnrather than an assumed mock. KNOWN LIMITATION — this fixes NEW deploys, not existing state records. A resource deployed by a pre-fix binary keeps its ARN-less attribute map and the upgrade does not heal it: the deploy engine skips a resource whose resolved properties equal state with NO provider call, so the scenario the issue opens with (upgrade, then addnew cdk.CfnOutput(this, 'ParamArn', { value: param.attrArn })and change nothing else) STILL hard-fails — and the resolver'sattributes are not enriched for this resource type ... file an issuemessage is now FALSE for these two types. The heal plus that wording are issue #1852, deferred because both files aresrc/deployment/**, owned by another lane and in theinteg-broadgate scope; until then the remedy is a real property change on the resource (or a re-import). -
AWS::DynamoDB::GlobalTabledestroy +WarmThroughputhardening (issues #1830 / #1857):delete()retries the transientCannot delete table while indexes are being created, updated, or deletedrefusal (message classifier + awaitForIndexesActivere-arm between attempts, both provider-local at the time and since LIFTED into the sharedsrc/provisioning/dynamodb-index-busy-delete.tsby issue #1931, which gave the siblingAWS::DynamoDB::Tabletype the same retry) instead of surfacing a hardPartialFailureError. The re-arm poll is BOUNDED at 60s per attempt rather than reusing the #1521 gate's 15-minute one: that cap is sized for a wait that runs ONCE, while this one runs per retry, so at the then-8 retries the budget was ~2h inside a calldestroy-runner.tscaps at 30 min — a genuinely stuck index therefore produced a 30-minute wait ending in a genericResourceTimeoutErrorthat never mentions indexes, instead of AWS's own actionable sentence in seconds. The first index-busy retry also emits ONElogger.warnnaming the cause, sincewithRetryannounces retries only at debug level and a delete spending minutes re-polling otherwise printed nothing. And a THROTTLEDDescribeTableinside that poll now keeps waiting rather than reading as "settled" (which degraded the re-arm to no wait at all); any OTHER describe failure still gives up the WAIT — never the operation, since a delete path must tolerate a stale read — but says so at warn level instead of debug. On theWarmThroughputhalf, the block is now numerically coerced per member intoSdkGlobalSecondaryIndexes(the one funnel all four send sites read), refused-and-warned when no member resolves, and dropped with a warning at eachUpdateTablesite when it would LOWER the live value (AWS rejects a decrease). The twoWarmThroughputRULES — the per-member numeric coercion and the decrease guard — live in a new sharedsrc/provisioning/dynamodb-warm-throughput.tsthat BOTH DynamoDB providers read, rather than in a second GlobalTable-local copy: PR #1808 had just landed the same two rules for the siblingAWS::DynamoDB::Table. Only that Table spelling ever SHIPPED — the GlobalTable side never existed outside this change — so what landed here is the Table rule LIFTED rather than two shipped rules reconciled, and no deployed behaviour changed for either type. Comparing the shipped rule against the GlobalTable DRAFT input by input (a quoted numeric string, a partially usable block, a mixed decrease/increase, an absent live value, a zero, a negative, a non-numeric string, an explicitnull, an empty block, a scalar, an array) showed the decrease guard agreeing on EVERY probe and the coercion on every probe but one — a whitespace-only string, where a bareNumber(' ')is 0 rather than NaN, and the shared rule takes the REFUSING answer. The GlobalTable provider's owntoFiniteNumber(capacity units, on-demand ceilings) moved into the shared module in the same change, alongsideWARM_THROUGHPUT_MEMBERS: it was one rule written by hand three times (here, the Table provider's byte-identicalcapacityNumber, and the shared module's own member parser), and a file REFUSING a whitespaceWarmThroughputwhile reading a whitespaceMaxReadRequestUnitsas ZERO is the very divergence the extraction exists to prevent, one property over.dynamodb-table-provider.tskeeps its owncoerceWarmThroughputsignature as a four-line adapter over the shared coercion, so its behaviour, its call sites and its five warm-throughput test suites are unchanged. Also corrects a claim the GlobalTable code carried while #1808 was still unmerged: that provider'sisSendableWarmThroughputwas issue #1760'sBoolean(value)truthiness gate at the time, and #1808 redefined it ascoerceWarmThroughput(value) !== undefined— i.e. the same rule as the coercion'sspecpresence, not a competing one, so the same-name/different-meaning trap no longer exists. - The ECR registry-host FORM TABLE is spelled ONCE, and the host is matched case-insensitively (issues #1792 / #1793) —
src/utils/ecr-uri.ts,src/utils/regexp.ts,src/cli/commands/gc.ts,src/assets/asset-redirect.ts,tests/unit/utils/ecr-uri.test.ts,tests/unit/cli/gc.test.ts,tests/unit/local/ecs-task-resolver.test.ts,tests/integration/gc-custom-asset-names/. The forms were spelled TWICE and the two already disagreed:gc.ts'sECR_REGISTRY_HOSTcarried the-fipsandon.awsforms whileecr-uri.ts'sECR_URI_HOST_REGEXmatched only the plaindkr.ecrone, so a genuine FIPS or dual-stack registry was not recognized there AT ALL andcdkd local invoke/start-api/run-taskclassified it as a public image — anonymousdocker pull, nodocker login, opaque auth error.ecr-uri.tsnow owns the FORM TABLE (ECR_REGISTRY_HOST_FORMS+ theecrRegistryHostPattern(...)builder) andgc.tsbuilds its alternation from it, each side keeping its own suffix-ACCEPTANCE rule on top: gc still matches the suffix against the union over every partition (over-matching only ever KEEPS an asset), whileparseEcrRegistryHoststill pairs the captured suffix WITH the region. The four rows are read off the AWS-publishedecrendpoint list —dkr.ecr(every partition),dkr.ecr-fips(the six US / GovCloud FIPS regions), and the dual-stackdkr-ecr/dkr-ecr-fips, whose suffix is the FIXEDon.aws— so a form spelled with the other's suffix is refused, and the fixed literal is the tightest available check for those two (on.awsis AWS-owned, so unlike a captured suffix it cannot be substituted by a look-alike). Unifying the copies surfaceddkr-ecr-fips.<region>.on.aws, which BOTH had been missing: on gc's side that is the IRREVERSIBLE direction, since a missed reference reads as unreferenced and the live image is DELETED. Separately, thedkr.ecrLABELS were case-SENSITIVE literals — the last piece #1786 deliberately left alone — so<acct>.DKR.ECR.<region>.<suffix>named the same host but matched no shape at all, making BOTH entry points go quiet (parseEcrRegistryHostundefinedANDlooksLikeEcrHostWithForeignSuffixfalse, i.e. not even the #1764 diagnostic fired) and, on gc's side, deleting a live image. Both matchers now fold case. Widening rather than refusing was confirmed against docker's own semantics before shipping:distribution/referencespellsdomain-componentas([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])while the repository PATH isalpha-numeric := [a-z0-9]+, andParseNormalizedNamedraisesrepository name must be lowercasefor the REMOTE NAME only (splitDockerDomainperforms no case folding on the domain) — so docker accepts an upper-cased host, cdkd is not made todocker loginfor a URI docker would then reject, and the repository path is never rewritten. What docker does NOT fold is its credential STORE, keyed on the hostname verbatim (measured in #1801), so the fix here is the SHAPE match only and the login / pull spelling stays reconciled byecr-puller.ts'scanonicalizeImageUriHost; #1817 tracks moving that fold intoparseEcrRegistryHost. Tests: the #1786 2x2 extended to the labels (upper / mixed x genuine / look-alike, each measured against the lower-cased verdict, plus explicit ACCEPTED / REJECTED guards so no arm can pass vacuously), a negative block asserting the shapes a WIDENING regression would admit (an unescapeddkr.ecrmatchingdkr-ecr/dkrxecr, a label run merely CONTAINING a served spelling, a U+212A fold), per-form acceptance for all four forms, all four form/suffix MISPAIRINGS refused,on.awsas an exact literal rather than a prefix, and a cross-matcher pin driven off the exported table so a form added to it must work in gc AND in the strict matcher or the suite reds. Four things review corrected, each worth stating because the first cut read as safer than it was. (1) The unification is deliberately PARTIAL and the docs now say so: gc is the ONLY consumer ofecrRegistryHostPattern, whileECR_URI_HOST_REGEXre-spells the alternation off the table's LABELS column, because the strict matcher needs the suffix CAPTURED at a FIXED group index and the builder inlines afixedUrlSuffixuncaptured. So the shared unit is the TABLE — which is what had drifted — not the whole host shape; a THIRD, looser spelling (isCdkAssetImageUri'shost.includes('.dkr.ecr.')) still lives insrc/local/ecs-task-resolver.tsunder issue #1846. (2) gc's DIGEST match looked protective and was INERT. Theiflag widenssha256:[0-9a-f]{64}to upper-case hex, so...@SHA256:AAAA...started being COLLECTED — but a digest is compared for EXACT equality against ECR's always-lower-caseimageDigest, so it could never match and the live image was still DELETED. The digest is now lower-cased on insert (an OCI digest is defined as lower-case hex, so the fold cannot merge two distinct digests); the TAG stays verbatim, because ECR tags ARE case-sensitive and folding one would create the mirror-image inert collection. The old comment claiming the flag's reach past the host "can only over-PROTECT" was false and is corrected in place. Fenced end-to-end (arunGcarm assertingBatchDeleteImage's payload, not merely whatcollect()gathered) plus a real-AWS arm ingc-custom-asset-names. (3) RECOGNITION IS NOT A WORKING PULL for the three newly matched forms:ecrLoginauthenticates against the PLAIN host (proxyEndpoint) while the pull targets the host the template names, and docker's credential store is keyed on the hostname verbatim — so a FIPS / dual-stack pull now fails withno basic auth credentialswherelocal invokeused to REFUSE it with a clear message.docs/local-emulation.mdwas narrowed accordingly and the endpoint threading is filed as issue #1855. The case fold is unaffected (login and pull both land on the plain lower-case host). (4) The label fold moved from an ASCII-only map to plaintoLowerCase(): the ASCII rationale was INVERTED — the labels only select a row from a closed literal set and derive nothing, so full folding is safe, whereas under a futureuflag the ASCII map would REJECT a host UTS-46 maps onto the plain form, re-introducing the #1792 failure rather than preventing a substitution. Also:escapeRegExpwas a THIRD copy (ecr-uri.ts/gc.ts/assets/asset-redirect.ts) and is now the sharedsrc/utils/regexp.ts, with gc-side negative arms proving the escaping (unescaped, gc collectsdkrxecr/onXawslook-alikes); the complete classification table of every verdict these two issues changed — including the five DIAGNOSTIC-ONLY flips, where the parse result is unchanged butlooksLikeEcrHostWithForeignSuffixgoesfalse->true— is recorded inECR_URI_HOST_REGEX's doc and pinned row-by-row by a test; and the consumer-level effect finally has coverage (ecs-task-resolver.test.tsarms proving a FIPS / dual-stack / UPPER-cased host now classifieskind: 'ecr', and that a mispairing stayspublicwhile reaching the #1764 diagnostic). - The
AWS::ECS::ServiceDeploymentConfigurationnested blocks classified against real AWS — three parity rows and one DIVERGENCE, no behavior change (issue #1806) —src/provisioning/providers/ecs-provider.ts(comment only),docs/provider-development.md,tests/unit/provisioning/ecs-deployment-configuration-subfield.test.ts. The residual #1805 left open: its ROLLING probe could not reachLinearConfiguration/CanaryConfiguration/LifecycleHooks[], and those carry no (or a partial)requiredlist, so unlike the #1802 depth-2 row a kept-but-partial block there IS reachable from a template CloudFormation ACCEPTS. Measured us-east-1 2026-08-13, SDK and CloudFormation A/B per block on a real traffic-shifting service: the nested struct IS replaced, but the absent member is filled with an AWS-side DEFAULT (StepBakeTimeInMinutes-> 6,CanaryBakeTimeInMinutes-> 10, a hook'sTimeoutConfiguration.Action->ROLLBACK; the defaults are fixed, not derived — an emptyLinearConfiguration: {}from a live{33, 9}came back{10, 6}), and CloudFormation handed the SAME partial template reached the IDENTICAL end state in all three cases. So cdkd's verbatim pass-through is parity and a "fill the missing member from the previous side" normalization would be the DIVERGENCE. One PREMISE of the issue did not survive contact, and one FINDING it does not discuss is recorded beside it: those configs are NOTBLUE_GREEN-reachable (each is gated on its OWN strategy — AWS answersLinear configuration can only be present with LINEAR deployment strategy— so the fixture the issue prescribed could not have reached them at all;LifecycleHooksis not strategy-gated that way, measured underCANARY), and the default-fill does not become phantom drift, because the deploy engine capturesobservedPropertiesfromreadCurrentStateand the AWS-filled value lands in the drift baseline. The sweep of the REST of the tree splits TWO ways (plus the array shape), so neither "the other blocks are refused" nor "the rest is parity" is the takeaway (DeploymentAlarmskeeps its permissive-divergence row). AWS itself REFUSES — the ASGInstanceMaintenancePolicydisposition (#1227), cdkd fails loudly, parity with no nested required-ness check involved — for a hook element missingLifecycleStages; a hook element missingTargetType(absent DEFAULTS toAWS_LAMBDA, which then demands a target ARN and role a PAUSE hook does not carry, so dropping one member arms a requirement for two others); andDeploymentCircuitBreaker.ThresholdConfigurationmissingValue. That last one carriesrequired: [Type, Value]in the live registry, so CloudFormation refuses it too and the parity comes from BOTH engines failing — what decides a verdict is whether AWS ACCEPTS, not whether CFn refuses (DeploymentCircuitBreakermissingRollbackis CFn-refused and AWS-ACCEPTED, which is exactly why that row is the permissive divergence and this one is not). It was probed through@aws-sdk/client-ecsrather than the AWS CLI on purpose: the CLI refuses it CLIENT-side off botocore's required trait while the JS SDK cdkd uses serializes the partial, so cdkd's refusal comes from the SERVICE — the models agree, only where validation runs differs. And one genuine DIVERGENCE, opposite in polarity to #1802 and filed as #1861:DeploymentCircuitBreaker's OPTIONAL children (ResetOnHealthyTask, and the wholeThresholdConfigurationblock) dropped from an otherwise complete parent are RETAINED by cdkd and RESET to their AWS defaults by CloudFormation (measured from the identical baseline withRollbackflipped in the same call so the update demonstrably applied: cdkd leftfalse/{COUNT, 7}intact, CFn producedtrue/{BOUNDED_PERCENT, 50}). So cdkd fails to apply a removal CFn applies — too STICKY where #1802 is too PERMISSIVE. What CFn is doing is applying a REMOVAL rather than materializing defaults, which is what a fix must key on: a member the template NEVER declared (set out of band) SURVIVED a CFn update that changed onlyMinimumHealthyPercent, while the same member declared-then-dropped was reset — the previous-present / current-absent semanticclearOnUpdateRemovalimplements one level up. Removing the WHOLE property still resets nothing under either engine, so only a member dropped from a STILL-DECLARED struct is a removal; theLinearConfigurationfamily is parity because the API itself default-fills there. Service identity was captured on both sides (same ARN /createdAt, stack events showing UPDATE) so a replacement cannot be mistaken for a reset. This PR ships the pass-through unchanged and records the divergence; the reset is a behavior change #1861 owns. TheLifecycleHooksARRAY is replaced WHOLESALE (a 2-element list re-sent with one element left exactly that one), making a per-element drop a non-issue. Pass-through pinned by a new describe block whose assertions are written against the shape the REGRESSION would emit. Three mutation probes back that up rather than a blanket claim: an arm-specific default fill fails the Linear, Canary, hook and missing-stages pins; declaringDeploymentConfigurationingetDriftUnknownPathsfails the drift fence; and a readback that still WRITES the key but stops re-casing it (raw SDK camelCase) fails thereadCurrentStateround-trip fence while leaving every send-side pin green — which is why that fence is a round-trip rather than the source grep it replaced. -
ResourceUpdateNotSupportedErroris terminal by construction, so a resource named*DependencyViolation*no longer burns ~47s of backoff (issue #1838) —src/utils/error-handler.ts,src/deployment/retryable-errors.ts(comment only),tests/unit/deployment/retryable-errors.test.ts. Found by the review of PR #1827, which added themarkNonRetryable/isMarkedNonRetryablemarker mechanism (issue #1778) and recorded this as a KNOWN LIVE INSTANCE it could not cover, becausesrc/utils/error-handler.tswas owned by the concurrent #1710 lane. The retry classifiers inretryable-errors.tsmatch by SUBSTRING against the error MESSAGE, andResourceUpdateNotSupportedErrorinterpolates the resource's LOGICAL ID into its message.DependencyViolationis the only whitespace-free entry inRETRYABLE_ERROR_MESSAGE_PATTERNS, so an ordinary composite CDK logical id was enough:MyDependencyViolationSubmadeisRetryableTransientErrorreturn true for a deterministic, cdkd-authored "this type does not support UPDATE" refusal, which ~20 providers raise from inside theupdate()calldeploy-engine.tswraps inwithRetry(anddrift.tswraps for--revert). The refusal was therefore retried through the full generic schedule — 8 retries, ~47s of pure sleep — before the deploy reached the--replaceDELETE+CREATE fallback the error exists to trigger. It now callsmarkNonRetryable(this)in its CONSTRUCTOR rather than at eachthrow: the class is a refusal in every instance, so marking centrally means no provider throw site has to remember, and one forgotten call cannot re-open the hole for a single provider. No import cycle is introduced —retryable-errors.tshas zero imports of its own, andsrc/utils-> other-layer imports already have precedent (src/utils/live-renderer.ts->src/provisioning/resource-name.js). Class identity,exitCode = 2, and theresourceType/logicalId/suggestionpayload are untouched, so the deploy engine'sinstanceoffallback selection andhandleError's exit code are unaffected. The issue's sibling audit of the other refusal classes in the same file marked nothing else:ProvisioningError(and theCdkdErrorbase) must stay unmarked because they are the generic wrapper for RELAYED AWS failures — marking either would make every transient AWS error terminal;ResourceTimeoutErroris raised BYwithResourceDeadline, which wraps the retry loop from OUTSIDE at both sites (deploy-engine.ts:2375,destroy-runner.ts:983), so the classifier never sees it;IntrinsicResolutionRefusalErrorinterpolates user text but intrinsic resolution runs OUTSIDE thewithRetryclosure (which wraps onlyprovider.create/provider.update), and one of its four arms — the fabricated-account guard — is genuinely time-dependent, sincegetAccountInfocaches a fabricated answer for only 10s so a later attempt can heal, which is a positive reason NOT to mark it; and every remaining class (StackTerminationProtectionError,NestedStackChildDirectDestroyError,StackHasActiveImportsError,PartialFailureError,DeployCancelledError,AssetError,MacroExpansionError,LocalMigrateError,MissingCdkCliError, theLocal*/ state / lock / synthesis / config classes) is thrown from destroy pre-flight, command-level aggregation, asset publishing, synthesis or local emulation — none inside a retried closure. Tests pin the marker rather than the wording:isRetryableTransientErroris false for aResourceUpdateNotSupportedErrorwhose logical id containsDependencyViolation, a CONTROL asserts an UNMARKED error carrying the SAME message is still classified retryable, a premise test asserts the message really does carry the pattern (so the block cannot pass vacuously), and further cases cover all four constructor arg shapes, survival through aProvisioningError.causewrap, precedence over a co-occurring throttle signal, payload / class-identity preservation, and the four audited-and-left-unmarked siblings. A second block intests/unit/deployment/retry.test.tspins the user-visible SYMPTOM rather than the classifier verdict — the real class driven through the realwithRetryrejects after exactly ONE attempt with ZERO sleeps, against a CONTROL where the same message unmarked still drives 9 attempts and over 40s of requested backoff. Mutation-probed: disabling the constructor'smarkNonRetryable(this)call turns exactly the 5 marker-dependent tests RED (154 still pass, so both files were found) while both CONTROLs stay green. Code review additionally found the SAME defect shape innestedStackChildFailureMessage(src/provisioning/nested-stack-messages.ts), which IS thrown from inside the destroy runner's retried closure; it is filed as issue #1849 rather than fixed here, becausesrc/provisioning/**was owned by a concurrent lane and — unlike the type-level immutability fact this change marks — a whole-child re-destroy could plausibly heal, so it needs its own per-arm audit plus a real-AWS destroy integ. - The AWS-computed per-index
WarmThroughputstops drifting a GlobalTable forever (issue #1742 defect 2) —src/provisioning/providers/dynamodb-globaltable-provider.ts,tests/unit/provisioning/dynamodb-globaltable-provider-drift-phantoms.test.ts. The half PR #1773 shipped without, now that the mechanism it needed exists on main.DescribeTablereports aWarmThroughputfor EVERY index whether or not the template asked for one — a default{ReadUnitsPerSecond: 12000, WriteUnitsPerSecond: 4000, Status: 'ACTIVE'}, measured us-east-1 2026-08-13 on therollback-replay-effective-propsfixture — so on aproperties-only baseline (what a reverse-replacement rollback leaves behind, sincerollback-executor.tsstripsobservedProperties) the member was a PERMANENT one-sided difference:cdkd driftreported an untouched table forever and--revertre-issued calls for it. Both of the issue's own candidate answers are unusable and each is now pinned by a test. Declaring the path ingetDriftUnknownPaths— the #1760 answer for the siblingAWS::DynamoDB::Table's TOP-LEVEL member — cannot express a member of an ARRAY ELEMENT:calculateResourceDriftcompares arrays wholesale, soisIgnoredPathis never asked about a path crossing one and the only expressible suppression is the WHOLEGlobalSecondaryIndexessubtree, i.e. never detecting an index add / remove / capacity change again (the #1420 stopgap already refused). Gating the READBACK emission on the desired side removes this population's phantom and CREATES one for the far largerobservedPropertiespopulation — every bag already in S3 was written by a binary that emitted the computed member, so the firstcdkd driftafter upgrading compares a baseline that HAS it against a readback that does not and reports the whole array; it does not self-heal, since the observed capture only runs on CREATE / UPDATE. That attempt was implemented, reviewed and REVERTED under #1773, and the in-code comment now says so, so it is not re-proposed. What ships instead is thecanonicalizeDriftPropertiesseam (issue #1784): the provider stripsGlobalSecondaryIndexes[].WarmThroughputfrom BOTH comparison sides, which converges the two populations at once — a stale observed record and a fresh readback both lose the member, and a template-only baseline that never had it now faces an AWS side without it. Scope is a CLOSED module-level path table rather than a walk for any key namedWarmThroughput(Replicas[].GlobalSecondaryIndexes[]entries carry only the read-half throughput blocks, andLocalSecondaryIndexesis a wider raw-SDK divergence than one member), theresourceTypeis CHECKED as a cheap shape guard — the arm is UNREACHABLE today (drift.tsresolves the provider by(resourceType, provisionedBy)and passes that same type back, and this provider is registered for one type), and an earlier draft citing the #1784 CC-API-fallback caveat as the reason was wrong: that caveat is about the bag SHAPE for a type with noreadCurrentStateand never produces a foreign type, and the function is pure / non-mutating / identity-returning when inapplicable so an unaffected table pays nothing. The readback half ships with it:readCurrentStatenow reverse-maps the per-indexWarmThroughputto the CFn shape, dropping the AWS-managedStatusmember the CFnWarmThroughputTypehas no concept of — a table that DECLARES warm throughput drifted permanently against its own declaration on that member alone, incdkd driftand in--revert'supdate()previous side (NOT incdkd diff, which comparesstate.propertiesand never consults the readback). Dropping a key from a readback would ordinarily strand every already-writtenobservedPropertiesrecord (the #1760 lesson); it does not here precisely because the canonicalizer removes the WHOLE member from both sides, which is why the two halves must ship together and the code says so. ACCEPTED COST, recorded in-code rather than filed: a template that DECLARES a per-indexWarmThroughputno longer has changes to it REPORTED bycdkd drift. The value is still SENT (toSdkGlobalSecondaryIndexesforwards it), so this is a detection gap and not a delivery one, and it is bounded by what cdkd could do about the difference anyway — warm throughput only ever GROWS on the AWS side and cannot be lowered, so--revertwould issue a decrease AWS rejects (the residual issue #1768 records for the sibling type's declared arm). A per-index declared-gate is not expressible in any case: the hook sees ONE bag, with nosideargument and no reference to the desired side, which is the symmetry #1784 requires. Twelve new unit cases — ten on the canonicalizer, two pinning BOTH polarities of the readback shape (a reportedWarmThroughputloses itsStatus; an index AWS reports none for gets no key at all, since an always-emit{}placeholder would be a fresh one-sided difference) — each paired with a discrimination case that must NOT change: the stale-baseline transition and the template-only baseline are both asserted through the REALcalculateResourceDrift(not by eyeballing the two bags) with a negative control proving the clean result comes from the hook; a real index deletion stays visible; identity is asserted by reference for an unaffected bag, a bag with no index list and another resource type; a non-array index list and a non-object element are shape-guarded; non-mutation is asserted on the caller's bag; andgetDriftUnknownPathsis pinned as NOT declaring the subtree. Both source halves were mutation-probed in isolation (neutralizing the canonicalizer fails 4 cases, restoring the pre-fix readback line fails 1). Still open on #1742: the live test the issue calls load-bearing — a real-AWS run seeded with a hand-patched STALEobservedPropertiesbaseline, which a fresh-deploy fixture structurally cannot provide because both comparison sides come from the same readback — plus restoring therollback-replay-effective-propscapacity table's GSI, which that fixture drops today to work around this defect. - The
onUnusablewarning is a terminated SENTENCE and describes the READ, so it no longer runs together with — or contradicts — the caller's appended clause (issue #1735) —src/provisioning/config-shape.ts,tests/unit/provisioning/config-shape.test.ts. Deploying anAWS::DynamoDB::Tablewhose template carriesBillingMode: ''against a livePAY_PER_REQUESTtable logged, verbatim:... check for an unresolved intrinsic or a mis-nested template value. Ignoring it and using the default (PROVISIONED) here; the same value is REFUSED on a template-path create The mode this update compared against (PAY_PER_REQUEST) is kept .... Two defects, both in the SEAM between the shared helper's message and the caller's appended clause, so neither belonged to one provider. (1) No sentence separator: everyonUnusablemessage ended bare while every caller composes as${message} <their sentence>, so the two ran together at exactly...template-path create The mode.... All fouronUnusablemessages (readConfigString's container arm,requireConfigString,requireConfigArray,requireConfigObject) now end with a period; thethrowarms deliberately do NOT, since nothing composes onto those. (2) The two halves contradicted each other: the helper said "Ignoring it and using the default (PROVISIONED)" while the caller's clause said the compared mode (PAY_PER_REQUEST) "is kept" — both true of DIFFERENT things (what the HELPER returned vs what the provider SENDS), but read as one sentence the user could not tell which value reached AWS, which is the only question the warning exists to answer.requireConfigStringnow describes its own READ ("This read falls back to the default (PROVISIONED)") and leaves the outcome entirely to the caller;readConfigString's container arm takes the same re-scoping. For a caller that appends NOTHING the read IS the outcome, so those sites lose nothing.requireConfigArray/requireConfigObjecttake (1) but NOT (2): they returnundefinedand every caller SKIPS, so "Leaving this configuration unapplied" is what actually happens on all of them and no caller's clause contradicts it. Fixed in the HELPER, not at the callers, which is what makes it durable AND what made it shippable:.claude/rules/providers.mdprescribes exactly such a per-site clause ("keep the PREVIOUS value" / "SKIP the block" / "SUPPRESS the diff"), so the appending set GROWS, and a future appender now inherits the fix instead of repeating the bug — while the two contended appending files (dynamodb-table-provider.ts,dynamodb-globaltable-provider.ts, both owned by in-flight lanes) needed no edit at all. Why no test caught it: every assertion in this family usesstringContaining, so none of them ever rendered the COMPOSED sentence — a live real-AWS run was what surfaced it. The suite now carries a composition block that asserts the joined string directly, table-driven over all four guards (terminal punctuation, nocreate [A-Z]run-together signature, and the no-contradiction case reproducing the reported shape verbatim), plus a pin that the throw arm stays unterminated so a future "consistency" sweep does not punctuate messages that have no composition problem. Mutation-probed against the reverted wording: 10 assertions fail across all four guards and both defects. Full suite green (12431 tests), confirming no other suite depended on the changed wording. - Route 53 HostedZone
NameServerskeeps its CloudFormation list shape through Output resolution —src/provisioning/providers/route53-provider.ts,src/deployment/intrinsic-function-resolver.ts,tests/unit/{provisioning/route53-provider,deployment/intrinsic-functions}.test.ts,tests/integration/route53/**. Before: all three SDK-provider paths that exposeAWS::Route53::HostedZone.NameServers— create, update, and on-demandgetAttribute— joined the delegation set into one comma-delimited string before recording it in state. CloudFormation defines this attribute as a LIST, so the common CDK patternFn::Join(',', Fn::GetAtt(..., 'NameServers'))received a string, failed Output resolution with “Fn::Join's second argument must be a list”, and silently left that Output absent even though the hosted zone itself deployed successfully. Now: the provider preserves the AWS SDK array in every path, soFn::GetAttsupplies the list shape thatFn::Joinrequires. Existing stacks do not need recreation or a manual state edit: the intrinsic resolver recognizes the old Route 53-only comma-delimited state shape at its read boundary and converts it back to a list, including the empty-string edge, without a schema-version bump. Unit tests cover create, update, on-demand enrichment, the current array state, and the legacy string state. The Route 53 live fixture joins the Output and compares its sorted values with the realGetHostedZonedelegation set; the 2026-08-13 run passed with 8 resources created, 8 deleted, 0 errors, and 0 orphans. A separate 19-resource broad deploy/destroy also passed because the compatibility read sits in the shared intrinsic resolver. -
AWS::Glue::Databaseresource links and Lake Formation default permissions reach AWS (issue #1807) —src/provisioning/providers/glue-provider.ts,scripts/gen-nested-key-coverage.ts,docs/_generated/nested-key-coverage.{json,md},.claude/rules/providers.md,tests/unit/provisioning/glue-database-input-members.test.ts,tests/unit/scripts/gen-nested-key-coverage.test.ts,tests/integration/glue-update-hardening/. The bug:GlueProvider.buildDatabaseInputis a FRESH-OBJECT builder (const result: DatabaseInput = { Name }) that named onlyDescription/LocationUri/Parameters, while both the CFn schema and@aws-sdk/client-glue'sDatabaseInputalso declareTargetDatabase,FederatedDatabaseandCreateTableDefaultPermissions— andgrep -rnfor those three spellings undersrc/returned ZERO hits. So a database declared as a RESOURCE LINK to another catalog, a FEDERATED database, or one carrying Lake Formation default table permissions deployed "successfully" as a plain empty database: no error, no warning, and nocdkd driftfinding either, becausereadCurrentStatedid not surface them. Eleven nested paths were affected. Why nothing caught it: the CFn and SDK spellings AGREE, so the nested-key critic's key pass classified every pathsame-spellingand stayed silent — this is the #1432 "membership does not guarantee delivery" class, visible only to the WRITE-EVIDENCE pass. That pass was deliberately left OFF for this type when the Glue targets were registered (#1393 item 3), with the 11 paths pinned BY NAME inGLUE_DATABASE_DROPPED_PATHSplus a calibration test rather than silenced by 11 allow-list entries — an allow entry would have muted a CI-blocking bucket for a LIVE drop, which that critic's design refuses. The pin was written so that fixing the provider FAILS it, forcing the opt-in into the same change; this PR is that change, so the list and its test are gone andAWS::Glue::Databasenow carriesfreshObjectMapper: true(15 audited paths, allsame-spelling, 0 blocking findings). The fix names every member INDIVIDUALLY rather than casting the three blocks through verbatim, which the siblingTableInput.TargetTable/ViewDefinitionforwards do. A cast would typecheck and deliver correctly, but it leaves the 8 CHILD paths reportingno-write-evidence— on a fresh-object target only a per-member write is delivery proof — so the opt-in would have been impossible, and the next member AWS adds would vanish silently instead of failing the critic.readDatabasereverse-maps the same three blocks EMIT-WHEN-PRESENT (unlike the always-emittedDescription/Parametersplaceholders): AWS reports a Lake Formation DEFAULTCreateTableDefaultPermissionsofIAM_ALLOWED_PRINCIPALS/ALLfor a database that declared none, so a placeholder would have to invent one of two shapes and be wrong on the other population — and an AWS-only key is ignored by the drift comparator, which descends only into keys present in the baseline. Wire behavior was PROBED, not assumed (us-east-1, 2026-08-13): a same-account resource link needs no Lake Formation onboarding; AWS REFUSESDescriptionandTargetDatabasetogether (Description and resource link cannot exist together in a database!), which is why the integ arm puts them on separate databases; an undeclared database reads backCreateTableDefaultPermissions: [{IAM_ALLOWED_PRINCIPALS, [ALL]}], which is what makes a declared[SELECT]distinguishable from a silent drop; andUpdateDatabasere-sendingTargetDatabaseround-trips unchanged. The three blocks are SHAPE-GUARDED, which the first cut of this change was not (found by all three PR reviewers): a malformedTargetDatabasepassed every member read asundefinedand cdkd SENT an empty{}block, a non-arrayCreateTableDefaultPermissionsthrew a rawTypeErrorout of.map, and a plain-object-but-unreadable block (an unresolved intrinsic, a bare{}) shipped empty too. The blocks now take the repo's standard split — REFUSE on a template-path create (downgraded throughreplayWarnon a reverse-replacement replay, which neededCreateContextthreading intocreateDatabase), WARN plus RETAIN THE PREVIOUS block on update, becauseUpdateDatabasereplacesDatabaseInputwholesale so omitting it would ERASE a live resource link (the #1612 UPDATE row reached through a whole-blob API), and DROP the key when both sides are unusable.CreateTableDefaultPermissionsis validated ALL-OR-NOTHING rather than per entry: it is one wholesale call, so dropping unreadable entries would silently NARROW a Lake Formation grant list. The readback ALSO needed drift scoping, which the same review caught: on theobservedPropertiesbaseline the comparator walks the key UNION, so surfacing AWS's Lake Formation default would have reported drift on EVERY already-deployed Glue database until an unrelated update ordrift --accept—getDriftUnknownPathsnow scopesDatabaseInput.CreateTableDefaultPermissionsout PER RESOURCE for a database whose template declares none (the ELBv2Targetsshape; an explicit empty list IS a declaration and stays compared). A related reviewer concern did NOT reproduce and is recorded in-code: AWS refuses a database carrying both a description and aTargetDatabase, but the refusal is on a NON-EMPTY description — the always-emittedDescription: ''placeholder is accepted alongside a link on both create and update (measured us-east-1, 2026-08-13), so thedrift --revertround-trip through the readback is safe. A SECOND review round then found the guard itself incomplete, in the direction that mutates: the readable-but-empty case (a bare{}/ an unresolved{Ref}) is a plain OBJECT, so the shape guard accepted it and a?? previousfallback never fired — the block built empty andUpdateDatabasedropped a link the template still declared, which is exactly what the retain arm exists to prevent. The block's SOURCE is now picked before it is built (pickBlockSource): desired-when-it-names-a-sendable-member, else previous, else nothing — one ladder for the wrong-shape and readable-but-empty cases alike. "Sendable" means a STRING or a NUMBER, which also closes the leaf hole the same round found (an intrinsic-valuedDatabaseNamerode an unchecked cast onto the wire) and makes an unquoted-YAML numericCatalogIdreach AWS as the string the SDK wants. APrincipalnaming no sendable identifier is the same defect one level in and invalidates its entry. The drop-both-unusable warning now says what a drop MEANS on a wholesale-replace API (a RESET to the account default), andgetDriftUnknownPathstreats an UNREADABLE properties bag as "declares no block" for this one key — the usual default is to compare, and this key inverts it because AWS always materializes the default, so an older import carrying only the top-levelDatabaseNamewould otherwise report drift on every run with no way to clear it. A THIRD round then closed a silent NARROWING the second had introduced: the sendable-member test usedsome, so ONE literal member licensed the whole block and an intrinsic-valued sibling was dropped with no message —TargetDatabase: {DatabaseName, CatalogId: {Ref}}shipped withoutCatalogId, silently retargeting a cross-account resource link at the caller's own catalog, and the guard's strictness depended on siblings (the sameCatalogIdalone WAS refused). A block is now usable only when EVERY declared member present is sendable. The same round alignedisSendableLeafwithcatalogIdForApion non-finite numbers, coerced a numericDataLakePrincipalIdentifierlike every other leaf, and gave the refusal message an action clause naming what happens next on a wholesale-replace update. Tests: 51 unit cases (each a WHOLE-PAYLOADtoEqual/toStrictEqualon theDatabaseInputthe SDK command carries, because the regression is a member silently MISSING — atoMatchObjector per-key probe passes against exactly the shape the bug produced), covering create / update / readCurrentState, partial blocks, omission-when-absent, template-side removal, a readback-to-update()round-trip, every malformed shape on both paths, the replay downgrade, the retain-previous and drop-both-unusable arms, an explicit empty permission list, and the per-resource drift scoping. The critic's own fence is INVERTED from the old pin (empty finding set + the 11 paths named), and the fix was mutation-probed through the shipped--check's--providers-dir=seam: with the three write blocks stripped from a scratch copy of the real provider it exits 1 naming exactly those 11 paths, and 0 with them present. Live coverage:glue-update-hardeninggains a resource-link database (TargetDatabasepointing at the fixture's existing table database) and a second database carryingCreateTableDefaultPermissions, asserted on BOTH the create and the UPDATE phase — the update phase flips the granted permission set, sinceUpdateDatabaseREPLACESDatabaseInputwholesale and a regression would ERASE the block from a live database rather than merely never send it — plus post-destroy gone-probes for both. -
cdkd deployconsumes the{ outcome: 'skipped' }delete outcome its five call sites used to discard (issues #1762 / #1803) — newsrc/deployment/delete-outcome.ts,src/deployment/{deploy-engine,rollback-executor}.ts,src/cli/commands/{deploy,destroy-runner}.ts,src/provisioning/composite-id.ts,src/provisioning/providers/{iam-policy,iam-user-group,custom-resource,lambda-layer,lambda-permission}-provider.ts,tests/unit/deployment/{deploy-engine-delete-skipped,rollback-executor-delete-skipped}.test.ts,docs/{cli-reference,deployment-events,provider-development}.md,.claude/rules/providers.md. Issue #1752 gaveResourceProvider.deletea'skipped'outcome meaning "this row was NOT destroyed and may still be alive" and taughtcdkd destroyto report it; every other call site discarded the value, so the same skip printed✓ … deleted, counted as deleted and — in the deploy engine's template-DELETE branch — DROPPED the state record, leaving the user with neither the AWS resource gone nor an id pointing at it. Each of the five sites now handles it the way its own situation allows, which is the whole design decision: the template-removal DELETE warns, prints⚠ <id> (<type>) skipped (<reason>), KEEPS the record, counts a newDeployResult.deleteSkipped(rendered asSkipped (not deleted)only when non-zero) and emitsRESOURCE_SKIPPEDinstead ofRESOURCE_SUCCEEDED— but still exits 0, because the kept record means the resource is still a pending DELETE and the next deploy re-attempts it, wherecdkd destroyhas no next run and therefore exits 2; the three replacement deletes (--replacedelete-first,--recreate-via-{cc-api,sdk-provider}, the UPDATE-not-supported fallback) FAIL the resource, since their create would otherwise run beside a live old one or collide with its name; the create-first cleanup delete warns, matching the policy that site already had for a delete FAILURE (the new resource is created and recorded, so the old one is untracked either way, and failing would roll back a replacement that worked); and all five rollback-executor arms throw into their existing per-op accounting — at four of them that means afailurescount withROLLBACK_RESOURCE_FAILEDand a KEPT journal segment rather than a silent successful revert, while the fifth (deleting the NEW resource AFTER the old one was re-created) lands in that site's own pre-existing catch as awarningscount, because the revert itself succeeded and only the new resource leaks — the same outcome that arm already gives a FAILED delete, so the skip inherits its policy rather than inventing a stricter one. Two consequences beyond the reporting: a skipped DELETE is no longer pushed tocompletedOperations, because journaling a delete that never happened would makecdkd rollbackre-CREATE a live resource; and the skip is checked OUTSIDE each site'scatch, so a providerreasoncan never be read by the already-deleted message classifiers (not found/does not exist/ …) those blocks run — reading a skip as "already gone" is the exact mis-accounting being removed.ProvisionCounts.deleteSkippedis deliberately separate from the existingskipped, which counts UPDATEs that resolved to no change and feedsunchanged. Thirteen unit cases across the two new files, mutation-probed by neuteringdeleteSkipReason(9 of 13 went red; the 4void/{ outcome: 'deleted' }controls correctly stayed green). Shipped with #1803's one-line correction in the same PR since both land in the skip-accounting family:destroy-runner.ts'sskippedCountJSDoc said "Two producers today" and had gone stale three times (once per lane that added a producer), so it now points atResourceDeleteResult's own enumeration as the single source of truth instead of restating it. The same staleness was fixed in the five providers'DEPLOY_SKIP_CAVEATstrings and incompositeIdFormatMessage, all of which warned users that the deploy side "DROPs the record and reports success" — true when they were written, false as of this change. Superseded 2026-08-19 by issue #1960: the exit-0 half of this entry no longer holds — such a run now exits 2 (the state record is still kept, which was the other half and is unchanged). See the 2026-08-19 entry. - The FOURTH region -> URL-suffix derivation — cdk-local's — is now behind a canonicalizing boundary (issue #1814) —
src/local/intrinsic-image.ts,tests/unit/local/intrinsic-image.test.ts. Residual of the #1795 region-case fix, found by that PR's code review. #1795 folds the region insidederivePartitionAndUrlSuffix(src/utils/aws-partition.ts) so every cdkd-OWNED caller inherits ONE normalization point, butderivePseudoParametersFromRegionis not cdkd's — it arrives through thesrc/local/intrinsic-image.tsshim as a bare re-export fromcdk-local/internal, and cdk-local carries its own partition table whose prefix tests are case-sensitive in the same direction. So theFn::JoinCode.ImageUripath (issue #637) still synthesized<acct>.dkr.ecr.CN-NORTH-1.amazonaws.com/...— acn-region carrying the commercial suffix, a host that does not exist — on the five call sites, across three files, that reach the symbol:src/cli/commands/local-start-api.ts,src/cli/commands/local-invoke-agentcore.ts(x3) andsrc/local/lambda-resolver.ts. The shim's re-export of that ONE symbol becomes a thin BOUNDARY WRAPPER (the shapedocker-image-builder.tsalready uses for its error-class boundary) that runs the region throughcanonicalizeRegionbefore delegating;substituteImagePlaceholders/tryResolveImageFnJoinstay bare re-exports. This is the FALLBACK direction the issue names — the preferred fix is upstream, and the wrapper stays correct if cdk-local later canonicalizes too, because the fold is idempotent. The INPUT is canonicalized rather than the result post-processed, because the returnedregionis substituted as${AWS::Region}into every ARN the resolver builds, so a raw value would misspell those too and not just the derived suffix;undefinedstill passes through so upstream keeps owning the missing-region verdict. Unit tests pin each non-commercial partition cdk-local knows (cn-/us-gov-/us-iso-/us-isob-) as case-invariant across the upper-cased AND mixed-case spellings a--regionflag can carry, with every commercial answer asserted byte-identical to today's so the change is safe without a non-commercial account, plus a BINDING PROOF that each of the five call sites reaches the symbol through the shim rather than importing it straight fromcdk-local/internal— a site doing the latter would silently keep the raw-region behavior while every other assertion still passed. That predicate is itself proven against a synthetic import block, so it cannot pass vacuously. Probing the two tables against each other while writing those tests surfaced a SEPARATE divergence, filed as issue #1821 and deliberately not fixed here: cdk-local's table predates the three rows cdkd's #1764 added, sous-isof-/eu-isoe-/eusc-regions resolve COMMERCIAL even when spelled canonically. That is a table-COVERAGE gap, orthogonal to case and unfixable by canonicalization; it is pinned by its ownit.eachcase so it fails loudly the day upstream gains the rows instead of being re-discovered. The PR's own code review then found the sibling half in the same file:cdkd local invoke-agentcorewas the one region-takingcdkd localcommand the #1795 sweep skipped, so while this change fixed its pseudo-parameter path, its RAW--regionstill reached three consumers that are case-SENSITIVE in the same direction the partition table is — the SigV4 signing SCOPE (where--regionhas the highest precedence over the credential chain's region), theSTSClientendpoint resolution, andapplyRoleArnIfSet. It now folds at the handler entry like its three siblings, andtests/unit/cli/local-region-case.test.ts's source-level pin (the fold is not reachable from a unit test) gains it as a fourth row — verified binding by reverting the statement and watching exactly that row fail. - Seven provider ARN builders derive their partition instead of hardcoding
arn:aws:(issue #1815, PARTIAL) —src/provisioning/providers/{cloudwatch-alarm,servicediscovery,sqs-queue,logs-loggroup,s3-directory-bucket,apigateway,budgets-budget}-provider.tsplus their unit tests. Same class as issues #1730 / #1745 / #1794 and quiet for the same reason: an ARN built with the wrong partition is structurally VALID, so nothing downstream rejects it — it is simply recorded into state and served as the resource'sFn::GetAttanswer. Each builder now routes throughderivePartitionAndUrlSuffix(region).partition(src/utils/aws-partition.ts), the same closed mapping${AWS::Partition}uses. Two of the seven are load-bearing beyond the recorded string: the API Gateway stage ARN is passed straight toTagResource/UntagResourceasresourceArn, and the S3 Express directory-bucket ARN is BOTH an attribute and the S3 Control tagResourceArn— so outside the commercial partition those tag mutations named a resource that does not exist. Where no region is in hand the answer is derived from the best source available rather than guessed.budgets-budget-provider.tshas no region SEGMENT but still carries a partition, sobudgetArnbecame async and readsgetClient().config.region()— the RESOLVED region of the very client that receives the ARN, the same source itsdelete()path already consults forassertRegionMatch. Deriving fromproviderRegion(process.env['AWS_REGION']) instead was wrong on the PRIMARY path, not a fallback: when that env var is unsetgetClient()buildsnew BudgetsClient({})and the SDK resolves the region from its OWN chain (AWS_DEFAULT_REGION, the~/.aws/configprofile), so a profile-configuredcn-north-1/us-gov-*caller derived the COMMERCIAL partition from an empty string while the client talked to a non-commercial endpoint. The three placeholder arms (logs/sqsunknown:unknown, the cloudwatch wildcard-region*:*) readAWS_REGIONas a BEST-EFFORT hint — those arms are dominated by an STS failure, so the env var is neither the client's authoritative region nor the first source in the try arm's own chain — and it derives to the commercialawswhen unset or unrecognized, so commercial output is byte-identical everywhere the old output was right. The cloudwatch wildcard arm is additionally near-DEAD in production (a real SDK region provider resolves FROMAWS_REGION, soconfig.region()throwing while the env var is set is a state the runtime does not normally produce); it is noted as such in-code so the pinning test is not mistaken for evidence of a live non-commercial path. Per the issue #1745 / #1794 convention each site gets acn-north-1AND aus-gov-west-1case asserting thearn:aws-cn:/arn:aws-us-gov:output, PAIRED with a commercial case asserting the result is byte-identical to the pre-fix literal — that pairing is what makes the change provably non-breaking without a non-commercial account. Known bound recorded in-code and pre-existing rather than introduced here:s3-directory-bucket-provider.tsderives the ARN's region (and now partition) froms3Clientwhile the tag calls consuming it go throughs3ControlClient, built fromproviderRegion— the two can disagree, and the region segment already carried that exposure. The issue stays OPEN: this is the file-disjoint subset one lane could claim. Still residual —glue-provider.ts:2141/:2709,lambda-eventsource-provider.ts:68-74(partition-aware but hand-enumerated, so it works in China and not in GovCloud), and both partition-sensitivestartsWithpredicates, which change BEHAVIOR rather than a recorded string and are the half the issue flags as the user-visible one:custom-resource-provider.ts:732misroutes an SNS-backed custom resource as Lambda-backed, andiam-managed-policy-provider.ts:501reads an AWS-managed policy as customer-managed. - The
DenyExternalAccessbucket policy names the caller's partition (issue #1794, partial) —src/utils/deny-external-access-policy.ts(new),src/cli/commands/bootstrap.ts,src/cli/commands/state-migrate.ts,src/assets/asset-storage.ts,.claude/rules/code-layout.md,tests/unit/utils/deny-external-access-policy.test.ts+ per-site cases in the three call sites' suites. All three buckets cdkd owns — the state bucket (cdkd bootstrap), thecdkd state migratedestination bucket, and the asset bucket (ensureAssetStorage) — are hardened with aDenyExternalAccessstatement denyings3:*to any principal whoseaws:PrincipalAccountis not the owner. ItsResourceARNs hardcoded theawspartition, so onaws-cn/aws-us-govthe statement named a resource that DOES NOT EXIST there: it matched nothing and the deny protected nothing. Nothing downstream could catch it — the document stays structurally valid,PutBucketPolicysucceeds, and each command still prints✓ Set bucket policy (deny external access)over a bucket with no effective deny at all. The three documents were byte-identical copies (which is how the literal drifted in all three at once), so the fix centralizes them in ONE helper that derives the partition viaderivePartitionAndUrlSuffix(region).partition— a fourth copy cannot re-introduce the class. Every call site derives the partition from the CLIENT that writes the policy (await client.config.region()), never from a localregionvariable — one rule, no per-site judgement about which value is authoritative. The first cut of this fix passedbootstrap'sregionvariable on the argument that AWS credentials are partition-scoped so it must agree; the 3-axis PR review (spec + code reviewers independently) showed that argument is sound about credentials-vs-bucket but does not describe the code:regionisoptions.region || AWS_REGION || 'us-east-1', a HARDCODED commercial fallback, whileAwsClientsomitsregionentirely when--regionis absent and lets the SDK chain read the profile's region andAWS_DEFAULT_REGION. So a GovCloud / China user with a non-commercial profile region, noAWS_REGIONand no--regionreproduced #1794's exact failure mode ON THE FIX'S OWN PATH. That regression is pinned by a dedicated unit case (mutation-probed: it fails against the reverted derivation). The underlying variable divergence ALSO mis-names the asset bucket / ECR repo / bootstrap-marker key, which is a rename-and-migration question rather than a bug fix and is filed separately as issue #1820. Commercial output is unchanged BYTE-FOR-BYTE, pinned by a literal-document test rather than by rebuilding the expectation from the same helper, since every already-deployed cdkd bucket carries exactly that policy. Every partition assertion was mutation-probed against the pre-fix hardcoded literal (9 failures across the helper and all three call sites; every commercial counter-case still passed). Remediation for an EXISTING non-commercial bucket:bootstrapskips the policy PUT for a bucket that already exists unless--forceis passed, so anaws-cn/aws-us-govstate bucket bootstrapped by an earlier cdkd keeps its inert deny untilcdkd bootstrap --forceis re-run (it rewrites versioning / encryption / policy and does not touch existing state). Partial by design: the issue's fourth site,CloudControlProvider'sAWS::S3::BucketArnenrichment, is NOT in this change —src/provisioning/cloud-control-provider.tsis owned by the in-flight #1778 lane and the repo's one-lane-per-file rule applies — so #1794 stays open with that site as its remaining scope. The wider residual class the audit surfaced (~10 provider ARN builders plus two partition-sensitivestartsWithpredicates) is issue #1815. - Region / host CASE no longer routes
cdkd local *at a host that does not exist (issues #1795 / #1801) —src/utils/aws-partition.ts,src/local/ecr-puller.ts,src/cli/commands/local-{run-task,invoke,start-api}.ts,tests/unit/utils/aws-partition.test.ts,tests/unit/local/ecr-puller.test.ts,tests/unit/cli/local-region-case.test.ts. Two halves of ONE case seam, found by the code review of the #1786 PR and MEASURED rather than reasoned about. (a) region CASE is canonicalized, in both of the two places it has to be. The sharedderivePartitionAndUrlSuffix(src/utils/aws-partition.ts) folds its region through the new exportedcanonicalizeRegionbefore thePARTITION_TABLEprefix walk, which is a case-SENSITIVEstartsWith— so--region CN-NORTH-1fell through to the COMMERCIAL partition and the threecdkd localcommands synthesized<acct>.dkr.ecr.CN-NORTH-1.amazonaws.com/<repo>:<tag>, acn-region carrying the commercial suffix. Doing that in the shared helper keeps ONE normalization point for the derived SUFFIX, so every present and future caller inherits it and the three call sites cannot drift apart again. But the suffix is only HALF the defect, and the review of this PR MEASURED the other half: the AWS SDK's own endpoint resolution is case-sensitive in exactly the same way — against this repo's vendored@aws-sdk/util-endpointspartition data,cn-north-1resolvesaws-cn/amazonaws.com.cnwhileCN-NORTH-1resolvesaws/amazonaws.com(both the exact-match table and theregionRegexfallback are case-sensitive) — so every SDK client built from the raw region talked to the WRONG partition's endpoint:sts.CN-NORTH-1.amazonaws.comfor the${AWS::AccountId}lookup (which then failed, dropping the account id with a warn and leaving the imageFn::Subunresolved), plus the ECR / SecretsManager / SSM clients further down thecdkd local run-taskpath; and${AWS::Region}itself was substituted verbatim into every ARN. So each of the three commands ALSO folds the region VALUE, at its pseudo-parameter resolution point (covering all four sources —--region, both env vars, and the synth- or state-derived region) AND once at the handler entry, sooptions.regionis canonical for every consumer those files hand it to. Double-folding is a no-op, which is what makes having it in both layers safe rather than redundant.parseEcrRegistryHost(src/utils/ecr-uri.ts, owned by a concurrent lane) is deliberately untouched: it does not fold case today, which is exactly whyparseEcrUricanonicalizes BEFORE calling it, and if that lane later adds its own fold this one becomes a no-op rather than a conflict — that boundary is where an untrusted, DNS-shaped string from a template or a state record enters cdkd, a different trust question from a CLI flag. (b)ecr-pullerlogs in to and pulls from the SAME host. It authenticated against the lower-cased endpoint (authData.proxyEndpoint, or the derived fallback) while runningdocker pullagainst the RAWimageUri, and docker's credential store is keyed on the hostname VERBATIM — measured with a temporaryDOCKER_CONFIGholding auth for<acct>.dkr.ecr.us-east-1.amazonaws.com, the lower-cased reference sent credentials (denied: Your Authorization Token is invalid) while theUS-EAST-1spelling sent NONE (no basic auth credentials), anddocker pullwas independently confirmed to preserve host casing verbatim in the request URL.canonicalizeImageUriHostnow folds ONLY the registry HOST andparseEcrUrireturns it ascanonicalUri, whichdocker pull/docker image inspect/ the returned run-reference all use. The repository path and tag are deliberately NOT folded — only the domain is case-insensitive, and rewriting the path would change WHICH image is pulled. "Everything before the first/" is deliberately NOT the rule either: docker treats component 1 as a registry only when it contains a.or a:or equalslocalhost, soMyOrg/MyRepo:tagis a Docker Hub repository PATH and folding it would name a DIFFERENT image — the component is tested rather than assumed. An AWS-reportedproxyEndpointstill WINS over the derived login endpoint (it can legitimately be a VPC-endpoint host, and AWS reports it lower-cased), so the invariant is that login and pull agree on the DERIVED fallback; both arms are covered. The caller region is folded here too, which closes the same class one level down: the two module-level STS caches are KEYED on it, soUS-EAST-1andus-east-1were separate entries paying a duplicateGetCallerIdentity/AssumeRole— exactly the cost those caches exist to avoid — and the STS / AssumeRole clients themselves resolved the commercial endpoint. The same change compares canonical forms on both sides of the cross-region check, so--region US-EAST-1against a lower-case host stops logging a spuriousCross-region ECR pullline. Not a regression in either half: before #1786 a mixed-case host was rejected outright and the image was pulled anonymously, so the user-visible outcome (the private pull fails) was the same and only the error differed — what these fix is that #1786's benefit was INCOMPLETE. Unit tests pin (a) per CALL SITE three ways — the suffix, the region VALUE, and the RESOLVED imageFn::Subthose two compose into (the assertion that would have caught a fix covering only the suffix), plus a source-level pin on the handler-entry fold whose SDK consumers a unit test cannot reach — and (b) that the login endpoint and the pull reference name the same host for a mixed-case input, on the derived-fallback arm AND the production-dominantproxyEndpointarm, with the VPC-endpoint precedence pinned alongside so the invariant cannot be read as "the login host is always the pull host". Every assertion is paired with an all-lower-case / commercial counter-case asserting byte-identical behavior, and each was verified to FAIL with its own source change reverted in isolation. - A suppressed GlobalTable billing flip stops recording capacity AWS never received, and an ABSENT recorded mode now consults AWS (issues #1738 / #1733) —
src/provisioning/providers/dynamodb-globaltable-provider.ts,tests/unit/provisioning/dynamodb-globaltable-provider-suppressed-flip-capacity.test.ts,tests/unit/provisioning/dynamodb-billing-mode-junk-previous.test.ts. #1738: when an unusable desiredBillingModesuppresses the flip (the issue #1683 arm 3 warn-and-KEEP path), the capacity call belonging to the OTHER mode never fires either — every one of those emissions is billing-mode-gated — while the effective bag still recorded whatever capacity blocks the template declared.retainUnsendableCapacityMembersnow decides the split per member against the KEPT mode, at all four container levels (top-level, per-GSI, per-replica, per-replica-index): with the kept modePROVISIONEDthe on-demand half is unsendable and is replaced by the PREVIOUS record's value while the provisioned half stays as declared (step 4b / 6b's auto-scaling reconcile and step 6's GSI diff really do deliver it); withPAY_PER_REQUESTthe mirror. That is the.claude/rules/providers.mdUPDATE row — retain the PREVIOUS value rather than the replay-CREATE DROP #1726 takes on the create side, because the table already exists and dropping the key would leave a later template that REMOVES the block deriving no removal. The previous side is VALIDATED throughasRecord(the same predicate every wire read of these blocks applies) before it is retained, per the #1653 review rule, and the key is DROPPED when the previous side is unusable OR absent; entries are matched byIndexName/Region, never positionally. NocanonicalizeDesiredPropertiestwin — this is a SKIP, so folding the desired side would derive a REMOVAL of a live capacity block from a template whose only fault isBillingMode. #1733: an ABSENT recordedBillingModeresolved to the create-path defaultPAY_PER_REQUESTWITHOUT consulting AWS (#1552 scoped its live-read fallback to the present-but-unusable shape), so a record with no mode — left bycdkd importof a PROVISIONED table, or by this arm's own DROP — made a correctedPAY_PER_REQUESTtemplate compare EQUAL, issue noUpdateTable, and silently lose the flip, withcdkd driftunable to report it either. The live-read fallback is widened to "absent OR unusable", under one deliberate narrowing: the read is consulted only when the DESIRED side DECLARESBillingMode, so a template that legitimately OMITS the property never consults AWS and cannot flip an imported PROVISIONED table to on-demand. That gate is not new —AWS::DynamoDB::Tablehas resolved an absent previous exactly that way all along, so GlobalTable CONVERGED onto its sibling rather than diverging from it. ABillingModeSummaryAWS does not report resolves to PROVISIONED, the same readingliveBillingModeand both providers'readCurrentStatealready take: DynamoDB omits the summary for a table created without an explicit mode, and such a table IS provisioned. The first cut resolved it to this provider's create-path default instead, and review caught that this left the fix INERT on its own headline population, while ALSO re-opening the #1552 same-modeUpdateTablefrom the other side (a record with no mode against aPROVISIONEDtemplate compared PAY_PER_REQUEST vs PROVISIONED and flipped a table that was already provisioned). With the alignment the seed cannot re-introduce that rejection at all: the flip is gated onoldBilling !== newBillingand a baseline resolved fromDescribeTableIS the mode AWS holds. The seeded branch also REMOVES a spurious call — aPROVISIONEDtemplate on an already-PROVISIONED table whose record carried no mode used to read as a real flip. Both halves are live-covered by newdynamodb-globaltablefixture steps: 13g asserts the kept-PROVISIONED retention (a declared on-demand ceiling raised on the suppressed-flip deploy must record the PREVIOUS value, with the live table carrying no ceiling at all), and a newBillingSeedTabledrives 13j (state record patched to removeBillingMode, corrected template must APPLY the flip) and 13k (kept-PAY_PER_REQUEST must retain the previous replica read capacity). The run also MEASURED whatBillingModeSummaryreports for an explicitly-PROVISIONED create, which answers issue #1733's open question (a): AWS reports none even when the mode was declared explicitly. So the no-summary population is every provisioned table rather than only an imported one, and the PROVISIONED inference is what carries the common case — logged rather than asserted, since the code is correct under either answer. - The CFn schema fixtures now capture each definition's
requiredlist, so a required-ness-dependent parity verdict can be fenced (issue #1800) —scripts/refresh-cfn-schemas.mjs(+ its.d.mts), all 134tests/fixtures/cfn-schemas/*.json,tests/unit/provisioning/ecs-deployment-configuration-subfield.test.ts,tests/unit/scripts/gen-nested-key-coverage.test.ts,.claude/rules/code-layout.md,docs/provider-development.md. The fixtures capturedproperties/readOnlyProperties/createOnlyProperties/primaryIdentifier/nestedProperties/nestedPropertyPaths/definitionShapes— but notrequired, which the live registry schema does carry. Why that mattered: the #1225 classification ofAWS::ECS::Service.DeploymentConfigurationconcluded that cdkd's verbatim pass-through is CloudFormation parity at every depth, and the depth-2 arm of that verdict rests ENTIRELY on required-ness — a keptDeploymentCircuitBreakermissingRollbackhas itsrollbackREPLACED true -> false byUpdateService(measured live), which would be a real silent drop except that the shape is unreachable from a valid template because CFn refuses it (Model validation failed (... required key [Rollback] not found)). So the day AWS relaxes that list the depth-2 replace becomes reachable and the parity verdict silently becomes wrong, with no test failing. The PR closing #1225 item 1 tried to fence exactly this and could not: with norequiredin the fixture the best available assertion was that the member paths are still MODELLED, which stays true under a relaxed list — so that test stated its own limitation in-code and carried aguard the guardassertion designed to RED the day the fixture started carryingrequired. What shipped:extractDefinitionRequiredcaptures the per-definition list (top-level under the same reserved#topkeydefinitionShapesuses; a definition with no list, or an empty one, gets NO entry, since the absence IS the fact and emitting[]for the ~90% in that state would bloat every fixture for no signal — consumers read a missing key as "nothing required here" and the SECTION's presence as the capture marker), emitted with the same omit-when-empty rule as its two siblings so a type requiring nothing anywhere keeps its prior byte shape. All 134 fixtures were re-captured live (us-east-1, 2026-08-13). The guard fired as designed and the ECS test now asserts the ACTUAL lists —DeploymentCircuitBreaker[Enable, Rollback],DeploymentAlarms[AlarmNames, Enable, Rollback],DeploymentLifecycleHook[LifecycleStages] — with exacttoEqual, since a member DROPPED from a required list is precisely the relaxation being fenced andtoContaincannot see a removal. It also asserts the ABSENCE of a list onLinearConfiguration/CanaryConfiguration, which is what SCOPES the parity verdict rather than over-claiming it and keeps issue #1806 visible: those two are fully optional, so a kept-but-partial block there IS reachable from a template CFn accepts — strictly worse than #1802, which at least has a CFn-side refusal to point at. A non-vacuity guard pins that the section exists and is populated, so a capture regression fails there rather than silently downgrading the test to the shape it replaced. The re-capture also defused a fence exactly as that fence's own comment predicted: thedefinitionShapesloud-failure probe picked its stand-in DYNAMICALLY from fixtures predating the shape capture, and once every fixture had one it had nothing to select and failed with its own "no pre-shape-capture fixture left to probe with" message. Its comment prescribed adding a fixture-dir seam; one already existed (loadReport's fourth parameter, added by #1464 for the siblingnestedPropertyPathsfence), so the probe now builds its own partially captured tree and is immune to the next re-capture — the property the dynamic pick was reaching for and could not actually hold. Three unit tests cover the extractor (sorted lists,#top, the omit-when-empty branch, non-string members, a non-arrayrequired). One side effect of the re-capture was measured rather than assumed, because it is theproperty backfill flips CC routeclass in reverse: a fixture that has been stale since 2026-05-16 gains whatever AWS has added since, and a NEW top-level property with no provider wiring becomes asilentDropentry — which does not reject the deploy but AUTO-ROUTES the resource through Cloud Control (the #614 rule). Nine such entries appeared (AWS::CloudWatch::Alarm.EvaluationWindow,AWS::Cognito::UserPool.{IssuerConfiguration,KeyConfiguration},AWS::EC2::Route.OdbNetworkArn,AWS::EC2::VPC.VpcEncryptionControl,AWS::ElasticLoadBalancingV2::Listener.Tags,AWS::Neptune::DBCluster.{GlobalClusterIdentifier,NetworkType},AWS::WAFv2::WebACL.MonetizationConfig), andListener.Tagslooked like a live route flip for thealb/alb-advancedfixtures, both of which apply stack-levelcdk.Tags.of(this). It is not: every one of the nine is absent from itsaws-cdk-lib2.244.0 L1 props interface (checked per entry, not reasoned about), so they are CFn-schema-only properties ahead of CDK support and no CDK app can emit one — the same disposition the pre-existing hand-writtenEvaluationIntervalentry records. A hand-authored L1 usingaddPropertyOverridecan still reach them, and the CC auto-route is the correct answer there. The FULL suite — not the targeted runs, which were green — then surfaced three more consequences of the re-capture, all of them the coverage critics working as designed. (a) Those nine properties are unaccounted, so each gets an entry intests/fixtures/cfn-schemas/_todo-backfill.json, the ledger built for exactly this case, regenerated via the documentedCDKD_GENERATE_BACKFILL=true(umbrella campaign: #609). (b) That regeneration ALSO cleaned a pile of STALE entries —AWS::ApiGateway::Stageentirely, plusLaunchConfigurationName/NotificationConfiguration/AvailabilityZone/ElasticGpuSpecifications/ElasticInferenceAccelerators/EvaluationCriteria/EvaluationIntervaland others. Those are NOT schema removals (checked per property — every one is still in the re-captured fixture): they are properties the providers already account for viahandledProperties/unhandledByDesign, which the ledger kept listing because retiring an entry is the manual third step its own comment describes, and nobody took it. (c) TWO REAL latent bugs: AWS added an ARN read-only attribute toAWS::RDS::DBSubnetGroup(DBSubnetGroupArn) andAWS::SSM::Parameter(Arn), neither provider caches it, and since the resolver reads the cachedattributes[<CFnName>]rather than callinggetAttribute, an outputFn::GetAtton either HARD-FAILS the*Arnshape guard — the #1179 class. Both are recorded asKNOWN GAPentries inSDK_ATTR_ALLOW_LISTpointing at issue #1824 (the same dispositionAWS::Lambda::EventSourceMapping.EventSourceMappingArnhad under #1190), so the critic is green while the gaps stay VISIBLE and deleting each entry is what verifies the eventual fix; fixing them here would mean editingsrc/provisioning/providers/**, which activates theinteg-destroygate and drags a real-AWS integ run behind a no-behavior-change capture PR. Review then surfaced two more, both of which the re-capture itself caused. (d) TheAWS::SNS::Subscriptionentry inSDK_ATTR_ALLOW_LISTis now provably INERT and is RETIRED: its fixture predated the #1694primaryIdentifiercapture, soArnused to reach the allow-list, and once the re-capture gave the fixtureprimaryIdentifier: ['Arn']the classifier filters that attribute out BEFORE consulting the list — which is exactly the "auto-classify those as not-a-gap instead of requiring a hand-written entry per type" behaviorextractPrimaryIdentifierexists for. It was found by a fence added in this PR, not by reading:classifyTypetestscachedKeysbefore the allow-list, so a fixed or superseded entry silently goes inert and nothing flags it (there is nofindStaleAllowListEntrieshere the way there is ingen-nested-key-coverage.ts). The new test asserts every allow-listed attribute still classifiesallow-listed, which is what makes the "DELETE this entry when it is fixed" note on the two #1824 entries enforceable rather than aspirational — an in-code claim this PR made and had to be corrected on, since the first draft asserted the staleness detection already existed. (e) The summary saidgap: 0for two types whoseFn::GetAtthard-fails, because a NOT-A-BUG entry and a tracked real gap shared one bucket;AllowListEntrygained aknownGapflag and the summary reports those separately, so the headline stays honest while CI stays green. Two contract claims this PR wrote were also wrong and are corrected:definitionRequired's absence does NOT mark a fixture as un-captured (seven types legitimately require nothing anywhere and carry no section —generatedAtis the discriminator), and the capture sees only a definition's OWN top-levelrequiredarray, so required-ness expressed through aoneOfcombinator (AWS::S3::Bucket'sTargetObjectKeyFormat) or on an inline nested object (AWS::WAFv2::WebACL'sFieldToMatch.SingleHeader) is absent — for those a missing entry means UNKNOWN, not permissive, which is the exact false verdict the capture exists to prevent. Both bounds are now pinned by tests. Otherwise no behavior change: no runtime code reads the newdefinitionRequiredsection, and no CLI flag, dependency, or state-schema change. -
cdkd exportstops aborting on a dual-stack VPC, and an array-valued identifier property no longer reaches CloudFormation as-is (issues #1788 and #1787) —src/cli/commands/export.ts,.claude/rules/code-layout.md,tests/unit/cli/export-identifier-overlay.test.ts. Both halves were split out of #1771, which registered the splitters it named but deliberately left the shared overlay rule alone. #1788:AWS::EC2::VPCCidrBlockhas a COMPOSITEprimaryIdentifierand had noCOMPOSITE_ID_SPLITTERSentry, andcdkd exportis all-or-nothing, so its mere presence aborted the WHOLE command withadd an entry to COMPOSITE_ID_SPLITTERS— while any VPC carrying a secondary IPv4 CIDR or an Amazon-provided IPv6 CIDR declares one, including every CDKVpcconfigured withipProtocol: IpProtocol.DUAL_STACK. Every schema fact behind the entry is a liveDescribeTypemeasurement (us-east-1, 2026-08-13), not the issue's table: identifier[Id, VpcId], withIdread-only, so the overlay narrows toVpcId— the same narrowing, for the same reason, as theAWS::EC2::VPCGatewayAttachment/AWS::EC2::Routesiblings. No SDK provider registers the type, so it is always Cloud-Control-routed and the physicalId is the primaryIdentifier joined in schema order (<Id>|<VpcId>). Both segments start withvpc-, but the association id carries the longer, distinctvpc-cidr-assoc-prefix, so the two ARE discriminable and the splitter shape-binds BOTH (the way theAWS::EC2::EIPentry bindseipalloc-; validating one side only is not a discriminator). It also accepts the BARE<Id>form, recoveringVpcIdfrom recorded properties, because CloudFormation reports this type'sPhysicalResourceIdas the association id alone — the shape a--migrate-from-cloudformationstack carries. #1787:overlayResourceIdentifierOnPropertiesonly overwrote a field the template carried as a literal STRING. That is right for the two cases it was designed for — an absent key, and aRef/Fn::GetAttintrinsic that must be preserved (#319) — and wrong for a third, reachable case: a field the template carries as a non-string LITERAL. The measured instance isAWS::S3Tables::Namespace, whose registry type is{"type": "string"}and whose CDK L1 emits a string, butaddPropertyOverride('Namespace', ['analytics'])synthesizes"Namespace": ["analytics"]— which cdkd deploys, becauses3-tables-provider.tsaccepts both wire shapes. The array was not a literal string, so the overlay left it alone and it reachedCreateChangeSet, where CFn answered with an opaque rejection. The rule is now keyed on a four-way classification (classifyOverlayCurrentValue): absent and intrinsic are unchanged, any LITERAL — string, number, boolean, or an array of those — is overwritten with the scalar identifier, and a list carrying an intrinsic / a nested list / an empty list is REFUSED with a message naming the resource, the property and the scalar to declare instead. Overwriting is not a guess: the overlay value comes from the cdkd-recorded physicalId, which is the authority on what the resource IS, while the template side is only what CDK happened to synthesize — the same reasoning the pre-existing prefixed-name literal-mismatch case already rests on. Refusing is the one honest answer left, and the message distinguishes WHY per shape rather than asserting a single reason for all three: preserving any of them reproduces the opaque rejection this change removes, but only a list actually carrying an object element has an intrinsic to discard — an empty list has no value to reconcile against the identifier, and a nested one simply is not representable as the declared scalar. 12 unit tests drive BOTH overlay call sites (filterTemplateForImportandapplyImportOverlayForPhase2— a refusal on only one of the two would let the same template through on the other path) and assert phase-1 / phase-2 agreement, since a divergence there makes CFn see a property change between the IMPORT'd state and the UPDATE template and silently REPLACE. Binding-proved: restoring the pre-#1787 preserve-the-array arm reds 5 of the 12. No CLI flag, dependency, or state-schema change. - The state-bucket region probe reaches the caller's partition (issue #1763) —
src/utils/aws-region-resolver.ts,.claude/rules/code-layout.md,tests/unit/utils/aws-region-resolver.test.ts.resolveBucketRegionbuilt itsGetBucketLocationprobe client with a HARDCODEDregion: 'us-east-1'and swallowed every failure into a commercial default, so outside the commercial partition the probe could not reach the bucket at all and every consumer of the shared state-bucket resolver — the S3 state backend, the lock manager, the exports index store, the custom-resource response path, andupload-cfn-template'sTemplateURL(which is why the #1758 URL-suffix fix was inert rather than wrong there) — proceeded against the wrong region. The probe endpoint is now taken from, in order: a newopts.region(the caller's own region),opts.fallbackRegion, the AWS SDK's own region chain, and only thenus-east-1. The cross-region question was MEASURED, not assumed (2026-08-13, real eu-west-1 bucket): SDK v3GetBucketLocationresolves a bucket in ANY region from us-east-1 / us-west-2 / ap-northeast-1 / eu-west-1 clients alike, so the probe never needs to know the answer to ask the question and moving off the global endpoint costs nothing — it only has to reach the right PARTITION. No call-site threading was needed, and the issue's premise that "no caller passesfallbackRegion" was wrong:rebuildClientForBucketRegionalready passes it, sourced from the client's ownconfig.region(), which covers the whole state-bucket family; anddeploy/destroy/export/import/orphaneach assign--regionintoprocess.env.AWS_REGIONbefore any work, so the SDK chain coversuploadCfnTemplate's five call sites (two of which live inexport.ts, held by a parallel lane). A caller with no region configured anywhere still probesus-east-1, so commercial behavior is preserved rather than merely equivalent. Second half of the fix: only a SUCCESSFUL lookup is now cached, mirroringwrite-only-properties.tsand #1746'sgetAccountInfo— the failure answer is a GUESS, and caching it pinned every later caller in the process to one transient error's wrong region with no way to heal, while concurrent callers still collapse onto a single probe. Unit tests pair every non-commercial assertion with a commercial counter-case, and do NOT mock the resolver (the #1758 tests did, which is exactly why they could not see this). - Two drift DECLARATION seams a path could not express (issues #1783 / #1784) —
src/analyzer/drift-normalize.ts,src/types/resource.ts,src/cli/commands/drift.ts,docs/provider-development.md,tests/unit/analyzer/drift-normalize.test.ts,tests/unit/cli/drift.test.ts. Both were surfaced by #1767 as residuals it could not ship, and both are MECHANISM only — no provider declares either yet;AWS::DynamoDB::Table(#1767) andAWS::DynamoDB::GlobalTable(#1742) are the intended first consumers and are held by other lanes. (1) A LEAF-ONLY form for the provider path lists. Every entry was a SUBTREE declaration, and the unordered walk DESCENDS INTO ARRAY ELEMENTS giving each its parent's path — so declaring an unordered OBJECT list whose elements contain an order-SIGNIFICANT array of their own was impossible:'GlobalSecondaryIndexes'sorts the index list AND every per-indexKeySchema, where HASH must precede RANGE, so it would HIDE a real key change rather than remove a phantom one. That is why #1767 shipped the reverse-map half and left the ordering half undeclared, carrying phantom drift for theproperties-baseline population a reverse-replacement rollback leaves behind. AppendingLEAF_ONLY_PATH_SUFFIX([]) now claims the path ALONE:'GlobalSecondaryIndexes[]'sorts the list and stops. The marker is honored by BOTHgetDriftUnknownPathsandgetDriftUnorderedPathsrather than only by the list that needed it — they share ONE matcher precisely so their spellings cannot diverge — and a bare'[]'matches NOTHING, since it would otherwise name the root bag. (2)ResourceProvider.canonicalizeDriftProperties(resourceType, properties), applied bydrift.tsto BOTH comparison sides as the last normalization pass. This is the seam for a difference no PATH can name — a member of an ARRAY ELEMENT:calculateResourceDriftcompares arrays wholesale viadeepEqualand never descends, soisIgnoredPathis never asked about a path crossing one and the only expressible suppression is the WHOLE array. Using it for #1767's residual would have meant never detecting an out-of-band index add / remove / capacity change again, permanently, to clear a one-time report — a trade that was refused. Stripping the AWS-managed member from BOTH bags converges an already-writtenobservedPropertiesrecord with a post-fix readback instead, with no ignore-path and no lost detection. It takes NOsideargument on purpose (one-sided normalization is the mistakedrift-normalize.ts's header already records — it manufactures drift on theproperties-fallback baseline, whose baseline is the user's raw template), must be pure / synchronous / non-mutating / identity-returning when inapplicable, and is applied to the COMPARISON copies only, so theawsbag--acceptwrites to state and--revertdiffs against is untouched. Both seams are pinned by command-path tests that driverunDriftForStack(not the comparator directly) plus negative controls proving the clean result comes from the declaration being threaded through: the SUBTREE spelling is shown HIDING theKeySchemareorder the leaf-only form keeps visible, and a real index deletion is shown surviving the canonicalizer. -
cdkd gc's URL-suffix list is now DERIVED from the partition table, so a new partition arm cannot silently outrun it (issue #1785) —src/cli/commands/gc.ts,tests/unit/cli/gc.test.ts.AWS_URL_SUFFIXESwas a hand-written literal maintained as a SUPERSET ofderivePartitionAndUrlSuffix's arms by convention only, and the test meant to fence it iterated a hand-written region list — so a new arm inPARTITION_TABLEredded nothing and gc would go on treating that partition's assets as UNREFERENCED, the irreversible direction. The list is now built fromPARTITION_TABLE's suffixes plus a declaredGC_EXTRA_URL_SUFFIXESseam (empty today), so an addition propagates in the same commit. The seam is kept rather than dropped because coupling makes gc FOLLOW the table, and gc must be able to LEAD it: a partition cdkd has state for but no derive-table row yet, and — the direction coupling introduces — a suffix EDITED or removed from the table, which would otherwise silently stop gc matching state already written with the old spelling. The unit test now probes one synthesized region per EXPORTED table row (so a new arm arrives on its own, with a count floor so an empty table cannot pass vacuously) and separately pins a hand-written FLOOR of the seven suffixes cdkd has already recorded, which is what makes a removal red. Verified by severing the coupling on a scratch copy: both new tests fail namingeusc-probe-1 (amazonaws.eu). Not in scope, and split out rather than assumed: the ECR host-form widening this issue also asked for had already shipped in the #1781 PR, and sharing those host forms withsrc/utils/ecr-uri.ts(which matches only the plaindkr.ecrform, so the two already disagree) needs that module's opposite strict-vs-loose acceptance rule preserved — tracked as issue #1793, which the in-code note now points at. - The four
AWS::S3::Buckethost attributes derive their URL suffix, and the resolver and provider halves can no longer disagree (issue #1745) — newsrc/utils/s3-endpoints.ts,src/deployment/intrinsic-function-resolver.ts,src/provisioning/providers/s3-bucket-provider.ts,tests/unit/utils/s3-endpoints.test.ts,tests/unit/deployment/s3-bucket-attribute-url-suffix.test.ts.DomainName/RegionalDomainName/DualStackDomainName/WebsiteURLwere templated in TWO places — the provider records them into state, the resolver answers a cross-resourceFn::GetAttstate has no cached value for — and both hardcoded the commercialamazonaws.com, so outside that partition every one emitted a host that does not resolve (structurally valid, so nothing downstream could catch it). Both sides now call one shared module, which is why this had to move together: fixing one half alone makes theFn::GetAttanswer disagree with whatreadCurrentStatereports, the phantom-drift shape.claude/rules/providers.mdwarns about. The provider's ownArnmoved toarn:${partition}:s3:::in the same object (the #1730 class, one line over).WebsiteURLwas NOT a mechanical suffix swap, which the issue asked to verify rather than splice: AWS serves the legacys3-website-<region>spelling for exactly NINE regions ands3-website.<region>for every other, so the separator is per REGION and cannot be derived from the partition —us-gov-west-1takes the hyphen while itsus-gov-east-1sibling takes the dot. The set was read out ofaws-cdk-lib'sregion-infoS3_STATIC_WEBSITE_ENDPOINTfacts (the AWS-authored table CDK itself resolves a website endpoint from) rather than from prose, and an unknown region falls back to the dot form, matching CDK. That corrects the answer for non-legacy COMMERCIAL regions too — aneu-central-1bucket'sWebsiteURLused to name a host AWS does not serve. The resolver additionally gained aDualStackDomainNamecase: it had none, so the unknown-attribute fallback served the bucket NAME where a hostname was requested, disagreeing with the provider's own record. Fenced against the same AWS-authored table (every known region's endpoint and suffix must equal what the helper builds, with a count floor), plus commercial counter-cases asserting byte-identical output — which is what makes the change safe to ship without a non-commercial account. Residuals, filed not assumed.arn:aws:is still hardcoded in the CC-provider's S3Arnenrichment (so the two ROUTES disagree for the same bucket) and in three self-authored IAM policies — issue #1794. AndDomainNameis the one attribute a suffix swap cannot fix: it is S3's partition-GLOBAL host, and that endpoint exists only in the commercial partition (s3.amazonaws.com.cnis NXDOMAIN; GovCloud's…s3.amazonaws.comresolves to COMMERCIAL S3). cdkd emits the spellingaws-cdk-lib's ownBucket.fromBucketAttributesuses rather than inventing the regional form, because a guess would diverge from whatever CloudFormation returns there — recorded in-code, pinned by a test that says so, and tracked as issue #1809. Note also that these values live instate.attributesand refresh only on the bucket's next create/update, so an existing non-legacy-region stack keeps its oldWebsiteURLuntil then. - ECS
DeploymentConfigurationsub-field + whole-block removal measured: top-level parity, depth-2 a permissive divergence — no behavior change (issue #1225 item 1, closing the last actionable row) —src/provisioning/providers/ecs-provider.ts(comment only),tests/unit/provisioning/ecs-deployment-configuration-subfield.test.ts,docs/provider-development.md§2a. Why it was open: this was the one row #1227's first pass classified as a REAL silent drop —UpdateServicemerges at the sub-field level, so a template keepingDeploymentConfigurationwhile droppingMinimumHealthyPercentretains the live value — and the issue prescribed implementing BOTH the kept-partial normalization and the #1160 whole-block reset together, blocked on deployment-type-dependent defaults (REPLICA 200/100, DAEMON min 0) and the circuit-breaker / alarms clear shapes. The measurement inverted that direction (live A/B, us-east-1, 2026-08-13, SDK and CloudFormation against the same service, every probe torn down): the merge is real — a kept block carrying onlyMaximumPercent: 150leftMinimumHealthyPercentat 50 and the circuit breaker enabled — but CloudFormation renders the same partial block and reached the IDENTICAL end state on a realUPDATE_IN_PROGRESS->UPDATE_COMPLETE, so the retained value is CFn's behavior and normalizing it would DIVERGE. The whole-block removal answered the same way: CFn issued a genuine resource UPDATE and did not reset the configuration, so cdkd'sundefinedis correct and the #1160 reset this field was holding open must NOT be added. One level DOWN the answer flips, and that row is NOT parity — the review of this PR corrected an earlier draft that said it was. A keptDeploymentCircuitBreakermissingRollbackhas the nested struct REPLACED rather than merged, and the SDK ACCEPTS it (a liverollbackwent true -> false), while CloudFormation refuses the same template up front (Model validation failed (#/DeploymentConfiguration/DeploymentCircuitBreaker: required key [Rollback] not found),UPDATE_ROLLBACK_COMPLETEwith the live config intact) because the registry schema marks theDeploymentCircuitBreakerdefinition required [Enable, Rollback] andDeploymentAlarms(theAlarmsproperty) required [AlarmNames, Rollback, Enable]. cdkd enforces nested required-ness NOWHERE —property-coverage.tsis top-level-only andmutually-exclusive-properties.tshandles combinations, neither reads a definition'srequiredlist — so cdkd deploys a template CFn rejects and silently flips the live setting: an accepted divergence in the PERMISSIVE direction, filed as #1802. It is deliberately not papered over by re-fillingRollbackfrom the previous side, which would invent a value the template never declared and would fix one property rather than the class. The ASGInstanceMaintenancePolicyanalogy does NOT apply (there AWS itself rejected the partial, so cdkd failed loudly too). What shipped: no behavior change — the deferral comment is replaced by the measured three-shape classification, and 9 unit tests pin the pass-through against the shape each REGRESSION would emit (a synthesized sub-field, a reset payload, a re-filledrollback, a fill-only-missing on the CREATE path at both levels, and theAlarmstwin). The accompanying schema check deliberately does NOT claim to fence a relaxedrequiredlist — the fixtures capture norequiredat all, so it asserts only that the members the verdict names are still modelled, states that limit in-code, and carries a guard that reds the day the fixture does start carryingrequired(#1800). Both pins are mutation-probed against the real provider: adding aclearOnUpdateRemovalreset reds the removal test, merging the previous block into the desired one reds two more. Remaining #1225 scope is now only item 2, ASGCapacityReservationSpecification, which needs a billed Capacity Reservation to probe. No CLI flag, dependency, or state-schema change. - Six delete-result discarders outside the deploy engine: the ASG delegation now propagates, and the four REPLACE paths stop swallowing a skipped delete (issue #1778) —
src/provisioning/cloud-control-provider.ts,src/provisioning/providers/{acm-certificate,iam-managed-policy,iam-role,sns-subscription}-provider.ts,docs/provider-development.md,.claude/rules/providers.md,tests/unit/provisioning/{replace-path-delete-skip-outcome,cloud-control-delete-result-propagation}.test.ts. These are LATENT-today hardenings — none of the five providers involved currently has a skip arm, so no shipped behavior changes until a skip arm reaches one of these five providers; the point is that the arms land into call sites that already handle the outcome, instead of into ones that drop it. Two classes. Delegation:CloudControlProvider.deletehands a protectedAWS::AutoScaling::AutoScalingGrouptoASGProvider.deleteunder--remove-protectionand used toawait+ barereturn, so a skip inside the delegate would have reached the destroy runner as a plain successful delete — the #1777 nested-stack hole one layer down. The contract chosen is PROPAGATE (matchingNestedStackProvider.delete'sskippedCount/interruptedforwarding) rather than ASSERT-it-cannot-skip, because an assertion needs re-verifying every time the delegate grows an arm and fails loudly on a case the delegate considers merely unaddressable; the delegate is typed asResourceProviderso the forwarding survivesASGProviderwidening its own return type. The provider'sdeletereturn type widened toPromise<void | ResourceDeleteResult>accordingly. The sibling site —cleanupFailedCreateRemnant's SELF-delete of a failed-create remnant — was confirmed uninteresting on the ACCOUNTING axis (a CREATE path, so nothing it returns reaches the destroy counters) and NOT on the MESSAGE axis: theRemoved failed-create remnantdebug line asserted the opposite of what a skip means, and a skip is exactly the outcome under which the name is still taken, so it now takes the same "a retry may fail with AlreadyExists" warning a failed cleanup takes. REPLACE paths: the fourupdate()implementations that paircreate()+delete()discarded the delete result, and because a skip does not throw it slipped past the verycatchwhose warning names the orphan risk. The ORDERING was re-verified per provider rather than trusted, and decides the remedy — create-then-delete (acm-certificate/iam-managed-policy/iam-role) cannot abort (the new resource exists;ResourceUpdateResulthas no skip channel), so each WARNS with the skip'sreasonin the same orphan wording its failure arm already used; delete-then-create (sns-subscription) ABORTS with aProvisioningErrorbefore creating the replacement, since continuing would leave two subscriptions on the topic delivering every message twice and that duplicate is exactly what the CREATE would add. The premise is stated as "the old resource was not destroyed" rather than "no AWS call was issued" —ResourceDeleteResult's contract explicitly warns against the second reading (NestedStackProviderreportsskippedafter a recursion that may already have deleted the child's other resources), and the abort holds under the weaker premise anyway. The abort was checked against everyupdate()caller (deploy-engine,drift --revert, the rollback executor's revert arms) — none is better served by a duplicate subscription — and is deliberately NOT symmetric with a THROWN delete, which still warns-and-continues, because a throw may mean the unsubscribe partially landed. Two mechanical consequences of the rollback caller, both covered by tests: the SNS check (only that one — the three create-then-delete providers keep their warn-only branch inside the existingtry) lives OUTSIDE thetry, sodelete()'s ownProvisioningErrorfailure path keeps its warn-and-continue semantics; and the thrown message interpolates NOTHING but the template logical id, because the rollback arms wrapupdate()inwithRetryandretryable-errors.tsclassifies by SUBSTRING — a provider-suppliedreasonor a state-bornephysicalIdcarryingdoes not exist/Rate exceeded/DependencyViolationwould have burned the whole backoff schedule before a certain failure, and the physicalId is the worse offender (cdkd import --resource <id>=<anything>writes it verbatim, and the only skip family reaching this path is literally "malformed physicalId in state"). Both are still reported — on the warn line, and the physicalId on theProvisioningError's structured field. Keeping values out of the message narrows that surface but cannot close it, since the match is a SUBSTRING rather than an equality — an ordinary composite logical id likeMyDependencyViolationSubcarries a retryable pattern (measured), and a message naming nothing is not diagnosable. Sosrc/deployment/retryable-errors.tsgained amarkNonRetryable/isMarkedNonRetryablepair (a non-enumerableSymbol.formarker, walked down the.causechain likeisThrottlingError) thatisRetryableTransientErrorconsults BEFORE any name or message heuristic, and the abort is marked: a cdkd-authored refusal is now terminal by DECLARATION. The fence is at the RETRY LOOP, not only in that one classifier —withRetryrethrows a marked error ABOVE itsopts.isRetryable ? ... : ...branch (the custom-classifier branch bypassed the marker entirely, and all four call sites that pass one —isRecreateRetryableError/isNameCooldownError, wired by the deploy engine's--replacedelete-first fallback and the rollback executor's reverse-replacement — are MESSAGE-only, so they could not see it even in principle), anddestroy-runner.ts's delete-retry loop gates its ownToo Many Requestsmessage test the same way (the marker gates BOTH arms rather than replacing either, so a genuine throttle still retries). Two one-line changes fence every caller in the tree with no classifier signature widened.markNonRetryableis narrowed toE extends Error(the reader is a prototype-chain lookup, so marking a shared prototype would mark every instance) and returns a non-extensible error UNMARKED rather than throwing — callers use it inline asthrow markNonRetryable(...), where aTypeErrorwould REPLACE the refusal it decorates. The remediation names the STATE record as well as AWS, since neither skip family shipping today (the state-borne composite-id arms,NestedStackProvider's propagation) is repaired by deleting the AWS resource alone, and it points at "the warning naming<logicalId>" rather than "the preceding warning", which is false at--concurrency > 1.logPendingConfirmationSkip's two CFn-parity delete-SUCCESS arms gained an in-code note NOT to convert them to skips — doing so would abort every deploy of aPendingConfirmation-adopted subscription with no flag to force it — plus a positive test pinning that UPDATE consequence. All six sites stay LATENT even after #1770 lands (that issue's eight arms are inlambda-layer/lambda-permission/custom-resource/iam-policy/iam-user-group, none of whichCloudControlProviderdelegates to or any of the four REPLACEupdate()s calls;AWS::IAM::Policyis a different type and file fromAWS::IAM::ManagedPolicy), so the reason to fix them is that the mechanism lands BEFORE any skip arm reaches these five providers. 30 unit tests: per site, a forced{ outcome: 'skipped' }asserting the new behavior, plus an INVERTED CONTROL where the delete succeeds and the replacement completes normally, plus a rollback-executor caller test driving the realreplayRollbackrevert arm and proving the abort surfaces after ONE attempt (benign and hostile reasons) and hostile-reason/ hostile-physicalIdregression rows proving the thrown message is not classified retryable; every site and every guard mutation-probed RED. - The eight non-composite-id warn-and-SKIP delete arms stop being counted as
deleted(issue #1770, residual of #1752) —src/provisioning/providers/{lambda-layer,lambda-permission,custom-resource,iam-policy,iam-user-group}-provider.ts,docs/provider-development.md,.claude/rules/providers.md,tests/unit/provisioning/provider-delete-skip-outcome.test.ts. #1752 gaveResourceProvider.deleteitsResourceDeleteResultreturn and converted the five malformed-composite-physicalId arms, but its own scope statement was wider — every warn-and-continue arm that issues NO AWS call has the same defect, and eight of them outside that family still returned barevoid, socdkd destroyprinted✓ <id> (<type>) deleted, counted them towardN deleted, DROPPED the state record and exited 0 over resources that may still be alive. All eight now return{ outcome: 'skipped', reason }, so the runner prints⚠ <id> (<type>) skipped (<reason>), counts a separateskipped, KEEPS the record and exits 2 — no runner change was needed. What survives each skip is named per site rather than genericized: a Lambda layer version stays published (both malformed-LayerVersionArnarms — a too-short ARN and an unparsable trailing version, kept as two reasons because they point at different halves of the id); aRemovePermissionstatement stays on the function's resource policy, i.e. an invoke grant outliving the stack; a Custom Resource's handler never sees aDeleterequest, so whatever it manages in a third-party API / another account is untouched (both the no-properties and the no-ServiceTokenarms); an inline IAM policy stays ATTACHED to its roles / groups / users, i.e. live permissions outliving the stack; and anAWS::IAM::UserToGroupAddition's memberships survive, so the users keep every permission the group grants. Eachreasonis an exported named constant beside its provider (deliberately NOT one shared literal likeCOMPOSITE_ID_SKIP_REASON, because the status line has to say WHICH half of the record is broken), pinned by a test asserting all eight are distinct, ≤ 64 chars, namestate/physicalId, and say what did NOT happen. The twoAWS::IAM::UserToGroupAdditionarms were the issue's explicit judgment call and are converted with the reasoning recorded in-code: they logged at DEBUG, which reads as "routine, nothing to do", butGroupNameandUsersare BOTH required by the CloudFormation schema, so a record missing either is CORRUPT rather than empty, and the create path issued realAddUserToGroupcalls — the level is now WARN to match, since a skip preserves state and exits non-zero and a normal-verbosity run has to say why. Their genuinely-routine neighbour is pinned by its own test: an EMPTYUsers: []is a VALID membership list, falls through the!usersguard (an array is truthy), does nothing and correctly reportsdeleted— collapsing the two would turn a legitimate no-op destroy into a non-zero exit. The*NotFoundidempotent arms in the same files are untouched, as isCustomResourceProvider's backing-Lambda-is-gone pre-check (issue #804) — those mean the resource IS gone, and the Custom Resource inverted control deliberately routes THROUGH that arm, so it doubles as a fence that it still reportsdeleted. Every arm carries a skip test (outcome + reason + no AWS call issued) and an inverted control with a well-formed input proving the normal delete path still runs; all 8 were mutation-probed by reverting the arm (all RED), and the 5 guards were separately probed as firing UNCONDITIONALLY to prove the inverted controls have teeth (all RED) — two of those probe anchors turned out to occur 2x / 3x in their file and were re-anchored multiline rather than patched blind. The 3-axis review then found two arms that should not have been skipping at all, and a skip is expensive precisely because it is not inert — it preserves the record, warns, and exits 2 on EVERY re-run, so the destroy can never go green.AWS::Lambda::Permissionread onlyproperties['FunctionName'], although the physicalId's documented<functionArn>|<statementId>shape — the very shape the code below it already splits for the statementId — carries the function ARN, andRemovePermissionaccepts a full ARN asFunctionName;AWS::IAM::Policyderived the name only from the physicalId, althoughPolicyNameis inhandledPropertiesandcreate()uses it VERBATIM as the real AWS name. Both now exhaust the second source first, ordered by what was DEPLOYED rather than by convenience — the physicalId wins over the property, because a template edit renaming the policy without a replacement having landed would otherwise send aDeleteRolePolicyfor a name AWS never had — and the Lambda fallback is gated onarn:+:function:so a non-Lambda ARN, or a statementId that merely contains|, is never sent as a function name.POLICY_NAME_SKIP_REASONchanged tono policy name in stateaccordingly, since the arm is now only reached once both sources are exhausted. Three wording defects from the same review: every warning promised "repair state.json and re-run", which is true only on DESTROY — the deploy / rollback callers DROP the record (#1762), so each message now carries the same caveatcompositeIdFormatMessagedoes; "LEFT IN PLACE" was asserted unconditionally and is FALSE for four arms when the parent is in the same stack (deleteGroup->removeAllUsersFromGroupanddeleteUser->removeUserFromAllGroupsremove exactly those memberships, a deleted function drops its whole resource policy, a deleted role drops its inline policies), so AWS ended clean while cdkd claimed an orphan and exited 2 forever — those four now qualify the claim and namecdkd state orphan <stack>, while the layer / Custom Resource arms deliberately do NOT, a false reassurance being worse than the warning it softens; and theUserToGroupAdditioncomment cited the wrong producer forUsers: []—createUserToGroupAdditionTHROWS on an empty list, so the reachable producer isupdateUserToGroupAddition, which removes every user and records[]. Theoutcome: 'skipped'member JSDoc insrc/types/resource.tsalso still read "no AWS call was issued", contradicting the block comment 20 lines above it that warns against exactly that reading (NestedStackProviderreportsskippedafter deleting the child's other resources). Review round tests: the fallback pair per arm (the fallback really deletes; the precedence is deployed-first), a negative control that a non-Lambda ARN is still refused, non-stringPolicyNameshapes (a number / an intrinsic / an array — the case an emptiness test cannot see, since''is falsy and the||chain rejects it anyway while a truthy non-string would reach the SDK verbatim), the#1762caveat on all eight arms, and the in-stack-parent qualifier on the four that carry it plus a control that the other four do not.warnContainswas also made arm-SPECIFIC, since seven arms shared the literalLEFT IN PLACEand a copy-pasted wrong remediation would have passed. Two review-round probes came back GREEN and were DIAGNOSED rather than accepted: an!== ''check in the IAM guard was genuinely redundant (the||chain already rejects an empty string) and was deleted, with the load-bearingtypeofhalf probed instead once the non-string test cases existed; and the in-stack-parent qualifier was unasserted, which is why the control tests above were added. A delta re-review then found the round-2 fallbacks could report a silentdeletedover a LIVE resource - worse than the skip they replaced - in three ways, all fixed. (1) Thetypeofhardening had been applied to the IAM guard but NOT to its Lambda sibling, so a truthy NON-stringFunctionName({ Ref: 'MyFn' },['my-fn'], a number) beat a perfectly good ARN: the SDK URI-encodes it,[object Object]comes backResourceNotFoundException, and the IDEMPOTENT arm then reports the statement DELETED - while an array coerces to a bare name nothing validated and the call can SUCCEED against the wrong function. An emptiness test cannot see either shape, since''is falsy and the||chain rejects it anyway. (2) The ARN fallback was adopted without checking its REGION: this provider holds ONE client, at the stack's region, so a cross-region ARN sentRemovePermissionto the wrong region, came backResourceNotFoundException, and was likewise reported DELETED while the real statement stayed live - cdkd has no client that could reach it, so the honest answer is the skip. (3) Thearn:requirement REFUSED a genuine second source: the CFn primary identifier is[FunctionName, Id]andFunctionNameis often a BARE name (src/cli/commands/export.tsdocuments exactly this), so the gate declined a real physicalId shape and put the arm back in the class #1770 exists to remove. Accepting it is safe because AWS'sStatementIdpattern is[a-zA-Z0-9-_.]+, which FORBIDS|- so a|anywhere in the physicalId can only ever be the composite separator, which also means the gate's original in-code justification ("a statementId that merely happens to contain a pipe") described an impossible input. A fourth arm was added on the way: a composite physicalId with an EMPTY trailing segment (<arn>|) used to reachRemovePermissionwith an empty HTTP label and HARD-ERROR where it used to skip, so it is now its own skip with its own reason (the function may be perfectly well named - pointing the user atFunctionNamewould be the wrong half of the record). The IAMPolicyNamefallback separately converted an honestskippedinto a silentdeleted: with the name now resolvable the guard passed, but an inline policy exists only as an ATTACHMENT, and a record naming noRoles/Groups/Usersand no legacy role segment reaches a body where every branch is skipped - ZERO AWS calls,return undefined, i.e. DELETED. The zero-call hole pre-dated the fallback (physicalId: 'MyPolicy'with empty properties already reached it), but the fallback ROUTED formerly-skipped records into it, so it is closed here rather than inherited: a newPOLICY_NO_TARGET_SKIP_REASONarm reports it. The guard deliberately uses the SAME truthiness spelling the legacy branch and the removal loops use rather than=== undefined- a null-valuedRoles, which a hand-edited or pre-v7 state file carries, would otherwise fall through into the very hole being closed - while a PRESENT-but-emptyRoles: []stays an honestdeleted(nothing is attached, so there is nothing to remove; the same judgment asUsers: []). Also fixed in the same round: the four in-stack-parent qualifier tests asserted only generic substrings (UNLESS,part of this stack), so a qualifier copy-pasted from the WRONG arm still passed - the exact weakness removed fromwarnContainsone round earlier; each now pins an arm-specific phrase PLUS its own message head. Two more probes came back GREEN and were diagnosed rather than accepted: the redundant!== ''check (deleted; the load-bearingtypeofhalf was probed instead once non-string cases existed) and the=== undefinedvs!rolesdistinction (which theRoles: nulltest now discriminates). Still out of scope and unchanged: the deploy-engine / rollback-executor callers discard the return value entirely (issue #1762). - The #1758 strict ECR host check stops being case-sensitive, so an upper-cased region can no longer bypass it (issue #1786) —
src/utils/ecr-uri.ts,tests/unit/utils/ecr-uri.test.ts.parseEcrRegistryHostcompares a registry host's captured suffix againstderivePartitionAndUrlSuffix(region).urlSuffixso a look-alike host cannot attract adocker loginby CASE, but both the region capture and the suffix comparison were case-SENSITIVE while DNS is not. (Stated precisely, and the precision took three drafts: the first overclaimed, the second described a partition table that no longer exists. #1764 shipped in PR #1790 while this branch was open, so on today'smainthe ISO-E / ISO-F suffixes ARE in the table and...us-isof-south-1.csp.hci.ic.gov/...resolves correctly. What this change guarantees is therefore exactly one thing -- the check is no longer bypassable by CASE -- and it remains true that any region whose partition the table does not yet carry is misclassified in BOTH directions, the look-alike accepted and the genuine host rejected. That is a partition-table gap, not a case gap.) Reproduced by execution before the fix, matching the issue's two probes exactly:123456789012.dkr.ecr.us-iso-east-1.amazonaws.com/r:twas REJECTED (correct — a commercial suffix on an ISO region is a look-alike) while123456789012.dkr.ecr.US-ISO-EAST-1.amazonaws.com/r:twas ACCEPTED, because upper-casing the region fails everystartsWithprefix test in the partition helper, falls through to the commercial partition, and that partition'samazonaws.comis exactly what the host carries. The measurement also found the INVERSE half the issue does not name, in the same run and the same fail-open/fail-closed pair: an upper-cased region or suffix on a GENUINE host stopped matching its own partition, soUS-ISO-EAST-1.c2s.ic.gov,us-east-1.AMAZONAWS.COMandCN-NORTH-1.amazonaws.com.cnwere all REJECTED — a real ECR registry silently classified as a user-managed image with nodocker login, i.e. the #1758 regression one layer up. Both halves now normalize at ONE boundary, a new module-privatematchEcrRegistryHostthat lower-cases the captured region, the captured suffix AND the derived expected suffix and returns all of them, so the two exported entry points share the comparison rather than each spelling it:looksLikeEcrHostWithForeignSuffixis documented as "exactly the case the parse rejects" and carried the identical defect, consistently — under an upper-cased region the parse accepted a look-alike and the predicate returnedfalse, soecs-task-resolver.tsdid not even log the #1764 foreign-suffix diagnostic. The normalization lives here rather than inderivePartitionAndUrlSuffix(src/utils/aws-partition.ts, deliberately untouched) because THIS is where an untrusted DNS-shaped string enters cdkd, and keeping it in ONE function is what stops the two entry points drifting apart. That is deliberately NOT the stronger claim that every other caller of the partition helper is already canonical -- the review measured that it is not:local-run-task.ts/local-invoke.ts/local-start-api.tsderive${AWS::URLSuffix}from the RAW user--region, so--region CN-NORTH-1synthesizes acn-host carrying the commercial suffix, which this module then correctly rejects as a look-alike. Both spellings are broken (pre-fix it was accepted and pointed adocker loginat a host that does not exist), and the repair belongs where the CLI accepts the flag -- filed as issue #1795 and SHIPPED, alongside #1801, by PR #1818 while this branch was open. That does not make these guards redundant, and the distinction is the load-bearing one: #1795 addedcanonicalizeRegioninsidederivePartitionAndUrlSuffixso the partition LOOKUP folds its own input, whereas these guards decide whether the captured segments are a region id and a URL suffix at all. Folding cannot answer that -- folding is exactly what turnsus-eKst-1into the plausibleus-ekst-1-- so the two compose rather than overlap, which a re-probe against the merged helper confirmed: removing the region guard now reds NINE cases rather than six. The derived suffix is lower-cased too — a no-op against today's all-lower-case table, kept so this stays the single answer if that table ever gains a mixed-case entry. The returnedregionis now the canonical lower-case spelling, which matters beyond the comparison:ecr-puller.tsuses it as an SDK client region andecs-task-resolver.tsas theregionof akind: 'ecr'image. Tests: 40 cases pinning the 2x2 the issue asks for — {genuine, look-alike} x {UPPER, mixed} region — plus the mixed-case SUFFIX arm (AMAZONAWS.COM,AmAzOnAwS.CoM, both halves upper at once), each asserted EQUAL to the lower-cased verdict rather than to a hand-written expectation, so a future partition-table change cannot make the arms agree on a wrong answer; one guard asserts the look-alike arms are genuinely REJECTED so the equality pairs cannot pass vacuously, the mirror guard asserts the GENUINE arms are genuinely ACCEPTED (their baseline lives in anotherdescribe, so a regression rejecting BOTH sides would otherwise satisfy them), and an upper-cased FOREIGN suffix stays rejected so case-folding cannot make an unrelated suffix pass. The review also found that case was only ONE way to defeat thestartsWithclassification -- any leading junk defeats it identically, and the captured segment does not stay inert (it becomes anECRClient({region})and is interpolated into the fallback login endpoint), measured as" us-iso-east-1"and"us-i̇so-east-1"parsing successfully -- so the boundary now also refuses a region segment that is not[A-Za-z0-9-](the class admits UPPER case -- that IS the fix -- and is tested against the RAW capture), with an inverted control pinning commercial / China / GovCloud / 2-digit-suffixed region ids (each against its OWN partition's suffix, since pairing them all withamazonaws.comwould rejectcn-north-1for an unrelated reason) and a third mutation probe reddening 6 arms. The 3-axis round then found the guard covered only HALF the problem: the SUFFIX is folded before comparison too, and #1790'saws-isoesuffixcloud.adc-e.ukCONTAINS ak, so...eu-isoe-west-1.cloud.adc-e.u<U+212A>/...folded onto it and was ACCEPTED -- unreachable at the branch tip, live the moment this branch rebased onto main. Both captures are now held to the same raw-capture rule, with its own probe and an inverted control asserting the real suffix still parses in any casing. Mutation-probed in FOUR cuts against verified-unique anchors -- reverting the region lower-case reds 8 of 39, the suffix lower-case reds 4, deleting the region shape guard reds 6, and moving that guard back AFTER the fold reds exactly the Kelvin-sign arm -- with the restore done by undoing the probe edit specifically, nevergit checkout. One finding audited and deliberately NOT ridden along: thedkr.ecrLABELS inECR_URI_HOST_REGEXare still matched case-sensitively, so<acct>.DKR.ECR.<region>.<suffix>/does not match at all (measured; falls through as a public image, and returnsfalsefrom the foreign-suffix predicate so not even the diagnostic fires). Its failure direction is the safe one -- NO credentials are sent on any of the four call sites, traced end to end:local run-taskandlocal invoke-agentcorefall through to an anonymousdocker pullthat fails, whilelocal invokeandlocal start-apiREFUSE before any pull -- and widening the shape MATCHER is a behavior expansion wanting its own decision rather than part of closing a bypass, so it is recorded in-code and filed as issue #1792. A second review round (3-axis, against the final head) found one real defect in the guard itself and two wrong statements. The guard was applied AFTERtoLowerCase(), andString.prototype.toLowerCaseperforms full Unicode case folding, so U+212A KELVIN SIGN folds to ASCIIkand...dkr.ecr.us-e\u212Ast-1.amazonaws.com/...was ACCEPTED as the regionus-ekst-1-- a region the host does not name, i.e. precisely the substitution the guard exists to refuse. It now tests the RAW capture against[A-Za-z0-9-]before folding, which keeps case-insensitivity while admitting only characters that fold to themselves. The header's claim thataws-partition.tswas unavailable because a concurrent lane owned it was STALE by the time it was written (PR #1790 merged 30 minutes earlier); the shape is unchanged and still correct, but the reason is now stated as what it actually is -- a CLI flag and a host string read out of a template or a state record are different trust questions, so this boundary keeps its own normalization regardless of what #1795 does. The review also measured, against a real docker daemon with a temporaryDOCKER_CONFIG, that docker's credential store is keyed on the hostname VERBATIM, soecr-puller.tslogging in to the lower-cased host while pulling the raw-cased URI means a mixed-case genuine host still cannot authenticate -- the classification is fixed, the pull is not. Not a regression (the pre-fix path rejected such a host outright and pulled anonymously, failing too) but an incomplete benefit, filed as issue #1801 because the repair lands insrc/local/**and carries its owninteg-localobligation. One deliberate WITHDRAWAL is now stated in-code and pinned: refusing a malformed region also withdraws the #1764 foreign-suffix diagnostic for such a host, which is correct rather than a loss -- that diagnostic's subject is "this suffix does not belong to its region's PARTITION", and a segment that is not a region id has no partition to belong to. No CLI flag, dependency, or state-schema change. -
cdkd gcstops deleting referenced container images outside the commercial partition (issue #1781) —src/cli/commands/gc.ts,docs/cli-reference.md,tests/unit/cli/gc.test.ts. All three asset-reference matchers (virtual-hosted S3, path-style S3, ECR image URI) hardcoded the commercialamazonaws.comURL suffix, so a state file recordingamazonaws.com.cn/c2s.ic.gov/sc2s.sgov.govhosts matched NOTHING and its assets read as UNREFERENCED — the same hardcoded-suffix class as #1758 / #1745 but with the opposite blast radius (those emit a host that does not resolve; this one silently deletes). What it actually deleted was MEASURED, not assumed, and is narrower than the issue claims.CONTENT_HASH_KEY_REcollects<sha256>.<ext>tokens out of any string regardless of host, and cdkd's file assets are content-addressed with an extension, so S3 was already rescued by accident — a real--dry-runagainst a seededcn-north-1state file protected 71 of 72 objects including the referenced one with the matchers still broken. ECR is where the bug bites: cdkd's image tags are bare 64-hex with no.<ext>tail and digests aresha256:<hex>, so the content-hash pass cannot see either, and a referenced non-commercial image was listed for deletion. The three matchers now match the suffix against a closedAWS_URL_SUFFIXESSET — the union over EVERY partition, notderivePartitionAndUrlSuffix(region).urlSuffixfor the caller's region, because the scan reads every state file in the bucket written by any binary for any region, so a single derived literal would still delete acn-north-1stack's images during aus-east-1gc run. The list is deliberately a SUPERSET of the arms that helper knows (issue #1764): a suffix missing here deletes live assets, the irreversible direction, so it must LEAD that table rather than follow it —cloud.adc-e.uk/csp.hci.ic.gov/amazonaws.euare covered here today and the helper still lacks them. It stays a closed SET rather than a[^/\s]+wildcard so a look-alike host (https://<assetBucket>.s3.<region>.example.com/<key>, or one merely EMBEDDING a real suffix without ending in one) is not read as a cdkd asset reference — that direction only over-protects, but it would let any string naming the bucket pin an object forever and quietly turn gc into a no-op. Note this is a WEAKER check thansrc/utils/ecr-uri.ts, which captures the suffix and validates it against the region the host names; that pairing is right for one host with a caller-known region and wrong for a scan, where it would re-inherit #1764's missing arms on the irreversible side. The same change widens the ECR HOST forms toecr(?:-fips)?plus the short-form<acct>.dkr-ecr.<region>.on.awsalias, kept in a separateECR_REGISTRY_HOSTconstant precisely soon.awscannot leak into the two S3 matchers (pinned by its own test). Tests: 10 cases, every non-commercial assertion paired with a commercial counter-case asserting the unchanged collection (the #1758 convention), and the assertion that actually protects the user — an end-to-endcdkd gc --region cn-north-1run over a state file recordingamazonaws.com.cnhosts deletes ONLY the genuinely unreferenced object / image. Reverting the suffix set reds 8 of the 10 (the two negative-assertion cases correctly stay green — over-matching was never the pre-existing bug); narrowing the ECR host back to\.dkr\.ecr\.reds the FIPS case, and dropping the short-form arm reds the short-form case. One scope note recorded because the obvious stronger claim is NOT true: the test that derives a suffix per partition uses a HAND-WRITTEN region list, so it fences the suffix each listed region derives but NOT the arrival of a new arm in the derive table; coupling gc's list to that table needssrc/utils/aws-partition.tsto export the mapping and is tracked as (#1785). No CLI flag, dependency, or state-schema change. - A nested stack whose child FAILED to destroy now fails the parent's resource instead of reporting it deleted (issue #1777) —
src/provisioning/providers/nested-stack-provider.ts,src/provisioning/nested-stack-messages.ts(new),src/cli/commands/destroy-runner.ts,.claude/rules/providers.md,docs/{cli-reference,provider-development}.md,tests/unit/provisioning/nested-stack-provider.test.ts,tests/unit/cli/destroy-runner-nested-child-failure.test.ts,tests/unit/deployment/deploy-engine-nested-stack-delete-failure.test.ts,tests/integration/nested-stack/{verify.sh,README.md,lib/nested-stack-example.ts}.NestedStackProvider.deleteruns the child stack's destroy through a nestedDestroyRunner, andDestroyRunnerResultcarries THREE fields that say "this child stack is NOT gone". (#1752) / PR (#1774) propagated two of them —skippedCountandinterrupted— as{ outcome: 'skipped', reason }; the third,errorCount, was deliberately deferred because its correct answer is a THROW and that is a behavior change with its own blast radius. This is that change. Pre-fix, a child resource that genuinely FAILED to delete was swallowed: the parent printed✓ <Child> (AWS::CloudFormation::Stack) deleted, DROPPED the child's row, and — with the parent's ownerrorCountstill 0, sopreserveStateevaluated to FALSE — deleted the parent'sstate.jsonAND the exports index outright, exiting 0. The end state was worse than a dangling pointer: the child's ownstate.jsonsat there preserved describing live, billing resources, and nothing left named it, so recovery required knowing the<parent>~<child>key layout by hand. Nowdeletethrows, naming the child stack, the failure count, andcdkd state show <parent>~<child>as the file to open; the parent's nested-stack row FAILS like any other type's failed delete, so the row survives,state.jsonand the exports index survive, and the run exits 2 (PartialFailureError) — the same "state preserved, stack not destroyed" contracterrorCount > 0already carries everywhere else. This is a deliberate BEHAVIOR CHANGE and it reaches BOTH callers ofdelete:cdkd destroy/cdkd state destroyfail the row (exit 2), ANDcdkd deploy— removing a nested stack from the template routes the row through the deploy engine's DELETE path, so a child that fails to destroy now fails the DEPLOY and triggers its rollback where it previously recorded the row as deleted and carried on. That asymmetry is the reviewable part: unlike a{ outcome: 'skipped' }RETURN value, which the deploy-side sites still discard ((#1762)), a throw cannot be ignored by any caller — sodeploy-engine.tsgains this behavior with no edit to it. The review found a self-defeating gap and it is fixed here: the destroy runner'serrorCount > 0summary namedcdkd state orphan <parentStack>as the last resort, and following that advice DROPS theChildrow — the exact pointer the throw preserves. This PR is what first makes a nested-stack row reach that arm, so as first written it preserved the pointer and then told the user to destroy it.destroy-runner.tsnow collectsfailedStateTargetsalongside #1752'sskippedStateTargets, both through one sharedstateTargetFor(logicalId, resourceType)helper so the two cannot drift, and the error summary names the CHILD's state file (<parent>~<child>) for a nested-stack row and<stack>for an ordinary one, listing both when a run has each; astackNamefallback keeps the hint non-empty if a future path ever incrementserrorCountwithout recording a target. The re-review then found the mirror-image gap in the same line: a run carrying BOTH an error and a skip lands in the error arm (the skip-only arm is unreachable onceerrorCount > 0), so naming only the failed target printed, N skippedin the counters and silently dropped #1752's guidance for the skipped rows — whose remedy is different IN KIND, since a skip is not retryable until its state record is repaired. The arm now appends that guidance whenskippedStateTargetsis non-empty, and both arms build their hints through onehintForhelper. Two more review items shipped with it: the message builder moved OUT of the provider into a new dependency-free LEAF modulesrc/provisioning/nested-stack-messages.ts, because importing it from the provider draggeddestroy-runner→register-providers→ every provider into two unrelated unit tests and closed an import cycle (nested-stack-provider→destroy-runner→register-providers→nested-stack-provider) that is safe today only because nothing on the ring does module-scope work; and the fixture'sverify.shgained anassert_existsmirroringgone_probe's THREE-way outcome, since a wrappedif ! aws ...reports "state.json was DELETED" on ANY probe failure — a throttle or expired credentials would ACCUSE the fix of the very data loss it prevents. Three more decisions are deliberate. TheerrorCountcheck runs BEFORE the skip arm and wins over a concurrent skip / interrupt, naming both as context — an error means something was ATTEMPTED and FAILED, while a skip asserts no AWS call was issued, so reporting the run as a mere skip would be a lie in the other direction. The throw's wording avoids every already-deleted-shaped phrase (not found/does not exist/No policy found/NoSuchEntity/NotFoundException, plus the deploy engine'swas not found/ResourceNotFoundException), because both callers' catch blocks read those as idempotent success and DROP the state row — the exact outcome the throw exists to prevent; the union of both sets is pinned. And an ABSENTerrorCountfalls OPEN rather than throwing: the field is a required number the runner always initializes, so an absent one can only come from a partial / stubbed result, and keying a refusal on ABSENCE would turn every such input into a hard failure — the residual risk (a future refactor making the field optional restores the silent success) is pinned by a test rather than left implicit. Tests: the provider file gains the throw, the message content + the both-catch-sets wording fence, a FOUR-row table over the (skip, interrupt) combinations (with only the both-set and neither-set rows, SWAPPING the two clause bodies passed) each asserting the message contains no empty()(the only assertion that fences the conditional suffix), the absent-errorCountrow, the inverted control that a clean-but-skipped child still returns{ outcome: 'skipped' }, and a 3-LEVEL grandchild -> child -> parent chain (plus its clean control) driven by making the mocked runner RECURSE into the same provider and convert the throw intoerrorCountexactly as the real runner does — nesting depth was covered nowhere before, since the provider test mocks the runner, the runner test mocks the provider and the fixture is 2 levels. Two caller-side files pin what the throw BUYS: the destroy runner keeps theChildrow,state.jsonand the exports-index entries and names the child in its orphan hint (with an inverted control that an ordinary failure still names the stack, and that a CLEAN nested delete still drops the row and deletes both), and the deploy engine propagates the throw as its ordinaryFailed to delete resource <id>failure while keeping the record (with controls that an already-gone child IS still swallowed idempotently, and that a clean delete still drops the row). Both of those files are PROBE-INERT for the fix by construction — they drive a FAKE provider, so they pass against pre-#1777 code too — and their headers now say so instead of claiming to be regression tests for it; each builds its expected message from the newly exportednestedStackChildFailureMessagebuilder rather than a hand-copied literal, so wording drift into an already-deleted shape cannot leave a stale literal passing, and each fake provider setsdisableOuterRetry: truelike the real one so the harness exercises production's single-attempt path. Mutation-probed in SIX cuts against real source, each restored by hand: theerrorCountthrow neutered → 7 provider rows red;|| childResult.interruptedremoved → the interrupt row red;failedStateTargetsrecording the parent instead of the derived target → the two orphan-hint rows red; the throw's wording drifted into an already-deleted shape (appending(resource not found)) → 3 rows red across the provider AND the deploy-side file, which is what proves the two probe-inert caller files still fence something real; and the skipped-guidance clause made unconditional → its control row red, then dropped entirely → the both-kinds row red. Integ:tests/integration/nested-stack/gained averify.sh(it had none — the ledger records the test as modestandard, so "a clean child still reports deleted" was only ever an implicit consequence of that flow exiting 0; Phase C makes it an explicit assertion and the ledger's mode column flips toverify.sh). It provokes a genuinely failing child delete without touching the CDK app: the child's S3 bucket has noautoDeleteObjects, so cdkd's CloudFormation-parity data guard ((#1340)) refuses to delete it while it holds an object. Phase B PUTs one object and asserts the destroy exits 2, prints no✓ Child … deletedline, names the child stack, asserts the last-resort remedy names the CHILD state target (with a negative half asserting the PARENT form is absent — the orphan-hint fix was unit-fenced only until the re-review), and preserves BOTH state files plus the parent'sChildrow AND the child state's still-listed bucket row (the child file's mere existence is non-discriminating — the child runner preserved it pre-fix too); Phase C empties the bucket and re-destroys for the clean control. Scratch files go to a per-runmktemp -drather than fixed/tmppaths, so two concurrent runs cannot clobber each other's captured destroy output. The state assertions are what discriminate: an exit-code-only check would pass against the un-fixed binary, which exited 0. The load-bearing absence ofautoDeleteObjectsis documented at the EDIT SITE in the fixture's stack, not only in its README — stating accurately that adding it makes Phase B FAIL at itsrc -ne 2check (an earlier draft claimed the assertions would keep passing, and overstating a hazard is how the next reader concludes the fixture is lying about something else). A 3-level integ arm was considered and deliberately NOT added — the only lever available makes teardown materially riskier (a mid-tree failure leaves two child state files plus a non-empty bucket whose name the cleanup can only learn from a state file the failing run may have moved past), and the chain is covered by the recursive unit test instead; the cost is teardown risk on a real account, not wall-clock. Becausedestroy-runner.tsis in theinteg-broadgate scope, this change needs a broad integ in addition to the feature one. No CLI flag, dependency, or state-schema change. -
cdkd exportno longer aborts on a Route / EIP / EventInvokeConfig, and theVPCGatewayAttachmentidentifier it shipped is corrected (issue #1771) —src/cli/commands/export.ts,src/provisioning/providers/s3-tables-provider.ts,tests/unit/cli/export.test.ts,tests/integration/export/{lib/export-stack.ts,verify.sh}.cdkd exportis all-or-nothing, so ONE composite-primaryIdentifiertype with noCOMPOSITE_ID_SPLITTERSentry blocks the whole command. Three types cdkd deploys routinely were unregistered —AWS::EC2::Route(the widest blast radius: essentially every public-subnet VPC declares one),AWS::EC2::EIP,AWS::Lambda::EventInvokeConfig— and every schema fact behind the three new entries is a liveDescribeTypemeasurement (us-east-1, 2026-08-13), not the issue's table.AWS::EC2::Route: identifier[RouteTableId, CidrBlock]withCidrBlockread-only, so the overlay narrows toRouteTableId. The subtlety is whatCidrBlockHOLDS: the schema gives it an empty description, so "it is the IPv4 CIDR" is the natural and wrong reading. Cloud ControlGetResourceagainst a scratch route table carrying both shapes says otherwise —rtb-…|::/0reads back{"CidrBlock":"::/0","DestinationIpv6CidrBlock":"::/0"}— i.e. it carries whichever ofDestinationCidrBlock/DestinationIpv6CidrBlock/DestinationPrefixListIdthe route declares, so the physicalId's destination segment maps onto it verbatim and no per-key branch is needed. All three destination shapes were read back live (0.0.0.0/0/::/0/pl-63a5400a), so the mapping is measured rather than inferred. The identifier is always returned in CANONICAL form, which is load-bearing rather than cosmetic:createRoutepacks the destination it SENT into the physicalId and EC2 clears host bits on the way in, so an SDK-written state entry can holdrtb-…|100.68.0.18/18while AWS holds100.68.0.0/18— and because the recorded properties hold that same non-canonical value, the agreement check passes and a verbatim identifier would have been shipped straight into the opaque CFn rejection this path exists to prevent. The recorded properties are then read as a cross-check whose OUTCOME depends on whether the benign explanation is decidable: AWS canonicalizes a non-canonical CIDR onCreateRoute(CFn documents rewriting100.68.0.18/18to100.68.0.0/18) and the Cloud Control path records what AWS returned, so a modelled host-bit difference passes through silently; an unexplained divergence is REFUSED whenever every declared destination is a shape whose AWS-side normalization cdkd models (an IPv4 CIDR, or a prefix-list id — measured to be stored verbatim on both sides, sopl-AAAvspl-BBBis conclusive), because a stale id can name a DIFFERENT route that still exists in the same table — IMPORT would adopt it and phase 2 would then REPLACE (delete) a route the user never targeted — while a divergence involving an IPv6 CIDR (the one shape whose canonicalization cdkd does not model) only WARNS, since refusing on an undecidable signal would block exports that are fine.AWS::EC2::EIP: identifier[PublicIp, AllocationId]with BOTH read-only, so it is the first entry whosepropertiesOverlayis an explicit empty map; a bareeipalloc-…/ bare-IP physicalId cannot produce both fields and is refused with a re-deploy hint rather than sent half-filled. The two segments are bound by SHAPE — BOTH halves validated, since an allocation-id-only guard passeseipalloc-a|eipalloc-band then shipsPublicIp: 'eipalloc-b'— rather than by position: every writer goes througheipPhysicalId, but that ordering is the one thing here no state record in the wild has verified, and a positional bind turns a reversed record into{PublicIp: 'eipalloc-…'}plus an opaque changeset-create failure.AWS::Lambda::EventInvokeConfig: identifier[FunctionName, Qualifier], NO read-only fields, so the default whole-map overlay is correct; a bare function name is read as qualifier$LATEST, mirroring the provider's ownparsePhysicalId, and the id splits on the FIRST|so a function ARN survives. Plus a correction to #1691'sAWS::EC2::VPCGatewayAttachmententry: it producedAttachmentTypevalues ofInternetGateway/VPN, which are plausible, satisfy every unit test written against them, and are REJECTED by CloudFormation —Invalid request provided: Invalid Attachment Type 'InternetGateway'at changeset-create. The real values areIGW/VGW(Cloud ControlListResourcesagainst a VPC carrying both attachment kinds, us-east-1, 2026-08-13); the registry schema types the field as a bare string and enumerates nothing, which is why only a live IMPORT settles it. Both old spellings are still accepted as INPUT — nothing ever WROTE them (they were produced by the table on the way out), so this is compatibility for a hand-written /--resource-supplied id, and the lookup usesObject.hasOwnso a first segment ofconstructorcannot resolve to an inherited function. Live-tested against real CloudFormation, not only unit mocks: a scratch VPC + IGW + attachment + route table + route + EIP, and a separate Lambda + EventInvokeConfig, were adopted through realChangeSetType=IMPORTchangesets — both reachedIMPORT_COMPLETE, and the same run is what produced theInvalid Attachment Typerejection. Tests: theexportinteg fixture now declares all of it (VPC / IGW / attachment / route table / default route / EIP / EventInvokeConfig),verify.shasserts the RESOLVED identifier value per type in the printed import plan — from a SHARED helper called by BOTH thedefaultanddry-runarms, because/run-integ exportruns the default variant only and assertions living in the dry-run arm alone would never execute under the skill meant to gate them — plus the PhysicalResourceId CloudFormation recorded per resource after the real import (each glob pinned to a measured value, since a CFn PhysicalResourceId is a THIRD string distinct from both the Cloud Control identifier and cdkd's own physicalId: for the attachment the two even disagree on order,vpc-…|IGWvsIGW|vpc-…, and CFn reports an EIP as the bare public IP) — assertions that fail both ways a splitter can be wrong (absent, because the command aborted; and present-but-wrong, e.g.AttachmentType=InternetGateway); 40 new unit cases (34 in a dedicated#1771 typesblock, 2 on the correctedVPCGatewayAttachmententry, and 4 prototype-pollution cases split across the two registry lookups) cover all three destination shapes, the empty/short/over-long id refusals, the explicit-empty vs default overlay distinction (asserted through BOTH template overlay sites, sincepropertiesOverlay: {}only protects the EIP if the?? resourceIdentifierfallbacks treat it as write-nothing), the$LATESTbare form, the shape-bound EIP segments, theObject.hasOwnalias lookup, the previously-unreachedvgw-last-resort arm, and each arm of the Route divergence policy. Eleven mutation probes in the final round (each anchor counted for uniqueness first) confirm every new branch fails RED when broken, including theevery-vs-somedecidability boundary, the/0mask special case, and both registry lookups. The IPv6 route arm is deliberately NOT in the fixture: it needs anAWS::EC2::VPCCidrBlock, itself an unregistered composite type that would abort the same export (#1788). Also corrects a stale comment ins3-tables-provider.tsclaiming the CFn schema typesNamespaceasList<String>(it is{"type": "string"}; the provider still accepts both wire shapes becauseaddPropertyOverridecan produce an array — the overlay's handling of that array is split out as #1787). No CLI flag, dependency, or state-schema change. -
AWS::EC2::SecurityGroupIngressrecords thesgr-…rule id, socdkd exporthas a CloudFormation identifier to resolve (issue #1761) —src/provisioning/providers/ec2-provider.ts,tests/unit/provisioning/ec2-sg-ingress-rule-id.test.ts,tests/integration/sg-circular-dependency/**,docs/state-management.md,docs/import.md. cdkd's physical id for the type is the composite<groupId>|<ipProtocol>|<fromPort>|<toPort>— the tupleRevokeSecurityGroupIngressneeds, and correct for deploy / destroy / drift — but CloudFormation identifies a rule by the SINGLE fieldId, which is thesgr-…security-group rule id AWS mints (livedescribe-type, us-east-1, 2026-08-13:primaryIdentifierandreadOnlyPropertiesare both exactly["/properties/Id"], handlerscreate/delete/list/read/update).createSecurityGroupIngressthrew that value away (attributes: {}on both arms), which is why #1659 had to register the type as a pure REFUSAL inCOMPOSITE_PHYSICAL_ID_IDENTIFIERS. It is now recorded underId— the same name the three sibling composite types use, i.e. the CFnprimaryIdentifierfield, which is also the type's onlyFn::GetAttattribute. Where the value comes from is the load-bearing part: it is read offAuthorizeSecurityGroupIngress's OWN response (SecurityGroupRules[].SecurityGroupRuleId, verified against@aws-sdk/client-ec2'sAuthorizeSecurityGroupIngressResult), never from a follow-up describe — anawaitadded insidecreate()'s try AFTER the mutating call re-creates the #1710 orphan class, since a throw there leaves the rule live on AWS while the failed CREATE journals no physical id. A unit test pins the AWS call COUNT at 1 so a later "just describe it to be sure" refactor cannot reintroduce it. An AMBIGUOUS response (a template declaring bothCidrIpandCidrIpv6makes AWS mint two rules) records NOTHING rather than naming one of them. The idempotent "already exists" arm has no response to read, so it does a best-effortDescribeSecurityGroupRuleslookup matched on protocol + ports + the one declared source — deliberately NOT viasgRuleKey, whoseSourceSecurityGroupOwnerId/Descriptionmembers AWS fills in on the read side even when the template omits them — and swallows its own failures, because turning a deploy AWS already considers satisfied into a failure over an export-time convenience is the worse trade.import()gained the type too:--resource <logicalId>=sgr-…verifies the rule, declines an EGRESS id (that isAWS::EC2::SecurityGroupEgress, whose delete calls a different API), and records the composite plus the attribute. NohandledProperties/ readback companion is needed —Idis read-only, never templated, and lives inattributes, which drift does not compare. The export side is converted in the same PR.COMPOSITE_PHYSICAL_ID_IDENTIFIERS'AWS::EC2::SecurityGroupIngressentry was a pure REFUSAL that existed only because nothing recorded the id; it is now arecordedArnIdentifier-shaped resolution readingattributes.Id, with an EMPTYpropertiesOverlay(Idis the type's only identifier field AND isreadOnlyProperties, so CFn rejects writing it into the template'sProperties). The predicate is the anchored^sgr-[0-9a-f]+$and BOTH accepted sources go through it — the recorded attribute and a physicalId that is already the bare rule id — because validating one side is not a discriminator (the finding #1771 made about the EIP splitter's segment binding). Anchoring is what does the work: a barestartsWith('sgr-')acceptssgr-abc|tcp|443|443, i.e. the composite wearing the identifier's prefix. Measured live rather than derived (us-east-1, 2026-08-14), because #1771 established that the three strings naming one resource can disagree: a throwaway CloudFormation stack carrying a standalone ingress rule reportsPhysicalResourceIdsgr-02345615af6d2db0d, withRefandFn::GetAtt .Idreturning that same value — so for THIS type, unlikeAWS::EC2::EIP/::VPCGatewayAttachment, CFn's string agrees with the identifier.tests/unit/cli/export-composite-identifier.test.tshad a case PINNING the refusal ("refuses … even when anIdattribute happens to be present", justified by "nothing in cdkd writes anIdattribute for this type today"); that premise is now false, so the case is inverted rather than left to keep CI green over the wrong behavior. Known gap: a rule deployed by an older cdkd has no recordedIdandcdkd exportblocks it — with a message that does NOT say "re-deploy the stack once" like its three siblings, because the id ridesAuthorizeSecurityGroupIngress's own response and a no-op deploy issues no call; it names the real remedy (change any property, or destroy and re-deploy, accepting a momentary traffic interruption). Tests: 15 unit cases plus a live arm on thesg-circular-dependencyinteg — state must carry ansgr--shapedattributes.Id, andcdkd export --dry-runmust resolve that EXACT value into the import plan. The export arm runs against a second, deliberately minimal stack (CdkdSgIngressExportExample: an L1 VPC with no subnets plus the same SG-to-SG pair) becausecdkd exportaborts on the first unresolvable resource even under--dry-run, and the circular-ref fixture'sec2.Vpcemits anAWS::EC2::Route— a composite-identifier type with no splitter, i.e. exactly the class issue #1771 tracks. No CLI flag, dependency, or state-schema change (attributesis a free-form map). - DynamoDB: the Table GSI / LSI readback is reverse-mapped to its CFn shape, and a WarmThroughput DECREASE stops being sent (issues #1767 / #1768) —
src/provisioning/providers/dynamodb-table-provider.ts,tests/unit/provisioning/dynamodb-table-provider-index-drift-phantoms.test.ts,tests/unit/provisioning/dynamodb-table-provider-warm-throughput-decrease.test.ts. Both were found by the code review of the #1760 PR and land in one file, hence one lane. #1767 — the index readback was forwarded VERBATIM.readCurrentStateemitted theDescribeTableindex descriptions unchanged, soIndexStatus/Backfilling/ItemCount/IndexSizeBytes/IndexArn, the on-demand{0, 0}ProvisionedThroughputplaceholder and the AWS-computed per-indexWarmThroughputall took part in drift detection — the method's docstring claimed "the comparator filters them" and it does not, because that filter iscalculateResourceDrift's state-keys-only TOP-LEVEL walk while these are members of an ARRAY the baseline DOES carry, compared positionally bydeepEqual. Two failures fell out: apropertiesbaseline could never equal the readback (permanent phantom), and — worse, on the ORDINARY path —ItemCount/IndexSizeBytesMOVE with write traffic, so any in-use table with an index drifted against its ownobservedPropertieson a schedule set by its own traffic, withdrift --revertre-sending the frozen blob throughupdate(). Each entry is now reverse-mapped by an ALLOW-LIST (IndexName/KeySchema/Projection, plus the three throughput blocks gated on the DESIRED bag's entry of the sameIndexNamedeclaring them — the #1760 shape one nesting level down, matched by NAME never by position). The allow-list is what caughtBackfilling, which the issue's quoted sample does not list. ThegetDriftUnknownPathscompanion ignores each index list when the template declares none, covering that population's upgrade transition and steady state; the residual for a table that DOES declare indexes (its pre-fixobservedPropertiesreports a one-sided difference until the next deploy orcdkd drift --accept) is carried deliberately: no PATH can express the middle ground, since the comparator is never asked about a path that crosses an array. A NON-path seam does now exist —canonicalizeDriftProperties, applied to both comparison sides, landed in #1799 and closed #1784 — and adopting it here is its own change with its own real-AWS verification, tracked as #1812. The index lists are NOT declared ingetDriftUnorderedPaths: a plain entry there is a SUBTREE declaration whose walk descends into array elements, so it would also sort the per-indexKeySchemaand reverse #1760's order-significance decision at the index level only. The leaf-only form that fixes exactly this —'GlobalSecondaryIndexes[]'— shipped in #1799 and closed #1783; declaring it for this type is part of the same follow-up, #1812. #1768 — a declaredWarmThroughputAWS has GROWN was unrevertable. Measured live (us-east-1, 2026-08-13): against a table AWS reports{12000, 4000}for, anUpdateTablelowering either member is REJECTED (decreasing WarmThroughput is not supported) while a re-assert of the equal value is accepted — socdkd drift --revertissued a call that could only fail and the resource reportedcould not revert(exit 2).update()now SKIPS a request in which every declared member is at-or-below the live value and at least one is strictly below, warning with both numbers; a MIXED request, an absent live value and any unusable value all fail OPEN (still sent). NoeffectivePropertiesis returned on purpose: recording AWS's value would silence the drift report that is the user's only signal to edit the template. The skip was checked against everyupdate()caller — deploy,drift --revert, and the rollback executor's two revert arms — none of which can make AWS lower the value. The PER-INDEX sibling is fixed in the same change:applyGsiUpdatessent onlyProvisionedThroughput/OnDemandThroughput, so a template-declared per-indexWarmThroughputwas silently dropped on every index add / change and--revertexited 0 claiming success — it now rides theCreateaction and gets its ownUpdateaction (measured: aWarmThroughput-only GSI update action is accepted, and a per-index decrease is rejected naming the index), under the same decrease guard. Live coverage for both issues ridestests/integration/dynamodb-gsi-update: the fixture now writes real items, asserts the deploy-writtenobservedPropertiesindex entry carries ONLY the CFn members, assertscdkd driftreports the table CLEAN (parsed from--json, so a drift-unknown outcome cannot read as clean) against both the observed and the stripped TEMPLATE baseline, and — for #1768 — deploys a template declaring a WarmThroughput BELOW the value AWS holds, which pre-fix failed the deploy outright and now must skip with the warning while AWS's value stays put. One more residual the review surfaced: a{0, 0}per-indexProvisionedThroughput(AWS's on-demand placeholder) arriving from a pre-fix state record via--revertis refused rather than sent, since AWS rejects capacity 0 in either billing mode. Review round 2 closed four more, all found by the 3-axis reviewers against the per-index send path this PR had just added: the per-indexWarmThroughputgained theliveCapacityAlreadyMatchestwin it was missing (warmThroughputAlreadyMatches), without which a--revertof a stale blob emitted one redundantUpdateTableplus a full index-ACTIVE wait PER GSI — which had also falsified the residual note beside it, so that note now names the three skips it depends on instead of claiming the revert is a no-op "by construction"; the shared send rule stopped being bare truthiness, soWarmThroughput: {}/'nonsense'no longer reach the wire (at least one member must resolve to a finite number, newly reachable once per index); the per-index decrease warning gained the deploy-path caveat AND a second remedy for the caller whose template declares no per-indexWarmThroughputat all; andNonKeyAttributesis copied rather than aliased into the SDK response, like itsKeySchemasibling. The suspectedWarmThroughput.Status/runGsiOpsrace was MEASURED rather than reasoned about (us-east-1, 2026-08-13): the wait predicate IS satisfied while a warm update settles —IndexStatus: ACTIVEalongsideWarmThroughput.Status: UPDATINGfor 90+ seconds — but all three next-op shapes (warmUpdateon another index, on the SAME index, and a GSICreate) were ACCEPTED, so the wait is deliberately NOT gated on warm status and the measurement is recorded where the predicate lives. Round 3 added the per-index EMIT arm the fixture was missing (the maintainer signed off on its recurring cost): phase 2 now declaresWarmThroughput: {12001, 4000}ongsi1, which rides theCreateaction the GSI add already issues — no extra AWS call — andverify.shasserts AWS reports 12001/4000 back. 12001 is the SMALLEST raise AWS accepts above the 12000/4000 floor (measured), and it is the discriminator: an index created with no declared WarmThroughput reports exactly 12000/4000, so the assertion cannot pass on the default. It reads the units WITHOUT waiting forWarmThroughput.Status, because the same measurement shows the units report the requested value from the first poll (whileIndexStatusis still CREATING) whereas the status stays UPDATING for minutes — the bounded poll is for read consistency, not for the warm update to finish. The phase-2b observed-baseline key set becomesIndexName,KeySchema,Projection,WarmThroughput, which now asserts BOTH halves of the reverse-map at once: the declared block survives, the AWS-managed members do not. Review round 3 closed seven more: tightening the send rule had made a refusedWarmThroughput(a member typo,'nonsense',{}, an unresolved intrinsic) vanish with no warning at all — pre-PR it reached AWS and was rejected BY NAME — so all four write sites now announce it while an ABSENT or falsy block stays silent;warmThroughputOpForand the capacity path share ONEUpdateaction per index (both members are optional onUpdateGlobalSecondaryIndexAction, so splitting them cost a secondUpdateTableplus a second full index-ACTIVE wait); the reverse-mapper NARROWS the GSI/LSI union instead of casting across it; the integ arm's poll no longer aborts the whole verify on a transient describe (set -ekills a bare assignment from a failing command substitution — measured both ways) and every scratch file is swept by the existing trap; and two comments were corrected against the code rather than reworded — the caller-blind{0,0}rationale had claimedupdate()takes no context whenUpdateContextexists ands3-bucket-provider.tsconsumes it (the behavior stands: its one field separatesdrift --revertonly, and the rollback replay is still indistinguishable from a template deploy), andindexDeclares's header had claimed its predicate is what the write path calls universally, which is false forcreate()'s verbatimCreateTableforward. Review round 4 fixed a regression the round-3 narrowing had introduced — oneif (!('ProvisionedThroughput' in live)) return out;guard ahead of all three throughput blocks gatedOnDemandThroughputandWarmThroughputon a THIRD, SDK-optional member, so the ordinary PAY_PER_REQUEST-with-caps readback (OnDemand + Warm, noProvisionedThroughputkey) dropped BOTH declared blocks and produced exactly the one-sided drift this change exists to remove; the narrowing is now per block. It also unblinded a CI critic: twoproperties!['…']non-null assertions are not followed byscripts/gen-handled-property-wiring.ts, which had silently dropped thegetDriftUnknownPaths/readCurrentState/delegatedevidence forWarmThroughputand both index lists from the checked-in matrix — an explicitproperties !== undefined &&restores it. Plus the adopted-index arm's{0, 0}refusal gained the test that reds when it is removed, and three more comments were corrected against the code rather than reworded. Review round 5 closed the last one: the tightened send rule ACCEPTED a numeric string while all four send sites forwarded the declared value verbatim, so{ReadUnitsPerSecond: '12000'}went on the wire as a string in a Long field — the one doomed shape the tightening exists to stop, andgetDriftUnknownPathsthen answered "declared" for it. The value is now COERCED per member at every send site ('12000'->12000, matching whatcreate()'s table-levelProvisionedThroughputalready did), an unusable member is dropped and named rather than reaching AWS asNaN, andisSendableWarmThroughputis DEFINED as the coercion's success so the drift gate and the write path cannot diverge for any input. The per-index refusal warning also moved below the unchanged-value gate, so both warn sites now mean "once per changed value" rather than re-warning on every deploy that touches another index. Review round 6 found the FIFTH send site the coercion had missed —create()forwards the declaredGlobalSecondaryIndexesarray toCreateTable, so a per-index{ReadUnitsPerSecond: '12000'}reached the wire as a string and{}as an empty block, both silently, which made one template fail on a fresh create and succeed on a later GSI add; each entry is now mapped through the same helper (entries are rebuilt, never mutated, since the engine records the same bag into state). It also scoped the identity claim to the BLOCK level, where it is exact, and states the per-MEMBER residual explicitly (a template whose second member is an unresolved intrinsic sends one member while the readback emits both — fail-open, announced on the deploy that introduces it and silent on repeats since every warn sits behind a change gate, with the drift report standing throughout, and closing it means areadCurrentStateshape change with its own baseline migration); moved the decrease / already-matches gates onto the COERCED spec so the analysis describes the request actually sent; and noted thatProjection's rebuilt key order is whatapplyGsiUpdates'sJSON.stringifycompare depends on. Review round 7 applied that same gate principle to the TABLE-level decrease check, which had been left reading the raw bag: for{ReadUnitsPerSecond: {Ref: 'X'}, WriteUnitsPerSecond: 2000}against a live{12000, 4000}it failed open on the unusable member and then transmitted the coerced{WriteUnitsPerSecond: 2000}— a decrease AWS refuses — so the DEPLOY FAILED while the per-index arm warned and continued on the identical input. Both paths now coerce ONCE, before the gate, which also means the dropped-member announcement survives a skip instead of being lost with the send. Three claims were corrected against measurement rather than reworded: the residual is announced on the deploy that introduces it and silent on repeats (every warn sits behind a change gate) rather than "on every deploy"; theProjectionkey-order flip comes from the TEMPLATE side, sinceaws-cdk-libemits{NonKeyAttributes, ProjectionType}while a readback is always the reverse; and the note claimingcreate()forwards a per-indexWarmThroughputverbatim was made false by round 6's own fix. Review round 8 was fences and precision only: the dropped-member announcement gained real coverage at BOTH levels (the assertion that looked like its fence matched a member name the DECREASE warning also prints, so it passed whether or not the announcement fired), the two pure warm-throughput gates now take aWarmThroughputrather thanunknownso the coerced-spec invariant is structural, and the announcement's tail no longer claims the value "was sent" — it fires ahead of the gates, so a later skip made that false. - The GlobalTable replay-CREATE omit stops orphaning
AttributeDefinitions, and one of two permanent drift phantoms goes away (issues #1741 / #1742) —src/provisioning/providers/dynamodb-globaltable-provider.ts,.claude/rules/providers.md,.claude/rules/code-layout.md,tests/unit/provisioning/dynamodb-globaltable-provider-replay-create-effective-props.test.ts,tests/unit/provisioning/dynamodb-globaltable-provider-drift-phantoms.test.ts. Both were found by the FIRST live exercise of theAWS::DynamoDB::GlobalTablereplay-CREATE arms (therollback-replay-effective-propsfixture added for #1724 / #1726, us-east-1, 2026-08-13), and neither is visible to a unit mock. Bundled because they land in one file, which cannot host two lanes. #1741 — the omit produced a call AWS rejects.create()takes the #1544replayWarndowngrade when a state record carries a malformedGlobalSecondaryIndexes: the translator warns and returns the well-defined EMPTY list, soCreateTablegoes out with no indexes — but with the record'sAttributeDefinitionsUNCHANGED. DynamoDB requires the definitions to be exactly the attributes named byKeySchemaand by the indexes actually being created, so for any table whose GSI was keyed on its own attribute — the ordinary case — the omit left that attribute defined and unused and AWS rejected the whole call (ValidationException: … Some AttributeDefinitions are not used. AttributeDefinitions: [pk, gsipk], KeySchema: [pk]). The downgrade exists so a reverse-replacement rollback can restore a table whose record an older binary wrote badly, so it was failing on exactly the population it was built for: the old table was not re-created, the rollback reported the resource as remaining, and the user was left with the new table plus a record describing the old one — worse than the refusal it replaced, which at least failed before touching AWS. Now the arm prunesAttributeDefinitionsto the attributes the call still keys on, and records the pruned list ineffectivePropertiesfor the same reason #1724 drops the index key (an unsent definition is a recordreadCurrentStatecan never match). LSI key attributes SURVIVE —LocalSecondaryIndexesis create-only and is not omitted by this arm, so a naive "keep only the tableKeySchema" would break a table with an LSI in the opposite direction — which is whycollectDesiredKeyAttributeNamesgained an explicit index-list scope rather than a hand-rolled twin;update()'s existing removal guard passes no scope and reads exactly as before. It fails OPEN on an unreadableKeySchema(an intrinsic-valued one resolves to no names, and pruning against an empty set would strip every definition and turn a template defect into a more confusing AWS error). The fixture's workaround — keying its index on the table's own partition key — is what made the arm pass before; that constraint can now be lifted, and the cross-region half of #1741 (the omit keepsReplicas[].GlobalSecondaryIndexesoverrides, whichaddReplicathen sends against a zero-index table) stays OPEN pending a cross-region fixture arm, so the issue is not auto-closed. #1742 — two phantom drifts on an UNTOUCHED table. Both reachable only when the drift baseline ispropertiesrather thanobservedProperties, i.e. after any reverse-replacement rollback, whichrollback-executor.tsstripsobservedPropertieson — which is why they had not been seen: on the ordinary path both comparison sides come from the same readback and already agree. (a)AttributeDefinitionsis an unordered SET compared positionally:DescribeTablereturns it in an order matching neither the request nor alphabetical (a table declaring[pk, gsipk]read back as[gsipk, pk]), so it drifted forever. The provider now declares it ingetDriftUnorderedPaths— the first such declaration on this type, so the method is new — and sorting cannot hide a real change because the list is keyed byAttributeName.KeySchemais deliberately NOT declared at either level, being order-SIGNIFICANT (HASH before RANGE). (b)GlobalSecondaryIndexes[].WarmThroughputis AWS-COMPUTED (a 12000/4000 default reported for every index) and is deliberately NOT fixed here, so #1742 stays open for that half. The obvious fix -- gating thereadCurrentStateemission on the desired side -- was implemented, reviewed, and then REVERTED, because it trades one population's phantom drift for another's: everyobservedPropertiesbag already in S3 was written by a binary that emitted the member, so the firstcdkd driftafter upgrading would report the wholeGlobalSecondaryIndexesarray on every untouched table (measured live on the siblingAWS::DynamoDB::Tabletype under #1760). The companion that closes that transition -- declaring the path ingetDriftUnknownPaths-- exists for a TOP-LEVEL key but cannot express a PER-INDEX one, since an ignore-path never crosses an array. It needs a both-sides normalizer in thedrift-protocol-normalize.tsmould plus a live test seeded with a STALE observed baseline, which a fresh-deploy fixture structurally cannot provide. Shipping the ordering half alone carries no such hazard: sorting is applied to BOTH comparison sides, so an existing baseline and the readback converge rather than diverge. Also measured and NOT bundled: the siblingAWS::DynamoDB::Tableprovider has the identical un-declaredAttributeDefinitionsreadback and an unconditional table-levelWarmThroughputemission — different file, different type, and the second half needs its own us-east-1 measurement — filed as issue #1760. #1739 was closed unfixed, having been measured as ALREADY fixed on main by PR #1722 (theAWS::DynamoDB::GlobalTable <logicalId>:prefix and the "compared against" wording are both present and fenced atdynamodb-globaltable-provider-billing-mode-shape.test.ts:319). Tests: 14 new cases across two files. Every fix is paired with discrimination cases that must NOT change — an omit orphaning nothing keeps the declared list verbatim, a valid GSI block prunes nothing, an unreadableKeySchemafalls open, a malformed definition entry is kept rather than silently pruned, a cross-region replica index block survives while the local one is dropped, and a retyped attribute still differs after canonicalization so the sort is not vacuous. The ordering case asserts through the SAMEcanonicalizeUnorderedArraysAtPathshelperdrift-calculator.tsapplies, using the two orders the live run produced, rather than asserting the declaration string alone. Mutation-probed against the real source in three cuts (the prune, the unordered declaration, the cross-region replica guard); each fails only its own rows and every discrimination case survives. No CLI flag, dependency, or state-schema change. - DynamoDB: the two
AWS::DynamoDB::Tablephantom drifts —AttributeDefinitionsorder and the AWS-computed table-levelWarmThroughput(issue #1760) —src/provisioning/providers/dynamodb-table-provider.ts,tests/unit/provisioning/dynamodb-table-provider-drift-phantoms.test.ts. TheAWS::DynamoDB::Tabletwin of theAWS::DynamoDB::GlobalTablepair filed as (#1742), in a different file. The two halves reach the user by DIFFERENT routes, and the issue's single premise covered only one of them — the spec review of this PR caught the summary asserting one reachability for both.AttributeDefinitionsis the half the issue describes: reachable only when the drift baseline is the templatepropertiesrather thanobservedProperties(after a reverse-replacement rollback, which stripsobservedProperties, or on a resource deployed before observed-capture existed) — on the ordinary path both comparison sides come from the same readback and already agree, which is why routine runs never surfaced it.WarmThroughputis the OPPOSITE: the comparator's top-level walk iteratesObject.keys(stateProperties)(src/analyzer/drift-calculator.ts), so a key the template never declared cannot drift against apropertiesbaseline AT ALL. Its harm lands on theobservedPropertiesbaseline, where the AWS-computed value is frozen at capture time and a later AWS-side increase surfaces as drift on a property the user never declared, whichdrift --revertthen re-sends.AttributeDefinitions. The provider declared nogetDriftUnorderedPathsat all while emitting the readback verbatim, and the property is a genuine SET keyed byAttributeName: measured us-east-1 2026-08-13, aCreateTabledeclaring[{pk,S}, {gsipk,S}]read back as[{gsipk,S}, {pk,S}]on the create response AND on every laterDescribeTable, so the comparator's positional array compare reported permanent drift on a table nobody touched. It is now declared unordered;KeySchemais deliberately NOT, at either level, because it is order-SIGNIFICANT (HASH before RANGE) and sorting it would HIDE a real key change — pinned by a test in both directions.WarmThroughput. The issue filed this half UNMEASURED and it measured POSITIVE: on the same probe table, whose create input declared none,DescribeTablereturned{ReadUnitsPerSecond: 12000, WriteUnitsPerSecond: 4000, Status: 'ACTIVE'}— so the existing emit-when-present guard could never do what its comment claimed ("only on tables that set warm throughput"). The value is AWS-computed and AWS-owned (it only ever rises with the table's traffic and cannot be lowered), so freezing it into anobservedPropertiesbaseline turns a later AWS-side increase into drift on a property the user never declared, whichdrift --revertthen re-sends throughupdate()as a decrease AWS rejects.readCurrentStatenow emits it only when the DESIRED bag declares it — never a blanket drop, since the type accepts an explicit value and a real change to one must stay visible — which needed thepropertiesparameter threaded in, the same one-line signature change (#1742) PROPOSES for the per-index sibling (that issue is still open, so this is the first site to carry the shape). "Declares" is spelled the way the WIRE spells it:create()/update()gate on truthiness, so a declaredWarmThroughput: nullsends nothing while AWS still computes 12000/4000, and an!== undefineddrift-side gate would re-create the identical phantom one value over — with--revertunable to clear it, since the write gate skips a falsy value. OnedeclaresWarmThroughputpredicate serves both drift-side consumers so the three spellings cannot drift apart, and a table-driven test pins emit-vs-ignore agreement across every bag shape. The gate alone would have shipped a regression, and that is the second half: every state record written by an earlier binary already carries the computed value inobservedProperties, so dropping it from the AWS side flips EVERY existing table to a one-sidedWarmThroughputdrift until the next deploy refreshes the capture.getDriftUnknownPathstherefore ignores the path on BOTH sides when the recorded template declares nothing, scoped per resource through the same seamAWS::ApiGatewayV2::Integration'sTlsConfiguses (#1602); a table that DOES declareWarmThroughputis compared normally. Every assertion was mutation-probed against the real source line it targets, and the whole fix was live-tested through the realcdkd driftCLI against a hand-seeded state file (no deploy — thepropertiesbaseline this class needs cannot be produced by one), where deleting either half reproduced its phantom drift verbatim. Two residuals the review surfaced are filed rather than ridden along: the GSI / LSI blobs are still emitted verbatim, soItemCountalone drifts every in-use table with an index (issue #1767 — wider than this fix and needing its own migration story), and a DECLAREDWarmThroughputAWS has since grown is unrevertable, since--revertissues a decrease AWS rejects (issue #1768). - S3: the lifecycle block now round-trips whole — legacy singular transitions, the rule-level expiration reshapes and the sent-but-unrecorded
Filter— and a malformedEventBridgeEnabledstops silently ENABLING notification delivery (issues #1754 / #1755 / #1759) —src/provisioning/providers/s3-bucket-provider.ts,tests/unit/provisioning/s3-bucket-provider-{never-emitted-spellings,eventbridge-notification,effective-properties}.test.ts,tests/integration/s3-lifecycle/**,scripts/gen-nested-key-coverage.ts,tests/unit/scripts/gen-nested-key-coverage.test.ts. The three residuals the (#1748) / (#1751) PR filed rather than rode along, bundled because all three land in the same two blocks and the same recording-vs-wire decision. #1755 is the widest: it fires on an ORDINARY CDK-shaped lifecycle rule using no cdkd tolerance at all —ExpirationInDays/NoncurrentVersionExpirationInDayswere recorded against anExpiration/NoncurrentVersionExpirationreadback, and the empty-prefixFilterthe applier SENDS for a scope-less rule was recorded not at all — so state and the readback carried different KEY SETS for essentially every lifecycle-configured bucket CDK creates. The expiration half is fixed on the READBACK side (readLifecyclenow emits the three rule-level CFn scalars the registry schema actually declares; recording the SDK shape instead would put a spelling in state that no template can declare), the other two on the desired side, andeffectiveLifecycleRuleis the ONE fold both the recorder andcanonicalizeDesiredPropertiesrun. The scope half is wider than the issue's headline because the same round trip also moves a bare top-levelPrefix, rule-levelTagFiltersand the rule-levelObjectSize*members underFilter;lifecycleRuleScope/lifecycleUsesFilterFormwere hoisted to module level so the fold and the applier decide V1-vs-V2 through the SAME functions rather than two copies — the decision is made across the WHOLE rule list, so a pure per-rule fold could record aFilterthe wire never sent. #1754 — the legacy singular actions.Transition/NoncurrentVersionTransitionare folded into the plural array by sharingmergeLegacySingularitself, so the readback's only spelling is what state records. The collision arm is the reason it needed its own decision, and the answer is NOT a no-op callback: on aStorageClasscollision the wire drops the singular and WARNS, so folding both sides identically would make the comparison equal,update()would never be called again, and that warning would stop after one deploy — a colliding rule is left COMPLETELY alone instead, keeping the difference visible. #1759 — the notification residuals. A malformedEventBridgeEnabled(null,'yes', an array, an unresolved intrinsic) took the ENABLE arm becausecoerceCfnBooleananswersundefinedandundefined !== falseis TRUE, so a value cdkd cannot read turned a notification feature ON on a LIVE bucket with no warning anywhere — the destructive-default class (#1595). It now runsconfigBooleanRefusaland takes the per-path answer: THROW on a template-path create, warn-and-SKIP on the replay-reachable update path, where the skip unit is the WHOLE notification configuration becausePutBucketNotificationConfigurationis a full replace (the caller retains the previous value, the #1612 UPDATE answer). #1430's polarity survives intact — a usablefalsestill emits no block, a usabletruestill emits{}— and thes3-lifecycleinteg asserts both on both phases. The stringly-typedEventBridgeEnabled: 'true'is now recorded as the BOOLEAN the readback emits, and a declared-but-EMPTY notification family is DROPPED from the record — MEASURED on the reverse-mapper rather than assumed, because the sibling shape answers the opposite way:readLifecyclealways emits{Rules: []}so the lifecycle placeholder is RECORDED (#1718), whilereadNotificationemits a family only when it is non-empty. MEASURED and deliberately NOT folded, stated rather than quietly omitted:readNotificationemitsEventBridgeConfigurationunconditionally, so a configuration declaring no EventBridge block is one key short of the readback — nothing was declared, so there is no spelling to normalize, and supplying it would record a key the template does not have on every notification-configured bucket (the shape #1723 records); #1430 already scopes that difference as a one-time, self-clearing drift. Three more divergences came from the PR REVIEW, all of the same shape and all fixed here — which is the unit-upgrade lesson one level below the whole-rule fence:Transitions[].TransitionDatewas only RENAMED by the alias fold while the applier sendsnew Date(...)and the readback emits.toISOString(), so'2030-01-01'recorded against'2030-01-01T00:00:00.000Z'— the identical class the rule-levelExpirationDatefold closes one member over;mergeLegacySingular.filter(isPlainObject)s the plural array UNCONDITIONALLY, so keying the fold on the singular's presence made junk-element filtering depend on an unrelated key; and the nestedExpiration.Datewas folded on PRESENCE where the applier reads TRUTHINESS, so a falsy date would have recorded1970-01-01. The review also asked for #1755's fourth bullet, the per-item notificationId, to be MEASURED rather than dropped: the applier sendsId: t['Id'], AWS generates one when none is declared, andreadNotificationemits it — an AWS-COMPUTED value that belongs inobservedProperties, and one that fails the (#1643) knowable-at-send-time test outright, so it is deliberately not folded and the measurement is recorded in-code and pinned by a row. Tests: 67 new rows on the round-trip fence, upgraded from per-key to WHOLE-RULE comparison — which is precisely why the #1748 rows missed these divergences — plus the arms that must NOT fold (a colliding singular, a refusedStatusorFiltercontainer, an unreadableExpiredObjectDeleteMarker, a marker colliding withDays, a non-array notification family), each with its reason. Two pre-existing rows that PINNED the destructive behavior are inverted into fences of the new one. Mutation-probed in TWENTY-TWO cuts against the real provider (each fold neutered in turn, the collision arm removed, the refusal gate short-circuited, the readback reverted,coerceCfnNumberdowngraded totypeof, the declared-nullguard narrowed to "declared and non-null", the junk-element filter keyed back on the singular, the date normalization dropped); every cut goes red. One probe came back GREEN and the ROW was FIXED rather than the probe re-tuned: an all-junkRulesarray is already short-circuited by the empty-list arm, so only a MIXED array exercises the element-count guard. Thes3-lifecycleinteg gains a malformed-value arm on a new bucket rather than a new fixture: phase 1 deploys a usablefalse, phase 2 replaces it with the string'yes', and the block must be absent in BOTH — pre-fix, phase 2 created one. The critic's withdrawn-name fence moved 27 -> 26 (TagFilters), measured against a scratch copy of the pre-change provider through the--providers-dir=seam:--checkreports byte-identical totals, and deleting a real wire write still fails by name, so the folds vouch for nothing. TWO folds write their member under a COMPUTED key for that reason (EventBridgeConfiguration,TransitionDate) — measured both ways, and the naive named-member form really does retire those names from the reverse-map exclusion. Live-tested against the REAL synthesized fixture templates (both phases, every bucket): the recorded bag and whatreadCurrentStateemits carry identical keys and values, the malformed-value arm refuses with a warning on the update path and throws on a create, and #1430's polarity holds on all four true/false buckets. A SECOND independent review round found one more defect of the identical class and one hazard:foldTransitionDatefolded on PRESENCE where the wire reads TRUTHINESS, so a declaredTransitionDate: 0recorded1970-01-01T00:00:00.000Zagainst a wire that sends no date at all — MANUFACTURING the phantom drift the fold exists to remove, and the same presence-vs-truthiness slip the commit that added it had just fixed for the nestedExpiration.Date; andcfnDateToIsowas timezone-dependent (new Date('Jan 1 2030')resolves LOCAL), which is harmless for the wire (the applier shares the dependence) but not forcanonicalizeDesiredProperties, a DIFF-side pure function that runs wherever the CLI does — a UTC runner and a JST laptop would fold the same template to different ISO strings and manufacture a diff neither machine can clear. The fold is now restricted to the forms ISO 8601 fixes the meaning of (bareYYYY-MM-DD, aZ/±HH:MMdate-time, an epoch number) and leaves every other spelling alone, with a row asserting twoprocess.env.TZsettings canonicalize identically. The one measured residual is key ORDER — the record keeps the template's, the readback the reverse-mapper's — which is pre-existing, unchanged here, and invisible tocdkd drift, whose baseline is captured from that same reverse-mapper; stated in-code rather than left to be re-discovered. No CLI flag, dependency, or state-schema change. - S3: the notification / lifecycle never-emitted spellings are normalized on both sides, and a malformed inventory
Enabledstops silently ENABLING a report (issues #1748 / #1751) —src/provisioning/providers/s3-bucket-provider.ts,src/provisioning/config-shape.ts,.claude/rules/providers.md,tests/unit/provisioning/s3-bucket-provider-never-emitted-spellings.test.ts,tests/unit/provisioning/s3-bucket-provider-substituted-properties.test.ts. The two residuals the (#1707) / (#1717) / (#1718) PR filed rather than rode along; bundled because both land in the same provider and the same recording-vs-wire decision. #1748 — never-emitted spellings. Where the provider accepts more than one spelling on the DESIRED side but its reverse-mapper emits only ONE, a record written in the other can never match the readback:cdkd driftre-reports it forever and--revertre-issues the same call, with no warning anywhere because nothing is malformed and nothing is substituted.effectiveNotificationConfiguration/effectiveLifecycleConfigurationare now the ONE helper the appliers andcanonicalizeDesiredPropertiesshare, folding each tolerated spelling onto the emitted one and REMOVING the tolerated key (not setting itundefined, which survives astructuredCloneand leaves theunionWalkObjectsdrift path seeing two key sets). The audit found more than the issue named, twice. Matching the(a['X'] ?? a['Y'])ALIAS form turned up THREE lifecycle aliases where the issue named one (TransitionInDays ?? Days/TransitionDate ?? DateonTransitions[],TransitionInDays ?? NoncurrentDaysonNoncurrentVersionTransitions[]), so fixing the reported key alone would have left its siblings broken. Then the ROUND-TRIP fence — assert the recorded bag against whatreadCurrentStateACTUALLY EMITS for the configuration just sent, never against a literal — failed immediately on a divergence neither the issue nor the audit had named:readNotificationemitted the SDK LISTEventswhile every template carries the CFn scalarEvent(the registry schema declares noEventsmember), so the record and the readback disagreed on EVERY notification-configured bucket rather than only on the rareTopicArn-spelled one. That half is fixed on the READBACK side, by ARITY — a single-element list is the CFnEvent, a longer one has no CFn spelling and staysEvents— mirroring whatreadLifecyclealready does forTransitionInDays; one-time drift on upgrade, the accepted cost the sibling reverse-map changes in that file already document. Two literal-based fences would both have passed. #1751 — inventoryEnabled. The one member of that item whose wire read coerced SILENTLY ((config['Enabled'] as boolean) ?? true), so a declaredEnabled: nullwent out astrue— ENABLING a report the template may have been disabling — while the record keptnull. Fixed on the WIRE, not in the fold: the issue's option 2, because defaulting a malformed value onto a LIVE inventory is the destructive-default class (#1595) already refuses at that item's string members. It now SKIPS the configuration item with a warning on the replay-reachable update path and THROWS on a template-path create, which is what makes the fold's presence test correct rather than making the fold mirror a bad read;configBooleanRefusal(new inconfig-shape.ts, alongside acoerceCfnBooleanmoved there from the provider) is the predicate, and it runs the very function the wire read calls. A CFn STRING boolean is NOT malformed — CloudFormation is stringly typed — so'false'is sent COERCED and recorded coerced, the lossless-coercion arm of (#1633) that meets the (#1643) bar. A review-class finding shipped with it: the twin was first wired as{ ...out, LifecycleConfiguration: folded }, andgen-nested-key-coverage's write-evidence walk recognizes that named-member spread as a whole-blob HAND-OFF (its #1475 recognizer) — so a DIFF-side pure function vouched for the forward mapper and retired three reviewed allow-list entries (LifecycleConfiguration.TransitionDefaultMinimumObjectSize,…Rules.TagFilters.Key/.Value), i.e. silently switched the write pass off for that subtree while the critic's only complaint was that those entries were now "stale". A COMPUTED key (canonicalizeBlock) restores it; measured both ways through the critic's--providers-dir=seam, and the general lesson is recorded in.claude/rules/providers.md: when a critic calls an allow-list entry stale, first ask what NEW evidence made it resolve. Tests: 32 new rows plus a rewrittenEnabledblock (the old rows CHARACTERIZED the pre-fix behavior and are inverted into fences of the new one), each paired with counter-cases — a CFn-spelled template assertseffectivePropertiesis ABSENT, a nullish first spelling still falls through to the alias, a multi-elementEventsis left alone, a SKIPPED lifecycle Put keeps the previous value. Mutation-probed in SEVEN cuts against the real provider (silent coercion restored, each fold neutered, the readback reverted toEvents,deleteswapped forundefined, the twin unwired, a skipped Put folded anyway); each fails only its own rows. Two residuals filed rather than ridden along: the legacy singularTransition/NoncurrentVersionTransitionMERGE (issue #1754) and the RULE-levelExpirationInDays/NoncurrentVersionExpirationInDays/Filterdivergences the same fence measured (issue #1755), so "the aliases converge" is not mistaken for "the block converges". No CLI flag, dependency, or state-schema change. - A warn-and-SKIP delete is no longer counted and printed as
deleted(issue #1752) —src/types/resource.ts,src/types/deployment-events.ts,src/utils/resource-line.ts,src/provisioning/composite-id.ts,src/provisioning/providers/{glue,appsync,ec2}-provider.ts,src/cli/commands/{destroy-runner,destroy,state}.ts,tests/unit/cli/destroy-runner-skipped.test.ts,tests/unit/provisioning/composite-id-delete-skip-outcome.test.ts, plus cases intests/unit/{utils/resource-line,cli/destroy,cli/state-destroy}.test.ts.ResourceProvider.deletereturnedPromise<void>, so the destroy runner's only success signal was "did not throw" — and the five malformed-composite-physicalId arms (AWS::Glue::Table,AWS::AppSync::{DataSource,Resolver,ApiKey},AWS::EC2::NetworkAclEntry) return normally after issuing NO AWS call. The run therefore printed✓ <id> (<type>) deleted, counted the resource towardN deleted, DROPPED its state record, and exited 0, over a resource that may still be alive and billing — found by the #1657 live test, which reportedStack Cdkd1657Verify destroyed (3 deleted, 0 errors)having deleted nothing.deletenow returnsPromise<void | ResourceDeleteResult>;voidstill means deleted (so none of the ~80 other providers changed), and an arm that could not ADDRESS the resource returns{ outcome: 'skipped', reason }via the sharedcompositeIdSkipResult(). The runner then prints⚠ <id> (<type>) skipped (<reason>)— a yellow warning glyph, the one op that is neither the✓success line nor the✗ Failed to deletefailure line — counts it in a newskippedCount, emits aRESOURCE_SKIPPEDdeployment event (noerror: nothing was attempted), and renders(2 deleted, 1 skipped, 0 errors). Two decisions the issue left open: state RETENTION — a skipped resource stays inremainingResources, sostate.jsonis preserved with its record intact, because dropping it is the second half of the data loss (the user would have neither the AWS resource deleted nor an id to delete it with); and the EXIT CODE —cdkd destroy/cdkd state destroynow raisePartialFailureError(exit 2), which is not a new policy but the existing "state preserved, stack not destroyed" contract thaterrorCount > 0and a graceful interrupt already carry. The idempotent*NotFoundarms are untouched and still count asdeleted(the resource IS gone), which is why the issue's three-value'already-absent'sketch was reduced to two: no call site produces it and no consumer would treat it differently. A run with zero skips is byte-identical to before — theskippedsuffix renders only when non-zero. Every new test was mutation-probed (9 probes across the runner branch, thepreserveStateclause, the retry-loop capture, the line formatter, three provider arms and both exit-code branches; all went red). Nested stacks are covered too, which the 3-axis review caught as a HIGH gap:NestedStackProvider.deleterecursed intorunDestroyForStackand DISCARDED the child's result, so a skip inside a child re-created the identical mis-report one level up — the parent printed✓ <Child> (AWS::CloudFormation::Stack) deleted, dropped the child's row and exited 0 while the child's ownstate.jsonsat there preserved describing a live resource; it now returns{ outcome: 'skipped' }naming the child and its count. (errorCountis swallowed by the same call and is deliberately left alone — its answer is a THROW, a behavior change with its own blast radius.) Four more review findings shipped with it:ResourceDeleteResultis a DISCRIMINATED union soreasonis REQUIRED on'skipped'(a skip whose line reads a bareskippedis barely better than thedeletedit replaced) — the one guarantee no runtime test can fence, so it carries the repo's first*.test-d.ts;RUN_FINISHEDgainedcounts.skipped, without which a skip-only run recordedresult: 'FAILED'withdeleted: Nand nothing named as failed;cdkd eventsrendersRESOURCE_SKIPPEDyellow instead of the neutral cyan it shared withRESOURCE_STARTED(RESOURCE_RETAINEDdeliberately stays neutral — keeping THAT resource is the user's own instruction); and the warn text was re-scoped, because the same message is emitted by the deploy engine and rollback executor, where the record IS still dropped and the remedy it now names (cdkd state orphan) would be impossible. A review round then closed six more gaps in the same shape:NestedStackProvideralso propagates the child'sinterrupted(a SIGINT mid-child is the identical data loss reached through another field — with the child'serrorCountat 0 the parent'spreserveStateis FALSE, so it deleted the parent state.json AND the exports index and exited 0 while the child's preserved state.json described live resources);cdkd eventsnow RENDERScounts.skippedas⚠N(it was emitted but never displayed, so the text surface still showed a failed run naming nothing that failed — the symptom the field exists to remove) and renders a new per-eventreason, because the events store is the durable post-mortem and a bareRESOURCE_SKIPPEDthere cannot say why; the summary now names the state file the user must actually open, which for a nested-stack skip is the CHILD's (<parent>~<child>) rather than the parent's; the run-level exit message counts ENTRIES rather than resources, since a skipped nested-stack row is one entry however many child resources it covers and "N resource(s)" was false in exactly that case; the composite-id warning now names the plain template-removal DELETE (the most common deploy-side reacher) alongside the replacement / rollback ones; and three contract comments that still claimed a skip means "no AWS call was issued" were corrected — withNestedStackProvideras a second producer the invariant is "this row was not destroyed", and the child's siblings WERE deleted first. Deliberately out of scope, all filed:deploy-engine.ts/rollback-executor.tsdiscard the return value entirely, so the deploy-time / replacement / rollback deletes still mis-report — issue #1762; eight same-class warn-and-SKIP arms OUTSIDE the composite-id family (Lambda layer / permission, Custom Resource, IAM policy / user-group) still return barevoid— issue #1770; the child'serrorCountis still swallowed byNestedStackProvider.delete, whose answer is a THROW with its own blast radius — issue #1777; and six further delete-result discarders (thecloud-control-provider.tsself-cleanup / ASG delegation pair plus four intra-provider REPLACE paths, where a skip would orphan the old resource or leave a duplicate SNS subscription delivering every message twice) — issue #1778. None of those six is NEWLY reachable from this PR: each delegates to a provider that is not a skip producer. Together that is why this PR does not claim the whole class the issue describes is closed. -
cdkd exportstops sending cdkd's composite physical id to CloudFormation IMPORT, and refuses non-importable types up front (issue #1659) —src/cli/commands/export.ts,tests/unit/cli/export-composite-identifier.test.ts,docs/cli-reference.md,docs/state-management.md,docs/troubleshooting.md,README.md. The issue's titled premise is falsified and did not ship. It namedAWS::Glue::Tableas the victim; live measurement (us-east-1, re-verified 2026-08-13) shows CloudFormation rejects that TYPE before it ever evaluates an identifier (ProvisioningType: NON_PROVISIONABLE, nohandlersblock, soCreateChangeSetanswersResourceTypes [AWS::Glue::Table] are not supported for Import), which makes all three of the issue's candidate directions dead code for it. What DID ship is the issue's direction 2 applied to the four types its sibling audit found, plus a pre-flight for the class Glue::Table actually belongs to. (a) Composite physical id, single-field CFn identifier.resolveResourceIdentifierbranched on the CFnprimaryIdentifier's field COUNT alone, and its single-field arm assumed "cdkd's physicalId IS the identifier value" — true for a scalar id, silently WRONG for a composite one, so anAWS::S3Tables::Tablewas imported as{TableARN: '<bucketArn>|analytics|events'}.COMPOSITE_ID_SPLITTERScould not fix it (that table is only consulted on the multi-field arm) and a split would not have helped anyway: the correct identifier is a different value entirely. A newCOMPOSITE_PHYSICAL_ID_IDENTIFIERStable is consulted BEFORE the arity branch, separating "cdkd's id is composite" from "the CFn identifier is multi-field", and resolves the value from the ARN the provider RECORDED —TableARNforAWS::S3Tables::Table,DataSourceArn/ResolverArnfor the twoAWS::AppSync::*children (recorded since #1681), with a bare-ARN physicalId accepted as the second shape becauseS3TablesProvider.importTablerecords exactly that for a Cloud-Control-routed table. An unrecorded ARN blocks the resource with a re-deploy-to-heal message rather than shipping a wrong identifier; a schema whoseprimaryIdentifierno longer matches the registered field refuses by name.propertiesOverlayis empty for all of them (every field isreadOnlyProperties, measured live).AWS::EC2::SecurityGroupIngressis the fourth member and is REFUSED: CFn identifies a rule by thesgr-...id AWS mints and cdkd records nothing carrying it, so the message points at #1761, which tracks the attribute-recording work inec2-provider.ts. (b) Pre-flight refusal for types CFn cannot IMPORT.NEVER_IMPORTABLE_TYPES' comment deliberately deferred to theCreateChangeSeterror; that decision is revisited, because the error is not exhaustive — a probe carrying three unsupported types named two of them, and a two-type probe named one, so the user got a fix-one-rerun loop after cdkd had already acquired the stack lock, prompted, and preprocessed the template.buildImportPlannow derives the verdict from the registry schema it ALREADY fetches for the identifier (no hand-maintained list) and blocks every offender in one pass, before the lock. The predicate requires TWO agreeing fields — noreadhandler ANDProvisioningType: NON_PROVISIONABLE— so a partial or unusualDescribeTyperesponse falls back to letting AWS answer rather than refusing an export that works today; the fallback identifier table likewise reportsunknown. Measured live:AWS::Glue::Table/AWS::Route53::RecordSet/AWS::Route53::RecordSetGroup/AWS::AppSync::ApiKey/AWS::EC2::NetworkAclEntry/AWS::SQS::QueuePolicy/AWS::SNS::TopicPolicyall satisfy both and are all rejected by AWS;AWS::S3::Bucketsatisfies neither and passes. The check runs AFTER theIMPORT_UNSUPPORTED_RECREATABLE_TYPESbranch, sinceAWS::IAM::Policyalso lacks areadhandler and cdkd has a real answer for it (pre-delete + phase-2 CREATE). One sibling splitter had to ship with it: the live test foundcdkd exportaborting on EVERY S3 Tables stack withcomposite primary identifier (2 fields: TableBucketARN, Namespace)—AWS::S3Tables::Namespacehad noCOMPOSITE_ID_SPLITTERSentry, and since CDK'sCfnTablerequires a namespace resource, theAWS::S3Tables::Tablefix above was unreachable in practice without it. It splits<tableBucketARN>|<namespaceName>in CFn identifier order and keeps the whole-mappropertiesOverlay(liveDescribeTypereports noreadOnlyPropertiesat all for the type and both fields are plain writable strings the synth template already carries). Review round 2 added three things worth naming. The recorded-attribute arm — the one that fires on essentially every export — did NO shape validation while the rare bare-physicalId fallback did, so a hand-edited row, an older binary's row holding the composite under the attribute name, or another service's ARN would each have been shipped as the IMPORT identifier and adopted the WRONG resource; both arms now share one predicate (arn:prefix + the type's own service segment + no|, since the S3 Tables composite is built FROM the bucket ARN) and both trim on return.buildImportPlanand theblockedhard-fail now run BEFOREacquireLockon the single-stack path — the docs claimed that and the code did not, so a stack that could not be exported at all first locked out a concurrentcdkd deploy; planning issues no AWS write, and the nested-stack path had always planned before locking. And--skip-import-support-preflightis the escape hatch the refusal was missing: the verdict is a registry HEURISTIC rather than AWS's published supported-for-import list, andblockedaborts the whole run, so a type AWS later makes importable would otherwise be unrecoverably blocked until a cdkd release (its neighbourIMPORT_UNSUPPORTED_RECREATABLE_TYPEShas--no-recreate-import-unsupported; this is the counterpart). Two follow-ups were filed rather than folded in: #1771 (AWS::EC2::Route/::EIP/AWS::Lambda::EventInvokeConfigare composite-identifier types with no splitter, so every VPC stack with a route is un-exportable today — the same class the Namespace entry closed) and #1772 (AWS::ApiGatewayV2::Stagenow reportsFULLY_MUTABLEwith areadhandler and a 2-field identifier, contradicting the two facts recorded as the reason it takes the downtime-incurring pre-delete + re-CREATE path; the measurement is recorded in-code and nothing is changed on it). Tests: 40 new cases, every one mutation-probed. Both arms of each polarity decision are fenced separately, including the one where the two pre-flight signals DISAGREE — no type published today has areadhandler while reportingNON_PROVISIONABLE, so the fixtures made the signals coincide and a probe showed the read-handler half was unfenced until an explicit arm was added. No CLI flag, dependency, or state-schema change. - The account-info cache stops pinning the first caller's region, and four more sites stop hardcoding
amazonaws.com(issues #1746 / #1745) —src/deployment/intrinsic-function-resolver.ts,src/provisioning/providers/cognito-provider.ts,src/assets/docker-asset-publisher.ts,src/assets/asset-redirect.ts,tests/unit/deployment/account-info-region-independent-cache.test.ts,tests/unit/provisioning/cognito-provider-url-suffix.test.ts,tests/unit/assets/partition-url-suffix.test.ts. Both are residuals of the two 3-axis review rounds on PR #1743, bundled because both land in the same cross-cutting resolver file. #1746 item 1 — the cache.cachedAccountInfostored a wholeAwsAccountInfo, so the FIRST caller'sresolverRegion(orAWS_REGION) was frozen and a later caller passing NOoverrideRegioninherited it. That was pre-existing forregionalone; PR #1743 madepartitionDERIVE from the region, so the stale region started dragging a stale partition with it — a first call from acn-north-1-scoped resolver cached{region: 'cn-north-1', partition: 'aws-cn'}and a subsequent no-override call from a us-east-1 context readaws-cn. The issue named two options; this takes the one it called cleaner, storing only the ACCOUNT.CachedAccountIdentity(accountId+ thefabricatedflag) is what the two caches now hold, andaccountInfoFor(identity, overrideRegion)derivesregion+partitionper call on the way out — for the process cache, the bounded fabricated-TTL window, AND the shared in-flight promise.resolveAccountIdentitytakes no region parameter at all, so the comment that the shared promise is safe across callers with different regions is now structural rather than a property to preserve, andwithOverrideRegionis gone (its whole job was patching a cached entry after the fact). #1746 item 2 — the guard.constructGuardedAttribute's fabricated-account refusal testedtypeof value === 'string'. Every account-bearingconstructAttributebranch returns a string today — the only non-string returns are the EC2 IPv6 CIDR lists, which carry no account — so this is hardening, not a live defect: the extractedembedsAccountIdalso walks arrays of strings, so a future list-valued attribute embedding an account cannot bypass the guard with no test failing. It is exported for its own test precisely because the array arm is unreachable through the public resolver API today. #1745 — the URL suffixes.derivePartitionAndUrlSuffix(region)returns aurlSuffixthat isamazonaws.com.cninaws-cn(andc2s.ic.gov/sc2s.sgov.govinus-iso*), and #1743 routedAWS::URLSuffix/AWS::StackId/ both ECRRepositoryUrisites through it. Four more sites still hardcoded the commercial suffix and emitted a hostname that does not resolve elsewhere — structurally valid, so nothing downstream catches it:CognitoUserPoolProvider'sProviderName/ProviderURLon BOTH create and update (fixed together, or an update would rewrite a correct suffix into a commercial-only one), all six ECR registry URIs inDockerAssetPublisher(the four push targets the issue lists, plus theecrLogincache key and the login ENDPOINT fallback the issue's grep missed — that one is whatdocker loginactually receives when AWS reports noproxyEndpoint), andasset-redirect.ts'sevaluatePseudoParam, whoseAWS::URLSuffixarm returned a hardcoded string under a comment claiming to mirrorIntrinsicFunctionResolver— a claim #1743 had just made false, so a foldedFn::Joinproduced an unresolvable host. The S3 trio is deliberately NOT in this change and #1745 stays open for it:DomainName/RegionalDomainName/WebsiteURLspan the resolver ANDs3-bucket-provider.ts, which an in-flight lane (PR #1744) owned, and the issue itself requires both halves to move together or theFn::GetAttanswer disagrees withreadCurrentState(the phantom-drift shape).WebsiteURLadditionally needs thes3-website-<region>vss3-website.<region>spelling verified per partition rather than spliced. Tests: 19 cases, every one paired with a commercial counter-case asserting BYTE-IDENTICAL output, which is what makes the change shippable without a non-commercial account to test against. The cache fix was mutation-probed by re-introducing the pre-#1746 behavior (a module-local first-region pin) into the real source: exactly the three region-inheritance rows fail and the eight others survive. No CLI flag, dependency, or state-schema change. - Every composite-physicalId decode site states the EXPECTED format (issue #1657, reported by an outside contributor) —
src/provisioning/composite-id.ts,src/provisioning/providers/{glue,s3-tables,appsync,ec2,apigateway}-provider.ts,tests/unit/provisioning/composite-id-format-message.test.ts,tests/unit/provisioning/composite-id-decode-messages.test.ts,tests/unit/provisioning/composite-id-decode-message-sites.test.ts. The behavior. A type needing more than one value to address its resource packs them into one pipe-joined physicalId, and when that id is malformed the decode site has to say what it wanted. Roughly half of them did (expected "restApiId|resourceId|httpMethod", got "..."— API Gateway Method x2, EC2 VPCGatewayAttachment / Route / SecurityGroupIngress) and half only echoed the value back (Invalid Glue Table physical ID format: <value>), which tells the user nothing they did not already have: the format is documented NOWHERE else (the companion docs gap is issue #1656), so on the silent half the message was the only possible route to the answer and it did not carry it. The change addscompositeIdFormatMessage(format, logicalId, physicalId, options?)as the decode-side counterpart of the module's existingpackCompositeId/compositeIdSeparatorRefusalpack pair, and routes all eleven previously-silent sites through it — Glue Table update + delete, S3 Tables Namespace delete, AppSync DataSource / Resolver / ApiKey update + delete (six), EC2 NetworkAclEntry delete, and theapplyTableTagsDiffarm PR review found unrouted — PLUS the five that already got it right, so those cannot drift back, and S3 Tables Table, whose hand-written expected-format text now shares the wording. A per-typeCompositeIdFormatcarries alabeland orderedsegments; every segment is a PLACEHOLDER whose name must match the type'spackCompositeIdsegment names. The first cut had a literal-segment form used exactly once, renderingAWS::EC2::VPCGatewayAttachmentasIGW|<vpcId>— andIGWis a token no packer emits (the real value is theinternetGatewayId), so the message would have instructed a user repairingstate.jsonto writeDetachInternetGateway(InternetGatewayId: "IGW"). Two independent reviewers caught it; the literal form was DELETED rather than corrected, and the segment names now mirror the packers (ipProtocol,namespace).alsoAcceptscoversAWS::S3Tables::Table's bare-ARN alternative. It is a WORDING change only, deliberately. Each caller keeps its own split-and-guard: the arities genuinely differ (AppSync Resolver and EC2 NetworkAclEntry accept AT LEAST three segments; the others destructure the first N and tolerate extras — the #1672 ambiguity, tracked separately and claimed by another lane), so folding them into one predicate here would silently change which ids deploy while claiming to fix a message. The issue's secondary observation — five of the sites arelogger.warn+returnon the DELETE path, so a malformed record leaves the AWS resource ALIVE whilecdkd destroyreports success — is answered without changing the warn-and-continue policy: those sites pass{ skipping: true }and the warning now says the resource is LEFT IN PLACE and the destroy will still report success, so the orphan is diagnosable rather than invisible. Two rows of the issue's own table were already stale and the PR says so rather than inventing work:AWS::S3Tables::Tablehad gained its expected-format text, androute53-provider.ts's message no longer exists at all — PR #1678 removed that arm in favour ofparseRecordSetCompositeIdplus a template-derived fallback, so there is nothing left to fix there. Tests: 24 new cases across three files — the builder's own rendering (the single-segment no-dangling-separator case, blank-value quoting, and the skip clause's presence / absence), six behavioral cases driving the realAppSyncProviderthrough both the throw and warn arms, and a per-SITE suite pinning the EXACT message at each of the eight sites that had no behavioral coverage — which is what makes a copy-pasted format constant detectable, since review proved a constant swap previously passed the whole suite. Each arm is paired with a well-formed counter-case proving the guard still only fires on malformed input; three existing assertions that pinned the old wording were updated. Mutation-probed twice against the real source: restoring the pre-#1657 literal atdeleteResolverfails exactly that row, and swapping the Route site's constant for the SecurityGroupIngress one fails exactly the Route row. No CLI flag, dependency, or state-schema change. -
Fn::Substops laundering resolver REFUSALS into a literal${X.Y}, andgetAccountInfostops fabricating a partition (issues #1740 / #1730) —src/deployment/intrinsic-function-resolver.ts,src/utils/error-handler.ts,src/provisioning/cloud-control-provider.ts,tests/unit/deployment/intrinsic-sub-getatt-refusal.test.ts,tests/unit/deployment/account-info-partition-and-cache.test.ts,tests/unit/provisioning/cloud-control-fabricated-account-arn.test.ts. Bundled because both land in the same cross-cutting resolver file, which cannot host two parallel lanes. #1740 — theFn::Subcatch.${LogicalId.Attribute}resolves throughresolveGetAttinside a barecatch, so EVERY error was downgraded to a warning and the raw${...}text kept. Warn-and-keep is the deliberate answer for a genuinely unknown variable; it was also, silently, the answer for the three refusals the resolver raises ON PURPOSE —guardedPhysicalIdFallback's*Arn/*Urlshape hard-fail (the #1103 class), the--strict-getattrejection, and #1729'srejectPlaceholderArnAttribute. So the identical reference hard-failed in a resource property and degraded to a literal${Resource.Attribute}shipped to AWS by a green deploy when written inside anFn::Sub, with a warning (not found) naming the wrong cause and discarding whatever remedy the refusal carried. Now those three sites throwIntrinsicResolutionRefusalError(a newCdkdErrorsubclass whose ONLY consumer is this catch) and the catch re-raises that class; a genuine miss still keeps the placeholder, and its warning carries the underlying reason instead of assertingnot found. TheRefarm re-raises a refusal only when the variable has NO.— with one,Refis a speculative first attempt whose miss is how theGetAttinterpretation is reached. Where the intrinsic sits still decides what the user sees, and the PR body says so rather than over-promising "it now fails": in a resource property the throw fails the resource, while in a stack Outputdeploycatches it per-output and exits 0 withkeeping the previously persisted outputs(the pre-existing best-effort Output policy, deliberately unchanged — the win there is that the output is not UPDATED with a wrong value, loudly). #1730 —getAccountInfo. Two independent defects. (a)partitionwas hardcoded'aws'with an in-code comment admitting it (// Could be aws-cn, aws-us-gov, etc.), on the success AND the STS-failure path, so every ARN built from it was wrong outside the commercial partition — this module's ownFn::GetAttconstruction plus fourCloudControlProviderenrichment sites (KMS key, ECR repository x2, Kinesis stream) — and structurally valid, so nothing downstream could catch it. It now derives through the sharedderivePartitionAndUrlSuffix(region), INCLUDING on the paths that swap in a caller's override region:partitionis a function ofregion, so a cached entry handed back under acn-override would otherwise carry the partition derived for the cached one (withOverrideRegion, and the review-caught case a naive fix misses). PR #1727's workaround can now read the field, though preferring the helper where a region is in hand still avoids an STS hop. (b) The FABRICATED result (the hardcoded123456789012fallback) was cached for the process, so ONE transient STS blip poisoned every later caller in the run. Only a non-fabricated answer is cached now, mirroringwrite-only-properties.ts's "only SUCCESSFUL lookups are cached"; an operator-suppliedAWS_ACCOUNT_IDis a real answer to "which account" and is still cached as one. The fourCloudControlProviderARN sites additionally OMIT the attribute whenfabricatedis set (accountInfoForSynthesizedArn), mirroringAppSyncProvider.childImportAttributesfrom #1728 — an ARN built from the placeholder account carries no wildcard, so #1681'sisPlaceholderArncannot catch it and the value would be RECORDED into state and served as the resource'sFn::GetAttanswer, indistinguishable from a real one; the resolver's ownguardedPhysicalIdFallbackthen hard-fails an*Arnread with a message naming the cause, and the record heals on the next update. Directions (1) and (2) of #1730 — making the fabricated ACCOUNT ID itself loud, or refusing the deploy outright — remain open, so that issue is NOT auto-closed. The 3-axis review round found one BLOCKER and it is the most instructive part of this entry: the resolver-side refusal matched the colon-delimited:<accountId>:an ARN uses, butAWS::ECR::Repository'sRepositoryUriembeds the account with NO colons (<acct>.dkr.ecr.<region>.amazonaws.com/<repo>) — the single such site inconstructAttribute— so the resolver served the fabricated URI and silently NULLIFIED theCloudControlProvideromission of that same attribute. The match is a bare substring now, deliberately fail-SAFE (a value merely CONTAINING the placeholder digits is refused rather than served, and only while STS is failing). Review also caught a regression this change introduced: dropping the fabricated cache entirely madegetAccountInfore-issueGetCallerIdentity— with the SDK's own 3-attempt retry, plus one warning — on EVERYFn::GetAttand everyAWS::AccountId/AWS::Partition/AWS::StackIdpseudo-parameter, i.e. hundreds of calls on a large stack. A fabricated answer now gets a bounded 10s window (accountInfoClockis the test seam) instead of nothing, PLUS a sharedaccountInfoInFlightpromise so the parallel case collapses too — the TTL alone only helps SEQUENTIAL callers, andcdkd deploy --concurrency 10resolves ten resources' intrinsics at once. Together that is what "not cached the way the success result is" actually needs to mean. Two more review items rode along: ECR's registry host (<acct>.dkr.ecr.<region>.amazonaws.com) hardcoded the URL SUFFIX at both the resolver andCloudControlProvidersites —amazonaws.com.cninaws-cn, the identical defect one field over from the partition, at the very attribute the blocker was about — andAWS::URLSuffixwas issuing agetAccountInforound trip for a value that is a pure function of the region. Three smaller review items shipped in the same round:AWS::StackIdandAWS::URLSuffixwere the identical hardcoded-partition defect one site over (arn:aws:cloudformation:…and'amazonaws.com'inaws-cn) and now derive; a deadwithOverrideRegioncall on the fresh-resolve path was removed (regionis alreadyoverrideRegion || …, so the branch could never be taken — and one of the claimed mutation cuts was therefore not probing anything); and two comments insrc/utils/aws-partition.ts/appsync-provider.tsstill asserted the field this change fixes is hardcoded, which reads as a license to revert it. Tests: 60 cases across four files, each paired with counter-cases (a valid${X.Y}still substitutes; an unknown attribute with noArn/Urlsuffix still falls back to the physical ID; a real account still enriches, including acn-north-1ARN underaws-cn), since a test that only asserts the refusal cannot tell a precise guard from one that broke the deliberate warn-and-keep. Mutation-probed against the real source in TWELVE separate cuts — the twoinstanceofre-throws, the success-path / override-path / failure-path partition derivations, the fabricated-caching skip, the CC refusal, theconstructAttributerefusal, the colon-vs-bare account match, the TTL window, theStackId/URLSuffixderivations, the dotted-branch warning wording, the ECR URL-suffix derivation and the in-flight dedup — every cut fails only its own rows and every counter-case survives. No CLI flag, dependency, or state-schema change. - The nested-key critic now audits the three MIXED-CASE SDK clients (issue #1393 item 3) —
scripts/gen-nested-key-coverage.ts,tests/fixtures/cfn-schemas/AWS-{Events-Rule,Scheduler-Schedule,Glue-*}.json,docs/_generated/{nested-key,enrichment,sdk-attr}-coverage.{json,md},tests/unit/scripts/gen-nested-key-coverage.test.ts. Item 1 of #1393 namedclient-eventbridge/client-scheduler/client-glueas the evidence that a single per-targetkeyStylehides camelCase ISLANDS inside a PascalCase model (awsvpcConfiguration,capacityProvider,type/field/expression,scanRate/scanAll) — and the deeper gap was that those types were not audited at all, so the #1381 EventBridge / Scheduler Fargate-target create failures and the Glue CrawlerscanRatedrop had no fence behind their fixes. Nine types are now registered asNESTED_KEY_TARGETS, following the shape PR #1694 set forAWS::Lambda::EventSourceMapping:AWS::Events::Rule(76 audited paths),AWS::Scheduler::Schedule(47), and the seven auditable Glue types —::Table(88),::Crawler(46),::Connection(37),::Trigger(16),::Database(15),::SecurityConfiguration(9),::Job(7). 341 new audited paths, 0 blocking findings, 0 allow-list entries: the 19 non-same-spelling verdicts are allprovider-handled, each verified against the conversion that handles it rather than taken on the literal's word (toSdkEcsParameters/toSdkTargetfor the ECS-target islands,CFN_TO_SDK_DYNAMODB_TARGET_KEYSforScanRate,buildEncryptionConfigurationfor the CFn-pluralS3Encryptions→ SDK-singularS3Encryptionrename, andenforceIcebergTableInputAbsentforOpenTableFormatInput.IcebergInput.IcebergTableInput, where the handling is a pre-flight REFUSAL rather than a conversion).keyStyle: 'exact'is the load-bearing declaration: it is what routes each island to the case-insensitive near-miss bucket instead of lowercasing every PascalCase member past the test, which is the item-1 blind spot. None of the nine opts into the write-evidence pass yet, and the calibration test pins the measured forced-on numbers (Table36,Database11,Trigger9,SecurityConfiguration2, the other five 0) — but review established those four do NOT mean the same thing, and the first cut of this entry wrongly claimed they did.TableandSecurityConfigurationare builders that DO name their full member set, andTriggeris not a builder at all — itsPredicate/Actions/EventBatchingConditionare verbatimproperties['X'] as <SdkType>forwards, so the write pass has nothing to ask of it. (Stating one shape for the whole file was wrong twice: a blanket "they forward whole blobs" first, then a narrower sentence that still mis-filed Trigger. The file genuinely mixes both.)Database11 is a REAL SILENT DROP:buildDatabaseInputis a fresh-object builder naming onlyName/Description/LocationUri/Parameters, while CFn and the SDK'sDatabaseInputboth also declareTargetDatabase,FederatedDatabaseandCreateTableDefaultPermissions— none of which appears anywhere insrc/, so a template setting any of them is dropped today. Same spelling on both sides means the KEY pass structurally cannot see it (this is #1393 item-5 territory); only the write pass can. The opt-in is deliberately deferred to the provider fix rather than shipped with 11 allow-list entries, because an allow entry would silence a CI-blocking bucket for a live drop — the one thing this critic's design refuses. The 11 paths are instead pinned BY NAME inGLUE_DATABASE_DROPPED_PATHSplus a test, so fixing the provider fails that test and forcesfreshObjectMapper: truein the same change.AWS::Glue::Workflowis the one registered Glue type deliberately left out — its schema has no nested property at all, so a target would carry aminNestedKeysfloor of 0 and fence nothing. Its fixture was re-captured for this: review caught that assertingnestedPropertyPaths === undefinedagainst the STALE capture was unfalsifiable, since that field did not exist before 2026-08-10 and every stale fixture lacks it regardless of shape. The test now dates the capture past both extensions (generatedAt >= '2026-08-10') and asserts#topisdefinitionShapes' only definition — the latter is the real discriminator, sincedefinitionShapesalone proves nothing (it landed 2026-08-09, one day beforenestedPropertyPaths, andAWS-ECS-Cluster.jsonis a live capture with the first and without the second). A committed sibling control additionally guards against a broken re-capture sweep. Prerequisite worth repeating from #1694: opting an EXISTING type in needsnode scripts/refresh-cfn-schemas.mjs <TypeName>with an explicit type argument —--only-missingskips any type that already has a fixture file, and the older captures predate thedefinitionShapes/nestedPropertyPathssections the generator requires. Two false gaps disappeared as a side effect of the re-capture, the same way they did in #1694: the fixtures now carryprimaryIdentifier, whichgen-enrichment-coverageandgen-sdk-attr-coverageauto-classify as not-a-gap, soAWS::Events::RuleArnand theGlue::{Connection,SecurityConfiguration,Table}Idattributes stop being reported as SDK-fallback gaps they never were (98 down from 102). Three known BOUNDS are recorded in the header and pinned by tests rather than left for a reader to discover. (i) The seven Glue targets share one 8-class provider file, so the file-global literal rescue draws on a pool spanning seven resource types — demonstrated by injection: aScanRateunder::Jobis cleared by the CRAWLER class's literal and anIcebergTableInputunder::Crawlerby the TABLE class's, while a control name correctly flags, and the pool sizes are ASSERTED in the test rather than written in prose (an earlier revision exported a descriptive string carrying them, which nothing read and which would have drifted the first time the file grew; it is gone). Scoping the pool to the enclosing class is NOT done here:collectStringLiteralsis consulted by all 24 targets, including the 14 opted-in ones whose floors and all 22 allow-list entries were calibrated under file scope, so it is a re-measurement of the whole table — and it is precisely the un-scoped remainder of #1393 item 2, which PR #1574 closed only for write-evidence targets. (ii) The key pass matches SDK member names flatly, so CrawlerScanAll(onlyMongoDBTargetspells it PascalCase) and RulePlacementConstraints.Type/PlacementStrategies.Typeare cleared by unrelated interfaces and visible only in the shape pass. (iii) The shape backstop is not total: six CFn definitions on this group have no same-named SDK interface (CrawlerTargets, DatabasePrincipalPrivileges, JobDefaultArguments/NonOverridableArguments, TableIcebergTableInput/SerdeInfo) and are audited by neither pass — pinned exactly so the set cannot grow silently. (iv) The critic fences literal PRESENCE, not conversion CORRECTNESS, and this is the widest of the four:provider-handledmeans the provider NAMES the CFn spelling somewhere the literal collector can see, and nothing checks what it then does with it. Measured on the real tree — replacingEventBridgeRuleProvider.renameItemKeys's whole body withreturn item, so every ECS-target island stops being converted while every call site keeps naming the keys, leaves the shipped--checkat exit 0. The provider-side RED probes therefore delete the CALL (or the whole rename map) rather than the conversion body. The same bound is why the Glue probe must delete BOTH rename directions: the reverse map keeps the CFn spelling alive as a literal, which is the #1448 reverse-map asymmetry (the write pass excludes theread*/*ToCfnfamilies, the literal collector does not) — explicitly NOT bound (i), since both maps are module-level consts in the same file and class-scoping would not change it. Closing (iv) needs the shape-aware v2 of #1378. Tests: 9 real-repo assertions (per-type headroom and zero findings in each blocking bucket; the write-pass opt-out and theexactkeyStyle; the per-path island credits including theScanAllkey-vs-shape split; a per-BLOB path floor for all 21 blobs of the nine new targets (21 is the blob count; 24 is the TARGET count, and all 24 targets have 115 blobs, which this fence does not claim), since the aggregate floor lets::Crawlerlose all four non-Targetsshapes and::Schedulerits wholeFlexibleTimeWindow; a SECOND-LEVEL family floor for all 42 composite families, because the blob floors themselves carry 8-14 paths of slack on the three big blobs (Rule.Targets74/60,Scheduler.Target45/36,Crawler.Targets40/32) and an entire family fits inside it — including all eight crawler-target families, of which the 4-pathTargets.DynamoDBTargetsis thescanRateisland's own home; the 11 pinned Glue Database drops; theunmatchedDefinitionsset; the literal-bleed bound; and the Workflow exclusion) and 10 RED probes: 7 throughloadReport'sfixtureDirseam covering both blocking buckets across all three clients, and 3 through the SHIPPED--checkagainst a scratch copy of the REAL providers tree — deleting EventBridge's and Scheduler'sCapacityProviderStrategylowercasing and Glue'sscanRaterename bridge each exits 1 naming the re-bucketed paths. The provider-side trio is what proves theprovider-handledcredits above, per the repo rule that a checker must be shown to FAIL against real code; the fixture-seam probes alone are the weaker form. Tooling-only, nosrc/**diff, so no live-AWS test applies. Still open on #1393: item 5 (needs the shape-aware v2 of #1378), and item 3 for the remaining candidate types —AWS::EMR::Cluster(+ InstanceGroupConfig / InstanceFleetConfig) is blocked on a real prerequisite, since PR #1399 moved its conversion intosrc/provisioning/emr-configuration.tsand the key pass's literal scan reads onlyproviderFilewithout following same-package helper imports, soConfigurationProperties/StepPropertieswould false-positive; plus CodeBuild siblings, DynamoDB GlobalTable, WAFv2, ECR, S3Vectors and Cognito. - Three partition-sensitive PREDICATES stop hardcoding
arn:aws:(issue #1815, the remainder after PR #1834's seven ARN builders) — unlike the seven builders, these change BEHAVIOR rather than a recorded string, and all three CLASSIFY an ARN the caller already holds, so the partition is read OFF THE ARN (aws[a-z0-9-]*, the shapeIAM_ROLE_ARN_REinsrc/utils/role-arn.tsalready uses) rather than derived from a region throughderivePartitionAndUrlSuffix— that helper answers "given a region, which partition am I in", which is the right question when BUILDING an ARN and the wrong one here, where no region reaches the call site at all. A closed partition list is exactly what goes stale when AWS adds a partition. (1)CustomResourceProvider.isSnsServiceTokenis the SNS-vs-Lambda ROUTING decision for the whole provider — it gatessendRequest(SNS publish + S3 poll vs synchronous Invoke), the delete path's backing-Lambda pre-check andrecycleBackingFunctionExecEnv— so anarn:aws-cn:sns:/arn:aws-us-gov:sns:ServiceToken was classified Lambda-backed and every one of those paths took the wrong branch. (2)IAMManagedPolicyProvider.import's AWS-managed-policy refusal keyed onarn:aws:iam::aws:, and AWS-managed policies exist under every partition (arn:aws-us-gov:iam::aws:policy/AdministratorAccessis a real, attachable ARN), so a GovCloud / ChinaAdministratorAccessfell through and was ADOPTED as customer-managed —cdkd destroywould then rundetachAllPrincipalsagainst every user / role / group in the account beforeDeletePolicywas (always) rejected; the miss direction here is the dangerous one. (3)lambda-eventsource-provider.ts'sclassifyEventSourcehand-enumeratedarn:aws:andarn:aws-cn:per service and so returned'unknown'for GovCloud, all four iso partitions andaws-eusc—'unknown'is in neitherKINDS_WITH_FUNCTION_RESPONSE_TYPESnorKINDS_WITH_SOURCE_ACCESS_CONFIGURATIONS, so the type-discriminator gating silently stopped emitting its placeholders andcdkd driftsaw a one-sided difference on every clean run; the per-servicestartsWithchain is replaced by one service-segment capture plus a lookup table. 24 new unit cases, every partition per site, each with the commercial (and for site 3 theaws-cn) counter-case asserted unchanged — a commercial-only test cannot detect any of the three — plus non-ARN / wrong-service negative cases so the widened partition segment cannot start matching things that are not event sources or SNS topics. Binding verified by restoring each hardcoded literal in turn and confirming only the corresponding cases go red.glue-provider.ts:2141/:2709stay open on the issue (owned by a concurrent lane).
Recently Implemented (2026-08-12):
- CI teardown-completeness guidance:
--remove-protection/--skip-final-snapshot/--purge-events(skill v0.6.0) —README.md(CI section),plugins/cdkd-skills/skills/cdkd/SKILL.md, manifests bumped to 0.6.0. The CI section's destroy-on-close flow silently left three kinds of residue after the close job: protection-enabled resources (RDS / DynamoDB deletion protection, EC2 termination protection, etc.) survive until the scheduled sweep;DeletionPolicy: Snapshotresources leave a final snapshot behind on EVERY close (cost + clutter that accumulates per PR); and the deployment-event history keeps the state bucket non-empty. Both the README (linking the--remove-protectionandDeletionPolicy: Snapshotsections) and the skill's CI bullets now cover the full teardown:--remove-protectionfor one-pass deletion,--skip-final-snapshotwhen the environment's data is disposable (the skill gates it on explicit user confirmation — it is a data-loss opt-out), anddestroy --purge-events/events prune <stack> --allfor a fully empty state bucket. Surveyed the full destroy option surface for completeness:-f/--forceis confirmation-skip only, and no DeletionPolicy-Retain override flag exists (template-side control by design), so nothing else belongs in the CI guidance. - The
AWS::DynamoDB::TableBillingMode warning stops asserting an unchecked mode, and names its resource (issue #1734) —src/provisioning/providers/dynamodb-table-provider.ts,tests/unit/provisioning/dynamodb-table-provider-billing-mode-shape.test.ts. Found by the round-5 test review of the PR closing #1683, which fixed both shapes inDynamoDBGlobalTableProviderand left the siblingAWS::DynamoDB::Tableprovider — same two shapes, different path — out of scope. Before: the update-pathrequireConfigStringonUnusablearm said "The table's current billing mode (X) is kept", where X isprevBillingMode— the RECORDED previous, not aDescribeTablereading. For a record AWS has drifted away from (an out-of-band re-price) that names a mode the table does not have, while reading as though it had been checked. Review correction: the first cut of this entry also claimed X is the CFn TYPE DEFAULT when the recorded previous is ABSENT. That is FALSE and the reviewer measured it — the arm sits inside theproperties['BillingMode'] !== undefinedbranch, so the desired side is always defined when the message fires, andprevBillingModetherefore resolves to the record (when usable) or the table's LIVE mode (when absent or unusable). The type-default arm needs an absent desired side and is unreachable from here. So the stale record was the only wrong route — which is still a real one, and still what the reword fixes. It also carried no logical id, while all FOUR other prefixed arms in the same provider do (the recorded-previous baseline warn, the BillingMode-flip refusal, the GSI-removal guard, the capacity guard), so on a stack with more than one table neither the user nor an integ assertion could tell which one warned. (An earlier draft of this entry said "both sibling arms ... the GSI and stream guards"; there is no stream guard in this provider, and the count was wrong — a review nit worth recording because it is the same accuracy class the change itself is about.) Now it readsAWS::DynamoDB::Table <logicalId>: … The mode this update compared against (X) is kept …, which is true on every branch. Message-only — no behavior change, and the existing suite already exercises both polarities, so no real-AWS run was needed. The two pinned assertions were updated and a FENCE for the prefix itself was added, anchored at the string START and carrying the BillingMode sentence: neither half fences it alone, since a bareAWS::DynamoDB::Tablesubstring also matches the guard's own message text and a bare prefix match would be satisfied by either sibling arm. Mutation-probed by stripping the prefix from the real provider — 6 rows fail. - The two #1681 residuals: an imported AppSync child records its ARN, and
Fn::GetAttstops serving a pre-#1681 placeholder (issues #1728 / #1729) —src/provisioning/providers/appsync-provider.ts,src/deployment/intrinsic-function-resolver.ts,docs/state-management.md,tests/unit/provisioning/appsync-child-ref-arns.test.ts,tests/unit/deployment/intrinsic-functions.test.ts. Shipped as ONE lane because both land in the same two test files and both are residuals of the same fix. #1728 — import.AppSyncProvider.import()returned{ physicalId, attributes: {} }for every non-GraphQLApitype, so a child adopted withcdkd importhad no ARN in state, the resolver'sREF_RETURNS_ARN_FROM_STATElookup missed, andReffell back to the raw compound id — the pre-#1681 behavior — withFn::GetAtt DataSourceArn/ResolverArn/Arnequally unresolved until the resource's next update (#1727) healed the record. It now calls the SAMEchildRefAttributesmappingcreate()/update()use, so the three spellings cannot drift, and records the COMPLETE set (Name/ApiKeytoo —importwrites the map outright, so a partial answer would leave those unresolvable as well). RECONSTRUCTED rather than read back: every segment is already in the supplied composite id,importadopts in the caller's own account and region, and the reconstruction costs one process-cached STS call instead of a Describe per imported child. It never throws — a failure warns and degrades to{}, i.e. exactly the pre-fix behavior, because adopting the resource is worth more than its bookkeeping attribute; a mis-arity id degrades the same way rather than guessing. Two of those degradations were review findings, and the first is the one that mattered: atrydoes NOT cover the credentials failure, becausegetAccountInfoCATCHES its own STS error and returns the hardcoded123456789012— so nothing throws, and an ARN built from it would be PERSISTED while carrying no wildcard forisPlaceholderArnto catch downstream, i.e. strictly worse than recording nothing.AwsAccountInfogained an additivefabricatedflag — set on the STS-failure arm AND on a SUCCESSFUL call that carries noAccount, which lands on the identical hardcoded id (absent otherwise, so no existing consumer changes; the fabrication itself remains issue #1730). The refusal lives inbuildAppSyncArn, NOT at the import call site, because a second review round showed the UPDATE path is the worse one:childRefAttributesrebuilds the ARN on every in-place update and anupdate()attribute map REPLACES the record's wholesale, so an STS blip mid-deploy would OVERWRITE a correct create-time ARN — destroying a known-good value where import merely fails to write a missing one. Each caller then takes its own answer: create warns and omits (unchanged), update reports NO attributes so the engine carries the existing ones forward, and import keeps the account-INDEPENDENT keys (Name/ApiKeycome out of the physical id) while dropping only the ARN. The second: the ARN is now built fromResourceImportInput.region— the regionimportkeyed the STATE RECORD by — rather than the provider client's own config, which can differ whenAWS_REGIONis unset and a profile region is set. #1729 — GetAtt.RefandFn::GetAttread the same cached attribute and #1681 guarded only theRefhalf, so a child created by a pre-#1681 binary still resolved{"Fn::GetAtt": ["MyDataSource", "DataSourceArn"]}toarn:aws:appsync:*:*:apis/...— structurally valid, unusable, indistinguishable downstream from a real ARN.rejectPlaceholderArnAttributenow refuses it, scoped to theREF_RETURNS_ARN_FROM_STATEtypes AND their declared ARN attribute names (the issue's narrowest direction): a wildcard-bearing ARN is only KNOWN to be a placeholder for those three attributes, and another type could legitimately cache an ARN-shaped string carrying*in a position the predicate inspects — soAWS::AppSync::ApiKey.ApiKey,AWS::AppSync::DataSource.Nameand every non-AppSync*Arnare untouched, each pinned by a test. It THROWS where theRefhalf degrades, and that divergence is the decision:Ref's fallback is the raw compound id, while this value is requested under an ARN-suffixed name, so serving a non-ARN would be exactly the shape mismatchguardedPhysicalIdFallbackalready hard-fails on (the #1103 class — a green deploy shipping a wrong value into a stack Output). The error names the cause and the remedy (re-deploy; the next in-place update heals the record). Both fixes mutation-probed: disabling either makes exactly its own three rows fail. - S3 inventory / analytics: the destination SHAPE is normalized, the recording fold gets its
canonicalizeDesiredPropertiestwin, and the CREATE-path empty-collection skip stops being silent (issues #1707 / #1717 / #1718) —src/provisioning/providers/s3-bucket-provider.ts,.claude/rules/providers.md,tests/unit/provisioning/s3-bucket-provider-substituted-properties.test.ts,tests/unit/provisioning/s3-bucket-provider-shape-and-empty-skip.test.ts,tests/unit/scripts/gen-nested-key-coverage.test.ts. Three residuals of the (#1686) / (#1671) / (#1670) PRs, bundled because all three land in the same provider and the twin is shared. Before: (a) the appliers accepted the destination block in two spellings (the flattened CFn{BucketArn, …}and the nested SDK{S3BucketDestination: …}) plus aBucket/BucketArnalias, and recorded it at the branch the TEMPLATE declared — whileinventorySdkToCfn/analyticsSdkToCfnemit only the flattened form withBucketArn, so a record in either tolerated spelling could never match the readback andcdkd driftre-reported it forever with no warning anywhere (nothing is malformed, nothing is substituted); (b) the (#1686)Schedule->ScheduleFrequencyrecording fold shipped without itscanonicalizeDesiredPropertiestwin, so an UNCHANGED template redeployed as1 to updateforever (measured us-east-1, 2026-08-12); (c)applyAllSubConfigsForCreateskipped an empty lifecycle / CORS collection in SILENCE, so a fresh bucket came up without a declared configuration and nothing said so; (d)Enabled/IncludedObjectVersions/OutputSchemaVersionwere always SENT (and always read back) but recorded only when substituted, so an item omitting one recorded fewer keys than the readback produces and the whole array compared unequal. Now two module-level pure folds —effectiveInventoryItem/effectiveAnalyticsItem, over a sharedeffectiveS3BucketDestination— are the ONE helper both the appliers (called with the values that went on the wire) and the newS3BucketProvider.canonicalizeDesiredProperties(called with none) use, so state and template can never be folded to different keys; the destination normalizes wholesale to the flattened CFn spelling, which supersedes (#1670)'s write-back-at-the-declared-branch decision by dissolving its reason (no other key survives to carry the malformed value); the defaulted-but-sent members are recorded and defaulted on BOTH diff sides; and the create-path skip warns by name. The (#1717) obstacle did not reproduce and that is stated rather than omitted: the issue measured the twin makinggen-nested-key-coveragereport theInventoryConfigurationssegmentRenamesentry STALE, and with the folds written as module-level functions all three passes report 0 divergences, the committed matrix does not drift a byte, and the entry is still inusedSegmentRenames— no entry removed, no staleness check widened. What did move is that file's withdrawn-name measurement (Enabled/ScheduleFrequency/BucketArnleft the reverse-map withdrawal set); the hazard that would matter — a RECORDING write vouching for a forward mapper that stopped writing the SDK member — was measured away per-name with real-code probes: deleting the wire write forIsEnabled/Bucketfrom a scratch copy of the real provider still fails the critic by name. The fold is keyed off the DECLARED shape, never off a refusal, so (#1670)'s finding 3 survives: a malformed value passes through intact andcdkd diffkeeps reporting it until the template is corrected. The (#1718) sibling audit the issue asks for was run and is not uniform: intelligent tiering defaultsStatuson the wire and reads it back, so it gets the same fold (effectiveIntelligentTieringItem, nosentparameter — that applier has no warn-and-substitute arm at all, since a malformedStatusSKIPS the item per (#1595)); metrics defaults no scalar member and needs none, which is now fenced by a test rather than left as a claim in a comment. Review of this PR caught one real defect before merge: the folds first resolved every default with??, which reads a DECLAREDnullas absent and would have folded the template side onto the substituted value — concealing the malformed value whose warning is the user's only signal, i.e. exactly what (#1670)'s finding 3 refused a twin over. Defaults now resolve by PRESENCE (key in container), the two-source schedule read prefers a present-but-malformed first source, and three fence rows use the NULLISH spellings specifically (every malformed-value row written earlier used a blank string / array / intrinsic, all non-nullish, so all of them passed against the broken fold). Each fence was mutation-probed. The 3-axis review then found a SECOND route to the same concealment, this one INTRODUCED by the first cut: a malformedScheduleCONTAINER ('Weekly'/[]/42) fell through to the default AND deleted the key, while the applier SKIPS such an item — so the folded desired side could compare EQUAL to the retained previous item and the skip warning would stop. A declared-but-unfoldable container is now left completely alone. The review also retired theonSubstitutedcallback (all three call sites had been left passing a comment-only no-op, so the helper ran a secondconfigStringRefusalper read to invoke nothing), corrected a stale paragraph that still said these sites take NO twin, narrowed the create-path announce gate to!= null, and fenced themetrics-needs-no-fold conclusion against the WIRE rather than against the absence of a fold. Two residuals were filed rather than ridden along: the notification / lifecycle alias families that (#1707) also names (issue #1748, sinceCloses #1707would otherwise orphan them) and the pre-existingEnabled: nullcoercion (issue #1751, where two reviewers proposed opposite fixes). The now-unusedwithDeepValue/withoutKeyhelpers were deleted with their rationale carried onto the folds. - The two replay-CREATE
AWS::DynamoDB::GlobalTablearms #1683 left unanswered now record what they SENT (issues #1724 / #1726) —src/provisioning/providers/dynamodb-globaltable-provider.ts,tests/unit/provisioning/dynamodb-globaltable-provider-replay-create-effective-props.test.ts,tests/unit/provisioning/dynamodb-globaltable-provider-sibling-effective-properties.test.ts. PR #1722 answered three arms of the "state records something other than what was SENT" class and NAMED these two rather than fixing them, so both are closed here from the same measurement. (1) The GSI omit now DROPS the key (#1724). On a state replay a malformedGlobalSecondaryIndexeswarns andtoSdkGlobalSecondaryIndexesreturns the well-defined EMPTY list, soCreateTablegoes out with NO indexes — while the engine recorded the malformed desired blob, a recordreadCurrentStatecan never match and the next update reads as its previous side (the #1552 class). Nothing was applied, so the answer is the.claude/rules/providers.mdreplay-CREATE + SKIP row: drop the key. Deliberately NOT the SUBSTITUTE shape its two siblings take — those APPLY a default, so what they record is what they sent; which arm you are on is a property of the GUARD, not of the path. Only the TOP-LEVEL key is dropped: aReplicas[].GlobalSecondaryIndexesoverride is a separate template key this guard never read, and its wiring runs on its own rather than offsdkIndexes. (2) TheBillingModesubstitution now strips the capacity blocks it never sent (#1726). SubstitutingPAY_PER_REQUESTalso skipscreateParams.ProvisionedThroughput, hands the SUBSTITUTED mode to the GSI translator (so every PROVISIONED-only per-index member is dropped before the call), and skips auto-scaling registration — yet onlyBillingModewas rewritten, leaving the same permanent phantom drift the arm exists to remove, one key over. Which members are safe to strip was the open question, and the answer comes from whatreadCurrentStateemits for a PAY_PER_REQUEST table, per member, not from the CFn schema: the top-levelWriteProvisionedThroughputSettingsemits{}and the per-replica / per-index / per-replica-index blocks are omitted — every one of those emissions is type-discriminator-gated onbillingMode === 'PROVISIONED'in that same method. So the new purestripProvisionedCapacityKeysremoves exactly that set and deliberately PRESERVES the on-demand ceilings, which go on the wire under this very mode (OnDemandThroughputis attached when the mode is not PROVISIONED). It is non-mutating at every nesting level, because the replay caller's bag ISpreviousState.propertiesand the rollback executor spreads the answer shallowly. NocanonicalizeDesiredPropertiestwin is needed for either arm, and the reason is the arms' own rather than an inherited carve-out: the bag ALREADY records aBillingModediffering from the declared one, so the next deploy ALREADY classifies an in-place UPDATE — the strip folds into that same UPDATE instead of manufacturing a new one, and the update path re-sends the capacity settings once the template's mode is corrected. Tests: 12 new cases plus an INVERTED pre-existing pin — the sibling suite carried a test asserting the OLD behavior ("KEEPS the PROVISIONED-only capacity blocks"), written expressly so that changing the answer had to change the test; it now asserts the strip and that the substituted mode survives it. Three mutation probes, each fencing a different way to get this wrong: reverting the strip fails 3 cases, disabling the GSI drop fails 3, and a BLANKET strip that also removes the on-demand ceilings passes every other assertion and fails exactly the preservation case. The replica-override and valid-blob rows fence the two over-reach directions. Residual, filed rather than silently carried: the UPDATE-side twin — a suppressed billing flip fires no capacity call either, yet still records the declared blocks — is #1738, and the create-side answer does NOT transfer to it (the kept mode can be either value, and the resource already exists, so the rules file's retain-the-previous-value row applies rather than the drop). No CLI flag, dependency, or state-schema change. -
AWS::Route53::RecordSet's three composite-id packers join the #1672 refusal (issue #1711) —src/provisioning/providers/route53-provider.ts,src/provisioning/composite-id.ts(header),tests/unit/provisioning/composite-id-provider-guards.test.ts. Before: the #1672 sweep adoptedpackCompositeIdat every composite packing site across glue / s3-tables / appsync / ec2 / apigateway / lambda-event-invoke-config and deliberately skippedroute53-provider.ts, which packs${hostedZoneId}|${recordName}|${recordType}at three sites (create, update, import). The reason was CONCURRENCY, not a technical one — a parallel lane (#1702) owned the file while the sweep was in flight — and the exclusion was backwards on the merits:recordNameisproperties['Name'], the only TEMPLATE-chosen segment the sweep left unguarded, while the segments it DID guard as uniform defense-in-depth (sg-…,igw-…,acl-…) are all AWS-generated. Now all three sites route through the shared helper. The first cut claimed the blast radius here is milder than the Glue sibling's —parseRecordSetCompositeIdrequires EXACTLY three parts, so a four-part id is REJECTED rather than silently mis-decoded — and concluded it closed an id-nothing-can-decode path rather than a wrong-resource-deleted one. That was FALSE, and review caught it; the paragraph below is what the code actually does. The three sites take three different answers, and the differences are the substance:creategets the ordinaryCreateContext.replayingStatedowngrade (warn-and-pack), which required threadingcontextthroughcreate()intocreateRecordSet— the provider declared nocontextparameter at all, the silent-ignore failure mode.claude/rules/providers.mdrecords forSNSTopicProvider;updatetakes theupdateRouteprecedent and downgrades UNCONDITIONALLY. The first cut threw there, on the argument that every valueupdate()packs is template-borne and therefore user-editable — FALSE for the two callers.claude/rules/providers.mdnames:rollback-executor.ts's revert arm andcdkd drift --revertboth callupdate(..., previousState.properties, ...), so the desired bag can be a cdkd STATE record, and a refusal there would leave a record an older binary wrote under the ambiguous id UN-REVERTABLE with no template edit that repairs it.update()takes noCreateContext, so it cannot tell the two apart; the pre-guard behavior therefore stands and the ambiguous id becomes ANNOUNCED rather than silent, while the refusal keeps its teeth on the create path where the value is always template-borne; andimport()neither throws nor adopts, taking the method's ownadoptVerbatimescape hatch instead — the verbatim id decodes to nothing, so delete / drift fall back to the template properties, where the packed composite would have frozen a mis-arity id into state that nothing can parse (the #1658 shape). Both mutating sites compute the id BEFOREChangeResourceRecordSetsand OUTSIDE the wrappingtry, so a refusal can neither orphan a record AWS has already written nor be mis-reported asFailed to create / update record set. The import arm calls the bare predicatecompositeIdSeparatorRefusalfor the DECISION andpackCompositeIdfor the join, which cannot throw there — deliberate, so the three sites share one spelling of the join and cannot drift. A reviewer then found the guard was standing one layer too high, and that is the more valuable half of this change.parseRecordSetCompositeIdasks only for three NON-EMPTY segments, so a record NAME carrying two pipes —a|b|c.example.com., which is exactly the CloudFormation physicalIdcdkd import --migrate-from-cloudformationpre-populates fromDescribeStackResources— parsed as a valid composite meaning zonea, nameb, typec.example.com., at THREE sites that never reached the new packing guard:importRecordSet's early accept,resolveRecordSetIdentity's short-circuit, anddeleteRecordSet's own parse. The last is where it costs something:cdkd destroyissuedChangeResourceRecordSetsagainst hosted zonea, hit theNoSuchHostedZonealready-deleted arm, and reported SUCCESS while the real record stayed live and billing — the silent-wrong-resource class this whole family exists to close, reached from ABOVE the packers rather than through them. All three now cross-check the parsed segments against the template (compositeAgreesWithTemplate): a genuine composite agrees with the template on the two segments they share, a look-alike does not, and a disagreement falls through to resolving the identity from the properties. Each check is skipped when its template side is not a usable string, so an unresolved intrinsic leaves the prior behavior exactly as it was; the NAME compare goes throughcanonicalizeQueryName, so case and CDK's trailing dot do not cause a false rejection, and a false rejection costs only one verification call and lands on the same physicalId anyway. The TYPE check alone is not sufficient —a|b|Aon a record whoseTypereally isAagrees on the type and still decodes to zonea— which is why both are applied. Tests: 12 cases. The refusal set — create refused before the AWS call, the id SHAPE named in the message (a wrong segment name would point the user at a composite this type never packs), the clean create id, the replay downgrade, an explicitreplayingState: falsecreate that must still throw, update warn-and-pack, the clean update id, and the import verbatim adoption — plus the four look-alike rows. Those last are asserted on the DELETE path rather than onimport's return value, and that is the load-bearing part: the bug and the fix adopt the SAME id string and differ only in what it is taken to MEAN, so an import-return assertion passes under both (measured — it did). Mutation-probed against the real provider: reverting the two mutating packers to raw joins and dropping the import refusal fails exactly the 4 refusal / adopt cases; replacing the update downgrade with a bareundefinedfails exactly the update case; reverting all three cross-checks fails exactly the 3 look-alike rows with the genuine-composite control surviving. Known bound, recorded rather than fixed: on the degraded path wherecloudformation:DescribeTypeis unavailable, aNamechange classifies in-place rather than as a replacement, soupdate()warn-and-packs a template-borne ambiguous id and UPSERTs a second record — the create-path refusal keeps its teeth only when the registry lookup succeeds. The helper's header exclusion note is retired, leavingintrinsic-function-resolver.tsas the single recorded exclusion (excluded by KIND — its joins buildRefVALUES nothing decodes). Directions 1 (stop packing) and 2 (escape the separator) remain OPEN on #1672. No CLI flag, dependency, or state-schema change. -
Refto a Route53 RecordSet / AppSync child resolves to CloudFormation's value instead of cdkd's compound id (issues #1681 + #1712) —src/deployment/intrinsic-function-resolver.ts,src/provisioning/providers/appsync-provider.ts, newtests/unit/provisioning/appsync-child-ref-arns.test.ts, plusdocs/state-management.md. Before:cfnRefValueFromPhysicalIdtranslated a compound physicalId back to CloudFormation'sRefvalue through two Sets — after-LAST-pipe and before-FIRST-pipe — and four types fit NEITHER, so{Ref: <resource>}handed the consumer cdkd's raw compound.AWS::Route53::RecordSetstores<hostedZoneId>|<name>|<type>while CFn'sRefis "the name of the record", the MIDDLE segment (after-last yields the record TYPEA, before-first the zone id). The threeAWS::AppSync::*children store<apiId>|…while CFn'sRefis the resource ARN, which is no segment of the id at all — and the ARN attribute the provider recorded forFn::GetAttwas itself string-built asarn:aws:appsync:*:*:…, i.e. a literal*in the region AND account positions, soFn::GetAtt DataSourceArn/ResolverArn/Arnwere ALSO returning an unusable value (the ApiKey one additionally used the pluralapikeyswhere the documented segment is the singularapikey). Route53 was the one likely to be hit in practice:CfnRecordSet.refis what an L1 template andCfnOutput(value: record.ref)use, so a record name composed into a string emittedZ1D633PJN98FT9|www.example.com.|A. After: two mechanisms sit beside the existing Sets —REF_RETURNS_SEGMENT_AT_INDEXextracts an INTERIOR segment at an EXACT declared arity (a mis-arity'd id falls through to the raw value rather than returning a confidently-wrong middle segment), andREF_RETURNS_ARN_FROM_STATErecovers the ARN through the existingstateLookupseam the S3Tables / Backup / CodeCommit cases use.AppSyncProvidernow records the REAL ARN:CreateDataSource/CreateResolverreport one, so it is taken from the response verbatim, andAWS::AppSync::ApiKey(whoseCreateApiKeycarries no ARN field) is reconstructed from the deploy's own partition / region / account via the shared STS-backedgetAccountInfo— the same helperCloudControlProvideruses. Two degradations are deliberate: an IMPORTED child recordsattributes: {}, so itsReffalls back to the raw compound id rather than a fabricated ARN; and a record written by a PRE-fix binary holds the*:*placeholder, which the resolver REFUSES (matched positionally on the region / account fields, so a legitimate ARN whose RESOURCE segment contains*is unaffected) — handing it out would be a regression introduced by the recovery itself, since it is no more usable than the compound id and looks valid. Such a record heals on the resource's next update: the three child update paths now report the COMPLETE attribute set (update()previously returned none, and the engine carries the existing map forward, so only a REPLACEMENT would ever have refreshed it). All fourRefsemantics were docs-verified 2026-08-12. #1712 rode along as the stale-comment half:intrinsic-function-resolver.ts's Glue entry still saidcreateTablepacks the id UNGUARDED, which #1672 had made untrue on the template path — narrowed to the rollback-executor REPLAY case, where the refusal deliberately downgrades to a warning. Tests: resolved-value assertions per entry (never Set membership — the two pre-existing extractions both return something plausible for Route53, so membership would not distinguish a correct entry from either wrong one), negative-polarity arity cases, the imported-child and placeholder fall-throughs, a positional-guard case for an ARN whose resource segment contains*, and provider-side assertions on the recorded value plus a whole-class fence that no child records a wildcard-bearing ARN. Both halves were mutation-probed against real code (removing each mechanism / restoring the placeholder fails the suite naming the case). The five pre-existing AppSync suites gained agetAccountInfomock — they were reaching REAL STS, so their result depended on the machine's credentials. - An empty
BucketEncryption/OwnershipControlscollection no longer DELETES the live configuration (issue #1713, the #1671 sibling) —src/provisioning/providers/s3-bucket-provider.ts,tests/unit/provisioning/s3-bucket-provider-shape-and-empty-skip.test.ts. Before:applySubConfigDiffstreated an empty collection in TWO incompatible ways. Lifecycle / CORS SKIP the Put and leave the live configuration alone (the #1671 arm), whileOwnershipControls/BucketEncryptionnormalize BOTH sides throughemptyListConfigToUndefinedBEFOREdiffSubConfig— so a non-empty previous against a declared-but-empty desired becameundefined, took the onDelete arm, and issuedDeleteBucketOwnershipControls/DeleteBucketEncryption. ForBucketEncryptionthat silently drops a declaredaws:kmsdefault down to the SSE-S3 / AES256 default on a template whose only fault is a condition-pruned or intrinsic-collapsed array — reachable from an ORDINARY template path, not just a state replay. Measured against live CloudFormation (us-east-1, 2026-08-12, the same account and date as the #1671 A/B): updating a deployed bucket from anaws:kmsdefault plus aBucketOwnerPreferredrule toServerSideEncryptionConfiguration: []/Rules: []drives the stack toUPDATE_ROLLBACK_COMPLETEwithThe XML you provided was not well-formed or did not validate against our published schema, and BOTH live configurations survive the rollback unchanged. So CloudFormation treats the empty collection as an INVALID template, NOT as a removal — the identical answer #1671 measured for lifecycle / CORS — and cdkd was deleting instead. Now both arms route through the sameemptyCollectionSkiphelper the lifecycle / CORS arms use: the call is skipped, the live configuration is untouched, the PREVIOUS value is recorded (so a later template that genuinely REMOVES the block still derives the removal), and the skip is ANNOUNCED rather than silent. The fold itself is deliberately NOT what changed, and that is the whole design:readCurrentStateALWAYS emitsBucketEncryption: { ServerSideEncryptionConfiguration: [] }/OwnershipControls: { Rules: [] }for a bucket with no explicit setting, socdkd drift --revertfeeds that placeholder straight back throughupdate()and both sides must keep normalizing for empty-vs-empty to compare EQUAL and issue no call at all. A new predicatedeclaresEmptyCollectionrides alongside the fold and splits only the arm the fold reaches — declared-but-empty SKIPS, ABSENT still DELETES — which is what keeps the template-side remedy (drop the whole property) working, exactly as the skip's warning tells the user. The predicate tests "present, and the fold erased it" rather than re-deriving the empty shapes, so a MALFORMED block (which the fold passes through unchanged, and which therefore never reaches the Delete arm) is still refused by name by the apply call rather than being swallowed into the skip. This REVERSES a contract issue #1466 pinned, and the reversal is the substance rather than a side effect: that issue's row assertedempty placeholder desired + real previous REMOVES (empty == not declared)on the reasoning that it is "a genuinedeclared -> not declaredtransition … matching CloudFormation". The CFn-parity half was ASSERTED, not measured, and the A/B above measures the opposite. The row is inverted with both the old reasoning and the new measurement recorded in place, plus a new sibling row pinning the transition #1466 actually meant (ABSENT desired still REMOVES) — which is the discrimination the whole fix rests on, since before it the two desired sides were literally the sameundefinedafter the fold and could not disagree. The scope is exactly what was MEASURED and no wider, which two failing #1466 rows enforced during the work:emptyListConfigToUndefinedalso folds a bare{}and a{listKey: null}-only block toundefined, #1466 pinned BOTH as removals, and the #1713 A/B exercised neither — sodeclaresEmptyCollectiontests the list key PRESENT and EMPTY directly rather than delegating to the fold, which would have reversed two more contracts on evidence that does not cover them. Review then found the fix NOT shippable, and closing that is the larger half of this entry (issue #1732).readCurrentStatespells "this feature is not set" as the SAME empty collection —readOwnershipControlsreturns{Rules: []}onOwnershipControlsNotFoundError— andcdkd drift --revertbuilds its desired bag from that readback. So the revert caller sends a bag byte-identical to the collapsed template while meaning the OPPOSITE: "restore the unset state", where DELETE is correct. Skipping it regressed revert on the two properties where revert had WORKED (lifecycle / CORS already skipped since #1671, so nothing was lost there), and worse — the skip'sretainPreviouswrites the AWS-CURRENT value intoeffectiveProperties, whichdrift.tspersists intoobservedProperties, so an out-of-band change was laundered into the baseline and the nextcdkd driftreported clean. Nothing caught it because the unit tests, the mutation probe and a three-phase real-AWS live test all exercise the TEMPLATE caller; a reviewer prompted specifically to ask "does any other caller depend on the old behavior?" did. The fix is a newUpdateContext— theupdate()sibling ofCreateContext, optional so none of the 77 providers implementingupdate()changed, with one fielddesiredFromAwsReadbackset only bydrift.ts's revert call. The NAME is load-bearing rather than cosmetic: the rollback executor's revert arms are state-borne too, but their desired bag ispreviousState.properties— a TEMPLATE recorded earlier — so{Rules: []}there means what the template meant and SKIP is right; they deliberately pass no context, and astateBornespelling would have swept them in and deleted a live configuration during a rollback. Review then found the SAME laundering two arms below, on the lifecycle / CORS revert path (readLifecyclereturns{Rules: []}onNoSuchLifecycleConfiguration,readCorsreturns{CorsRules: []}onNoSuchCORSConfiguration— the identical unset-spelling). Those are not folded, so a revert bag reaches the onPut arm, hits the empty guard, skips, and records the AWS-current rules as the new baseline. Pre-existing (#1671) rather than a regression here — but the enumerate-every-caller rule this change itself wrote does not get to skip its own siblings, so both are fixed in the same PR with the same one-line predicate. Tests: 7 new cases plus 2 reversed / added #1466 rows — the two regression rows (no Delete, no Put, previous value recorded), and THREE controls that are what make them non-vacuous: removing the property entirely still Deletes BOTH configurations, thereadCurrentStateplaceholder round-trip still issues NO call in either direction, and a malformed block still reaches the applier and throws. Mutation-probed against the real provider in BOTH directions, which is what proves the two arms cannot be collapsed into one: neutralizing the two skip guards fails exactly the 2 template regression rows with all controls surviving, ignoring the flag (skip always wins) fails exactly the revert row, and inverting it (delete always wins) fails exactly the 2 template rows. Live-verified end to end on one binary: an out-of-bandOwnershipControlsaddition is still removed bydrift --revert(the pre-fix binary's behavior, preserved), while a collapsed-array redeploy still leaves a declaredaws:kmsdefault untouched. No CLI flag, dependency, or state-schema change. - Composite physicalIds refuse a segment carrying cdkd's
|separator at deploy time (issue #1672, fix direction 3) — newsrc/provisioning/composite-id.ts, plussrc/provisioning/providers/{glue,s3-tables,appsync,ec2,apigateway,lambda-event-invoke-config}-provider.ts, newtests/unit/provisioning/composite-id.test.ts+tests/unit/provisioning/composite-id-provider-guards.test.ts. Before:ResourceProviderpasses a SINGLEphysicalId: stringas a resource's identity while CloudFormation keeps the physical id and its containing scope as separate values, so every composite-id type PACKS its segments into one string joined by an UNESCAPED|. A segment that itself contained a|produced an id with the wrong arity, and every decode site splits the stored id back apart — a Glue table nameda|bin databasemydbrecordedmydb|a|b, which decodes to databasemydb, tablea. Both halves are non-empty, so every existing well-formedness guard passed and the DEPLOY SUCCEEDED; what broke was everything after it.cdkd destroydeleted the WRONG table if one existed under the decoded pair, or tookdeleteTable's warn-and-skip arm and reported success while the real table stayed alive and billing, andcdkd driftread backundefinedforever. No warning was emitted at any point. AWS accepts such a name (live probe, us-east-1 2026-08-12:glue:CreateTablewithTableInput.Name: 'a|b'succeeds) and CloudFormation manages the resource fine, so the limitation is cdkd's own. Now a sharedpackCompositeId(resourceType, logicalId, segments, options?)joins the segments and REFUSES with aProvisioningErrornaming the resource type, the logical id, the offending segment's meaning, its value and the id shape (<databaseName>|<tableName>), pointing at the issue; a bare-predicate siblingcompositeIdSeparatorRefusalreturns the same sentence WITHOUT acting on it, for theimport()paths whose answer to an unusable id is warn-and-skip (skipped-not-found) rather than a throw that would abort the wholecdkd importover one row. Both share one message builder. Adopted at 13 CREATE-path packing sites across 6 providers —AWS::Glue::Table,AWS::S3Tables::{Namespace,Table},AWS::AppSync::{DataSource,Resolver,ApiKey},AWS::EC2::{EIP,VPCGatewayAttachment,Route,SecurityGroupIngress,NetworkAclEntry},AWS::ApiGateway::Method,AWS::Lambda::EventInvokeConfig— plus the three import-path adoptions (AWS::S3Tables::Namespace,AWS::S3Tables::Table,AWS::EC2::EIP). What is actually MEASURED is one segment, and the entry says so rather than implying a survey:AWS::Glue::Table'sTableInput.Nameis the only value probed live (glue:CreateTablewithName: 'a|b'succeeds, us-east-1 2026-08-12). Its siblingDatabaseNameis guarded on the same footing but unprobed — both are user-chosen and the lowercase-alphanumerics rule usually quoted for Glue names is an Athena / Data Catalog convention, not an API constraint. Every OTHER segment is either structurally incapable of carrying a|(AWS-generated ids, ARNs, CIDRs, numbers, booleans, closed-set HTTP verbs) or is user-chosen but constrained by AWS in a way that should reject one upstream of cdkd — AppSyncName/TypeName/FieldNameare GraphQL identifiers ([_A-Za-z][_0-9A-Za-z]*), a LambdaFunctionNameis[a-zA-Z0-9-_]+, an S3 Tables namespace / table name is lowercase alphanumerics and underscores, and an EC2IpProtocolis a protocol name or number. Those are guarded because the guard is cheap and uniform, NOT because the hazard was demonstrated; the in-code comment at each site says which of the two it is. The one case worth naming separately is EC2IpProtocoland LambdaQualifier/FunctionName: cdkd's ownrequireConfigStringaccepts any non-blank string there, so cdkd would pack whatever the template wrote even though AWS would then reject the call — refusing first is what keeps the ambiguous id out of the idempotent "already exists" arm.AWS::Route53::RecordSetis deliberately NOT covered here (a parallel lane owned that file), and directions 1 (stop packing where the decode sites already receive the properties bag) and 2 (escape the separator) remain OPEN on the issue — this closes the data-loss path, it does not restore CloudFormation parity. Placement is load-bearing: the id is computed BEFORE the create call at every site where all segments are known pre-call, so a refusal cannot leave an orphan resource AWS has already created with no state record; the two sites where a segment comes from the create RESPONSE (AppSyncapiKeyId, EC2 EIP) are exactly the ones whose segments cannot carry a|, and both sit OUTSIDE thetrywhosecatchre-wraps asProvisioningError('Failed to create …')— inside it, a refusal would be mis-reported as an AWS creation failure for a resource AWS had in fact created. Replay safety: the refusal downgrades to a WARNING (and packs the ambiguous id, i.e. the pre-guard behavior) wheneverCreateContext.replayingStateis set, since the rollback executor's reverse-replacement arm creates from a cdkd STATE record that no template edit can repair; the EC2 Route and SecurityGroupIngress sites reuse the callback theirupdate()already passes UNCONDITIONALLY, because those paths delete-then-re-create and a throw would strand a deleted route / revoked rule.S3TablesProvider.creategained aCreateContextparameter so its two guards take the same downgrade: the refusal is structurally unreachable there (a table-bucket ARN plus AWS-constrained names), but.claude/rules/providers.mdrecords a MISSINGcontextparameter as its own failure mode — no type error, no warning, just a refusal that still fires on a replay, theSNSTopicProvidercase — so three lines retire the reasoning burden rather than leaving it to be re-derived.GlueProvider.import()already refused the same shape viaresolveTableIdentity(issue #1651), so its composite is left alone.dynamodb-globaltable-provider.ts's|joins were audited and left untouched — they are internal cache / diff keys, not physicalIds — andintrinsic-function-resolver.ts's two joins are excluded by KIND: they build aRefVALUE, not a recorded physicalId, so nothing ever decodes them. Both exclusions, plus the route53 one, are recorded in the helper's header so the "every composite packer" claim is not read as wider than it is. The predicate tests the STRINGIFIED value, nottypeof value === 'string', which is what makes the guard reach the shape a reviewer found: every call site reads its segments through an unvalidated cast (tableInput['Name'] as string | undefined), so a hand-writtenName: ['a|b']is truthy, survives the provider's own required-field gate, arrives as an ARRAY, andString(['a|b'])is'a|b'— the exact ambiguous id, formerly unrefused.CompositeIdSegment.valueis typedunknownto say so. Recorded known bound: a plain object (an unresolved intrinsic) stringifies to[object Object], carries no|, and is not refused — that is a malformed-value problemconfig-shape.tsowns, and the resulting id is wrong without being wrong in a way that decodes to a different resource. Tests: 55 cases across two new files — 18 on the helper (clean join, number / boolean stringification, the ARRAY-valued segment, an empty-string segment, multiple separators, a leading and a trailing separator, the object-segment known bound, the refusal and its message content,ProvisioningErrorcarrying the type + logical id, EVERY offending segment named rather than just the first, theonRefusaldowngrade, and a fence that the predicate and the action emit the SAME sentence) and 37 on the providers, covering EVERY guarded site rather than one per file — including the six a reviewer found untested (AppSynccreateApiKey, EC2createEipand itsimport()arm, EC2createVpcGatewayAttachment, EC2createNetworkAclEntry— the only site with number / boolean segments — and S3 TablesimportNamespace— that last one is fenced at the HELPER level only, sinceparseNamespaceCompositeId's exact-two-part split makes the provider arm structurally unreachable, so deleting it would not fail a test and the in-code comment says why) and the replay / downgrade arms that no test previously pinned (both EC2 create-dispatchreplayingState ? cb : undefinedternaries,updateRoute's unconditional callback, and the AppSynccreateResolver/ Lambda-EIC replay-warn arms). Every provider refusal case also asserts the AWS client was never called, so the pre-flight placement is pinned rather than assumed. Every fix in the review round was MUTATION-PROBED individually rather than covered by the aggregate: reverting the predicate totypeoffails exactly the two array cases; reverting either EC2 create-dispatch ternary to a bareundefinedfails exactly its replay case; droppingupdateRoute's callback fails exactly the update-path case; moving either post-call pack back inside itstryfails exactly the not-a-creation-failure case; dropping the S3 Tablescontextfails exactly its two replay cases; and making the EIP import arm ADOPT rather than skip fails exactly the skip case. Neutralizing the guard outright fails 42 of the 55 — the 13 survivors are the clean-path controls that fence the happy path. No CLI flag, dependency, or state-schema change. -
cdk.json"app": "node bin/app.js"no longer runs asnode node bin/app.js(issue #1714) —src/synthesis/app-executor.ts,tests/unit/synthesis/app-executor.test.ts. Before:AppExecutor.guessExecutableprefixed the current node executable whenevertrimmed.endsWith('.js') || trimmed.split(/\s+/)[0]?.endsWith('.js'), then rewrote token 0. The SECOND disjunct expresses the intended rule ("a bare.jspath needs an interpreter"); the FIRST is true for any command whose LAST token is a.jsfile, INCLUDING one that already names its runner — sonode bin/app.js, the commandcdk init --language javascriptwrites intocdk.json, became"<node>" "node" bin/app.jsand every synth died withError: Cannot find module '/path/to/app/node'(MODULE_NOT_FOUND). Now: only the first token decides, sobin/app.js(with or without args) still gets the interpreter whilenode bin/app.js/npx tsx bin/app.js/ any.tsentrypoint go to the shell verbatim — matching upstreamguessExecutable, which prefixes an interpreter only when the referenced token is a real file on disk. A quoted first token ("bin/app.js") is now recognized too and re-quoted cleanly, which the substring test never handled. Why no test caught it: every fixture undertests/integration/**uses"app": "node bin/app.ts", whose last token ends in.tsand whose first token isnode— both disjuncts false — so the broken form was unreachable from the entire fixture set, and the one existing unit case (bin/app.js) exercises only the arm that was already correct. Tests: 5 new cases pinning both polarities — the runner-named form unchanged (mutation-probed: restoring the whole-string check fails it), a non-node runner of a.jsfile, args preserved after a bare entrypoint, a quoted entrypoint, and a whitespace-padded.tscommand passed through trimmed. No CLI flag, dependency, or state-schema change. -
cdkd exportno longer aborts on VPC + IGW stacks or on REST v1 API stacks (issues #1691 / #1692) —src/cli/commands/export.ts,tests/unit/cli/export.test.ts. Both areCOMPOSITE_ID_SPLITTERSdefects surfaced by the review of PR #1680, and both abort the WHOLE command becauseexport.tsis all-or-nothing. #1691 —AWS::EC2::VPCGatewayAttachment: the entry produced{VpcId, InternetGatewayId}and its comment asserted both fields were writable Properties. LiveDescribeType(us-east-1, 2026-08-12) says the identifier is[AttachmentType, VpcId]withAttachmentTypeinreadOnlyProperties, soresolveCompositeId's field check threwdid not produce field 'AttachmentType'on any stack with a VPC + Internet Gateway — i.e. essentially every public-subnet VPC. The splitter now derivesAttachmentTyperather than hardcodingInternetGateway: the Cloud-Control-written shape already carries it verbatim as segment 0 (a template declaringVpnGatewayIdtrips the #614 silent-drop routing, soVPN|vpc-…really occurs in state), the SDK-written<gatewayId>|<vpcId>shape derives it from WHICH gateway property state recorded, and a last-resortigw-/vgw-prefix read covers a state entry carrying neither; anything else refuses with a named error rather than guessing.propertiesOverlaynarrows toVpcId, since writing a read-only property intoPropertiesis rejected at changeset-create. #1692 — fiveAWS::ApiGateway::*children:Deployment/Stage/Authorizer/Model/RequestValidatorall have composite CFn identifiers while cdkd stores a bare id, and none had a splitter, socdkd exportwas unusable for REST v1 stacks (every CDKapigateway.RestApiemits a Deployment AND a Stage). They share onerestApiChildSplitterfactory that accepts BOTH stored shapes — the SDK providers' bare child value paired withRestApiIdfrom the recorded properties, and Cloud Control's|-joined composite in CFn primaryIdentifier order. That order is not uniform and the factory takes it as a parameter:Deploymentdeclares[DeploymentId, RestApiId](child FIRST), every sibling declaresRestApiIdfirst.propertiesOverlaynarrows toRestApiIdfor the three whose child field is read-only (Deployment/Authorizer/RequestValidator) and is left at the default whole-map forStage/Model, which declare no read-only properties at all — verified live per type, with the reason recorded per entry.ModelandRequestValidatorhave no SDK provider today, so they only ever route through Cloud Control; the bare form is accepted anyway so a future provider needs no state migration. Tests: 20 new cases — AttachmentType derived from each gateway property, from the CC-written shape, from the id prefix, and the named refusal when none resolves; each REST child in both stored shapes with its overlay asserted; theDeploymentchild-first ordering pinned separately from its siblings; and the three refusal shapes (blank id, >2 segments, missingRestApiIdin state). The pre-existing case that pinned the WRONG VPCGatewayAttachment map is replaced rather than kept. No CLI flag, dependency, or state-schema change. - Three
AWS::DynamoDB::GlobalTablearms record what they SENT — and a fourth is deliberately left unanswered (issue #1683, the #1653 siblings) —src/provisioning/providers/dynamodb-globaltable-provider.ts,.claude/rules/providers.md,docs/provider-development.md,tests/unit/provisioning/dynamodb-globaltable-provider-sibling-effective-properties.test.ts,tests/integration/dynamodb-globaltable/. #1653 answered only theStreamSpecificationarm; siblings in the same provider put a value on the wire that differs from the declared one and still recorded the DESIRED bag — the permanent phantom driftreadCurrentStatecan never clear. (1)BillingModewarn-and-SUBSTITUTE (create): under thereplayWarndowngrade a malformed value is replaced byPAY_PER_REQUESTand the table really is created on-demand, so the SUBSTITUTED mode is now recorded — #1633's "what you return is what you SENT", reached by the same single-call-site exception.claude/rules/providers.mdlicenses for a read of KNOWN warn-and-DEFAULT class. The callback is COMPOSED ontoreplayWarn's own and installed only when that callback exists, so a template-path create keeps refusing rather than silently gaining a downgrade. (2)desiredGsiUnusablewarn-and-SKIP (update): the GSI diff is suppressed, so AWS keeps the index set it already holds and the effective bag now retains the PREVIOUS list — not the create side's OMIT, which here reads as "the template declares no GSIs". The previous side is VALIDATED first through the SAME translator (a probe call with a flag-only callback — the helper is pure, so this cannot drift from a hand-writtentypeoftwin the way a re-derived shape check would), the retained array is COPIED rather than aliased (rollback-executor.tsspreads the answer shallowly), and the key is DROPPED when both sides are unusable. (3)BillingModewarn-and-KEEP (update): the SAME property one path over, named by the issue's arm 1 and caught by the re-review — an unusable desired value does NOT flip the table, it keeps the previous mode, so the effective bag now records that mode rather than the malformed onereadCurrentStatecould never match. It needs no both-sides-unusable branch: the kept mode already falls back to whatDescribeTablereports, so the retained value is always one AWS holds. Note the create and update arms of one property answer DIFFERENTLY because what reached AWS differs. One residual is named rather than stripped — substitutingPAY_PER_REQUESTon the create path also skips every PROVISIONED-only capacity block, which stays recorded although nothing sent it (#1726). (4) theneedsStreamauto-enable (create) is NOT answered, on purpose. It is the same class — cross-region replication requires a stream, so cdkd enablesNEW_AND_OLD_IMAGESon a template that declared none, with nothing malformed and no guard logging a refusal — and a 3-axis review established that recording it in isolation is exactly the shape the twin rule forbids.DiffCalculator.comparePropertieswalks the key UNION, so an UNCHANGED template would classify an UPDATE on the next deploy;update()(whose stream gate isproperties['StreamSpecification'] !== undefined, false for a template that declares none) would return no effective bag and re-record the desired one, dropping the key again. Net: one spurious no-op UPDATE and no durable record — never a disabled stream, which is why this is a follow-up rather than a hazard. ThecanonicalizeDesiredPropertiestwin that would fix it cannot simply be added: it is pure and synchronous and does not know the deploy region, whileneedsStreamdoes. Tracked as issue #1723 together with the prior question of whether the arm is needed at all (drift-calculatordescends only into keys present in state, and theobservedPropertiesbaseline already carries the stream). A fifth arm found by the same review — the replay-CREATE GSI omit, which sends no indexes yet records the malformed blob — is filed as #1724; both are named in-code so neither reads as already-settled. Tests: 23 unit cases covering all three answered arms in both polarities, the composition on BOTH paths, and a pin that the auto-enable still SENDS the stream while recording nothing. Every composition claim is mutation-probed and labelled by what it actually fences: the create-path pair and the update path's GSI and BillingMode sites each fail when assigned{ ...properties }, while the update path's stream site passes because that arm always runs first — recorded in-code rather than claimed as covered. Real-AWS: thedynamodb-globaltableinteg gains assertions that a skipped GSI update retained the previous list and that a suppressed billing flip recorded the previous mode while leaving the live table PROVISIONED — both on the SAME resource in one deploy, so the run is also the live twin of the composition test, and its #1571 / #1585 junk-state phases now seed by hand-patchingstate.json(properties only;observedPropertiescame from a real read-back and was never junk) because arm 2 closed the path that used to write that record — the recovery machinery still matters for every record an older binary wrote, so the coverage stays rather than being deleted along with its producer. No CLI flag, dependency, or state-schema change. -
AWS::Kinesis::Streamback-fillsDesiredShardLevelMetrics+MaxRecordSizeInKiB(issue #609) —src/provisioning/providers/kinesis-provider.ts,src/provisioning/property-coverage.generated.ts,tests/unit/provisioning/kinesis-provider-metrics-and-record-size.test.ts,tests/unit/provisioning/kinesis-provider-readcurrentstate.test.ts,tests/integration/kinesis-stream-mode-switch/. Before: both properties were insilentDrop, so a template declaring either was rejected at deploy-time pre-flight (with the--allow-unsupported-propertiesescape hatch). Now:MaxRecordSizeInKiBrides onCreateStreamInputat create andUpdateMaxRecordSizeat update (that API has noStreamNamemember, so it resolves the ARN the same way theUpdateStreamModepath does), andDesiredShardLevelMetricsis applied viaEnableEnhancedMonitoring/DisableEnhancedMonitoring— reconciled as a removal pass then an addition pass, since enhanced monitoring is a SET with no "replace" API. Both are read back byreadCurrentState;MaxRecordSizeInKiBneeded a secondDescribeStreamSummarycall becauseDescribeStream'sStreamDescriptionshape does not carry it. TheALLshorthand is the interesting half. Measured us-east-1 2026-08-12: AWS EXPANDSALLinto seven metric names and never stores the literal, so recording the template'sALLwould be permanent phantom drift that no readback could ever match. The provider therefore expands on the wire and records the expanded list viaeffectiveProperties— paired withcanonicalizeDesiredProperties, which narrows BOTH diff sides through the same shared helper, per the.claude/rules/providers.mdcontract that the recording half alone breaks the next deploy (state would hold seven names while the template still saidALL, and every latercdkd diffwould report a change the user never made). The integ asserts exactly that: after theALLdeploy, AWS reports the seven names, cdkd state records the seven names, andcdkd diffis CLEAN. The readback order is AWS-chosen (matched neither request order nor alphabetical), so the type also declaresDesiredShardLevelMetricsingetDriftUnorderedPaths. A declared metric list has THREE states, not two: an ABSENT declaration is a template removal and correctly disables every live metric, while a declared-but-UNUSABLE one (an unresolved intrinsic, or a MIXED array) must not collapse to the same empty list — doing so would disable live monitoring on the strength of a value cdkd could not parse.readShardLevelMetricsclassifies the three and each caller picks the action: create REFUSES (template-borne, downgraded to a warning underCreateContext.replayingState), update WARNS and skips the whole reconcile (rollback /drift --revertreplay state records throughupdate(), where a throw has no template-side remedy). A mixed array is refused rather than partially sent, since filtering the intrinsic away would put a list on the wire the template never declared while state recorded the declared one.MaxRecordSizeInKiBis coerced through one helper on BOTH comparison sides (matching the siblingShardCount), so a string-valued template does not reach the SDK as a JSON string nor re-issue the call on every deploy; and a non-not-foundDescribeStreamSummaryfailure RETHROWS rather than returning a snapshot missing the key, whichcdkd driftwould report as2048 -> undefinedand--acceptwould then erase from the baseline. The slice's third property,WarmThroughputMiBps, is deliberately NOT shipped: it needs an account-level Kinesis minimum-throughput billing commitment this account lacks (ValidationExceptionon create), so it cannot be asserted against real AWS and would violate #609's per-PR "the integ must prove the property reached AWS" rule — split out to issue #1695 and left insilentDrop. - Cross-stack references fall back to CloudFormation when the producer is not in cdkd state (issue #1697) —
src/deployment/intrinsic-function-resolver.ts,src/deployment/deploy-engine.ts,src/cli/options.ts,src/cli/commands/{deploy,diff,diff-recursive,export}.ts,tests/unit/deployment/intrinsic-cfn-fallback.test.ts, docs (cross-stack-references.md/cli-reference.md/supported-features.md/architecture.md/ README / rules / distributed plugin skill). Before: both cross-stack intrinsics read ONLY cdkd's S3 state, so a cdkd-deployed consumer referencing a producer stack still managed by CloudFormation (cdk deploy/ raw CFn) died at resolve time —Fn::ImportValuewithexport not found in any stack,Fn::GetStackOutputwithMake sure the producer stack has been deployed via cdkd— blocking the mixed-estate scenario cdkd is positioned for (shared infra on the CDK CLI, app stacks on cdkd) and forcingcdkd exportmigrations to proceed leaf-first. Now a cdkd-state miss falls back to CloudFormation:Fn::ImportValue→ paginatedListExportsin the consumer's region (CFn's own semantic for the intrinsic — this was a template-compatibility gap),Fn::GetStackOutput→DescribeStacksoutputs in the target region (same-account only; theRoleArncross-account path keeps reading the producer account's cdkd state exclusively). cdkd-first precedence is inherent (the fallback fires only after the index + state scan miss), so cdkd-to-cdkd behavior is byte-identical. CFn-sourced resolutions are WEAK references — deliberately NOT recorded intostate.imports/state.outputReads, since cdkd cannot destroy-protect a producer it does not manage and CFn's export-in-use protection cannot see cdkd consumers — so no state schema change. Lookup failures (missingcloudformation:ListExports/DescribeStackspermission) degrade gracefully: warn + the original not-found error (which now names both searched sources). Opt-out:--no-cfn-fallbackondeploy+diff(threaded throughDeployEngineOptions.cfnFallbackinto the resolver, inherited by nested-stack child engines via the options spread, and honored bycdkd diff's best-effort resolvers incl. the recursive child-parameter resolution so preview and apply resolve identically). Thecdkd exportcross-stack consumer scan's warn/refuse text is updated to match (consumers now keep resolving via the fallback; only--no-cfn-fallbackconsumers retain the leaf-first constraint). Tests: 14 new resolver cases (fallback hit, pagination, cdkd-first precedence with no CFn call, opt-out, graceful degradation warn, both-sources error text, weak-reference non-recording on both bags, available-outputs error, does-not-exist-as-miss, region pinning, per-region client caching) + engine threading assertions + a cross-account assertion that theRoleArnpath never takes the fallback; verified live with a realaws cloudformation deployproducer consumed by a cdkd deploy. -
AWS::Route53::HostedZoneimport auto-resolves from the template'sName(issue #1702) —src/provisioning/providers/route53-provider.ts,tests/unit/provisioning/route53-provider.test.ts,docs/import.md. Before:docs/import.mdlisted the type under Auto-resolved, butimportHostedZone()returnednullunless an explicit--resource <logicalId>=<physicalId>override was supplied — it was override-only in fact, and the contradiction only became visible when PR #1678 corrected the provider's own JSDoc. A type listed as auto-resolved that silently declines produces askipped-not-foundrow the user did not expect, and under--migrate-from-cloudformationthe state write happens anyway, so the zone was left out of cdkd state after the CloudFormation stack had already been retired. After: with no override the zone is resolved from the template'sName— the physical-name route the Auto-resolved section documents — through the SAMEresolveHostedZoneIdhelper the RecordSet path uses, so it inherits #1678'scanonicalizeQueryNamequery-side positioning (a boundedListHostedZonesByNamepage cannot skip past the zone) and its split-horizon ambiguity refusal. That refusal would have made the common split-horizon pair unresolvable, so the helper gained one option:requirePrivateZonenarrows the name match to the public or private side BEFORE the ambiguity decision, and the new module-leveltemplateZoneVisibilityderives that side from the template's ownVPCs(absent = public; an array carrying at least one element that is not purely an unresolved intrinsic = private; everything else = UNDECIDABLE, leaving the refusal in place — a non-array, an empty list, and an all-intrinsic list such as[{Ref: 'AWS::NoValue'}], which is non-empty yet may resolve public). Narrowing changes what a NEGATIVE proves, and that is the subtle half.absenceIsSoundreasons from AWS returning the page AT the query key: without narrowing this line is only reached with an EMPTY match set, so any returned zone is a LATER one and the same-named run is provably over. With narrowing, the filter can empty the match set while the whole page is same-named zones of the OTHER side — and if that page is truncated the zone we want may be the next entry, so treating it as absence would decline a zone that exists and let the next deploy CREATE a duplicate. The narrowed case therefore demands stronger evidence: a DIFFERENT name on the page, or an untruncated one. The two ways of failing are kept DISTINCT (corrected during review — the first cut declined everything on the argument that a throw would fail the whole run, which is FALSE:importOneinimport.tscatches per resource and recordsoutcome: 'failed'). A proven absence returnsnull->skipped-not-foundand the next deploy creates the zone; an ambiguous name or a failed lookup THROWS ->failedfor that row alone, so a deniedroute53:ListHostedZonesByNameis reported as a denial instead of being flattened into "no matching AWS resource" — which was wrong at exactly the moment--migrate-from-cloudformationretires the CFn stack. Both messages name the--resourceremedy through a newsubjectoption, since the shared helper's RecordSet wording ("pass the<zoneId>|<name>|<type>composite") names a physicalId shape a hosted zone does not have. An explicit--resourceoverride still wins and is only verified. Tests: 18 cases — auto-resolution + query canonicalization, both split-horizon sides (the private zone listed FIRST in the public case, so a take-the-first-match implementation fails), the three undecidable-VPCsrefusals (non-array, empty list, all-intrinsic elements) plus the concrete-element-with-Ref-inside acceptance, both truncated-page directions, the no-match / wrong-side declines, the denied-lookup failure, override precedence, and — added by the 3-axis review — post-narrowing ambiguity (two PRIVATE zones sharing a name, which narrowing cannot disambiguate), a zone whose response carries noConfigblock, and both--resourceverification arms. Every guard probed by mutation against the real provider: reverting the soundness rule fails 1, the naiveVPCs.lengthcheck fails 1, decline-everything fails 5, disabling the visibility filter fails 3, removing the auto-resolution fails 5, "narrowing means take the first survivor" fails 1, and swallowing every override-path error fails 1. - The S3 inventory schedule is recorded in the CFn spelling, and an empty rules collection records the previous value (issues #1686 / #1671) —
src/provisioning/providers/s3-bucket-provider.ts,tests/unit/provisioning/s3-bucket-provider-shape-and-empty-skip.test.ts(new),.claude/rules/providers.md. Two siblings of the #1612 / #1670 recording class, both producing PERMANENT phantom drift rather than a wrong AWS call. #1686 — a SHAPE mismatch. Before: the inventory applier accepts two spellings of the schedule on the desired side, the CFnScheduleFrequencyand the SDKSchedule: { Frequency }(the #1605 fall-through), butinventorySdkToCfnemits only the former — and the live CFn registry schema declaresScheduleFrequencyREQUIRED with noSchedulemember at all, so the SDK spelling is a cdkd-only tolerance. A record carryingSchedulenamed a key the readback can never produce: everycdkd driftre-reported it and--revertre-issued the same Put. #1670 had fixed the adjacent VALUE half (the fall-through records what went on the wire); the KEY survived alongside it, and the case that mattered carried no malformed value at all — an item declaring only the SDK spelling sends the right cadence and records the wrong key. Now the normalization keys off the DECLARED shape rather than off the refusal: whenever the item declaresSchedule, the effective item recordsScheduleFrequency(the value that went on the wire) and DROPSSchedulevia a newwithoutKeyhelper — the removal twin ofwithDeepValue, removing the key rather than setting itundefined, sinceJSON.stringifydrops anundefinedmember but astructuredCloned state record keeps it and theunionWalkObjectsdrift path walksObject.keys. The tolerance is kept rather than retracted, because refusing the SDK spelling would retract the #1605 fall-through whose purpose is that a malformed first source lands on a value the record ALSO carries instead of skipping a live inventory report. #1671 — an empty-collection SKIP. Before: the lifecycle / CORSonPutarms skip the Put for an empty rules array while AWS still holds the previous rules, and the engine recorded{Rules: []}— a Put that never ran, written as though it had; unlike the malformed-value skips this arm is reachable from an ORDINARY template path, since a condition-pruned or intrinsic-collapsed template synthesizes an empty array. The issue's open question was whether an empty collection is a REMOVAL intent, which would make the skip itself wrong. It is not, measured by live A/B against CloudFormation (us-east-1, 2026-08-12): updating a deployed bucket toLifecycleConfiguration: { Rules: [] }+CorsConfiguration: { CorsRules: [] }drives the stack toUPDATE_ROLLBACK_COMPLETEand BOTH live configurations survive the rollback unchanged — so it is an INVALID template, not a removal, and turning the arm into a Delete would both diverge from CFn and destroy a configuration the user still wants. The registry schema only says the shape is legal (Rules/CorsRulesrequired, nominItems), which is why the behavior had to be measured rather than read. Now the skip stands, records the PREVIOUS value (the #1612 UPDATE answer, now with CFn behavior behind it —undefinedwhen the previous side declared none, which removes the key so a later genuine removal still derives one), and is ANNOUNCED with a warning naming the property and the remedy, since CFn's own answer to this template is a loud failure. It stays a warning rather than a throw so thereadCurrentStateround-trip the arm exists to absorb (drift --revertfeeds an always-emitted empty-rules block back throughupdate()) keeps working. Known residual: the fold ships WITHOUT itscanonicalizeDesiredPropertiestwin, so a template declaring the SDK spelling keeps reportingInventoryConfigurationschanged on everycdkd diffand re-issues an idempotent per-IdPut (measured: an unchanged template redeploys as1 to update). Shipped anyway because the alternative is worse in the direction that MUTATES — without the fold,cdkd driftreports the key forever and--revertre-issues the call — and because the twin has a real obstacle: acanonicalizeDesiredPropertiesfolding this key makesgen-nested-key-coveragereport the still-correct plural->singularsegmentRenamesentry forInventoryConfigurationsstale, while removing that entry surfaces genuineno-write-evidencedivergences. Tracked in issue #1717. Tests: 20 unit rows — 10 for #1686 (SDK-spelling-only, the five malformed fall-through shapes, the redundant-Schedulecase, the unmutated-desired-bag fence, and a convergence row driving the record and a readback-shaped snapshot through the realcalculateResourceDrift) and 10 for #1671 (lifecycle + CORS each asserting NO Put AND NO Delete, both warnings incl. the absent-previous wording, the ABSENT / non-array arm, the key removal, and two CONTROLS: a non-empty array still applies, and REMOVING the property entirely still Deletes). Mutation-probed both ways at the time of the probe (16 rows then, 20 now): reverting the #1686 normalization fails exactly the #1686 rows, and reverting the #1671 lifecycle arm fails exactly its rows with the CORS row still green, so the two arms are independently fenced. Audit: the sibling sweep #1686 asks for was done mechanically — 158 live-registry-schema property names diffed against every desired-side key read — yielding four non-CFn reads, two legitimate SDK RESPONSE-side spellings (IsEnabled,AccountId),Schedule(fixed here), and the analytics / inventoryDestination.S3BucketDestinationnested branch plus itsBucket/BucketArnalias, filed as issue #1707 because fixing it revisits #1670's write-back-at-the-declared-branch decision. No CLI flag, dependency, or state-schema change. - The reverse-replacement replay-CREATE records the provider's
effectiveProperties(issue #1682) —src/deployment/rollback-executor.ts,tests/unit/deployment/rollback-executor.test.ts,tests/integration/rollback-replay-effective-props/(new),.claude/rules/providers.md,.claude/rules/code-layout.md,docs/testing.md. Before: issue #1644 made everyupdate()caller honoureffectiveProperties, but the CREATE side kept one caller that did not — and it is the one that needs it most. The reverse-replacement arm re-creates the OLD resource frompreviousState.properties, i.e. from a cdkd STATE record rather than a template, so it is the only create path whose input bag can carry a malformed block an older binary wrote and which the provider deliberately WARNS about and SUBSTITUTES instead of refusing (the #1544replayWarndowngrade). That arm typed its local result as{ physicalId; attributes? }—effectivePropertieswas not even declared on the type — and rebuilt the state record fromprev.properties, so whatever the provider reported was discarded. Every provider substituting from its replay-CREATE arm was therefore writing into a void, which made the.claude/rules/providers.mdeffective-properties table's replay-CREATE row unreachable in production for all of them. Now the arm mirrors its UPDATE-side twinrecordAfterRollbackUpdatevia a newrecordedPropertiesAfterReplayCreate: the bag handed tocreate()ISpreviousState.properties, so a returnedeffectivePropertiesreplaces the record'spropertieswholesale, and reporting none keeps the previous bag rather than blanking the record (the gate is PRESENCE, not emptiness —{}is a legitimate complete answer meaning "I sent nothing"). It applies on the name-idempotent ADOPT path too: that arm's warning says state records "the pre-replacement properties" and it still does, since a substitution repairs an unusable field of that same bag rather than swapping in the new generation's values. Tests: 5 unit cases — the substitution recorded (and COPIED, not aliased), the default polarity keepingprev.properties, an EMPTY report honoured as complete, the delete-new-first collision retry's SECOND create attempt, and the ADOPT path. 4 of the 5 fail against the pre-fix source; the default-polarity case passes both ways by design, as the regression guard for the arm every rollback hits. Real-AWS coverage: a new fixture, because no existing one reaches this branch —rollback-failure-injectionrolls back CREATEs, which is a delete, not a re-create. It deploys anAWS::EC2::Route, injects a second destination key into the state record (the shape recorded before #1591 narrowed it), flips the create-only destination with a failure wired AFTER the route so the replacement COMPLETES and rollback classifiesreverse-replacement, then asserts the post-rollback record kept the SUBSTITUTED bag and that two consecutivecdkd driftruns converge. The vehicle is a route rather than theAWS::S3::Bucketthe issue names because a bucket's reverse-replacement re-create must re-acquire a just-deleted GLOBALLY unique name, whose release is not immediate — flaky for a reason unrelated to what it tests — while a route's<RouteTableId>|<Destination>identity is stack-scoped and deterministic;createRoute's multi-destination warn arm is gated on the sameCreateContext.replayingStateflag, so the engine path is identical. Verified in us-east-1 BOTH ways: PASS with the fix (6 phases, 5 deleted / 0 errors / 0 orphans) and, against a rebuilt pre-fix binary, a FAIL at the assertion phase carrying the#1682 REGRESSIONmessage — so the fixture demonstrably catches the defect rather than passing vacuously. No CLI flag, dependency, or state-schema change. - NLB flag-OMISSION semantics A/B-verified: AWS RETAINS, so cdkd's omit-the-member behavior is correct (issue #1619, split out of #1609 item 2) —
src/provisioning/providers/elbv2-provider.ts,tests/unit/provisioning/elbv2-lb-targetgroup-props.test.ts, new fixturetests/integration/nlb-source-nat/. The open question:EnablePrefixForIpv6SourceNatandEnforceSecurityGroupInboundRulesOnPrivateLinkTraffichave no AWS API of their own — they ride onSetSubnets/SetSecurityGroupsinupdateLoadBalancer. When ONLY the flag is removed cdkd issues no call at all and the live value is retained, and that arm was settled. The COMBINED case was not: with the flag removed in the SAME deploy that changesSubnets/SecurityGroups, the Set* call IS issued and is issued WITHOUT the member, and AWS's omission semantics there (retain vs reset-to-default) had never been A/B'd. Both in-code arms carried aCaveat:comment saying so. If AWS reset, cdkd would silently flip a live NLB's IPv6 source-NAT — or, security-relevantly, its PrivateLink inbound-rules enforcement — on an unrelated subnet / security-group change. Measured (us-east-1, 2026-08-12, one NLB per flag so the two cannot cross-talk, each templated at its NON-default so retain and reset are distinguishable): AWS RETAINS on omission for both. Source-NAT heldonacross a 2 -> 3 subnet change, keeping the per-subnetauto_assignedprefixes and auto-assigning one to the new subnet; enforce held the non-defaultoffacross a 1 -> 2 security-group change. The delta: no behavior change — omitting is correct and re-sending would be redundant. The twoCaveat:comments are replaced by the verified statement plus the measurement date, per the issue's "whichever way AWS goes, pin it". Also recorded: the enforce flag is settable and readable on ANY SG-bearing NLB, so the PrivateLink endpoint service the issue scoped as necessary is NOT needed — which is most of why the fixture is cheap. Tests: two unit tests for the previously-uncovered combined arm (each asserts the member is OMITTED; both were mutation-probed against a re-send-the-retained-value implementation and fail under it), plus thenlb-source-natinteg fixture, which pairs each retention assertion with a companion-property readback proving the Set* call actually fired — without that a cdkd regression that skipped the call would leave the flag untouched and the assertion would pass vacuously. Scope of the evidence, since the provider can emit two request forms: the fixture regression-fences theSubnetMappingsarm; the plainSubnetsarm was measured by hand in the same A/B and retained identically, but nothing in the repo reproduces it, and the in-code comment says so rather than implying both are guarded. Theinteg-aws-commandsinvocation CEILING was re-slacked 3000 -> 3400 (the tree had grown to 2988 against a ceiling last set at 2635, so ordinary fixture growth, not a parser false positive, had exhausted it). - A custom-resource failure that nothing explained now shows the backing function's log tail (issue #1687, surfaced by the review of #1685) —
src/provisioning/providers/custom-resource-provider.ts,tests/unit/provisioning/custom-resource-provider-authz-log-tail.test.ts. #1674 taught cdkd to read the backing Lambda's invocation log tail, but it only ACTED on it when the tail matchedCR_TRANSIENT_AUTHZ_LOG_SIGNALS. For every other terminal failure — a 404, a Python traceback, a JSON decode error —decodeInvokeLogTailran and the result was discarded, so the user still saw onlyCustom resource handler returned FAILED: Command '[...]' returned non-zero exit status 1.and still had to open CloudWatch. That is literally the second half of what #1674 reported, and a spec reviewer caught that #1685 had closed only the first. Now a terminal FAILED whose reason carried no authz wording AND whose log matched no signal emits the decoded tail as an EPHEMERALlogger.warn, capped byCR_LOG_TAIL_WARN_MAX_CHARS. The arm is deliberately narrow in one direction and deliberately broad in the other. It does NOT fire when the reason itself named the cause (the #756 path) or when the log matched a signal (the #1674 path) — both have already explained the failure, and repeating the tail beside an explained one is noise. It DOES fire for every other terminal FAILED rather than trying to judge whether the reason was informative, because cdkd cannot tell an explanatory handler message from an unhelpful one, and at that point the user is debugging a failure and the tail is exactly what they would go and fetch. It stays out of the thrown message, which is the #1685 data-class rule applied again: a thrown message is persisted todeployments/{runId}.jsonl, which outlivescdkd destroyand is contractually free of anything that may carry secrets, and a log tail is arbitrary handler stdout. Review corrected two over-assertions, both about claiming more than cdkd knows.LogResultis NEVER absent on aLogType: 'Tail'invoke — Lambda always emitsSTART/END/REPORT— so the first cut'slogTail !== undefinedguard filtered nothing and would have dumped several hundred characters of zero-value boilerplate on EVERY unexplained failure;hasHandlerLogOutputnow requires at least one handler-authored line, and it is a positive test rather than a size threshold precisely because the boilerplate is long. And the tail belongs to the DISPATCH invoke: for a handler that works inline that IS where the failure happened, but for the CDK Provider framework's async pattern the FAILED arrives frompollS3Responseand was authored by a LATER Step-Functions-driven execution whose log this is not — a mismatch that costs only a redundant retry on the SIGNAL path but would present an unrelated log as THE explanation here, so the message names the invocation it came from and says the failure may have happened elsewhere. The wording also says cdkd "could not classify the reason" rather than that the reason carried no recognizable cause, since a perfectly informative reason simply may not match an authz signal. A second review round then found the boilerplate set itself incomplete: it omitted the COLD-START platform lines (INIT_START/INIT_REPORT/ the SnapStartRESTORE_*pair /EXTENSION), which is where it mattered most — the IAM-propagation race this mechanism exists for IS a cold-start phenomenon, so the filter would have been inert in its own main case. The same round aligned theFunctionErrorarm onto the same filter, so the constant's claim ("a tail consisting only of these carries no diagnostic value") is true of the whole file rather than one arm; Lambda's ownTask timed out after .../Runtime exited with error: ...lines are NOT boilerplate by the pattern, so the realistic crash shapes still print. Tests: 11 new cases (the traceback case asserting the tail reaches the warn and NOT the thrown reason, the boilerplate-only guard, the dispatch-invoke disclosure, the cap, the two noise guards, and SUCCESS), bound by five mutation probes against the shipped source — removing the arm fails 3, dropping the boilerplate guard 1, dropping the!reasonIsAuthznoise guard 1, removing the cap 1, dropping the dispatch-invoke disclosure 1. No CLI flag, dependency, or state-schema change. -
cdkd importin cdkd-assets mode records the PRE-rewrite asset references in state (issue #1652) —src/cli/commands/import.ts,tests/unit/cli/import.test.ts,docs/design/1002-cdkd-asset-storage.md,docs/import.md. Before: design §7.1 specified "rewrite before writing state, so imported state matches what the next deploy would write (no spurious first-deploy churn)", andimport.tsrewrotestackInfo.templatein place BEFOREbuildStackStatereadtmplResource.Properties. That optimization is only sound for a resource whose live value the import actually reads back from AWS, and most providers'import()return just a physical id —IAMPolicyProvider.import()returns{ physicalId, attributes: {} }and never reads the live policy document. So state claimed the cdkd asset bucket while AWS still held the CDK bootstrap one, the deploy diff compared a rewritten template against a rewritten state, the change classifiedNO_CHANGE, and the live resource was never corrected. For theAWS::IAM::Policythats3deploy.BucketDeploymentgenerates that is a silent split-brain: the handler role keeps a grant oncdk-<qualifier>-assets-*whileSourceBucketNamesnamescdkd-assets-*, surfacing whenever the custom resource next runs as a runtimeAccessDenied— and it silently opts the stack back out of thecdk gcprotection issue #1002 exists to provide.cdkd diffshowed nothing, so there was no signal at import time; the only recovery wascdkd drift --revertor re-importing beforecdkd bootstrap. Now the template is still rewritten (that is what makes assets resolve, and nothing else about §7.1 changes), but astructuredClonesnapshot taken immediately BEFORE the rewrite is what feedsbuildStackState+resolveImportedProperties, sostate.propertiescarries the CDK-bootstrap values AWS actually holds and the first post-importcdkd deployproduces the corrective UPDATE. The recursive--migrate-from-cloudformationchild walk takes the same treatment — each nested child template is snapshotted before its own §7 rewrite — since a child's state write is a separate code path that would otherwise keep the old behavior. The trade is explicit and is the point of the change: one deploy's worth of churn in exchange for correctness when adopting a stack something else deployed. An info line naming the rewritten-reference count is emitted at import time so that churn is expected rather than surprising. The deploy-driven legacy → cdkd-assets migration was never affected (state there already held the oldcdk-*names, so its diff always produced a real UPDATE); this brings the import path in line with it. Unchanged:--use-cdk-bootstrap-assetsstill pins legacy destinations end to end, asset-less apps and legacy regions are byte-identical (no clone is taken when no redirect applies), and no state-schema, CLI-flag or dependency change is involved. Tests: the pre-existing#1002 PR 2case is INVERTED to assert the state-side pre-rewrite value while additionally asserting the template object WAS rewritten in place (so a fix that simply dropped the rewrite would fail), plus the info line; a new nested-child case asserts the same for the--migrate-from-cloudformationwalk. Both fail against the pre-fix source and pass after. - The three grandfathered
*Once-leak files are fixed, so the detector's allow-list now holds only its canary (issue #1655) —tests/unit/provisioning/elbv2-provider-roundtrip.test.ts,tests/unit/provisioning/iam-role-provider.test.ts,tests/unit/provisioning/s3-tables-provider-roundtrip.test.ts,tests/once-leak-allowlist.json,scripts/gen-once-leak-allowlist.ts,.claude/rules/testing.md,docs/testing.md. Before: the runtime detector added by issue #1618 measured 27 cross-test consumptions across these three files and grandfathered them, so each was exempt from the check — including for any test added to it later. After: every over-priming is removed, all three files are dropped fromtests/once-leak-allowlist.json, and the list contains nothing but the deliberately-leakingonce-leak-canary.test.ts, which is the documented goal state. The fix is priming-side rather than amockReset()drain inbeforeEach: a drained suite can never be flagged again, which would re-create exactly the grandfathering the ratchet exists to remove. What the 27 turned out to be: in all three files the surplus described a call the code path never makes, and in all three the priming's own COMMENT asserted that call —s3-tablesprimed aListTagsForResourcethatreadTableCurrentStategates onresp.tableARN, a field the mockedGetTableresponse omitted;iam-roleprimed three policy/tag no-op responses although a sibling test's helper in the same file already documented that those helpers issue ZERO sends for props carrying no policies or tags; andelbv2primed aDescribeTagsat seven sites althoughupdate()derives the tag diff from the desired-vs-previous property bags and never reads AWS tags. So the measured consumption contradicted the annotation every time. Whether any assertion was hiding a defect: no — this was checked per the issue's scope item 2 rather than assumed. Two shifted tests had genuinely never executed their own branch (s3-tables's Format-lessGetTablecase was reading the previous test's leftover tags response, and theiam-roleclear-on-removal trio was taking a leaked{}as itsGetRoleresponse), and both still pass once the branch actually runs, because each asserts on the COMMAND INPUT rather than on the response. Nothing to file separately. Kept honest afterwards by two guards that catch DIFFERENT regressions, which a probe forced us to state precisely: dropping the files from the allow-list re-arms the detector for the surplus case (re-introducing one primer and running withCDKD_ONCE_LEAK_DETECT=1fails, naming the priming test), whileexpect(mockSend).toHaveBeenCalledTimes(N)beside the existing assertions catches the complementary one — the code path changing how many calls it makes, which is what silently invalidates a priming in the first place. The count pin does NOT catch a surplus primer, because an unconsumed response leaves the count unchanged; that was measured, not assumed. Also fixes a mis-titled test (s3-tables' Format-less case announcedAWS::S3Tables::TableBucketwhile exercising::Table). Nosrc/**change: unit-test priming, the generated allow-list, its generator's now-stale$comment, and the two testing docs. Verified with the full suite underCDKD_ONCE_LEAK_DETECT=1(594 files, 11,179 tests, zero leaks, exit 0) plus thedetector canarystep, which must still FAIL with the detector's own wording — confirmed exit 1 carryingprimed by an EARLIER test. - The #756 custom-resource authz retry now fires when the handler SWALLOWED the denial (issue #1674, reported by @sakurai-ryo) —
src/provisioning/providers/custom-resource-provider.ts,tests/unit/provisioning/custom-resource-provider-authz-log-tail.test.ts. The bug: #756 retries a custom resource that FAILed on the IAM-propagation race (cdkd's fast SDK path writes the backing function's execution-role policy and invokes it seconds later, so the cold start caches pre-policy credentials), and it classifies on the FAILED reason string viaCR_TRANSIENT_AUTHZ_SIGNALS. But the reason is written by the HANDLER, so a handler that wraps an SDK / CLI failure in its own message — ordinary handler hygiene — erases every authz phrase before cdkd ever sees it. CDK'ss3deploy.BucketDeploymentis the widely-used instance and is GUARANTEED to hit the race, because CDK generates the handler role, its inline policy and the custom resource in the same stack:aws_command()letssubprocess.check_callraise,str(CalledProcessError)is onlyCommand '[...]' returned non-zero exit status 1., and the asset-object 403 reaches cdkd with no authz wording at all. So a resource #756 would have recovered failed the deploy on its FIRST attempt, with a message that reads like a handler bug; the 403 existed only in the backing function's CloudWatch log, which the user had to open to find out. The fix:invokeLambdanow passesLogType: 'Tail', so the invoke response carries the last 4 KB of THAT invocation's log inline (LogResult, base64).sendRequestreturns the decoded tail alongside the cfn-response, and when the reason itself carried no authz wording the retry consultsCR_TRANSIENT_AUTHZ_LOG_SIGNALS— the reason set PLUS the CLI / SDK spellings of the same denial (an error occurred (403)/accessdenied/access denied) — against the tail. Why the tail rather than the issue's suggested options: reading CloudWatch Logs after the fact (its option 2) would need a new client,logs:GetLogEventson cdkd's own credentials and an extra API call, and would target whatever is latest in the log stream rather than the invocation that failed; the tail rides the invoke response cdkd already awaits, so it costs none of those and is invocation-scoped. Triggering on the deploy-time IAM edge (its option 1, and the one the reporter favored) was NOT taken:CreateContextcarries onlyreplayingState, so there is no plumbing from the deploy engine to the provider today, and adding it would land indeploy-engine.ts— a cross-cutting file — to replace a signal the log tail already supplies precisely. Diagnostics were the other half of the report and are fixed too, and WHERE each thing is surfaced turned out to be a data-class decision rather than a formatting one — three independent reviewers converged on it. The axis turned out to be WHO AUTHORED THE TEXT, not how long it is, and it took TWO review rounds to land there — both earlier positions were wrong, and each was rejected by two reviewers independently. Round one: the first cut appended the WHOLE decoded tail to theFunctionErrorthrow. A thrown message is captured byextractDeploymentEventErrorintodeployments/{runId}.jsonl— a store that OUTLIVEScdkd destroyand whose own header restricts it to error + metadata, explicitly never anything that may carry secrets — so aprint(event)in a handler would have written that resource'sResourcePropertiesinto permanent state. Round two: the fix for that folded the matched LINE intocfnResponse.Reason,truncateReasond to 200 chars, on the argument that "the reason is already handler-authored free text persisted through this same path". That argument does not transfer, and the truncation is not the safeguard it looks like: a reason is text the handler CHOSE to hand to CloudFormation, a log line is text it wrote for itself, and 200 chars bounds the VOLUME while being precisely where a dumped properties bag begins. So what is folded in now is the matched SIGNAL PHRASE — one of the fixedCR_TRANSIENT_AUTHZ_LOG_SIGNALSstrings, authored by cdkd — and the persisted record carries zero handler-authored bytes while still saying "the log matched an IAM-authorization signal" instead of pointing back at CloudWatch. The verbatim line and the full tail live only in ephemerallogger.warns, themselves capped byCR_LOG_TAIL_WARN_MAX_CHARSbecause CI logs and scrollback are not nothing either. The tail is also returned UNDECODED fromsendRequestand decoded only on a FAILED whose reason missed, so the happy path pays nothing. Known bounds, recorded rather than papered over: the tail always belongs to the DISPATCH invoke — for a handler that works inline and PUTs the cfn-response itself (BucketDeployment, the case this exists for) that IS where the failure happened, but for the Provider framework's genuinely async pattern it is not, so a stray denial logged by theonEventwrapper buys a bounded extra retry naming the wrong execution; that path is otherwise covered by the reason string, so the cost is redundancy, not a wrong answer. A reviewer proposed gating the tail onisAsyncPatternto close that; it was NOT taken and the reason is recorded in-code, because that flag means only "the invoke returned no direct payload", which is equally true ofBucketDeployment(its Python handler returnsNone) — gating on it would have switched off exactly the case the change exists for. A bare403/forbiddenis likewise deliberately not a signal. Accepted cost: a handler that merely LOGS a genuine permanentAccessDeniedbuys the bounded retries, so the failure is DELAYED, never masked. Tests: 36 unit cases pinning the reporter's shape VERBATIM (the realBucketDeploymentreason and a real log tail) rather than a paraphrase that happens to still match — retry-then-succeed, the swallowed denial named in the warning,LogType: 'Tail'asserted on every invoke, the denial still reported when retries are exhausted, and the four ways the fallback must NOT fire (a handler bug whose tail carries no denial, no tail at all, an undecodableLogResult, the SNS path with no Lambda invoke), plus classifier cases covering the CLI / SDK denial spellings and non-denial log noise (downloading 403 objects,HTTP/1.1 403). Binding-proved by mutation against the shipped source: droppingLogType: 'Tail'fails 12 cases, disabling the log-tail retry arm 6, folding the VERBATIM handler line into the reason 4, appending the tail to theFunctionErrorthrow 1, and removing the ephemeral warn cap 1. The secret-leak probe is the one that changed most: the first version placed its marker at the END of the tail, so a regression folding a TRUNCATED tail sailed past it — the assertion is now anchored at the end of the message and catches any appended content. Real-AWS wire probe (us-east-1, 2026-08-12), because a mocked SDK test endorses a wire assumption instead of proving it: a throwaway Python Lambda shelling out viasubprocess.check_callreturned the reasonCommand '['/bin/cat', '/nonexistent/asset.zip']' returned non-zero exit status 1.with no trace of the cause, while the same invoke'sLogResultdecoded tocat: /nonexistent/asset.zip: No such file or directory— confirming that a handler's SUBPROCESS stderr does reach the tail, which is the single assumption the whole fallback rests on. Probe resources (function, role, auto-created log group) were deleted and verified gone. Four integs (custom-resource-provider,bucket-deployment— the resource the issue reports —,aws-custom-resource,custom-resource-getatt-data) all destroyed cleanly with 0 errors and 0 orphans. Two follow-ups the review surfaced are filed rather than bundled: the tail is still discarded for NON-authz terminal failures (issue #1687), and a handler that CRASHES on the same 403 (FunctionError) is still not retried although the signal is already in hand (issue #1688). No CLI flag, dependency, or state-schema change. - Glue: every catalog-scoped call threads
CatalogId, and{Ref: <AWS::Glue::Table>}resolves to the table name (issues #1675 / #1667) —src/provisioning/providers/glue-provider.ts,src/deployment/intrinsic-function-resolver.ts,tests/unit/provisioning/providers/glue-provider.test.ts,tests/unit/deployment/intrinsic-functions.test.ts. #1675 — the silent leak:GlueProvider.deleteTablesentDeleteTableCommand({DatabaseName, Name})with NOCatalogIdwhilecreateTableandimportTableboth forwarded it. Omitting the field selects the caller account's DEFAULT Data Catalog, so a table adopted into a cross-account or Lake Formation federated catalog answeredEntityNotFoundException, the delete path's warn-and-continue idempotency treated that as "already gone", andcdkd destroyreported SUCCESS while the table survived. cdkd's physical id (<databaseName>|<tableName>) encodes no catalog, but the properties bagResourceProvider.deletereceives does. A new shareddeleteCatalogId()/catalogIdForApi()pair reads the field so an unresolved intrinsic OBJECT is DROPPED rather than sent (theimportableStringguard #1651 introduced) and a YAML-numeric account id is COERCED to a string rather than dropped —--migrate-from-cloudformationreads the stack's ORIGINAL template, where an unquotedCatalogId: 123456789012parses as a JSON number, and dropping it would retarget the DELETE at the default catalog where a same-named table can exist and be destroyed instead. The NotFound arm now DISCRIMINATES: a NotFound after a correctly-targeted delete stays at debug, while one after cdkd knowingly fell back to the default catalog WARNS and names the manual remedy (not "re-run" — oncdkd destroythe state record is already gone). The account-id pseudo parameter ({Ref: AWS::AccountId}/ the twoFn::Subspellings — what@aws-cdk/aws-glue-alpharenders for an environment-agnostic stack) is deliberately EXCLUDED from that warning: its resolved value IS the API default, so dropping it is provably harmless and warning would fire a leak alarm on every ordinary destroy. Both log arms name the catalog actually addressed, so a LITERAL-but-wrongCatalogId(a typo, a stale account id) is diagnosable from the log. Sibling audit of every Glue delete path:deleteTablewas the missing one;deleteDatabaseandGlueConnectionProvider.deletealready forwarded it but through a bareas string | undefinedcast that would have sent an intrinsic object, and both moved onto the shared guard;DeleteWorkflow/DeleteSecurityConfiguration/DeleteJob/DeleteCrawler/DeleteTriggerhave no gap (neither the CFn type nor the SDK request shape declaresCatalogId), so Table / Database / Connection are the complete set. The same guard was extended to every OTHER read whose bag can be a raw template — the threereadCurrentStatereaders andGlueConnectionProvider.import(the straggler #1651 left on a bare cast), socdkd driftstops handingGetTable/GetConnectionan object. CREATE / UPDATE reads deliberately keep their cast (the deploy engine resolves intrinsics first; that is the separate #1513 decision), with the rollback-replay residual recorded in-code. #1667 — the wrongRef:AWS::Glue::Tablewas in neither ofcfnRefValueFromPhysicalId's compound-id Sets althoughGlueProviderstores<databaseName>|<tableName>, so{Ref: <Table>}resolved tomydb|my_tablewhere CloudFormation returns the TABLE NAME — and that value was pushed to AWS by whatever consumed it (a crawler'sTargets.CatalogTargets[].Tables, aCfnOutput, a Lake Formation permission). The type joinsREF_RETURNS_SEGMENT_AFTER_PIPE; the fix also reachescdkd orphan's{Ref: <orphan>}rewriter, which shares the same pure helper. Pre-#1651 the path was unreachable forcdk deploy-managed stacks (they could not be imported at all), which is what widened its reach. Composite-type sibling audit (every row ofdocs/state-management.md's composite table plus the two types it lists as ACCEPTING a composite), recorded as an AUDIT RECORD comment above the Set: correct to exclude becauseRefis a synthetic / AWS-generated id —ApiGateway::Method,EC2::NetworkAclEntry,EC2::Route,EC2::VPCGatewayAttachment,Lambda::EventInvokeConfig; correct to exclude because the docs page documents noRefat all —EC2::SecurityGroupIngress(and its Egress sibling) andLambda::Permission; already correct —EC2::EIP,S3Tables::Namespace/::Table,ECS::Service; KNOWN WRONG and not fixable by a Set entry, filed as #1681 —AppSync::ApiKey/::DataSource/::Resolver(Refreturns the resource ARN, so it must be RECONSTRUCTED like the WAFv2 WebACL case, not extracted) andRoute53::RecordSet(Refreturns "the name of the record" — the MIDDLE segment of<hostedZoneId>|<name>|<type>, which neither extraction direction yields). Two stale claims in the resolver were corrected in the same pass: the Set is no longer described as Cloud-Control-only (it now carries SDK-provisioned provider-built compounds too), and the after-last-pipe entry no longer claims the extraction is universally safe —createTablepacks${db}|${name}UNGUARDED, so a deploy-created table nameda|bis already broken independently of this entry (the create-path half of #1672). Tests: 24 new Glue cases (exactDeleteTableCommandinput withCatalogId; the drop-on-intrinsic, no-CatalogId-declared, absent-bag, numeric-coercion, non-finite, empty-string and explicit-nullarms; both polarities of the pseudo-parameter carve-out; the Database / Connection skip-helper call sites bykind; the malformed-physicalId skip arm the issue quotes as the leak surface; the four read-path sites) plus 3 resolver cases asserting the RESOLVED valuemy_table— bare, throughFn::Join, and the pipe-free no-op. Binding proofs run for each fix by stripping it from real code and observing the named failures.intrinsic-function-resolver.tsis in theinteg-broadmarkgate scope, so a broad real-AWS integ is required before merge. No CLI flag, dependency, or state-schema change. -
AWS::S3::Bucket's warn-and-SUBSTITUTE reads now record the value they SENT, and the analytics readback stops emitting a shape CloudFormation does not declare (issue #1670) —src/provisioning/providers/s3-bucket-provider.ts,tests/unit/provisioning/s3-bucket-provider-substituted-properties.test.ts,.claude/rules/providers.md,docs/provider-development.md. The gap: #1612 fixed the warn-and-SKIP arms; the warn-and-SUBSTITUTE reads in the same appliers were deliberately left alone (they are the reason the skip signal had to be explicit rather than anonUnusablewrapper) and were the same bug class unfixed. The issue named three —applyAnalyticsConfigurations'StorageClassAnalysis.DataExport.OutputSchemaVersionand destinationFormat, plusapplyInventoryConfigurations' destinationFormat— and review found a FOURTH inside the same method: the inventoryScheduleFrequency->Schedule.Frequencyfall-through (#1605), which warns, SENDS the second source, and left the malformed first source in the record. Each WARNS and then SENDS a substituted value, so the Put SUCCEEDS and the engine recorded the malformed DECLARED value while AWS holdsV_1/CSV/ the fallen-back cadence;analyticsSdkToCfn/inventorySdkToCfnread all of them back, so the difference was visible to the comparator and never converged — the #1591 / #1633 phantom-drift loop reached through a per-item Put. Now the fourId-keyed appliers return aPerItemApplyOutcome({skipped, substituted}) instead of a bare index list, and both call-site recorders fold the substituted item intoeffectiveProperties: on UPDATE and on the replay-CREATE arm alike the item is kept IN PLACE, in its DECLARED shape, with only the substituted field replaced — a substitution is not a skip (the configuration WAS applied), so collapsing the two arms would drop the item on create and retain a stale previous item on update, manufacturing a fresh phantom drift in place of the one being removed. Three details are load-bearing: the write-back targets the destination branch the template actually declared (the CFnDestinationblock is accepted flattened AND nested, so a hardcoded branch leaves the malformed value alive at the other key and adds a stray one —s3BucketDestinationSegmentsderives it from the picked bag's IDENTITY rather than re-runningisFlattenedDestination, since a third copy of that branch condition is what the predicate's own header warns about);readSubstitutedConfigStringhands the recorder the value the read RETURNED rather than the fallback literal, and forwards ONEConfigStringOptionsbag to both theconfigStringRefusalprobe and thereadConfigStringit fronts, so neither "recorded vs sent" nor "probe vs read" can drift apart; and the item is COPIED, never mutated, because the desired bag is still read byDiffCalculatorafterwards. WithoutonUnusablethere is no probe and the template-borne CREATE still REFUSES, which the substitution deliberately does not weaken. A send-side record is only half a fix, and the review caught the other half missing:analyticsSdkToCfnemitted the SDK's NESTEDDestination: { S3BucketDestination: … }wrapper, a shape the CFn schema does not declare at all —tests/fixtures/cfn-schemas/AWS-S3-Bucket.jsoncontains zero occurrences of that key and itsnestedPropertyPathsend atStorageClassAnalysis.DataExport.Destination.{BucketAccountId,BucketArn,Format,Prefix}, while the inventory sibling already emitted the flattened form. So the WHOLE analytics destination sub-object differed from any template-shaped or effective-properties-shaped baseline on every comparison, and the analyticsFormathalf of this fix would have done nothing. The mapper now emits the flattened CFn shape with the same!== undefinedper-field guards asinventorySdkToCfn(keeping theBucket->BucketArnrename, and the SDK'sBucketAccountIdspelling, which isAccountIdon the inventory side — the two mappers look alike and are NOT interchangeable on that member). Transitional note: a bucket whoseobservedPropertieswere captured by an older binary holds the nested shape, so the firstcdkd driftafter upgrading reports the analytics destination once; the next deploy (orcdkd drift --accept) re-captures the flattened shape and it converges. Deliberately NOcanonicalizeDesiredPropertiestwin, and NOT by inheriting #1612's carve-out — that one is about a SKIP, whose effect is not a pure function of the desired bag, whereas a substitution's is, so the #1633 twin rule genuinely reaches these sites and was answered on the merits, in three findings now recorded as a reusable checklist in.claude/rules/providers.md. (1) The hazard the twin averts — a create-only property re-reading as a REPLACEMENT — cannot arise:AWS::S3::Bucket'screateOnlyPropertiesisBucketName/BucketNamePrefix/BucketNamespaceand neither array property is named in either half of the type'sReplacementRulesRegistryentry, soisClassifiedis false, the createOnly fallback decides, and the un-canonicalized diff derives an in-place UPDATE re-issuing the same idempotent Put. (2) Sharing the twin with the provisioning path is a COST question, not an impossibility one — a path-conditional substitution is no obstacle in itself, sincenarrowIngressIpProtocolalso throws without anonUnusableandcanonicalizeDesiredPropertiesbridges it with a no-op callback; what differs is that EC2 folds one top-level scalar while the effective value here is rebuilt per ITEM at the declared destination branch inside a per-Idloop, so the shared pure helper would re-implement the applier's item walk. (3) The twin would CONCEAL the defect, silencing the one warning the user gets on UPDATE while an identical fresh deploy hard-refuses — and EC2 accepts exactly that concealment because there it PREVENTS a replacement of a rule AWS already holds, whereas finding 1 says there is no replacement to prevent here. The accepted cost is the mirror image of the drift this removes —cdkd diffkeeps reporting the property until the template is corrected, which is TRUE and ends with one template edit — and a unit row fences the decision so implementing the hook later has to be a re-derivation rather than a reflex. The #1643 bar ("record what AWS will REPORT") is met by measurement of the API surface:StorageClassAnalysisSchemaVersionhas exactly one member (V_1),AnalyticsS3ExportFileFormatexactly one (CSV),InventoryFormatisCSV | ORC | Parquetwith no aliasing, and both reverse mappers copy the fields straight back — there is no service-side value MAPPING (theIpProtocol: 6 -> tcpshape), so the send-side record is the one that converges. Tests: 25 cases — the five malformed shapes each asserted against the value read back off the SENT command (not merely against a literal), both destination branches with the sibling-key-absent assertion, two substitutions accumulating in one item, a SUBSTITUTED and a SKIPPED item coexisting in one array, the substituted item recorded at ITS OWN index behind a skipped and a clean one (a hardcoded0passes all 5727 provisioning tests otherwise), the replay-CREATE item kept rather than dropped with its wire value asserted, theScheduleFrequencyfall-through with its skip-arm and precedence siblings, a closed-loop round trip (desired -> effective record -> the PUT configuration echoed back through the List API ->readCurrentState-> the realcalculateResourceDriftreports nothing) paired with a negative twin proving the declared value does NOT converge, the desired bag asserted unmutated, and three negative-polarity rows (a well-formed value and an ABSENT field both record NOTHING —effectivePropertiesundefined, not equal-to-desired — and a template-path create still throws). Five mutation probes: neutering the substitution report fails 14 of 19 pre-existing rows, hardcoding the flattened destination branch fails exactly the 2 nested rows, hardcoding the recorded index fails the index row, deleting theScheduleFrequencyrecord fails its row, and reverting the analytics mapper to the nested shape fails the convergence row. Live coverage:tests/integration/s3-analytics-inventorygains three phases, because the fixture passed for the wrong reason — nothing in it carried a malformed value, so no substitution arm was ever reached, nothing read the state record back, and nothing compared the recorded bag against the readback (the only comparison that could have caught the nested-vs-flattened mapper). A newCDKD_TEST_UPDATE=malformed-substitutemode blanks the three template-expressible fields (a BLANK STRING is what an unresolved intrinsic produces, is exactly whatconfigStringRefusalrefuses, and needs no cast to express). Phase 2 asserts the deploy succeeds, that each substitution was ANNOUNCED — the anti-vacuity guard, since the substituted values equal phase 1's and a run where the Put never fired would satisfy every state assertion — that AWS holds the defaults, that the STATE record holds the SUBSTITUTED value, and that the recorded AND observed analytics destinations are FLATTENED; the observed side is written byanalyticsSdkToCfnfrom real AWS data, so it is the live proof of the mapper half. The mode leaves every other value at its phase-1 setting, so the substituted result is byte-identical to the phase-1 template (verified by synthesizing all three modes locally) and phase 3 requirescdkd diff --jsonto report no analytics/inventory difference, twice — with a NEGATIVE twin that rewrites the recorded destination into the pre-fix NESTED shape and requirescdkd diff --failto NAME it, so a non-zero exit for an unrelated reason cannot satisfy it.diffrather thandriftbecause the drift comparator prefersobservedPropertiesand both sides of that comparison come from the same readback mapper, so a wrong-shaped mapper agrees with itself. Phase 4 hand-patches the state record malformed and redeploys the valid template: that does NOT reach the substitution arms (on a redeploy the desired bag comes from the TEMPLATE) but does prove only the DESIRED side is guarded, so a value an older binary recorded cannot wedge the stack. TheScheduleFrequencyfall-through stays unit-only — the CFn schema declares noSchedulemember and aws-cdk-lib's L1 renderer drops a member it does not declare, so no CDK template can carry the second source. No CLI flag, dependency, or state-schema change. - DynamoDB GlobalTable
StreamSpecificationand Lambda URLAuthTypewarn arms now reporteffectiveProperties(issues #1653 / #1654) —src/provisioning/providers/dynamodb-globaltable-provider.ts,src/provisioning/providers/lambda-url-provider.ts,tests/unit/provisioning/dynamodb-globaltable-provider-stream-spec-effective-properties.test.ts,tests/unit/provisioning/lambda-url-provider-authtype-effective-properties.test.ts,.claude/rules/providers.md,docs/provider-development.md. The two non-S3 sites the #1612 audit found. Before: both guards let the deploy SUCCEED while the declared value never reached AWS as written, so the engine recorded the malformed desired bag —readCurrentStatecould never match it, every latercdkd driftre-reported the same difference, anddrift --revertre-issued the same call. Now each returns the bag actually delivered, and the answer differs per arm because what reached AWS differs. GlobalTable UPDATE (the #1551 warn-and-SKIP) retains the PREVIOUSStreamSpecification— theUpdateTablenever ran, so AWS still holds the previously-applied configuration. Lambda URL's previous-value arm records the previousAuthType, which is literally what went on the wire. Lambda URL's OMITTED arm (previous side unusable too, soAuthTypeis left out of the merge-semantics update) DROPS the key: recording the create default'NONE'would describe a PUBLIC function URL that may still be IAM-guarded, and recording the live value off theUpdateFunctionUrlConfigresponse would be what AWS HOLDS rather than what cdkd SENT — a read-back value belongs inobservedProperties, not in thepropertiesbaseline the #1160 absent-field removal derivation reads — while a dropped key is never compared bydrift-calculator, so the phantom drift is gone either way. Neither gains acanonicalizeDesiredPropertiestwin, per the #1612 carve-out: these are a SKIP and a SUBSTITUTION, not pure narrowings, so canonicalizing the desired side would derive a REMOVAL and disable the live stream / auth type. The GlobalTable replay-CREATE arm deliberately DIVERGES from the carve-out's "replay-CREATE -> DROP the key" answer, and both rules files now record why: that answer is scoped to a SKIP, where nothing reached AWS, whereas this arm's downgrade SUBSTITUTES theNEW_AND_OLD_IMAGESdefault and a stream really is created — so #1633's "what you return is what you SENT" binds instead, and the value is recorded in the CFn shape (StreamViewTypeonly — GlobalTable'sStreamSpecificationdeclares noStreamEnabled, verified against aws-cdk-lib's converter) so the effective bag is indistinguishable from an ordinary template-path create's. That arm was NOT observable when this landed — the only caller that setsreplayingStatediscardedeffectiveProperties— but #1682 / PR #1696 has since wired the reverse-replacement create to honour it, so the arm is LIVE; what it still lacks is per-provider live coverage (the #1696 fixture proves the engine path viaAWS::EC2::Route), tracked with the S3 create arm from #1660 as #1706 and flagged in-code at both the provider arm and the fixture. Two review findings shipped with it. The previous value is now VALIDATED through the sameconfigStringRefusalpredicate the desired side runs before being retained:previousPropertiesis a STATE record, so on a replay it can holdnull/''/ a bare string, and copying that in re-created the same phantom drift from the other direction; when both sides are unusable the key is dropped, matching the Lambda arm, and the retained value is COPIED because the rollback executor spreads the answer shallowly. AndLambdaUrlProvider.creategained a replay warning for an ABSENTAuthType: an absent value is not malformed, so neither the refusal norreplayWarnfires and'NONE'applies silently — meaning the new drop would have let a later reverse-replacement replay recreate the URL as PUBLIC with no evidence anywhere, strictly worse than the malformed-value-in-state behavior it replaced. The default still applies (refusing would make the URL unrestorable) but is now announced, and a template-path create with a legitimately absentAuthTypestays silent. Tests: 30 cases across the two new files — exact recorded values on every arm, the six malformed previous-side shapes, the empty-block previous that must still be retained, the copy-not-alias pin, the identically-malformed pair that never reaches the skip at all, the wire-negative proving nothing was sent, and the negative polarities (a well-formed value, an absent value and the diff-based no-op all answer with noeffectiveProperties); mutation-probed, with the create warn, the previous-side validation and the copy each failing their own cases when reverted. Sibling arms in the same GlobalTable method —BillingModewarn-and-SUBSTITUTE,desiredGsiUnusablewarn-and-SKIP, theneedsStreamauto-enable and theStreamSpecification: {}silent default — are the same class, out of scope here, and tracked in #1683 with in-code pointers. No CLI flag, dependency, or state-schema change. -
AWS::Glue::Tableimport accepts CloudFormation's physicalId, not only cdkd's composite form (issue #1651, reported by @sakurai-ryo) —src/provisioning/providers/glue-provider.ts,tests/unit/provisioning/providers/glue-provider.test.ts. The bug: cdkd's physicalId for a Glue table is the composite<databaseName>|<tableName>, built bycreateTablebecauseGetTable/UpdateTable/DeleteTableall need both segments whileResourceProvider's read-side methods (getAttribute,readCurrentState) receive a single string. CloudFormation's physicalId for the same type is the TABLE NAME ALONE —Refreturns it,DescribeStackResourcesreports it, and it never contains|. Auto-mode import merges CFn-derived ids into the overrides map before the loop (#1128 / #1130), soknownPhysicalIdlegitimately arrived as"my_table";importTabledidconst [dbName, tName] = knownPhysicalId.split('|')and returnednullon the resultingtName === undefinedWITHOUT ever calling AWS. Everycdk deploy-managed Glue table therefore reportedskipped-not-foundundercdkd importandcdkd import --migrate-from-cloudformation, while the accompanying hint told the user topass --resource <LogicalId>=<physicalId>— pointing them at exactly the id the code had just rejected. The template-derived fallback below would have succeeded (CDK rendersDatabaseNameas aRefto the siblingAWS::Glue::Database, whose CFn physicalId IS the database name, sosubstituteOverrideRefsresolves it to a literal beforeimport()runs), but the presence ofknownPhysicalIdshort-circuited past it — which is what makes this an early-return defect rather than a missing feature.docs/import.mdlists the type under "Auto-resolved (no--resourceflag needed)", a claim the fix makes true. The fix:resolveTableIdentityresolves the(databaseName, tableName)pair from EITHER shape — a|inknownPhysicalIdmeans the composite, its absence means CloudFormation's bare table name, paired with the template'sDatabaseName. The probe result is ALWAYS normalized back to<db>|<table>before it is recorded, sinceupdateTable/deleteTable/readTableall split the stored id — adopting a table under the bare CFn name would have written a state record the rest of the provider cannot use, trading a visible not-found for a silent one (deleteTable's malformed-id arm warns and SKIPS, so the failure would have surfaced as a leaked table on acdkd destroythat reported success).|is the discriminator, and the review established its limit rather than letting the code assume it away: the "lowercase alphanumerics and underscore" rule usually quoted for Glue is the Athena / Data Catalog CONVENTION, not the API's constraint — a live probe (us-east-1, 2026-08-12) showedglue:CreateTableACCEPTS a table literally nameda|b. Such a table is deliberately NOT adoptable, and arriving at that answer took two rounds. The first response to the probe was a fallback: try the composite reading, then retry the bare one. Three independent reviewers converged on why that is wrong — the fallback SUCCEEDS by recording<db>|a|b, andupdateTable/deleteTable/readTableall destructure the stored id into exactly TWO segments, so that record decodes as database<db>, tablea— a DIFFERENT table, whichdeleteTablewould then delete. It traded a visible not-found for precisely the silent corruption the normalization exists to prevent, and the test written for it asserted the broken id as correct output, locking the invariant in. Issue #1658 landed mid-review as independent confirmation:AWS::Route53::RecordSet'simport()accepts CloudFormation's id verbatim and the adopted stack becomes UNDESTROYABLE — the same class, and the reporter's framing is that the accepted-into-state half is the more severe one. So the fallback was REMOVED and replaced by a round-trip guard:resolveTableIdentityrefuses any resolved segment containing|, whatever branch produced it (the composite and bare branches are|-free by construction, but a templateTableInput.Nameofa|bis not), and the refusal happens BEFORE any AWS call. A third review round then found that guard incomplete, in the direction that matters: an id with MORE than two segments destructured to its first two, somydb|a|bread as databasemydb, tablea— both|-free, so the guard passed — and cdkd probed and adopted a DIFFERENT table under an id that round-trips cleanly and therefore never looks wrong again. That shape is on the INVITED path rather than hypothetical, because the refusal warning tells the user to pass<databaseName>|<tableName>, so someone whose table is nameda|btypes exactly it. The composite branch now requires EXACTLY two segments. The refusal also stopped guessing its own reason:resolveTableIdentityreturns a discriminated{ok: false, reason}(pipe-in-name/unpairable/unidentified) because re-deriving the cause at the call site got it wrong —'db|'contains a|but its real defect is the empty segment — and the three causes need three different messages, one of which ("rename the table, no id shape will work") is not the--resourcehint at all. A non-not-found error is rethrown rather than reported as absent — a throttle or an authorization failure means the answer is UNKNOWN. An explicit override wins over the template'sTableInput.Name— the user named a specific resource to adopt.importableStringalso covers the override itself, so a--resource-mappingentry of""(whichparseMappingJsonaccepts, unlike the--resourceflag) stays not-found instead of sendingGetTable({Name: ''}), whoseInvalidInputExceptionis notEntityNotFoundExceptionand would abort the whole import run. A bare id with no templateDatabaseNameto pair with still returnsnullwith no AWS call, but now WARNS with the composite form spelled out, because the caller-side hint tells the user to pass--resource <LogicalId>=<physicalId>and passing CloudFormation's bare name again fails identically — the dead end this issue is about, one step over. Second, independent defect, same issue:CatalogIdwas read asproperties['CatalogId'] as string | undefinedand forwarded toGetTable/GetDatabasewhenever truthy.@aws-cdk/aws-glue-alphasetscatalogId: Stack.of(this).account, which renders as{"Ref": "AWS::AccountId"}for an environment-agnostic stack; a pseudo parameter is never in the overrides map, sosubstituteOverrideRefsleaves the intrinsic in place and the cast handed an OBJECT to the Glue API as if it were a catalog id. The newimportableStringguard drops any non-string, which matches the API default (the caller's own account) — i.e. what the intrinsic would have resolved to anyway. It is applied toCatalogId,DatabaseNameandTableInput.Nameon the import path in bothimportTableandimportDatabase. Deploy-path reads are deliberately untouched: the deploy engine resolves intrinsics before the provider sees them, so guarding there is the separate #1513 decision. Scope correction on the issue's fourth suggestion: adding anAWS::Glue::Tableentry toCOMPOSITE_ID_SPLITTERSinsrc/cli/commands/export.tswould be dead code.DescribeTypereportsprimaryIdentifier: ["/properties/Id"]— a SINGLE field — soresolveResourceIdentifiertakes the single-key branch and returns{ Id: physicalId }before ever reaching the splitter lookup. The export-side defect is real (cdkd's composite is handed to CFn asId) but needs a different mechanism, so it is tracked separately rather than bundled here. Tests: 17 unit cases plus a real-AWS integ phase. Units: the bare CFn id paired with the template database, the composite accepted with NO templateDatabaseNamepresent (so the composite cannot come to depend on it), the template-only fallback, a bare id with nothing to pair with, the warning naming the composite form, BOTH precedence directions where the override and the template DISAGREE (a review finding — with agreeing values, swapping the precedence passed every other test in the file, so it pinned nothing),EntityNotFoundExceptionwith an explicit call-count assertion (another review finding —toBeNull()alone is satisfied by "declined before calling AWS", i.e. the pre-fix behavior), a non-not-found error rethrown rather than reported as absent, thea|brefusal with no AWS call, an empty-string override staying not-found, an unresolvedCatalogIdintrinsic dropped on both the Table and Database paths, a literalCatalogIdstill forwarded, and an unresolvedDatabaseInput.Nameintrinsic no longer probed with. A 3-case ROUND-TRIP fence covers the property #1658 shows actually matters, independent of which branch produced the id: whatever is recorded must have exactly two segments and decode back to the pair that was probed. The headline case was mutation-probed: forcing the composite branch unconditionally fails it. Real-AWS coverage — the fix is about an id auto mode had already resolved, so a mocked test cannot show the resolution actually happened: theimport-auto-modefixture (which exists for #1128, the RESOLUTION half of this same path) gains a Glue database + table pair.DatabaseNameis aRefto the sibling database — what CDK emits, and the reason a bare table name can be paired at all — phase 2b asserts CloudFormation's reported table id carries no|before anything relies on it, phase 3 asserts the CFn lookup seeded all THREE ids (without it, dropping the table's override would letimportTable's template fallback produce the identical composite and the fixture would stay green while testing nothing — a review finding), phase 4b asserts the ADOPTED id is the composite, and phase 5 gone-probes both Glue resources. Verified in us-east-1: 3 imported / 0 not found, 3 deleted / 0 errors / 0 orphans. Review also found two defects OUTSIDE this diff, filed rather than bundled: #1667 —{Ref: <AWS::Glue::Table>}resolves to the composite because the type is in neither ofintrinsic-function-resolver.ts's compound-id sets, a pre-existing bug this fix WIDENS the reach of (previously such stacks could not be adopted at all, so the path was unreachable); and #1668 —S3TablesProvider.import()adopts an unverifiedknownPhysicalId, the silent variant of this same class. The suite'sbeforeEachalso gained amockGlueSend.mockReset(), sincevi.clearAllMocks()does not drain themockResolvedValueOncequeue and a test failing before it consumes its primed response shifts it into the next one — the leak class issue #1618's detector exists to catch. No CLI flag, dependency, or state-schema change. -
AWS::S3::Bucket's warn-and-skip replay arms now reporteffectiveProperties, so a skipped Put stops producing permanent phantom drift (issue #1612) —src/provisioning/providers/s3-bucket-provider.ts,tests/unit/provisioning/s3-bucket-provider-effective-properties.test.ts,.claude/rules/providers.md,docs/provider-development.md. Before: issues #1579 / #1581 / #1595 / #1605 each converted a hard refusal into a warn-and-skip on the replay-reachable paths — the right call, since a refusal there leaves the resource UN-ROLLBACKABLE with no template-side remedy. But the deploy then SUCCEEDS, soDeployEngine.propertiesToRecordrecorded the desired bag, malformed value and all, for a Put that never reached AWS.readCurrentStatecould never match it, every latercdkd driftre-reported the same difference, anddrift --revertre-issued the same skipped call — the #1591 phantom-drift loop, reached through a SKIP instead of a narrowing. The engine side has been ready since #1591 and, since #1644, so havedrift --revertand both rollback revert arms; the missing half was that the S3 skip paths never RETURNED it. Now all eight skip-capable appliers report their skip explicitly —applyVersioning/applyLifecycleConfiguration/applyLoggingConfiguration/applyReplicationConfigurationreturn aPromise<boolean>"applied", and the four per-Idappliers (applyMetricsConfigurations/applyAnalyticsConfigurations/applyIntelligentTieringConfigurations/applyInventoryConfigurations) return the indexes of the items they skipped — andcreate()/update()fold those intoeffectiveProperties. Reporting the skip EXPLICITLY rather than by wrappingonUnusableis load-bearing: that callback is shared by SKIP-class guards (configStringRefusal/requireConfigObject/requireConfigArray) and warn-and-DEFAULT reads (readConfigStringwith the options bag, where the applier proceeds WITH a substituted default — the analyticsOutputSchemaVersionand the analytics / inventory destinationFormat), and a wrapper cannot tell them apart, so a defaulted-but-APPLIED configuration would have been recorded as skipped and manufactured exactly the drift this removes. What is recorded differs per path and neither answer generalizes: on UPDATE the PREVIOUS value is retained (the Put never ran, so AWS still holds the previously-applied configuration; dropping the key instead would be wrong in the other direction — a later template that REMOVES the block would derive no removal and the live configuration would survive forever), on the replay-CREATE arm the key is DROPPED (the bucket is new, nothing was applied, there is no previous value to keep), and for the per-item appliers the effective array substitutes the previous item of the sameIdIN PLACE — dropping it when the skipped item was an ADD — preserving the DESIRED order, becauseDiffCalculatorcompares arrays positionally and a reordered effective array would manufacture a fresh phantom drift. Two skips beyond the malformed-value class take the same remedy: the Object-Lock arm that declines to suspend versioning (a structural AWS constraint, identical state consequence), and the create-path logging GATE. Deliberately NOcanonicalizeDesiredPropertiestwin, which is the opposite of what.claude/rules/providers.mdanddocs/provider-development.mdpreviously read as an unconditional rule — both now carry the carve-out. That rule is about a NARROWING, a pure function of the desired bag; for a SKIP the twin is actively destructive, because canonicalizing the desired side drops the malformed configuration from it and a previous side holding a VALID configuration then derives a REMOVAL — deleting the live lifecycle / replication configuration over one unusable field. Tests: 16 cases (per-path recording, the ABSENT-previous key removal, per-item in-place substitution, a skipped ADD dropped, and the two negative rows assertingeffectivePropertiesstays UNDEFINED when nothing was skipped); five mutation probes confirm they fence the behavior — dropping the key instead of retaining previous fails 7, appending retained items instead of substituting in place fails 1, always answering with the bag fails 2, recording[]instead of removing the key fails 1, and neutering the replay-CREATE arm fails 1. One non-obvious constraint is pinned in-code: the per-item loops keep a hand-kept index over a plainfor…ofrather thanconfigs.entries(), because the nested-key critic's write-evidence walk resolvesconfigas a value read off the desired property bag and cannot follow an array-destructuring pattern — the tidier spelling reported 9 falseno-write-evidencedivergences. No CLI flag, dependency, or state-schema change. The audit of item 3's non-S3 warn-and-skip sites foundAWS::EC2::Routealready covered by #1591, and two live gaps filed as #1653 (DynamoDB GlobalTableStreamSpecification) and #1654 (Lambda URLAuthType). - EC2: the two
IpProtocolresiduals split out of #1643 (issues #1648 / #1649) —src/provisioning/providers/ec2-provider.ts,tests/unit/provisioning/{ec2-canonicalize-ip-protocol-diff,ec2-provider-readcurrentstate}.test.ts,tests/integration/drift-revert-arrays/verify.sh,.claude/rules/code-layout.md. #1648 — the DIFF side. #1643 taught the readback that AWS substitutes a canonical NAME for four protocol numbers, butcanonicalizeDesiredPropertiesstill folded only the TYPE (#1633's stringification). So a template edit rewritingIpProtocol: 6to'tcp'read as a CHANGED property, and sinceIpProtocolis create-only onAWS::EC2::SecurityGroupIngressthe diff classified a REPLACEMENT — deleting and re-creating a rule AWS already held exactly. Both diff sides now get the name fold, for the standalone type and forAWS::EC2::SecurityGroup's inlineSecurityGroupIngress[]/SecurityGroupEgress[]lists (lower stakes there — not create-only — but the diff previewed anupdate()that, since #1643 keys both spellings identically, issues no revoke/authorize at all). The fold sits incanonicalizeDesiredPropertiesand deliberately NOT innarrowIngressIpProtocol, which also feeds the CREATE path: what cdkd puts on the WIRE is not the defect, since AWS accepts either spelling and storestcpfor both. #1649 — Tags were in neither camp. Every SIBLING standalone type (SecurityGroupIngress,Route,VPCGatewayAttachment,SubnetRouteTableAssociation,NetworkAclEntry,SubnetNetworkAclAssociation) declaresTagsingetDriftUnknownPathsbecause its read cannot return them.AWS::EC2::SecurityGroupwas neither declared NOR read, so a templated tag block compared againstundefinedand reported permanent phantom drift on theproperties-fallback baseline — the very population #1643's comparator half exists for, which is how it surfaced. Of the two candidate fixes the better one turned out to be available:DescribeSecurityGroupsreturnsTagsin the CFn[{Key,Value}]shape verbatim (measured us-east-1 2026-08-12), so the provider READS them rather than declaring them unreadable, and a REAL tag change becomes detectable instead of being permanently hidden.normalizeAwsTagsToCfnstrips the reservedaws:-prefixed entries a template can never declare; the key is omitted entirely when AWS reports no tags, so an untagged group is never compared against[]. Landing this let thedrift-revert-arraysstep 6bTagsworkaround be deleted. Both halves mutation-probed; 9 + 3 unit tests. - "Use in CI: per-PR environments" section in README + distributed skill v0.4.0 —
README.md(new section after the wait-modes section),plugins/cdkd-skills/skills/cdkd/SKILL.md, both manifests bumped to 0.4.0. CI was one of cdkd's main use cases with no assembled guidance: the pieces (diff --fail,publish-assets,--no-waitCI-minutes rationale,force-unlockafter cancelled jobs) were scattered, and--role-arn— the switch-role pattern most CI users need — appeared in README only as a Prerequisites link and in the skill not at all. The new README section follows the maintainer's Zenn article (cdkd-pr-environment-ci; content provided by the maintainer, deliberately NOT linked from the README — English-only reader base): per-PR stacks via-c prNumbercontext + name suffix (state keyed by (stackName, region), per-stack locks → concurrent PR deploys), the OIDC base-role →--role-arn/CDKD_ROLE_ARNswitch into a dedicated admin-equivalent deploy role (base role holds ONLYsts:AssumeRole; trust policy pinned back;cdk-hnb659fds-*roles unusable), a minimal GitHub Actions workflow (deploy on open/sync/reopen; destroy on close viastate destroy --yeswith no checkout/npm ci/synth), and housekeeping (state list --jsonsweeps,gcoutside deploy hours because of the lock abort,force-unlock,state show --jsonfor PR comments). The skill gains a matching "Use cdkd in CI" section placed AFTER the destructive-operation guards so its non-interactive exception reads as a refinement: in a CI workflow--yesis the sanctioned confirmation (the approval happened at workflow review), while interactive sessions keep the confirmation rules. One fact-check against the article: the stale-lock TTL is 30 minutes in current code (lock-manager.tsttlMinutes ?? 30), not the article's 15 — docs follow the code. - A runtime
*Once-leak detector now fails any test that consumes another test's primed mock response (issue #1618) —tests/once-leak-detector.ts,tests/setup.ts,tests/once-leak-allowlist.json,scripts/gen-once-leak-allowlist.ts,vite.config.ts,.github/workflows/ci.yml,.claude/rules/testing.md,docs/testing.md. Before:vi.clearAllMocks()clears call RECORDS but does not drain the queue seeded bymockResolvedValueOnceand its four siblings, so a test that primed more responses than its code path consumed leaked the remainder into a later test in the same file — which then read a response describing a different resource, took a different branch, and still PASSED, because the assertions on that branch are typically absence assertions (toBeUndefined(),not.toHaveBeenCalled()) that "the code never got there" satisfies just as well as "the guard correctly declined". Issue #1588 hit exactly this; its only symptom was onelogger.warnthat was never called, and locating it took a full instrumentation pass. After: every*Oncespelling funnels throughmock.mockImplementationOncein@vitest/spy, so instrumenting that one method wraps each queued implementation in a closure that remembers which test primed it; a value shifted off the queue during a DIFFERENT test fails that test, naming the earlier test to fix. The detector is inert unlessCDKD_ONCE_LEAK_DETECT=1, so the defaultvp run testis byte-for-byte unchanged and no in-flight branch is disturbed — the newonce-leak-detectCI job (vp run test:once-leak) is what arms it. Three pre-existing offenders are grandfathered intests/once-leak-allowlist.json(regenerate withvp run gen:once-leak-allowlist; fixing them is issue #1655), and an empty list is the goal state. Notable non-choices, all measured rather than assumed: the issue's own proposal — a static lint requiringmockReset()wherever a*Onceprimer appears — was rejected because 182 of the 265*Once-using files have no reset and the mechanical swap breaks 1181 tests, i.e. it checks a proxy and needs a 182-file remediation batch; and the first implementation's "queue non-empty when the test ends" heuristic was rejected after a probe suite showed it flags a suite that drains withmockReset()inbeforeEach(a setup-fileafterEachruns BEFORE the next test'sbeforeEach), which is the very remediation the docs prescribe. Checking the real defect implicated 3 files instead of 182 and needed no remediation batch. A deliberately-leaking canary suite plus adetector canaryCI step close the gate's own vacuity hole: with the grandfather list honoured, "the detector went dead" and "the tree is clean" produce the identical green result, so that step re-runs the canary alone with the allow-list ignored and requires a failure carrying the detector's own wording (an exit-code-only check would pass on a deleted canary file, whose "No test files found" also exits 1). The three-axis review then caught a real blocker:consumeOnceis afunctiondeclaration and therefore constructable, sotests/setup.ts's pre-existingwrapConstructableImplementationsaw a constructable value, passed it through unwrapped, and an ARROW implementation underneath died onReflect.construct— makingvp run test:once-leakdiverge behaviourally fromvp run test. Both patches now sharetests/constructable-implementation.ts. The same review found the construction test was a SURVIVOR (@vitest/spy already reaches the wrapper viaReflect.construct, so athis-mutating probe passes under.applytoo) and that thevi.spyOnarm had NO coverage at all — deleting it kept every check green while 13 real files lost protection. Both are now mutation-verified. 31 unit tests for the detector and allow-list; the detector was additionally probed against real code in both directions — a deliberate over-priming intests/unit/utils/aws-region-resolver.test.tsmade it exit non-zero naming the leak, and the allow-list suppressed it. -
drift --revertand the rollback revert arms now honour a provider'seffectiveProperties(issue #1644, found by the test review on PR #1641) —src/cli/commands/drift.ts,src/deployment/rollback-executor.ts,tests/unit/cli/drift.test.ts,tests/unit/deployment/rollback-executor.test.ts,docs/cli-reference.md. Before:effectivePropertiesis how a provider says "this is the bag I ACTUALLY sent" — the loop breaker behind #1591 (AWS::EC2::Route) and #1633 (AWS::EC2::SecurityGroupIngress) — andDeployEngine.propertiesToRecordrecords it in place of the desired bag. The three OTHERprovider.update()call sites discarded the return value entirely:cdkd drift --revertand both rollback revert arms (revert,revert-failed-update). So a narrowing announced on those paths never reached state, the record kept describing a value AWS does not hold, the nextcdkd driftreported the identical difference, and--revertre-issued the identical revoke + re-authorize with the identical warning — forever, for anyone using--revertas their repair tool. The deploy path self-heals, so a singlecdkd deployconverged; the loop persisted only on these commands. Now all three record it. The two shapes differ because the bag handed toupdate()differs. The rollback arms sendpreviousState.propertiesverbatim, soeffectivePropertiesis its complete replacement and the arms store{...restored, properties: effectiveProperties}— physical id, attributes, dependencies and policies of the restored record all survive untouched.--revertsendsbuildRevertNewProperties's output — AWS-CURRENT values for every non-drifted key merged with the state baseline for the drifted ones — so writing that back wholesale would import AWS-authored values into state for keys nobody reverted, quietly turning--revertinto--accept. It therefore persists a per-key DELTA (collectNarrowedTopLevelKeys): only top-level keys where what the provider DELIVERED differs from what it was HANDED, with a key the provider dropped entirely represented as an explicitundefinedthe caller turns into adelete(a{...baseline, ...delta}spread would otherwise leave anundefined-valued key in the JSON). Three details of that delta were found by the PR review and each closes a route back to--acceptbehavior: presence is decided bykey in effectiverather than by comparing toundefined, so a genuinelynull-valued key on both sides is not a phantom drop while a DROP of one still is; the comparison is a key-order-INDEPENDENT deep equality rather thanJSON.stringify, because a provider that rebuilds a nested object with the same members would otherwise register as changed and the value written back for such a key is the AWS-current one that was SENT; and only a key the BASELINE already declares may move, since the sent bag starts as the AWS-current snapshot and carries keys state never tracked. A fourth: on a resource with NOobservedPropertiesonly a DROP is recorded, never a value — that baseline is the raw template andbuildRevertNewPropertiesran inpreserveUntemplatedmode (#1626), so the sent value deliberately carries every AWS-authored path the template never declared, and writing it intopropertieswould make the DESIRED baseline describe AWS-side data and silently disable the #1160 absent-field removal derivation that reads it. The capture also sits AFTER the per-resource success accounting in its owntry, so a provider handing back a non-comparable bag cannot re-report an already-succeeded revert asAWS update failed. The delta lands in the same field the drift comparator uses as its baseline —observedPropertieswhen the resource has one, elseproperties— matching--accept, sopropertieskeeps the user's last-deployed template intent: a narrowing is an AWS-side fact, not a template edit. The write happens ONCE per stack, after the concurrent per-resource tasks settle and still under the stack lock, with the sameexpectedEtagoptimistic-locking andmigrateLegacyhandling as--accept; a run where no provider narrowed anything writes no state at all, so the pre-existing "--revertdoes not touch state" behavior is unchanged for every resource type that does not report a narrowing. The write is BEST-EFFORT, unlike--accept's: there the state write IS the operation, here AWS has already been reverted and this is a secondary convergence step, so a failure warns and the command carries on — throwing would skip every later stack's revert under--all, a regression against not writing at all, and the warn path's only cost is that the narrowing re-surfaces on the nextcdkd drift(i.e. exactly the pre-fix state). Tests: 6 drift cases (observed-baseline write,propertiesfallback, the ONLY-provider-changed-keys fence with a drifted key and an AWS-onlyTagsaddition that must NOT move, a dropped key, and the negative case that must not write) + 3 rollback cases (both arms record, the no-narrowing arm keepspreviousStateby identity, and the caller's object is not mutated in place); all mutation-probed — neutering the drift wiring fails 4, neutering the rollback recorder fails 2. Applies to everyeffectivePropertiesproducer, soAWS::EC2::RouteandAWS::EC2::SecurityGroupIngressconverge on these paths today and any future producer inherits it. No CLI flag, dependency, or state-schema change. -
IpProtocolreadback canonicalization forcdkd drift(issue #1643, the residual of #1633 / #1591) —src/utils/ip-protocol.ts(new, the shared value canonicalizer),src/analyzer/drift-protocol-normalize.ts(new, the path scoping),src/cli/commands/drift.ts,tests/unit/analyzer/drift-protocol-normalize.test.ts,.claude/rules/code-layout.md. Before: #1633 madeAWS::EC2::SecurityGroupIngressrecord theIpProtocolit actually SENDS, which fixed the two arms where cdkd's value differed from the declared one only in JS TYPE. It did NOT fix a protocol NUMBER that AWS RENAMES: forIpProtocol: 6cdkd sent and recorded'6', AWS held'tcp',readSecurityGroupIngressCurrentStatereturned'tcp', and everycdkd driftreported the difference forever while--revertrevoked and re-authorized into the same state — permanent phantom drift via a third route, and one #1633 deliberately left untouched for the inlineAWS::EC2::SecurityGroup.SecurityGroupIngress[]/SecurityGroupEgress[]rules as well. Now a purecanonicalizeIpProtocols(baseline, aws, resourceType)pass runs on BOTH comparison sides indrift.ts, immediately after the #1515 principal pass. This is the issue's option 2 (readback normalization) rather than option 1 (a send-side table innarrowIngressIpProtocol): the divergence is AWS's spelling of a value it owns, not a question of what cdkd sent, so normalizing at the comparison also covers theproperties-fallback baseline (a resource deployed before observed-capture, whose baseline is the user's raw template) and the inline-rule shape, neither of which a send-side fix reaches. The mapping table was MEASURED, not guessed — the issue explicitly warned against assuming the IANA list: probing one rule per protocol on a scratch security group (us-east-1, 2026-08-12, ingress AND egress) showed AWS renames exactly FOUR values (1->icmp,6->tcp,17->udp,58->icmpv6) while-1,0,2,4,27,41,47,50,51,88,89,94,103,112,132and255all read back as the number. So the rename set is the closed set of protocols EC2 has a NAME for, and-1being outside it is exactly why #1633's live test came back clean. A second probe established that case is AWS's to decide too —TCP/Tcp/tcpauthorized on one port collapsed into ONEtcppermission — so a name is matched case-insensitively and lowered, but ONLY the four known names, which keeps the pass from ever making two genuinely different values compare equal. Numbers canonicalize to their STRING form, since an unquoted YAMLIpProtocol: 47parses to a number while AWS always reads back a string. Scope is a CLOSED path table (the two standalone rule types plus theAWS::EC2::SecurityGroupinline arrays) rather than a walk for any key namedIpProtocol, because a blanket rewrite would turn an unrelated'6'into'tcp'; EVERY number is stringified (not only an integer one, so a malformed6.5keeps its pre-#1633 key andNaN/Infinitydo not collapse onto one JSONnullbucket); a value that is neither string nor number - an unresolved intrinsic above all - passes through untouched, and an unchanged bag is returned by identity. The real-AWS run then found a SECOND, lower layer the issue's premise had wrong. #1643 states thatreadSecurityGroupIngressCurrentState"returnstcp"; it does not — it returnedundefined, andcdkd driftreported the standalone rule as "drift unknown" rather than as drift. A standalone rule's physicalId is<groupId>|<protocol>|<from>|<to>built from the value cdkd SENT, so forIpProtocol: 6the tuple pre-filter (sg-…|6|…vs an AWSIpPermissions[].IpProtocoloftcp) matched nothing, and the full-signaturesgRuleKeycompare would have missed for the same reason. That half is structurally invisible to a comparison-side fix, because no AWS-side bag is ever produced — soec2-provider.ts'ssgProtocolKeynow routes through the samecanonicalizeIpProtocolValue, which heals the standalone lookup AND the inline-rulereconcileSgRulesordering from ONE definition of protocol identity. That mirrors the reason its existing comment already gives for stringifying a legacy numeric-1: normalizing at the KEY heals the deployed population with no migration. Both halves are needed and neither subsumes the other — the provider half makes the AWS-side bag exist, the comparator half stops itstcpfrom reading as drift against a recorded'6'. 39 unit tests, each half mutation-probed (deleting the6->tcprow fails 7 comparator tests including the headline case; revertingsgProtocolKeyfails the provider lookup test). Thedrift-revert-arraysfixture gains BOTH shapes: an inline numeric rule injected at the L1 (the L2addIngressRulecan only emit a name) and a standalone numeric rule on its OWN security group — deliberately not on the shared one, since a standalone rule materializes a member into the parent's liveIpPermissionswhile the parent's template still declares fewer, which is real drift on the parent (the #1498 sibling-materialization class) and would have failed the fixture for an unrelated reason. Known residual on the very path the comparator half exists for: a security group carryingTagsstill phantom-drifts on theproperties-fallback baseline, becausereadSecurityGroupCurrentStatenever reads tags back whileAWS::EC2::SecurityGroup— unlike every sibling standalone type — is not ingetDriftUnknownPaths. Filed as issue #1649; the fixture's step 6b drops theTagskey with an in-code pointer so it fences the protocol comparison only. The issue's third item is also settled here rather than deferred, now that PR #1641 has merged and released both files:.claude/rules/providers.md/docs/provider-development.mdwarned that a "lossless" coercion needs no warn arm, which a reader could take as "number -> string is the bar" — the measured6->'6'-> AWStcpcase is exactly that trap, so both now state the real bar ("matches what AWS HOLDS") and name the TYPE-coercion vs SERVICE-value-mapping split that decides whether the fix belongs on the send or the readback side. - Pre-flight rejection of mutually exclusive properties (issue #1634, the remainder of #1591 / #1566) —
src/provisioning/mutually-exclusive-properties.ts(new),src/provisioning/provider-registry.ts,tests/unit/provisioning/{mutually-exclusive-properties,provider-registry-mutually-exclusive}.test.ts,docs/troubleshooting.md,.claude/rules/providers.md. Before:EC2Provider.createRouterefused a template declaring more than one ofDestinationCidrBlock/DestinationIpv6CidrBlock/DestinationPrefixListId(#1566), but only on a template-borne CREATE. For a stack that ALREADY had the route, #1591's both-sides canonicalization made the deploy diff classifyNO_CHANGE, so the provider was never called and the refusal was unreachable — an invalid template could sit in a repo indefinitely, deploying green while AWS held only the first destination. Now a general, reusable pre-flight rule (MUTUALLY_EXCLUSIVE_PROPERTIES+findMutuallyExclusiveViolations) runs insideProviderRegistry.validateResourcePropertieson every deploy, aggregating every offending resource into ONE error ahead of the silent-drop routing lines. The message names the key that WOULD reach AWS, which turns the remedy into a provably safe edit: the service was never sent the others, so deleting them cannot change the deployed resource. The unresolved-intrinsic carve-out is what makes it safe — pre-flight runs BEFORE intrinsic resolution, so a key whose value is{Fn::If: [...]}/{Ref: ...}counts as UNKNOWN rather than declared (anFn::Ifarm resolving toAWS::NoValueis the canonical way to declare both keys conditionally, and that template is VALID); only two or more UNCONDITIONALLY present keys are refused, and the truthiness predicate mirrorsnarrowRouteDestinations'Boolean(...)narrowing so pre-flight cannot refuse a bag the provider itself would accept. There is deliberately no--allow-*escape hatch (unlike--allow-unsupported-types/--allow-unsupported-properties, which exist for gaps in cdkd — this defect is in the TEMPLATE, and CloudFormation rejects it too), so the table is seeded with only the ONE combination this repo has verified end to end, with a unit test pinning every rule property against the type's CFn schema fixture so a rename cannot leave a silently dead rule.cdkd diffis deliberately NOT wired: #1591's canonicalization warning inDiffCalculator.comparePropertiesalready fires for exactly this shape, so a second surface would be duplicate noise. Two bounds worth knowing: the message DROPS its "only X would reach AWS" sentence when a HIGHER-precedence key sits behind an unresolved intrinsic (that key may resolve to a real value and win the provider's||chain, so naming a winner would be a confident falsehood — the remedy is unaffected); and a NESTED-STACK child is validated by its ownDeployEngineinsideNestedStackProvider, so an invalid child resource is refused mid-deploy after the parent has created resources rather than at the parent's pre-flight — the same shapevalidateResourceTypeshas always had, not a regression introduced here. Live-verified viacdkd deploy --dry-runon a scratch CDK app — the two-literal route is refused with the full message, and theFn::Ifpair plans 4 creates without complaint. 27 unit tests, both behaviors mutation-probed (removing the carve-out fails 5; removing the wiring fails 3). - EC2:
SecurityGroupIngress.IpProtocolno longer produces permanent phantom drift (issue #1633) —src/provisioning/providers/ec2-provider.ts,tests/unit/provisioning/ec2-sg-ingress-ip-protocol-effective.test.ts. Before: the provider sent anIpProtocolthat could differ from the one the template declared, and cdkd recorded the DECLARED value — the identical shape #1591 fixed one method over. Two arms produced it.createSecurityGroupIngresswarn-substitutes the-1default for a MALFORMEDIpProtocol(''/{}/true/ an explicitnull) on the replay-reachable paths and sends the default; and an unquoted YAMLIpProtocol: -1is a NUMBER, accepted by design since #1513 and STRINGIFIED before it is sent. Either wayreadSecurityGroupIngressCurrentStatereturns what AWS holds ('-1') and could never match the record, so everycdkd driftreported the difference anddrift --revert"repaired" it by callingupdate()again — which revokes, re-authorizes and re-emits the same warning, forever. Now both halves of the #1591 remedy are in place. A new sharednarrowIngressIpProtocolhelper resolves the protocol ONCE for the create arm, the update arm's re-create and the diff-side canonicalizer, so state and template can never be normalized to different values;create()returnseffectiveProperties(from the success arm AND the idempotent "already exists" arm, which also writes state) andupdateSecurityGroupIngressforwards it, so state records what was SENT; andcanonicalizeDesiredPropertiesgains anAWS::EC2::SecurityGroupIngressarm so the template side is narrowed identically. The second half is not optional:IpProtocolis create-only on this type in the registry schema, so normalizing state alone would classify the template's original value as a changed IMMUTABLE property, and the resulting replacement create — which receives no context and so gets noonUnusabledowngrade — would turn a previously-green no-op deploy into a hard failure. Normalizing both sides is also what keeps records written BEFORE the fix (which still carry the raw value) comparing clean. Unchanged: a TEMPLATE-path create still REFUSES a malformed value outright, and the warn arm still warns — recording the substitution does not replace announcing it. The numeric arm does not warn, and does not need to:-1and'-1'name the same protocol, so nothing is lost, and recording the string additionally fixes the delete path, which forwards the state record tobuildIpPermissionand would otherwise hand the EC2 API a number. An ABSENTIpProtocoldeliberately stays absent rather than gaining the default — the drift comparator only descends into keys present in state, so materializing it would START comparing a key the template never declared. 20 unit tests, each half independently mutation-probed. - The replacement-collision refusals stop calling a cdkd-GENERATED physical name "user-supplied" (issue #1636, scope item 1) —
src/provisioning/resource-name.ts,src/deployment/deploy-engine.ts,tests/unit/deployment/deploy-engine-collision-name-origin.test.ts. Before: every create-first replacement collision reported that "the resource has a user-supplied physical name" and prescribed "rename the resource in your CDK code". For a resource the template never named BOTH halves are false, and the false half is the part the user is asked to act on:generateResourceNameproduces{stackName}-{logicalId}with no random component, so the create-first attempt lands on the name the old resource still holds exactly as it would for a user-named one — the collision is cdkd's own naming scheme, not the template — and "rename" there means renaming the CONSTRUCT (changing the logical id), a materially more disruptive action. A reader who finds no such name in their template cannot connect the message to their code at all. Observed live (us-east-1, 2026-08-12) on thelambda-durable-replacementfixture, whose function declares nofunctionNameand whose message namedCdkdLambdaDurableReplacementExample-DurableFn91E3F4D8. Now a new best-effortlooksLikeCdkdGeneratedName(physicalId, logicalId, stackName)runs the naming scheme backwards, and the engine's sharedreplacementNameOriginhelper emits a per-origin descriptor + remedy: a generated name is reported as generated, with "give the resource an explicit physical name, or rename the CONSTRUCT (note that this replaces the resource)" as the remedy; a template-supplied name keeps the pre-#1636 wording verbatim. Applied at all FOUR sites that carried the false assertion, not just the two the issue named — theNAMED_REPLACEMENT_COLLISIONthrow, theUpdateReplacePolicy: Retainrefusal beside it, the--replaceinfo line (now "collided with the existing resource's name" rather than "the custom-named resource"), and theNAMED_REPLACEMENT_IDEMPOTENT_CREATEarm — since fixing only the reported pair would leave siblings telling the same untruth. Classification is deliberately APPROXIMATE and biased to the safe answer: the per-typemaxLength/lowercase/allowedPatternoptions live in the providers, so the test compares the alphanumeric skeleton and accepts the truncated…-<8 hex>form (requiring BOTH the prefix and the hash, so a hash-suffixed user name is not claimed), and EVERY unresolved case — including no activewithStackNamescope — answersfalse, which merely keeps the old wording. Unchanged: the CloudFormation-parity framing, the--replaceescape hatch, and every control-flow decision — this PR changes message text only. Item 2 of the issue (should a generated name get a FRESH unique name on replacement, as CloudFormation does, so--replaceis not needed at all?) is deliberately NOT in scope: the deterministic name is relied on by state readback, orphan scans and fixture assertions, so it needs its own analysis. 13 unit tests; the classifier is mutation-probed in BOTH directions (always-false = pre-#1636 behavior fails 6 rows; always-true fails the 4 template-supplied rows). - DynamoDB: the GSI Create / Update arms are idempotent, so a post-GSI failure no longer wedges every later deploy (issue #1630) —
src/provisioning/providers/dynamodb-table-provider.ts,tests/unit/provisioning/dynamodb-table-provider-gsi-idempotent.test.ts,.claude/rules/providers.md. Before:applyGsiUpdatesissued oneUpdateTableper GSI op, and everything after it inupdate()— PITR, TTL, ResourcePolicy, Kinesis streaming, Contributor Insights — could still throw. cdkd writes state only onceupdate()RETURNS, so any op that already succeeded was left unrecorded and the next deploy re-emitted it: a created index'sCreatewas re-sent and AWS rejected it (the index now exists), and a landed per-index throughputUpdatewas re-sent with the same value and AWS rejected it with "The provisioned throughput for the index X will not change. The requested value equals the current value." State never advanced, so every retry failed identically until the user rancdkd drift --accept— the same permanently-wedged class #1617 fixed for the Delete arm, on its two siblings. Now both arms consult the liveDescribeTablesnapshotupdate()already holds: aCreateis skipped (with a WARN, since state and AWS disagreeing is worth surfacing) for an index name AWS already has, and a throughputUpdateis skipped when the live index already carries the requested pair. The two arms are gated DIFFERENTLY and deliberately — an index's EXISTENCE is billing-mode-independent so the Create skip applies on either mode, while the capacity VALUES are meaningless under PAY_PER_REQUEST (DescribeTablereportsProvisionedThroughput: {0, 0}for every index of an on-demand table, the #1571 trap) so the Update skip is disabled there entirely, as it also is while THIS deploy is flipping to PROVISIONED (the flip itself delivers the capacity, whichgsiHandledByBillingFlipalready suppresses by a different route). The capacity comparison is member-by-member as numbers rather than structural, because aDescribeTablereadback carriesNumberOfDecreasesToday/LastIncreaseDateTimealongside the two capacities and adeepEqualagainst the two-member desired object could never match — the suppression would have been dead code that still looked safe. Every unresolvable shape (absent live entry, malformed value, unresolved intrinsic) fails OPEN and still issues the call: the worst case is the pre-fix behavior, whereas a false match would silently drop a capacity change the user asked for. 9 unit tests; the Create skip, the Update skip, the PAY_PER_REQUEST gate and the member-wise comparator are each independently mutation-probed.
Recently Implemented (2026-08-11):
- Drift: unordered comparison for OBJECT arrays, and
ElasticLoadBalancingV2::TargetGroup.Targetsbecomes visible tocdkd drift(issue #1620, split out of #1609 item 6) —src/analyzer/drift-normalize.ts,src/provisioning/providers/elbv2-provider.ts,tests/unit/analyzer/drift-normalize.test.ts,tests/unit/provisioning/{elbv2-provider-readcurrentstate,elbv2-lb-targetgroup-props}.test.ts,tests/integration/drift-revert-arrays/{lib,inject-drift.ts,verify.sh,README.md}. Before: the provider-declared opt-in passcanonicalizeUnorderedArraysAtPaths(viaResourceProvider.getDriftUnorderedPaths) handled PLAIN-STRING arrays only, so a type whose unordered list is an array of OBJECTS had no way to declare it.TargetGroup.Targetswas therefore parked ingetDriftUnknownPaths— meaningcdkd driftcould not see a console-side target registration AT ALL — even though the provider can read it back viaDescribeTargetHealth. Now the pass sorts object arrays too, at a declared path only, keyed on a private key-order-independentcanonicalJson: key order deliberately does not participate, because AWS's readback order for an object's own keys is no more guaranteed than its order for the list, so a rawJSON.stringifysort key would reintroduce the very phantom drift the pass removes. Each shape is sorted only when the array is HOMOGENEOUS in it — a mixed strings-and-objects list, anullelement, and a nested array inside a declared path are all left untouched, so a mis-declared path can never reorder a heterogeneous list. Array-of-arrays stays out by an explicit decision, not an inherited accident (no CFn shape in tree is one, and an inner list's order-significance is a separate question the single declared path cannot express).ELBv2Providerthen movesTargetsout of the TYPE-levelgetDriftUnknownPathsintogetDriftUnorderedPathsand reads it back in CFn shape. The unknown-path entry did not disappear, it became PER-RESOURCE (the issue #1602 seam): a target group fronting an ECS service or an ASG declares NOTargets— the SIBLING resource registers them and re-registers on every scale event — so comparing an undeclared list would report drift on an untouched stack and--revertwould deregister the tasks the service just placed (the #1498 class, whichundeclaredEmptyObservedKeysonly covers when the capture happened to be EMPTY — for a redeployed running service it is not).Targetsis therefore compared only when the template declares it; an explicitTargets: []IS a declaration and stays compared, and an absent properties bag falls back to comparing per the method's contract. The readback needed a second fix ordering alone does not cover: deregistration is asynchronous, soDescribeTargetHealthkeeps reporting a just-removed target asdrainingfor minutes — including one would freeze it into the deploy-timeobservedPropertiessnapshot and produce PERMANENT phantom drift once it finally disappeared. The provider excludes exactlydrainingand INCLUDES every other state (initial/unused/unhealthy/unavailableare all registered; health is not registration), drops theAvailabilityZone: 'all'AWS substitutes for an unscopediptarget, and leaves the key ABSENT (never empty) when the health read fails, so a missingelasticloadbalancing:DescribeTargetHealthpermission reports nothing rather than reporting every target as removed. Integ:drift-revert-arraysgains a standalonetargetType: 'ip'TargetGroup (no load balancer — free, and itsunusedtargets exercise the include-non-draining decision live) with three deliberately unsorted IP targets, a SECOND back-to-backcdkd driftassertion (two consecutive runs disagreeing is this class's signature — one run cannot detect it), and an out-of-bandRegisterTargetsof a fourth untemplated IP asserted live BEFORE the revert (so the post-revert assertion cannot pass vacuously) and asserted deregistered after, with the other three RETAINED. - S3: the remaining FIELD-level reads no longer hard-throw on a state replay (issue #1605) —
src/provisioning/providers/s3-bucket-provider.ts,tests/unit/provisioning/s3-bucket-field-read-replay.test.ts(new),tests/unit/provisioning/s3-bucket-provider-roundtrip.test.ts. Before:applyVersioning'sStatus,applyLoggingConfiguration's destination +TargetPrefix, andapplyInventoryConfigurations'ScheduleFrequency/Schedule.Frequencypair werereadConfigStringrefusals carrying noonUnusable, so they THREW on the replay-reachable paths —rollback-executor.ts's revert arm andcdkd drift --revert(which callupdate(..., previousState.properties, ...)) and its reverse-replacement arm (which callscreate(..., previousState.properties, REPLAYING_STATE_CREATE_CONTEXT)). The desired bag there is a cdkd STATE record, so a refusal left the resource un-rollbackable with a hand-edit ofstate.jsonas the only remedy. After: each downgrades, and the three answers DIFFER because the skip UNIT was the whole question — which is why #1595 split them out rather than repeating its per-item pattern. Versioning SKIPS the Put: there is no item to skip and no previous value to keep, and the alternative is theSuspendedfallback that #1471 measured turning versioning OFF on a live bucket; the skip's real cost (a rollback of a versioning change is not reverted) is the cheaper one and the warning says so. Logging SKIPS the Put with both reads probed together, sincePutBucketLoggingreplaces the whole status and the destination's blank fallback makes the clearing branch turn access logging OFF — the create-path GATE takes the same downgrade, because it reads the same field to decide whether to call at all and would otherwise throw before the applier's skip could run. Inventory takes a THIRD shape: a malformedScheduleFrequencyFALLS THROUGH toSchedule.Frequency, and the item is SKIPPED when there is nothing to fall back to — inventing aWeeklycadence for a live report is exactly the substitution this guard class refuses. "Nothing to fall back to" includes an ABSENT second source, and that is the case that matters (PR review blocker):ScheduleFrequencyis the only spelling the CFn schema declares —tests/fixtures/cfn-schemas/AWS-S3-Bucket.jsonhas noSchedulemember — so a record with a brokenScheduleFrequencyalmost never carries one, and treating the absent container as usable let the fall-through land onreadConfigString's ownWeeklydefault, making the whole downgrade inert for real input. A template-path create still REFUSES at every site, and the create/update asymmetry is now deliberate rather than an oversight (the roundtrip suite's "CREATE and UPDATE agree" test is renamed and rewritten to say so). Also threadsCreateContextintoapplyConfiguration, whose versioning read was reachable from the reverse-replacement create with no way to downgrade — the siblingapplyAllSubConfigsForCreatehad taken the context since #1463 for exactly this reason. Tests: 19 new cases across the three sites (replay create, update, template-create refusal, well-formed no-op, and the fall-through / skip split), plus the enable arm's!== 'Suspended'test pinned so an unrecognized status still reaches AWS instead of being silently swallowed; all verified to FAIL with the guards neutered. - DynamoDB: removing a GSI in the same deploy as a BillingMode flip now converges (issue #1617) —
src/provisioning/providers/dynamodb-table-provider.ts,tests/unit/provisioning/dynamodb-table-provider-billing-flip-gsi.test.ts,tests/integration/dynamodb-ondemand/{lib/dynamodb-ondemand-stack.ts,verify.sh}. Before: a deploy that dropped aGlobalSecondaryIndexes[]entry AND flippedAWS::DynamoDB::TablefromPAY_PER_REQUESTtoPROVISIONEDcould not succeed by any template-side edit. The dropped index is still LIVE when the flip runs, so AWS demands per-indexProvisionedThroughputfor it (ValidationException: ProvisionedThroughput must be specified for index: <name>, measured 2026-08-11) while the template no longer declares any — and cdkd'sDeleteop lived inapplyGsiUpdates, which runs AFTER the flip and was therefore never reached. Every deploy failed identically; #1588 (which made the flip possible at all for an indexed table) deliberately deferred the reordering and cdkd warned with a two-deploy workaround. After: the removal is applied FIRST — oneUpdateTableper dropped index with a table-and-indexes ACTIVE wait between each (AWS's one-GSI-op-per-call budget, and the wait is what returns the table to a state that accepts the flip), then the flip, whose per-index capacity list is built from the live indexes MINUS the ones just deleted. This is what CloudFormation does on this shape. Only the removal moves: creates and capacity updates stay after the flip, where the index they describe exists, and the reorder is scoped to the flip TOPROVISIONED(the other direction needs no per-index capacity). Guards, all four found or confirmed by the 3-axis PR review. The pre-flip delete is REFUSED whenever the flip is already doomed for a reason visible here — an index that will STILL be live declaring no usable per-index capacity, OR an absent / unusable TABLE-levelProvisionedThroughput(the flip block deliberately leaves that one to AWS for CFn parity, which is fine for the flip but is exactly the deterministic failure a delete must not run ahead of). Only an index cdkd's own PREVIOUS record knows about is removable, so a live index created out of band is named in the warning rather than silently deleted. A present-but-NON-ARRAY desiredGlobalSecondaryIndexes(an unresolved intrinsic) reads as an empty desired set, which would have classified every live index as removed and deleted them all — that shape was non-destructive before, so the removal is refused and the pre-existing loud downstream failure is preserved. And theDeletearm is now IDEMPOTENT against AWS's live index list, which is what actually makes the residual recoverable: the first version claimed "the next deploy retries the flip and the GSI diff is already satisfied", and the review showed that was WRONG — cdkd writes state only afterupdate()RETURNS, so a mid-update failure leaves the deleted index still in the previous side and the next deploy would emit aDeletefor an index AWS no longer has, failing withResourceNotFoundExceptionon every deploy forever and re-creating the very unconvergeable class this change removes. Skipping aDeletefor a name that is not live closes that, and subsumes the narrower already-deleted set it replaced (it also covers an index removed out of band or by an earlier interrupted run). Tests: 16 unit cases across the two GSI suites (order, per-index-list exclusion, no duplicate delete, one call per dropped index, the idempotent skip for an index AWS no longer has, all four refusal arms, a CREATE-after-flip case proving only the removal moves, and both scope negatives), each verified to FAIL without the fix; two pre-existing tests whose mocks returned aDescribeTablewith no GSI list while removing a GSI — a response real DynamoDB never sends — were corrected to list the index live; thedynamodb-ondemandfixture'sBillingRemovalTablegrew a second baseline GSI that the flip deploy removes, asserted present before the flip and gone after. - AppSync
DataSource/Resolveropted into the nested-key critic, and the two nested silent drops it found are fixed (issue #1597) —src/provisioning/providers/appsync-provider.ts,scripts/gen-nested-key-coverage.ts(two new targets + the sharedAPPSYNC_WRITE_FLOORS),tests/fixtures/cfn-schemas/AWS-AppSync-{DataSource,Resolver,ApiKey,GraphQLSchema}.json(re-captured), newtests/unit/provisioning/appsync-datasource-nested-configs.test.ts,tests/integration/appsync/{lib,verify.sh,README.md}, coverage matrices regenerated. Before: the #609 AppSync backfill closed both types' TOP-LEVEL silent-drop maps but could not opt them intoNESTED_KEY_TARGETS— their schema fixtures pre-dated thedefinitionShapes/nestedPropertyPathscaptures the generator hard-errors without — so nothing audited one level down, where a handled top-level property hides its own dropped members. Now both are targets (Resolvermeasured CLEAN on the first run at 9 audited paths;DataSourcemeasured 10 findings at 27 paths, all fixed here), and the whole tree is back to 0no-write-evidence. The two families the opt-in exposed:HttpConfig.AuthorizationConfig(+AuthorizationType/AwsIamConfig.SigningRegion/.SigningServiceName), which the issue named and which is security-relevant rather than cosmetic — an IAM-signed HTTP data source reached AWS UNSIGNED, so every request to the endpoint went out without SigV4; andDynamoDBConfig.DeltaSyncConfig(+BaseTableTTL/DeltaSyncTableName/DeltaSyncTableTTL) plusDynamoDBConfig.Versioned, which the issue did NOT name — the critic found them, which is the point of opting in. Both are wired on create AND update (one sharedapplyDataSourceConfigmapper) with matchingreadCurrentStatereverse maps, since a forwarded-but-unreadable member becomes permanent phantom drift. The delta-sync TTLs are the one CFn->SDK TYPE divergence in the batch: CFn (and CDK's L1) declare both as STRINGS while the SDK models them as longs, so the write side converts and refuses a non-numeric value loudly rather than sendingNaN, and the read side converts BACK to strings — emitting AWS's number would have differed from the template baseline under the stringify-based drift comparator on every run. The three AppSync targets now share oneAPPSYNC_WRITE_FLOORSconstant because both collector outputs are per-FILE, not per-type;minWrittenMembersmoved 100 -> 105 in the process, since the file grew past the point where the old value still sat inside the hygiene band. Integ: theappsyncfixture gains an IAM-signed HTTP data source and a versioned DynamoDB data source (with a second table as the delta-sync store) and asserts both nested blobs across all three phases — created, changed on update (SigningRegionand both TTLs), and cleared on removal with a retained sibling INSIDE each block (HttpConfig.Endpoint,DynamoDBConfig.Versioned) so a reset that wiped the whole block cannot pass. - A Lambda replacement is performable again: the name-collision matcher missed AWS's SINGULAR spelling (issue #1625) —
src/deployment/retryable-errors.ts,tests/unit/deployment/retryable-errors.test.ts, newtests/integration/lambda-durable-replacement/. Before:isNameCollisionErrormatched/already exists/i, andCreateFunctionraisesResourceConflictException: Function already exist: <name>— SINGULAR — so NOAWS::Lambda::Functioncould take the collision path. The consequence was not cosmetic: a property-driven replacement (droppingDurableConfig, changing the create-onlyTenancyConfig) create-firsts into its own still-live name, and the unmatched collision meant the raw SDK exception escaped instead of cdkd's actionableNAMED_REPLACEMENT_COLLISIONerror ANDcdkd deploy --replace's delete-first fallback never fired — the replacement was unperformable by any flag. Verified against real AWS by creating one function name twice (us-east-1, 2026-08-12). Now the matcher acceptsalready exist/already existsas one word-bounded signature (a participle such as "already existed as a draft" is still refused, since crediting it at a create-first site would trigger the destructive delete-first fallback), so the refusal is actionable and--replaceworks. New fixturelambda-durable-replacementis the live coverage issue #1625 asked for, and its first run corrected the issue's own premise twice: an UNNAMED Lambda collides exactly like a pinned one (cdkd generates{stackName}-{logicalId}deterministically, so the create-first attempt lands on the name the old function still holds) and the physical id is IDENTICAL on both sides — so "assert the physical id changed" cannot prove the replacement. The fixture proves it from the END STATES instead: phase 2 dropsDurableConfigWITHOUT--replaceand asserts the deploy refuses actionably while the live function keeps its old properties, phase 3 re-runs WITH--replaceand asserts the delete-first fallback left a function with NO durable config and the new description, phase 4 destroys clean. The follow-up the run surfaced — both collision messages call a cdkd-GENERATED name "user-supplied" — is filed as issue #1636. -
AWS::ECS::Service: the last 9 silent-drop properties wired, type CLOSED (issue #609 ECS Service batch) —src/provisioning/providers/ecs-provider.ts,scripts/gen-nested-key-coverage.ts(2 allow-list entries + re-measured floors), newtests/unit/provisioning/ecs-service-config-props.test.ts,tests/integration/ecs-service-update-props/{lib,verify.sh,README.md},tests/integration/ecs-fargate/{lib,verify.sh,README.md},tests/fixtures/cfn-schemas/_todo-backfill.json(ECS Service block + stalePlacementStrategybogus-tolerance removed), coverage matrices regenerated. Before:AvailabilityZoneRebalancing/DeploymentController/ForceNewDeployment/Monitoring/Role/ServiceConnectConfiguration/VolumeConfigurations/VpcLatticeConfigurationswere silent-drops, so any Service template carrying one flipped to the #614 Cloud Control fallback route (PlacementStrategieswas already wired since #613 — its backfill entry was simply stale). Now all are wired throughCreateService/UpdateServiceandPROPERTY_COVERAGE_BY_TYPEreports an EMPTYsilentDropfor the type, so every ECS Service is SDK-routed — theecs-fargatefixture (ServiceConnect + managed EBS volume) is the live route-flip proof (provisionedBy=sdkpinned + deployment-level read-backs). The config blobs go through the shared recursive PascalCase->camelCase converter as whole-blob hand-offs (ManagedEBSVolume->managedEBSVolumeandSizeInGiB->sizeInGiBare exact first-char-only flips, pinned by unit tests;ServiceConnectConfiguration.LogConfiguration.Optionsis a free-form log-driver map copied verbatim).ForceNewDeploymentis the one CFn-only shape: an object{EnableForceNewDeployment, ForceNewDeploymentNonce}with NO per-member SDK counterpart —resolveForceNewDeploymenttranslates Enable=true OR a nonce change into UpdateService's plainforceNewDeployment: true(create-path no-op; two nested-key allow-list entries document the collapse).Rolemoved OUT ofunhandledByDesign(it is a real create-only registry property): passed through on create, and a change classifies as REPLACEMENT via the create-only schema fallback (no hand rule needed; pinned against the fixture'screateOnlyPropertiesthrough the real DiffCalculator). Update semantics: every deployment-triggering blob is change-gated (an unchanged value is never re-sent, so a no-op update cannot start a spurious rollout); removals — ServiceConnect resets to the documented disable shape{enabled: false}, VolumeConfigurations / VpcLatticeConfigurations reset to[](both UNVERIFIED-LIVE, flagged in code), AvailabilityZoneRebalancing and Monitoring are DEFERRED with in-code rationale (the AZ-rebalancing defaults genuinely diverge: CreateService defaults ENABLED while a never-set service reads as DISABLED, so no single reset value is defensible without a live probe — the DeploymentConfiguration deferral precedent), DeploymentController sends nothing on removal by design (absent = default ECS controller). Read side:AvailabilityZoneRebalancingis surfaced verbatim andDeploymentControlleralways (with a{Type: ECS}fallback because DescribeServices documents omitting the field for the default controller); the unreadable members (ServiceConnectConfiguration/VolumeConfigurations/VpcLatticeConfigurations/Monitoring/ForceNewDeployment/Role) are declared via the provider's newgetDriftUnknownPathsso they cannot become permanent phantom drift —Roledeliberately so, since AWS reports the service-linkedAWSServiceRoleForECSARN for templates that never set it. Integ:ecs-service-update-propslive-covers AvailabilityZoneRebalancing (describe-services + observedProperties), the explicitDeploymentController: {Type: ECS}(observedProperties fallback), Monitoring CreateService ACCEPTANCE (no read-back API exists; a mis-flipped required member would fail the deploy), a drift run asserting no phantom drift on any #609 member, and a newforce-noncephase where ONLY the nonce changes anddeployments[0].idmust change. PlacementStrategies (EC2 launch type), VpcLatticeConfigurations,CODE_DEPLOY,Role(classic ELB) and the Monitoring read-back stay unit-only with the rationale recorded in the fixture README. - ELBv2: a removed BOOLEAN LoadBalancer / Listener attribute no longer fails the whole deploy (issue #1609 item 1) —
src/provisioning/providers/elbv2-provider.ts,tests/unit/provisioning/elbv2-lb-targetgroup-props.test.ts,tests/integration/alb/{lib/alb-stack.ts,verify.sh}. Before: theLoadBalancerAttributesandListenerAttributesdiff arms pushed EVERY removed key back asValue: '', and neither removal had ever been exercised by an integ — the #609 batch shipped them on the assumption that both APIs accept the empty string as "clear the override", an assumption its own mocked unit tests could only agree with. The live A/B settled it per VALUE KIND, and the assumption was half wrong:ModifyLoadBalancerAttributesaccepts''for the numericidle_timeout.timeout_seconds(and genuinely resets it to 60), but REJECTS it for the booleandeletion_protection.enabled— "The value of 'deletion_protection.enabled' must be 'true' or 'false', but was ''" — andModifyListenerAttributesrejects it identically forrouting.http.response.server.enabled. Because a rejection fails the ENTIRE Modify* call, one removed boolean took the whole deploy down, and then took its automatic rollback down with it (the rollback re-runs the same diff with the sides SWAPPED, so a key the two template sides did not share became a removal in the OTHER direction and hit the same refusal on a SECOND key the forward pass never touched — a user could not deploy their way out of it). Now each arm resolves a removed key through a documented-defaults table (LOAD_BALANCER_ATTRIBUTE_DEFAULTS/LISTENER_ATTRIBUTE_DEFAULTS) and falls back to''for anything not in it. That fallback is the deliberate DIVERGENCE from theTARGET_GROUP_ATTRIBUTE_DEFAULTSsibling, whose unknown-key arm warns and retains: ModifyTargetGroupAttributes rejects''for every key, so there the default is the only possible reset, whereas here''is valid for the majority of keys and falling back to it preserves working behavior instead of silently retaining a value the template asked to drop. Keys whose default depends on the LOAD BALANCER are deliberately absent, since cdkd knows neither the type nor the scheme at diff time:load_balancing.cross_zone.enabled(always-on and unconfigurable on an ALB, default false on an NLB / GWLB) andipv6.deny_all_igw_traffic("false for internet-facing load balancers and true for internal load balancers") — the second was caught by review AFTER being wrongly listed, and it is the sharper lesson: a valid-but-wrong boolean is ACCEPTED by AWS, so the entry would have silently un-blocked internet-gateway access on every internal load balancer instead of failing loudly.dns_record.client_routing_policyis out for a different reason (the model documents no default at all). And a key already AT its default is SKIPPED entirely, which is a safety property rather than an optimization:cdkd drift --revertpasses the FULLreadCurrentStatesnapshot as the previous side, so against a state record with noobservedPropertiesevery untemplated attribute looks removed — writing defaults there would silently reset deletion protection, access / connection logs, HTTP/2, WAF fail-open and zonal shift on a live load balancer. Before the table existed that call was accidentally safe (''fails validation, so the revert aborted and changed nothing), so shipping the table without the skip would have converted a loud refusal into a silent destructive write. Integ: thealbfixture now templates aLoadBalancerAttributesentry (idle_timeout120 -> 180 -> dropped) and drops the Listener'sListenerAttributesin the removal phase, asserting both reset to AWS's documented defaults (60 /true).deletion_protection.enabledis restated in EVERY phase and is doing two jobs: it is the retained sibling the removal-testing convention requires for a collection-valued property, and it pins the CDK-L2 trap the fixture's first run walked into — the L2ApplicationLoadBalanceremits its owndeletion_protection.enabledentry, so a removal phase that merely DROPPED the property override let that default reappear, making the phase a value CHANGE plus an unintended second removal rather than the clean single-key removal it read as (thefeedback_cdk_l2_emits_explicit_defaults_in_removal_fixturesshape; synthesizing both phases and diffing the rendered attribute lists is what catches it). Also in this batch: the TargetGroupTargetsupdate test now pins the register-BEFORE-deregister call ORDER rather than only the payloads (the order is the behavior — a target whose Port changed is one backend under two spellings, so deregistering first would open a window with zero registrations), andreadCurrentState's two best-effort reads added by the #609 batch gained coverage for BOTH catch arms, which encode different decisions: a NotFound means the resource is gone and the whole read must returnundefined, while a permission error must leave only that key absent and the rest of the snapshot intact, since emitting a wrong value there would fire false drift on every run. Items 2 and 6 of #1609 are split out to their own issues — the NLB flag OMISSION semantics on a combined removal-plus-change deploy needs a dualstack / PrivateLink fixture that does not exist yet, and the unordered object-array drift comparison that would letTargetGroup.TargetsleavegetDriftUnknownPathsis a drift-layer feature well past ELBv2's blast radius. -
AWS::AppSync::Resolver+AWS::AppSync::DataSource: the last 12 silent-drop properties wired, both types CLOSED, plus theGraphQLSchema.DefinitionS3Locationno-op sibling (issue #609 appsync batch 2) —src/provisioning/providers/appsync-provider.ts, newtests/unit/provisioning/appsync-resolver-datasource-props.test.ts,tests/unit/provisioning/appsync-provider-readcurrentstate.test.ts,tests/integration/appsync/{lib/appsync-stack.ts,lib/templates/*.vtl,verify.sh,README.md},tests/fixtures/cfn-schemas/_todo-backfill.json(Resolver + DataSource + the stale GraphQLApi block PR #1569 forgot), coverage matrices regenerated. Before: Resolver templates usingCachingConfig/CodeS3Location/MaxBatchSize/MetricsConfig/RequestMappingTemplateS3Location/ResponseMappingTemplateS3Location/SyncConfig, and DataSource templates usingElasticsearchConfig/EventBridgeConfig/MetricsConfig/OpenSearchServiceConfig/RelationalDatabaseConfig, were rejected by the #608 pre-flight — and with--allow-unsupported-properties, silently dropped (ElasticsearchConfigsat inunhandledByDesignas a "deprecated alias", but it is a live SDK member the AMAZON_ELASTICSEARCH data-source type still requires, so it is now wired rather than excused). Now every member rides shared create/update mappers (applyResolverConfig/applyDataSourceConfig, theapplyGraphQLApiConfigpattern, so create and update cannot diverge), hand-mapped member-by-member with the casing traps pinned by tests (dbClusterIdentifierfor CFn'sDbClusterIdentifier,awsSecretStoreArn,openSearchServiceConfig,lambdaConflictHandlerConfig.lambdaConflictHandlerArn;MetricsConfigis a scalar enum on both types, not a block), with matchingreadCurrentStatereverse maps gated on the response's own Type/Kind discriminator so nothing becomes permanent phantom drift. The three*S3Locationproperties have NO SDK member at all — CloudFormation fetches the S3 object and passes its BODY as the inline member, and cdkd now mirrors that: ans3://bucket/keyURL (the form CDK's asset bindings emit; anything else is refused with a clear error naming the property, never coerced) is fetched viaGetObjectand passed ascode/requestMappingTemplate/responseMappingTemplate, the same helper also replacing theGraphQLSchema.DefinitionS3Locationwarn-and-drop no-op; inline wins over its S3 sibling with a loud warning (the StepFunctionsDefinitionS3Locationprecedent), an empty S3 body is refused, and all four properties are declared drift-unknown (getDriftUnknownPaths) because AWS returns only the resolved body, never the URL. On update, a changed URL re-fetches; an unchanged URL is a no-op even if the object behind it changed (CFn parity — CDK assets bake the content hash into the key). Removal semantics differ from the GraphQLApi batch by design:UpdateResolver/UpdateDataSourceare FULL-REPLACE writes, so "absent in template ⇒ omitted from the update input ⇒ cleared by AppSync" and no reset sentinels are needed — pinned in unit tests (the removal FIRES the call and the input OMITS the member) and live by the integ's REMOVAL phase, which drops the resolver'sMetricsConfig, asserts AWS cleared it toDISABLED, and keeps the EventBridge data source's ownMetricsConfigas the retained sibling so a blanket wipe cannot pass. Integ: theappsyncfixture gains anAMAZON_EVENTBRIDGEdata source (realAWS::Events::EventBus+events:PutEventsrole,EventBridgeConfig+MetricsConfig+ description update), resolver-levelMetricsConfigon the existing resolver (the API'sEnhancedMetricsConfigis alreadyPER_RESOLVER_METRICS), and a second resolver whose mapping templates come from S3 viaaws-s3-assetsAssets — verify.sh asserts the live template equals the local asset file byte-for-byte and that the UPDATE phase's v2 asset (new key) was re-fetched, extends theprovisionedBy == sdk#614 routing guard to every Resolver/DataSource row, and gone-probes the event bus on destroy.CachingConfig(needs a billed ApiCache),SyncConfig(VERSIONED delta-sync),MaxBatchSize(Lambda BATCH_INVOKE),CodeS3Location(same fetch helper the template S3Locations prove live),OpenSearchServiceConfig/ElasticsearchConfig(real OpenSearch domain) andRelationalDatabaseConfig(Aurora Data API) stay unit-only, recorded in the fixture README. Deliberately NOT done, recorded here: noReplacementRulesRegistryentries (unlikeApiType/Visibilitylast batch, the registry schema for both types DOES carrycreateOnlyProperties— ApiId/TypeName/FieldName and ApiId/Name — so the DescribeType fallback classifies them, with the provider's existing immutability refusals as defense-in-depth), and noNESTED_KEY_TARGETSopt-in for the two types: their schema fixtures pre-date the #1378/#1464definitionShapes/nestedPropertyPathscaptures and re-capturing requires livecloudformation:DescribeType, out of this change's no-AWS budget — follow-up in issue #1597: refresh the two fixtures, opt both types in with measured floors, and fix the pre-existingHttpConfig.AuthorizationConfignested silent drop found while scoping. -
AWS::EC2::Route's multi-destination warn arm records the NARROWED bag, ending the permanent phantom drift (issue #1591) —src/types/resource.ts,src/deployment/deploy-engine.ts,src/provisioning/providers/ec2-provider.ts, newtests/unit/provisioning/ec2-route-effective-properties.test.ts+tests/unit/deployment/deploy-engine-effective-properties.test.ts. The residue #1590 (issue #1566) knowingly left behind. Before: the multi-destination refusal downgrades to a WARNING on the state-borne paths — includingupdateRoute, which deletes the route before re-creating it, so a throw would strand it — and the update then SUCCEEDS, so the engine recorded the DESIRED bag carrying every declared destination key.readRouteCurrentStatecan only ever return the ONE key AWS holds, so the losers were PERMANENT phantom drift: reported by everycdkd drift, anddrift --revert"repaired" it by callingupdate()again, which delete-and-recreated the route and re-emitted the same warning, forever. That is the #1552 junk-state class one provider over. Now: a provider may returneffectiveProperties(a new optional field onResourceCreateResult/ResourceUpdateResultvia the sharedEffectivePropertiesResult) carrying the bag it ACTUALLY delivered, and the engine records that in place of the desired one at all three state-write sites — plus the replacement-result literal, which rebuilds the update result and would otherwise drop the field on the floor.createRoute's warn arm strips only the LOSING destination keys (everything else is sent verbatim, so narrowing further would silently disable the #1160 removal derivation for those properties) andupdateRouteforwards it from the re-create. Gated on??, not truthiness: an empty bag is a legitimate answer and only an ABSENT field means "record the desired properties", so no existing provider changes behavior. The create-path refusal is untouched — this is the replay/update answer, not a licence to accept the CFn-invalid shape from a template. Deliberately NOT the mechanism for AWS-side defaults or computed values, which belong inobservedProperties; the rules file records the three conditions under which returning it is right. The recording is only half the fix, and shipping it alone would have been worse than shipping nothing — found by the PR review, confirmed against the registry schema. With state narrowed and the template still declaring both keys, the next diff reads the dropped key as a user-made ADD; everyAWS::EC2::Routedestination key is create-only, so that classifies as a REPLACEMENT, and the engine's replacement create passes no context — socreateRoutehits the #1566 refusal and a previously-green no-op deploy starts FAILING. Without create-only knowledge (noDescribeType) it classifies in-place instead and delete-and-recreates a live route on EVERY deploy. So BOTH comparison sides are narrowed identically: a new optionalResourceProvider.canonicalizeDesiredProperties(resourceType, properties)(pure, synchronous) thatDiffCalculatorapplies to the resolved desired properties via a newcanonicalizeDesiredargument — injected as a function so the analyzer layer keeps no dependency on the provisioning layer. Both sides share ONE helper,narrowRouteDestinations, because re-deriving the rule would let state and template narrow to different keys, which is the original bug wearing a new hat. This is the same "normalize BOTH comparison sides" ruledrift-normalize.tsalready records for ordering. 24 new unit tests across three files; mutating the engine helper to MERGE instead of replace fails 4, gating it on a non-empty object instead of??fails the empty-bag row, and reverting either of the two engine sites the review found UNCOVERED (the property-driven replacement inside UPDATE, and the update-failure replacement fallback literal) now fails one each. Three further review rounds found three more real defects in the fix itself, each now fenced: normalizing only the DESIRED side left the issue's own population (a state record written before the narrowing, carrying every key) reading the loser as a REMOVAL — same create-only replacement, same #1566 refusal, same broken deploy, reached from the other side, so BOTH sides are normalized and the record self-heals on the next write;cdkd diffwas never wired, so the preview forecast a REPLACEMENT the deploy would never perform — the phantom moved one command over rather than being fixed — sodiff.tsnow builds the SAME normalizer through the sharedmakeCanonicalizePropertiesFnand threads it throughbuildDiffTree/computeStackDiffinto every nested child; and the diff test re-implemented the provider's gate in a local closure, so guttingEC2Provider.canonicalizeDesiredPropertiestoreturn propertiesproduced a ZERO-diff full suite — it now calls the real method through the real builder. The engine->diff wiring itself is pinned too (replacing the argument withundefinedwas previously undetectable). -
AWS::ApiGatewayV2::Integration: the last 10 silent-drop properties wired, the type CLOSED (issue #609 apigatewayv2 batch) —src/provisioning/providers/apigatewayv2-provider.ts,scripts/gen-nested-key-coverage.ts, newtests/unit/provisioning/apigatewayv2-integration-props.test.ts,tests/integration/apigatewayv2-update-removal/{lib/apigatewayv2-update-removal-stack.ts,verify.sh,README.md}, coverage matrices regenerated. Before:ConnectionId/ConnectionType/ContentHandlingStrategy/CredentialsArn/IntegrationSubtype/PassthroughBehavior/RequestTemplates/ResponseParameters/TemplateSelectionExpression/TlsConfigwere declared nowhere and delivered nowhere, so every HTTP API service integration (IntegrationSubtype+CredentialsArn), every WebSocket MOCK integration (RequestTemplates+TemplateSelectionExpression) and every VPC-Link integration was rejected by the deploy-time pre-flight — and silently dropped under--allow-unsupported-properties. Now all ten rideCreateIntegration/UpdateIntegrationwith the matching reverse map inreadCurrentState;PROPERTY_COVERAGE_BY_TYPEreports an emptysilentDropfor the type, which closes the last of the fiveAWS::ApiGatewayV2::*types. Nine are exact-spelling pass-throughs;ResponseParametersis not, and forwarding it verbatim would have been a silent drop one level down. CloudFormation models it as{"<status>": {ResponseParameters: [{Destination, Source}]}}while the API takes the FLATTENED{"<status>": {"<Destination>": "<Source>"}}—Destinationbecomes a map KEY — so the SDK serializer, which drops members it cannot model, would have delivered an empty map per status code with a green deploy.toSdkResponseParametersperforms the fold (passing a non-object through untouched so an unresolved intrinsic still produces AWS's own validation error, and accepting an already-flat block for hand-written templates), andtoCfnResponseParametersperforms the inverse inreadCurrentState— without which the AWS-current side would carry the flat map against a CFn-shaped baseline and everycdkd driftrun would report permanent phantom drift on an untouched integration. Because a fold through a COMPUTED key is exactly what the nested-key critic's write-evidence pass cannot credit, the two audited paths carryNESTED_KEY_ALLOW_LISTentries rationale'd as a shape difference (the same shape as the existingAWS::AppSync::GraphQLApiTags.Keyentry), and the target'sminNestedKeysfloor moves 0 -> 4 now that the type audits its first nested blobs. Absent-field REMOVAL is deliberately NOT part of this batch, and that is a scope decision rather than an oversight — each field would need its own live CloudFormation A/B to establish whether CFn resets or retains it and which sentinel the API accepts as "unset", and the sibling Stage/Route batch is precisely why: its first draft guessed, and the live probe contradicted it. Guessing a reset here would turn a silent drop into a silent OVERWRITE of a live integration, which is strictly worse; the group stays with the #1160 umbrella and a unit test pins the no-op so a later change to it is visible in the diff. Integ: theapigatewayv2-update-removalfixture now carries THREE integrations, because the properties are scoped to different integration types — an HTTPHTTP_PROXYone (TlsConfig+ResponseParameters), an HTTPAWS_PROXYservice integration invoking EventBridge under its own IAM role (IntegrationSubtype+CredentialsArn), and a WebSocketMOCKone (RequestTemplates/TemplateSelectionExpression/PassthroughBehavior/ContentHandlingStrategy, which the previous batch's fixture comment had recorded as unreachable while they were still silent-drop).verify.shtherefore resolves integration ids byIntegrationTypeinstead of takingItems[0], and theResponseParametersassertion reads the values back under theirDestinationKEYS — which only passes if the fold reached AWS. All ten change VALUE across phases 1 and 2 rather than being removed, matching the scope decision above.ConnectionIdstays unit-test-only (a live VPC Link costs a VPC + subnets + SG per run for a plain pass-through string), recorded in the fixture README. The 3-axis review found one blocker and it is worth recording:Sourcewas skipped unless it was a STRING, but CFn typesResponseParametersas free-formobjectand coerces scalars, so an unquotedSource: 403under the canonicaloverwrite:statuscodedestination — the exact shape a YAML author writes — was dropped with a green deploy, re-introducing the very class the fold exists to close.Sourceis now coerced from a number / boolean; a non-stringDestination(it becomes a map KEY, so a scalar shorthand makes no sense there) is still skipped but no longer SILENTLY — it warns. The fixture writes the status code unquoted so the coercion is live-covered, and the review also moved three smaller things: the re-sentIntegrationSubtypenow falls back to the PREVIOUS side (the constraint is about the LIVE integration, which stays a service integration even when the template drops the subtype),toCfnResponseParameterssorts its pairs byDestination(it turns an unordered SDK map into an array, and the drift comparator compares arrays positionally), and all ten update gates use!= nullso an explicitnullcannot produce a field-lessUpdateIntegrationon every deploy. The fixture gained aprovisionedByassertion pinning all three integrations to the SDK route — a backfill that left one property unhandled would flip the type to Cloud Control, which forwards the full map and would still deploy green with none of this code on the path. A third finding came from the fixture growing: with three integrations across two APIs, two consecutive runs failed on DIFFERENT resources with API Gateway v2'sUnable to complete operation due to concurrent modification. Please try again later.— the service serializes mutations per API while cdkd deploys siblings of oneApiIdin parallel by design, andsrc/deployment/retryable-errors.tscarried no pattern for it, so a whole deploy failed on an error whose own message asks for a retry. The string now sits on the EXPONENTIAL half of the table (it is load-shaped contention, so the dense IAM-propagation cadence would make it worse), which made the run deterministic; issue (#1607) records the scope questions left open (a code-based match would be sturdier than a message one, and the DELETE path is unexamined). - ELBv2 LoadBalancer + TargetGroup silent-drop batch — closes both types on issue #609 —
src/provisioning/providers/elbv2-provider.ts,tests/unit/provisioning/elbv2-lb-targetgroup-props.test.ts,tests/integration/alb/. Nine previously-dropped properties are wired. LoadBalancer:EnablePrefixForIpv6SourceNat(CreateLoadBalancer + SetSubnets on update),Ipv4IpamPoolId(nested into the SDK'sIpamPoolswrapper on create;ModifyIpPoolson update withRemoveIpamPools: ['ipv4']on removal),EnforceSecurityGroupInboundRulesOnPrivateLinkTraffic(no CreateLoadBalancer member — applied via post-create / updateSetSecurityGroups),MinimumLoadBalancerCapacity(post-create / updateModifyCapacityReservation; removal issuesResetCapacityReservationbecause a retained reservation keeps billing), andEnableCapacityReservationProvisionStabilize(CFn-only orchestration flag with NO SDK member — cdkd pollsDescribeCapacityReservationuntil every zone isprovisioned, bounded ~10 min, timeout warns-and-continues, afailedzone errors; skipped under--no-wait; declared ingetDriftUnknownPathssince it has no AWS readback). TargetGroup:IpAddressType+TargetControlPorton CreateTargetGroup (both immutable — IpAddressType is schema createOnly, TargetControlPort has no modify API AND is missing from the schema's createOnlyProperties, so the update path rejects a change withResourceUpdateNotSupportedErrorand the engine falls back to replacement),TargetGroupAttributes(post-create ModifyTargetGroupAttributes + key-diff on update via the new shareddiffAttributeshelper — also now used by the LB / Listener attribute diffs; the removal arm sends the documented default fromTARGET_GROUP_ATTRIBUTE_DEFAULTSbecause ModifyTargetGroupAttributes REJECTS the empty-string reset — "A target group attribute value must be specified", live-caught by the alb integ's removal phase after the mocked unit tests had agreed with the wrong wire assumption; a removed key with no documented default warns and retains. NOTE this entry originally added "…the empty-string reset the LB / Listener attribute APIs accept"; that was only half true and the later issue #1609 item 1 A/B corrected it — those two APIs accept''for numeric / free-form-string keys but reject it for BOOLEAN / ENUM ones, see the #1609 entry below), andTargets(post-create RegisterTargets + full-tuple diff on update: register-first, then DeregisterTargets; deliberately NOT read back byreadCurrentState— declared ingetDriftUnknownPathsbecause the readback is an unordered object list the positional drift comparator would phantom-flag, and deregistration is async). TargetGroup create gained the same best-effort-delete-then-rethrow partial-create cleanup as the LB create path (TG names are unique, so a stranded TG would collide on the next deploy).readCurrentStatereads back the new LB fields (flatteningIpamPoolsto the CFn spelling, capacity viaDescribeCapacityReservationemitted only when units > 0) and the TG fields (full sortedTargetGroupAttributesset, same model as LB/Listener attributes). Live-verified end-to-end by the extendedalbinteg (baseline attrs/targets/IpAddressType → update-phase attribute diff + target swap → removal-phasetraffic-portreset + attribute 300 reset → destroy clean). The capacity-reservation pair is unit-only: the integ account lacks the LCU entitlement — a liveModifyCapacityReservationattempt was rejected with "This AWS account does not support configuring minimum load balancer capacity reservation" (the request shape reached the API; the refusal is account-level, not payload-level), and that failed update also live-exercised the automatic rollback (the TargetGroup's just-applied attribute + target changes were restored from state cleanly, with no update-refusal firing on the state-borne replay). ELBv2 is deliberately NOT added toNESTED_KEY_TARGETSin this PR (the critic script is contested by two in-flight lanes; tracked by issue #1393's target-expansion item). - The four S3 per-item / per-rule STRING refusals warn-and-SKIP on a state replay instead of hard-throwing (issue #1595) —
src/provisioning/config-shape.ts,src/provisioning/providers/s3-bucket-provider.ts, newtests/unit/provisioning/s3-bucket-provider-per-item-string-replay.test.ts. The path-split half of #1581, on the reads that issue's audit relied on to conclude the per-item containers of three appliers need no dedicated guard — that conclusion stands (they DO refuse a malformed item); what was unresolved is that they refuse on the REPLAY path too. Before: four reads — the lifecycle per-ruleStatus, the intelligent-tiering per-itemStatus, the inventory per-itemIncludedObjectVersions, and the replication per-ruleStatus(the fourth found while scoping, sitting in the same function as the container guard #1581 added, which makes it the one most likely to be mistaken for already-handled) — went through areadConfigStringcarrying noonUnusable.rollback-executor.ts's revert arm andcdkd drift --revertboth callupdate(..., previousState.properties, ...), so the desired bag can be a historical cdkd STATE record with no template-side remedy; a record written by an older binary with a malformed value made the bucket not merely un-updatable but UN-ROLLBACKABLE. Now: each site probes first and SKIPS on the replay paths. The downgrade is deliberately NOTreadConfigString's own warn-and-DEFAULT: every default here (Status->Enabled,IncludedObjectVersions->All) is applied to a LIVE resource, so defaulting would START an expiration rule deleting objects, an intelligent-tiering transition moving objects to archive tiers, or a replication rule copying objects OUT of the bucket — for a rule the template had DISABLED. The skip UNIT matches the API and the sibling container guard in the same applier: the WHOLE Put for lifecycle / replication (each replaces every rule, so applying the valid siblings alone would DELETE the malformed one from AWS) and the single configuration ITEM for intelligent tiering / inventory (those Puts are per-Id). A newconfigStringRefusal(container, key, fallback, containerPath, options?)inconfig-shape.tsexports the predicate WITHOUT the action clause — it returns the refusal sentence orundefined— andrequireConfigStringis re-expressed on the same internal predicate so the probe and the read cannot diverge; a test enumerates both over every value shape (a hand-writtentypeoftwin disagrees on exactly the blank string, the explicitnulland the coerced number). The CREATE path never probes, so its refusal is byte-identical to before, and the warning names the skip that actually happened rather than inheriting a "using the default" clause this path never makes good on. Deliberately out of scope, per the issue: the FIELD-level reads on the same replay-reachable paths (versioningStatus, the logging reads, the inventoryScheduleFrequency/Schedule.Frequencypair) — each is a separate per-site decision. 85 new unit tests (79 provider-side + 6 onconfigStringRefusal); neutering the skip fails 53 of them, and the 26 that survive are the create-path regression fences, which pass on the unfixed tree by construction. The PR review's test pass found three mutations the FIRST version of the suite did not catch, and each is now fenced: flippingcontinue->returnon the two per-Id appliers was invisible because both "valid sibling still applies" rows ordered the items['good','bad'], so an early return still emitted thegoodPut (the rows now put the malformed item FIRST); mutating the probe'sfallbackfrom'Enabled'to''was invisible because only one malformed shape was exercised per site, and a blank value is only refused when the fallback is non-blank (the replay / update rows are now parametrized over every shape); and swapping one applier's skip CLAUSE for a sibling's was invisible, so a log could claim an item skip where the whole Put had been left alone (the clause is now asserted per applier). - A
BillingModeflip to PROVISIONED now carries per-GSIProvisionedThroughput, so a table WITH an index can be flipped at all (issue #1588) —src/provisioning/providers/dynamodb-table-provider.ts, newtests/unit/provisioning/dynamodb-table-provider-billing-flip-gsi.test.ts,tests/integration/dynamodb-ondemand/{lib,verify.sh}. Surfaced by the review of PR (#1586), which shipped the real PAY_PER_REQUEST -> PROVISIONED flip and recorded in-code that per-index capacity was "a separate (deferred) concern" leaving "a silent gap". It was neither separate nor silent. The A/B was measured against real AWS on 2026-08-11 rather than assumed, and both halves matter: the flip carrying only table-levelProvisionedThroughput— exactly what the provider sent — is rejected outright withValidationException: One or more parameter values were invalid: ProvisionedThroughput must be specified for index: gsi1, applying NOTHING; the same call carryingGlobalSecondaryIndexUpdates[].Update.ProvisionedThroughputis accepted, and the readback shows the table at its declared capacity and the index at its own. So the flip was not degraded for a table with a GSI, it was impossible — the issue's own "very likely fails" is now measured fact. Now:update()buildsGlobalSecondaryIndexUpdatesin the SAMEUpdateTableas the flip, mirroring whatdynamodb-globaltable-provider.tsdoes for (#1387). Three scoping decisions, each with a reason a reviewer can challenge: the gate is the FLIP specifically (live PAY_PER_REQUEST -> desired PROVISIONEDwith at least one live index), because AWS rejects anUpdateTablethat re-asserts an index's current capacity and a plain capacity bump on an already-PROVISIONED table must keep working; the index list comes from the LIVEDescribeTable, not the template, because AWS's refusal names live indexes and a template-only list would miss exactly the index it complains about (an index that still exists but is no longer declared); and an index the template declares no capacity for is left OUT with a WARNING rather than pre-refused or given a guessed capacity — AWS names it a moment later, which is the CFn-handler outcome and a better message than anything cdkd could invent. 8 unit tests pin the positive path (single index, multiple indexes, string-typed capacities CFn emits, the live-vs-template list) and the scope fences (no index updates on a capacity bump, none on a flip to PAY_PER_REQUEST, the index-free #1553 shape untouched, the warn-and-omit case); suppressing the assignment fails exactly the 4 positive tests while the 4 fences correctly stay green. Thedynamodb-ondemandfixture's removal table gains a GSI — the very index its predecessor's comment says was left out BECAUSE of this gap — with capacity declared only on the update side, andverify.shwaits the index to ACTIVE (DynamoDB reports the TABLE active while an index still updates) before asserting the per-index capacity actually landed. - Four
AWS::Lambda::Functionsilent-drop properties wired:CodeSigningConfigArn/RuntimeManagementConfig/DurableConfig/TenancyConfig(issue #609) —src/provisioning/providers/lambda-function-provider.ts,src/analyzer/replacement-rules.ts,tests/integration/lambda-config-field-removal/,tests/unit/provisioning/lambda-function-props-609.test.ts,tests/unit/analyzer/replacement-rules-lambda-609-props.test.ts. Pre-PR behavior: all seven of the type'ssilentDropentries were rejected by the deploy-time pre-flight, so a template using any of them either failed to deploy or (with--allow-unsupported-properties) deployed with the value silently dropped. Now: four are delivered —CodeSigningConfigArnandDurableConfig/TenancyConfigonCreateFunction,RuntimeManagementConfigvia a post-createPutRuntimeManagementConfigunder the same delete-on-failure atomicity contractRecursiveLoop/ReservedConcurrentExecutionsalready used (the three now share one extractedapplyPostCreateConfighelper instead of three copies of the same 30-line block). Each also reads back inreadCurrentState, so none becomes phantom drift. The update path is where the AWS semantics stopped being guessable, and every rule below is a live probe (us-east-1, 2026-08-11), not a docs reading:CodeSigningConfigArnremoval maps toDeleteFunctionCodeSigningConfig(leaving it attached would keep enforcing a security control the template dropped — the #1160 class);RuntimeManagementConfighas no delete counterpart, so removal re-sends the AWS defaultUpdateRuntimeOn: Auto; andDurableConfigis the one field in theUpdateFunctionConfigurationblock that deliberately does NOT go throughclearOnUpdateRemoval, because AWS REJECTS adding a durable config to a function created without one ("You cannot add a durable configuration to a function that was originally created with no durable configuration") and omitting it on update KEEPS the live value with no reset payload available — so both presence toggles are routed to REPLACEMENT by a newconditionalReplacementspredicate while a both-sides-present edit stays in place.TenancyConfigis create-only in the CFn registry schema AND the SDK, so it is an unconditionalreplacementPropertiesentry. Deliberately NOT wired:CapacityProviderConfig+FunctionScalingConfig, which are one coupled feature —PutFunctionScalingConfigfails withAccessDeniedException: The function provided by the arn does not contain a capacity provider configurationunless the function already carries a capacity provider, which must be provisioned separately (issue #1616); andPublishToLatestPublished, now declaredunhandledByDesign— it is a CloudFormation version-publishing directive with no member on ANY@aws-sdk/client-lambdarequest shape (0 hits across the wholedist-typestree, while the other six all resolve). Tests: 30 new unit cases across the two files, plus thelambda-config-field-removalinteg extended to assert all four properties reached AWS, both removal spellings took effect, andprovisionedBy=sdkon both functions (a silent-drop re-route to Cloud Control would fail the run). Three existing test fixtures that usedRuntimeManagementConfigas their canonical Lambda silent-drop example were moved toFunctionScalingConfig, which cannot be backfilled on its own and so stays silent-drop. - Lambda
ImageConfigkept-partial is whole-object REPLACE — measured, pinned, no behavior change (issue #1225 item 3) —src/provisioning/providers/lambda-function-provider.ts(comment only),tests/unit/provisioning/lambda-function-provider.test.ts,docs/provider-development.md§2a. Why it was open: #1227's first pass classified every object-typed reset field shipped by the #1160 batches, andImageConfigwas one of the three left UNPROBED — ifUpdateFunctionConfigurationMERGED its sub-shape, a template that keepsImageConfigwhile droppingCommandwould silently retain the live value (the #1160 class one level down), and the provider's verbatim pass-through would have been a divergence. The measurement (live A/B, us-east-1, 2026-08-11, a container Lambda on a throwaway ECR image, both probes torn down): the SDK REPLACES — a create carrying{EntryPoint, Command, WorkingDirectory}followed byUpdateFunctionConfigurationwith{EntryPoint}alone left ONLYEntryPointlive; and CloudFormation reaches the same end state, anupdate-stackthat dropsCommand+WorkingDirectoryfrom a keptImageConfigblock reading back{EntryPoint}. Both halves were measured rather than one inferred from the other — "does CFn reset it" and "what does the API do with a partial object" are separate questions, and only the second says whether pass-through is already right. The{}clear value the provider has sent since #1157 is verified by the same run (SDKImageConfig: {}and a CFn template dropping the whole block both leaveImageConfigResponseabsent). What shipped: no behavior change — the classification is recorded at theclearOnUpdateRemovalsite and in §2a next to theLoggingConfigwhole-replace precedent, plus a unit test pinning that a kept-but-partial block goes out verbatim with no sub-field synthesis (a mutation that merges the previous sub-fields fails it). Remaining #1225 scope is now the two probes this lane deliberately did not take: ECSDeploymentConfiguration(its file is owned by the in-flight #609 ECS lane) and ASGCapacityReservationSpecification(needs a billed Capacity Reservation). -
AWS::DynamoDB::TableBillingModeremoval is a RESET to PROVISIONED, and no longer emits an empty UpdateTable (issue #1553) —src/provisioning/providers/dynamodb-table-provider.ts, newtests/unit/provisioning/dynamodb-table-provider-billing-mode-removal.test.ts,tests/integration/dynamodb-ondemand/{lib/dynamodb-ondemand-stack.ts,verify.sh}. Before:create()substituted the CFn type defaultPROVISIONEDfor an absentBillingMode, butupdate()left itundefined. Against a recorded previous ofPAY_PER_REQUESTthat read as a change, and theupdateInput.BillingMode = …assignment was gated on the value being truthy — soUpdateTable({TableName})went out with NO mutable field, DynamoDB rejected it, and the deploy failed with a confusing error on every attempt (the #1160 absent-field class, surfaced by the review on the #1545 PR, which preserved the behavior byte-identically because its scope was the malformed-value guard). The measurement, not an inference from the type default (live CloudFormation A/B, us-east-1, 2026-08-11, stackCdkdIssue1553BillingModeAb): a table deployed withBillingMode: PAY_PER_REQUESTand then UPDATE'd with the property REMOVED and noProvisionedThroughputfails the stack —UPDATE_FAILED … Property ProvisionedThroughput cannot be empty->UPDATE_ROLLBACK_COMPLETE— while the same removal WITHProvisionedThroughput: {3, 4}reachesUPDATE_COMPLETEand reads backPROVISIONEDat 3/4. So CFn RESETS on removal; it does not retain, which is the opposite of what the destroy-parity folklore would have predicted and why the #1545 PR deliberately left it unmeasured. Now an ABSENT value on EITHER side normalizes toPROVISIONED, so the update path agrees withcreate(), the call always carries the resolved mode, and — because BOTH sides normalize — a table that never declared the property does not acquire a spurious change on every deploy (the regression a one-sided normalization would have introduced). A flip to PROVISIONED with noProvisionedThroughputin the template is deliberately left to fail at AWS rather than pre-refused: that is CFn parity, DynamoDB's own error names the missing member, and a pre-flight throw on the UPDATE path would fire on a rollback /drift --revertreplay of a state record the user cannot edit. The GlobalTable sibling was checked and is clean —AWS::DynamoDB::GlobalTable's own CFn default isPAY_PER_REQUESTand itsupdate()already resolves an absent value throughrequireConfigString(…, 'PAY_PER_REQUEST', …), sonewBillingis neverundefinedand the empty-UpdateTable shape cannot occur there. Integ: thedynamodb-ondemandfixture gained an unconditional hand-written L1BillingRemovalTable(an L2dynamodb.Tablealways emitsbillingMode, and the property has to be genuinely absent) whoseCDKD_TEST_UPDATE=truephase removesBillingModeand declares 3/4 instead, plus a phase-1.6 readback assertingPROVISIONEDat 3/4. - GlobalTable GSI recovery baseline now carries live VALUES, so a corrected template stops lagging a deploy (issue #1571) —
src/provisioning/providers/dynamodb-globaltable-provider.ts, newtests/unit/provisioning/dynamodb-globaltable-provider-gsi-live-baseline.test.ts,tests/integration/dynamodb-globaltable/{lib/dynamodb-globaltable-stack.ts,verify.sh}. Before: when a cdkd state record'sGlobalSecondaryIndexeswas present-but-unusable (which the provider's own warn path records whenever a malformed desired block is deployed), PR #1562 seeded the diff baseline from the LIVE table's index NAMES only —desiredSdkIndexes.filter(gsi => liveIndexNames.has(gsi.IndexName)). That stopped the permanent index loss it was written for, but every carried entry was a byte-copy of its desired counterpart, so the diff could produce nothing but ADDs: a per-index capacity edit compared no values at all, and the #1160 on-demand ceiling RESET — which is derived from the PREVIOUS side — could never fire, leaving a droppedMaxReadRequestUnitslive in AWS while cdkd reported success. NowbuildLiveRecoveryGsiBaselinecarries the live values that are safe to compare, with each exclusion tied to one of the three MEASURED failure modes of the first value-carrying attempt rather than to a guess:ProvisionedThroughputis gated on the LIVEBillingModeSummary(DescribeTable reports{0, 0}for every index of an on-demand table, and re-sending that is an AWS rejection), an index whose template declares*CapacityAutoScalingSettingskeeps an identity-only capacity (the live number belongs to Application Auto Scaling while the desired side isMinCapacity, so comparing them issues a scale-down nobody asked for — detected from the TEMPLATE by the newcollectAutoScaledGsiNames), andKeySchema/Projection/WarmThroughputare always copied from the desired side so they can never differ (the first two are immutable on an existing index and AWS does not guaranteeNonKeyAttributesreadback order; warm throughput reads back with aStatusmember the translated shape lacks and is increase-only). The whole entry is built by SPREADING the desired one so key ORDER is preserved —deepEqualisJSON.stringify, which is the second measured failure mode. A billing flip in play, or a live mode that disagrees with the desired one, falls back to the identity-only baseline. REMOVES are still not derived, and the decision is now recorded with the correct remedy: an index live but absent from the template stays in neither side, because a junk state record cannot distinguish "cdkd created this and the template dropped it" from "somebody added it out of band" and an index delete is irreversible — but the old warning promised that a later deploy would clear it, which CANNOT happen (once this deploy records a valid block the index is in neither side of every subsequent diff, so it survives indefinitely). The warning now says so and points atcdkd drift --accept+ redeploy, which does put the index back on the previous side. Integ: thedynamodb-globaltablefixture gained an unconditionalGsiRecoveryTable(presence unconditional, configuration mode-keyed — a mode-gated RESOURCE would be deleted by every later deploy whose mode list omits the token) plus a two-phase sequence no mocked client can reach:gsi-state-junkrendersGlobalSecondaryIndexesas an unfoldedFn::Joinover the region pseudo-parameter (Token.asAnyis what gets it past the generated L1 validator) so cdkd resolves it to a STRING and RECORDS the junk block, thengsi-state-recoverycorrects the template while changing one ceiling (40 -> 90) and dropping the other (50 -> absent). Pre-fix both read back unchanged. -
AWS::ApiGatewayV2::Stage+::Route: the remaining silent-drop properties wired, both types CLOSED (issue #609 apigatewayv2 batch) —src/provisioning/providers/apigatewayv2-provider.ts, newtests/unit/provisioning/apigatewayv2-stage-route-props.test.ts,tests/unit/provisioning/apigatewayv2-provider-readcurrentstate.test.ts,tests/integration/apigatewayv2-update-removal/{lib/apigatewayv2-update-removal-stack.ts,verify.sh}, coverage matrices regenerated. Before: Stage droppedAccessLogSettings/ClientCertificateId/DeploymentId/RouteSettingsand Route droppedApiKeyRequired/ModelSelectionExpression/RequestModels/RequestParameters/RouteResponseSelectionExpression, so a template using access logging, per-route throttling or any WebSocket route feature was rejected by the deploy-time pre-flight (and silently dropped under--allow-unsupported-properties). Now all nine rideCreateStage/UpdateStage/CreateRoute/UpdateRouteas exact-spelling pass-throughs, with the matching reverse map inreadCurrentState;PROPERTY_COVERAGE_BY_TYPEreports an emptysilentDropfor both types. Removal semantics were MEASURED, and the first draft got them wrong. A raw-SDK probe against real AWS (2026-08-11, us-east-1) settled all five open questions at once, and the review that demanded it is why:UpdateStageMERGES, so a stage keeps itsAccessLogSettingsverbatim through an update that omits the member — the pass-through the first draft shipped would have left access logging (and its billing) running forever. AWS ships dedicated delete APIs for exactly these, soupdate()now issuesDeleteAccessLogSettingson removal,DeleteRouteSettingsper DROPPED route key, andDeleteRouteRequestParameterper dropped parameter key — the same out-of-band shapeDeleteCorsConfigurationalready had on::Api, and each fires even when noUpdate*-able field changed. The probe also CONFIRMED the two sentinels the review flagged as unverified:UpdateStageacceptsClientCertificateId: ''and clears it, andUpdateRouteaccepts an empty-stringRequestModelsvalue and clears the model, soclearableUpdate/mapWithRemovalsare right for those.ApiKeyRequiredresets to the CFn defaultfalse. OnlyDeploymentIdand the two selection expressions stay pass-through — no delete API, no documented sentinel. Integ: theapigatewayv2-update-removalfixture gained a WebSocket API, because all five Route properties are documented WebSocket-only — an HTTP API cannot exercise them at all. Two WS routes, not one: AWS rejectsRequestParametersanywhere but$connect(Request parameters are only supported for the $connect route in WEBSOCKET APIs, hit live on this fixture's first run), while the selection expressions belong on a body-carrying route. The WS stage carriesAccessLogSettings(against a real log group) plusRouteSettings, whose members are written in PascalCase on purpose:CfnStage.routeSettingsis typedany, so CDK passes the map through verbatim and a camelCase key would be dropped by the SDK serializer with a green deploy — the silent-drop class this backfill closes, one level down. A third phase (CDKD_TEST_REMOVAL) drops the three delete-API-only members and asserts each is gone on AWS while a RETAINEDRouteSettingssibling key proves the removal deleted one key rather than the block; it builds on the update phase rather than reverting to the baseline, so the only delta it introduces is the removal itself. Run 2026-08-11: PASS 105s, destroy 0 errors, 0 orphans.ClientCertificateId(needs a WebSocket client certificate),DeploymentId(meaningful only withAutoDeployoff, andAWS::ApiGatewayV2::Deploymentis not a registered cdkd type) andRequestModels(needsAWS::ApiGatewayV2::Model, also unregistered) stay unit-test-only, recorded in the fixture README.AWS::ApiGatewayV2::ApiFailOnWarningsis NOT part of this batch — it was alreadyunhandledByDesign(OpenAPI-import-only), and::Integration's 10 remain open. - A malformed non-object CONTAINER is refused (create) or warned-and-skipped (state replay) instead of silently emptying the block (issue #1581) —
src/provisioning/config-shape.ts,src/provisioning/providers/s3-bucket-provider.ts, newtests/unit/provisioning/s3-bucket-provider-container-shape.test.ts. The parent-container sibling of #1579, on the containers whose members are probed for PRESENCE rather than read as a string — so neitherreadConfigString(which needs a string read to reach its rule 2) norrequireConfigArray(which needs a list) ever fired on them. Before: a present-but-non-OBJECT lifecycleFilter(Filter: 'logs/', an array, an unresolved intrinsic) indexed EVERY member probe ingatherScope—Prefix/TagFilters/ObjectSizeGreaterThan/ObjectSizeLessThan— toundefined, so the rule kept NO location scope and fell through to the empty-prefix V2Filter, applying to the WHOLE bucket; for an expiration rule that deletes objects the rule was never meant to touch (the #1388 widened-scope hazard reached through the parent container instead of a mis-placed key). The same shape on analyticsStorageClassAnalysisindexed theDataExportprobe toundefinedand shippedStorageClassAnalysis: {}, which S3 ACCEPTS as "no export" — a silent drop with no error anywhere. Found by the PR #1580 review, filed rather than folded in. Now: a newrequireConfigObject(value, path, options?)inconfig-shape.ts— the object-shaped sibling ofrequireConfigArray, same overloadedonUnusablestate-replay downgrade, same division of labour (the caller keeps the ABSENT case, so{}and an omitted block both stay legitimate) — guards four containers: REFUSE on a template-path create; WARN and skip on the replay-reachable paths, skipping the whole lifecycle / replication Put (each replaces every rule, so applying the valid siblings alone would DELETE the malformed one from AWS) or the single analytics configuration item (that Put is per-Id). The PR review found a THIRD live instance of the class, with the widest blast radius of the three, and it is fixed here rather than deferred:applyReplicationConfiguration's per-ruleFilterhas the identical shape, and its fall-through arm emitsFilter: {}— the valid CFn form meaning "replicate EVERY object" — so a malformed container silently replicated the WHOLE bucket instead of the declared subset, sending data outside its intended scope at cross-region transfer cost. That site additionally sat behind a TRUTHINESS gate (if (filter)), the #1493 shape, so a falsyFilter: ''skipped the branch entirely and fell to the top-level-Prefixarm; the gate is now!= null.applyReplicationConfigurationgained theonUnusableparameter and both call sites thread it (update always warns, create passesreplayOnUnusable). TheStorageClassAnalysis.DataExportcontainer was previously refused only INDIRECTLY, by thereadConfigString(dataExport, 'OutputSchemaVersion', …)below it — which carries no downgrade, so a historical state record with a malformed block hard-threw and left the resource un-rollbackable; it now takes the same warn-and-skip contract as its siblings, with the create-path refusal unchanged. The lifecycle path'sisPlainPrefixOnlygate was hardened from!== undefinedto!= nullto match, so an explicitFilter: nullmeans "block omitted" throughout the function instead of silently forcing every rule in the configuration into V2 Filter form. Audited and deliberately NOT guarded (the issue asked for the one-level-up sweep before scoping): the per-ITEMconfigcontainers of the five appliers — intelligent tiering, inventory and the lifecycle rules already refuse a non-object item through an existingreadConfigString(config | rule, …), while metrics and analytics read onlyIdoff the item, whose absence makesPutBucket{Metrics,Analytics}Configurationreject the request outright. That is a LOUD failure rather than the silent scope-widening these guards exist for, so the decision is recorded in-code next to the loop rather than paid for with a replay-path behavior change. 27 new unit tests (21 provider-side + 6 onrequireConfigObjectitself) pin refusal / warn-skip / valid-and-absent-still-apply / the array-vs-object case a baretypeof === 'object'check would wave through; reverting either guard fails the suite. - A malformed non-array
TagFiltersis refused (create) or warned-and-skipped (state replay) instead of silently omitting the filter (issue #1579) —src/provisioning/config-shape.ts,src/provisioning/providers/s3-bucket-provider.ts, newtests/unit/provisioning/s3-bucket-provider-tagfilters-shape.test.ts. Before: all three S3 per-item filter builders (metrics / analytics / intelligent-tiering) readTagFilterswith a blind cast, so a present-but-non-array value (a single tag OBJECT, an unresolved intrinsic, a string) read as ZERO predicates via?.lengthand theFilterblock was silently omitted — the configuration deployed with a WIDER scope (all objects) than the template declared; on the lifecycle path the same shape read as "no tags" and an expiration rule applied to the WHOLE bucket (the #1388 widened-scope hazard from a malformed container). Found by the PR #1576 review (recorded on the then-closed umbrella #1493, re-filed standalone). Now:requireConfigArrayaccepts the sameonUnusablestate-replay downgrade #1556 gavereadConfigString(overloaded so existing throw-only callers keep their non-undefinedreturn type), and all four S3 sites validate the container: REFUSE on a template-path create; WARN and skip on the replay-reachable paths (replayingStatecreate, the keyed update sync) — skipping the whole configuration item (metrics / analytics / IT) or the whole lifecycle Put (it replaces every rule, so skipping one rule would silently DELETE it from AWS), never applying the item without its tag predicate. Container-level validation only, matchingrequireConfigArray's existing contract (element garbage inside a real array still fails loudly at S3). 24 new unit tests (21 provider-side + 3 onrequireConfigArrayitself) pin refusal / warn-skip / valid-sibling-still-applied / absent-still-applies; neutering the guard fails 19 of them. The PR review additionally hardened the lifecycle path's two strict=== undefinedcompares to== null— an explicitTagFilters: nullmeans "no entries" per the list-block contract and used to crash.lengthwith a raw TypeError; the sibling malformed-Filter-CONTAINER class (one level up) is follow-up issue (#1581). - A committed raw NUL made
efs-provider.tsinvisible to every grep/rg audit (issue #1587) —src/provisioning/providers/efs-provider.ts, newscripts/check-source-control-bytes.ts+tests/unit/scripts/source-control-bytes.test.ts. Before: thecreationTokenhash separator at line 435 was written as a LITERAL U+0000 byte rather than the\0escape. Identical at runtime, so typecheck / lint / format / tests all passed — butgrepandrgclassify a NUL-carrying file as BINARY and skip its contents, and the systemgrephere is ugrep, which omits GNU grep's "Binary file matches" notice entirely. Sogrep -rn '<term>' src/provisioning/providers/returned zero hits from this provider, with no warning and exit 0. That is not hypothetical: a/work-issuespass audited the #1160 SUSPECT row for this file, got zero hits forThroughputMode, and concluded the efs removal batch was unshipped when PR #1535 had shipped it on 2026-08-10 — the row read as open work purely because the audit tool could not see the file.git grep/git diffwere UNAFFECTED (git's binary heuristic inspects only the first 8000 bytes, and the NUL sat at 17230), so GitHub review rendered the file normally and nothing in the PR flow could have surfaced it. Now: the separator is the\0escape — byte-identical, so thecdkd-${logicalId}-${tokenHash}creation token is unchanged and no live file system's identity moves (verified by computing the digest both ways) — and a new classifier rejects every C0 control byte plus DEL except TAB / LF (CR included; the repo is LF-only) across every tracked non-binary file, exempting genuinely binary files by EXTENSION so a new asset needs no allow-list edit. Scope was MEASURED rather than predicted: after the one fix, a tree-wide sweep of all tracked files found this was the ONLY affected source file (assets/cdk-vs-cdkd.gifis the only other NUL carrier and is legitimately binary), so the checker starts from a green tree at zero findings. 19 unit tests cover the classifier shapes, the extension exemption, the message content and the tree-wide sweep; per the repo's checker rule the gate is probed against REAL code — re-introducing the actual NUL intoefs-provider.tsfails the sweep namingefs-provider.ts:435 ... offset 17230, reproducing the original defect exactly. -
AWS::EC2::Routerefuses a multi-destination template instead of silently narrowing it (issue #1566) —src/provisioning/providers/ec2-provider.ts, newtests/unit/provisioning/ec2-route-multi-destination.test.ts. Before:createRoutepicked the destination viaDestinationCidrBlock || DestinationIpv6CidrBlock || DestinationPrefixListId, so a (CFn-invalid) template carrying MORE than one destination key deployed with only the highest-precedence one and SILENTLY DROPPED the rest, while CloudFormation and EC2 reject the combination withInvalidParameterCombination. Surfaced by the code review on the #609 EC2::Route backfill (PR #1568) and deferred there because the provider file was already integ-verified. Now: the create path throws aProvisioningErrornaming every declared destination key, pre-flight — nothing reaches the wire. The refusal is TEMPLATE-path only: both STATE-borne paths downgrade to a warning and keep the pre-fix precedence, per the "an UPDATE-path refusal is a replay refusal too" rule — the rollback executor's reverse-replacement create (detected viaCreateContext.replayingState, mirroring theSecurityGroupIngressidiom in the samecreate()switch) andupdate()'s delete-and-recreate, whichcdkd rollback/drift --revertalso drive. The update path is load-bearing rather than cautious: it deletes the route BEFORE re-creating, so throwing there would strand a deleted route with no template-side remedy. An empty-string sibling is not counted as a second destination, matching the||chain the guard sits in front of. The existing #609 precedence pin (which asserted the silent narrowing on the template path — the contract this issue deliberately redefines) is retargeted to the state-replay path, the only place precedence still decides anything. 9 new unit tests plus the retargeted pin. - S3 analytics / intelligent-tiering / metrics filters count PREDICATES, not predicate kinds (issue #1573) —
src/provisioning/providers/s3-bucket-provider.ts, newtests/unit/provisioning/s3-bucket-provider-filter-predicates.test.ts. Before:applyAnalyticsConfigurations/applyIntelligentTieringConfigurationschose the wire shape from the COUNT OF PREDICATE KINDS (prefix present? + tags present?), soTagFilters: [A, B]with noPrefixcounted as one kind, fell into the single-Tagarm, and sentFilter = { Tag: A }— every tag after the first SILENTLY DROPPED (found by the PR #1574 review while spot-checking theAnalyticsConfigurations.TagFiltersterminal rename against its write site).applyMetricsConfigurationshad the same class in a different arm:Prefixwas tested FIRST in an else-if chain, soPrefix+TagFilters(orPrefix+AccessPointArn) sentFilter = { Prefix }alone and dropped the other predicate(s); it also sentAnd: { Tags: [] }for a bare emptyTagFilters: []. Now: all three builders count PREDICATES — more than one routes through theAndoperator, exactly one keeps its dedicated single-predicate shape, and an empty tag array is no predicate at all. Every newly-reachable wire shape was live-probed on 2026-08-11 and ACCEPTED with faithful readback:And{Tags[2]}on PutBucketAnalyticsConfiguration + PutBucketIntelligentTieringConfiguration,And{Prefix,Tags[2]}andAnd{Prefix,AccessPointArn}(real access point) on PutBucketMetricsConfiguration. ThereadCurrentStatereverse maps already read theAndmembers individually, so the round-trip is drift-clean without changes. 11 unit tests pin the regression shapes (the exact{ Tag: A }/ bare-{ Prefix }drops), the preserved single-predicate shapes, and the empty-array edge; reverting the fix fails 5 of them. - CloudTrail
KMSKeyIdremoval reset + the two S3 replication SSE-KMS members, both unblocked by disproving a shared premise (issues #1533 / #1523) —src/provisioning/providers/cloudtrail-provider.ts,tests/unit/provisioning/cloudtrail-trail-removal-resets.test.ts,tests/integration/cloudtrail-trail/{lib,verify.sh,README.md},tests/integration/s3-replication-and-filter/{lib,verify.sh}. The premise both issues rested on was wrong. Each said its measurement was blocked because a customer-managed KMS key's 7-day minimum deletion window would leave aPendingDeletionorphan behind every run, conflicting with the repo's never-end-a-run-with-orphans rule — and each therefore proposed sourcing a long-lived, alias-referenced key plus a bootstrap story as its preferred option. But a KMS key cannot be deleted synchronously at all, soPendingDeletionIS the terminal state of a deleted key rather than a leak, and three fixtures already assert exactly that as their expected post-destroy outcome (loggroup-kms-associatesince #958, pluspropagation-races-2ands3-vectors). Both issues are therefore closed with an ordinary per-run key and aPendingDeletionassertion — no bootstrap, no account-level prerequisite, and no fixture that fails on a fresh account.KMSKeyId(#1533): the live CFn A/B ran on 2026-08-11 and measured BOTH halves separately, because "CFn resets it" does not tell you the wire shape — a real CloudFormation removal UPDATE read backKmsKeyId: null(CFn RESETS),UpdateTrailwith the field ABSENT left the live key attached (so cdkd's pass-through was a silent #1160 divergence: the deploy succeeded, state dropped the field, and the trail stayed encrypted under a key the template no longer named), andKmsKeyId: ''was ACCEPTED and cleared it (so''is the payload).update()now routes the field through the sameclearOnUpdateRemovalshape asSnsTopicName. The probe also disproved the in-code note claimingUpdateTrailrejects an empty string withKmsKeyId is not in valid ARN format— the last field that note covered, after #1160 had already disproven it forSnsTopicName/CloudWatchLogsLogGroupArn.IsOrganizationTrailis the one field of the umbrella's SUSPECT row still unmeasured, and it is unmeasurable HERE rather than merely deferred: the integ account is not in an organization at all (AWSOrganizationsNotInUseException, probed the same day), so closing it needs a different AWS account, not more fixture work — it stays pass-through, pinned by a unit test that says so. Unit tests: the full #1157 trio plus both''-placeholder polarities forKMSKeyId, and the aggregate one-call reset. Integ: thecloudtrail-trailfixture gains a per-run key whose reference (not the key itself) is dropped in theCDKD_TEST_REMOVALphase, so phase 2 is a property removal on a LIVE trail; phase 1 captures the liveKmsKeyIdand guards it non-empty, which is what stops "null afterwards" from passing vacuously against a trail that never got a key. The two S3 members (#1523):Destination.EncryptionConfiguration.ReplicaKmsKeyIDand its required partnerSourceSelectionCriteria.SseKmsEncryptedObjects— wired by #1495 but unit-only — are now read back off the live bucket ins3-replication-and-filter. Neither bucket's default encryption is switched to SSE-KMS:SseKmsEncryptedObjectsis a source-object FILTER and needs no bucket-level change, while addingBucketEncryptionwould widen the drift comparison surface (the calculator compares arrays WHOLESALE, so an AWS-defaultedBucketKeyEnabledmember would read as drift) for no extra coverage. DeclaringSseKmsEncryptedObjectsalso retires that fixture's own phantom-drift hypothesis —assert_drift_cleanwas written around it as the canonical example of a sub-block AWS returns that the template never declared, and the template now declares it, so the check covers the opposite direction too.Destination.AccessControlTranslation.Ownerstays unit-only and is NOT a follow-up:Owner: Destinationis an ownership OVERRIDE that is only meaningful across accounts, so a same-account fixture could assert nothing real however it is written. -
AWS::AppSync::GraphQLApi: the last 13 silent-drop properties wired, type CLOSED (issue #609 appsync batch) —src/provisioning/providers/appsync-provider.ts,src/analyzer/replacement-rules.ts,scripts/gen-nested-key-coverage.ts, newtests/unit/provisioning/appsync-graphqlapi-config-props.test.ts,tests/integration/appsync/{lib/appsync-stack.ts,verify.sh,README.md}, schema fixture re-captured, coverage matrices regenerated. Before:handledPropertieslisted onlyName/AuthenticationType/XrayEnabled/LogConfig/Tags, so a template using any auth-provider block, API-level limit or environment variable was rejected by the #608 pre-flight — and with--allow-unsupported-properties, silently dropped. Now all 13 are wired throughCreateGraphqlApi/UpdateGraphqlApi(AdditionalAuthenticationProviders,ApiType,EnhancedMetricsConfig,IntrospectionConfig,LambdaAuthorizerConfig,MergedApiExecutionRoleArn,OpenIDConnectConfig,OwnerContact,QueryDepthLimit,ResolverCountLimit,UserPoolConfig,Visibility) plusEnvironmentVariablesthrough its ownPutGraphqlApiEnvironmentVariablesAPI pair, with a matching reverse map inreadCurrentStateso the new properties cannot become permanent phantom drift.PROPERTY_COVERAGE_BY_TYPEnow reports an EMPTYsilentDropfor the type. Three things the wiring had to get right, each pinned by a test: (1) the SDK member spellings are irregular —openIDConnectConfig/iatTTL/authTTL, not the mechanical first-letter flipopenIdConnectConfig/iatTtl/authTtl— and the AWS SDK v3 serializer DROPS unknown members, so a near miss is a silent config loss on a green deploy; the type is therefore opted intoNESTED_KEY_TARGETS(freshObjectMapper: true,keyStyle: 'lower-first', measured at 33 audited paths / 0 findings, withTags.Key/Tags.Valueallow-listed because AppSync models tags as a flatRecord<string, string>rather than CFn's[{Key, Value}]list). (2) The nestedUserPoolConfigunder an ADDITIONAL auth provider is a DIFFERENT AWS shape (CognitoUserPoolConfig, nodefaultAction) from the top-level one, so the two have separate mappers. (3)ApiType/Visibilityare create-only on AWS (UpdateGraphqlApihas no member for either) while the registry schema ships an EMPTYcreateOnlyPropertieslist — so thecreate-only-properties.tsfallback classifies NOTHING and the change would have been routed to an in-place UPDATE that silently drops it; both are now hand-authoredreplacementPropertiesinReplacementRulesRegistry, with a defense-in-depthResourceUpdateNotSupportedErrorin the provider. Removal semantics were measured, not assumed: AppSync treats an OMITTEDUpdateGraphqlApimember as "no change" (the #1160 absent-field class), so the members with a documented AWS reset sentinel are actively reset on removal (IntrospectionConfig->ENABLED,QueryDepthLimit/ResolverCountLimit->0,OwnerContact->'',AdditionalAuthenticationProviders->[],EnvironmentVariables->{}via a whole-map re-PUT) while the three auth-config blocks andMergedApiExecutionRoleArn, which have none, WARN with a destroy-and-redeploy pointer instead of no-op-ing silently. One pre-existing bug the fixture surfaced live: everyupdate()path in this provider returnedattributes: {}, and the deploy engine resolvesresult.attributes ?? currentResource.attributes— so a PRESENT-but-empty object wiped the create-timeApiId/Arn/GraphQLUrland the stack'sGraphQLApiUrloutput broke on the first in-place update (Cannot resolve Fn::GetAtt … attributes are not enriched for this resource type). All eight update returns now OMIT the field; the integ asserts the output still resolves after the update. Integ: theappsyncfixture gained averify.shwith baseline /CDKD_TEST_UPDATE/CDKD_TEST_REMOVAL/ destroy phases that read every value back offGetGraphqlApi+GetGraphqlApiEnvironmentVariables(a green deploy proves nothing here), assertsprovisionedBy == sdkas a #614 routing guard, and backs the two nested auth blobs with a real Cognito user pool + Lambda authorizer. Run 2026-08-11: PASS in 74s, destroy 0 errors, 0 orphans. Top-levelUserPoolConfig(needs Cognito as the PRIMARY auth mode),MergedApiExecutionRoleArn(needsApiType: MERGED) andVisibility: PRIVATE(needs a VPC endpoint) stay unit-test-only, recorded in the fixture README. The 3-axis review then found four defects, all fixed here:EnvironmentVariablesrides a SEPARATE API that can only run afterCreateGraphqlApisucceeds, so a failure there threw beforecreate()returned the physicalId — the deploy engine never learned the API existed, orphaning it and colliding by name on the next attempt (now best-effort deleted in the catch, mirroring the Cognito / DynamoDB post-create rollback pattern); a present-but-non-object config block read as "absent" and deployed green with no auth configured, which is the very silent-drop class this batch closes (each nested container now refuses a malformed value, andAdditionalAuthenticationProvidersgoes through the sharedrequireConfigArray); a malformedEnvironmentVariableswas the destructive case of that same bug — the PUT is a WHOLE-MAP replace, so reading it as absent would have WIPED every live variable, and the update path now warns and leaves AWS untouched rather than throwing, since the rollback executor replaysupdate()with a state record the user cannot edit; and a non-string variable value would have reached AWS as the literal"[object Object]". The drift-side env-var read was also raised from debug to WARN, because silently dropping the key makes the read asymmetric with the recorded baseline and manufactures a phantom removal that--revertwould act on. Two review points were deliberately NOT taken and are recorded in code:UserPoolConfig.DefaultActionis NOT defaulted (it decides whether an unauthenticated request is allowed, so inventing a value would be a security decision made on the user's behalf, and CFn's own default here is not A/B-verified — an omitted required member reaches AWS as a loud validation error, not a silent drop), and the five sentinel-less members stay in the update-diff list so their removal still produces the "cannot clear X" warning, at the cost of one no-opUpdateGraphqlApion a removal-only redeploy. -
AWS::CloudTrail::TrailEventSelectorsremoval is a RESET, not a no-op (issue #1549) —src/provisioning/providers/cloudtrail-provider.ts,tests/unit/provisioning/cloudtrail-trail-removal-resets.test.ts,tests/integration/cloudtrail-trail/**. The gap: the #1160 cloudtrail batch swept the fields that rideUpdateTrail;EventSelectorshas its ownPutEventSelectorscall and its branch fired the Put onlyif (newEventSelectors && newEventSelectors.length > 0), so removing the property from the template skipped the call entirely and the live trail kept its selectors while cdkd reported success — the #1160 absent-field silent-drop shape, ~15 lines from anInsightSelectorsbranch that already did the right thing. The batch left it alone deliberately: the repo rule is to A/B before mirroring a sibling by analogy, and this one had not been measured. The measurement (live CFn A/B, us-east-1, 2026-08-11): a trail deployed withEventSelectors: [{ReadWriteType: WriteOnly, IncludeManagementEvents: true}]and then UPDATE'd through real CloudFormation with the property REMOVED reads back asReadWriteType: All— CFn CLEARS the custom selector to the default a selector-less trail has. And the wire shape is NOT the sibling's:PutEventSelectorswithEventSelectors: []is rejected (InvalidEventSelectorsException: Specify a valid number of selectors (1 to 5) for your trail), while sending the explicit default selector reproduces the post-removal read byte for byte — so the two branches agree on POLICY (fire the Put whenever the diff fires) and legitimately differ in PAYLOAD. Also fixed, from the same review: a removedIsLogging(desiredundefined, previousfalse) compared as a change and took theelsearm, silently issuingStartLogging— unreachable from a valid template (IsLoggingis CFn-required) but reachable from a rollback /drift --revertreplay of a partial state record, so the branch now requires a defined desired value. The integ fixture gains theWriteOnlybaseline (deliberately not the AWS default, so a reset is distinguishable from a retention AND from the pre-fix "no call at all"), the post-removal reset assertion, and the post-removalcdkd diff --failno-op assertion the review asked for as a phantom-drift guard. - Nested-key critic: the file-global literal rescue is now scope-verified (issue #1393 items 1+2) —
scripts/gen-nested-key-coverage.ts,tests/unit/scripts/gen-nested-key-coverage.test.ts, regenerateddocs/_generated/nested-key-coverage.{json,md}. Before: a CFn nested key with no same-spelled SDK member was classifiedprovider-handledwhenever its string literal appeared ANYWHERE in the provider file — so a literal named legitimately at one place (a reverse map, an unrelated top-level) vouched for every same-spelled occurrence with a broken write path (measured on the real pre-#1426 S3 provider:TagFilters/TransitionInDayswere unflaggable), and the rescue ran BEFORE the near-miss check, so a file-global literal also masked case-divergences (item 1). Now: for a write-evidence (freshObjectMapper) target — every current target — the literal is trusted only with scoped delivery proof: a genuine SDK member written at the RESOLVED parent chain whose case-folded name equals the audited key, or a declaredterminalRenamesentry that resolves on the write side; whole-blob hand-offs deliberately do NOT vouch (a verbatim forward carries the CFn spelling, which by premise matches no SDK member — the serializer drops it on the wire). The 45 entries the old heuristic had been rescuing were each verified against their provider's actual write site and declared: 42 terminal renames (S3 35, CloudFront 6 incl. the computed-key acronym renames that resolve through the #1475 spread-and-patch exclusions, ECS TaskDefinition 1; the lifecycle-singular child paths ride the pre-existing scoped segment renames), and 3 reviewedpasses: ['key']allow entries for conversions no walk can see (S3 presence-encodedEventBridgeEnabled, S3 lifecycle destructured-gatherScopeTagFilters, CloudFront legacyCustomOrigin.OriginSSLProtocols). Red-direction proofs added per the checker rules: stripping the analyticsFilter.Andwrite flipsAnalyticsConfigurations.TagFilterstono-sdk-memberwhile the sibling families stay clean (the exact discrimination #1430 recorded as impossible), each new allow entry is load-bearing (removing it flags the unregressed provider), and the stale-allow-list probe was rebuilt on the new rule (a floating literal no longer un-stales an entry — that rubber stamp is what this closes). Items 3 (new targets: EventBridge / Scheduler / Glue / EMR / …) and 5 (union-masking needs the shape-aware v2, #1378) stay tracked on #1393. - Distributed
cdkdskill v0.2.0: drift / gc / orphan / local coverage (follow-up to PR #1548) —plugins/cdkd-skills/skills/cdkd/SKILL.md, both manifests bumped to 0.2.0. The v0.1.0 skill covered the install→deploy→verify→destroy spine but four command areas were missing or guard-list-only: local (new "Run workloads locally" section — thecdkd local *family runs Lambda / API Gateway / ECS / ALB / CloudFront / AgentCore on Docker with no AWS credentials, so the skill now explicitly exempts these from the deployment-boundary ceremony; links docs/local-emulation.md), drift (new section: detect exits 1,--accept= state←AWS vs--revert= AWS←state, mutually exclusive, both honor--dry-run, revert flagged destructive), gc (new section: deletes only state-unreferenced assets, per-region, never touches CDK bootstrap storage,--dry-runfirst, keep the default--older-than 30dage guard; also added to the destructive-guard list), and orphan (ownership paragraph distinguishing per-resourcecdkd orphan <stack/ConstructPath>from whole-stackcdkd state orphan <stack>, both leaving AWS resources intact, paired with removing the construct from the app). Command reference gaineddrift+gc --dry-run. Verified: plugin loads as 0.2.0 via realclaude --plugin-dir(on-invoke ~4.4k tok), no Japanese, new doc link returns 200. - Distributable
cdkdskill for AI coding agents (follow-up to PR #1522) —plugins/cdkd-skills/skills/cdkd/SKILL.md,plugins/cdkd-skills/.claude-plugin/plugin.json,.claude-plugin/marketplace.json,skills/cdkd(symlink),.claude/skills/use-cdkd/SKILL.md,README.md. Before: the safe-usage guidance contributed in PR #1522 lived only in the project-scopeduse-cdkdskill, so end users needed a full repo clone plusclaude --add-direvery session, and non-Claude agents had no install path at all. Now: the end-user guidance ships as a standalonecdkdskill installable three ways — Claude Code plugin marketplace (/plugin marketplace add go-to-k/cdkd+/plugin install cdkd-skills@cdkd),gh skill install go-to-k/cdkd cdkd, andnpx skills add go-to-k/cdkd --skill cdkd(the latter two serve any SKILL.md-format agent, Codex and Cursor included). The repo layout mirrors the proven go-to-k/cdk-skills structure (plugin dir + root-levelskills/symlink). The repo-internaluse-cdkdskill is slimmed to the contributor concern (build the checkout, pnpm link) and defers to the distributed file for the shared safe-usage flow; both files carry a sync comment requiring same-PR updates plus manifest version bumps when CLI behavior changes. README's "Use with Claude Code" section became "Use with AI Coding Agents" with the three install channels;--add-dirremains documented as the contributor path. Distributed-skill links were converted to absolute GitHub URLs (all live-checked 200). Verified: realclaudeCLI end-to-end —plugin marketplace add(local path) +plugin install cdkd-skills@cdkd+ inventory check (1 skill, ~90 tok always-on) — and realnpx skills add --skill cdkd(installs exactly the distributed skill; the bare form lists all 14 repo skills, which is why the README pins the--skillform). npm-package contents are unaffected (filesallowlist excludesplugins/), andchore:commits do not trigger semantic-release. - The last top-level
??defaulting site: DynamoDB GlobalTableBillingMode(issue #1513) —src/provisioning/providers/dynamodb-globaltable-provider.ts,src/provisioning/config-shape.ts(header), newtests/unit/provisioning/dynamodb-globaltable-provider-billing-mode-shape.test.ts,.claude/rules/code-layout.md. The gap: PR #1524 decided every top-levelproperties['X'] ?? 'default'site per site and shipped all but one —dynamodb-globaltable-provider.tswas owned by a parallel lane at the time, soBillingMode ?? 'PAY_PER_REQUEST'kept substituting on a present-but-unusable value. That default is the consequential direction: aBillingMode: null(anFn::Ifthe resolver could not resolve, a hand-authored L1 typo) on a template that saysPROVISIONEDcreated an ON-DEMAND table, and everyWriteProvisionedThroughputSettings/ per-GSIProvisionedThroughputblock below was then dropped by the provider's own billing-mode gate with only a diagnostic to show for it. The change: the create-path read goes throughrequireConfigString(properties['BillingMode'], 'PAY_PER_REQUEST', …, replayWarn(this.logger, context))and theupdate()read through the same helper with an unconditionalonUnusablewarn. Three per-site decisions follow #1524's recorded precedent verbatim:BillingModeis ENUM-valued so it does NOT takecoerceNumber(a number there is a template bug, not the unquoted-YAML scalar CFn coerces); the create refusal downgrades to a warning underCreateContext.replayingState, which required adding the optionalcontextparametercreate()did not previously declare, becauserollback-executor.ts's reverse-replacement arm revives the old resource from a state record the user cannot edit from their CDK code; and the update path warns unconditionally, since a rollback replaysupdate(…, previousState.properties, …)and a refusal there would leave the table UN-ROLLBACKABLE. ThepreviousPropertiessibling stays unguarded for the same state-side reason #1493 recorded. One thing the review of the sibling guard surfaced: the read sits OUTSIDEcreate()'stry, so the refusal is wrapped in aProvisioningErrorexactly as theStreamSpecificationguard 70 lines below already is — without it an untypedErrorwould escape into the deploy engine's retry loop. 14 unit tests cover the five malformed shapes, the typed wrap, the absent-key default, aPROVISIONEDpass-through that positively asserts the provisioned block survived the billing gate, bothreplayingStatepolarities, and the update-path warn plus the unguarded previous side. -
AWS::Route53::HostedZoneHostedZoneTags+QueryLoggingConfigremoval resets (issue #1160 route53 batch) —src/provisioning/providers/route53-provider.ts, newtests/unit/provisioning/route53-provider-removal-resets.test.ts,tests/integration/route53/{lib/route53-stack.ts,verify.sh}. The gap: both entries on the umbrella'sroute53-provider.tsSUSPECT row were REMOVAL paths the provider had no way to express.applyHostedZoneTagsonly ever sentAddTags, so a tag dropped from the template stayed on the zone forever — and itstags.length === 0early return made clearing ALL tags a silent no-op.applyQueryLoggingConfigreturned early on an absent block, so removingQueryLoggingConfigleft the live config in place: query logging kept writing to CloudWatch — and kept billing — while the template said otherwise andcdkd diffreported no changes, i.e. permanently invisible. Measured, not assumed: a live CloudFormation A/B on 2026-08-10 (a hosted zone with two tags plus a query-logging config, updated to drop one tag and the whole block) showed CFn UNTAGS the dropped key while keeping the other, and DELETES the config — so cdkd's pass-through was a real divergence in both directions, and the fix mirrors what CFn actually does rather than what the docs imply. The change:update()now threadspreviousPropertiesinto both helpers, which is what turns an add-only apply into a diff. Tags sendRemoveTagKeysfor the previous-minus-desired key set alongsideAddTagsin ONEChangeTagsForResourcecall (computing removals that way guarantees no key lands in both lists); query logging routes an absent desired block to the provider's existingdeleteQueryLoggingConfigForZone, gated on the PREVIOUS side having carried a config so an ordinary hosted-zone update does not pay aListQueryLoggingConfigsprobe per deploy. Both helpers keeppreviousPropertiesOPTIONAL, socreate()keeps its existing removal behavior — there is nothing to remove from a zone that did not exist a moment ago. The 3-axis review then found three defects in the destructive direction, all fixed here: a per-ELEMENT malformed tag ([{Key:{Ref:'X'}}]IS genuinely an array) was dropped by the reader, so its well-formed SIBLING was untagged — the guard is now LOSSY-READ based and validatesValueas well asKey, since Add and Remove share oneChangeTagsForResourcecall and one bad value failed the whole request and took the removals with it;QueryLoggingConfig: {}was classified malformed although that is exactly what this provider's ownreadHostedZoneemits for "no live config", whichcdkd drift --revertfeeds back as the DESIRED side, so every hosted-zone revert would have warned and told the user to omit a block they never wrote; and a failed REMOVAL was swallowed, which is NOT self-healing the way a failed ADD is (state is rewritten without the property, so the next deploy's previous side no longer carries it and the value survives forever withcdkd diffclean — the exact #1160 failure mode), so both removal paths now throw and the query-logging one re-lists to verify the delete landed.updateHostedZone's catch passes aCdkdErrorthrough rather than re-labelling it, which theupdate-wrap-coveragecritic flagged. 24 unit tests carry the #1157 trio per behavior plus the create-unchanged, malformed-shape and removal-failure pins; each guard was proven by MUTATION against the real provider (reverting thepreviousPropertiesarguments fails 3 tests, disabling the lossy-read guard 2, reverting the removal throws 2, re-classifying{}as malformed 1). Theroute53integ fixture gained aCDKD_TEST_REMOVALphase (baseline asserts both are live first, so the "it's gone" result cannot be vacuously true) plus a post-destroy sweep for the query-logging log group. -
AWS::CodeBuild::ProjectBuildBatchConfigremoval resets (issue #1160 codebuild batch) —src/provisioning/providers/codebuild-provider.ts. The A/B result reshaped the batch, the same way it did for elbv2: the umbrella's SUSPECT row listed 14UpdateProjectfields, but a live CFn A/B (2026-08-10, NO_SOURCE project) showed CloudFormation itself RETAINSDescription/TimeoutInMinutes/QueuedTimeoutInMinutes/ConcurrentBuildLimit/AutoRetryLimit/Cache/LogsConfigthrough a real removal update — CFn's own handler omits them fromUpdateProjectand CodeBuild merges — so cdkd's pass-through was ALREADY parity for all seven and they are pinned by retention tests rather than "fixed" into a divergence. Each of them HAS a working clear sentinel ('', 60, 480,-1, 0,NO_CACHE, cloudWatchLogsENABLED, all probed alone), which is exactly what makes resetting them an easy and wrong change. The one real gap:BuildBatchConfigcame backnullafter the CFn removal, and the ONLY shape that clears it is an EMPTY object — an omitted field is the merge no-op this issue tracks — soupdate()now routes the property throughclearOnUpdateRemovalwith a{}clear value, reusing the shared mapper so create and update stay on one code path. The reset is update-only: a create with no batch config still sends nothing. Not A/B'd, recorded rather than guessed:VpcConfig/SecondarySources/SecondaryArtifacts/SecondarySourceVersions/FileSystemLocations/BadgeEnabledneed infra a NO_SOURCE probe project cannot carry (a VPC, EFS, a real source provider, a badge-capable source) and stay pass-through. Unit tests:tests/unit/provisioning/codebuild-project-buildbatchconfig-removal.test.ts(the #1157 trio + the create-path guard + the seven CFn-parity retention pins). Integ: newcodebuild-projectfixture with aCDKD_TEST_REMOVALphase asserting BOTH halves of the table plus a replacement guard (run 2026-08-10: reset + all seven retentions live-verified, destroy 3 deleted / 0 errors, 0 orphans). Fixture note worth keeping: the CDK L2 emitsCache: { Type: NO_CACHE }unconditionally, so simply omitting the prop is a value CHANGE, not a removal — the first run failed the cache retention assertion against entirely correct provider behavior, and the fixture now usesaddPropertyDeletionOverride('Cache')to make phase 2 a true removal. -
AWS::CloudTrail::Trailremoval resets (issue #1160 cloudtrail batch) —src/provisioning/providers/cloudtrail-provider.ts,src/deployment/retryable-errors.ts. Before:UpdateTrailis a merge-semantics API — a live probe with onlyName+S3BucketNameleft every other value untouched — so a property removed from the template was a silent no-op: the deploy reported success, state dropped the field, and the nextcdkd diffsaid "No changes". Now: a live CFn A/B (2026-08-10, a real trail with every optional field set and then removed) split the umbrella's SUSPECT row, andupdate()mirrors the measured result exactly. FIVE fields CFn resets and cdkd now clears:S3KeyPrefix->'',SnsTopicName->'',IsMultiRegionTrail->false,EnableLogFileValidation->false,IncludeGlobalServiceEvents->false(each sentinel probed ALONE against the live trail). TWO are RETAINED by CFn —CloudWatchLogsLogGroupArn/CloudWatchLogsRoleArnkept their values through the removal update — so the pass-through is already parity and is pinned by retention tests rather than "fixed" into a divergence. A stale in-code note was disproven in passing: the provider claimedUpdateTrailrejects an empty string for ARN-shaped fields; the probe shows''is ACCEPTED forSnsTopicNameandCloudWatchLogsLogGroupArnand nulls them out, which is what makes the string resets possible. TheIncludeGlobalServiceEventsreset is GUARDED on the effectiveIsMultiRegionTrail: AWS rejects a multi-region trail that excludes global service events (hit live while building the fixture), and the A/B only ever removed both together — resetting it under a retainedIsMultiRegionTrail: truewould manufacture a hard failure on a combination CFn was never measured on.retryable-errors.tsgains the CloudTrailVerify in IAM that the role has adequate trust relationshipsIAM-propagation pattern:CreateTrailvalidates that CloudTrail can assume the CloudWatch Logs role, and cdkd's fast SDK path issues the create ~1s after the role's CREATE — the live CFn A/B passed with the same trust policy purely because CFn is slower, and the fixture failed until the retry landed. Unit tests:tests/unit/provisioning/cloudtrail-trail-removal-resets.test.ts(22 cases — the #1157 trio per reset field, the''-placeholder previous-side normalization, both polarities of the multi-region guard, and the retention pins) + the retryable-errors pattern pin. Integ: newcloudtrail-trailfixture with aCDKD_TEST_REMOVALphase asserting BOTH halves of the table (run 2026-08-10: five resets + both retentions live-verified, destroy 10 deleted / 0 errors, 0 orphans). Follow-up filed: #1533 —KMSKeyIdandIsOrganizationTrailcould NOT be A/B'd (a customer-managed KMS key's 7-day minimum deletion window would orphan aPendingDeletionkey every run; an org trail needs an Organizations management account), so both stay pass-through, recorded as unmeasured rather than guessed. -
AWS::ElasticLoadBalancingV2::TargetGroupHealthCheckPortremoval resets totraffic-port(issue #1160 elbv2 batch) —src/provisioning/providers/elbv2-provider.ts. The A/B result reshaped the batch: the umbrella's SUSPECT row listed all 8 target-group health-check fields plus the listener'sSslPolicy, but a live CFn A/B (2026-08-10, HTTP target group + HTTPS listener) showed CloudFormation itself RETAINS 8 of the 9 on removal —HealthCheckProtocol/HealthCheckPath/HealthCheckEnabled/HealthCheckIntervalSeconds/HealthCheckTimeoutSeconds/HealthyThresholdCount/UnhealthyThresholdCountandSslPolicyall kept their customized values through a real CFn removal update — so cdkd's pass-through (absent ->undefined->ModifyTargetGroup/ModifyListenermerge) was ALREADY CFn parity for those, and they are now pinned by negative tests rather than "fixed" into a divergence. The one real gap:HealthCheckPortis the single field CFn resets, to its create defaulttraffic-port;update()now mirrors that viaclearOnUpdateRemoval. GENEVE target groups are deferred with in-code rationale (their create default is port 80, and CFn's GLB removal behavior is not A/B-verified — retain instead of guessing). Unit tests:tests/unit/provisioning/elbv2-targetgroup-healthcheckport-removal.test.ts(the removal trio + GENEVE deferral incl. previous-side protocol resolution + the CFn-parity retention pins for the other 7 fields). Integ: thealbfixture gained a baselineHealthCheckPort: 8080+ aCDKD_TEST_REMOVALphase (run 2026-08-10: reset totraffic-portlive-verified, destroy 16 deleted / 0 errors, 0 orphans). -
AWS::EFS::FileSystemthroughput removal resets provisioned mode tobursting(issue #1160 efs batch) —src/provisioning/providers/efs-provider.ts. The A/B split the SUSPECT row in two: a live CFn A/B (2026-08-11) showed CloudFormation RETAINS aThroughputModeremoved alone from a non-provisioned file system (elastic stayed elastic through a real CFn removal update) — so cdkd's pass-through skip was ALREADY CFn parity there and is now pinned by a negative unit test — but RESETS a'provisioned'file system to the create default'bursting'whenThroughputMode+ProvisionedThroughputInMibpsare both removed (provisioned@1MiBps -> bursting, provisioned value cleared). Before:update()skippedUpdateFileSystementirely on removal (the API merges — absent = "no change"), so the file system silently stayed provisioned and KEPT BILLING for provisioned throughput. Now: the both-removed-from-provisioned shape sendsThroughputMode: 'bursting'explicitly. Removing ONLYProvisionedThroughputInMibpswhile keepingThroughputMode: 'provisioned'stays deferred with in-code rationale (the API requires a value in that mode; no wire shape can reset it). Unit tests:tests/unit/provisioning/efs-provider-throughput-removal.test.ts(the removal trio + the elastic/bursting retention pins + true-path polarity + both deferral pins). Integ:efs-standalonegained aprovisioned@1MiBpsbaseline + aCDKD_TEST_REMOVALphase asserting the live in-place reset tobursting. -
AWS::SNS::TopicDeliveryStatusLoggingprotocol canonicalization: the whole HTTP family folds onto the one prefix AWS accepts (issue #1529) —src/provisioning/providers/sns-topic-provider.ts. Before:normalizeDeliveryStatusProtocolhad two pre-existing gaps that made the entire HTTP-family protocol unusable. (1) The canonical CFn / CDK L2 spelling'http/s'— whatsns.LoggingProtocol.HTTPand the CFn schema's allowed-value list both emit, so what real templates actually carry — hit the switch'sdefaultand threwunsupported DeliveryStatusLogging protocol "http/s"at create/update time. (2) The'https'spelling it did accept mapped to anHTTPSprefix, producing attribute names likeHTTPSSuccessFeedbackRoleArnthat do not exist —SetTopicAttributesrejects them withInvalidParameter: Invalid parameter: AttributeName— and the reverse map inreadCurrentStateprobed those same never-present names. So a template using HTTP delivery-status logging either failed fast on the canonical spelling or failed at the AWS call on the alias. Now:'http/s','http'and'https'all canonicalize toHTTP,HTTPSis dropped fromSNS_DELIVERY_STATUS_PROTOCOLS(which holds AWS attribute PREFIXES), and the fail-fast error lists the accepted TEMPLATE spellings via the newSNS_DELIVERY_STATUS_PROTOCOL_SPELLINGS(application, firehose, http/s, lambda, sqs) rather than the prefixes — listing prefixes would have told users to writeHTTP, which the CFn schema does not accept. The existingstateProtocolCaseMapmechanism carries the state-recorded spelling through the reverse map unchanged, so a topic recorded ashttp/sreads back ashttp/sand no phantom drift fires on the respelling. Folding the family made a same-prefix collision reachable for the first time (a template declaring bothhttp/sandhttps); it WARNS and lets the last entry win rather than throwing, because this function's throw side is also fed by the rollback executor'supdate()replay of a cdkd STATE record, where the user has no template-side remedy. Live A/B (2026-08-11), both halves: a CloudFormation stack carryingProtocol: http/sproducedHTTPSuccessFeedbackRoleArn/HTTPSuccessFeedbackSampleRate/HTTPFailureFeedbackRoleArnon the live topic;SetTopicAttributeswithHTTPSSuccessFeedbackRoleArnon that same topic was rejected. Tests: newtests/unit/provisioning/sns-topic-provider-protocol-canonicalization.test.ts(every HTTP-family spelling folds toHTTP; no input ever yieldsHTTPS; unknown protocols and non-strings still rejected; the error names spellings not prefixes; the collision warn and its no-collision control), plus the three pre-existing suites that had pinned theHTTPSprefix updated to the live-verified names — reverting the normalizer fails 11 tests. Assertions that the rejected names are absent list them exactly rather than testing astartsWith('HTTPS')prefix, sinceHTTPSuccessFeedbackRoleArnalso starts with those five characters — the collision that made this bug subtle. Integ:sns-sqs-event's delivery-status topic gains a second,http/sprotocol entry with Phase-1 assertions that it lands underHTTPwith noHTTPS*attribute set, and a Phase-2 assertion that the removal reset covers it too (the HTTP family had no integ coverage at all before). - Cloud Map namespace
Description/ SOA-TTL removal resets (issue #1160 servicediscovery batch) —src/provisioning/providers/servicediscovery-provider.ts. Before: the threeUpdate*Namespacechange objects MERGE (an absent field keeps the live value) and each update method gated onproperties['Description'] !== undefined/ a desired-side-only SOA extraction, so aDescription(all 3 namespace kinds) orProperties.DnsProperties.SOA.TTL(private + public DNS kinds) REMOVED from the template silently survived on AWS. Now: removal resets mirror CloudFormation (live CFn A/B 2026-08-11 on PublicDnsNamespace + HttpNamespace: a removed Description is cleared entirely; a removed SOA TTL goes from the customized value back to 60): Description resets via the sharedclearOnUpdateRemovalwith the''clear sentinel (raw-SDK probed:UpdateHttpNamespaceaccepts''andGetNamespacethen omits the field), and the newresolveSoaTtlChange()resets a removed TTL to the KIND's create default —PUBLIC_DNS_NAMESPACE_DEFAULT_SOA_TTL = 60/PRIVATE_DNS_NAMESPACE_DEFAULT_SOA_TTL = 15— both raw-SDK probed (and the private kind separately CFn A/B'd after a reviewer caught that a shared 60 contradicted the PR #201-era live observation of 15).AWS::ServiceDiscovery::ServiceDescriptionstays in the umbrella's UNCERTAIN bucket — deliberately untouched by this batch. Unit tests:tests/unit/provisioning/servicediscovery-provider-namespace-removal.test.ts(the removal trio per kind + true-path polarity). Integ:servicediscovery-namespacesgained baseline-Description assertions + aCDKD_TEST_REMOVALphase asserting both Descriptions cleared and the SOA TTL reset to 60 in place (no replacement). -
AWS::SNS::TopicDeliveryStatusLoggingremoval resets (issue #1160 sns batch) —src/provisioning/providers/sns-topic-provider.ts,src/deployment/retryable-errors.ts. Before: SNSSetTopicAttributesis per-attribute merge, andupdate()iterated only the DESIREDDeliveryStatusLogginglist — so removing the whole property (or a sub-field / protocol entry) silently kept the live per-protocol feedback attributes, and aSuccessFeedbackSampleRateof0was truthiness-skipped on create and update. Now: the new sharedbuildDeliveryStatusAttributeMap()flattens both sides into<Protocol><Suffix>attribute maps and the update diffs them: kept/changed attributes are sent, attributes present only on the previous side are explicitly reset — RoleArns cleared via''andSuccessFeedbackSampleRatereset to'0', the exact shape a CloudFormation removal leaves (live CFn A/B 2026-08-10;''is REJECTED for the sample rate). Presence is!= null, so a0sample rate now reaches AWS as'0'. The desired side throws on a malformed container / unknown protocol; the previous side (cdkd state) is walked in skip mode per the guard-the-desired-side-only rule.retryable-errors.tsgains the SNSis not a valid role to allow SNSIAM-propagation pattern (a feedback role created ~1s earlier in the same stack is rejected until IAM propagates — live-probed: the same policy-less role is accepted seconds later). Unit tests:tests/unit/provisioning/sns-topic-provider-delivery-status-removal.test.ts(the #1157 trio + sub-field / protocol-entry removal, case-only respelling no-op, malformed-container polarity, create-path0sample rate) + the retryable-errors pattern pin. Integ:sns-sqs-eventgained a delivery-status topic + feedback role and aCDKD_TEST_REMOVALphase asserting the live reset (run 2026-08-10: removal reset verified, destroy clean, 0 orphans). Follow-up filed: #1529 (pre-existing: the canonical CDK L2'http/s'protocol value is rejected and'https'maps to the nonexistentHTTPSattribute prefix) — since RESOLVED, see the #1529 entry above. -
AWS::S3Express::DirectoryBucketTagsbecomes a handled property (issue #609 batch) —src/provisioning/providers/s3-directory-bucket-provider.ts, new dep@aws-sdk/client-s3-control. Before:Tagswas in the type's silent-drop set, so a tagged directory bucket needed--allow-unsupported-propertiesand the tags never reached AWS — which also kept the destroy data guard's tag opt-in (aws-cdk:auto-delete-objects, #1344) out of reach for first-class templates. Now: create forwardsTagsonCreateBucketConfiguration.Tags(the only create-time tag write the s3express split supports;PutBucketTaggingis documented not-supported for directory buckets); update applies the tag diff via S3 ControlTagResource/UntagResource(additive-only semantics compensated — removed keys, including whole-property removal, are explicitly untagged);readCurrentStatesurfacesTagsviaListTagsForResourcefor drift. Both deferred items recorded on #609's 2026-08-02 note ship in the same change: (1) the standard-bucketdeleteBucketWithEmptyRetrybounded race loop is PORTED to the opted-in auto-empty delete path (the ~zero-opt-in rationale from PR #1347 no longer holds now the tag is mainstream); (2) thes3-directory-bucketinteg gains a SECOND, tagged, DATA-CARRYING bucket whose auto-empty is live-exercised in the same destroy the untagged sibling's guard refuses, plus create-time tag assertion and aCDKD_TEST_UPDATE=truetag-mutation phase (TagResource value change + UntagResource removal, asserted viaaws s3control list-tags-for-resource). Tests: new tags unit suite (create forwarding / empty-omit, update add/change/remove/full-removal/no-op/error-wrap, readCurrentState tags); delete suite reworked to the ported retry shape incl. the bounded re-empty race case;gen-update-wrap-coveragepinned no-aws set updated (the provider'supdate()now sends + wraps). - The residual state-replay refusal sites, and the junk state the warn path they produce records (issues #1551 / #1552) —
src/provisioning/providers/sns-topic-provider.ts,lambda-url-provider.ts,dynamodb-globaltable-provider.ts,dynamodb-table-provider.ts, newtests/unit/provisioning/sns-topic-provider-create-replay.test.ts+dynamodb-billing-mode-junk-previous.test.ts,.claude/rules/providers.md,docs/provider-development.md. The gap (#1551): the #1544 / #1538 downgrades left three sites strict, and each still stranded a replay.SNSTopicProvider.create()declared nocontextparameter at all, so theREPLAYING_STATE_CREATE_CONTEXTthe rollback executor's reverse-replacement arm passes as the 4th argument was silently ignored andbuildDeliveryStatusAttributeMap's'throw'mode fired on a bag that IS a cdkd state record — no type error, no warning, just a refusal on the one path with no template-side remedy. The Lambda URL update-pathAuthTypeand the GlobalTable update-pathStreamSpecification/GlobalSecondaryIndexesguards were left strict deliberately, because the obvious downgrade (warn, then use the CREATE DEFAULT) is worse than the refusal on a LIVE resource:'NONE'makes an IAM-guarded function URL PUBLIC,NEW_AND_OLD_IMAGESre-points a live stream, and an empty GSI list reads as "delete every index". After: the downgrade is per site — keep the PREVIOUSAuthType(omitting the field entirely when the previous side is unusable too, sinceUpdateFunctionUrlConfighas merge semantics and retains the live value), SKIP theStreamSpecificationblock, and SUPPRESS the GSI diff. The GSI helper'sonUnusableIndexeshook is now wired from BOTH update call sites, including the PREVIOUS-side translation, which had no hook at all and threw on a state-borne value — the guard-the-desired-side-only rule violated outright. The gap (#1552): a warn-and-continue update SUCCEEDS, so the engine records the unusable desired value as the new state; the next update then compares a corrected template againstBillingMode: null, reads a flip, and sends a same-modeUpdateTablethat DynamoDB rejects when no capacity change rides along — the deploy fails, state stays unchanged, and the rejection repeats forever. After: both DynamoDB providers seed the comparison baseline from the table's ACTUAL mode (already held by theDescribeTableeachupdate()issues) whenever the state-recorded previous is present-but-unusable; an ABSENT previous is deliberately NOT unusable, since seeding it would turn a no-op into a spurious change. Three pre-existing tests pinned the superseded contracts and were re-pointed at what they actually protect (the URL must not become public — now asserted on the value SENT). -
readCurrentStatehid a console-side CloudWatch Logs enable onAWS::CloudTrail::Trail(issue #1565) —src/provisioning/providers/cloudtrail-provider.ts,tests/unit/provisioning/cloudtrail-provider-readcurrentstate.test.ts+cloudtrail-trail-removal-resets.test.ts,tests/integration/cloudtrail-trail/verify.sh,docs/provider-development.md. The gap: theCloudWatchLogsLogGroupArn/CloudWatchLogsRoleArnpair was emitted only when AWS reported BOTH fields, so on a trail with no CloudWatch Logs wiring the keys were ABSENT from the captured snapshot — and the drift comparator's top-level walk is baseline-keys-only, which made a console-side ENABLE invisible tocdkd driftforever. The guard rested on two claims and BOTH were false: that AWS rejects a''round-trip for these fields (disproven by the issue #1160 live probe —''is accepted and nulls the field out) and that "a console-side enable shows up as both fields appearing at once on the next read" (it cannot, for the baseline-keys-only reason above; that wording was corrected in the #1160 review, which is what this issue was filed on). After: the pair is emitted unconditionally, TOGETHER, with''placeholders. The all-or-nothing invariant moves to the WRITE side:update()decides both fields together and forwards them on PRESENCE, so''CLEARS (without whichdrift --revertof a console-side enable was a silent no-op that still reported success) while an ABSENT pair stays retained per the measured CFn parity — pinned by new tests, because that is what makes the always-emit safe rather than a source of spurious calls. The integ gains a phase that CLEARS the pair out-of-band, re-baselines viadrift --accept, re-enables it out-of-band, and requirescdkd driftto exit exactly 1 — the sequence is what makes the assertion fail against the pre-fix binary, since only a snapshot captured while UNWIRED lacks the keys.docs/provider-development.md's Class-1 guidance is corrected too: its "console-side ADD is impossible" clause holds only when the discriminator is an INDEPENDENT sibling, not when the field group's own presence IS the switch. The write path grew two refusals in review: a non-string pair (anullsurvives a JSON state round-trip) and a HALF-populated one (reachable from the always-emitted snapshot of a trail AWS reports with one half) both send NEITHER field and warn, because coercing them would either read as a CLEAR and disable a live trail's log delivery or pair a real ARN with an empty one.create()routes its optional strings throughemptyToUndefinedfor the same replay reason.
Recently Implemented (2026-08-10):
- The 20 nested S3 keys the provider never wrote at all (issue #1495) —
src/provisioning/providers/s3-bucket-provider.ts,scripts/gen-nested-key-coverage.ts(header reason (C)),tests/unit/scripts/gen-nested-key-coverage.test.ts, newtests/unit/provisioning/s3-bucket-provider-nested-write-drops.test.ts,.claude/rules/{code-layout,providers}.md. The gap: #1474's builder recognizer cleared the false positives out ofAWS::S3::Bucket's write-evidence residual, and splitting what remained by "is this member written ANYWHERE in the file?" isolated 20 paths that were not — each with a same-spelled member in@aws-sdk/client-s3, each declared by the CFn registry schema under a top-level property the provider lists inhandledProperties, and none ever assembled. Nothing was dropped by the serializer; the provider simply never built the field, so the deploy reported success with the setting missing. The change: every one is now written, per member rather than by forwarding the blob, so a future CFn-only member cannot ride through unnoticed —LoggingConfiguration.TargetObjectKeyFormat(a bucket asking for the partitioned server-access-log key format silently got the flat default; the emptySimplePrefix: {}is preserved, since its PRESENCE is the signal), the fourReplicationConfiguration.Rules.Destinationblocks via a newbuildReplicationDestinationhelper —AccessControlTranslation(cross-account replication kept SOURCE ownership),EncryptionConfiguration.ReplicaKmsKeyID(replicas were not encrypted with the declared key; CDK'saws-s3L2 emits this for areplicationRulesentry with a KMS key), andReplicationTime+Metrics(a template asking for S3 Replication Time Control — a billed SLA — got plain asynchronous replication) — plus rule-levelSourceSelectionCriteria(replica-modification / SSE-KMS-object selection),LifecycleConfiguration.TransitionDefaultMinimumObjectSize(it sits on the PutBucketLifecycleConfiguration REQUEST, not insideLifecycleConfiguration, which is why the rules-only mapper never reached it), andBucketEncryption…BlockedEncryptionTypes(a bucket kept accepting the SSE-C it declared it would refuse). The matchingreadCurrentStatereads land in the same change, because a field cdkd SENDS but does not READ becomes permanent phantom drift thatcdkd drift --revertthen re-applies forever;TransitionDefaultMinimumObjectSizeis read back UNCONDITIONALLY. The first cut filtered outvaries_by_storage_classas "the account default" and review caught the polarity inverted — AWS defaults buckets created after September 2024 toall_storage_classes_128K, so the filter suppressed the one value a template meaningfully declares and a bucket declaring it would have reported permanent phantom drift. It also made the live assertion vacuous (it asserted the value AWS returns by default, so it passed with the write removed); the fixture now declares the NON-default value. Measured: S3's write-evidence residual 98 -> 81 and the never-written count 20 -> 0, so the pinned test is INVERTED from "these 20 are missing" into a fence asserting the set is EMPTY, backed by a second test naming the 20 members as written. Only 13 of the 20 cleared the bucket; the other 7 are written and still unresolvable at the audited CHAIN for the same structural reasons as the surviving 81 (a renamed CFn segment, an SDK-only wrapper segment, the request-hoisted member). Deferred, with the reason measured rather than predicted: declaringAWS::S3::Bucket'ssegmentRenamesand opting the target into the write pass is issue #1520 — S3 stays out for a purely structural reason now, with no known silent drop left behind it. -
gen-nested-key-coverage: the SPREAD-AND-PATCH forwarder is recognized, optingAWS::CloudFront::Distributioninto the write-evidence pass at 0 findings (issue #1475) —scripts/gen-nested-key-coverage.ts(the fourth recognizer + the CloudFront target opt-in + twoTags.*allow-list entries),tests/unit/scripts/gen-nested-key-coverage.test.ts,docs/_generated/nested-key-coverage.{json,md}regenerated. The shape:CloudFrontDistributionProvider.convertToSdkFormatseedsconst result = { ...config }off the tainted property-bag parameter and patches ~30 named members around it; the genericity test (correctly, by its own rule) rejects a member-naming callee, so all 162 of CloudFront's would-besame-spellingpaths were unmeasurable — recorded as reason (D) since #1445. The recognizer: a literal spreading a BAG-DERIVED seed registers its write path as a hand-off scope, BOUNDED by a per-scope exclusion set (ProviderWriteEvidence.handoffExclusions) — the first-segment keys the function subsequentlydeletes off the binding, resolved through theObject.entries(TABLE)rename-loop / literal-array shapes; an unresolvable delete key refuses the whole registration fail-closed, a wholly-reassigned binding is refused for the builder's reason, anddeliversWholeBlobnow refuses a deleted-from binding so the previously-full spread-only forward hands over to the bounded path. Measured: CloudFront 162 -> 0 (160 spread/scope-covered;Tags.Key/Tags.Valueallow-listed withpasses: ['write']— genuinely written bytoSdkTags, one SDK{ Items: Tag[] }wrapper level below the CFn transparent-array chain); every other target unmoved (the recognizer is monotone); tree residual 260 -> 98. Fences, proven on real code: deleting theIPV6Enabled -> IsIPV6Enabledrename entry still exits 1 (key pass,case-divergence); insertingdelete result['Aliases']exits 1 naming the path (the delete exclusion live); an unresolvabledelete result[dynamicKey]exits 1 (fail-closed refusal); replacing the seed with{}exits 1 via theminHandoffPointscollapse floor. Bounds recorded as (9) in the script header: an OVERWRITTEN member stays credited through the spread (the issue's "patches only rename / wrap" model), the exclusion is first-segment wholesale, and the spread delivers the seed's spelling verbatim. - GlobalTable: a serialized
UpdateTable/DeleteTableno longer races a still-transitioning INDEX (issue #1521) —src/provisioning/providers/dynamodb-globaltable-provider.ts,tests/unit/provisioning/dynamodb-globaltable-provider-roundtrip.test.ts. The gap: the provider must serialize itsUpdateTablecalls, and it waited onTableStatusbetween them — but a table is ACTIVE while one of its indexes is stillCREATING/UPDATING/DELETING, and AWS then rejects the next call withAttempt to change a resource which is still in use: Index is being updated. ThePAY_PER_REQUEST -> PROVISIONEDflip that ALSO drops a GSI is the shape that hits it: AWS applies the flip to every index, and cdkd issued the index delete while the index was still absorbing it. Worse, the failed deploy's owncdkd destroythen hit the same rule fromDeleteTable(Cannot delete table while indexes are being created, updated, or deleted) and left the table BEHIND — a billed orphan the destroy reported as an error but could not clear. Found by the #1511 fixture work in this same session, and attributed properly rather than assumed: thegsi-billing-flipstep failed 3 runs out of 4 on 2026-08-10, and a run from a pristineorigin/mainworktree failed identically — so it is pre-existing and timing-sensitive, not a regression from the branch that surfaced it. The change:waitForTableActiveAfterUpdatenow also requires that no index is in a transitional state, read off the SAMEDescribeTableresponse it already fetched (no extra API traffic), anddelete()waits for the indexes to settle beforeDeleteTable— gated on what the pre-delete describe already reported, so a table with nothing in flight makes exactly the calls it made before. The predicate tests the three TRANSITIONAL statuses rather than!== 'ACTIVE': an absent or unrecognized status must not park a deploy for the full cap on a table that is fine. Both waits stay best-effort past their cap (a large index backfill legitimately outlives any timeout, and AWS's own error remains the backstop) while the table-status half keeps its hard error. 3 unit cases, each verified to fail against a reverse-patched tree — including the no-extra-call one, since the first draft added aDescribeTableper wait and broke 20 call-sequence assertions elsewhere in the suite, which is what surfaced the cheaper design. - An unresolvable PROVISIONED capacity on
AWS::DynamoDB::GlobalTablewarns instead of silently deploying the 5/5 default (issue #1511) —src/provisioning/providers/dynamodb-globaltable-provider.ts,tests/unit/provisioning/dynamodb-globaltable-provider-throughput-cluster.test.ts. The gap: #1444 item A moved the unresolved-value diagnostic into the translation layer, but only for the ON-DEMAND members. The PROVISIONED side has the same input class with a worse outcome:derive{Read,Write}CapacityUnitsrun every value throughtoFiniteNumber, so a present-but-unresolvable member (an unresolved{Ref: …}/Fn::If,'', an object) returnsundefinedand both the table-level and per-GSI translations fall through toDEFAULT_CAPACITY_UNITS— a table the template explicitly sized is deployed at 5/5 with no diagnostic anywhere, i.e. a throttling symptom days later rather than an error. It was recorded as a won't-do on #1503 because the correct behavior needed its own decision: the on-demand side can SUPPRESS (send nothing, keep the live ceiling), which a provisioned table cannot — AWS requiresProvisionedThroughputon the table and on every GSI of one. The change: warn-and-default. A DECLARED-but-unreadable member now produces aThroughputDiagnosticon the #1503 channel naming the member and the substituted default, from the three places the value is actually consumed —derivePerCallProvisionedThroughput(create, and thePAY_PER_REQUEST -> PROVISIONEDflip), the per-GSI PROVISIONED branch oftoSdkGlobalSecondaryIndexes, andtoSdkReplicaThroughputOverrides(whose message reports SUPPRESSION instead of a default, because an absentProvisionedThroughputOverridemeans "inherit the source table"). The blamed member is re-derived in the same order the derivation consults members, so a flip namesSeedCapacitywhile a create namesMinCapacity(#1435), and a block that is ITSELF unusable is blamed as a whole. Deliberately silent, each pinned by a test: an ABSENT block (the pre-existing default-to-5 contract for a template that never asked for a capacity — warning would shout on every deploy of a fine table); a member an explicit SDK-shapedProvisionedThroughputstands in for (that value IS what reaches AWS); the table-level value while the billing mode holds still (nothing sends a table-levelProvisionedThroughputoutside the flip, so no default is substituted); and the whole PREVIOUS side, which comes from cdkd STATE — the #1428 asymmetry, since a value an older binary recorded there is not editable from the template. Emitted BEFORE the first mutating call on both paths, so a detectable defect never gets reported after a real (billable) table exists. 15 unit cases; every positive one verified to FAIL against a reverse-patched tree, and the real-AWS half is a dedicateddynamodb-globaltablefixture table whose READ capacity is anFn::Joinover the region pseudo-parameter — it survives synth (an all-literal join is constant-folded and then rejected by aws-cdk-lib's L1 validator, and anaddPropertyOverrideof the block hits the same validator) and resolves at DEPLOY time tous-east-1-x, so verify.sh asserts the warning fires AND that the table lands on read=5 (cdkd's default) with write=1 (the template's valid sibling). Review fixes folded in before merge: the no-flip silence case was pinned by a test that called the GSI translator with a table-level block it never reads — vacuous for ANY implementation, now driven throughupdate()and verified to fail when half the gate is deleted; the per-GSI diagnostic on a billing flip now uses the'seed'source step 4 actually reads, so it stops namingMinCapacityfor a value the failing call never consulted; theThroughputDiagnosticJSDoc no longer claimsunresolved-memberalways means "nothing was sent"; andderivePerCallProvisionedThroughputgained theArray.isArrayguard its sibling translator already had, since it now routes a user-facing message. -
cdkd driftno longer reports an IAM principal's ARN and itsAROA…unique id as drift (issue #1515) — newsrc/analyzer/drift-principal-normalize.ts,src/cli/commands/drift.ts,tests/unit/analyzer/drift-principal-normalize.test.ts,tests/unit/cli/drift.test.ts,.claude/rules/code-layout.md. The gap: AWS renders an IAM role / user principal inside a resource policy in two forms — the ARN, and the principal's unique id (AROA…/AIDA…) — substituting the unique id when the principal is transiently unresolvable at write time and re-rendering the ARN once it resolves. cdkd's deploy-timeobservedPropertiescapture stores whichever form the read happened to return, so a latercdkd driftcompared two spellings of ONE principal onAWS::S3::BucketPolicy'sPolicyDocument.Statement[].Principal.AWSand reported drift;--revertwrote the recorded form back, reported success, AWS re-canonicalized on write, and the NEXT run reported the identical difference — permanent phantom drift, the #1096 / #1498 class on a different property. It is a RACE, not a deterministic bug: the CDKautoDeleteObjectscustom-resource role is created concurrently with the bucket policy referencing it, which is why two back-to-backdrift-revertfixture runs on 2026-08-10 (09:26 clean, ~12:5x FAILing) disagreed with no relevant code change in between. The change: the issue's direction 1 — canonicalize the principal on BOTH comparison sides, in a new pass alongsidedrift-normalize.ts. Unlike the order-normalization passes there this one CANNOT be pure, since the mapping between the two forms is not derivable from the string: the shape detection is pure (walking onlyPrincipal/NotPrincipalpositions, where the forms are interchangeable — a unique id in aConditionvalue is left alone, where they are not) and oneiam:GetRole/GetUserlookup goes through an injected resolver thatdrift.tssupplies and caches per ARN for the whole command. Direction 2 (declaring the path ingetDriftUnknownPaths) was rejected for the reason the issue gives anddrift-normalize.ts's header already argues: it would trade a visible false positive for a silent false negative on real policy-principal changes. Bounded so it can only ever remove a PROVEN-equal difference: a pair is collapsed only when the lookup shows the ARN's unique id is the very one present on the other side, so a deleted role (exactly the case where AWS keeps the unique id forever), a cross-account principal, or a missingiam:GetRolepermission all leave both sides untouched and the drift REPORTED. Both sides are rewritten because the race is symmetric, and the pass short-circuits with NO AWS call unless a unique id AND a candidate ARN are both present — so a policy without one costs nothing. Known bound recorded in the module header: a policy carried as a JSON STRING rather than a parsed object is not walked, since re-serializing it could manufacture a different phantom drift. The one way this pass could still have hidden a real change, found in review and closed before merge:GetRole/GetUserare ACCOUNT-LOCAL and take a NAME, not an ARN, soarn:aws:iam::<other-acct>:role/Fooandarn:aws:iam::<self>:role/prod/Fooboth resolve to this account's root-pathFoo— the pass would then have "proven" two DIFFERENT principals equal and collapsed genuine drift. The resolver now round-trips the response ARN against the requested one (zero extra API cost) and treats a mismatch as unresolved, i.e. drift reported. Review also added a one-shot WARN when the lookup is denied (otherwise a missingiam:GetRolesurfaces as unexplained permanent drift, visible only under--verbose), folded the two collection walks into one, and recorded that--acceptnow stores the canonicalized ARN form for a subtree that drifts for some other reason. 26 unit cases across the pure pass and the wired command — including the resolves-to-a-different-entity guard, theGetUser/AIDAbranch,parseIamPrincipalArn's IAM-path and non-commercial-partition handling, and the one-lookup-for-two-resources cache — plus the real-AWS fixture steps, all verified to FAIL against a reverse-patched tree. - The TOP-LEVEL
properties['X'] ?? 'default'defaulting sites (issue #1513) —src/provisioning/config-shape.ts,src/provisioning/providers/{apigateway,ec2,iam-access-key,lambda-event-invoke-config,rds-dbproxy-targetgroup}-provider.ts,tests/unit/provisioning/config-shape.test.ts, newtests/unit/provisioning/toplevel-config-defaults.test.ts,.claude/rules/{providers,code-layout}.md. The gap: #1493 rolledreadConfigStringacross the NESTED-container??sites and deliberately left the top-level reads alone, because the container there is the provider's own property bag — rule 2 cannot fire, only rules 3/4 apply, and those turn a present-but-non-string value into a hard refusal. What still bit: anull/ mis-nested / unresolved-intrinsic VALUE silently became a default the template never asked for, and atAWS::ApiGateway::Method AuthorizationTypethat default isNONE, i.e. a PUBLIC method. The change:requireConfigStringtakes an optionalConfigStringOptions, and each site was decided on its own rather than swept.coerceNumberstringifies a finite number where an unquoted YAML scalar is a legitimate shape (IpProtocol: -1,Qualifier: 1— both deploy fine today, so refusing them would break working templates) and stays OFF at the enum-valued sites (InstanceType: 5is still refused).onUnusablewarns and defaults instead of throwing, used at UPDATE-path sites ONLY:rollback-executor.tsreplays a rollback viaprovider.update(..., previousState.properties, ...), so the desired bag there can be a historical cdkd STATE record and a refusal would make the resource UN-ROLLBACKABLE with no template-side remedy (theupdate-refusal-breaks-rollback-replaylesson, applied pre-emptively rather than caught in review). Guarded on create: API GatewayAuthorizationType, EC2InstanceType/ EIPDomain/ SecurityGroupIngressIpProtocol, IAM access-keyStatus, Lambda event-invokeQualifier, RDS DB-proxyTargetGroupName; the latter three also warn on update. Left unguarded, with the reason in-code rather than invisible:EC2Provider.buildIpPermission'sIpProtocol— textually a top-level read, but the helper is also reached fromdeleteSecurityGroupIngressand from the REVOKE half of the inline-rule update diff, both carrying STATE-borne rules, so a guard there would break destroy and rollback; the create path is guarded at its own call site instead, which refuses before the helper is ever reached (pinned by a test that deletes a rule whose recordedIpProtocolisnull). Everydelete()/readCurrentStateread stays unguarded for the same state-side reason. Left open by this PR and closed by the follow-up below: the GlobalTableBillingModesite, whose file was owned by a parallel lane. -
drift --revertpreserves AWS-service-authored tags (issue #1501) —src/cli/commands/drift.ts,tests/unit/cli/drift.test.ts,docs/cli-reference.md,.claude/rules/code-layout.md. The gap:buildRevertNewPropertiesoverwrites each drifted top-level key wholesale, so forTagsAWS ends up with exactly what cdkd state recorded. ECS attachesAmazonECSManagedto an ASG when a capacity provider binds it, and that tag is REQUIRED for managed scaling — post-revert the ASG kept the capacity provider with its managed scaling silently broken (verified live 2026-08-10 on the #1498 verify stack). Neither existing carve-out covered it:Tagsis template-DECLARED (any CDK ASG carries aNametag) so #1498's undeclared-and-captured-empty rule correctly does not apply, and the tag list is an ARRAY, which #1478's dropped-AWS-key walk compares wholesale and never descends into. The change: on a drifted top-level tag list (shape-detected as a non-empty array whose every element has a stringKey), an AWS-side entry the baseline lacks is kept IFF its key is service-managed —AmazonECSManagedor anyaws:-reserved prefix, which cdkd can never have authored since AWS rejects a write of one. Everything else is unchanged: a baseline tag AWS lost is re-added, a changed value is reset, and an ordinary console-added tag is still REMOVED. The--revertplan names each preserved key before the confirmation prompt. Why option 2 and not the issue's option 1, settled by a live test rather than by taste: option 1 (diff the WHOLE tag list, so ANY out-of-band add survives) was implemented first and FAILED thedrift-revertinteg at its final assertion — that fixture injects anIntegInjectedtag and requires--revertto strip it, i.e. "revert removes a console-added tag" is an established contract with a test behind it. Redefining revert semantics for a whole property class is exactly the design pass the issue said option 1 needed, so the narrow safeguard shipped instead. 11 unit cases, the refusal ones verified to FAIL against a reverse-patched tree, including the bound itself (an ordinary console-added tag is still stripped) and controls that a non-tag list and an EMPTY baseline still revert wholesale. - The
??spelling of the malformed-container defaulting class, plus the twoAuthTypesites the #1471 sweep's grep missed (issue #1493 item 1) —src/provisioning/providers/{codebuild,dynamodb-globaltable,route53,cloudfront-oai,ecs,lambda-url}-provider.ts,src/provisioning/config-shape.ts(header only),.claude/rules/providers.md, unit tests per provider plus a newtests/unit/provisioning/dynamodb-globaltable-provider-stream-spec-shape.test.ts. The gap: #1471 / #1490 rolledreadConfigStringacross(cfg['K'] as string) || 'default';?? 'default'fires on the sameundefineda malformed container indexes to — and on an explicitnullbesides. Measuring it is where this goes wrong, which is the reusable part: the issue's own suggested grep\] \?\? 'finds ZERO real sites (the cast sits inside the parens), and the cast-word formas [A-Za-z]+\) \?\? '(20 hits inproviders/) is blind to everyas string | undefined/ quoted-union / line-wrapped site — four of which this change rolls. The change: the 9 sites that INDEX A NESTED CONTAINER now go throughreadConfigString— CodeBuildSource/SecondarySources[]/Artifacts/SecondaryArtifacts[]/Environment.{Type,ComputeType}(a stringSource: 'GITHUB'built a NO_SOURCE project; a stringEnvironmentsilently downsized the compute type), DynamoDB GlobalTableStreamSpecification(a string container invented aNEW_AND_OLD_IMAGESstream), Route 53HostedZoneConfigon BOTH paths (create dropped the comment, update WIPED the live one —UpdateHostedZoneCommentis a whole-value write), CloudFront OAI config on both paths, and ECSDeploymentController(a string container claimed the ECS rolling-update controller, under which cdkd sends parameters AWS rejects for a CODE_DEPLOY / EXTERNAL service).mapSource/mapArtifactstake acontainerPathso a refusal names the right block. TwoAWS::Lambda::UrlAuthTypesites are the #1471 sweep's OWN residual — its grep keyed onas stringand that site casts toFunctionUrlAuthType, so a blank / null AuthType kept defaulting to'NONE', i.e. a PUBLIC function URL — and now userequireConfigString. Defects the 3-axis review and its fix-back re-review caught before merge, all now fenced by tests: the guards behind a TRUTHINESS gate (if (!source),if (streamSpecInput)) were skipped entirely by a FALSY malformed container, soSource: ''still built a NO_SOURCE project — the gates are!= nullnow, which is what.claude/rules/providers.mdalready required; and four sites threw the helper's plainErroroutside any wrapping catch (CloudFront OAI create + update, ECS update, GlobalTable create), escaping untyped into the deploy engine's retry loop instead of surfacing as aProvisioningError. A re-review of the fix-back itself then found the same class TWICE MORE, one level up: CodeBuild'sSecondarySources/SecondaryArtifactsARRAY containers were still truthiness-gated (a blank string was silently dropped; a truthy non-array died with a rawTypeError: .map is not a function), now guarded by a newrequireConfigArray; and the GlobalTableStreamSpecificationread on the UPDATE path was still unguarded while create refused the identical template, sendingStreamViewType: undefined— the exact create/update asymmetry the rule exists to prevent. Not rolled, recorded inconfig-shape.ts's header rather than left invisible: the TOP-LEVELproperties['X'] ?? 'default'reads (rule 2 cannot fire on the provider's own bag, and refusing a present non-string is a stricter-value decision with its own regression surface — an unquoted YAMLIpProtocol: -1is a NUMBER today; issue #1513); nested reads whose value is an identity key or a warning label and never reaches AWS; EC2's twoVpcId ?? ''attribute-cache reads (a guard would throw AFTER a successful create and orphan the security group); and every previous-side / state-side read. Items 2 and 3 of #1493 stay open — both live ins3-bucket-provider.ts, which issue #1495's lane owns. -
gen-nested-key-coverage: the write-scope index can see the BUILDER idiom, unblocking the CloudWatch AnomalyDetector opt-in (issue #1474) —scripts/gen-nested-key-coverage.ts,tests/unit/scripts/gen-nested-key-coverage.test.ts,docs/_generated/nested-key-coverage.{json,md}regenerated,.claude/rules/providers.md,.claude/rules/code-layout.md. Nosrc/**change — pure tooling / docs. The gap: the write-evidence pass indexes members written inside an object LITERAL, and a provider that assembles a sub-blob by MUTATION defeats that —const mapped: AnomalyDetectorConfiguration = {}; mapped.MetricTimezone = …; mapped.ExcludedTimeRanges = ranges.map(…); params.Configuration = mapped;names every member it delivers, per member, on the forward path, butresolveLiteralsresolvesmappedto the EMPTY seed and stops, soConfigurationscoped to nothing and all three of its children reportedno-write-evidence. That was a FALSE POSITIVE, and it is what keptAWS::CloudWatch::AnomalyDetectorout of the pass;S3BucketProvideruses the same idiom inapplyWebsiteConfiguration/applyObjectLockConfiguration/applyReplicationConfiguration. The change:collectWriteEvidencegainedresolveBuilders+walkBuilderAt, crediting a builder's assigned members at the SAME path a literal's own members get, at full depth (out.Rule.DefaultRetention = { Mode }opens the intermediate scopes rather than flattening onto the builder). A BUILDER is a local binding whose INITIALIZER is an object literal (empty or partial), populated afterwards byout.Foo = …/out['Foo'] = …assignments onto THAT BINDING, and reaching a write. Three things keep it from becoming a rubber stamp: the literal initializer (the object's identity has to be this file's —const out = makeThing(),let out;seeded later, and a binding REASSIGNED as a whole are all refused); DECLARATION IDENTITY rather than the bare name for every assignment (known bound (3) deliberately not inherited); and the credit bounded to the BUILDER, never the enclosing scope — the trap #1445's review proved load-bearing when whole-scope credit would have hidden ECS'sContainerPortRange. Declaration identity required fixingdeclarationOfitself, and until the review caught it the claim was false in the recognizer's own code: that helper searched the nearest FUNCTION scope, descended fully into nested functions and returned the FIRST textual match, so bound (3)'s bare-name weakness reached INSIDE a single function — two same-namedconst cfgbuilders in differentifarms collapsed onto one declaration and merged their member sets (each vouching for the other's blob, the false-CLEAR direction), and aconst cfgin a nested arrow declared textually first captured the enclosing function's owncfg, inverting both verdicts at once.const/letare BLOCK-scoped, sodeclarationOfnow resolves outward through block scopes; it cannot under-resolve a valid binding, since a reference outside the declaring block is a compile error, and the tree measures identically before and after. The sibling-METHOD case had a test from the start and always passed — only the intra-function shapes were broken, which is why the existing test was not evidence. Delivery stays the CALLER's question: the builder walk runs only fromrecordAt, i.e. only where a write takes the value, and those sites are already filtered byfeedsOnlyComparison/isComparisonOnlyLiteral, so a builder never handed to a write — or handed only to a diff — is never reached. Measured, not predicted: the recognizer is MONOTONE (it only adds scoped members) so no target gained a finding;AWS::CloudWatch::AnomalyDetectorwent 3 -> 0 and is now opted in (minWrittenMembers: 12/minWriteScopes: 2/minHandoffPoints: 1, measured against 17 / 4 / 4, the last deliberately below its yield for the reason recorded on the API Gateway v2 floors),AWS::S3::Bucket125 -> 98, tree total 290 -> 260. S3 stayed OUT, with the reason re-measured rather than predicted: its 98 split into 78 written-somewhere-but-not-at-the-audited-chain (CFn->SDK segment renames likeWebsiteConfiguration.RoutingRules.RedirectRule-> the SDK'sRedirect, plus plural-vs-singular per-item PUT APIs —segmentRenameswork) and 20 never written anywhere in the file, i.e. confirmed-looking write-side silent drops: theLoggingConfiguration.TargetObjectKeyFormatfamily, the fourReplicationConfiguration.Rules.Destinationblocks (AccessControlTranslation/EncryptionConfiguration/Metrics/ReplicationTime),Rules.SourceSelectionCriteria,LifecycleConfiguration.TransitionDefaultMinimumObjectSizeandBucketEncryption…BlockedEncryptionTypes— filed as issue #1495. No allow-list entry was added for any of it; an entry reading "delivered by a builder" would make the bucket stop meaning anything. Bound (4) is WIDENED by the recognizer, and recorded rather than left to be discovered: a reverse SDK->CFn helper NOT namedreadCurrentState*that uses the builder idiom previously contributed only its empty seed and now contributes a fully populated SCOPE (s3-bucket-provider.ts'sreadLifecycle, whoseconst out = {}is filled with CFn-spelledout['Id']/out['Status'];ecs-provider.ts'svolumesToCfn). Inert today — S3 is not opted in and ECS islower-first, so a CFn-spelled terminal misses the exact compare — but S3 isexact-style, where a CFn-spelled reverse write vouches for the forward mapper verbatim, so wideningREVERSE_MAP_FUNCTION_PREFIXESto a suffix match belongs to the S3 opt-in (#1495) where its effect on the LITERAL set can be measured on the target it affects.minWriteScopesstays 2 against a yield of 4, MEASURED rather than rounded: all three states were counted on this provider — recognizer working 4, recognizer collapsed 2, recognizer working with one real write deleted 2. A collapse and a genuine provider regression are the SAME number here (the recognizer's whole contribution is one assignment, which opens two scopes), so a floor of 3 fires on both and would turn "the provider stopped writingExcludedTimeRanges" into "parser regression?" — the miscalibrationminHandoffPointsalready records for API Gateway v2. A recognizer collapse is not silent at 2: it re-surfaces the three paths asno-write-evidence, failing the shipped--check, the opt-in-table test and both real-code probes. Tests: 24 new cases — one per BUILDER shape the recognizer claims (empty seed, partial seed,out['X'] =element access, spread-into-return, early-return arms,?:arms, multi-segment assignment chains), one per shape it REFUSES (never delivered, diff-only, same-named binding in another METHOD and in the SAME function, nested-arrow shadowing, enclosing-scope sibling, computed key, nestedreadCurrentState*helper with a non-reverse-named control that proves the branch load-bearing), the five bound-(8) pins (non-literal initializer, late-seededlet, whole-binding reassignment,Object.definePropertyonto a builder, flow-insensitivity) each paired with its ACCEPTED twin so an empty credit cannot pass on a broken harness, the S3 reason-(C) never-written pin, and FOUR REAL-CODE probes through the--providers-dir=seam against a scratch copy — two at the library level asserting the finding set and two spawned through the shipped CLI asserting the process EXIT CODE (deletingmapped.ExcludedTimeRanges = …exits 1 naming all three paths; deleting the hand-namedEndTime: toDate(r['EndTime'])inside the value that assignment carries exits 1 naming ONLYConfiguration.ExcludedTimeRanges.EndTime, the precision half proving the builder credit does not blanket the value it delivers). -
cdkd drift: template-undeclared keys captured EMPTY in the observed baseline are no longer compared, so sibling-populated parent properties stop phantom-drifting and--revertstops stripping them (issue #1498) —src/analyzer/drift-calculator.ts(undeclaredEmptyObservedKeys),src/cli/commands/drift.ts,tests/unit/analyzer/drift-calculator.test.ts,tests/unit/cli/drift.test.ts,docs/cli-reference.md,docs/state-management.md. The bug:observedPropertiesis captured per-resource right after THAT resource settles — structurally BEFORE dependent sibling resources run — so a parent key a sibling resource type materializes later (AWS::ECS::ClusterCapacityProviderAssociations->Cluster.CapacityProviders, a standaloneAWS::AutoScaling::LifecycleHook-> the ASG'sLifecycleHookSpecificationList, standalone SG ingress/egress rules) was captured empty and later populated: permanent phantom drift on a fresh, untouched stack, anddrift --revertthen live-verifiedly DETACHED the capacity provider, deleted ALL lifecycle hooks (including the cdkd-managed sibling still in state), and stripped ECS's requiredAmazonECSManagedtag while printing✓ reverted. CFn drift only compares template-declared properties, so the class was cdkd-only. The fix skips top-level observed-baseline keys that are BOTH template-undeclared AND captured empty ([]/{}/null) by feeding them into the comparator's existingignorePaths; an undeclared key captured with a REAL value (an AWS-side default) stays compared, preserving the observed baseline's extra detection power over CFn. Live A/B on the issue's ECS capacity-provider repro (us-east-1): old binarydriftexit 1 with 2 phantom resources; fixed binary reports only the CFn-parityTagsdelta, and--revert's blast radius shrank to that declared key — capacity provider attached, hooks intact. The declared-Tagsresidual (revert strips the service-authoredAmazonECSManagedentry) is split out as issue #1501. -
AWS::DynamoDB::GlobalTable: the throughput-translation cluster — the table-level READ ceiling is wired at all, explicit SDK-shaped blocks are merged instead of forwarded, the unresolved-value diagnostic reaches every case, andreadCurrentStatestops emitting SDK-shaped GSIs (issues #1436 / #1428 / #1444 / #1420 / #1434 / #1421) —src/provisioning/providers/dynamodb-globaltable-provider.ts+tests/unit/provisioning/dynamodb-globaltable-provider-throughput-cluster.test.ts+tests/integration/dynamodb-globaltable/. Five issues in one PR because they all land in the same translation layer and several only make sense against each other. #1436 (drop on the way IN, the widest of the five):AWS::DynamoDB::GlobalTablecarries the table's on-demand READ ceiling on the LOCAL REPLICA (Replicas[?Region==<deploy region>].ReadOnDemandThroughputSettings) — the mirror of the per-GSI split #1387 established — and the provider read only the top-level WRITE half, so the plain CDK spellingBilling.onDemand({maxReadRequestUnits: 100, maxWriteRequestUnits: 200})deployed a table with NO read ceiling and reported success. The symptom is a surprise bill or an unthrottled read spike, never an error, and the repo's own verbatim-cdk synthfixture already carried the value with no assertion over it. Both halves now go into oneOnDemandThroughputonCreateTable/UpdateTable, non-local replicas get{Provisioned,OnDemand}ThroughputOverrideon their{Create,Update}ReplicationGroupMemberAction, andreadCurrentStatereverse-maps the read half back onto the local replica entry. The comparison baseline is AWS's OBSERVED ceiling rather than the state record — the same AWS-aware diff theDeletionProtectionEnabledblock uses, and for a sharper reason: a table deployed before this fix has the value in cdkd state (state stores template intent) while AWS never received it, so a state-vs-state comparison reads “unchanged” and the drop would never be repaired. Reading the live value converges such a table on the FIRST post-fix deploy. A PROVISIONED -> PAY_PER_REQUEST flip DEFERS the declared ceilings to their ownUpdateTableafter step 4's flip (review catch on this PR: step 3 runs first, so sending them there hit a still-provisioned table and 400'd, making the flip template repeatedly undeployable — the per-GSI path already had the post-flip shape for free via step 6), andreadCurrentStatesynthesizes the LOCALReplicasentry whenDescribeTableomits the member (a single-region GlobalTable returns none, but the CFn schema requires the local entry — without it every local-replica-homed member, the new read ceilings AND the pre-existing per-replica PITR / Tags reverse maps, had no drift home for the common case). #1434's read half rides along: removing the ceiling now resets it with the same live-verified-1sentinel, per member, so dropping the read ceiling leaves the write sibling untouched. The REPLICA-level overrides still have NO reset arm and that stays a probe-informed decision rather than an omission —-1is stored literally there and the empty-block form wedged a table inUPDATINGfor over an hour — so a dropped replica override now WARNS that the old value is still in effect instead of guessing a third payload. #1428 (three defects, fixing any subset leaves a live one): an explicitly SDK-shapedProvisionedThroughput/OnDemandThroughput/*ThroughputOverridewas forwarded VERBATIM, which skipped the ONE coercion path (a stringly-typed CFn"5"reached the SDK unnormalized), let a PARTIAL explicit block SUPPRESS valid derived siblings (an explicit{MaxReadRequestUnits: 41}discarded a perfectly goodWriteOnDemandThroughputSettingsfrom the same template), and fired regardless of billing mode (a provisioned block on a PAY_PER_REQUEST table, which AWS rejects).mergeExplicitThroughputBlocknow coerces with a tightenednumber | stringpredicate (bareNumber()maps[]→0,true→1, and[]additionally flipped the presence gate ON), merges PER MEMBER over the derived value, and the caller gates on billing mode. It WARNS AND FALLS BACK, never throws: the issue records three earlier designs and each was broken by the same lever — the helper also runs onpreviousProperties, so a garbage value already in STATE made every subsequentupdate()throw including the deploy that would have removed it, and a throw from the replica path fired AFTERCreateTablehad committed a real table (whichDeletionProtectionEnabled: truethen refuses to delete, orphaning it). Diagnostics are collected by the pure translators and reported by the caller, which is what makes the required desired/previous ASYMMETRY expressible — only desired-side call sites pass a collector — and they are emitted BEFORE the first mutating call, including a pure pre-flight pass over the cross-region replica blocks.collectRawOnDemandDeclarationsbecame the UNION of the explicit and derived sources to match, since an explicit block no longer wins outright. #1444 item A: the unresolved-value diagnostic lived in the per-GSI reset gate, which runs only for an index the diff already classifiedmodified, only when a previous ceiling happened to exist, and only when the billing mode held still — so the SAME template defect was silent on a FIRST-TIME set (both translated sides lack the member, so the index is notmodifiedat all) and across a BILLING FLIP. It now fires fromtoSdkGlobalSecondaryIndexes, where the value is actually lost, and the gate's own warning is removed rather than duplicated. Item D was already folded into PR #1443. #1420:readCurrentStatewrote the raw SDKGlobalSecondaryIndexDescription[]into the CFn-shaped state key, socdkd driftcompared a CFn baseline against an SDK snapshot and reported PERMANENT phantom drift on any GlobalTable with a GSI — SDK-only members (IndexArn/IndexStatus/ItemCount/IndexSizeBytes) plus a completely different throughput model. The issue offered a stopgap (declare the subtree ingetDriftUnknownPaths) precisely because a correct map needed the per-index auto-scaling read, which was blocked on the registration gap; that gap shipped in #1419, so the correct map is built instead andgetDriftUnknownPathsstays EMPTY — the stopgap would have turned drift OFF for the entire GSI subtree.readAutoScalingSettingsis generalized to thedynamodb:index:*dimensions through the sharedautoScalingResourceId, so the read cannot spell theResourceIddifferently from the register / deregister calls. #1421 (integ): thePAY_PER_REQUEST -> PROVISIONEDflip on a table that HAS GSIs was asserted only against a mocked DynamoDB client — the two GSI fixtures are never mutated and the table the UPDATE flow does flip has no GSI — so if AWS rejected “GlobalSecondaryIndexUpdatesalongsideBillingModein oneUpdateTable” the unit suite stayed green and every such deploy failed in the field. A third fixture table plus agsi-billing-flipmode covers it, and drops one of its two indexes in the same phase to cover the second unverified sub-path (an index this deploy REMOVES is still live at flip time, since itsDeleteis issued later, so it too must carry throughput in the flip call); the surviving index and the table are pinned onto theirSeedCapacityrather thanMinCapacity, the one context AWS documents the seed for (#1435). Tests: 29 new units across the five issues plus new integ steps 4b/4d/13d/13e. Three PRE-EXISTING units were INVERTED rather than deleted, because they pinned the behavior this PR changes — whole-block replacement by an explicit block, an explicitProvisionedThroughputforwarded onto a PAY_PER_REQUEST table, and a present-but-unresolvable value forwarded raw (Number({Ref: …})isNaN) instead of suppressed-and-reported; each carries the old rationale and why it inverted. -
gen-nested-key-coverage: per-PATH fixture capture, so two same-named members inside ONE top-level stop vouching for each other (issue #1464) —scripts/refresh-cfn-schemas.mjs(+ its.d.mts),scripts/gen-nested-key-coverage.ts, 11 re-captured fixtures undertests/fixtures/cfn-schemas/,tests/unit/scripts/gen-nested-key-coverage.test.ts,.claude/rules/providers.md,.claude/rules/code-layout.md,docs/_generated/nested-key-coverage.{json,md}regenerated. Nosrc/**change — pure tooling / docs / fixtures. The gap: #1448 made the write-evidence pass path-scoped, but the FIXTURE stopped it one level short.nestedPropertiesis a flattened transitive closure per top-level property, soEnvironment.TypeandEnvironment.EnvironmentVariables[].Typewere literally the SAME audited path — measured then, deleting either one's write from a scratch copy of the realcodebuild-provider.tsexited 0, cleared by its cousin, and both were genuine silent drops. #1445 had since opted five more targets into the pass, which is what made the re-capture worth its cost. The fix, both sides:refresh-cfn-schemas.mjsgainedextractNestedPropertyPaths, emitting anestedPropertyPathscapture ALONGSIDE the flattened one — fullTop.A.Bchains,$ref-resolved, cycle-guarded per BRANCH rather than per top-level (a definition reached down two sibling branches must contribute to both, which the flattened capture's sharedseenRefsset collapses), arrays transparent soEnvironmentVariables.Namematches the write side'senvs.map((v) => ({ name: … })). Per-branch ancestry is combinatorial where the flattened walk was linear, so the capture is CAPPED (MAX_NESTED_PATHS_PER_PROPERTY5000 /MAX_NESTED_PATH_DEPTH12) with a NAMED throw: a diamond-shaped definition graph measured 786,430 paths at k=18, andprocessTyperuns the walk for every refreshed type.nestedKeyPathsForTargetreads the new capture;collectWriteEvidencekeys its scope index by the write PATH and descends, soenvironmentandenvironment.environmentVariablescarry differenttypefacts; and a WHOLE-BLOB HAND-OFF is recorded as a wildcard PATH credited by prefix (isHandoffCovered), replacing #1445's fold through the SDK model — strictly tighter, and free of that fold's bare-name union where anItemshand-off reached 217 unrelated CloudFront members.reachableSdkMemberNamessurvives only as theminHandoffPointsparser floor. Acceptance, spawned through the--providers-dir=seam against a scratch COPY of the real tree (neversrc/): deletingtype: ((environment?.['Type'] …))exits 1 namingAWS::CodeBuild::Project: Environment.Type [no-write-evidence]withEnvironment.EnvironmentVariables.Typeclean; deletingtype: (v.Type ?? 'PLAINTEXT')exits 1 namingEnvironment.EnvironmentVariables.TypewithEnvironment.Typeclean. Both exited 0 before. Segment SPELLING needs two mechanisms, and they are not interchangeable. A CASE difference on an intermediate segment is absorbed — the parent chain is matched case-insensitively (the terminal member never is: it is the only thing that proves delivery, in the scope test AND in the SELF case of the hand-off test), because CFnEFSVolumeConfigurationis SDKefsVolumeConfigurationand an exact parent match reported 16 membersecs-provider.tsdemonstrably writes. The fold is applied one LEVEL at a time while descending the index, never as a global lowercase union, which would merge the member sets of the 80 unrelatedname/Name-style scope pairs the same file carries. A genuine RENAME is out of the fold's reach: CFnProxyConfiguration.ProxyConfigurationPropertiesis the SDK'sProxyConfiguration.properties, which would have re-broken the ECS opt-in PR #1484 had just landed, so the target declares asegmentRenamesmap applied to NON-TERMINAL segments only. It is STALENESS-FENCED likeNESTED_KEY_ALLOW_LIST(findStaleSegmentRenames):--checkfails when the un-renamed chain starts resolving (the SDK renamed the member back) or the CFn segment disappears — but deliberately NOT when the provider merely stops writing the member, so the map can never mask the divergence it exists to expose. Allow-listing was refused; the entry would have silenced a CI-blocking bucket for a reason unrelated to delivery. Bound (2) —record()keying scopes by NAME — is half closed and half restated: a lexically nested write no longer opens a root-level scope (logsConfig: { cloudWatchLogs: { … } }stops producing a top-level-lookingcloudWatchLogs), and the suppression follows a.map(v => ({ … }))callback becauseresolveLiteralsdoes, while refusing to cross an opaqueJSON.stringify({ … })because nothing resolves that in the other direction. The UNION across write SITES at one path stays, because it is intrinsic — a key is cleared when ANY site covers it. Two known bounds were INVERTED into fences rather than deleted, so the closure cannot silently regress. Re-measured, not predicted: the audited unit grew 587 -> 703 paths across the 11 targets (CloudFront 121 -> 173, S3 155 -> 190, ECS TaskDefinition 121 -> 142, CodeBuild 93 -> 98, ECS Service 54 -> 56, CloudWatch AnomalyDetector 30 -> 31; the five API GW v2 targets are flat and did not move), so everyminNestedKeysfloor was re-calibrated — including two that sat exactly AT their measurement, the "every legitimate change is a false alarm" shape the band now forbids — as was ECS'sminWriteScopes(34 -> 58 non-empty scopes; floor 25 -> 40) and CodeBuild's (23 -> 32; floor 15 -> 20). The opt-in table's before/after re-measures to CodeBuild 0/95 -> 0/95, API GW v2 13/13 -> 0/13, ECS TaskDefinition 30/136 -> 0/136, ECS Service 45/56 -> 0/56, CloudWatch AnomalyDetector 30/30 -> 3/30, S3 130/152 -> 125/152, CloudFront 162/162 -> 162/162 (410 -> 290 total). Zero CI-blocking findings on the real tree —--checkstays at 0 divergences across all three passes with all EIGHT fresh-object targets opted in, and no allow-list entry was added. Eleven fixtures re-captured from the live CFn registry (cloudformation:DescribeType, targeted per type rather than the full ~135-type sweep); the only non-additive delta AWS itself introduced isAWS::ECS::Service'sDeploymentLifecycleHook.HookDetailsmovingscalar->mixedindefinitionShapes. Tests: 199 runtime cases, including the two inverted bound fences, theextractNestedPropertyPathswalker (chain, transparency, per-branch cycle guard, sibling-branch re-visit, the four container shapes the walk claims, the diamond cap, a one-directional consistency fence between the two committed captures, and an assertion that EVERY audited fixture carries the new one), the depth-separated scope index, the prefix-bounded hand-off credit with its verbatim-terminal half, the case-fold's positive AND negative direction, the rename map's four states (bridges / never touches the terminal / goes unused when renamed back / stays used when the provider breaks), and spawned--checkprobes for the two acceptance directions, the stale-rename exit and the missing-capture throw. -
ResourceProvider.creategains an optionalCreateContext, so a provider pre-flight can tell a rollback STATE REPLAY apart from a fresh template provision (issue #1463) —src/types/resource.ts,src/provisioning/region-check.ts,src/deployment/rollback-executor.ts,src/provisioning/providers/glue-provider.ts,tests/unit/deployment/{rollback-executor,deploy-engine-replace,deploy-engine-named-replacement-collision,deploy-engine-recreate-via-sdk-provider}.test.ts,tests/unit/provisioning/glue-provider-roundtrip.test.ts,docs/{provider-development,supported-resources,troubleshooting,architecture}.md,.claude/rules/{providers,code-layout}.md,CLAUDE.md. The bug:replayRollback's reverse-replacement arm (issue #1199) revives the OLD physical resource by callingcreate()withpreviousState.properties— a cdkd STATE record, not a template — so a provider pre-flight refusal fired there and that rollback operation failed with the old resource never restored.replayRollbackcatches per-op so the rollback was not wedged, but the resource stayed gone and, unlike the update case, the user had NO template-side remedy: the offending value lives instate.json, editable only by hand. #1454 had already movedAWS::Glue::Table'sIcebergTableInputrefusal from create+update to create-only for exactly this reason on the update path (rollback-executor.tscallsprovider.update(..., previousState.properties)); the review of that fix found the create arm is a replay path too, and filed it rather than weakening the refusal. The fix (maintainer-decided approach: option 1 of the three the issue listed): an optional 4th parametercontext?: CreateContextonResourceProvider.create, mirroring theDeleteContextthatdelete()has taken since the region-check work — a shape the codebase already establishes rather than a new idea. It is defined insrc/types/resource.tsnext to theResourceProviderinterface that consumes it, NOT besideDeleteContextinregion-check.ts: that type earns its home there because itsexpectedRegionfeedsassertRegionMatch, whereasCreateContexthas no region-checking role, so symmetry alone would have put an unrelated type in a module named for something else (a review finding). A one-line pointer sits next toDeleteContextso a reader looking for one still finds the other. It carries one field,replayingState: true, and the executor sets it at BOTH reverse-replacement create arms (the create-first attempt AND the delete-new-first retry after a name collision) via a single sharedREPLAYING_STATE_CREATE_CONTEXTconstant so the two cannot drift. What the flag licenses is bounded on purpose and the contract says so at the type: a provider may conclude that the user has no template-side remedy and must therefore downgrade a PRE-FLIGHT REFUSAL to a warning; it may NOT conclude anything about the properties' content (they are neither more nor less trustworthy than template properties), treat the call as a dry run, relax a data-safety guard, or skip validation that protects the AWS call itself (a missing required field stays a hard error — warning past it would just move the failure later).GlueProvider.enforceIcebergTableInputAbsentis the only consumer today and warns on replay, reusing the existing shared message builder — a thirdcreate-replaylead clause, with the probe evidence and the working shape identical to the create refusal and the update warning, so the three cannot drift. The warning is deliberately HONEST about the outcome rather than promising a clean restore: unlike the update path (whoseUpdateTableCommandInputhas noOpenTableFormatInputmember at all, so nothing is forwarded), the create path DOES forward the blob, so the re-created table is degraded exactly as the original was — under the CFn spellingIcebergTableInputthe AWS SDK v3 serializer drops the unknown member (#1390's silent drop, which is what made these state records exist in the first place) and the table comes back without its Iceberg metadata; under the SDK spellingCreateIcebergTableInputthe value IS sent and Glue rejects the call, failing that one operation. Either way the message namescdkd deploywith the working shape as the fix-forward. That is strictly better than refusing, which guaranteed the resource was not restored. Every create call site was audited, not just the two changed. The fivedeploy-engine.tssites (CREATE, the property-driven replacement, the--recreate-via-*destroy-then-create, the--replacedelete-first fallback, the update-failure replacement) all pass freshly resolved TEMPLATE properties and deliberately pass no context — the refusal must stand where the user can edit the input. The five providers that re-create inside their ownupdate()(ACM certificate, IAM managed policy, IAM role, Lambda permission, SNS subscription) are a DIFFERENT case, and getting the reason right matters because it is the rule the next provider author reads: they forward the update'spropertiesargument, which IS a state record during a rollback replay (rollback-executor.ts'srevertarm, anddrift --revert), so they are NOT template-driven. The first draft of this entry and the accompanying JSDoc said they were — factually wrong, caught independently by two reviewers. The real constraint is the reviewer's framing, now recorded at the type, inrollback-executor.ts, indocs/provider-development.md§1a, and in.claude/rules/providers.md: those internal re-creates CANNOT receive a context, so a provider with a create-side pre-flight refusal must not re-create insideupdate(). There is no live gap — reviewers verified that all five (plusiam-access-keyandfsx-filesystem) validate required fields only, whichCreateContext's contract explicitly keeps as hard errors — so this is a constraint on the next provider, not a description of today's tree. Implementer audit: all 85implements ResourceProviderclasses walked. 84 declare the plain 3-parametercreate(4 of them with_-prefixed unused names) and are valid against an optional 4th;GlueProvideris the one that gained it. No overloads, no rest params, no class-propertycreate, noParameters<ResourceProvider['create']>extraction, no.length/arguments.lengthread, and no wrapper that forwardscreatewith a fixed argument list (withRetrytakes a zero-arg thunk, so it cannot drop arguments). The four TS-Compiler-API checkers were checked too:gen-handled-property-wiringandgen-nested-key-coverageseed their taint walks by parameter NAME (PROPERTY_BAG_PARAM_NAMES/HANDOFF_BAG_PARAM_NAMES), never by position, so a trailingcontextparam contributes no evidence and both matrices are byte-identical;gen-update-wrap-coverageandgen-sdk-attr-coveragenever inspect parameters. Tests: 8 added, 1 updated. The load-bearing one is that a reverse-replacement create of a Glue table whose STATE carriesIcebergTableInputnow SUCCEEDS with a warning and forwards the blob verbatim toCreateTableCommand— it throws without the fix, which is the whole point, and pinning the forwarded payload stops a future "just strip the key on replay" edit from silently changing what AWS receives. A 3-case table proves the template path still REFUSES with no context, with{ replayingState: false }, and with a context carrying no flag. On the executor side, a new test drives the collision path so BOTH create arms run and assertscall[3]on every one — the delete-new-first arm is the one a fix could plausibly miss, and missing it would leave the bug alive for exactly the collision case. One deliberate consequence of the shared-body assertion: thecreate-replaylead cites #1454 as well as #1463, so the existingexpectIcebergMessageBodyhelper keeps fencing all three modes. Three review findings hardened the rest. (1) The INVERSE fence was missing entirely, and it is the direction that matters most: a reviewer injected{ replayingState: true }at each deploy-engine replacement create site and all 9270 tests stayed green, so the paths this change's own JSDoc promises still refuse could have been silently downgraded. Three arity fences now pincreateto exactly 3 arguments on the--replacedelete-first fallback (both its collided create-first and its post-delete re-create), the--recreate-via-*destroy-then-create, and the update-failure replacement — assertingcall.length === 3rather than a value, so ANY 4th argument fails, including a future context carrying some other field. Verified by re-injecting the probe: exactly those three fences fail and nothing else does. (2) The replay warning's own CONTENT was unpinned — the test asserted only the shared body plus the phrase "REPLAYING a historical cdkd STATE record", and a reviewer rewrote the whole lead to one that falsely promises a clean restore ("...and the old table is restored") with the suite still passing 56/56. Since the entire point of warning instead of refusing is that the outcome is DEGRADED, the assertions now pin the substance clause by clause:#1390,without its Iceberg metadata,Glue rejects, and thecdkd deployfix-forward. (3) The replay path covered only the CFn spelling; it is now parameterized over both, matching the template-path coverage. Also:assertIcebergTableInputAbsentis renamedenforceIcebergTableInputAbsent(it can now warn and return, soassertcontradicted the behavior), thecreateTablecomment claiming the divergent member "is refused pre-flight just above" is corrected to describe what a replay actually forwards, and the helper's unusedphysicalIdparameter — dead since #1454, never passed by its single caller — is removed. - Route 53: destroy no longer fails while a hosted zone's AcceleratedRecovery transition is in flight (issue #1467) —
src/provisioning/providers/route53-provider.ts,src/deployment/retryable-errors.ts,tests/unit/provisioning/route53-provider.test.ts,tests/unit/deployment/retryable-errors.test.ts. While a zone'sHostedZoneFeatures.AcceleratedRecoveryStatusis transitioning (ENABLING / DISABLING /*_HOSTED_ZONE_LOCKED), Route 53 rejects EVERY mutation withHostedZone <id> is marked disabled for mutation. Record sets are deleted BEFORE the zone in the destroy DAG, so the zone-side pre-delete guard never ran first and acdkd destroyissued during the window exited with per-record errors plus the knock-on zone-delete failure ("contains non-required resource record sets"), state preserved.deleteRecordSetnow catches the mutation-disabled rejection, polls until the status settles to a MUTABLE state (ENABLED / DISABLED / zone gone — deliberately NOT disabling the feature, a record delete must not flip a zone-level setting;*_FAILEDand timeout surface as operator-recovery errors; same env-overridable poll knobs as the zone guard), then retries the change once. The message is also inRETRYABLE_ERROR_MESSAGE_PATTERNS(generic exponential cadence, not the dense IAM one) as the net for the create/update paths and sub-budget windows. Theroute53integ fixture deliberately keeps racing the ENABLING window (deploy assertsENABLING|ENABLED, then immediately destroys) — with this fix that race IS the regression coverage for the destroy path. - ECS: two fresh-object-mapper silent drops closed —
PortMappings[].ContainerPortRange(issue #1472) andLoadBalancers[].AdvancedConfiguration(issue #1473) —src/provisioning/providers/ecs-provider.ts,tests/unit/provisioning/ecs-provider.test.ts,tests/integration/ecs-fargate/(fixture + verify.sh now pin ContainerPortRange). Found mechanically by the #1445 whole-blob hand-off walk.convertPortMappingsbuilt a fresh SDKPortMappingnaming five members and never wrotecontainerPortRange, so a dynamic host-port range registered with the range silently dropped;convertLoadBalancersnever convertedAdvancedConfiguration, so an ECS blue/green service was created with its alternate target group / listener rules / role silently dropped. The port-range member is now written explicitly and the blue/green block rides the sharedpascalToCamelCaseKeyshand-off (future CFn additions inherit). With the #1445 walk merged, BOTH ECS targets opt into the write-evidence pass in the same change (freshObjectMapper: truewith measured floors — written 233 / scopes 34 / expanding hand-offs 21), the script-header table records TaskDefinition 25->0 and Service 44->0, and the pre-fix six-drops pinning test becomes a pair of real-code probes: stripping either fix from a scratch providers tree re-flags the exact pre-fix findings by name. -
gen-nested-key-coverage: the write-evidence pass follows a WHOLE-BLOB HAND-OFF into a generic key converter, so five more targets can opt in (issue #1445) —scripts/gen-nested-key-coverage.ts,tests/unit/scripts/gen-nested-key-coverage.test.ts,.claude/rules/providers.md,.claude/rules/code-layout.md,docs/_generated/nested-key-coverage.{json,md}regenerated. Nosrc/**change — pure tooling / docs. The gap: the write-evidence pass (#1432, path-scoped by #1448) required a per-member WRITE, and a provider that hands a whole sub-blob to a GENERIC key converter has none to find.ECSProvider.convertLinuxParametersis the clean demonstration —return pascalToCamelCaseKeys(config)deliversCapabilities/Devices/Tmpfs/Swappiness/MaxSwap/SharedMemorySize/InitProcessEnabledand everything beneath them, all correctly wired and all invisible. That is why onlyAWS::CodeBuild::Projectopted in, with 327 would-besame-spellingpaths across the other targets sitting UNMEASURABLE. The fix teachescollectWriteEvidencethe three-step walkgen-handled-property-wiring(#1404) does one level up: SEED — a value read off the DESIRED property bag (HANDOFF_BAG_PARAM_NAMES, DECLARATION-scoped taint); PROPAGATE — the seed handed WHOLE through?:/??/||arms,constbindings, spread-only literals, andthis.f(…)/ free-function / SIBLING-MODULE calls (pascalToCamelCaseKeyslives inagentcore-case-convert.ts, soloadReportsupplies a same-directoryresolveImportSource); DELIVERY — the callee RETURNS the blob and the value is written, withfeedsOnlyComparisonstill refusing a diff-only literal. The collector gainshandoffPoints, and the newexpandGenericHandoffScopesfolds each point into its scope by expanding it through the SDK model's own reference graph (reachableSdkMemberNames;collectSdkInterfacesnow captures the ARRAY ELEMENT ref too, sinceLinuxParameters.tmpfsisTmpfs[]). Getting the discrimination right was the whole job. A callee counts as GENERIC only when it names NO member — anywhere in its body OR in any callee it can reach — sopascalToCamelCaseKeys(whose only write is the computedresult[camelKey]) qualifies whileconvertContainerDefinitions(45 named members),convertLoadBalancers(4) and CloudFront's spread-and-patchconvertToSdkFormat(30+) are all rejected. The TRANSITIVE part is not tidiness: a body-local test accepts the DELEGATING GUARDconvertLog(cfg) { if (!cfg) return cfg; return this.buildLog(cfg); }, becauseconvertLognames nothing and the delivery test is existential — thereturn cfgarm satisfies it whilebuildLogdoes the member-naming work. Tightening delivery to.everyis not the alternative fix (pascalToCamelCaseKeysreturnsresult, a binding to an empty literal). The test means exactly "names no member" and NOT "can only emit keys it read": a FILTERING / RENAME-MAP / PICK converter names nothing and is still credited, which is recorded as known bound (5) and pinned by a test asserting today's behaviour, withglue-provider.ts'srenameRecordKeysnamed as the shape that is one opt-in away. The credit is bounded to the BLOB rather than the enclosing scope:ContainerDefinitionscarries both theLinuxParametershand-off andconvertPortMappings, so a scope-wide wildcard would have hidden a real drop. And the property-bag taint root is load-bearing rather than theoretical — an earlier revision without it treated CloudFront's disable-then-deleteDistributionConfig: config(a config read straight offGetDistributionConfigand re-sent) as a hand-off and cleared 108 of its 110 findings, the exact rubber stamp the issue warns about. Measured before/after (would-besame-spellingpaths with no scoped write evidence): CodeBuild 0/90 -> 0/90, API GW v2 13/13 -> 0/13, ECS TaskDefinition 25/115 -> 1/115, ECS Service 44/54 -> 5/54, CloudWatch AnomalyDetector 29/29 -> 3/29, S3 106/125 -> 104/125, CloudFront 110/112 -> 110/112 — 327 -> 223. The fiveAWS::ApiGatewayV2::*targets opt in at 0 findings (measured 46 written names / 1 non-empty scope / 3 BLOB-CARRYING hand-off points out of 35 raw). The newminHandoffPointsfloor counts EXPANDING points rather than raw ones — a raw floor would be satisfied by the 32 inert scalar forwards a collapsed walk still emits — and is set to 1 rather than the measured 3, because a floor AT the measurement turns every legitimate provider-side change into "parser regression?" instead of the divergences it causes (measured: at 3, all three hand-off probes stopped reaching the classifier), while the collapse mode the floor exists for yields 0.minWriteScopes: 1is recorded as fencing NOTHING on this target — its only non-empty scope isattributes, a cdkd-internalResourceCreateResultfield no audited path consults — rather than left looking meaningful. Two new hygiene tests keep both honest: one bands the write floors against their measured yields, the other derives "walk-dependent" by re-classifying with the hand-off points stripped and requires exactly those targets to declare the walk's floor (so CodeBuild, which reaches 0 without the walk, must NOT). The walk's first run found two REAL silent drops inecs-provider.ts, and they are why ECS stays out:PortMappings[].ContainerPortRange(SDKPortMapping.containerPortRange, never written — issue #1472) andLoadBalancers[].AdvancedConfigurationplus itsAlternateTargetGroupArn/ProductionListenerRule/TestListenerRule/RoleArnmembers, i.e. the whole ECS blue/green deployment block (issue #1473). 44 unmeasurable ECS Service paths collapsed to exactly those 5, which is the pass working; an allow-list entry there would have silenced a genuine bug, so both targets' opt-in is blocked on the provider fix and the six paths are pinned BY NAME in a test. The two remaining shapes are recorded with counts rather than papered over: the BUILDER idiom (const out: any = {}; out.Foo = …; return out;— the members ARE written, just not where the scope index looks; CloudWatch AnomalyDetector 3, most of S3's 104 — issue #1474) and the SPREAD-AND-PATCH forwarder (CloudFront's{ ...config }plus ~30 named patches, rejected by the genericity test on those names; 110 — issue #1475). Recognizing the spread is deliberately NOT done here: it would clear 110 of 112 paths in one step, which is the shape most likely to become a rubber stamp. Tests: 168it(blocks / 174 runtime cases (up from 131 / 137) — 18 synthetic hand-off cases (incl. the delegating guard, a computed-literal member name, the declaration-scoped taint collision that the real CloudFront file cannot fence because BOTH itsconfigbindings come from AWS responses, a sibling-module callee that NAMES members being refused, and the bound-(5) filtering converter pinned as credited) (verbatim forward, const binding, same-file converter, the REAL sibling-module converter with and without the resolver, member-naming and spread-and-patch rejections, non-bag and previous-bag rejections, comparison-only rejection, guard arms, seed-key registration, nested recording), 5reachableSdkMemberNames/expandGenericHandoffScopescases incl. the anti-wildcard assertion, 6 real-repo cases (theLinuxParametersmembers invisible BEFORE the fold and credited AFTER, the real ECS / API GW v2 hand-off point sets, the API GW v2 floors, the measured opt-in table reproduced exactly, and the six ECS drops named), and 6 newspawnSync--checkprobes against a scratch COPY of the real providers tree: bothDefaultRouteSettingsforwards deleted -> exit 1 naming all five members; ONE deleted -> exit 0 (which is how the union-across-write-sites bound gets recorded instead of discovered later); the forward turned into a PARTIAL hand-named literal -> exit 1 naming exactly the four members it stops naming and NOT the one it keeps; the forward routed through the realpascalToCamelCaseKeys-> exit 0; the same shape sourced from a non-bag value -> exit 1; and a collapsed walk -> exit 1 naming the walk rather than 13 bogus divergences. The #1448BuildBatchConfig.ServiceRole/BatchReportModefences are unchanged and still fire. NO AWS integ (offline static analysis). -
AWS::Glue::Table/::Database: AWS-authoredParameterssurvive a full-replace update, and user removals still reach AWS (issue #1461) —src/provisioning/providers/glue-provider.ts,tests/unit/provisioning/glue-provider-roundtrip.test.ts,tests/integration/data-analytics/{lib/data-analytics-stack.ts,verify.sh},docs/supported-resources.md. The bug:updateTablebuiltTableInputpurely from the template and Glue'sUpdateTablereplacesTableInputWHOLESALE, so everyParametersentry AWS itself had written was erased by the first unrelated edit.Parametersis a general bag AWS writes into, and for an Apache Iceberg table the two entries it writes —table_type: ICEBERGandmetadata_location— are exactly what make the table READABLE as Iceberg by Athena / Spark / EMR. Verified live 2026-08-10 in us-east-1: after a fresh create both were populated; after a deploy changing ONLYTableInput.Description,aws glue get-table --query 'Table.Parameters'returnednulland the deploy reported success. The table silently degraded to a plain external table pointing at Iceberg data files. Same exposure for a crawler-writtenclassification,EXTERNAL,comment, and Lake Formation markers. This is the "absent-field removal on a full-replace update API" class (#1160's umbrella), but with AWS-AUTHORED rather than user-authored values — which is why no template-side diff hinted at it and why no unit test could have caught it. The fix reads the live resource immediately before the update (GetTable/GetDatabase) and merges back the AWS-authored entries — the read-merge-write the Cloud Control path gets for free. The merge rule is keyed on "present in NEITHER template side", not on "absent from the desired side": desired wins where the user declared the key; a key absent from desired but present inpreviousPropertieswas REMOVED BY THE USER and stays removed; a key present in neither is AWS-authored and is preserved. The naive form would have made user-authored parameters unremovable — the mirror image of the bug — and would have brokencdkd drift --revert's ability to clear a console-side addition. It keeps the provider on the repo's established clear-on-removal position (docs/provider-development.md§2a, issue #1155); no reset SENTINEL is needed because the API is full-replace, so omitting the key IS the reset.updateDatabasehad the identical shape and is fixed in the same change (DatabaseInput.Parameters— Lake Formation / federated-catalog markers). The other full-replace Glue updates were audited against the SDK models and do NOT have it:JobUpdate.DefaultArguments/NonOverridableArguments,ConnectionInput.ConnectionProperties,WorkflowUpdate.DefaultRunPropertiesandCrawler.Configurationare all user-authored bags AWS does not write into on its own, and preserving a console-side addition there would actively BREAKdrift --revert. The read fails CLOSED: onlyEntityNotFoundExceptiondegrades to "no live parameters" (soUpdateTable's own, more actionable error is not pre-empted); anything else throws aProvisioningErrornamingglue:GetTable/glue:GetDatabase, because silently skipping the merge on a throttle or a permission gap would reinstate the very erasure the read exists to prevent. The throw sits OUTSIDE the updatetryso the catch wrapper cannot re-label it, and the original error rides ascauseso a transient throttle stays retryable by the deploy engine's outerwithRetry. The read is also ordered AFTER the pre-flight validation, so an update refused for a malformed physical id or a missingTableInputissues noGet*at all. Tests: 14 unit cases (AWS-authored entries survive an unrelated edit; per-key removal; WHOLE-block removal, which a per-key test does not cover; a user-CHANGED value taking the user's side; a template that never declaredParameters; an empty live map leaving the payload byte-identical;drift --revertstill clearing a console addition, since that path passes the AWS-current snapshot aspreviousProperties;CatalogIdforwarding; the read not issued on a pre-flight refusal; not-found not masking the real error; the fail-closed throw asserted UNWRAPPED and with noUpdateTableissued; plus the Database twins). Six of them were confirmed to FAIL against a neutralized merge before being trusted. Real-AWS coverage — mandatory here, because the wiped values only exist on a live table: thedata-analyticsfixture gains an UPDATE phase (CDKD_TEST_UPDATE=true). The Iceberg table's ONLY phase-2 change is itsDescription, and verify.sh asserts that edit landed FIRST (otherwise the parameter assertions would pass vacuously against a table nothing updated) and then re-asserts BOTHtable_type == ICEBERGand a populateds3://metadata_locationthrough the same helper used after create. The removal half rides the sibling plaineventstable — phase 1 sets{classification, owner_team}, phase 2 dropsowner_team— so a user-parameter edit can never be confused with Glue's own Iceberg bookkeeping. 3-axis review added six things. (1) A rollback regression the fix would have introduced:rollback-executor.tscalledprovider.update()WITHOUTwithRetryat both itsrevertandrevert-failed-updatearms, unlikedeploy-engine.tsanddrift.ts— so the new pre-read made a throttle fail a rollback op that previously issued no read at all, and the best-effort catch counts that as a failure and moves on, leaving state unreverted. Both call sites are now retry-wrapped; a transient failure on a RECOVERY path is the worst place to add one. (2) A TOCTOU window that is data-affecting, not cosmetic: an Iceberg commit from Spark / Athena / EMR landing between the pre-read and theUpdateTablewould be UNDONE by writing back themetadata_locationcdkd read, pinning the table to an older snapshot — strictly worse than the bug being fixed.UpdateTableRequest.VersionId(whoseConcurrentModificationExceptionis a documentedUpdateTableerror) is now sent as an optimistic-concurrency precondition, with a dedicated error message naming the cause and the re-run remedy instead of a bare SDK exception. It is scoped to updates where the merge ACTUALLY carried live values: when the template declares every parameter, nothing read reaches AWS, so there is no stale value to write back — anddrift --revertis exactly that shape, deliberately overwriting whatever AWS holds, so guarding it would convert an intentional overwrite into a spurious failure.UpdateDatabaseRequesthas noVersionIdmember at all, so the database half keeps the exposure and says so in the docs rather than leaving it implicit. (3) Only the pre-read belongs outside the updatetry— hoistingbuildTableInput/buildDatabaseInputout with it turned aParameters: nulltemplate into a rawTypeErrorwith no resource context; the build + merge moved back inside (neither raises a control-flow class, soupdate-wrap-coveragestays green), and the null value is additionally coerced to "no declared parameters". (4)CatalogIdwas forwarded by the new readers but still not byreadTable/readDatabase— same file, same API, fixed on one side only, so on a cross-account Data Catalogcdkd driftread the account-default catalog and could report the resource gone;readCurrentState's already-declaredpropertiesargument now threads it. (5) Test + integ parity for the Database half, which had 3 unit cases to the Table's 11 and ZERO real-AWS coverage (the phase-2 template left the database untouched): the missing unit analogues landed (whole-block removal, never-declared-Parameters, empty-live, drift-revert, stringification, and theEntityNotFoundExceptiondegradation that mutation-testing proved was uncovered — deleting the line left 35/35 green), and the fixture's phase 2 now mutates the database too. Because an AWS-authored DATABASE parameter cannot be produced from a template (by definition the template never declares it, and a plain Glue database has no Iceberg-marker equivalent), verify.sh writes one OUT-OF-BAND between the phases viaaws glue update-database— indistinguishable to cdkd from any other entry absent from both template sides, which is precisely the merge branch under test. (6)verify.shpinned that AN update landed, not that it was IN-PLACE — a replacement would have satisfied both the Description and freshly-rewritten Iceberg markers, i.e. the exact failure the phase exists to detect dressed up as a pass;Table.CreateTimeis now captured before phase 2 and asserted equal after. Plus nits: optional chaining on the previously-unusedpreviousProperties, conditional-spreadCatalogIdonUpdateTableto match the readers,buildDatabaseInputstringifying its values likebuildTableInputalways did, the phase-specific#609/#1461diagnostics restored to the shared assertion helper, a deliberate (and commented)env -uasymmetry between phase 1 and phase 3, and test arms forparameterKeySet's array / non-object branch and an absent liveParametersfield. One test defect the new tests caught in themselves:mockLiveTable(params, undefined)silently kept the helper's'3'default (a JS default parameter fires on an explicitundefined), making the omit-VersionIdassertion vacuous — the helper now takes an explicitnullsentinel. The semantic price is now documented rather than implicit: a parameter added out-of-band is PERMANENT (preserved on every deploy) and INVISIBLE (post-deployreadCurrentStatefolds it intoobservedProperties, socdkd driftstops reporting it), with the removal paths spelled out;docs/provider-development.md§2b generalizes the price, the TOCTOU rule, and the audit-every-call-site-for-retry rule for the next full-replace provider. A delta re-review then caught three more. (1) The retry wrapper itself was wrong twice, because it did not carry the conventions of the call sites around it. It ignoredprovider.disableOuterRetry— whichCustomResourceProviderandNestedStackProviderBOTH set AND implementupdate()for — so rolling back a Custom Resource UPDATE would re-invoke it on a transient error, deriving a fresh RequestId + pre-signed response URL and stranding the first attempt's response at an S3 key nobody polls: exactly the hang the flag exists to prevent, reintroduced by a fix for an unrelated bug. It also droppedisInterrupted/onInterrupted, which this file's pre-existing wraps already thread, leaving Ctrl-C dead for the full ~47s backoff per op on a recovery path. Both are now routed through oneupdateWithRollbackRetryhelper so the three concerns (retry / opt-out / interrupt) cannot drift apart again, with tests for each. (2) TheVersionIdscoping was wrong and is removed. Sending the precondition only when the merge wrote back live values left the EMPTY-LIVE-READ case unguarded — that update still ships a wholesaleTableInputreplace, so a concurrent commit in the window is wiped, i.e. #1461 surviving its own fix — and its stated rationale did not hold either, because the pre-read runs milliseconds before the send on every update, so a pure template push (drift --revert, the case the scoping was meant to protect) carries a FRESH version too and could never have failed spuriously. It is now sent unconditionally whenever the read returned one, and theConcurrentModificationExceptionmessage is gated on whether a precondition was actually attached so it never claims cdkd refused a write AWS rejected on its own. (3) The guard's premise is now PROVEN against live AWS rather than assumed.UpdateTableRequest.VersionIdis documented only as "the version ID at which to update the table contents" — not explicitly as a precondition — so nothing showed a stale value is rejected rather than ignored, and an ignored token would make the whole guard a placebo that reads as protection in review. A new phase 2b in the fixture reads a table'sVersionId, advances it with an out-of-bandUpdateTable, replays the stale one, and requires AWS to refuse with a concurrency error specifically (any-error would let a shape or auth failure vouch for a guard that does not work); it fails loudly with instructions to REMOVE the guard if AWS accepts the stale write. It runs against the plaineventstable so a failed probe cannot leave the Iceberg fixture half-written. Plus two nits: thereplayFailedOperationstest block got the samebeforeEachmock-clear asreplayRollback(an inlinemockClearleft the documented cross-test leak in place), and both jq payload rebuilds switched from field allow-lists todel(<read-only members>)— an allow-list silently droppedCreateTableDefaultPermissions/TargetDatabase, reproducing inside the test the exact wholesale-replace data loss the test exists to detect. -
gen-nested-key-coverage: the audited unit becomes a PATH and write evidence becomes SCOPED, so a multiply-written member stops vouching for its own siblings (issue #1448) —scripts/gen-nested-key-coverage.ts,tests/unit/scripts/gen-nested-key-coverage.test.ts,.claude/rules/providers.md,.claude/rules/code-layout.md,docs/_generated/nested-key-coverage.{json,md}regenerated. Nosrc/**change — pure tooling / docs. The gap: the write-evidence pass #1432 added was sound for what it claimed, but its evidence was NAME-GLOBAL.collectWrittenMemberNamesreturned one flatSet<string>per provider FILE, so a member written ANYWHERE vouched for every CFn key with that spelling. Measured oncodebuild-provider.ts: 11 of the 55 same-spelling keys had more than one write site (Type9,Location5,Name5, andComputeType/EncryptionDisabled/SecurityGroupIds/ServiceRole/SourceIdentifier/SourceVersion/Status/Value2 each), so deleting the forward write of any one of them stayed silent.BuildBatchConfig.ServiceRolewas the sharpest case — the SIBLING of the member that motivated #1432, cleared by the unrelated top-levelserviceRole:write — which meant the pass really fenced 44 uniquely-named members, not all 55. The root cause was the flat KEY model, not the pass:nestedKeysForTargetyielded de-duplicated NAMES, so top-levelServiceRoleandBuildBatchConfig.ServiceRolewere literally the same audited key. The fix moves both sides. CFn side:nestedKeyPathsForTargetyields one audited unit per (handled top-level, nested key) pair — 488 names become 587 paths across the 11 targets — and every classification carriestopLevelProperty/terminalKeyalongside theTop.Keypath. Provider side:collectWriteEvidencereturns{ written, scopes }, where each written name is indexed to every member written BENEATH the value it is written with, resolvingthis.mapSource(source)calls,const/letbindings,?:/??arms, array elements, spreads and.map(cb)callbacks — the same reach as the #1404 taint walk, because CodeBuild'ssource: this.mapSource(source)and itslet buildBatchConfigdelivered as a shorthand property are both that shape. A path's terminal member is then checked against the scope its top-level maps to. Proved against real code, in both directions: deleting the forwardserviceRole:write from a scratch copy of the realcodebuild-provider.tsmakes the shipped--checkexit 1 withAWS::CodeBuild::Project: BuildBatchConfig.ServiceRole [no-write-evidence], while the name-global set still containsserviceRole— which is what shows the scoping, not an unrelated parse change, is doing the work. The seven smaller review items from the issue body (3) and its comment (4) landed too: a literal that only feeds a comparison or a measurement is not delivery (feedsOnlyComparison, the write-side twin ofgen-handled-property-wiring's diff-is-not-delivery rule); compound assignments (??=/||=/+=) andObject.defineProperty(sdk, 'x', {...})are recognized as writes, with the descriptor literal suppressed sovalue/get/setnever enter the set (valueIS a real CodeBuild member); allow-list entries gained apassesdimension (AllowPass=key|shape|write, defaulting to the deliberate #1378['key','shape']sharing) so an entry rationale'd for a SHAPE verdict can no longer silently clear a futureno-write-evidence, with PATH-first / terminal-name-second lookup and the matched key recorded on the verdict so staleness stays exact; and the reverse-map exclusion now applies tocollectStringLiteralsas well (REVERSE_MAP_FUNCTION_PREFIXES, renamed fromWRITE_EVIDENCE_EXCLUDED_*), removing the asymmetry where the write pass excludedreadCurrentStateand the key pass did not — measured free on the real tree (no key moves into a blocking bucket in either pass, whileBucketOwnerAccess/ResourceAccessRole/Valuestop being credited by the read path alone). A--providers-dir=test seam (mirroringgen-handled-property-wiring) letsspawnSyncdrive the SHIPPED--checkagainst a scratch COPY of the providers tree, which is the only way to reach the write-collector floors —loadReport's handledProperties throw precedes them — and is what closes the "the non-zero EXIT is not asserted" item. Floors gained a per-targetminWrittenMembers(CodeBuild 60, measured 82) and a per-targetminWriteScopes(CodeBuild 15, measured 23), because the name set and the scope index regress independently: a broken value walk leaves every name collected and every scope empty.minWriteScopesdeliberately has NO module-wide default — real-tree non-empty scope counts run 51 (S3) / 34 (ECS) / 23 (CodeBuild) / 21 (CloudFront) / 2 (CloudWatch) / 1 (API GW v2), so any shared floor tight enough to fence CodeBuild would throw "parser regression?" on a correct parse the moment a generic-converter target opts in — the miscalibration the #1449 review caught inMIN_WRITTEN_MEMBERS_PER_PROVIDER. The measured blind-spot table was re-run, not predicted: under scoped evidence the would-beno-write-evidencecounts are CodeBuild 0/90 (still free to opt in), API GW v2 13/13, ECS TaskDefinition 25/115, CloudWatch AnomalyDetector 29/29, ECS Service 44/54, S3 106/125, CloudFront 110/112 — 173 -> 327 in total, purely because an unrelated same-spelled write no longer vouches. Those remain #1445's generic-converter blind spot rather than silent drops. The bound is narrowed, not closed, and the script header now says so with measurements: because the fixture'snestedPropertiescapture is FLATTENED per top-level, a duplicate name inside the SAME top-level still vouches — deletingenvironment: { type: … }orsource: { type: … }from a scratch copy of the realcodebuild-provider.tsleaves--checkat exit 0, covered byenvironmentVariables[].typeandauth.typerespectively, and both are genuine silent drops. Scopes are also keyed by NAME and unioned across write sites, so two unrelatedenvironment: { … }literals share one scope. Closing either needs a per-PATH fixture capture (arefresh-cfn-schemas.mjschange plus an AWS re-capture of every fixture), which is out of scope here; both bounds are pinned by tests so they are recorded facts rather than surprises. Tests: 131it(blocks / 137 runtime cases in the file (up from 88 / 91) — 9 new synthetic collector cases (scoping, binding / call /.mapresolution, conditional + spread, depth, compound assignment,definePropertydescriptor suppression, comparison-only literals both directions), path-model + allow-listpasses+ path-vs-terminal lookup + staleness cases, per-SHAPE real-code anchors (the aggregatewritten.sizefloor cannot see a partial collapse), red-direction probes for both new floors, and 12spawnSync--checkprobes: exit 0 on the real tree, and exit 1 forBuildBatchConfig.ServiceRole,BuildBatchConfig.BatchReportMode, a collapsed write parse, and ONE PROBE PER remaining CI-blocking verdict (no-sdk-member,case-divergence,array-vs-wrapper,definition-member-missing, stale allow-list) plus the writer-mode guard. Review round 2 additionally: peeledawaitin the value walk and let the identifier search climb to the module scope (both fail-loud-on-correct-code directions), movedfilter/findout of the callback-returning set (their callback returns a predicate; the value comes from the receiver), restricted same-file callee resolution tothis.helper(…)/helper(…)so an unrelatedclient.mapSource(x)cannot borrow the provider's mapper, rejected--providers-dir=outside--check(it would have rendered the committed matrix from a scratch tree), re-calibrated everyminNestedKeysfloor to the path unit (CloudFront 100->110, S3 100->140, ECS TaskDefinition 50->105, CodeBuild 40->80, CloudWatch AnomalyDetector 5->25, ECS Service 30->45, API GW v2 Authorizer 0->2) with a hygiene test keeping each within 40% of its measured yield, and added a hygiene test forcing everyfreshObjectMappertarget to declare BOTH write floors — which is what makes the "declared per target" decision defensible rather than aspirational. Two shape floors that were vacuous were replaced with shape-UNIQUE real-code anchors (c['OriginKeepaliveTimeout'] = 5in CloudFront's forwardcompleteRequiredUpdateFieldsfor element access,this.client = new CodeBuildClient(…)for property access). Review round 3 then pinned the four round-2 walk changes synthetically — they are inert on the real tree, so nothing else fences them, and thethis.helper-only restriction guards a FALSE CLEAR on a CI-blocking bucket — rejected any unrecognized flag or positional argument (a--chekctypo, and the space form--providers-dir /tmp, both fell through to the WRITER path and rewrote the committed matrix while exiting 0;--helpnow prints usage), stopped the identifier climb at a PARAMETER of the nearest scope and stopped outer scopes from being descended into sibling functions (a parametercfghad resolved to an unrelated method'sconst cfg), and madeconcatdeliver its arguments as well as its receiver (receiver-only under-credits, which flags correct code). Two further bounds are now recorded rather than glossed: the reverse-map exclusion is PREFIX-only, so a suffix-namedvolumesToCfn/metricsSdkToCfnis not skipped (no live impact — the only opted-in target keeps its reverse map insidereadCurrentState), and the blind-spot counts are described as UNMEASURABLE rather than as confirmed non-drops. NO AWS integ (offline static analysis). -
AWS::Glue::Table:IcebergTableInputis REFUSED at pre-flight instead of forwarded — a deliberate parity divergence (issue #1454) —src/provisioning/providers/glue-provider.ts,tests/unit/provisioning/glue-provider-roundtrip.test.ts,docs/supported-resources.md,docs/troubleshooting.md. The decision: a template whoseOpenTableFormatInput.IcebergInputcarries the nested table spec — under the CFn registry spellingIcebergTableInputOR the SDK spellingCreateIcebergTableInput— now fails BEFORE any AWS call oncreate(), with an error naming the offending property path, both AWS-side rejections verbatim, and the working shape spelled out well enough to copy.update()deliberately WARNS instead of refusing (a review finding): rollback replays from cdkd STATE rather than the template (rollback-executor.tscallsprovider.update(..., previousState.properties)), and a table created by a pre-#1390 build carries the key in its state record because the SDK serializer dropped it silently -- refusing on update would make such a table both un-updatable AND UN-ROLLBACKABLE, and the rollback half has no template-side remedy, only hand-editing state.json. Warning costs nothing there:UpdateTableCommandInputhas noOpenTableFormatInputmember, so update forwards nothing and no silent drop can reach AWS from that path. Both messages share one builder so they cannot drift. One rollback arm is still exposed and is filed rather than papered over:replayRollback's reverse-replacement path revives the OLD resource by callingcreate()withpreviousState.properties, which DOES hit the create refusal — that op fails individually (the executor catches per-op, so the rollback is not wedged) but the old table is not restored. Fixing it cleanly needs a way for a provider to tell a state REPLAY apart from a fresh template provision, which means aCreateContextonResourceProvider.create— a cross-cutting interface change, so it is issue #1463 rather than scope creep here. Why this is not cdkd inventing a validation: CloudFormation does not validate the property either — it forwards it and rolls the stack back — so this IS a parity divergence, and it is recorded as a conscious one in a JSDoc block at the check site. What justifies it is that no working deployment is refused. The live probe on #1408 (2026-08-09, us-east-1, 5 rawglue:CreateTableshapes + 5 CloudFormation stacks) showed the spec is undeployable on BOTH paths, and the raw-API half is the decisive one because cdkd callsglue:CreateTabledirectly rather than going through CloudFormation — "CFn also rejects it" alone would NOT have settled the question. Raw:Location information cannot be null while creating an iceberg tablewithout aTableInput.StorageDescriptor,Table metadata information present at multiple parts of input requestwith one; the spec's ownLocationis never read, so no combination exists. CFn: every variant rolls back withTable metadata is expected only via TableInput or via IcebergTableInputProperties inside OpenTableFormatInput— a property name in NEITHER the registry schema nor@aws-sdk/client-glue, i.e. an AWS-side three-way contract bug. The deployable shape (TableType: 'EXTERNAL_TABLE'+StorageDescriptor+IcebergInput{MetadataOperation: 'CREATE'}, optionallyVersion) is unaffected and keeps its real-AWS coverage in thedata-analyticsfixture (PR #1453). The #1390 rename is REMOVED, not merely bypassed:toSdkOpenTableFormatInputexisted only to rename the CFn spelling toCreateIcebergTableInputso the value reached Glue at all; the refusal makes that branch unreachable and untestable, so it is deleted and the create site now forwards the blob verbatim (every member of the deployable shape is spelled identically in CFn and the SDK). The knowledge it carried is preserved as an explicit WARNING in the check's JSDoc — if AWS ever ships a deployable shape and the refusal is relaxed, the rename MUST be restored in the same change, or #1390's silent drop (the SDK v3 serializer discarding the entire unknown-keyed spec whileCreateTablereports success) comes straight back. Tests: 11 in the round-trip file, replacing the 2 that pinned the now-removed rename. Three review findings shaped them. (1) The create/update tests originally could NOT detect the check being moved INSIDE thetry-- the catch wrapper EMBEDS the original message, so everytoContainstill matched andmockSendwas still un-called; the shared helper now pins PLACEMENT (message starts with the rawAWS::Glue::Table <id>:prefix, carries noFailed to create/update Glue Tablerelabel, and has nocause). (2)toEqualon the forwarded blob could not see a member dropped toundefined-- exactly the #1390 failure mode -- so it istoStrictEqual. (3) Only the OUTER non-object guard was covered; the inner one (IcebergInputitself unresolved) is the likelier real shape, so both levels x string/null/array are now a 6-case table that ALSO asserts the blob reaches AWS verbatim rather than merely that nothing threw. Plus: the SDK spellingCreateIcebergTableInput, the working shape forwardingMetadataOperationtoCreateTableCommand, the working shape accepted by update, the update-side WARNING (asserting the shared body plus the update-specific lead), anddelete()NOT refusing -- destroy of a table that somehow carries the key must stay possible, and hoisting the assert into the shareddeletedispatcher would otherwise break destroy with zero test failures. -
AWS::DynamoDB::GlobalTable: per-index auto-scaling is registered,create()registers auto-scaling at all, and a fresh PROVISIONED table starts atMinCapacity(issues #1419 + #1435) —src/provisioning/providers/dynamodb-globaltable-provider.ts, a newtests/unit/provisioning/dynamodb-globaltable-provider-index-autoscaling.test.ts, and thetests/integration/dynamodb-globaltable/fixture. Shipped as ONE change because either half alone makes things worse, which is what #1435 spells out: creating atMinCapacitywhile no scaling policy exists would pin the table at min forever, strictly worse than the previous over-provision. The auto-scaling class (#1419): the provider registered application-autoscaling targets fordynamodb:table:*only. Nothing anywhere registereddynamodb:index:*, so a per-GSIwriteCapacity: Capacity.autoscaled(...)yielded a correct INITIAL capacity and then droppedMinCapacity/MaxCapacity/TargetTrackingScalingPolicyConfiguration— the index sat at its initial capacity forever. This read as "working" precisely because #1387's integ asserted that initial value and passed. Two sibling gaps had the same root:create()calledapplyAutoScalingDiffZERO times (onlyupdate()did), so a freshly created PROVISIONED table had no scaling policy until some later deploy happened to run an update; and the LOCAL replica's read dimension was never registered by ANY path, becauseupdate()'s replica loops allcontinueon the deploy region — only CROSS-REGION replicas ever got a read target. The fix generalizesapplyAutoScalingDiffto the four DynamoDB scalable dimensions (index targets useResourceId: table/<t>/index/<i>; the policy name keeps AWS's own<metricType>:<resourceId>convention so table-level names stay byte-identical and no already-deployed table orphans its existing policy), adds a purecollectAutoScalingTargets(properties, localRegion)that walks all four asymmetric CFn sources (write dimensions on the local region; read dimensions per-replica, incl. the local one), and reconciles them fromcreate()(all specs, after the table AND replicas are ACTIVE), from a newupdate()step 6b (after the GSI diff, so an added index exists and a dropped one is gone), and fromdelete()(index names read from the liveDescribeTable, not the possibly-stale template). A desired target is re-asserted even when the template did not change — a purely diff-gated register would never backfill the tables this issue is about: on anything deployed before this change the settings are byte-identical on both sides of every later deploy, so the dimension stays unregistered forever. Three review findings shaped how that is done. (1) The first cut confined step 6b to the two never-registered dimensions by a STATIC filter, handing table-level write and cross-region read back to their existing diff gates — which are exactly the gates the backfill argument says never fire, so those two dimensions were permanently excluded from the fix; 6b now covers all four, and double-application is avoided by a DYNAMIC skip-set of what earlier steps applied THIS deploy (a dimension whose gate declined is absent from the set and is therefore still backfilled). (2) Re-asserting everything on every deploy costs2 x (1 + N_gsi x (1 + N_replica))serial calls — over a hundred round trips on a 20-GSI, 3-replica table — so presence is probed first with one batchedDescribeScalableTargetsper region and an already-present, unchanged target is skipped; a failed probe means presence is unknown and everything is upserted, the correct direction to fail. (3)RegisterScalableTarget/PutScalingPolicynow carry a throttle-only retry: every error in this path is swallowed into a WARN, so an un-retriedThrottlingExceptionwould silently re-create the never-registered gap under exactly the burst this change introduces.
Three more review findings hardened the failure paths. The create-side registration is the LAST wiring step and is wrapped so it cannot throw: the partial-create cleanup deletes the table directly rather than routing through delete(), so a target registered before a later wiring step failed would be orphaned with no table left to name it — the leak class this issue exists to close, re-introduced on the failure path. The cross-region teardown reads index names from the TABLE's index list rather than the replica's own GlobalSecondaryIndexes, which AWS may omit for a replica that inherits throughput (its ProvisionedThroughputOverride is documented "if not described, uses the source table's"), leaking every index read target in that region. And a GSI added by the same deploy leaves the TABLE ACTIVE while the index is still CREATING, so step 6b waits for index readiness first — best-effort, since a missed registration self-heals on the next deploy but a throw would fail a deploy whose resources are all correct. The capacity class (#1435): deriveRead/WriteCapacityUnits ended in a fixed SeedCapacity ?? MinCapacity chain, but CloudFormation's precedence is context-dependent — live-verified against a real CFn stack (CdkdIssue1427Control, us-east-1), a TableV2 with MinCapacity: 1 / SeedCapacity: 20 reached CREATE_COMPLETE at WriteCapacityUnits: 1 on both table and index, with NumberOfDecreasesToday: 0 ruling out a scale-down. AWS documents SeedCapacity only for the billing-mode transition, and the registry schema marks Min/MaxCapacity Required: Yes against SeedCapacity's Required: No. So the helpers take a CapacitySource ('min' by default, 'seed' at the three PAY_PER_REQUEST -> PROVISIONED flip call sites and nowhere else); cdkd stops over-provisioning every autoscaled PROVISIONED GlobalTable by the seed-to-min ratio — a silent billing symptom, never an error. Tests: 14 new unit tests plus 5 reworked capacity-precedence ones; the three update() tests were mutation-proofed by disabling step 6b and confirming exactly those three fail. Integ: verify.sh gains step 4c (per-index scalable target + policy asserted against the BASELINE deploy, pinning the create-side half), step 12a (the local replica's read dimension), and step 16a2 (the index target is deregistered by destroy — application-autoscaling is a separate control plane, so DeleteTable alone leaves an orphan a future same-named table inherits), and its step 4b write expectation moved 3 -> 2 to match the CloudFormation semantics above.
-
gen-nested-key-coveragegains a WRITE-EVIDENCE pass, sosame-spellingstops vouching for a fresh-object mapper (issue #1432) —scripts/gen-nested-key-coverage.ts,tests/unit/scripts/gen-nested-key-coverage.test.ts,.claude/rules/providers.md,docs/_generated/nested-key-coverage.{json,md}regenerated. The gap: the critic'ssame-spellingbucket is SILENT — the SDK model has a member at the derived spelling, so nothing is reported. That is sound for a provider that FORWARDS a config blob (the serializer carries the key through) and unsound for one that builds a FRESH SDK object naming each member, where an unnamed sub-key is dropped even though the spellings agree perfectly.AWS::CodeBuild::ProjectBuildBatchConfig.BatchReportModeis the measured case: CFn declares it,@aws-sdk/client-codebuilddeclaresbatchReportMode, andCodeBuildProvider.mapPropertiesrebuiltbuildBatchConfignaming only four of the five members. The critic stayed silent — and STILL did with every occurrence of the SDK spelling renamed away, which is what proved the gap structural rather than a tuning miss. So.claude/rules/providers.mdtelling contributors that "a type insideNESTED_KEY_TARGETScannot regress" was over-promising for the fresh-object shape, which is common (CodeBuildProvider,DynamoDBGlobalTableProvider,ECSProvider). The fix addsfreshObjectMappertoNestedKeyTarget. For a target that sets it, a would-besame-spellingkey must ALSO carry WRITE evidence — its SDK-side spelling appearing as a WRITTEN member name (batchReportMode: …,{ batchReportMode },sdk.batchReportMode = …,sdk['batchReportMode'] = …) — or it lands in the new CI-blockingno-write-evidencebucket. Requiring a WRITE rather than a mention is what scopes evidence to the CFn->SDK direction:readCurrentState's reverse map READS the SDK member, and a read is not a property-assignment name. That is not enough on its own for anexact-style target, where the two spellings are identical and the reverse map's CFn-spelled WRITE would vouch for the forward mapper — #1393 item 2 one bucket over — soWRITE_EVIDENCE_EXCLUDED_FUNCTION_PREFIXESskips reverse-map bodies by word-boundary PREFIX — exact-name matching missed the split reverse maps that really exist (readCurrentStateService/readCurrentStateTaskDefinition/ the sixreadCurrentState*methods inapigateway-provider.ts). Measured withdrawal: 8 names froms3-bucket-provider.ts, 71 fromcodebuild-provider.ts, 42 fromecs-provider.ts(0 under the earlier exact match). The pass is OPT-IN per target, and the opt-in set was measured rather than predicted. Would-be-same-spellingkeys with no write evidence: CodeBuild 0/55, CloudWatch AnomalyDetector 12/20, API GW v2 13/13, S3 19/89, ECS TaskDefinition 22/107, ECS Service 37/48, CloudFront 70/112. Those 173 are not silent drops — they are the pass's blind spot, a GENERIC key converter delivering a whole sub-blob with no member to find (ECSProvider.convertLinuxParametersisreturn pascalToCamelCaseKeys(config), deliveringCapabilities/Devices/Tmpfs/Swappinessat once). Enabling the pass tree-wide would bury a real finding under dozens of false ones, and an allow-list cannot fix it because every entry would read "delivered by a generic converter". CodeBuild measures at exactly 0, so it opts in today for free and the #1386 defect becomes non-regressing; teaching the pass to follow a whole-blob hand-off (the taint walkgen-handled-property-wiringalready does one level up) is issue #1445. Proved against real code, in both directions, per the repo checker rules: deleting ONLY the forwardbatchReportMode:write from the realcodebuild-provider.ts— leavingreadCurrentState'sbbc['BatchReportMode'] = project.buildBatchConfig.batchReportModeintact — makes--checkexit 1 namingBatchReportMode [no-write-evidence], while the SAME regression withfreshObjectMapperremoved exits 0, silent. A write-collector parse floor (MIN_WRITTEN_MEMBERS_PER_PROVIDER) turns a collapsed parse into one legible error instead of 55 bogus divergences. Tests: 22 new (12 collector shape / direction / exclusion probes — incl. the three false-credit shapes an independent review caught: an element-access VARIABLE key, a destructuring ASSIGNMENT at any nesting depth or in a for-of head, and the exact-vs-prefix exclusion miss; 7 synthetic classification cases incl. the loose-literal non-rescue and the lower-first write lookup; and 6 real-code probes — the A/B above, the unregressed-zero with a 50-key coverage floor, the measured withdrawal lists, and theBuildBatchConfig.ServiceRolecase that pins the name-global bound). Known bound, documented rather than papered over: write evidence is a flat per-FILE name set and the audited unit is a key NAME rather than a path, so a member written anywhere vouches for every key of that spelling — 11 of CodeBuild's 55 same-spelling keys have >1 write site, andBuildBatchConfig.ServiceRole(the SIBLING of the motivating member) stays silent when dropped. The pass therefore fences the 44 uniquely-named members. Moving the key model to paths is #1448. -
AWS::DynamoDB::GlobalTable: the per-GSI on-demand reset stops firing on a present-but-unresolvable value (issue #1440) —src/provisioning/providers/dynamodb-globaltable-provider.ts+tests/unit/provisioning/dynamodb-globaltable-provider-gsi-throughput.test.ts. The class: the #1423 per-GSI reset decided "the template DROPPED this member" from the TRANSLATED side, andtoSdkGlobalSecondaryIndexesruns every value throughtoFiniteNumber— which returnsundefinedfor a present-but-unparseable value (an unresolved{Ref: …},'', an object). Such a GSI was therefore indistinguishable from a removal and was answered with the-1sentinel, silently CLEARING the ceiling the template was trying to SET. That is the same silent-wrong-action class the reset exists to remove, and the identical hazard was caught in self-review on the table-level path during #1434's PR (the gate there tests RAW key presence); this closes the per-GSI half, which #1434 deliberately left alone because it needed more than a one-line gate change. The fix addscollectRawOnDemandDeclarations(properties, region)besidetoSdkGlobalSecondaryIndexes— a per-IndexNamemap of RAW CFn key presence walking the SAME two sources the translation does (top-level GSI for the write half; the LOCAL replica's index entry for the read half, with the GSI-level spelling as the documented hand-authored fallback). Keeping the two functions adjacent is deliberate: they must agree on where each member lives, and a divergence between them is exactly what would re-open the bug. A missing entry means "not declared", which lets the reset FIRE — the DESTRUCTIVE direction, NOT a conservative one (the first draft's comment claimed the opposite and review caught it). Two different mechanisms keep that safe: a non-arrayGlobalSecondaryIndexesis refused loudly bytoSdkGlobalSecondaryIndexes, while a malformed per-ENTRY shape is deliberately passed through untouched but carries no usableIndexName, so the reset loop skips it before consulting the map. Be precise about what the guard buys — review probed the PR tip and disproved the obvious claim: it does NOT put the value back on a path that reaches AWS. The coercion happens upstream intoSdkGlobalSecondaryIndexes, so the unparseable value is already gone before the reset loop and NOTHING is sent for that member either way. What the guard removes is the DESTRUCTIVE half; what remains is a no-op — and a silent no-op is its own trap, since the only output was the immutable-field warning further down, which tells the user to RECREATE THE INDEX (useless and alarming advice for an unresolved intrinsic). The suppression therefore now WARNS, naming the member and saying the limit was left unchanged. Review also caught a source divergence that reintroduced the very bug: an already-SDK-shapedgsi['OnDemandThroughput']WINS over the derived members in the translation, so reading the derived spellings reported a member DECLARED that the translation never sends — an explicit{MaxReadRequestUnits: 50}beside a leftoverWriteOnDemandThroughputSettingssuppressed the write reset and left the old ceiling live, the #1160 silent drop re-created by the guard itself. The collector now derives both flags from the explicit block alone when present. A third review pass then folded in the BLOCK-level case: a block that is ITSELF an unresolved intrinsic ({"Fn::If": […]}) is a record with no member, so the member test reported "not declared" and fired the destructive reset — #1440 one level up from where the guard looked.isUnresolvedIntrinsicBlock(all keysRef/Fn::*) routes it into the same suppression + warning, while a genuine{}still counts as a removal. The same pass made the non-stringIndexNameskip EXPLICIT in the modified loop — such an entry had been safe only by a Map identity miss downstream, an accident rather than a guarantee. Tests: 9 units (unresolved intrinsic on the write half; on the read half via the replica entry; the GSI-level read fallback spelling counted as a declaration; the accurate warning; no misleading recreate-the-index advice alongside it; the explicit-block precedence; a block-level intrinsic treated as declared; a genuine empty block still treated as a removal; and a fence that a GENUINE removal still resets, so the guard narrows the reset rather than disabling it). Revert-proofed — neutering the raw-presence guard fails three, and neutering the explicit-block precedence fails its own test, while the #1423 fence stays green throughout. -
AWS::DynamoDB::GlobalTable: the TABLE-level on-demand write ceiling resets when the template drops it; the two REPLICA overrides are proven un-resettable and deliberately left alone (issue #1434) —src/provisioning/providers/dynamodb-globaltable-provider.ts+tests/unit/provisioning/dynamodb-globaltable-provider-gsi-throughput.test.ts. The class: removingWriteOnDemandThroughputSettingsfrom a template never setflatChanged, so noUpdateTablewent out and the old ceiling stayed live in AWS while cdkd reported success — the absent-field-reset silent drop (#1160), the table-level sibling of the per-GSI case #1423 closed. The fix merges per member rather than branching on "the whole block disappeared" (the shape the #1433 review caught: a block can survive with its member dropped, which is the likelier user edit) and is SUPPRESSED while the billing mode is flipping, since dropping the on-demand block on the way to PROVISIONED is the natural template edit rather than a clear request, and step 4's flip owns that call. TheoldBilling/newBillingpair is now resolved ONCE above step 3 and reused by step 4, so the two sites cannot disagree about what "flipping" means. The issue asked for three fields; only ONE turned out to be implementable, and the live probes are what settled it (us-east-1 source + us-west-2 replica, one global table per probe, both torn down — transcripts on the issue). Table-level-1behaves exactly as the issue assumed:{MaxWriteRequestUnits: -1}was accepted andDescribeTablethen returned{MaxReadRequestUnits: 100}, i.e. the dropped member cleared and the untouched sibling preserved, with the reset reading back as ABSENCE never as -1 — verified independently rather than inherited from #1423, since reset semantics are field-specific. The two REPLICA overrides have NO reset mechanism at all:OnDemandThroughputOverride: {MaxReadRequestUnits: -1}is accepted but stored literally as -1 on readback (writing it would be strictly worse than the current no-op — a nonsense value instead of a stale-but-valid one); the documented "empty override means inherit the source table" form{}is accepted and then hangs the table inUPDATINGfor over an hour with the override unchanged and every later call returningResourceInUseException(it left a table AWS refused to delete for having "acted as a source region for new replica(s) ... in the last 24 hours" — treat{}as hazardous in any future probe); an entry carrying onlyIndexNameis rejected outright (ValidationException: There are no actions specified in the Replica Update Action).ProvisionedThroughputOverrideis the same story by construction — the registry schema declaresReadCapacityUnits/MaxReadRequestUnits"minimum": 1, and live-1/0both fail validation while{}returnsInternalServerError. So no in-band sentinel exists, and shipping any of them would trade a silent no-op for a live defect. Tests: 6 units covering the full removal, the PARTIAL removal, value-changed (never the sentinel), stringly-typed coercion, billing-flip suppression, and the no-change redeploy; revert-proofed — neutering the reset fails exactly the first two while the other four stay green, since they pin behavior the change does not alter. Related finding filed, not fixed here: the probes surfaced that the table-level on-demand READ ceiling (Replicas[local].ReadOnDemandThroughputSettings, i.e. the canonicalBilling.onDemand({maxReadRequestUnits})) was never wired AT ALL — dropped on the way in rather than merely un-reset, and the repo's own verbatim-cdk synthfixture already carried the value with no assertion over it (issue #1436, since fixed in the throughput-cluster entry above, which also gave that ceiling the reset arm this entry could only give the write half).
Recently Implemented (2026-08-09):
-
AWS::S3::BucketjoinsNESTED_KEY_TARGETS, and the first run found theEventBridgeConfigurationboolean broken in BOTH directions (issue #1430) —scripts/gen-nested-key-coverage.ts(target + three allow-list entries),src/provisioning/providers/s3-bucket-provider.ts,tests/fixtures/cfn-schemas/AWS-S3-Bucket.json+AWS-S3-BucketPolicy.jsonre-captured,docs/_generated/nested-key-coverage.{json,md}regenerated, and a new unit-test file. Why the target was missing:.claude/rules/providers.mdrequires a provider that FORWARDS a nested CFn config blob to be a critic target, andS3BucketProviderforwards a dozen of them (lifecycle, CORS, replication, notifications, encryption, inventory, analytics, metrics, intelligent-tiering, object-lock, website routing) — but the type was never added, so the six #1388 / #1424 lifecycle defects were all found and fixed by hand in PR #1426. Adding it required re-capturing the schema fixture first: the storedAWS-S3-Bucket.jsonpredated thenestedProperties/definitionShapescapture the critic reads. The audit is now 115 nested keys (26 provider-handled conversions made visible, 3 allow-listed). What it would actually have caught was measured, not predicted: run against the REAL pre-#1426 provider it flagsTransition/NoncurrentVersionTransition/NoncurrentVersionExpirationInDays-- the legacy-singular defect. Issue #1430 predicted a different three and was wrong on all of them:TagFilters(16 literal sites pre-#1426) andTransitionInDays(2) were already named byreadCurrentState's reverse map, so the file-global literal heuristic calls themprovider-handledhowever broken the write path is (#1393 item 2), and rule-levelExpiredObjectDeleteMarkeris not shape-audited at all because@aws-sdk/client-s3spells the interfaceLifecycleRule, leaving CFn'sRuleinunmatchedDefinitions. The strip-probes therefore use only keys whose evidence a realistic single-site regression can actually remove, and each probe asserts the key really left the evidence set before asserting the bucket. The one live bug found, in both directions. CFn'sEventBridgeConfigurationcarries a REQUIRED booleanEventBridgeEnabled(that is exactly whatCfnBucketrenders), while the SDK'sEventBridgeConfigurationis an EMPTY structure whose PRESENCE enables delivery — so the boolean has no SDK member to land on and the critic bucketed itno-sdk-member. (1) Write side: the provider emitted the SDK block whenever the CFn block existed, so an explicitEventBridgeEnabled: falsesilently ENABLED EventBridge notifications — the inverse of the template's intent. It now emits the block only when the boolean is notfalse, coercing the stringly-typed CFn"false"too; an absent or unresolved value keeps the pre-change enable-on-presence behavior, so an unresolved intrinsic can never silently DISABLE something the template asked for. (2) Read side:readCurrentStatereturned the SDK's{}, but cdkd's state baseline holds the CFn spelling anddrift-calculatoronly descends into keys present in state — so the boolean read back as permanently missing on every EventBridge-enabled bucket. It now always emits{EventBridgeEnabled: <bool>}, matching the always-emit placeholder convention every other reader in this provider follows. Allow-listed, not fixed:TableName/TableArn/TableNamespaceare the FIRST real instance of the unreachable-definition false positiveclassifyTargetShapesdocuments — all three live only underMetadataConfiguration/MetadataTableConfiguration, which the provider declares as silent-drop, so those templates are auto-routed through Cloud Control and no SDK forwarding path exists to drop them. The entries name the top-levels to remove them under, and a stale entry fails in both modes. Tests: 9 units in a new file, revert-proofed — restoring either half of the pre-change behavior fails 4 of the 9, including a round-trip case pinning that the read shape is what the write side accepts (otherwise a drift REVERT would re-send a shape the write path misreads). Also corrected here: the #1431 changelog entry and.claude/rules/code-layout.mdboth still said CodeBuild was not a critic target, though #1431 added it in the same PR. - The two
handledPropertieswiring gaps the #1404 critic caught are closed by DECLARING the drop, not by faking a wire (issues #1411 / #1412) —src/provisioning/providers/ec2-provider.ts,src/provisioning/providers/logs-loggroup-provider.ts,scripts/gen-handled-property-wiring.ts(bothHANDLED_WIRING_ALLOW_LISTentries removed),src/provisioning/property-coverage.generated.ts+docs/_generated/handled-property-wiring.{json,md}regenerated,.claude/rules/code-layout.md, and thetests/integration/vpc-nat-gateway/fixture. Both issues proposed "wire it into create"; for #1411 that turned out to be IMPOSSIBLE, and re-deriving it changed the fix.AWS::EC2::NatGateway.MaxDrainDurationSecondsis not aCreateNatGatewayRequestmember (@aws-sdk/client-ec2models/models_1.d.ts), and EC2 ships noModifyNatGateway*operation at all — the ONLY two SDK inputs carrying the field areDisassociateNatGatewayAddressRequest(models_5) andUnassignPrivateNatGatewayAddressRequest(models_7), i.e. it is a per-call drain timeout for RELEASING secondary addresses, which is why the CFn registry schema lists it underwriteOnlyProperties. cdkd'supdateNatGatewayrejects every NatGateway property change, so it never issues those two calls and has nowhere to deliver the value. Modelling it as a replacement trigger (the fallback the issue suggested) was rejected too: the registry schema does NOT list it undercreateOnlyProperties, so recreating a gateway on a drain-timeout change would diverge from CloudFormation and needlessly break the data plane.AWS::Logs::LogGroup.ResourcePolicyDocumenttook option 2 of #1412 for the reason the issue itself gives — it maps to the separateAWS::Logs::ResourcePolicytype whoselogs:PutResourcePolicyis ACCOUNT-scoped, not per-log-group, so owning it from a log group's lifecycle would require inventing an ownership answer (which policy name to claim, what to do on delete when the account-wide policy may be shared, how to resolve two log groups declaring conflicting documents); managing the sibling resource remains the real feature. Both are nowunhandledByDesignwith rationales. Correcting a premise carried in both issues: neither provider setsdisableCcApiFallback, so this does NOT hard-reject such templates via the #614 viability guard — it fires the #614 AUTO-ROUTE, provisioning the resource through Cloud Control API where AWS's own handler applies the value. That is strictly better than both the silent drop and a reject. Note on existing stacks: routing stickiness iscc-api->cc-apiONLY (provider-registry.tsshort-circuits on that value alone), so a resource already recordedprovisionedBy: 'sdk'whose template sets one of these properties DOES re-evaluate and flip to Cloud Control on its next UPDATE —deploy-engine.tsdoes that deliberately. That is the intended outcome (the value starts being applied instead of dropped) and costs no physical-ID churn, since Cloud Control updates in place. Tests: 10 new units across two files (each asserts the property is absent fromhandledProperties, present inunhandledByDesignwith a rationale, reported byfindSilentDropProperties, and thatProviderRegistry.getProviderForroutes to CC for a template setting it and to SDK for one that does not), plus a guard thatCreateNatGatewaynever carries the field and a fence that neither retired property may return tohandledProperties;tests/unit/scripts/gen-handled-property-wiring.test.tsre-pointed its real-code stale-entry probes onto a still-live allow-list entry (IAMAccessKeyProvider#Serial). Revert-proofed: re-adding both properties tohandledPropertiesfails 6 of 10 and makesaudit:handled-property-wiring:checkfail naming both. The critic now reports 0 gaps, 2 allow-listed (down from 4). Integ: thevpc-nat-gatewayfixture gains a SECOND, L1-only private NAT gateway setting the property, so the L2 gateway stays on the SDK path and the two together assert heterogeneous routing in one stack. The value iswriteOnlyPropertiesand no EC2 API returns it, so a read-back-and-compare assertion is structurally impossible;verify.shasserts the routing consequence instead (provisionedBy == 'cc-api'on the drain gateway,'sdk'on the plain one, a vacuity guard grepping the literal out of the stack file so the two cannot drift, both gateways live andavailableon AWS, and both gone after destroy). -
AWS::CodeBuild::Project: seven nestedSource/Environment/Cachesub-keys wired, plus theSourceIdentifiercreate failure (issue #1386) —src/provisioning/providers/codebuild-provider.ts, unit tests, and thetests/integration/ci-cd/fixture. The class:mapSource/mapPropertiesbuild FRESH SDK objects, so every CFn sub-key they do not name is dropped by the SDK serializer with the deploy still reporting success — the #1373 nested-key class, in a provider that the same PR brought under that critic'sNESTED_KEY_TARGETS. Wired (create AND update — both sharemapProperties, and the drop is WORSE on update becauseUpdateProjectis read-modify-write, so an unnamed sub-key was actively WIPED off a live project):Source.Auth.{Type,Resource}->auth,Source.GitSubmodulesConfig.FetchSubmodules->gitSubmodulesConfigandSource.BuildStatusConfig.{Context,TargetUrl}->buildStatusConfig(both synthesized by the CDK L2, so daily-pattern surfaces),Environment.Fleet.FleetArn->fleet,Environment.DockerServer.{ComputeType,SecurityGroupIds}->dockerServer,Cache.CacheNamespace->cacheNamespace. Bonus fix found while wiring:Source.SourceIdentifierwas never mapped at all, and AWS REQUIRES it on everySecondarySourcesentry — so any template using secondary sources failedCreateProjectoutright (a loud failure, not a silent drop, which is why #1386's static sweep missed it).SecondarySourcesroute through the samemapSourcehelper, so all four Source sub-keys cover them automatically (pinned by a test). Read side:readCurrentStatereverse-maps each newly-wired sub-key emit-when-present, so the drift baseline stays symmetric with the AWS-current snapshot — exceptSource.Auth, deliberately excluded becauseBatchGetProjectsechoes it back partially and emitting a partial shape would fire phantom drift on every project that sets it (documented in-code). Not mapped:Environment.HostKernelexists in the CFn registry schema but has NO member anywhere in the installed@aws-sdk/client-codebuilddist-typestree, so there is nothing to map it onto until an SDK bump; it is a NESTED key, so it cannot live inunhandledByDesign(top-level-only); it is recorded instead as aNESTED_KEY_ALLOW_LISTentry with the rationale, which the critic fails on once an SDK bump makes the member reachable.handledPropertiesis unchanged (all seven sit under the already-declared top-levelsSource/Environment/Cache), soproperty-coverage.generated.tscorrectly needed no regeneration. Verified against the authoritative schemas, not the issue text: every SDK member name and nesting read off@aws-sdk/client-codebuild'smodels_0.d.ts, every CFn spelling off the livecloudformation:DescribeTyperegistry schema (the repo's capturedAWS-CodeBuild-Project.jsonfixture stores top-level names only). Live-probed before the fixture was written — a CODEPIPELINE-typed source REJECTS both nested Source sub-blocks ("Git submodules config is not supported for CodePipeline source" / "Source type CODEPIPELINE does not support BuildStatusConfig"), so the pipeline-fed project cannot carry them; the fixture adds a SECOND standalone L1CfnProjecton a PUBLIC GitHub source, the cheapest shape that accepts both and needs no source credential.verify.shasserts all four covered values off a SINGLEbatch-get-projectsblob (so a throttle cannot make some assertions pass while others silently vanish) and gone-checks the new project after destroy.Environment.Fleet.FleetArn(paid reserved-capacity fleet),Environment.DockerServer(billable docker server) andSource.Auth(needs a connected source credential) are unit-pinned but deliberately not in the fixture. Tests: 7 new provider units, binding-proofed by reverting the real provider (5 of the 7 fail without the fix; the 2 that pass both ways are the intentional "omits the members when unset" regression guards). -
AWS::DynamoDB::GlobalTable: GSI throughput translated to theCreateTableSDK shape, so a PROVISIONED GlobalTable with a GSI stops failing outright (issue #1387) —src/provisioning/providers/dynamodb-globaltable-provider.ts, a new unit-test file, and thetests/integration/dynamodb-globaltable/fixture. The class: the provider cast the CFnGlobalSecondaryIndexesblob RAW to the SDK'sGlobalSecondaryIndex[], but the two schemas model per-GSI throughput completely differently and the SDK v3 serializer silently drops unknown members — so a PROVISIONED-billing GlobalTable with a GSI failedCreateTableoutright (AWS requiresProvisionedThroughputon every GSI) and everyTableV2per-GSI on-demand limit vanished.TableV2is the recommended L2 since CDK 2.95, so this is a daily-pattern surface. The issue's own mapping table turned out to be materially incomplete, and re-deriving it from the authoritative schemas changed the fix twice. (1)WriteProvisionedThroughputSettingshas EXACTLY ONE member,WriteCapacityAutoScalingSettings— there is no literalWriteCapacityUnits, because write capacity on a GlobalTable is always auto-scaled;CreateTableneeds a concrete number, so the mapping takesSeedCapacity(the documented "initial provisioned capacity units") beforeMinCapacity. (SUPERSEDED by issue #1435 — see the entry above: the precedence is context-dependent, and CloudFormation creates atMinCapacity;SeedCapacityapplies only to thePAY_PER_REQUEST -> PROVISIONEDflip.) (2) Per-GSI READ capacity is not on the top-level GSI at all — CDK synthesizes it toReplicas[?Region==<deploy region>].GlobalSecondaryIndexes[].Read{Provisioned,OnDemand}ThroughputSettings, so the SDK's singleProvisionedThroughput/OnDemandThroughputobject has to be FUSED from both halves (the GSI-level spellings the schema also permits are honored as a fallback for hand-authored templates). Both facts came from the livecloudformation:DescribeTypeschema cross-checked against a realcdk synthunder both billing modes; the repo's captured fixture stores top-level names only and could not settle it. Call sites:create(),addReplica(), theupdate()replica-modify action, theupdate()GSI diff (both sides are now translated BEFORE diffing, so emittedCreate/Updateactions carry real throughput — a side benefit is that an auto-scaling-only edit, invisible to the DynamoDB API, no longer emits a bareUpdate: {IndexName}that AWS rejects as empty), and one site the issue did not name: thePAY_PER_REQUEST -> PROVISIONEDbilling-mode flip, where AWS requires per-indexProvisionedThroughputin the SAMEUpdateTablecall — without it the fix would have made create work while leaving the flip broken. Deliberately left unmapped, recorded in a JSDoc block rather than dropped silently (this provider has nounhandledByDesignmap):Replicas[].ReplicaStreamSpecification.ResourcePolicyandReplicas[].ResourcePolicy(both needPutResourcePolicy, not anyUpdateTablefield) andReplicas[].GlobalSecondaryIndexes[].ContributorInsightsSpecification(needs a per-indexUpdateContributorInsights).handledPropertiesis unchanged, so no coverage regeneration was needed. Three defects in the first cut were caught by review and fixed here, each mutation-proofed: (a) the billing flip built its index updates from the NEW template but filtered them by the PREVIOUS template's names, so an index the deploy REMOVES got no capacity — yet itsDeleteis issued later, so at flip time it is still a live index on a table becoming PROVISIONED and AWS rejects the whole call; (b) the first attempt at suppressing a false immutable-field warning skipped themodifiedloop wholesale on ANY flip, which silently dropped every per-GSIMax{Read,Write}RequestUnitson aPROVISIONED -> PAY_PER_REQUESTflip (the flip call carries per-GSI fields in one direction only) — the loop now sends the fields the NEW billing mode needs and suppresses only the warning; (c) a non-arrayGlobalSecondaryIndexes(an unresolved intrinsic) collapsed to[]and would have created the table with ZERO indexes while reporting success — the #1387 class one level up — and now throws.WarmThroughputalso rides the update-ADD path, so a GSI added later matches whatcreate()sends for the same template. Tests: 29 in a new file, with both property bags copied VERBATIM from a realcdk synthrather than hand-invented — which is precisely what surfaced the read-capacity-lives-on-the-replica asymmetry that a hand-written fixture would have encoded wrongly. Every call site and guard is mutation-proofed individually: reverting any one of them kills a specific test. Integ: the fixture gains two UNCONDITIONALTableV2s (not gated behindCDKD_TEST_UPDATE, so the baseline deploy exercises the previously-failing create path) — two tables because the billing modes cannot coexist on one. L2 was correct here rather than L1, sinceTableV2exposes every property needed.verify.shreads all four GSI values back plus the two table-level ones, and its step 3 stopped taking "the first GlobalTable in state" (which with three tables would have grabbed an arbitrary one) in favor of selecting by logical-id prefix. -
handledPropertiesWIRING critic (gen-handled-property-wiring) + the two live silent drops its first run caught (issue #1404) —scripts/gen-handled-property-wiring.ts(new, FIFTH codegen'd critic),docs/_generated/handled-property-wiring.{json,md}(new matrix),vite.config.ts+.github/workflows/ci.yml(gen +--checksteps),.claude/rules/code-layout.md. Nosrc/**change — pure tooling / docs. The class:handledPropertiescan LIE.gen-property-coverageproves a property is ACCOUNTED FOR andgen-nested-key-coverageaudits spellings INSIDE a forwarded blob, but neither proves an entry is WIRED —ECRProviderdeclaredImageTagMutabilityExclusionFiltershandled while it reached NO API call, so the pre-flight passed on the declaration alone and the value silently vanished (#1392, fixed in #1406). The critic: per declared property it requires read evidence in one of four AST shapes (element-read/property-read/destructure/table-loop) plus an orthogonaldelegatedtag, CLASS-SCOPED via a taint walk seeded from each method's desired-state parameter and propagated only through calls that pass the bag WHOLE — so a sibling class in the same file, a comment, agetDriftUnknownPathsentry, the declaration itself, and areadCurrentStatewrite-back all fail to vouch (each pinned by a test). Two rules keeptable-loopfrom becoming a rubber stamp, since one syntactic site credits N properties at once: the loop body must DELIVER, not merely compare (EC2Provider.updateSubnet's createOnly guard is a change GUARD, and crediting it smuggled the diff-is-not-delivery disguise back in one level up — the rule withdrew the tag from 46 properties across 8 classes, NONE of which became a gap since all are also read individually;RDSDBProxyProvidershows the discrimination, its immutable-field loop losing credit while itsmutableFieldsloop keeps it), and the table is resolved LEXICALLY from the loop outward (a FILE-wide pool let a table local to one class's method vouch for a DIFFERENT class and let two same-named tables override last-wins —glue-provider.tsreally does declareresultx12). Each wired property recordsseededByso a property wired only from a non-delivery member is visible rather than silently green (0 today, fenced). Two strictness calls were forced by the REAL tree, not by fixtures: a whole-bag forward does NOT blanket-excuse un-read declarations (the first draft's excuse silenced the very #1392 property viahasCdkAutoDeleteTag(properties)indelete(); measured cost 0 of 1063), and apreviousPropertiesread is not evidence — with the in-code JSDoc stating honestly that this is NARROWER than it looks (it does not close the disguise for a singleelement-read, only for helpers reached with the previous bag alone; the TABLE case IS closed by the delivery rule). First-run audit (4 flagged): TWO real silent drops, seeded as tracked KNOWN GAP allow-list entries and FILED rather than fixed here —AWS::EC2::NatGateway.MaxDrainDurationSeconds(issue #1411) andAWS::Logs::LogGroup.ResourcePolicyDocument(issue #1412, already admitted in an in-code comment);IAMAccessKeyProvider#SerialandNestedStackProvider#TemplateURLare rationale'd NOT-A-BUG entries. Stale entries fail in both modes, so wiring a property forces its entry's removal. Proven against real code per the repo's checker rules: reverting the realecr-provider.tsto its pre-#1406 state exits 1 naming the property (a FIRST probe that stripped only the lowercase-preads PASSED — the survivingpreviousPropertiesread cleared it, and that false clean is what drove the exclusion); stripping the realVpcIdreads while leaving the comparison-only guard standing must now REJECT; dropping one name from the real Glue / SQS tables must surface a gap; a class appended to the realglue-provider.tsmust not borrowbuildJobCommonFields's local table. The shipped--checkis driven viaspawnSyncagainst a scratch COPY of the providers tree (--providers-dir=seam), so exit code and failure text are covered without ever writing tosrc/. Tests: 68 (shape units, per-SHAPE real-repo floors — 84 classes / 1063 declared / 43table-loopof which 29 sole-evidence — allow-list stale + per-property keying, and the real-code probe set). NO AWS integ (offline static analysis). -
ModifyInstanceFleetneeds BOTH capacities, and every EMRInstanceTypeConfigsconversion site now has real-AWS coverage (issue #1400) —src/provisioning/providers/emr-instance-fleet-config-provider.ts(update()), new fixturetests/integration/emr-instance-fleets/,.claude/integ-coverage-allowlist.json(theAWS::EMR::InstanceFleetConfigentry removed — it now has an integ). The gap: both existing EMR fixtures are instance-GROUP based, and a cluster's instance-collection type is fixed at create (groups XOR fleets), so no fixture could exercise a FLEET. That left all threeInstanceTypeConfigsconversion sites —EMRClusterProvider.toInstanceFleetConfig(inlineCluster.Instances.{Master,Core}InstanceFleet),EMRInstanceFleetConfigProvider.create(AddInstanceFleet), and the same provider'sModifyInstanceFleetupdate — proven only by mocks. #1383 was precisely a send-side-looks-fine / AWS-silently-discards bug (CFnConfigurationPropertiesvs the SDK'sProperties), and a unit test can prove cdkd SENDS the block but never that EMR ACCEPTED it. The fixture: a fleet-based cluster (master + core inline fleets,Ec2SubnetIdsplural —Ec2SubnetIdis the group form) plus a standalone TASK fleet, each carrying a per-InstanceTypeConfigConfigurationsmarker thatverify.shreads back throughListInstanceFleets(SDK,Marker-paginated). The bug it found on its first run:AddInstanceFleettolerates an absent capacity (AWS defaults it to 0) butModifyInstanceFleetrejects the same payload —"The instance fleet (if-...) should have both targetOnDemandCapacity and targetSpotCapacity specified."The provider forwarded the template verbatim and the SDK v3 serializer omitsundefinedmembers, so EVERY resize of an ordinary On-Demand-only or Spot-only fleet failed. The ordinary CDK template declares exactly one of the two, so this was the common case, not an edge case. Fixed by defaulting the undeclared side to0, which also matches CFn desired-state semantics (an omitted capacity means zero) and the provider's owntargetCapacity()helper and delete-path scale-to-0, both of which already sent both members. The pre-existing unit tests all passed aBASE_PROPScarrying BOTH keys — which is exactly why the suite agreed with the bug; the added regression test uses the real one-sided shape and asserts both members are PRESENT on the wire (anObject.keyscheck, sincetoMatchObjectpasses on an absent key whose expected value isundefined). A generalization of memory ruleupdate_api_stricter_than_create_probe_payload: an AWS update API can REQUIRE a field its create counterpart defaults. - Shape pass for the nested-key critic:
{Quantity, Items}wrapper + definition-placement divergences mechanically enforced (issue #1378) —scripts/gen-nested-key-coverage.ts(shape pass),scripts/refresh-cfn-schemas.mjs(extractDefinitionShapes— per-definition member -> terminal type kind,$ref-resolved + cycle-guarded, top-level block under the reserved#topkey; plus the rider--help/ unknown-flag guard — an unrecognized flag previously fell through to a silent FULL ~135-type re-fetch), target fixtures re-captured,docs/_generated/nested-key-coverage.{json,md}extended. The gap: the #1373 key pass is structurally blind to divergences whose spelling exists SOMEWHERE in the SDK model — the CloudFront bare-array-vs-{Quantity, Items}wrapper family (previously a hand-maintainedQUANTITY_ITEM_FIELDSlist a NEW AWS array member would silently miss) and relocated/renamed members like theCachedMethodssibling-vs-nested placement (the hardest #1370 member). The pass: SDK interfaces are parsed with member type kinds (collectSdkInterfaces; aQuantity-bearing interface is a wrapper), and two new CI-blocking buckets fire when neither provider-named (dot-segment-expanded literals — the'ForwardedValues.Headers'path idiom) nor allow-listed:array-vs-wrapper(a CFnarraymember whose same-spelled SDK members are all wrapper refs) anddefinition-member-missing(a CFn definition member same-spelling an SDK member globally but missing from the same-named SDK interface). Keys with no same-spelled SDK member anywhere stay the key pass's domain (no double-reporting);ambiguousshapes and unmatched definitions stay visible non-blocking. First audit: no live bug — 62 clean bare-array pairs, 17 provider-handled re-shapings (the whole QUANTITY_ITEM_FIELDS family +CachedMethods+GeoRestriction.Locations), one new allow-list entry: legacyS3Origin, which the key pass could never see because the StreamingDistribution API still carries a same-spelled member — the definition pass catching it is the pass working as designed. Proven against real code: full-word-strippingAliasesfrom the REAL provider makes--checkexit 1 namingAliases [array-vs-wrapper](live probe + permanent unit probes for both buckets; the interface parse shares the SDK-member floor so a parser collapse fails loudly). Tests: +19 (shape-pass synthetic buckets incl. segment-expansion credit and per-key dedup,extractDefinitionShapeswalker, real-repo shape fences + floors, real-code probes,--helpguard spawn tests). Nosrc/**changes — pure tooling/fixtures/docs. - Nested CFn->SDK key-divergence critic (
gen-nested-key-coverage) + the two live bugs its first run caught (issue #1373) —scripts/gen-nested-key-coverage.ts(new, fourth codegen'd critic),scripts/refresh-cfn-schemas.mjs(extractNestedPropertyNames— per-top-level-property nested name capture,$ref-resolved + cycle-guarded, added to the fixtures),docs/_generated/nested-key-coverage.{json,md}(new matrix),vite.config.ts+.github/workflows/ci.yml(gen +--checksteps),src/provisioning/providers/cloudfront-distribution-provider.ts,src/provisioning/providers/ecs-provider.ts, SDK bumps@aws-sdk/client-cloudfront/client-ecs^3.1017->^3.1105. The class: the AWS SDK v3 serializer silently drops unknown keys, so an SDK provider forwarding a nested CFn config blob loses every key whose spelling it does not convert — write-side silent drops the top-level-onlyproperty-coveragepre-flight cannot see, previously found only by live failures (#1165/#1167 ECS, #1160 API GW v2, #1304MetricTimeZone, #1370 CloudFront x5). The critic: per declared target (CloudFront Distribution, CloudWatch AnomalyDetector, API GW v2 Api/Stage/Integration/Route/Authorizer, ECS Service/TaskDefinition), diffs the fixture's nested CFn key names for the provider's OWN handled top-levels against the SDK client model's member names (TS Compiler API overdist-types/models), with the provider's AST-level string literals as evidence of explicit handling;exactvslower-firstkey style per target; bucketssame-spelling/provider-handled/allow-listed/case-divergence(blocks CI, names the SDK near-miss) /no-sdk-member(blocks CI); per-targetminNestedKeys+ per-client SDK-member floors so a parser regression fails loudly; stale allow-list entries fail in both modes (an SDK bump that makes an allow-listed key reachable forces the entry's removal). Proven against real code per the repo's checker rules: re-introducing the #1370AcmCertificateArnrename into the REAL provider exits non-zero naming the key (live probe + permanent unit-test probes). First-run audit (13 flagged): TWO live bugs fixed here — (1) CloudFrontOriginCustomHeaderswas never renamed to the SDK'sCustomHeaders, so origin custom headers were silently dropped on create AND actively WIPED on update by #1371's required-field fill (CustomHeaders: {Quantity: 0}); now renamed + Quantity-wrapped inconvertOrigin, with the inverse restoring the CFn spelling for drift (note: distributions deployed BEFORE this fix haveobservedPropertiesbaselines recording the old read-back keyCustomHeaders, so their first post-upgradecdkd driftreports a one-timeOriginskey-rename drift until the next deploy ordrift --accept— same accepted tradeoff as #1372'sOriginSSLProtocolsrestore); (2) ECS TaskDefinitionS3FilesVolumeConfiguration(the new S3 Files volume) was not mapped inconvertVolumesat all AND is unreachable by the mechanical first-letter flip — the SDK member is the irregular all-lowercase-prefixs3filesVolumeConfiguration; now mapped both directions (write +volumesToCfnread-back). Eight keys (CloudFrontCacheTagConfig; ECS Service deployment-lifecycle members; TaskDefAccessPointArn/FileSystemArn) existed only in newer SDK models — resolved by the client bumps; three legacy pre-2012 CloudFront members (CNAMEs/CustomOrigin/DNSName) are allow-listed with rationales. The ECS Service fixture re-capture also surfaced AWS's new top-levelMonitoringproperty (registered in_todo-backfill.jsonper the property-coverage backfill flow). Shape-level divergences ({Quantity, Items}wrappers, sibling-vs-nestedCachedMethods) are v1-out-of-scope, recorded on #1373. Tests:tests/unit/scripts/gen-nested-key-coverage.test.ts(bucket units, capture-walker units, real-repo floors + fences pinning the #1370/#1373/#1304-fixed keys asprovider-handled, real-code regression probes), provider units for both fixes (ECS irregular-member mapping incl. the flip-spelling negative; CloudFront rename+wrap surviving the update fill, read-side CFn-spelling restore). - CloudFront Distribution: CFn -> SDK casing/shape maps + UpdateDistribution read-modify-write merge (issues #1370, #1371 — both externally reported) —
src/provisioning/providers/cloudfront-distribution-provider.ts, plus unit tests intests/unit/provisioning/cloudfront-distribution-provider.test.tsand an UPDATE phase + casing assertions added to thetests/integration/s3-cloudfront/fixture. (#1370)convertToSdkFormatpassed mostDistributionConfigkeys through verbatim, but threeViewerCertificatemembers (AcmCertificateArn/SslSupportMethod/IamCertificateId) and top-levelIPV6Enableddiffer in ACRONYM CASING between the CFn schema and the CloudFront API (ACMCertificateArn/SSLSupportMethod/IAMCertificateId/IsIPV6Enabled), andRestrictions.GeoRestrictioncarries a bare CFnLocationsarray where the SDK wants{ RestrictionType, Quantity, Items }— so a custom-domain distribution failed to create ("Your ViewerCertificate is missing one of ACMCertificateArn, IAMCertificateId, or CloudFrontDefaultCertificate"), IPv6 silently ended up disabled, and a geo restriction never reached AWS. Both directions are now mapped (convertToSdkFormat+ theconvertToCfnFormatinverse, socdkd driftcompares in CFn spelling with no phantom drift). The pre-flight property-coverage check cannot catch this class (it compares TOP-LEVEL properties only;DistributionConfigis handled) — the nested-sub-property audit remains tracked under the #1160/#1225 umbrella. (#1371)update()documented itself as merging the current config but actually sent the template'sDistributionConfigverbatim (onlyCallerReferencewas reused), whileUpdateDistributionis a read-modify-write API expecting the COMPLETE config — so EVERY update of a distribution failed on the first required-on-update member the template legitimately omits ("WebACLId is missing for the resource"). The newmergeUpdateConfigmerges per top-level member: template is the authority for members it carries, the live config fills members it never templated, and a member REMOVED since the previous template resets to its CloudFormation default (REMOVAL_RESET_DEFAULTS, sourced from the registry schema'sdefaultannotations + empty-list wrappers + the documented Logging-off shape) — members with no documented default (e.g.IsIPV6Enabled/Staging) keep their live value with a WARN instead of a silent guess.convertToSdkFormatalso stopped mutating its input's nestedLoggingobject (state-sourcedpreviousPropertiesnow flow through it). The live UPDATE run then surfaced a SECOND required-field layer: the API accepts SPARSEOrigins/ cache-behavior sub-shapes on CREATE but rejects them on UPDATE ("The 'OriginCustomHeaders' field is missing" / "OriginReadTimeout is required for updates" / "The parameter SmoothStreaming flag is missing" / field-level-encryption-id / allowed-method-settings / lambda-function-associations), somergeUpdateConfignow runscompleteRequiredUpdateFields— an absent-only fill of the empirically probed required set (per origin:OriginPath'' +CustomHeadersempty + CustomOriginConfig read/keepalive timeouts 30/5; per cache behavior:SmoothStreamingfalse,FieldLevelEncryptionId'',LambdaFunctionAssociations/FunctionAssociationsempty,TrustedSigners/TrustedKeyGroupsdisabled,AllowedMethodsGET/HEAD default with nestedCachedMethods) — validated by driving the exact payload to a liveUpdateDistributionsuccess. The probe also exposed two more members of the casing/shape family, fixed in both directions: the CFn template spellingCustomOriginConfig.OriginSSLProtocols(capital SSL) never matched the converter'sOriginSslProtocolslookup (the protocol list was silently dropped on every create), and the CFn SIBLINGCachedMethodsarray was never nested inside the SDK'sAllowedMethodswrapper (revertCacheBehaviorhas always hoisted it back out — the forward direction was simply missing). The first live run of the extended fixture immediately caught a third defect the shape fix newly exposed: CloudFront does not preserve the submitted order ofGeoRestrictioncountry codes (template[JP, US]read back[US, JP]), so the positional drift compare fired guaranteed phantom drift on any multi-country restriction —getDriftUnorderedPathsnow declaresDistributionConfig.Restrictions.GeoRestriction.Locationsas an unordered set (the FSxWindowsConfiguration.Aliasesprecedent, issue #1096 mechanism). Live-verified end-to-end by the extendeds3-cloudfrontinteg: create assertsIsIPV6Enabled=true+ the geo allowlist reached AWS, drift reports clean on the fresh deploy, the new Phase 1.5 re-deploys withCDKD_TEST_UPDATE=true(comment + geo change) through the merge — a phase that failed unconditionally before #1371 — and destroy completes clean.
Recently Implemented (2026-08-03):
- The
cdkd rollbackplan preview stops unwinding a record for a delete it will refuse; deadsupportsFinalSnapshotremoved (issue #1368) —src/cli/commands/rollback.ts,src/provisioning/final-snapshot.ts. Before: #1366 taught the plan LABEL to saythe rollback will REFUSE itfor a Snapshot shape cdkd cannot snapshot, butapplyPlanToPreview/applyFailedPlanToPreviewstill deleted that record from the preview state — and the preview state is what the NEXT (older) journal segment's plan is classified against, so an older segment's real work was downgraded toskip — already revertedin the one preview the user reads before typingy. Second-order: the per-item route stamping (#1366) reads the same preview state, so the dropped record also made a later item fall back to the journaled route — the #1366 defect one layer up. Now: both appliers takeskipFinalSnapshot(as the label functions already did) and consult the SAMErefusesFinalSnapshotpredicate with the SAME route, so the label and the previewed state cannot disagree by construction; under--skip-final-snapshotnothing is refused and the unwind is unconditional, matching the executor. Also:supportsFinalSnapshothad zerosrc/callers (never re-exported fromsrc/index.ts, so not a public-API break) and was deleted; its test block is re-homed onto the type SETS as a STRONGER fence — the union pinned against the literal CloudFormation-documented Snapshot-capable list, plus an explicit DISJOINTNESS assertion that nothing had before (load-bearing since #1366:finalSnapshotMechanismtests the atomic set first, so a type in both would silently take the atomic arm and never reach the pre-delete snapshot). Tests: 4 MULTI-SEGMENT CLI cases (a refused completed CREATE and a refused--revert-failedCREATE each keep their record so the older segment is not mislabelled;--skip-final-snapshotand a snapshottable shape both still unwind — the carve-out is refusal-only) + 3 re-homed set fences. Binding-proofed: neutering the carve-out fails 2, and addingAWS::EC2::Volumeto the atomic set fails the union + disjointness fences (and the #1366 matrix cases). - Rollback events report the route the delete took, and the plan preview stops promising a snapshot it will refuse (issue #1366) —
src/provisioning/final-snapshot.ts(new purefinalSnapshotMechanism(type, route)->atomic-delete-parameter/pre-delete-snapshot/refuse-cc-routed/refuse-unsupported-type, plus therefusesFinalSnapshotpredicate),src/deployment/rollback-executor.ts(prepareCreateRollbackFinalSnapshotswitches on that matrix instead of re-deriving it; the four CREATE-rollback arms emit the EFFECTIVE route; plan items carryeffectiveProvisionedBy),src/cli/commands/rollback.ts(sharedsnapshotNotehelper for both label functions). Before: (1) both delete sites resolved the routing layer viaeffectiveProvisionedBy(state record first) and routed the provider lookup with it, but the emittedROLLBACK_RESOURCE_*event still carriedop.provisionedBy— so when a legacy journal entry disagreed with the state record,cdkd eventsnamed a layer the delete did not use; (2)actionLabel/failedActionLabelrendered[DeletionPolicy Snapshot — final snapshot, then delete]for EVERY Snapshot-policy resource, including the cc-api-routed atomic types and no-mechanism types the executor was about to REFUSE, and the preview dropped their records as if the delete would happen. Now: the event reports the route actually used (resolved BEFORE the record is dropped on the orphan arms, where the authoritative side is about to vanish), and the preview reads the same matrix the replay runs — a shape that will be refused is labelledcdkd cannot snapshot this resource; the rollback will REFUSE it (re-run with --skip-final-snapshot to delete without one).--skip-final-snapshotstill wins over both (nothing is refused under the opt-out). Design note: the refusal verdict lives in the LABEL layer, not as a newrefuse-*action kind from the classifier — whether a Snapshot shape is refused also depends on--skip-final-snapshot, a CLI flag the pure classifier deliberately cannot see, and the label functions already take it. Tests: 4 matrix units infinal-snapshot.test.ts(per-route atomic polarity, route-agnostic pre-delete types, unsupported arm, the predicate pinned in both directions), 7 inrollback-executor.test.ts(event route on completed delete / orphan / failed delete / failed orphan — each with the journal and the record DELIBERATELY disagreeing, which is the only shape that can observe the bug — plus the two plan-stamping cases and the legacy fallback), 5 CLI plan-label units. Binding-proofed three ways: neuteringrefusesFinalSnapshotfails 4, reverting the event route fails 4, reverting the plan stamping fails 4. Scoped out:deploy-engine.ts'sprepareFinalSnapshotForDeletestill carries a third copy of the matrix — routing it through the new helper is the obvious consolidation but that file is in theinteg-destroy+integ-broadgate scope, so a pure refactor there would force a broad real-AWS integ; left for whenever that file is next touched for a behavior reason. -
--revert-failed's delete of a FAILED in-flight CREATE now honorsDeletionPolicy(issue #1362) —src/deployment/rollback-executor.ts(two newFailedOpActionKinds —orphan-failed-create-retain/delete-failed-create-with-final-snapshot;classifyFailedOpreads the CURRENT state record'sdeletionPolicy; the delete branch resolves the routing layer ONCE viaeffectiveProvisionedByand shares it between the snapshot gate and the provider lookup;prepareCreateRollbackFinalSnapshotwidened to a structural op type so both sibling paths run the SAME mechanism matrix),src/cli/commands/rollback.ts(plan labels for both new actions,skipFinalSnapshot-aware like the completed-op labels; preview state drops the record for both). Before: thedelete-failed-createbranch read NODeletionPolicyat all — aSnapshot-policy resource whose CREATE failed AFTER AWS provisioned it (a physical id is recorded, which is exactly what the action requires) was deleted with no final snapshot and no refusal, and aRetain-policy one was deleted outright while the COMPLETED-CREATE path orphaned it. Now: one matrix for both paths —Retainleaves the resource in AWS and drops the record,Snapshotsnapshots then deletes (atomic delete parameter on the SDK route,createPreDeleteFinalSnapshotfor the pre-delete types, REFUSAL for a cc-api-routed atomic type or any other Snapshot shape),RetainExceptOnCreate/Delete/ absent delete plainly. TheRetainarm is a deliberate scope extension beyond the issue text (recorded on the issue before implementation): fixing onlySnapshotwould leave the classifier readingdeletionPolicyfor one value and ignoring it for another, and CloudFormation applies the policy to a failed create's rollback delete too — which is whyRetainExceptOnCreateexists as a separate value to opt OUT of exactly that. Why strict (refuse) rather than tolerant (warn-and-plain-delete) for a shape that cannot be snapshotted: a refusal is a per-op failure, so the journal is KEPT and the op stays inremainingFailedOps— a half-created resource that is not snapshot-capable yet (an RDS instance rejects a final-snapshot delete whilecreating) becomes snapshot-able once it settles, so a re-run completes the job, and--skip-final-snapshotremains the explicit opt-out. The tolerant option's failure mode is unrecoverable by comparison. Tests: 15 new units intests/unit/deployment/rollback-executor.test.ts(classifier per policy incl. the mismatch guard running FIRST, Retain-orphan, atomic+SDK identifier threading withattemptedPropertiesstill flowing, pre-delete snapshot-before-delete ordering, snapshot failure aborting the delete AND keeping the op for a re-run, cc-api refusal, unsupported-shape refusal,--skip-final-snapshotcovering the refused shapes but NOT overridingRetain, routing-source alignment) plus 4 CLI units intests/unit/cli/commands/rollback.test.ts(identifier reaching the delete through the real command, both flag polarities,Retainissuing no delete at all, and the plan labels). Binding-proofed: reverting the two classifier lines fails 13 of them. Docs: cli-reference (DeletionPolicy: Snapshotsection,cdkd rollbackflag table + limitations),.claude/rules/code-layout.md. - A rolled-back CREATE under
DeletionPolicy: Snapshotis snapshotted then deleted, not orphaned (issue #1358) —src/deployment/rollback-executor.ts(newdelete-with-final-snapshotRollbackActionKind;classifyRollbackOpsplitsRetainfromSnapshot; newprepareCreateRollbackFinalSnapshotmirroring the deploy engine'sprepareFinalSnapshotForDeletemechanism matrix;RollbackExecutorContextgainsfinalSnapshotClients+skipFinalSnapshot),src/deployment/deploy-engine.ts(threads both fromDeployEngineOptionsinto the executor context),src/cli/commands/rollback.ts(--skip-final-snapshot+ stack-region-pinnedAwsClients),src/cli/options.ts(doc comment). Before:classifyRollbackOpmapped BOTHRetainandSnapshottoorphan-retain, so a deploy that failed after creating aSnapshot-policy resource LEFT IT IN AWS and dropped it from state — an untracked, billing resource, reported as a clean rollback (Leaving Cache (AWS::ElastiCache::ReplicationGroup) in AWS (DeletionPolicy: Retain) — removed from state). Deliberate when written (cdkd could not create a final snapshot at all), obsolete oncesrc/provisioning/final-snapshot.tsshipped (#1352 / #1353). Now: CloudFormation semantics — rolling a CREATE back IS a delete, soSnapshottakes the final snapshot and then deletes: atomic delete parameter for the SDK-routedATOMIC_FINAL_SNAPSHOT_TYPES,createPreDeleteFinalSnapshot+ wait forPRE_DELETE_SNAPSHOT_TYPES, and a REFUSAL (counted as a per-op failure, so the segment is not popped and the journal is kept for a re-run — never a silent fall-back to orphaning) for a cc-api-routed atomic type or any other Snapshot-tagged shape.cdkd rollback --skip-final-snapshotis the data-loss opt-out;Retain/RetainExceptOnCreate/Deleteare unchanged. The cc-api test uses the CURRENT state record'sprovisionedBywith the journaled op's as the legacy fallback (sharedeffectiveProvisionedByhelper, also used by #1354'srollbackFinalSnapshotId). The delete-of-the-NEW-resource direction (UpdateReplacePolicy, #1354) keeps its bounded atomic-only scope — that delete is load-bearing for same-name re-creation. Tests: 13 new units intests/unit/deployment/rollback-executor.test.tsplus 4 CLI-wiring units intests/unit/cli/commands/rollback.test.ts(the flag actually reaching the executor context, the plan label under both polarities, and the option being declared on therollbacksubcommand itself — the #1097 failure class) (classifier split; atomic+SDK threads the identifier; atomic+cc-api refuses via the state record AND via the op fallback; pre-delete type snapshots before deleting with the context clients; pre-delete snapshot failure aborts the delete; unsupported type refuses;--skip-final-snapshotplain-deletes incl. the otherwise-refused shapes;Retainstill orphans;RetainExceptOnCreatestill deletes). Binding-proofed: reverting the classifier line fails 9 of them. The command installs a STACK-REGION-PINNEDAwsClientsas the process-global for the replay (mirroringdestroy-runner.ts's cross-region destroy) and restores the original in itsfinally: pinning only the snapshot would be worse than not pinning it at all, since a--stack-regionrun would then take a real, billable snapshot and fail the delete that follows. Docs: cli-reference (DeletionPolicy: Snapshotsection +cdkd rollbackflag table / limitations, incl. the re-run snapshot-cost note for the name-keyed types),.claude/rules/{code-layout,providers}.md. Scoped out at the time, shipped since:--revert-failed's delete of a resource whose CREATE FAILED mid-flight was still policy-unaware — closed by issue #1362 (entry above). -
AWS::EC2::Volumeimmutable-property changes now drive a REPLACEMENT (issues #1356 / #1357) —src/analyzer/replacement-rules.ts,src/provisioning/stateful-types.ts,tests/integration/deletion-policy-snapshot/**. Before: changing an immutable EBS volume property (AvailabilityZone/AvailabilityZoneId/Encrypted/KmsKeyId/OutpostArn/SnapshotId) was classified as an in-place UPDATE and AWS rejected the deploy (Volume properties other than AutoEnableIO, type, size, and IOPS cannot be updated), where CloudFormation replaces the volume. Root cause was NOT a cdkd logic bug: thecreate-only-properties.tsCFn-schema fallback found nothing because the AWS registry schema for this type declares nocreateOnlyPropertiesat all (live-verified 2026-08-03), and the hand-authoredReplacementRulesRegistryhad no entry. Now: a hand-written rule classifies the six immutable properties as replacement and theModifyVolume-mutable set (AutoEnableIO/Iops/Size/Throughput/VolumeType/Tags) as explicitly in-place; the three remaining schema properties (MultiAttachEnabled/SourceVolumeId/VolumeInitializationRate) are deliberately left unclassified rather than guessed at, so the schema fallback still applies to them and no mutable property is ever mistaken for a replacement.AWS::EC2::Volumealso joinsSTATEFUL_TYPES, so the deploy engine's property-driven replacement path demands--force-stateful-recreationbefore destroying a volume's data — the change is therefore "deploy fails with an AWS error" → "deploy refuses until you confirm the data loss", never a silent delete. Tests: newreplacement-rules-ec2-volume.test.ts(per-property replacement / in-place polarity,isClassifiedpinning both directions, stateful guard). Integ: thedeletion-policy-snapshotfixture regains the replacement phase #1354 had to drop —CDKD_TEST_REPLACE=trueflipsVolumeKeep's AZ, and the run asserts the OLD volume is gone, a completed final snapshot of it exists (UpdateReplacePolicy: Snapshothonored at the engine's create-first cleanup delete site — the live coverage #1357 asked for), and the replacement landed in the expected AZ.covers: AWS::EC2::Volume. -
DeletionPolicy: Snapshotcoverage completed +UpdateReplacePolicy: Snapshothonored (issues #1353 / #1354) —src/provisioning/final-snapshot.ts(RedshiftCreateClusterSnapshot+ ElastiCache replication-groupCreateSnapshotpre-delete implementations behind the newcreatePreDeleteFinalSnapshotdispatcher, name-prefix idempotent reuse viafinalSnapshotNamePrefix, per-service 50/255-char identifier caps),src/utils/aws-clients.ts(redshift/elastiCachegetters),src/deployment/deploy-engine.ts(sharedprepareFinalSnapshotForDeletegate reused by the destroy DELETE branch AND all four replacement / recreate delete sites —--replacedelete-first,--recreate-via-*, create-first cleanup, update-not-supported fallback),src/cli/commands/destroy-runner.ts,src/deployment/rollback-executor.ts(rollbackFinalSnapshotId: rollback's delete-new honors the atomic SDK-routed shape only — plain delete elsewhere is a recorded scope decision, since delete-new is load-bearing for same-name re-creation). Before:AWS::Redshift::Cluster/AWS::ElastiCache::ReplicationGroupunderDeletionPolicy: Snapshotwere refused outright (#1352's fail-safe), andUpdateReplacePolicy: Snapshotold resources were plain-deleted after the stateful guard. Now: the FULL CFn-documented Snapshot-capable type list is honored on destroy, template-removal, and replacement paths; refusals remain only for cc-api-routed atomic types and Snapshot on types CFn itself would refuse. Tests: ~60 dedicated units (module incl. Redshift/RepGroup creators + dispatcher fail-closed drift guard, engine replacement-fallback polarities, runner dispatcher routing, rollback helper). Integ: newdeletion-policy-snapshot-heavyfixture (single-node Redshift + 1-node Redis replication group, ~35 min) proves both pre-delete implementations against real AWS. The replacement sites keep unit-only coverage: a planneddeletion-policy-snapshotreplacement phase (AZ flip) could not run because cdkd does not classify anAWS::EC2::VolumeAZ change as a replacement — the AWS registry schema for that type declares nocreateOnlyProperties(root cause filed as #1356; the integ follow-on is #1357). Docs: cli-reference table + notes, README, troubleshooting, rules. -
DeletionPolicy: Snapshotis now honored on every delete path (issue #1352) —src/provisioning/final-snapshot.ts(new),src/provisioning/region-check.ts(DeleteContext.finalSnapshotIdentifier),src/deployment/deploy-engine.ts(DELETE branch),src/cli/commands/destroy-runner.ts, provider deletes inrds-provider.ts/neptune-provider.ts/docdb-provider.ts/elasticache-provider.ts,--skip-final-snapshotflag ondeploy/destroy/state destroy(src/cli/options.ts, threaded throughnested-stack-context.tsfor recursive child destroys). Before:shouldRetainResourceonly honoredRetain/RetainExceptOnCreate, soSnapshotfell through to a plain DELETE — no final snapshot where CloudFormation creates one (live A/B onAWS::EC2::Volume, 2026-08-03: CFndelete-stackleft a completed snapshot,cdkd destroyleft nothing), and the RDS/Neptune/DocDB providers hardcodedSkipFinalSnapshot: true. Silent data loss on a daily pattern — the CDK RDS L2 defaultsremovalPolicytoSNAPSHOT. Now: Tier-A types (RDS DBInstance / DBCluster, Neptune / DocDB clusters, ElastiCache CacheCluster) delete with the API's atomic final-snapshot parameter under a generated, logged identifier (<physicalId>-final-<utcTimestamp>); the CFn cluster-member nuance is matched (a DBInstance withDBClusterIdentifierset gets no instance-level snapshot). CC-routedAWS::EC2::Volumegets a pre-deleteCreateSnapshot(taggedcdkd:final-snapshot-of), waited tocompleted, idempotent across destroy re-runs. Snapshot-tagged types cdkd cannot snapshot yet (AWS::Redshift::Cluster,AWS::ElastiCache::ReplicationGroup— issue #1353) are REFUSED with an actionable error instead of silently losing data, and so is a Cloud-Control-routed resource of an atomic type (provisionedBy: cc-api, the #614 routing — CCDeleteResourcehas no final-snapshot parameter;CloudControlProvider.deletealso fail-closes if the context field ever reaches it). Snapshot-wait failures are wrapped as typedFINAL_SNAPSHOT_*errors that the destroy call sites rethrow BEFORE their "not found = already deleted" heuristics (a rawInvalidSnapshot.NotFoundpoll error would otherwise drop a live, un-snapshotted volume from state), with transient post-CreateSnapshotInvalidSnapshot.NotFoundpropagation tolerated for a bounded window; the engine's EBS snapshot uses a stack-region-pinned EC2 client (DeployEngineOptions.finalSnapshotEc2) instead of the--stack-concurrency-raceable global; ElastiCache snapshot names are capped to the ~50-char cluster naming rules.--skip-final-snapshotis the explicit opt-out for all shapes.UpdateReplacePolicy: Snapshoton the replacement path is follow-up #1354. Tests:tests/unit/provisioning/final-snapshot.test.ts+deletion-policy-snapshot-providers.test.ts+tests/unit/deployment/deploy-engine-deletion-policy-snapshot.test.ts+tests/unit/cli/destroy-runner-deletion-policy-snapshot.test.ts(33 tests, both flag polarities pinned). Integ:tests/integration/deletion-policy-snapshot/(new) — engine path viaCDKD_TEST_UPDATE=truetemplate-removal redeploy + runner path via destroy, both asserting a completed tagged snapshot, artifact cleanup to orphan-zero. Docs: cli-reference "DeletionPolicy: Snapshot" section, state-management v5 note, state-schema / providers rules. - Destroy data guard extended to S3 Express directory buckets (issue #1344) —
src/provisioning/providers/s3-directory-bucket-provider.ts. Before:delete()PROACTIVELY emptied everyAWS::S3Express::DirectoryBucket(ListObjectsV2 + DeleteObjects on every delete, even before any not-empty error) — where CloudFormation DELETE_FAILs a non-empty directory bucket ("The bucket you tried to delete is not empty", 409; live-A/B-verified 2026-08-03). Now: the empty runs only with an opt-in —DeleteContext.forceDataDelete(--force-stateful-recreationreplacement consent, #1340 plumbing) or theaws-cdk:auto-delete-objectstag (honored when present, thoughTagsis not yet a handled property for this type, so it currently requires--allow-unsupported-properties; no CDKautoDeleteObjectssugar exists for directory buckets). Without an opt-in, DeleteBucket is attempted directly and a not-empty failure surfaces with an actionable message (manualaws s3 rm --recursive; no versioning on directory buckets). A side win: deleting an empty directory bucket now costs ONE DeleteBucket call instead of ListObjectsV2 + DeleteBucket. Thes3-directory-bucketinteg fixture gained averify.shexercising guard refusal with data intact + AWS-native cleanup + clean second destroy (the fixture previously had no verify.sh — the #1344 issue text's claim that it wrote objects was wrong). Tests: provider delete suite reworked (no-opt-in skips listing; refusal test with data untouched; forceDataDelete + tag opt-in paths; binding-proven — 2 fail with the gate reverted). Docs: cli-reference "Destroy data guards" table row, troubleshooting update.
Recently Implemented (2026-08-02):
- Interrupt handlers now register BEFORE lock acquisition in deploy / destroy / rollback (issue #1348) —
src/deployment/deploy-engine.ts,src/cli/commands/destroy-runner.ts,src/cli/commands/rollback.ts,src/utils/interrupt-signals.ts(comment), plus source-level ordering pins intests/unit/cli/signal-before-lock-ordering.test.ts(new). Before: each site acquired the stack lock and only then registered its graceful-SIGINT handler; a SIGINT/SIGTERM landing in that window (which includes an S3 round-trip — destroy additionally runs its under-lock strong-reference scan before registering) killed the process with the just-written lock stranded for its full 30-minute TTL (reproduced live while testing #1342). Now: the handler (and, transitively, the #1342 SIGTERM forwarder path) is armed first, so an interrupt during acquisition just flips the drain/interrupt flag — the run then starts no work and the normalfinallyreleases the lock. Each site removes the listener in a catch around the acquire so a lock-conflict failure does not leak the handler. destroy-runner safety gate: the second-signal force-quit's best-effortreleaseLockis now gated on alockHeldflag —releaseLockdeletes the lock key unconditionally, and before OUR acquire succeeds the key may belong to ANOTHER process (exactly what a conflicting acquire waits on), so firing it pre-acquire could have deleted a live foreign lock (a hazard the reorder would otherwise have introduced; pre-reorder code could not hit it because the handler did not exist yet). Live-verified: SIGTERM delivered whilecdkd destroyprintsAcquiring lock ...now drains gracefully and leaves no lock (the same timing stranded the lock before this change). - SIGTERM now rides the graceful-SIGINT path in
deploy/destroy/state destroy/rollback(issue #1342) —src/utils/interrupt-signals.ts(newforwardSigtermToSigint()— registers ONEprocess.on('SIGTERM')listener that re-emits the signal asSIGINT, returns an unregister function), wired at the top ofsrc/cli/commands/deploy.ts/destroy.ts/state.ts(stateDestroyCommand) /rollback.tswith the unregister in each command's outermostfinally(so the forwarder never leaks intocdkd local start-api, which owns a real SIGTERM handler). Before: the interrupt-sensitive commands handled only SIGINT; a CI cancellation (GitHub Actions escalates SIGINT → SIGTERM → SIGKILL; GitLab CI /docker stop/ Kubernetes send SIGTERM directly) killed the process on the unhandled SIGTERM, skipped everyfinally, and stranded the stack lock for its 30-minute TTL. Now: SIGTERM reaches every SIGINT listener — the top-level command handlers, the deploy engine's partial-state save, and the per-provider poll-abort listeners (CustomResource / ACM / CloudFront / Route53) — so a SIGTERM-only cancellation gets the full graceful drain (finish in-flight ops, save state, release the lock), and under GitHub Actions the SIGINT→SIGTERM sequence maps onto the existing second-signal force-quit escalation (destroy's force-quit fires its best-effort lock release right before SIGKILL; deploy's force-quit now prints thecdkd force-unlock <stackName>recovery hint — parity of message, no best-effort release plumbed at deploy's top level). A per-stack pre-start gate indeploy.ts'srunStackInneradditionally honors an interrupt that landed BEFORE the engine registered its handlers (previously the flag was only read by the second-signal escalation, so an early Ctrl-C / SIGTERM let the deploy proceed into lock acquisition + provisioning). SIGKILL remains unhandleable — the docs' CI-cancellation section (PR #1346) still documents the TTL +force-unlockrecovery. Tests:tests/unit/utils/interrupt-signals.test.ts— forwarding / repeat-forwarding / unregister behavior plus a source-level wiring pin asserting all four commands register AND unregister the forwarder (live-line match, so a commented-out call fails). Live-verified:kill -TERMduring a real-AWS deploy exits gracefully with partial state saved + lock released; a second SIGTERM force-quits with the recovery hint. - Destroy data guards: non-empty S3 buckets / image-carrying ECR repos are no longer force-cleaned without an opt-in (issue #1340) —
src/provisioning/providers/s3-bucket-provider.ts,src/provisioning/providers/ecr-provider.ts,src/provisioning/region-check.ts(DeleteContext.forceDataDelete),src/provisioning/data-delete-intent.ts(new shared intent helpers),src/deployment/deploy-engine.ts(threads--force-stateful-recreationconsent into the four replacement/recreate delete sites). Before:cdkd destroyof ANY non-empty bucket silently deleted every object version + delete marker (deleteBucketWithEmptyRetry's unconditionalemptyBucket), and ANY image-carrying ECR repo was deleted withforce: true— where both CloudFormation (DELETE_FAILED, live-A/B-verified) and Terraform (force_destroy/force_deleterequired) fail and protect the data, and inconsistently with cdkd's own deploy-side stateful-recreation guard (#648). Now: force-cleanup requires an opt-in signal — S3: CDK'saws-cdk:auto-delete-objectstag (autoDeleteObjects: true; the empty-retry also still absorbs the post-CR-cleanup object race for those buckets); ECR:EmptyOnDelete: true(boolean or CFn-string) or theaws-cdk:auto-delete-imagestag; both:DeleteContext.forceDataDelete, set ONLY by the deploy engine's replacement/recreate deletes when--force-stateful-recreationwas passed (the template-removed-resource DELETE path during deploy deliberately does NOT set it — CFn also fails there). Without an opt-in the AWS not-empty error surfaces with an actionable message (empty manually, or redeploy with the opt-in). Same implicit-force→explicit-opt-in migrationremoveProtectionwent through. Parity notes recorded (live CFn A/B 2026-08-02): SecretsManager hard-delete and IAM role force-detach are CFn parity — unchanged. Fixture sweep found no existing fixture relying on the old default (local-run-task-from-statedeletes images AWS-natively pre-destroy;synthetics-canarykeeps its bucket empty by design). Tests:tests/unit/provisioning/s3-bucket-provider-delete-data-guard.test.ts+tests/unit/provisioning/providers/ecr-provider-delete-data-guard.test.ts(11 tests; binding-proven — 3 fail with the gates reverted). Integ:tests/integration/destroy-data-guard/(new) — deploy guarded + opted-in resources, load data into all, first destroy force-cleans ONLY the opted-in pair and fails on the guarded pair with data intact, then AWS-native cleanup + second destroy completes with zero orphans. Docs: cli-reference "Destroy data guards" section, troubleshooting entry.
Recently Implemented (2026-07-31):
--verboseno longer prints NoEcho parameter values (issue #1329) —src/deployment/intrinsic-function-resolver.ts, plus unit tests intests/unit/deployment/intrinsic-noecho-redaction.test.ts(new).NoEcho: trueis the template author's explicit "this value is sensitive" declaration — CloudFormation masks such values everywhere it echoes them — but cdkd's debug output printed them raw at four sites: the user-provided-value / default-value / SSM-resolved-value lines inresolveParametersand theResolved Ref to parameterline inresolveRef. All four now route through the module-localstringifyParameterForLog(paramDef, value)(renders<redacted>when the definition carriesNoEcho: true); resolution itself is unchanged — consuming resources still receive the real value, only the log is masked. Sibling of PR #1328'sstringifyAttributeForLog(name-heuristic redaction forFn::GetAttattribute values; here the author told us explicitly, so no heuristic is needed). Live-verified: a--verbosedeploy of a NoEcho-param stack logs<redacted>at both resolver sites, zero occurrences of the sentinel value in the full log, and the consuming SSM parameter still received the real value. Spun out of PR #1328's delta review.AWS::IAM::AccessKeySDK provider (issue #1323) —src/provisioning/providers/iam-access-key-provider.ts(new),src/provisioning/register-providers.ts, schema fixturetests/fixtures/cfn-schemas/AWS-IAM-AccessKey.json(new), unit tests intests/unit/provisioning/providers/iam-access-key-provider.test.ts(new), real-AWS fixturetests/integration/iam-access-key/(new), rows indocs/supported-resources.md+docs/import.md, and regenerated coverage matrices. The type is NON_PROVISIONABLE in the CloudFormation registry (no Cloud Control handlers), so cdkd's pre-flight rejected any template declaring it — including the documented CDK CI-credentials patterniam.AccessKey+secretsmanager.Secret({secretStringValue: key.secretAccessKey})(found by the 2026-07-31/hunt-bugssweep). The provider mapsCreateAccessKey(create; aStatus: Inactivetemplate gets a follow-upUpdateAccessKey, with delete-on-failure cleanup so a half-created key never strands outside state and burns the 2-keys-per-user quota),UpdateAccessKey(in-placeStatusflip —UserName/Serialare createOnly; a REMOVEDStatusis reset to the CFn defaultActive), andDeleteAccessKey(the owning user comes from state properties, recovered viaGetAccessKeyLastUsedwhen absent;NoSuchEntityException= idempotent success after the shared region check).SecretAccessKeyis a create-time-only attribute —CreateAccessKeyis the only API that ever returns it — so the provider caches it in the create-result attributes (CFn-equivalent behavior; it lives in the S3 state file, the same trust boundary as the rest of cdkd state) andupdate()deliberately returns NO attributes so the cached secret survives (a partial attribute set replaces, not merges);getAttribute('SecretAccessKey')fails loudly instead of returning a wrong answer.Serialis declared HANDLED despite never being sent to IAM: its whole CFn semantic (change → new key) is the registry-schema createOnly replacement classification, and anunhandledByDesignlisting would land it in the silent-drop set, where the #614 auto-route viability guard on adisableCcApiFallbackNON_PROVISIONABLE type would wrongly HARD-REJECT any template usingserial. Import is explicit-override-only (keys are not taggable; verified viaGetAccessKeyLastUsed; the imported record has no cached secret, soFn::GetAtt SecretAccessKeyis documented as unresolvable for imported keys). Drift:readCurrentStateresolves the owner viaGetAccessKeyLastUsed+ListAccessKeys(paginated);Serialis declared drift-unknown (no read API). Three review-driven companions shipped in the same PR: (1)src/deployment/retryable-errors.tsgains the anchored IAM-propagation patternThe user with name—CreateAccessKeyissued ~1s after the same deploy'sCreateUsercan race IAM's own eventual consistency withNoSuchEntity: The user with name X cannot be found., a phrasing no existing pattern matched, so the deploy hard-failed instead of retrying (the paired negative test pins that IAM'sUser with name X already exists.collision phrasing stays non-retryable); (2) a partialCreateAccessKeyresponse carryingAccessKeyIdbut noSecretAccessKeynow best-effort deletes the minted key before failing (otherwise the retry / next deploy burns the 2-keys-per-user quota on a key cdkd never recorded); (3)Fn::GetAttdebug logging redacts credential-bearing attribute VALUES —stringifyAttributeForLoginsrc/utils/stringify.ts(name matches/secret|password|credential/iminus identifier suffixes like...Arn/...Id) — because this PR is the first to cache a live long-lived credential in state attributes, and a--verbosedeploy would have printed the usable IAM secret into terminals / CI logs at the three GetAtt resolution sites insrc/deployment/intrinsic-function-resolver.ts.CloudFront delete now waits out a still-propagating disable (issue #1316) —
src/provisioning/providers/cloudfront-distribution-provider.ts, plus unit tests intests/unit/provisioning/cloudfront-distribution-provider.test.ts.delete()only entered the disable-then-wait path when the config read backEnabled: true; a distribution that was ALREADY disabled but still propagating (Status: InProgress— the state a prior interrupted destroy or an out-of-band console disable leaves behind) skipped the wait entirely, andDeleteDistributionfailed in under a second with "The distribution you are trying to delete has not been disabled" on every retry until propagation finished. Found live while cleaning up an interrupted #1309 benchmark run. The already-disabled arm now runs the samewaitForDistributionStable(id, false)+ post-wait ETag re-fetch as the just-disabled arm (same warn-and-attempt on budget exhaustion); the settled common case pays exactly one confirmingGetDistributionread. More reachable post-#1282 (fire-and-forget default makes interrupted lifecycles onInProgressdistributions more common).BREAKING: CloudFront Distribution default completion is now fire-and-forget;
--full-waitopts into theDeployedwait (issue #1282) —src/provisioning/providers/cloudfront-distribution-provider.ts,src/cli/options.ts,docs/cli-reference.md,README.md,.claude/rules/providers.md, plus unit tests intests/unit/provisioning/cloudfront-distribution-provider.test.tsand the new mechanical backstoptests/unit/provisioning/full-wait-doc-coverage.test.ts(the--full-waitmirror ofno-wait-doc-coverage.test.ts, added now that a second type joined ECS).create()no longer waits forDeployedby default (previously a 3-15 min wait, skippable only via--no-wait); it returns onceCreateDistributionis accepted and prints an INFO line with the manual wait command (aws cloudfront wait distribution-deployed --id <id>) plus a--full-waithint gated onCDKD_WAIT_FLAGS_AVAILABLE(issue #1291 pattern, socdkd drift --revertnever advertises a flag it does not declare). UnderCDKD_FULL_WAITthe wait applies to BOTH create and update — update never waited before, so--full-waitis now full CloudFormation parity — with the ~20 min budget lifted by an explicit--resource-timeoutper the #1280 inner-vs-outer rule (the lift also reaches the delete path's disable-then-wait, which itself is an API requirement and stays unconditional). A--full-waittimeout WARNS and proceeds instead of failing (deliberate divergence from the ECS steady-state timeout: CloudFront has no failure state — a distribution deploy cannot fail, only lag — and failing would hand auto-rollback a healthy distribution to disable-and-delete). Policy-wise this is NOT an extension of the ECS disagree-case precedent — CloudFormation and Terraform (wait_for_deployment, defaulttrue) both wait here — so the wait-semantics rule indocs/cli-reference.mdgained an explicit 3-condition fast-side clause ((a) no in-deploy consumer of the waited state, (b) no failure signal, (c) both modes measurable on the comparison tool), which is also the recorded answer to "why not--no-waitby default?" (ACM / RDS / EC2 each fail condition (a)). The cloudfront benchmark scenario needs re-measuring into the two-row form (new default vswait_for_deployment = false;--full-waitvs Terraform default) — tracked as a follow-up in the PR.Destroy partial-failure guidance now names
cdkd state orphanas the last resort (issue #1303) —src/cli/commands/destroy-runner.ts(the ⚠ "partially destroyed" banner, with the concrete stack name),src/cli/commands/destroy.ts+src/cli/commands/state.ts(thePartialFailureErrormessages), plus a unit test intests/unit/cli/destroy-runner-incremental-state.test.ts. For a resource AWS itself refuses to delete (the #1301 pending SNS subscription pre-fix; any future AWS-side wedge), "re-run 'cdkd destroy' / 'cdkd state destroy'" fails identically forever —state destroydrives the same provider delete — and the actual escape hatch,cdkd state orphan <stack>(drop the state record, leave AWS resources), was not mentioned anywhere in the error output. All three guidance sites now append it, scoped to the "same resource keeps failing" case. Interrupted-destroy (Ctrl-C) messages are deliberately unchanged — an interrupt is not an undeletable resource. Message-only change; verified live by inducing a real partial destroy (security group blocked by an out-of-band ENI) and confirming exit 2 + both new messages.AWS::CloudWatch::AnomalyDetectorSDK provider (issue #1304) —src/provisioning/providers/cloudwatch-anomaly-detector-provider.ts(new),src/provisioning/register-providers.ts, schema fixturetests/fixtures/cfn-schemas/AWS-CloudWatch-AnomalyDetector.json(new), unit tests intests/unit/provisioning/providers/cloudwatch-anomaly-detector-provider.test.ts(new), real-AWS fixturetests/integration/cloudwatch-anomaly-detector/(new), rows indocs/supported-resources.md+docs/import.md, and regenerated coverage matrices. The type is NON_PROVISIONABLE in the CloudFormation registry (no Cloud Control handlers), so cdkd's pre-flight rejected any template declaring it (found by the 2026-07-30/hunt-bugssweep). The provider mapsPutAnomalyDetector(create; also the in-place update path — the registry schema marks every descriptor field createOnly, so onlyConfigurationreachesupdate()) andDeleteAnomalyDetector(delete, addressed by the metric descriptor from state properties;ResourceNotFoundException= idempotent success after the shared region check). There is no server-generated identifier, so the physical id is DERIVED deterministically from the descriptor (<Namespace>:<MetricName>:<Stat>[:<sorted dims>]for single-metric,math:<sha256-16>for metric-math), stable across in-place updates; the schema's read-onlyIdattribute resolves to it.disableCcApiFallback = trueper the NON_PROVISIONABLE provider rule (the #614 unhandled-property auto-route must never send this type to a CC target with no handlers). Import is explicit-override-only (detectors carry no tags and no name). Two shape conversions: CFnConfiguration.ExcludedTimeRangesISO-8601 strings become the SDK'sDate, and CFnConfiguration.MetricTimeZone(capital Z) is RE-KEYED to the SDK'sMetricTimezone(lowercase z) — passing the CFn key through verbatim is a client-side silent drop (the SDK serializer ignores unknown keys; caught by the fixture's first live run, whose Configuration readback came back empty, and now pinned by a unit test + the fixture's readback assertion).Subnet
MapPublicIpOnLaunch: phantom-drift race closed + in-place update wired (issues #1299, #1300) —src/provisioning/providers/ec2-provider.ts,src/analyzer/replacement-rules.ts, plus unit tests intests/unit/provisioning/ec2-provider-subnet-capture-readback.test.ts(new) andtests/unit/provisioning/ec2-provider-roundtrip.test.ts. Two coupled defects surfaced by adrift-revert-vpcinteg failure (2026-07-30): (1) issue #1299 —createSubnetreturned immediately afterModifySubnetAttribute(MapPublicIpOnLaunch=true), and the deploy engine's async observed-state capture (kickOffObservedCapture→DescribeSubnets) raced EC2's eventual consistency, occasionally persisting the stale pre-modifyfalseas the drift baseline — every latercdkd driftthen reported phantomfalse → truedrift on a subnet nothing touched.create()(and the newupdate()path) now read back the attribute via a bounded best-effortwaitForSubnetMapPublicIppoll (6 reads, 100ms exponential backoff, ~3s worst case, zero added latency when the first read already reflects the write; exhaustion / read failure degrades to the pre-fix behavior instead of failing the deploy). (2) issue #1300 —updateSubnetrejected EVERY update as "Subnet properties are immutable", thoughMapPublicIpOnLaunchis mutable viaModifySubnetAttributeandTagsviaCreateTags/DeleteTags— socdkd drift --revertcould not revert real (or phantom)MapPublicIpOnLaunchdrift (PartialFailureError: 1 update-not-supported), and a template flip forced a full subnet replacement where CloudFormation updates in place.updateSubnetis now property-aware (diff-based modify + tag diff, zero mutating calls on a no-drift round-trip; changedVpcId/CidrBlock/AvailabilityZonestill reject withResourceUpdateNotSupportedErrorso replacement classification is preserved on the revert path), andreplacement-rules.tsgains an explicitAWS::EC2::Subnetrule (replacementProperties: VpcId/CidrBlock/AvailabilityZone,updateableProperties: MapPublicIpOnLaunch/Tags) so the classification survives acloudformation:DescribeTypefailure.
Recently Implemented (2026-07-30):
Destroy no longer gets stuck on SNS subscriptions in PendingConfirmation (issue #1301) —
src/provisioning/providers/sns-subscription-provider.ts, plus unit tests intests/unit/provisioning/sns-subscription-provider.test.tsand the new real-AWS fixturetests/integration/sns-pending-subscription/.delete()calledUnsubscribeunconditionally, but SNS rejectsUnsubscribefor ANY subscription still pending confirmation (InvalidParameterException: ... Cannot unsubscribe a subscription that is pending confirmation) and no API can remove the record (it auto-expires after ~3 days) — so a stack with a never-confirmed email subscription was permanently un-destroyable:cdkd destroyANDcdkd state destroyfailed the resource on every retry (PartialFailureError, state preserved), and the only escape wascdkd state orphan. Found live by a/hunt-bugssweep. The provider now treats the pending-confirmation rejection as delete success (skip + warn), matching CloudFormation's documented behavior of removing the resource from the stack without unsubscribing; the literalPendingConfirmationplaceholder physical id (possible viacdkd import --resource) short-circuits before the API call the same way. Unlike the NotFound skip, noassertRegionMatchgate is needed — the error proves the subscription was positively found in the client's region.--full-waitECS steady-state cap now respects--resource-timeout(issue #1280) —src/provisioning/resource-timeout-registry.ts(new),src/provisioning/providers/ecs-provider.ts,src/cli/commands/deploy.ts/destroy.ts/state.ts,docs/cli-reference.md, plus unit tests intests/unit/provisioning/resource-timeout-registry.test.ts(new) andtests/unit/provisioning/ecs-service-full-wait.test.ts.ECSProvider.settleServicehardcodedmaxWaitTime: 600for the--full-waitsteady-state waiter, so--resource-timeout AWS::ECS::Service=20mlifted only the deploy engine's OUTER per-resource deadline while the provider's INNER waiter cap fired first at 10 minutes — the same inner-undercuts-outer shapeslow-cc-operation-timeouts.tscloses for Cloud Control types. A new process-wide registry (setResolvedResourceTimeoutsseeded by deploy / destroy / state destroy right aftervalidateResourceTimeouts;resolvedResourceTimeoutMs(type)resolving per-type > explicit global > undefined) now feeds the waiter, whose cap ismax(600s, resolved): an explicit--resource-timeout(per-type or global) lifts it, nothing lowers it below the 600s Terraform-parity floor, and the compile-time 30m default never leaks in. Seeding wiring is pinned by a source-level test; the lift/floor/UPDATE-side behavior is pinned by six new waiter-config cases.
Recently Implemented (2026-07-29):
Deploy tail de-serialized, the redundant pre-lock state GET removed, SecurityGroup wiring batched, and the
AWS::CDK::MetadataDescribeType warning fixed —src/deployment/deploy-engine.ts,src/state/deployment-events-store.ts,src/cli/commands/deploy.ts,src/cli/commands/prefix-migration-check.ts,src/provisioning/describe-type.ts,src/provisioning/create-only-properties.ts,src/provisioning/write-only-properties.ts,src/provisioning/providers/ec2-provider.ts,src/utils/error-handler.ts, plus unit tests intests/unit/deployment/deploy-engine-tail-ordering.test.ts(new),tests/unit/deployment/deploy-engine-create-only-prefetch.test.ts,tests/unit/state/deployment-events-store.test.ts,tests/unit/cli/prefix-migration-check.test.ts,tests/unit/provisioning/{describe-type,create-only-properties,write-only-properties}.test.ts,tests/unit/provisioning/providers/ec2-provider.test.ts. Four independent defects found while reading a verbose deploy timeline:(1) The success-path tail was fully serialized. After the last resource settled, the engine ran
saveState→ delete the rollback journal → update the exports index → release the lock, one after another (measured ~1.0s between "State saved" and "Lock released", reproducing on every scenario). The journal delete and the exports-index update target DISJOINT S3 objects and neither reads what the other writes —updateForStackonly ever touches the exports index (plus, on a first-ever call, a rebuild scan ofstate.jsonfiles, which the journal delete does not affect), and the journal delete reads nothing — so they now run under onePromise.all. What did NOT move, deliberately: both stay strictly AFTER the state save (deleting the journal before the new baseline is durable would lose the ability to revert) and strictly BEFORE the lock release (an early release would let a concurrent deploy observe a journal we are about to delete, or race the exports-index read-modify-write). ThedrainObservedCapturesstep beforesaveStatewas likewise left alone — observed properties MUST be in the record being persisted. Separately,DeploymentEventsStore.finalize()ran flush → index GET → index PUT → prune LIST → prune DELETE as five sequential round trips after "Deployment completed successfully" had already printed. The index READ now overlaps the flush PUT (different objects, no dependency) and the prune LIST overlaps the index PUT (read-only, and its cutoff is known before the PUT). The durability order is unchanged and is what bounds the change: the index is still WRITTEN only after the flush resolved, so it can never advertise a run whose stream is missing, and a stream is still DELETED only after the index that dropped it is durable — an index PUT failure aborts the prune exactly as before. The LIST stays gated on "the retained window is full", so stacks below the cap do not start paying a new LIST.(2) A redundant pre-lock state GET on every deploy. The
--prefix-user-supplied-namesmigration pre-flight issued its owngetStatebefore the lock, then the engine read the same object again right after taking it. Prefix-skipping has been the DEFAULT since v0.94.0, so this fired on every deploy of every stack — including brand-new stacks with no state at all, wherefindPendingPrefixRenamesreturns[]unconditionally.DeployEngineOptionsgained anonCurrentStateLoaded(stackName, state)gate the engine invokes once, immediately after the post-lock state read and before parsing / diffing / any provider call; the CLI's check moved into it via the newcreatePrefixMigrationGatefactory. The contract the check depends on ("surface this before any provider call runs") is preserved, and reading under the lock is strictly MORE authoritative — no concurrent deploy can mutate the state between the check and the diff it predicts. Declining the prompt now raises the newDeployCancelledError, which the deploy CLI unwinds quietly (no error output, no FAILED run event), matching the plain earlyreturnthe pre-lock version used. The gate is scoped by stack name because nested-stack children inherit the parent's engine option object.(3) SecurityGroup creation made needless serial API calls. Wiring a fresh SG was
CreateTags→ oneAuthorizeSecurityGroupIngressPER RULE →RevokeSecurityGroupEgress→ oneAuthorizeSecurityGroupEgressPER RULE, fully serialized — 4 sequential round trips for a typical CDK L2 SG. Tagging and the two rule directions are mutually independent (distinct APIs, distinct rule sets), so they now run concurrently, and both Authorize calls take anIpPermissionsARRAY, so N rules became ONE call per direction. The revoke still strictly precedes the egress authorize, and an EMPTYSecurityGroupEgress: []still revokes-without-authorizing (that is a deliberate "deny all outbound"). The revoke+authorize pair is skipped entirely when the templated egress is EXACTLY the rule AWS already created by default, reusing the existingisDefaultEgressRulepredicate so the create path and the drift reverse-mapper cannot disagree about what "default" means. That skip deliberately does NOT fire for the CDK L2allowAllOutbound: trueshape, which stampsDescription: 'Allow all outbound traffic by default'— the AWS default rule carries no description andAuthorizeSecurityGroupEgressrejects the identical-modulo-description rule as a duplicate, so the description can only be applied by revoking first; skipping there would leave the group in a state CloudFormation would not produce AND would surface as permanent phantom drift (readCurrentStatewould report no Description while state records one). Since this file is in theinteg-destroymarkgate scope, the change needs a real-AWS integ before merge.(4) BUG:
AWS::CDK::Metadatatriggered a misleading warning on every deploy. The deploy-start create-only DescribeType prefetch iterated the RAW template type set, so it always included theAWS::CDK::Metadatasentinel CDK injects into every synthesized template. That type has no CloudFormation registry entry, so the lookup always failed — burning one API call and printingFailed to resolve create-only properties for AWS::CDK::Metadata ... Grant cloudformation:DescribeType ..., naming a pseudo-resource the user cannot act on and implying a missing IAM permission that is not missing. The diff / type-validation / property-validation passes already excluded it; the prefetch did not. Rather than adding a third inline literal, the exclusion became a sharedhasNoRegistrySchema(resourceType)predicate indescribe-type.tscoveringCustom::*,AWS::CloudFormation::CustomResource(previously a private helper insidecreate-only-properties.ts), andAWS::CDK::Metadata. Both DescribeType-backed resolvers short-circuit on it —write-only-properties.tshad no custom-resource guard at all, which was unreachable rather than wrong, and now shares the one list — and the prefetch filters through it as the cheap outer guard.40 unit tests added across the four fixes, taking the suite from 8133 to 8172. No CLI flag, dependency, or state-schema change.
IAM-propagation retries get their own dense backoff schedule, instead of overshooting on the generic exponential one —
src/deployment/retry.ts,src/deployment/retryable-errors.ts,tests/unit/deployment/retry.test.ts,tests/unit/deployment/retryable-errors.test.ts,docs/troubleshooting.md. Measured problem: acdkd deploy --verboseof a stack with one IAM Role + 3 InstanceProfiles + 3t3.microinstances spent ~10.2s of a 25.9s deploy (9.92s -> 20.12s) sitting in retry backoff. All three instance profiles were created at 7.12s;RunInstancesfired ~2.8s later and AWS rejected each instance three times withValue (BenchEc2-Instance1InstanceProfileC04770B7) for parameter iamInstanceProfile.name is invalid. Invalid IAM Instance Profile name. The generic schedule retried at 9.92s (+1s), 11.26s (+2s) and 13.97s (+4s); IAM had actually propagated somewhere in(13.97s, 17.97s], but the 4s step meant cdkd did not look again until 17.97s and the instance only reached "waiting for running" at 20.12s. The root cause is structural and not a bug in the retry loop: CloudFormation and Terraform are slow enough between resources that IAM propagates on its own, and cdkd outruns it — so cdkd hits this class constantly, and its CADENCE (not its correctness) was wrong. Fix:retryable-errors.tsnow stores the message table as two composed halves — the exportedIAM_PROPAGATION_ERROR_MESSAGE_PATTERNS(the 24 just-created-IAM-entity signals:Invalid IAM Instance Profile,cannot be assumed,not authorized to perform,Policy Error: PrincipalNotFound,Caught ServiceAccessDeniedException, ...) plus a private non-propagation half — spread into the same singleRETRYABLE_ERROR_MESSAGE_PATTERNSexport, so retryability keeps ONE source of truth and the newisIamPropagationError(message)predicate only selects a CADENCE (a misfiled pattern can change the backoff shape, never whether an error is retryable at all).withRetrypicks the schedule per attempt: propagation errors back off0.25s -> 0.5s -> 1s -> 2s -> 2s ...(IAM_PROPAGATION_INITIAL_DELAY_MS250,IAM_PROPAGATION_MAX_DELAY_MS2_000,IAM_PROPAGATION_MAX_RETRIES26 = 47.75s of sleep), everything else keeps the generic1s -> 2s -> 4s -> 8scapped at 8s over 8 retries (47s of sleep). The dense budget is deliberately >= the generic one — a tighter probe grid must not shrink the window in which propagation can still be caught, or the fix would trade latency for flakiness, a strictly worse bug. Probe grid after the first failure:0.25, 0.75, 1.75, 3.75, then every 2s out to 47.75vs the old1, 3, 7, 15, 23, 31, 39, 47— from 3.75s onwards the new grid is strictly ahead and it never lags the old one by more than 0.75s in the early band. Against the measured timeline the worst-case overshoot past the propagation instant drops from up to 8s (the un-taken next generic step) to ~2.4s (2s cap + the ~0.35s the rejectedRunInstancesitself costs); realistically this recovers ~1-3s per affected deploy, not the full 10.2s — at least 4.05s of that 10.2s was IAM genuinely not having propagated yet, which no schedule can reclaim. Throttling and other transient errors are untouched on purpose: hammering a rate-limited API is harmful, and the 2s floor is chosen so three parallel instances pollRunInstancesat ~1.3 req/s rather than exceeding its ~2 req/s refill. Because the class is re-evaluated per attempt, a throttle encountered mid-propagation backs off exponentially for that attempt — self-correcting if the dense grid ever does provoke one. The dense schedule applies only where the caller left the schedule at its defaults (the deploy engine's create/update path,cdkd drift --revert, the ELBv2 / ServiceDiscovery attribute calls); any explicitmaxRetries/initialDelayMs/maxDelayMs/isRetryablemeans the caller picked its cadence deliberately and gets it verbatim — so the DELETE path's 3 x 5s, the delete-then-re-create sites' ~64s SQS-cooldown budget, anddescribe-type.ts's throttle-only retry are all unchanged. Two sibling IAM-propagation retry loops were deliberately NOT changed:EC2Provider.ensureIamInstanceProfileAssociated's associate/poll loop already ramps linearly from 1s (1,3,6,10,15,... = 45s), so its worst overshoot inside the window that matters is ~3s — a marginal gain that is not worth puttingsrc/provisioning/providers/**into the diff (and its real-AWS integ gates) for; andCustomResourceProvider.invokeCustomResourceWithRetryhas no backoff to tighten at all — it recycles the backing Lambda's execution environment and re-invokes immediately, with the cold start providing the delay. 25 unit tests added — 8 cadence tests inretry.test.ts+ 17 classifier tests inretryable-errors.test.ts, taking the suite from 8108 to 8133 (dense schedule used for the exact measured wire message; generic schedule preserved for aRate exceededthrottle and for a name-onlyThrottlingException; per-attempt re-classification when a throttle interrupts a propagation retry; total-sleep budget assertion of 47.75s >= 47s; caller-supplied schedules honoured verbatim; every propagation pattern round-trips through both classifiers), using the existingsleeptest seam so nothing waits for real.
Recently Implemented (2026-07-28):
-
AWS::CloudFront::OriginAccessControlgets an SDK Provider, taking it off the Cloud Control polling path —src/provisioning/providers/cloudfront-oac-provider.ts(new),src/provisioning/register-providers.ts,src/provisioning/property-coverage.generated.ts,tests/fixtures/cfn-schemas/AWS-CloudFront-OriginAccessControl.json(new),tests/unit/provisioning/cloudfront-oac-provider.test.ts(new),docs/supported-resources.md,docs/import.md. Why:S3BucketOrigin.withOriginAccessControl()is the standard way to write CloudFront + S3 in CDK, and the OAC it synthesizes was the ONLY resource in such a stack without an SDK Provider — so it fell through to the Cloud Control API and paid ProgressEvent polling (measured ~2.3s on the deploy critical path: 0.43s submit, two polls, 0.33s read-back) for aCreateOriginAccessControlcall that returns synchronously. The Distribution references the OAC, so that latency delayed everything downstream. Same shape as theAWS::EC2::EIPprovider (PR #1175), which cut ~23s to ~2.4s.createcallsCreateOriginAccessControland records the generatedIdas the physical id;updatefetches the currentETagwithGetOriginAccessControland passes it asIfMatchtoUpdateOriginAccessControl(every config field is mutable in place, so an OAC is never replaced);deletedoes the sameGet-then-DeleteIfMatchdance and treatsNoSuchOriginAccessControlfrom EITHER call as idempotent success, but only behind the sharedassertRegionMatch()guard so a wrong-region destroy cannot silently strip the resource from state;getAttribute('Id')returns the physical id with no AWS call (Idis the type's only read-only attribute AND itsprimaryIdentifier);readCurrentStatemaps the AWS-currentOriginAccessControlConfigback to CFn shape forcdkd drift(field names are identical on both sides, absent optionals dropped so they cannot fire phantom drift);importis explicit-override only, verified with a singleGetOriginAccessControl(OACs carry no tags and the config'sNameis a display field AWS does not accept as a lookup key). The CFn schema has exactly two top-level properties, so property coverage is complete with nounhandledByDesignentries:OriginAccessControlConfigis handled,Idis read-only. A missing required config field (Name/OriginAccessControlOriginType/SigningBehavior/SigningProtocol) raises aProvisioningErrornaming every offender before the AWS call, rather than letting the SDK reject with an opaque serialization error. - Deploy preflight trimmed from 6 sequential state-bucket round trips to 3 (issue #1283) —
src/cli/config-loader.ts,src/utils/expected-bucket-owner.ts,src/state/s3-state-backend.ts,src/cli/commands/deploy.ts,tests/unit/cli/config-loader.test.ts,tests/unit/utils/expected-bucket-owner.test.ts,tests/unit/state/s3-state-backend.test.ts. Why: everycdkd deploypaid a fixed cost before the first resource was touched, and the state-bucket preflight — which runs concurrently with synth and measured as the LONGER of the two poles — was almost entirely serialized AWS calls:sts:GetCallerIdentity, thenHeadBucketoncdkd-state-{acct}, thenHeadBucketon the legacycdkd-state-{acct}-{region}, thenGetBucketLocation, then a THIRDHeadBucketon the bucket just probed, with a freshGetCallerIdentitybehind eachExpectedBucketOwnerheader. Three changes: (1) the two candidate-nameHeadBucketprobes are independent and now run underPromise.all; (2)verifyBucketExists({ existenceAlreadyProbed })drops the duplicateHeadBucketwhen the bucket name came from the DEFAULT-name resolution AND that resolution's own probe came back clean — theGetBucketLocation+ region-correct client rebuild still always runs, and the skip is gated by the exportedstateBucketExistenceConfirmed(resolved), which requires BOTH a'default'/'default-legacy'source ANDprobe === 'ok'.ResolvedStateBucketgained an optionalprobefield ('ok'= 2xx or a 301 that only means "another region";'access-denied'= 403) because the name resolution deliberately treats 403 as "exists": that keeps name selection working for a bucket this identity cannot head, but it is exactly the case where a secondHeadBucketstill earns its round trip — it turns a confusing mid-deploy state-read failure into an up-front "Access denied" before any asset is published. An explicitly-specified bucket (--state-bucket/CDKD_STATE_BUCKET/cdk.json) is never probed at all, so it always keeps the full fail-fast check; (3)ExpectedBucketOwnerresolution is now memoized by the resolved ACCESS KEY ID as well as per client, and the default-name resolution seeds it viarecordResolvedAccountId(stsClient, accountId)with the account its ownGetCallerIdentityjust returned. The security property is unchanged and is what constrains the design: an access key belongs to exactly one account, so the key-id cache answers the same question the per-client cache did; an assumed-role session carries its ownASIA…key and still resolves the PRODUCER account, keeping the cross-accountFn::GetStackOutputRoleArnpath correct; and the seed is keyed by the seeding client's OWN credentials (never a caller-supplied account), so it is a memoization of a call that just happened rather than a global override. Also fixed in passing: the existence probe built its S3 client on the DEFAULT credential chain while the bucket name came from the--profileidentity, so under a profile pointing at another account every probe came back 403 = "exists" by accident; it now reuses the STS client's own credential provider, which is also what makes the skippedHeadBucketgenuinely redundant rather than merely duplicated. Net: 3 fewer sequential AWS round trips per deploy (2GetCallerIdentity+ 1HeadBucket), plus one probe-latency overlap. NOT applied tocdkd destroy/diff/import/state *— they callresolveStateBucketWithDefaultand keep the unconditionalverifyBucketExists, since their preflight is not on a latency-sensitive path. -
AWS::EC2::Instance.AvailabilityZoneis handled, so an ordinary CDK L2 instance stays on the SDK path (issue #1276) —src/provisioning/providers/ec2-provider.ts,src/provisioning/property-coverage.generated.ts,tests/unit/provisioning/ec2-instance-no-wait-gate.test.ts. Why:AvailabilityZonewas in neitherhandledPropertiesnorunhandledByDesign, and CDK's L2ec2.InstanceALWAYS emits it (from the selected subnet), so the #614 silent-drop routing rule sent EVERY ordinary CDK-authored instance to the Cloud Control API. Both paths wait forrunning, so this was never a semantics difference — it was pure overhead: CC's own stabilization detection plus the client-side ProgressEvent poll loop, on a type this provider handles directly. It also left the SDK provider's whole create/update/readback surface (including the #609 security backfill) dead code for L2 users. Verified before implementing by reading aws-cdk-lib 2.244.0'saws-ec2/lib/instance.jsAND by synthesizing a benchmark-shaped L2 instance (t3.micro + IAM role / instance profile + security group + encrypted EBS block device + userData + tags), which emits exactlyAvailabilityZone/BlockDeviceMappings/IamInstanceProfile/ImageId/InstanceType/SecurityGroupIds/SubnetId/Tags/UserData--AvailabilityZoneis the ONLY one of those missing from the handled set (every other unhandled candidate isundefinedunless the user opts in, so synth omits it), which is what makes mapping this one property sufficient. One opt-in still routes to Cloud Control:associatePublicIpAddressmakes CDK dropSubnetId/SecurityGroupIdsand emitNetworkInterfacesinstead, which remains a silent drop (tracked separately). What shipped: abuildPlacementhelper maps the CFn property ontoRunInstances'Placement.AvailabilityZone(omitted entirely when absent, so no pointless emptyPlacement: {}), the property joinshandledProperties, andreadInstanceCurrentStatereverse-maps it out ofPlacementso drift does not report a phantom diff.AvailabilityZonealso joins theReplacementRulesRegistryrule for this type (alongside the pre-existingEbsOptimized, plusImageId/SubnetId/KeyName, which carried the same latent exposure). Thecreate-only-properties.tsDescribeType fallback already classified all four from the registry schema'screateOnlyProperties, but it degrades to an EMPTY list whencloudformation:DescribeTypeis unavailable -- andupdateInstancesilently ignores an AZ / ImageId / SubnetId / KeyName change, so the degraded path would classify in-place and leave state recording the new value while AWS keeps the old one. Before this fix the same template change went down the CC path, where AWS hard-rejected it, so the hand rule is what keeps the degraded-IAM failure mode loud. Routing is sticky: a resource already in state withprovisionedBy: 'cc-api'stays on the CC path, so the improvement applies to newly created instances. Tests: the Placement mapping, the absent-property omission, ahandledPropertiesassertion, afindActionableSilentDropsassertion against the full L2-emitted property set (the layer the routing decision actually reads), thereadCurrentStatereverse map, and the four-property replacement classification. Theec2-instanceinteg fixture now emitsavailabilityZonetoo, so the newPlacementcreate path runs against real AWS and theprovisionedByassertion proves the resource stayed on the SDK path. No CLI flag, dependency, or state-schema change. - SDK waiter poll caps tightened, so a resource that is already ready is noticed in seconds rather than tens of seconds —
src/provisioning/providers/ec2-provider.ts,tests/unit/provisioning/poll-cap-tightness.test.ts. Why: the AWS SDK's generic waiter backs off asuniform_random(minDelay, min(minDelay * 2^(attempt-1), maxDelay)), so once the schedule saturates, the expected lag between a resource becoming ready and cdkd noticing ismaxDelay / 2. At the SDK defaultmaxDelayof 15s that is 7.5s of pure detection lag per waited resource, paid on every deploy, invisible in logs because nothing is failing — the resource is simply ready and cdkd has not looked yet. A Terraform-comparison benchmark made it measurable: the ec2 scenario lost by ~7.5s, which is exactly one such lag on the critical-path instance. What shipped: EC2 Instancerunning/terminatedwaits move fromminDelay5 /maxDelay15 to 2 / 5, and NAT Gatewayavailable/deletedfrom 5 / 15 to 5 / 10 (NAT genuinely takes ~90s, so a 2s floor would only add API calls; the cap is what matters). Real effect measured cold against Terraform: cdkd sees an EC2 instance reachrunningin 3.7-7.8s where Terraform's coarser poll reports a flat 15s for the same instances. A second, generalised guard:poll-cap-tightness.test.tsgained a rule that scans every{minDelay, maxDelay}waiter config in the provider tree and fails if anymaxDelayexceeds 10s, with a coverage floor of ≥6 sites so the rule cannot silently stop finding anything. The rule was verified the only way such a rule can be trusted — by reverting a real call site back to 5/15 and confirming the suite goes red — rather than against a synthetic fixture that would share the author's blind spots. No CLI flag, dependency, or state-schema change; this changes only how often cdkd asks, never what it waits for. - Wait semantics made explicit per resource type: ELBv2 LoadBalancer waits for
active, ECS Service keeps its non-wait default behind a new--full-wait, EC2 Instance honors--no-wait(issues #1274 / #1275 / #1277 / #1278) —src/provisioning/providers/elbv2-provider.ts,src/provisioning/providers/ecs-provider.ts,src/provisioning/providers/ec2-provider.ts,src/cli/options.ts,src/cli/commands/deploy.ts,docs/cli-reference.md,README.md,tests/unit/provisioning/{elbv2-loadbalancer-active-wait,ecs-service-full-wait,ec2-instance-no-wait-gate}.test.ts,tests/integration/ecs-fargate/. Why: a Terraform-comparison benchmark surfaced that cdkd's completion definition was decided ad hoc per provider and undocumented. Three concrete defects: (a)ELBv2Providerreturned as soon asCreateLoadBalancerreturned, on the header comment's premise that "ELBv2 Create* APIs are synchronous" — factually wrong for a LoadBalancer, which comes backState.Code: provisioningand 503s on theDNSNamecdkd had just handed downstream, while CloudFormation AND Terraform both wait foractive; (b) the EC2 Instancerunningwait was not gated onCDKD_NO_WAIT, so--no-waitsilently did nothing on any stack containing an instance and the two benchmark columns measured the same thing; (c)docs/cli-reference.mdclaimed default waiting was "the same behavior as CloudFormation", which no longer held. What shipped: ELBv2createLoadBalancerwaits foractiveviawaitUntilLoadBalancerAvailable(maxWaitTime600 matching Terraform'saws_lbcreate timeout,minDelay5 /maxDelay10 per the #1177 poll-cap sweep), gated onCDKD_NO_WAITand placed INSIDE the existing partial-create cleanuptry/catchso a waiter timeout deletes the LB rather than stranding it for the next deploy to hitDuplicateLoadBalancerName; update is untouched (SetSubnetsetc. act on an already-active LB). ECS Service KEEPS its non-wait default — CloudFormation waits for steady state and Terraform'swait_for_steady_statedefaults to false, nothing downstream needs a steady service (Fn::GetAttyieldsName/ServiceArnimmediately), and making it wait would be the one thing that puts Terraform's out-of-the-box default ahead of cdkd's — but the choice is no longer implicit: a new sharedsettleServicehelper prints the exactaws ecs wait services-stable --cluster X --services Ycommand on create AND update, and the new--full-waitflag opts intowaitUntilServicesStable(with a best-effortDeleteService --forcebefore failing a create, same partial-create reasoning as ELBv2).--full-waitand--no-waitare opposite ends of one axis and are rejected as a pair byvalidateWaitFlags, beforeapplyRoleArnIfSetso the rejection really does precede every AWS call. A one-shot post-CreateServicehealth probe was deliberately NOT added: at that moment a healthy and a doomed service are indistinguishable (ACTIVE/runningCount: 0/rolloutState: IN_PROGRESS), so it would warn on every deploy and imply a guarantee it cannot support.docs/cli-reference.mdreplaces the flat--no-waitlist with a six-column wait-semantics table (cdkd's three modes next to CloudFormation and Terraform) under a governing statement that cdkd is template-compatible with CloudFormation but NOT wait-semantics-identical: where the two engines agree cdkd matches them, where they disagree the default takes the dev/test-friendly side and--full-waitopts into the CloudFormation one, with ACM Certificate documented as the deliberate exception (an un-issued cert makes downstream CloudFront / ALB creates fail outright, and Terraform expresses that wait as a separateaws_acm_certificate_validationresource cdkd has no equivalent of). Tests: three new unit suites pin the default wait, the--no-wait/--full-waitgate, the poll caps, the create-side cleanup + its manual-command warning, and the negative contracts (ELBv2 update does not wait; an ECS update-side wait failure does NOT delete the service). Theecs-fargateinteg gained a Phase 0 rejecting--no-wait --full-waitand passes--full-waiton both the create and theCDKD_TEST_UPDATEredeploy, asserting aCOMPLETEDrollout withrunningCount == desiredCountimmediately after deploy returns — the only place the realwaitUntilServicesStablecall is exercised against AWS. A regression the gating itself introduced, fixed here (issue #1279): with therunningwait skipped,createInstance's post-launch IAM-instance-profile association check ran against apendinginstance, and AWS rejectsAssociateIamInstanceProfilefor anything notrunning/stopped. That is not a propagation error the retry loop absorbs -- it stays true until the instance is running, i.e. exactly the wait--no-waitasked to skip -- so the create FAILED and the stack rolled back. Every CDK L2ec2.Instancegets an instance profile, so--no-waitwas broken for all of them. The check is now skipped under--no-waitwith a warning that names the instance and the exactdescribe-iam-instance-profile-associations/associate-iam-instance-profilecommands to verify and repair, since a silently profile-less instance is a worse surprise than an unassigned IP; the default mode still enforces the association, so the fresh-profile propagation race the helper exists to close stays closed. Reachable only in combination with the #1276 routing fix (an L2 instance that still routes to Cloud Control never reaches this SDK code), which is why it surfaced during the benchmark rather than in theec2-instanceinteg. Known behavior change: any stack with an ELBv2 LoadBalancer now takes 90-180s longer on a default deploy. That is the fix working, and it is the clearest evidence the completion-definition policy is not reverse-engineered from desired benchmark numbers. No dependency or state-schema change.
Recently Implemented (2026-07-27):
- Lambda Function URL update-field removal resets to CFn defaults (issue #1160, lambda-url batch) —
src/provisioning/providers/lambda-url-provider.ts,tests/unit/provisioning/lambda-url-provider-update-removal.test.ts,tests/integration/cloudfront-function-url/(removal phase). Why: a priority-(3) batch of the #1160 umbrella audit.update()passedInvokeMode/CorsintoUpdateFunctionUrlConfigonly when present, and the API MERGES (live-probed 2026-07-27: an update omitting both retains the live values), so a property DROPPED from the template silently kept its old live value while CloudFormation resets it. What shipped (sharedclearOnUpdateRemovalhelper, #1223):InvokeMode->'BUFFERED'(the documented default),Corsremoval ->Cors: {}(live-probed: an empty Cors object clears CORS entirely;GetFunctionUrlConfigomits the field afterwards). The pre-existing Class-2 empty-placeholder sanitize is preserved by normalizing the all-emptyreadCurrentStateCors placeholder to "absent" on BOTH sides — a placeholder new side with real previous CORS still clears, and a placeholder-only previous never manufactures a spurious clear call. Thecloudfront-function-urlinteg gained a baseline URL-config assertion + aCDKD_TEST_REMOVAL=truephase 1b (drop both fields, assertInvokeModeback toBUFFERED+Corsgone). No deferrals:AuthTypeis CFn-required (always sent),TargetFunctionArn/Qualifierare create-only. - DLM LifecyclePolicy update-field removal resets to CFn defaults (issue #1160, dlm batch) —
src/provisioning/providers/dlm-lifecycle-policy-provider.ts,tests/unit/provisioning/providers/dlm-lifecycle-policy-provider-update-removal.test.ts,tests/integration/dlm-lifecycle-policy/(removal phase 2b). Why: a priority-(3) batch of the #1160 umbrella audit.update()mapped the default-policy shorthand fields intoUpdateLifecyclePolicyonly when present, and the API MERGES (live-probed 2026-07-27: an update carrying only PolicyId retains every live value), so a field DROPPED from the template silently kept its old live value while CloudFormation resets it. What shipped (sharedclearOnUpdateRemovalhelper, #1223):CreateInterval->1,RetainInterval->7,CopyTags->false,ExtendDeletion->false,CrossRegionCopyTargets->[], andExclusions->the per-DefaultPolicyexplicit-empty clear shape (VOLUME:{ExcludeBootVolumes:false, ExcludeVolumeTypes:[], ExcludeTags:[]}; INSTANCE:{ExcludeTags:[]}— the volume-only sub-fields are live-probe REJECTED on IMAGE_MANAGEMENT policies). All resets live-probed as accepted + applied; the service create-defaults empirically match CFn's documented defaults. A KEPT-but-partialExclusionsneeds no #1225-style handling — the API replaces the whole object (live-probed), so pass-through is already sub-field parity. Thedlm-lifecycle-policyinteg gained a second always-DISABLED VOLUME default policy + baseline assertions + aCDKD_TEST_REMOVAL=truephase 2b. Deferred with in-code rationale:Description(UpdateLifecyclePolicy rejects'', no documented clear sentinel — no wire shape can reset it),State/ExecutionRoleArn/PolicyDetails(required-on-create, no CFn default). - ElastiCache CacheCluster/SubnetGroup update-field removal resets to CFn defaults (issue #1160, elasticache batch) —
src/provisioning/providers/elasticache-provider.ts,tests/unit/provisioning/elasticache-provider-update-removal.test.ts. Why: the priority-(2)elasticachebatch of the #1160 umbrella audit (absent-field removal silent-drop bug class, reference fixLambdaFunctionProvider#1157).updateCacheCluster/updateSubnetGrouppassed optional fields intoModifyCacheCluster/ModifyCacheSubnetGroup, which MERGE (absent = "no change"), so a property DROPPED from the template silently kept its old live value while CloudFormation resets it. What shipped (sharedclearOnUpdateRemovalhelper, #1223): cluster —SnapshotRetentionLimit->0(CFn default; SDK doc: 0 turns backups off),AutoMinorVersionUpgrade->true(service create-default, live-verified 2026-07-27),NotificationTopicArnremoval -> the documented disable sentinelNotificationTopicStatus: 'inactive'(no clear-to-empty input exists; a KEPT ARN is now sent with an explicit'active'status so re-adding a topic after a removal reactivates delivery), andLogDeliveryConfigurationsper-LogType disable entries{ LogType, Enabled: false }for log types dropped from the template (ModifyCacheCluster applies each request entry to only its own LogType, so BOTH whole-property and per-entry removal are covered); subnet group — a removedDescription/CacheSubnetGroupDescriptionresets to the sameSubnet group for ${logicalId}defaultcreateSubnetGroupsynthesizes (create/update parity).readCurrentStatenow gates itsNotificationTopicArnemit onTopicStatus !== 'inactive'so the post-removal read round-trips clean. Deliberately NOT reset (in-code rationale):EngineVersion(no downgrade; removal implies an engine-default version change cdkd must not synthesize — #1222 rationale),CacheParameterGroupName(default.<family>is engine+version dependent),PreferredMaintenanceWindow/SnapshotWindow(AWS-assigned random windows, no documented reset sentinel),IpDiscovery(removal only meaningful ondual_stackclusters whose default AWS does not document; a wrong reset flips client DNS resolution),VpcSecurityGroupIds(#1160-audit UNCERTAIN, existing empty-guard). Tests: the #1157-style trio per API + per-entry log-delivery removal + the notification active/inactive round-trip gate, also pinning the deliberate non-resets (fails-without-fix proven via stash revert: 6 failed / restored 9/9). Live-verified end-to-end via cdkd (us-east-1, rediscache.t3.microsingle node): phase 1 deploy withSnapshotRetentionLimit: 3+ a same-stack SNSNotificationTopicArn(AutoMinorVersionUpgrade omitted — DescribeCacheClusters reads the create defaulttrue), phase 2 addsAutoMinorVersionUpgrade: false, phase 3 DROPS all three plus the subnet-groupDescriptionand DescribeCacheClusters/DescribeCacheSubnetGroups confirm retention0, topic statusinactive, AMVU back totrue, and the reset description — then destroy clean. Part of #1160 (umbrella stays open). No CLI flag, dependency, or state-schema change. - Neptune + DocDB update-field removal resets to CFn defaults (issue #1160, bundled neptune + docdb batch) —
src/provisioning/providers/neptune-provider.ts,src/provisioning/providers/docdb-provider.ts,tests/unit/provisioning/neptune-provider-update-removal.test.ts,tests/unit/provisioning/docdb-provider-update-removal.test.ts,tests/integration/docdb-neptune/. Why: the priority-(2) neptune/docdb batch of the #1160 umbrella audit — both families are ModifyDBCluster / ModifyDBInstance twins of the RDS batch (PR #1222).updateDBCluster/updateDBInstance/updateDBSubnetGrouppassed optional fields into merge-semantics Modify APIs (absent = "no change"), so a property DROPPED from the template silently kept its old live value — worst caseDeletionProtection: the user removes it, cdkd reports success, but the live cluster (or Neptune instance) still refuses deletion. What shipped: the sharedclearOnUpdateRemovalhelper resets the safely-resettable fields to their documented CFn/API defaults — Neptune cluster:DeletionProtection->false,BackupRetentionPeriod->1,IamAuthEnabled(-> Modify'sEnableIAMDatabaseAuthentication)->false; Neptune instance:DeletionProtection->false,AutoMinorVersionUpgrade->true(CreateDBInstance doc: "Default: true"); DocDB cluster:DeletionProtection->false,BackupRetentionPeriod->1; both families' DBSubnetGroup: a removedDBSubnetGroupDescriptionresets to cdkd's create-timeSubnet group for <logicalId>fallback (CFn declares the property required, so this is create/update parity for cdkd's tolerant template shape). Deliberately NOT reset (in-code rationale per method):EngineVersion(engine-default synthesis),DBClusterParameterGroupName/DBParameterGroupName(engine-version-dependentdefault.*family name),PreferredBackupWindow/PreferredMaintenanceWindow(AWS assigns a RANDOM window at create; no documented reset sentinel),VpcSecurityGroupIds(#1160-audit UNCERTAIN, existing empty-guard),Port/DBPort(engine-dependent default), DocDBMasterUserPassword(secret), DocDB instanceAutoMinorVersionUpgrade(inert — the SDK doc says DocDB never performs minor version upgrades regardless of the value). Tests: the #1157-style trio per API (removed -> exact reset; never-present -> stays absent; mixed -> kept fields pass through), also pinning the deliberate non-resets. The shareddocdb-neptuneinteg gained aCDKD_TEST_REMOVAL=truephase: the baseline deploy setsDeletionProtection: true+BackupRetentionPeriod: 7on both clusters (+IamAuthEnabled: trueon Neptune), the removal redeploy DROPS them, verify.sh polls DescribeDBClusters until both clusters read the CFn defaults, and the destroy runs WITHOUT--remove-protectionas live proof the reset landed (cleanup hardened with best-effort protection flip-offs for aborted runs — the #1222 pattern). No CLI flag, dependency, or state-schema change. - Cloud Control DELETE treats a handler-reported
ErrorCode: NotFoundas already-gone, and the failed-CREATE remnant cleanup no longer warns on a not-found (issue #1252) —src/provisioning/cloud-control-provider.ts,tests/unit/provisioning/cloud-control-provider.test.ts,.markgate.yml. Why: during the 0727 integ sweep,codedeploy-lambda-deployment-group's failed-CREATE remnant delete FAILED with CodeDeploy'sNo Deployment Group found for name: ...(Status Code 400) — a not-found whose wording contains none of the canonical substrings (not found/does not exist/NotFound) the DELETE idempotency check matched on, so cdkd emitted a misleading "a retry may fail with AlreadyExists until it is removed manually" warning over a benign already-gone state (the in-run retry then created the DG fine). What shipped: (1)delete()'s idempotent-success condition now ALSO accepts the STRUCTURED signal — aCloudControlOperationFailedErrorfrom an async FAILED DELETE carryingccErrorCode === 'NotFound'— under the sameassertRegionMatchguard as the string forms; (2)cleanupFailedCreateRemnant's catch downgrades service-worded not-found messages (new exportedisNotFoundMessage, which adds theno ... foundshape) to an "already gone; nothing to clean up" info — scoped to the remnant path where a false positive only downgrades a warning, NOT used for the main DELETE idempotency decision; (3).markgate.yml'sinteg-destroyscope gainssrc/provisioning/cloud-control-provider.ts(the CC delete/remnant path is deletion logic but was outside the gate's include list). Tests: structured-NotFound DELETE resolves (+ region-guard negative + non-NotFound still throws), both remnant shapes warn-free,isNotFoundMessagepositive/negative table — fails-without-fix proven via sed-swap revert (4 failed, restored 87/87). No CLI flag, dependency, or state-schema change. - SQS
DeduplicationScope/FifoThroughputLimitreset on template removal (issue #1160 sqs follow-up mini-batch) —src/provisioning/providers/sqs-queue-provider.ts,tests/unit/provisioning/sqs-queue-provider-update.test.ts,tests/integration/fifo-sqs-event-source/. Why: the #1249 review surfaced two SUSPECT entries PR #1240's sqs batch missed: both attributes are CFn "No interruption" in-place updateable (registry-schema createOnly is only[FifoQueue, QueueName]), but neither had aSQS_ATTRIBUTE_REMOVAL_RESETentry, so dropping them from the template silently kept the live high-throughput config (SetQueueAttributesmerges absent attributes) while CloudFormation resets them. What shipped:DeduplicationScope -> 'queue'andFifoThroughputLimit -> 'perQueue'added to the removal-reset map (the documented defaults — a bare FIFO queue reads exactly those values, live-verified 2026-07-27). FIFO-only, same no-guard reasoning asContentBasedDeduplication(the reset only fires when previousProperties carried the key, which only a FIFO queue can). Combined-shape semantics live-probed: both-removed ('queue'+'perQueue'in one call) accepted; only-FifoThroughputLimit-removed with keptDeduplicationScope: 'messageGroup'accepted; only-DeduplicationScope-removed while the template KEEPSFifoThroughputLimit: 'perMessageGroupId'is loudly rejected by AWS (InvalidAttributeValue, queue state unchanged) — that is CFn PARITY (CloudFormation's reset-to-default hits the same service constraint), so the reset is deliberately passed through with NO suppression guard, pinned by a dedicated unit test. Tests: the #1157-style trio per field + the combined one-call shape + the kept-inconsistent parity case (fails-without-fix proven via sed-swap revert: 4 new tests + the round-trip placeholder test failed, restored 21/21). Thefifo-sqs-event-sourceinteg baseline now setsdeduplicationScope: MESSAGE_GROUP+fifoThroughputLimit: PER_MESSAGE_GROUP_IDand theCDKD_TEST_REMOVAL=truephase 1b drops all three FIFO attributes, polling until CBD/scope/limit readfalse/queue/perQueuewith an unchangedCreatedTimestamp(in-place). Live-verified end-to-end against real AWS (us-east-1). Part of #1160 (umbrella stays open). No CLI flag, dependency, or state-schema change. - Rollback reverse-replacement no longer deletes the live resource on a name-idempotent re-create (issue #1247, rollback sibling of #1238) —
src/deployment/rollback-executor.ts,tests/unit/deployment/rollback-executor.test.ts. Why: the deploy engine's #1238NAMED_REPLACEMENT_IDEMPOTENT_CREATEguard (PR #1248) closed the create-first same-name hazard on deploy, but the rollback executor's reverse-replacement replay carried the SAME shape: it re-creates the OLD resource create-first while the NEW resource is still alive, and a name-idempotent Create API whose old/new resources share a user-supplied name silently returns the LIVE new resource's physicalId as the "re-created old" one — the delete-new step then deleted the very resource just recorded in state (silent resource deletion + state divergence). Reachability is narrow (needs a journaled replacement op — old id != new id — whose old/new share a name, i.e. a--replacedelete-first forward replacement of a name-idempotent type with a non-name-derived physicalId; SQS is structurally immune because its URL is name-derived) but real. What shipped: after the create-first re-create returns, ifcreateResult.physicalId === current.physicalId(and the new resource was NOT already deleted by the collision fallback), the delete-new step is skipped and the op WARN-AND-ADOPTS instead of hard-failing: state keeps the intended post-rollback record (previousState properties + the live physical id), the op is marked replayed with an exit-2 warning naming what was NOT reverted (the old resource's original properties may not have been re-applied) and pointing atcdkd drift/cdkd deploy/ a rename for reconciliation. Deliberately DIFFERENT semantics from the deploy-side hard-fail because rollback is a RECOVERY flow: a segment failure would block the segment pop and strand the user in a replay loop that can never succeed (memory rule: fail-fast on a state the user is re-running to FIX strands the resource). Anupdate()re-apply is deliberately not attempted (the op was classified reverse-replacement precisely because the property is immutable in place), and an automatic delete-first fallback is not taken either (unlike the collision case — where the Create THREW — the Create here RETURNED the only live copy; deleting it on speculation risks total loss with no--replace-style opt-in). The delete-new-first path is exempt (re-acquiring the same id after the new resource is gone is the expected outcome, mirroring the deploy-side guard's delete-first exemption). Tests: same-id shape -> no delete of the live resource, warnings=1 / failures=0, prev-properties state record, op replayed (fails-without-fix proven via sed-swap revert: pre-fix the delete fired); delete-new-first same-id exemption -> no warning; different-id shape pinned unchanged by the pre-existing #1199 suite. No CLI flag, dependency, or state-schema change. - SQS
ContentBasedDeduplicationclassified as updateable — no more queue replacement on a CBD toggle (issue #1237) —src/analyzer/replacement-rules.ts,tests/unit/analyzer/replacement-rules-sqs.test.ts,src/provisioning/providers/sqs-queue-provider.ts(stale-NOTE removal only),tests/integration/fifo-sqs-event-source/. Why: found during the #1160 sqs-batch live verification. TheAWS::SQS::Queuerule listedContentBasedDeduplicationinreplacementProperties, but CloudFormation documents it as "Update requires: No interruption" and the registry schema'screateOnlyPropertiesis only[FifoQueue, QueueName]—SetQueueAttributesflips it in place on a live FIFO queue (live-verified 2026-07-27 via raw SDK and again viacdkd deploy). The misclassification forced a DELETE+CREATE on any CBD change (message loss, plus the #1238 same-name deletion hazard) and made the #1160 removal reset (SQS_ATTRIBUTE_REMOVAL_RESET'sContentBasedDeduplication -> 'false') unreachable through a plain deploy. What shipped:ContentBasedDeduplicationmoved toupdateableProperties(explicitly classified so the DescribeType createOnly fallback can never override it); the provider's and its unit test's "currently UNREACHABLE via a plain deploy" NOTEs removed.DeduplicationScope/FifoThroughputLimitwere audited in the same pass: they are unlisted in the rule and correctly resolve to in-place via the fall-through + schema fallback (not the same misclassification class — no change). Tests:replacement-rules-sqs.test.tspins CBD as in-place UPDATE +isClassifiedtrue + QueueName/FifoQueue still replacement (fails-without-fix proven via temporary revert: 1 failed / restored 11/11). Thefifo-sqs-event-sourceinteg gained aCDKD_TEST_REMOVAL=truephase 1b that DROPScontentBasedDeduplicationon redeploy and asserts the live queue readsfalse(bounded 60s retry per SetQueueAttributes propagation docs) with an unchangedCreatedTimestamp(in-place, replacement-guarded re issue #1238), then destroys clean. Live-verified end-to-end (deploy -> toggle false -> remove entirely -> destroy, same CreatedTimestamp throughout). No CLI flag, dependency, or state-schema change. - Same-name replacement fails loudly instead of silently deleting the resource (issue #1238) —
src/deployment/deploy-engine.ts,tests/unit/deployment/deploy-engine-same-name-idempotent-create.test.ts. Why: observed live (2026-07-27, us-east-1): a FIFO SQS queue with an explicitQueueNameunderwent a property-driven replacement; SQSCreateQueueis name-idempotent, so the create-first attempt returned the OLD queue's URL as the "new" physicalId instead of colliding, and the flow's delete-old step then deleted that very queue — deploy reported success, the resource was gone, state still recorded it (silent resource deletion + state divergence). The pre-existingNAMED_REPLACEMENT_COLLISIONhandling (#960 follow-up) only caught Create APIs that THROW "already exists"; name-idempotent Creates sailed past it. What shipped: after the replacement CREATE returns, the engine compares the new physicalId against the old one wherever the old resource was still alive at create time. On a match: (a) property-driven create-first without--replacefails the resource with the actionableNAMED_REPLACEMENT_IDEMPOTENT_CREATEerror (rename / remove the explicit name, or re-run withcdkd deploy --replace) — no delete is issued, the old resource stays live, and its state record is preserved verbatim; (b) under--replacethe engine falls back to the same delete-first + re-create sequence as the collision path (now shared viareplaceDeleteFirstAndRecreate, covering the SQS ~60sQueueDeletedRecentlycooldown); (c)UpdateReplacePolicy: Retainhard-fails (Retain pins the name, and the create-first same-id outcome would otherwise re-adopt the resource Retain just orphaned) — including the--recreate-via-*+ Retain variant, which skips the destroy and would silently re-adopt without applying the new properties. Delete-first paths (--replacefallback,--recreate-via-*without Retain, CC UPDATE-unsupported fallback) are exempt: re-acquiring the same physical id under the same name after the old resource is gone is the expected outcome. Live-verified end-to-end (deploy CBD FIFO queue → CBD removal fails with the guard, queue + state intact →--replacere-run replaces cleanly through the cooldown → clean destroy). - SecretsManager Secret update-field removal resets to CFn defaults (issue #1160, secretsmanager batch) —
src/provisioning/providers/secretsmanager-secret-provider.ts,tests/unit/provisioning/secretsmanager-secret-provider-roundtrip.test.ts,tests/integration/secrets-dynamic-ref/. Why: the priority-(3)secretsmanagerbatch of the #1160 umbrella audit (absent-field removal silent-drop bug class, reference fixLambdaFunctionProvider#1157).update()gatedDescription/KmsKeyIdon!== undefined(KmsKeyId additionally skipping'') intoUpdateSecret, which MERGES (absent = "no change"), so a property DROPPED from the template silently kept its old live value while CloudFormation resets it. What shipped: both fields route through the sharedclearOnUpdateRemovalhelper (#1223) with clear value''—Description: ''clears the description, andKmsKeyId: ''is the SDK-documented "use the Amazon Web Services managed key aws/secretsmanager" sentinel (live-probed 2026-07-27: accepted, and DescribeSecret afterwards OMITS KmsKeyId — the pristine never-had-a-key shape, fully drift-clean; the previous in-code claim that AWS rejects''as an invalid ARN was empirically false and the comment was corrected;alias/aws/secretsmanageralso works but leaves an explicit alias in DescribeSecret, so''is the strictly better sentinel). The''readCurrentState-placeholder defenses are preserved and strengthened: a placeholder-only previous side fires no reset, and a placeholder never passes through as a customer-key value — which also fixes a latentdrift --revertgap where a console-side KmsKeyId add could not be reverted to the managed key. Tests: the #1157-style trio per field + placeholder-previous no-reset + kept-customer-key pass-through (fails-without-fix proven via sed-swap revert: 3 failed / restored 14/14). Thesecrets-dynamic-refinteg gained baseline Description/KmsKeyId assertions and aCDKD_TEST_REMOVAL=truephase 2 that drops both and asserts DescribeSecret reports the pristine shape (both absent), then destroys clean. Deferred (in-code rationale):Typekeeps its emit-when-present gate — the #1160 UNCERTAIN bucket tracks it (partner-managed secrets, no documented clear sentinel, unprobeable without a partner-linked secret). No CLI flag, dependency, or state-schema change. - SQS Queue update-attribute removal resets to CFn defaults (issue #1160, sqs batch) —
src/provisioning/providers/sqs-queue-provider.ts,tests/unit/provisioning/sqs-queue-provider-update.test.ts,tests/integration/sns-sqs-event/. Why: the priority-(3)sqsbatch of the #1160 umbrella audit.SQS_ATTRIBUTE_REMOVAL_RESET(the provider's local removal-reset map) covered 9 attributes but missedContentBasedDeduplicationandSqsManagedSseEnabled, so dropping either from the template silently kept the live value (SetQueueAttributes merges absent attributes) while CloudFormation resets them. What shipped:ContentBasedDeduplication->'false'(FIFO-only; the reset can only fire on a FIFO queue because only those can have carried the attribute) andSqsManagedSseEnabled->'true'(SSE-SQS is the service default), PLUS a mandatory mutual-exclusion guard: the SSE reset is skipped when the desired properties carry a non-emptyKmsMasterKeyId— live probe confirmed AWS rejectsKmsMasterKeyId+SqsManagedSseEnabled='true'in one SetQueueAttributes call ("You can use one type of server-side encryption (SSE) at one time"), while the both-removed shape (KmsMasterKeyId=''+SqsManagedSseEnabled='true') is accepted; the empty-string readCurrentState placeholder does NOT suppress the reset. Tests: the #1157-style trio per field + the KMS-guard / empty-placeholder / both-removed cases (two-stage fails-without-fix proof: map entries bind, guard binds). Thesns-sqs-eventinteg gained an SSE-removal L1 queue (SqsManagedSseEnabled: false) and aCDKD_TEST_REMOVAL=truephase 2 that drops the property and asserts the live queue readstrueagain with an unchangedCreatedTimestamp(in-place, replacement-guarded re issue #1238), then destroys clean. Known limitation (filed): theContentBasedDeduplicationreset is unreachable through a plain deploy until issue #1237 (replacement-rules misclassifies CBD as replacement-requiring) is fixed — it is pinned by unit tests + a raw-SDK live probe instead. No CLI flag, dependency, or state-schema change. - IAM Role update-field removal resets to CFn defaults (issue #1160, iam-role batch) —
src/provisioning/providers/iam-role-provider.ts,tests/unit/provisioning/iam-role-provider.test.ts,tests/integration/iam-role-prefixed-name-update/. Why: the priority-(3)iam-rolebatch of the #1160 umbrella audit (the absent-field removal silent-drop bug class, reference fixLambdaFunctionProvider#1157).IAMRoleProvider.update()gatedDescription/MaxSessionDurationon!== undefinedintoUpdateRole, and that API MERGES (absent = "no change" — live-verified 2026-07-27; the SDK doc's "default value of one hour is applied" sentence is CreateRole text and does NOT apply to UpdateRole), so a property DROPPED from the template silently kept its old live value while CloudFormation resets it. What shipped: both fields route through the sharedclearOnUpdateRemovalhelper (#1223):Description->''(the documented clear sentinel),MaxSessionDuration->3600(the IAM/CFn default). Never-present fields stay absent from the input, and the drift-revertDescription: ''pass-through rationale is preserved. Deferred fields: none — these are the only two UpdateRole fields; AssumeRolePolicy / PermissionsBoundary / policies / tags already handle removal via dedicated Delete/Detach calls. Tests: the #1157-style trio (removed ->''/3600; never-present -> absent; mixed -> kept fields pass through); the pre-existing test that PINNED the silent drop (asserting omission on removal) was converted into the never-present case. Theiam-role-prefixed-name-updateinteg gained aCDKD_TEST_REMOVAL=truephase 3 (kept separate fromCDKD_TEST_UPDATEso the migration-prompt regression assertions stay non-vacuous): phases 1-2 setdescription+maxSessionDuration: 7200, phase 3 drops them and asserts the live role reads cleared / 3600 with an unchanged RoleId, then destroys clean. No CLI flag, dependency, or state-schema change. -
--strict+--ignore-errorsannotation flags onsynth/deploy(issue #1230, CDK CLI parity follow-up to #1228) —src/synthesis/stack-messages.ts(StackMessageOptions+ failAt resolution),src/cli/options.ts(sharedannotationMessageOptions),src/cli/commands/synth.ts+deploy.tswiring,tests/unit/synthesis/stack-messages.test.ts. Why: #1228 shipped the always-on default (fail on error annotations, display warnings/infos) with no knobs; the CDK CLI carries two related global flags cdkd lacked. Flag semantics were verified against the CDK CLI SOURCE (validateMetadataFailAtinaws-cdk/lib/cli/cdk-toolkit.ts) + empirically (the originally-planned--no-validationturned out to control thevalidateOnSynthpolicy-validation path, NOT annotations — the real bypass is the global--ignore-errors). What shipped:processStackMessages(stacks, logger, options?)now logs every message at its level ([Error at /path] …vialogger.error, matching the CDK CLI's log-lines-then-bare-Found errorsoutput shape) and resolves the failure threshold exactly like the CLI: default fails on errors (Found errors);--strictalso fails on warnings (Found warnings (--strict mode); errors win when both exist; infos never fail);--ignore-errorsdisplays everything but never fails ("will likely produce an invalid deployment" — same caveat as upstream); strict overrides ignoreErrors when both are passed (upstream precedence). Both flags registered onsynthANDdeployvia one shared option pair. Tests: strict warning-fail / info-pass / error-precedence, ignore-errors display-but-proceed, combined-flags precedence, plus the reshaped default-path assertions (error lines now logged, bareFound errorsthrown). Live-verified against aws-cdk-lib 2.248.0 side-by-side with the real CDK CLI:--ignore-errorsexits 0 on an addError app (error line still displayed),--strictexits 1 on a warning-only app with the exact upstream message, on bothsynthanddeploy --dry-run. Also restores the #1225 changelog bullet header that a prior merge-conflict resolution had glued into the #1228 entry. No dependency or state-schema change.
Recently Implemented (2026-07-26):
- CDK annotation messages: fail on
addError, displayaddWarning/addInfo(issue #1228) — newsrc/synthesis/stack-messages.ts,src/synthesis/assembly-reader.ts(StackInfo.messages),src/types/assembly.ts(ArtifactManifest.additionalMetadataFile),src/cli/commands/synth.ts+deploy.tswiring,tests/unit/synthesis/stack-messages.test.ts. Why: cdkd previously ignored CDKAnnotationsentirely — a stack carrying an error annotation (the mechanism L2 constructs/aspects use to mark must-not-deploy configurations) sailed throughcdkd synth/cdkd deploywith exit 0 and got deployed, where the CDK CLI refuses withFound errorsexit 1; warnings/infos were never shown. What shipped:collectStackMessagesreads annotation entries from BOTH cloud-assembly layouts — the artifact's inlinemetadata(older aws-cdk-lib) and the<artifactId>.metadata.jsonside file referenced byadditionalMetadataFile(current aws-cdk-lib; a referenced-but-unreadable side file throws, fail-closed) — intoStackInfo.messages.processStackMessagesprints[Warning at /path] …/[Info at /path] …and throwsSynthesisErrorwith the CDK CLI format ([Error at /path] …+Found errors, exit 1) when any stack in the given set carries an error.synthchecks all synthesized stacks;deploychecks the FINAL deploy set (selection-aware like the #1150 deferred macro expansion — an error in a non-selected sibling stack does not block deploying the selected ones) before macro expansion / assets / locks / any AWS mutation.diff/list/ other synth-driven commands deliberately unchanged (upstreamcdk diffdoes not fail on error annotations either). Live-verified against aws-cdk-lib 2.248.0: error app → synth + deploy exit 1 with the two-line format; warning+info app → both lines displayed, run proceeds; 2-stack app with the error on the non-selected stack → selected stack deploys, targeting the bad stack fails. - Sub-field-removal classification inside kept config objects (issue #1225, first pass) —
src/provisioning/providers/asg-provider.ts+ecs-provider.ts(comment-only),tests/unit/provisioning/asg-provider-update-removal.test.ts. Why: the #1160 clear-on-removal batches reset TOP-LEVEL property removal; a sub-field dropped from a KEPT config object is the same bug class one level down and had no recorded classification, inviting a future "helpful" normalization that would break CFn parity. What shipped (doc-based classification + pins, no behavior change): ASGInstanceMaintenancePolicykept-partial is REJECTED by AWS (SDK doc: both percentages must be specified) — CloudFormation submits the same partial object, so the loud failure IS parity and pass-through is correct (pinned by a new unit test asserting no sub-field synthesis); ASGCapacityReservationSpecificationkept-partial is UNPROBED (a live probe needs a billed Capacity Reservation) and passes through verbatim (pinned); ASGAvailabilityZoneDistributionhas a single sub-field (no partial shape). ECSDeploymentConfiguration's comment now records that its sub-field merge ALSO makes a kept-partial block silently retain dropped sub-fields — tracked in #1225 with the whole-block reset (both blocked on the same live probes: deployment-type-dependent defaults + circuit-breaker/alarms clear shapes). Remaining #1225 work (narrowed on the issue): those ECS probes, the ASG CapacityReservationTarget probe, and a LambdaImageConfigkept-partial probe. No CLI flag, dependency, or state-schema change. - Shared
clearOnUpdateRemovalhelper extracted (issue #1223) — newsrc/provisioning/update-removal.ts, edits tosrc/provisioning/providers/{lambda-function,ecs,rds,asg}-provider.ts, newtests/unit/provisioning/update-removal.test.ts,docs/provider-development.md§2a. Why: the #1160 clear-on-removal fix pattern had accumulated four verbatim private copies ofclearOnUpdateRemoval<T>(newValue, previousValue, clearValue)(Lambda #1157, ECS #1164, RDS #1222, ASG #1224), and every remaining SUSPECT-family batch would add another. What shipped: the tri-state resolver (present -> pass through; removed -> explicit reset; never present -> stay absent) now lives once insrc/provisioning/update-removal.tswith the canonical JSDoc; all 41 call sites across the four providers import it, the private copies are deleted, and §2a's reference snippet points at the shared helper. Pure mechanical refactor — no behavior change; the per-provider §2a trio suites still pin the end-to-end semantics and a new module-own test pins the helper's contract directly (incl. falsy-but-present values never being replaced). No CLI flag, dependency, or state-schema change. - ASG update-field removal resets to CFn defaults (issue #1160, asg batch) —
src/provisioning/providers/asg-provider.ts,tests/unit/provisioning/asg-provider-update-removal.test.ts,tests/integration/launchtemplate-asg-inplace/. Why: the priority-(1)asgbatch of the #1160 umbrella audit (the absent-field removal silent-drop bug class, reference fixLambdaFunctionProvider#1157, pattern twin of the ECS batch #1164).ASGProvider.update()spread every optional field intoUpdateAutoScalingGrouponly when present, and that API MERGES (absent = "no change"), so a property DROPPED from the template silently kept its old live value while CloudFormation resets it to the default. What shipped: theclearOnUpdateRemoval(next, previous, clearValue)helper routes 13 fields, each reset to its CFn default or SDK-documented clear sentinel:HealthCheckType->'EC2',HealthCheckGracePeriod->0,Cooldown/DefaultCooldown(treated as ONE logical field across both template spellings)->300,TerminationPolicies->['Default'],NewInstancesProtectedFromScaleIn->false,CapacityRebalance->false,MaxInstanceLifetime->0(documented clear sentinel),DesiredCapacityType->'units',DefaultInstanceWarmup->-1(documented remove sentinel),InstanceMaintenancePolicy->{MinHealthyPercentage: -1, MaxHealthyPercentage: -1}(documented clear sentinels),CapacityReservationSpecification->{CapacityReservationPreference: 'default'},AvailabilityZoneDistribution->{CapacityDistributionStrategy: 'balanced-best-effort'},DeletionProtection->'none'. Deliberately NOT reset (in-code rationale):DesiredCapacity(CFn leaves capacity unmanaged when absent — scaling policies own it),LaunchTemplate<->MixedInstancesPolicy/VPCZoneIdentifier<->AvailabilityZones(mutually-exclusive pairs — a removal is a switch),ServiceLinkedRoleARN(no clear sentinel),Context(reserved),SkipZonalShiftValidation(transient request flag), andAvailabilityZoneImpairmentPolicy(DEFERRED — no SDK-documented sub-field defaults). Tests: the #1157-style trio (removed -> exact reset; never-present -> stays absent; mixed -> kept fields pass through) + dual-key Cooldown cases (spelling switch is not a removal). Thelaunchtemplate-asg-inplaceinteg gained aCDKD_TEST_REMOVAL=truephase 3 (kept separate fromCDKD_TEST_UPDATEso the #985 only-instanceType-changed assertion stays non-vacuous): phases 1-2 set non-defaultHealthCheckGracePeriod/MaxInstanceLifetime/TerminationPolicies, phase 3 drops them and asserts the live values return to defaults, then destroys clean. No CLI flag, dependency, or state-schema change. - RDS DBCluster/DBInstance update-field removal resets to CFn defaults (issue #1160, rds batch) —
src/provisioning/providers/rds-provider.ts,tests/unit/provisioning/rds-provider-update-removal.test.ts,tests/integration/rds-aurora/,tests/integration/rds-dbinstance-backfill/. Why: the priority-(2)rdsbatch of the #1160 umbrella audit (the absent-field removal silent-drop bug class, reference fixLambdaFunctionProvider#1157, pattern twin of the ECS #1164 / ASG batches).updateDBCluster/updateDBInstancepassed optional fields intoModifyDBCluster/ModifyDBInstance, which MERGE (absent = "no change"), so a property DROPPED from the template silently kept its old live value — worst caseDeletionProtection: the user removes it, cdkd reports success, but the live cluster/instance still refuses deletion. What shipped: theclearOnUpdateRemoval(next, previous, clearValue)helper resets the safely-resettable fields to their documented CFn/API defaults — cluster:DeletionProtection->false,BackupRetentionPeriod->1,MonitoringInterval->0,EnableIAMDatabaseAuthentication->false; instance:DeletionProtection->false,MonitoringInterval->0,EnableIAMDatabaseAuthentication->false. Deliberately NOT reset (in-code rationale per method):EngineVersion(removal implies an engine-default version change cdkd must not synthesize),ManageMasterUserPassword+MasterUserSecret(off-flip requires a MasterUserPassword),MonitoringRoleArn(no clear API; inert at interval 0),ServerlessV2ScalingConfiguration(engine-mode implications),PubliclyAccessible(context-dependent default),VPCSecurityGroups/VpcSecurityGroupIds(#1160-audit UNCERTAIN, existing empty-guard),AllocatedStorage(no shrink),Port/DBPortNumber(engine-dependent default). Tests: the #1157-style trio per API (removed -> exact reset; never-present -> stays absent; mixed -> kept fields pass through), also pinning the deliberate non-resets. Therds-aurorainteg (ModifyDBCluster) andrds-dbinstance-backfillinteg (ModifyDBInstance) each gained aCDKD_TEST_UPDATE=truephase: phase 1 setsDeletionProtection: true+EnableIAMDatabaseAuthentication: true, phase 2 DROPS both and polls DescribeDB* until both read false, and the phase-3 destroy runs WITHOUT--remove-protectionas live proof the reset landed (cleanup paths hardened with best-effort protection flip-offs for aborted runs). No CLI flag, dependency, or state-schema change. - CR response-client follow-ups:
setResponseBucketdrops its region-hint parameter + the finally-guard test pin (issue #1202, follow-up to #1195) —src/provisioning/providers/custom-resource-provider.ts,src/provisioning/provider-registry.ts,src/cli/commands/deploy.ts,tests/unit/provisioning/custom-resource-provider-response-bucket-region.test.ts. Why: (1) the #1195 region-hint client (new S3Client({ region: bucketRegion })insetResponseBucket) was built on the DEFAULT credential chain, dropping--profile— and withreuseClientCredentials: truethe region-correction rebuild inherited those default-chain credentials too (pre-existing: the pre-#1195 code built the client identically). The hint also added nothing post-#1195: the lazyensureResponseClient()probe resolves the bucket's ACTUAL region regardless of the starting client's region, and deploy.ts's hint was the deploy region — the shared client's region already. (2) The #1195 hardening's finally-block generation guard (a stale probe settling while a successor probe is in flight must not null the successor's single-flight promise) had no test directly pinning it. What shipped:setResponseBucket(bucket)/ProviderRegistry.setCustomResourceResponseBucket(bucket)take NO region parameter; deploy.ts passes only the bucket. Region correction now always starts from the sharedAwsClients.s3client, so--profile/ static credentials carry into both theGetBucketLocationprobe and the rebuilt client at every call site (deploy / destroy / drift / state / rollback). A new unit test pins the finally-block generation guard (stale probe A settles while successor probe B is in flight → B's promise survives, a third op joins B instead of starting a redundant probe C; break-tested — removing the guard fails it). Tests: the 7-test response-bucket-region suite updated for the new signature + the guard pin. Live-verified via thebench-cdk-sampleinteg run cross-region (AWS_REGION=us-west-2against the us-east-1 state bucket — itsCustom::VpcRestrictDefaultSGcustom resource exercises the presign path without the hint) with a clean destroy. Internal API change only (setResponseBucket/setCustomResourceResponseBucketare not user-facing); no CLI flag, dependency, or state-schema change. - Custom-resource
ResponseURLpresign region-corrects to the state bucket's actual region (issue #1195) —src/provisioning/providers/custom-resource-provider.ts,tests/unit/provisioning/custom-resource-provider-response-bucket-region.test.ts. Why: the custom-resource response bucket is cdkd's STATE bucket, and the S3 client that places the placeholder object and signs the pre-signedResponseURLPUT was built from the deploy/base region (setResponseBucket(bucket, baseRegion)from deploy.ts, or no region at all from the destroy / drift / state call sites). A pre-signed URL's host is region-specific, so deploying a CR-bearing stack to a region different from the state bucket's actual region (the account-scoped region-free default bucket) failed with an S3 301 PermanentRedirect ("must be addressed using the specified endpoint") on the placeholderPutObject. The state backend (#60), LockManager (#803), and ExportIndexStore (#819) already region-correct via the sharedrebuildClientForBucketRegionhelper (#827); the CR response path was the one remaining un-migrated state-bucket consumer. What shipped: a lazy, memoizedensureResponseClient()(mirroring the siblings'clientResolved/ single-flightresolveInFlightpattern) runs before the first response-bucket S3 operation ingenerateResponseURL, resolving the bucket's actual region via the cachedGetBucketLocationprobe and swapping in a region-corrected client (reuseClientCredentials;tolerateNonStandardClientso test doubles degrade to no-rebuild; the sharedAwsClients.s3original is never destroyed).setResponseBucket'sbucketRegionargument is now only a starting hint (its "state bucket is always in the base region" assumption was wrong), and re-calling it re-arms the probe. All call sites benefit, including the region-less destroy / drift / state ones. Tests: 5 new unit tests (corrected client carries the placeholder PutObject + cleanup + presign; helper receives the option set; null → original client kept; memoization; re-arm on re-set; concurrent ops share one in-flight probe). Live-verified via thecustom-resource-providerinteg run cross-region (AWS_REGION=us-west-2against the us-east-1 state bucket — the exact #1195 repro, which failed pre-fix) with a clean destroy. No new dependency, CLI flag, or state-schema change. -
cdkd rollbackreverse-replacement + opt-in--revert-failed(issues #1198, #1199 — follow-ups to #1183) —src/deployment/rollback-executor.ts,src/deployment/deploy-engine.ts,src/cli/commands/rollback.ts,src/types/rollback-journal.ts; unit tests intests/unit/deployment/{rollback-executor,deploy-engine-rollback-journal}.test.ts+tests/unit/cli/commands/rollback.test.ts. Why: two documented v1 limitations of #1183 — (1) a replacement op rolled back best-effort only (the old physical resource is gone, so replayingprovider.update()with old properties against the NEW resource is guaranteed to throw on the immutable property), and (2) the resource whose op FAILED mid-deploy was left as-is (only completed ops were journaled). What shipped (#1199):classifyRollbackOpnow routes a replacement op (previousState.physicalId !== op.physicalId) to a reverse-replacement: re-CREATE the old resource from its journaledpreviousState(routed via the recorded oldprovisionedBylayer) and delete the new one — create-first, with a delete-new-first + bounded name-release retry fallback when a user-supplied physical name collides.UpdateReplacePolicy: Retainis handled distinctly (reverse-replacement-readopt): the orphaned old resource still exists, so the new one is deleted and state re-adopts the old — a true clean revert. Stateful types (STATEFUL_TYPES) warn loudly that the old data is unrecoverable and the re-created resource starts empty. Applies to BOTH the standalone command and the in-process automatic rollback (shared executor); already-reverted (state at old physical id) →skip-already-done, neither-old-nor-new id →skip-mismatch. What shipped (#1198): the deploy engine journals the failed op(s) on each segment asfailedOperations(ADDITIVE journal field, nojournalVersionbump — old binaries ignore it): logicalId, changeType,previousState, physicalId,provisionedBy, and the intrinsic-RESOLVEDattemptedProperties(snapshotted just before the provider call — load-bearing so a patch-based revert diffs previous-vs-attempted instead of producing an empty patch). A failure before ANY op completed now writes a failed-only segment (previously skipped as empty).cdkd rollback --revert-failed(off by default — the failed resource's remote state is unknown) replays them before the segment's completed ops: failed UPDATE → force-applypreviousState; failed CREATE with a state record → delete; failed CREATE with nothing recorded → warn; failed DELETE → no-op (resource still in place). Without the flag the plan prints a "(left as-is … pass --revert-failed)" hint per failed op. Handled failed ops are stripped from the persisted segment right away and PER-OP (S3StateBackend.setRollbackJournalFailedOperationspersists only the still-pending list — also on the interrupt / partial-failure paths) so a re-run cannot re-issue a revert that already succeeded (the attempted-properties diff side would generate a patch undoing changes that no longer exist). A clean automatic rollback still deletes the journal (failed-op records included) — the flag targets--no-rollback/ interrupted / partially-failed-rollback journals. Tests: classify matrix for both features (Retain/Snapshot/already-done/mismatch; failed CREATE/UPDATE/DELETE variants), replay flows (re-create + delete-new order, collision retry, stateful warning, readopt, delete-new failure leak warning; failed-op force-revert diff sides previous-vs-attempted, partial-failure segment retention), engine journaling (failed op withpreviousState+attemptedProperties, failed-only segment), and command plumbing (--revert-failedon/off/failure). No CLI-breaking change, no state-schema bump, no journal-version bump. - Standalone
cdkd rollbackcommand to revert a failed--no-rollback/ interrupted deploy (issue #1183) — newsrc/cli/commands/rollback.ts, newsrc/deployment/rollback-executor.ts(extracted fromdeploy-engine.ts), newsrc/types/rollback-journal.ts, plus edits tosrc/deployment/deploy-engine.ts,src/state/s3-state-backend.ts,src/types/deployment-events.ts,src/cli/program.ts,src/cli/commands/deploy.ts,src/cli/commands/export.ts; newtests/integration/rollback-command/; unit tests undertests/unit/{deployment,types,state,cli/commands}/. Why: after a deploy failed with--no-rollback(or was interrupted with Ctrl+C, or its automatic rollback died partway), the only options were fix-forward (cdkd deploy) or clean-up (cdkd destroy) — there was no way to go back to the pre-deploy state (the cdkd equivalent ofcdk rollback/ CloudFormationRollbackStack). What shipped: the deploy engine now persists a rollback journal (s3://bucket/cdkd/{stack}/{region}/rollback-journal.json, a sibling ofstate.json, its ownjournalVersion— NOT a state-schema bump) whenever a deploy ends without a completed rollback: a--no-rollback/ output-resolution failure (reason: no-rollback-failure), a SIGINT (reason: interrupted, so an interrupted deploy is now revertible, not just resumable), and before every automatic rollback (reason: auto-rollback-started, deleted after a clean replay so a rollback that dies partway is resumable). Each journal holds onesegment(a verbatimCompletedOperation[]) per failed attempt; consecutive failed deploys append. The newcdkd rollback [STACK]command (synth-free) loads the journal and replays it newest-first via the sharedrollback-executor.ts, saving state after each op and popping each cleanly-replayed segment; when the oldest replayed segment was the stack's first-ever deploy and state ends empty,state.jsonis deleted too. Flags:--force,--orphan <logicalId>(repeatable, likecdk rollback --orphan),--stack-region,--role-arn,--state-bucket. Exit codes: 0 clean / 2 partial (journal kept, replay is idempotent so re-running is safe) / 1 hard error. The extraction added two behavior fixes shared by both callers: a rolled-back CREATE withDeletionPolicy: Retain/Snapshotis now ORPHANED (not deleted), and replay is idempotent (skips already-reverted / physical-id-mismatched / absent resources). The journal is deleted on the next successful deploy, after a clean rollback, and bycdkd destroy/cdkd state destroy(viadeleteState, which now sweeps the journal key).cdkd exportwarns + confirms if a journal exists.DeploymentRunCommandgained the'rollback'literal (additive, no event-schema bump) socdkd eventsrenders the standalone run. Tests: unit coverage for the executor (deletionPolicy matrix, skip rules,--orphan, ordering, per-op failure counting, interrupt), journal parse/version-guard, the backend journal CRUD +deleteStatesweep, the engine's per-reason segment writes +initialDeploy, and the command's resolution / error / terminal paths; therollback-commandinteg fixture exercises a real--no-rollbackfailure →cdkd rollback→ assert reverted + journal gone (incl. aninitialDeployscenario that deletesstate.json). No state-schema bump. -
AWS::Lambda::EventSourceMappingcachesEventSourceMappingArnforFn::GetAtt(issue #1190) —src/provisioning/providers/lambda-eventsource-provider.ts,tests/unit/provisioning/lambda-eventsource-provider.test.ts,tests/integration/eventsourcemapping-race/. Why:create()/update()recorded only{ Id }, soFn::GetAtt [Esm, EventSourceMappingArn]missed the cached-attribute path; the physical id is the ESM UUID (not ARN-shaped) and the resolver'sconstructAttributehas no ESM branch, so the resolver's shape guard HARD-FAILED the deploy (the #1179 GetAtt-key class, surfaced by the #1187sdk-attr-coveragecritic). What shipped: bothcreate()andupdate()now recordEventSourceMappingArnunder its exact CFn read-only name from the SDK response (createreadsresponse.EventSourceMappingArn;updatealready readupdateResp.EventSourceMappingArnfor the tag diff), so the ARN resolves from cached state. The #1187 lint'sSDK_ATTR_ALLOW_LISTentry that tracked this as a KNOWN GAP was removed, so theaudit:sdk-attr-coverage:checkcritic now verifies the ARN stays cached (a regression re-flags it). Tests: 2 new unit tests assert bothcreate()andupdate()return{ Id, EventSourceMappingArn }; theeventsourcemapping-raceinteg gained aCfnOutputonattrEventSourceMappingArnso the real-AWS deploy exercisesFn::GetAtt EventSourceMappingArn(pre-fix this failed to resolve). No CLI flag, dependency, or state-schema change; theimport()path (override-only) is unchanged. -
AWS::BedrockAgentCore::Runtimeimport()enriches the full read-only attribute set (issue #1188) —src/provisioning/providers/agentcore-runtime-provider.ts,tests/unit/provisioning/agentcore-runtime-provider.test.ts. Why: after #1179, deploy-created runtimes cache the type's read-only attributes under their CFn names, soFn::GetAtt [Runtime, AgentRuntimeArn]resolves from state. An imported runtime (cdkd import --resource <logicalId>=<agentRuntimeId>) recorded only{ AgentRuntimeId }, so the sameFn::GetAttmissed the cache and fell through to the livegetAttribute()path on every reference — correct (the #1179 fix widenedgetAttributeto resolve the full set), just an extraGetAgentRuntimeper distinct attribute reference instead of a cached read. What shipped:import()now issues oneGetAgentRuntimefor the supplied physical id and populatesattributesvia the samebuildAttributes()helpercreate()/update()use, so imported runtimes match deploy-created runtimes' cached-attribute behavior. Enrichment is best-effort: any failure (throttle, transient error, a--resourceid that does not resolve) falls back to the prior minimal{ AgentRuntimeId }record with a debug log — import never fails on the enrichment, and the livegetAttribute()path still resolves the ARN.WorkloadIdentityDetailsis not populated on the import path (GetAgentRuntimedoes not return it — create/update-response field only). Tests: 2 new / rewrittenimport()unit tests assert the enriched attribute map on a successfulGetAgentRuntimeand the minimal fallback on a thrown error. No CLI flag, dependency, or state-schema change; import coverage category is unchanged (still override-only). -
AWS::BedrockAgentCore::Runtimeenriches its read-onlyFn::GetAttattributes under the CFn names (issue #1179) —src/provisioning/providers/agentcore-runtime-provider.ts,tests/unit/provisioning/providers/agentcore-runtime-provider.test.ts. Why:create()/update()recorded the runtime ARN under the state-attribute keyArn, but CloudFormation's read-only attribute name for this type isAgentRuntimeArn. Output / cross-resourceFn::GetAttresolution looks the cached attribute up BY CFn name (IntrinsicFunctionResolver.constructAttributenever calls a provider'sgetAttribute), soFn::GetAtt [Runtime, AgentRuntimeArn]missed the cache, fell through to the unknown-attribute shape guard, and hard-failed because the physical id (the runtime NAME, e.g.cdkd_bench_cdkd-cKobI0ABvH) is not ARN-shaped — aCfnOutputreferencing the ARN broke the deploy. What shipped: a sharedbuildAttributes(response, agentRuntimeName)helper (used by bothcreate()andupdate()) now records the type's registry-schemareadOnlyPropertiesunder their exact CFn names —AgentRuntimeArn/AgentRuntimeId/AgentRuntimeVersion/Status/CreatedAt/WorkloadIdentityDetails(the last two only when the SDK response carries them;WorkloadIdentityDetailsis re-shaped to PascalCase viacamelToPascalCaseKeys), plusAgentRuntimeName.getAttribute()(the live-fetch path used by e.g.cdkd orphan) was widened to the same attribute set via oneGetAgentRuntimecall, keeping the legacyArnname as an alias. Both the container (fromContainerAsset) and S3 (fromCodeAsset) deploy paths are fixed. Tests: 8 new unit tests assert the CFn-named attribute map from create + update (incl. the omit-when-absent case and no leftoverArnkey) and the widenedgetAttributeresolution. No CLI flag, dependency, or state-schema change; existing state files carrying the oldArnkey are unaffected (the next deploy re-writes the attributes).
Recently Implemented (2026-07-23):
- ECS
readCurrentStatereverse-maps nested objects to CFn PascalCase (issue #1167, read-side follow-up to #1165) —src/provisioning/providers/ecs-provider.ts,tests/unit/provisioning/ecs-provider-readcurrentstate.test.ts,tests/unit/provisioning/ecs-provider-roundtrip.test.ts,tests/integration/ecs-service-update-props/. Why: #1165 fixed the SET path (create/update); its mirror on the READ side (readCurrentState, consumed bycdkd drift) still surfaced the same nested objects in the SDK's camelCase value shape under a PascalCase top-level key (result['DeploymentConfiguration'] = s.deploymentConfiguration->{maximumPercent, ...}), while cdkd's drift baseline uses PascalCase keys (the templateproperties, orobservedProperties).EphemeralStoragewas already re-PascalCased, so the read side was internally inconsistent. The drift comparator descends only into baseline keys, so when a resource's baseline falls back to the templateproperties(PascalCase) — theobservedProperties-absent window (state from an older binary, a capture failure, or before observed-capture existed) —cdkd driftcompared{MaximumPercent}(baseline) against{maximumPercent}(AWS read) and reported phantom drift that wasn't real. What shipped: the read side now reverse-maps SDK camelCase -> CFn PascalCase via the existingcamelToPascalCaseKeys(the inverse of the converter #1165 used) forAWS::ECS::ServiceDeploymentConfiguration/CapacityProviderStrategy/PlacementConstraints/PlacementStrategy+PlacementStrategies/ServiceRegistries/NetworkConfiguration/LoadBalancers,AWS::ECS::TaskDefinitionRuntimePlatform/ProxyConfiguration/PlacementConstraints, andAWS::ECS::ClusterConfiguration/DefaultCapacityProviderStrategy.DeploymentConfiguration.LifecycleHooks[].HookDetails(a free-form document) is preserved verbatim;ProxyConfigurationuses an explicit reverse-map (proxyConfigurationToCfn) because the SDK fieldpropertiesmaps back to CFnProxyConfigurationProperties. Scope note: the wholeContainerDefinitionsblob is still surfaced as raw SDK camelCase (a much larger structure needing the full inverse ofconvertContainerDefinitions, incl. the nestedLinuxParameters/SecretOptions#1165 added) — left as a further follow-up; drift on container-definition sub-fields was already inconsistent before #1165. Migration: no state-schema change; a resource deployed with an OLD binary whoseobservedPropertieswere captured in camelCase will phantom-drift ONCE against the new PascalCase read until its next deploy re-capturesobservedProperties(self-healing) — negligible in practice since these nested fields only became functional in #1165 (any real use requires a re-deploy anyway). Tests: 3 newreadCurrentStateunit tests feed camelCase SDK responses and assert PascalCase output for Cluster / Service / TaskDefinition (incl.HookDetails-verbatim andProxyConfigurationProperties); two pre-existing round-trip tests that asserted the old camelCase read output were flipped to PascalCase. Theecs-service-update-propsinteg gained a phase 1b that asserts the deploy-timeobservedPropertiesbaseline (captured FROM readCurrentState) carriesRuntimePlatform.CpuArchitecturein CFn PascalCase — pre-#1167 the read side emitted camelCase (runtimePlatform.cpuArchitecture) so the PascalCase lookup would be missing; live-verified against real AWS end-to-end with a clean destroy. (A whole-stackcdkd drift-clean assertion was deliberately not used: the same fixture'sContainerDefinitions— out of scope — and the ECS Service's ARN-form physicalId reading back drift-unknown would fail it for unrelated reasons.) No new dependency, CLI flag, or state-schema change. - ECS provider converts CFn PascalCase nested objects to SDK camelCase (issue #1165) —
src/provisioning/providers/ecs-provider.ts,tests/unit/provisioning/ecs-provider.test.ts,tests/integration/ecs-service-update-props/. Why: unlike most AWS SDK v3 clients,@aws-sdk/client-ecsuses camelCase input shapes while CloudFormation templates carry PascalCase keys.ECSProviderpassed several nested-object / array-of-objects properties (properties['X'] as T) straight into the SDK's camelCase input slots, so the SDK read the camelCase sub-keys it expected, found them absent, and serialized nothing — a silent drop on create AND update (deploy went green, state recorded the intended value,cdkd diffsaid "No changes", so the divergence was permanent and invisible — the SET-path twin of #1160). Confirmed live against real AWS 2026-07-22 forAWS::ECS::Service.DeploymentConfiguration(a customminHealthyPercent/maxHealthyPercent/ circuit breaker deployed as the AWS defaults). Only bit SDK-routed services (a service that trips the #614 CC-fallback routing forwards the full property map to Cloud Control and was unaffected — which is why existing integ coverage missed it). An audit of the whole provider found the same class on four more ECS nested fields, including the high-impactTaskDefinition.RuntimePlatform(aCpuArchitecture: ARM64Graviton task registered as the default X86_64) andEphemeralStorage. What shipped: PascalCase->camelCase converters wired on both create() and update() forAWS::ECS::ServiceDeploymentConfiguration/CapacityProviderStrategy/PlacementConstraints/PlacementStrategies/ServiceRegistries,AWS::ECS::TaskDefinitionRuntimePlatform/EphemeralStorage/ProxyConfiguration/PlacementConstraints/ (per-container)LinuxParameters+LogConfiguration.SecretOptions, andAWS::ECS::ClusterConfiguration/DefaultCapacityProviderStrategy. The pure first-letter-flip fields reuse the shared recursivepascalToCamelCaseKeysconverter (so fields CDK/CFn add later — e.g. the blue/greenStrategy/LifecycleHooks— convert automatically instead of re-introducing the drop), withDeploymentConfiguration.LifecycleHooks[].HookDetails(a free-form__DocumentType) preserved verbatim so its user-defined keys are NOT case-flipped.ProxyConfigurationneeds an explicit converter because the CFn keyProxyConfigurationPropertiesmaps to the SDK'spropertiesfield (not a first-letter flip).NetworkConfiguration/LoadBalancerswere already converted.DeploymentConfigurationremoval-reset stays deferred to #1160 (it merges at the sub-field level, so a correct reset needs the full live-probed default shape); the casing gap that blocked it is now closed, and an absent value still passesundefined(no reset). Audit result: ECS is the only affected provider — every other SDK provider flagged by the raw-nested-pass-through grep (cognito / cloudtrail / apigateway / apigatewayv2 / cloudwatch) wraps a PascalCase-input SDK, so CFn PascalCase matches the wire shape directly and no conversion is needed. Tests: 6 new unit tests feeding CFn PascalCase input and asserting the SDK command receives camelCase (the pre-existing tests hand-fed camelCase and so agreed with the bug — those were flipped to PascalCase), covering all five Service fields on create + update, the TaskDefinition trio (incl. HookDetails-verbatim +ProxyConfigurationProperties->properties), and the Cluster Configuration deep flip. Theecs-service-update-propsinteg fixture (SDK-routed by design) was extended to set a customDeploymentConfiguration(create + a phase-2 change) and a GravitonRuntimePlatform+EphemeralStorage, asserting AWSdescribe-services/describe-task-definitionreturn the custom values (not the defaults) — live-verified end-to-end (deploy -> update -> clean destroy). No new dependency, CLI flag, or state-schema change. - ECS Service update-field removal resets to CFn defaults (issue #1160, ecs Service batch) —
src/provisioning/providers/ecs-provider.ts,tests/unit/provisioning/ecs-provider.test.ts,tests/integration/ecs-service-update-props/. Why: the priority-(1)ecs Servicebatch of the #1160 umbrella audit (the absent-field removal silent-drop bug class, reference fixLambdaFunctionProvider#1157).ECSProvider.updateService()passedproperties['X'] as T | undefinedstraight intoUpdateService, which MERGES (an absent field = "no change"), so a field DROPPED from the template stayed live on AWS while CloudFormation resets it to the property default — deploy reported success, state dropped the field, nextcdkd diffsaid "No changes", divergence baked in invisibly. What shipped: aclearOnUpdateRemoval(next, previous, clearValue)helper (the ECS twin of Lambda's) sends an explicit reset value when a field was present before and is now absent, for the fields whose reset was live-probed against real AWS (2026-07-22):PlatformVersion->LATEST,HealthCheckGracePeriodSeconds->0,PropagateTags->NONE,EnableECSManagedTags->false,EnableExecuteCommand->false(CFn default; boolean, not live-probed since execute-command needs an SSM task role), andCapacityProviderStrategy/PlacementConstraints/PlacementStrategies->[](empty array is the AWS-documented + live-confirmed clear sentinel; the capacity-provider empty list reverts the service to its launch type).LoadBalancers/ServiceRegistriesalready clear via #975. Fields left untouched (immutable / required / always-carried):ServiceName(immutable, already guarded),Cluster/TaskDefinition/DesiredCount/NetworkConfiguration,SchedulingStrategy(create-only).DeploymentConfigurationremoval is DEFERRED — its correct reset requires the full default shape (live-probed: it merges at the SUB-FIELD level, so a partial reset leavesdeploymentCircuitBreaker/alarmsstuck), and it is entangled with a separate pre-existing CFn-PascalCase (MaximumPercent) -> SDK-camelCase (maximumPercent) nested-object conversion gap (the field is passed raw, unlikeNetworkConfigurationwhich has a converter); fixing it properly means converting the SET path too, out of this batch's scope. Tests: 3 new unit tests (the #1157 trio: removed -> reset / never-present -> absent / kept -> pass-through) + the existingecs-service-update-propsinteg fixture extended with a #1160 removal phase (phase 1 setsPlatformVersion: 1.4.0+HealthCheckGracePeriodSeconds: 30via the L1 escape hatch; phase 2 drops them and asserts AWS reset toLATEST/0). Live-probed reset semantics against real AWS; other #1160 SUSPECT provider families are untouched by this batch. No new dependency, CLI flag, or state-schema change. - ApiGatewayV2 update-field removal resets to CFn defaults (issue #1160, apigatewayv2 batch) —
src/provisioning/providers/apigatewayv2-provider.ts,tests/unit/provisioning/apigatewayv2-provider-roundtrip.test.ts. Why: the first service-family batch of the #1160 umbrella audit (the absent-field removal silent-drop bug class, reference fixLambdaFunctionProvider#1157). EveryAWS::ApiGatewayV2::*update()passedproperties['X'] as T | undefinedstraight intoUpdateApi/UpdateStage/UpdateIntegration/UpdateRoute/UpdateAuthorizer, all of which MERGE (an absent field = "no change"). So a field DROPPED from the template stayed live on AWS while CloudFormation resets it to the property default — deploy reported success, state dropped the field, nextcdkd diffsaid "No changes", divergence baked in invisibly. What shipped: a sharedclearableUpdate(next, previous, resetValue)helper (the apigatewayv2 twin of Lambda'sclearOnUpdateRemoval) sends an explicit reset value when a field was present before and is now absent, for the fields whose reset was live-probed against real AWS (2026-07-22): ApiDescription/Version->'',DisableExecuteApiEndpoint->false,IpAddressType->ipv4,ApiKeySelectionExpression->WebSocket default; StageAutoDeploy->false; IntegrationDescription->''; RouteAuthorizationType->NONE,AuthorizerId->'',AuthorizationScopes->[],OperationName->''; AuthorizerAuthorizerCredentialsArn/AuthorizerPayloadFormatVersion/IdentityValidationExpression->'',AuthorizerResultTtlInSeconds->0.CorsConfigurationremoval is the one field an emptyCorsinUpdateApidoes NOT clear (live-probed), so it is cleared out-of-band viaDeleteCorsConfiguration.StageVariablesand IntegrationRequestParametersalso merge per-key ({}does not clear), so a newmapWithRemovals(next, previous)helper sends every dropped key with an empty-string value (whole-block removal sends all old keys as''). Fields the underlying API rejects a reset for are deliberately left untouched (and documented inline as matching the same CFn/API constraint):Name/RouteKey/Target/RouteSelectionExpression/IntegrationType/IntegrationUri/IntegrationMethod/PayloadFormatVersion(required per protocol/integration type),IdentitySource/JwtConfiguration(required per authorizer type),EnableSimpleResponses(REQUEST-only — rejects on a JWT authorizer), and StageDescription(UpdateStage silently ignores an empty-string reset) /DefaultRouteSettings(merge-only, no whole-block reset). Tests: 24 new unit tests — the #1157 trio (removed -> reset / never-present -> absent / mixed -> kept fields pass through) across all five APIs, plus the CorsConfigurationDeleteCorsConfigurationpath, per-key + whole-block map clearing, and explicit not-reset assertions for the required/merge-only fields. Live-probed reset semantics against real AWS; other #1160 SUSPECT provider families are untouched by this batch. No new dependency, CLI flag, or state-schema change. -
AWS::Lambda::MicrovmImagedrift detection +--no-waitdocs completion + poll-config hardening (follow-up) —src/provisioning/providers/lambda-microvm-image-provider.ts,tests/unit/provisioning/lambda-microvm-image-provider.test.ts,tests/unit/provisioning/no-wait-doc-coverage.test.ts,tests/integration/lambda-microvm-image/verify.sh,docs/cli-reference.md,README.md,src/cli/options.ts,.claude/rules/providers.md. Why: three more gaps from the MicrovmImage provider: noreadCurrentState(socdkd driftreported the type as drift-unknown), the type was missing from the user-facing--no-waitresource lists (the user caught this — and the audit foundDocDB/Neptune/CertificateManager::Certificatewere ALSO pre-existing gaps in those lists), and the test-only poll-config env vars producedNaN(spurious immediate timeout) on a non-numeric value. What shipped: (1)readCurrentStatemapsGetMicrovmImage+ListTagsback to theName+Tagscdkd stores; the entirewriteOnlybuild config (BaseImageArn/CodeArtifact/Logging/ ...) is declared viagetDriftUnknownPathsso it never fires false-positive drift (GetMicrovmImagecan't read it back). Drift is scoped to the mutable, readable surface (Tags). (2)AWS::Lambda::MicrovmImage(+ the missing DocDB / Neptune / ACM) added to the--no-waittable + intro in cli-reference.md, the README feature bullet, and thenoWaitOptionhelp + JSDoc. A new mechanical testno-wait-doc-coverage.test.tsscans providers forprocess.env['CDKD_NO_WAIT']and fails CI if any handled type is absent from the cli-reference--no-waittable (coverage floor >= 8); step 7 of the "Adding a New SDK Provider" checklist in.claude/rules/providers.mdnames the four doc surfaces. (3) The poll-config reads go throughpositiveIntFromEnv(guardsNaN/ non-positive). Tests: 5 new provider unit tests (readCurrentState Name+Tags / aws:-tag strip / gone -> undefined / getDriftUnknownPaths / poll-config NaN guard) + the mechanical--no-waitdoc test + a real-AWS integ drift phase (deploy -> drift clean -> out-of-band tag mutation -> drift detected -> revert -> clean). No new dependency, CLI flag, or state-schema change. - Lambda
Environment: {}normalization on update + LoggingConfig removal-reset live verification (issue #1158, follow-up to #1155) —src/provisioning/providers/lambda-function-provider.ts,tests/unit/provisioning/lambda-function-provider.test.ts,docs/provider-development.md. Why: the PR #1157 3-axis review flagged two pre-existing behaviors. (1)Environment: {}(present, noVariableskey — a hand-written L1 / imported-template shape CDK never emits) passed throughupdate()verbatim; live-probed 2026-07-22:UpdateFunctionConfigurationKEEPS the old env vars for a Variables-lessEnvironment, while the template's declarative meaning is "no env vars" — the same absent-means-keep hazard #1155 fixed, one level down. (2) TheLoggingConfigremoval reset{LogFormat:'Text'}was suspected of not restoring a customLogGroupto the/aws/lambda/<fn>default (subfield merge). What shipped: (1)normalizeEnvironmentForUpdaterewrites a Variables-lessEnvironmentto the explicit-clear{Variables: {}}before theclearOnUpdateRemovalpass; populated / absent shapes pass through unchanged. (2) The LoggingConfig suspicion was REFUTED by a live probe (customLogGroupset →{LogFormat:'Text'}-only update →LogGroupreverted to/aws/lambda/<fn>): the API replaces theLoggingConfigobject wholesale, so the existing reset value is correct and no code change was needed. Also:docs/provider-development.mdgains §2a "UPDATE removal semantics — clear-on-removal", codifying the #1155 bug class (merge-vs-full-replace classification, CFn-default reset table, never-synthesize-for-never-set, the sub-structure normalization variant, and the three mandatory unit-test shapes) for every future provider. Tests: two new unit tests (Variables-lessEnvironment→{Variables:{}}on the wire, verified to fail without the fix; populatedEnvironmentpasses through un-normalized). No new dependency, CLI flag, or state-schema change. -
AWS::Lambda::MicrovmImageimport support +--no-waitinteg coverage (follow-up) —src/provisioning/providers/lambda-microvm-image-provider.ts,tests/unit/provisioning/lambda-microvm-image-provider.test.ts,tests/integration/lambda-microvm-image/{verify.sh,lib/lambda-microvm-image-stack.ts},docs/import.md. Why: the initial provider PR shipped with two gaps:import()was unimplemented (socdkd importreported the typeunsupported), and the headline--no-waitbehavior (the reason for the Tier-1 provider over the CC fallback) was unit-tested only, never verified against real AWS. What shipped: (1) an override-onlyimport()— adopt an existing image by ARN via--resource <logicalId>=<arn>(GetMicrovmImageconfirms existence; a bare name is rejected sinceGetMicrovmImagerequires the ARN; returnsnullwhen the image is gone or no ARN is supplied). Moves the type from import.md's "Unsupported" to the override-only adopt-by-ARN list. (2) The integ fixture gained a--no-waitphase: after the waited-create + tags-update + destroy, it re-deploys with--no-wait, asserts the image is stillCREATINGimmediately after cdkd returns (proving cdkd did NOT wait forCREATED, unlike the always-polling CC fallback), waits forCREATED, then destroys clean. Tests: 4 new unit tests (import adopt-by-ARN / not-found -> null / no-override -> null / non-ARN reject) + the real-AWS--no-waitinteg phase. No new dependency, CLI flag, or state-schema change. - Lambda config-field removal resets to CFn defaults (issue #1155) —
src/provisioning/providers/lambda-function-provider.ts,tests/unit/provisioning/lambda-function-provider.test.ts,tests/integration/lambda-config-field-removal/**. Why: the 2026-07-22 /hunt-bugs sweep confirmed live that removing a previously-setTimeout/MemorySize/Environment(whole block) /EphemeralStoragefrom the template left the old value live on AWS:update()passed those fields straight through asundefined, andUpdateFunctionConfigurationtreats an ABSENT field as "no change" — while CloudFormation resets it to the property default. The deploy reportedupdated+ success, state dropped the field, and the nextcdkd diffsaid "No changes", so the divergence was baked in invisibly. The provider already had the right mechanism (clearOnUpdateRemoval, used byDeadLetterConfig/KMSKeyArn/FileSystemConfigs/ImageConfig/SnapStart/LoggingConfig), but seven fields were missed; the existinglambda-env-removalinteg only covers removing one KEY from a still-presentEnvironment.Variablesmap, not the whole block. What shipped:Timeout(reset3),MemorySize(128),Description(''),Environment({Variables: {}}),Layers([]),TracingConfig({Mode: 'PassThrough'}), andEphemeralStorage({Size: 512}) now route throughclearOnUpdateRemovalinupdate()'sUpdateFunctionConfigurationCommandInput. Never-present fields still passundefined(no spurious reset);Role/Handler/Runtimestay direct pass-throughs (CFn requires them for their package type, so removal is not a valid template transition);Architecturesremoval was already handled on theUpdateFunctionCodepath. Tests: three new unit tests (all-seven removal → exact reset values; never-present → all seven stayundefined; mixed kept/removed → kept fields pass through unchanged while removed ones reset), the first verified to fail without the fix. Real-AWS integtests/integration/lambda-config-field-removal/deploys with six fields set, re-deploys with all six removed (asserts AWS shows Timeout 3 / MemorySize 128 / empty Description / no env vars / EphemeralStorage 512 / TracingConfig PassThrough), then destroys clean. No new dependency, CLI flag, or state-schema change. -
AWS::Lambda::MicrovmImageSDK provider (Tier 1) —src/provisioning/providers/lambda-microvm-image-provider.ts,src/provisioning/register-providers.ts,src/utils/aws-clients.ts,tests/unit/provisioning/lambda-microvm-image-provider.test.ts,tests/integration/lambda-microvm-image/**. Why:AWS::Lambda::MicrovmImage(Lambda MicroVMs, the Firecracker-snapshot compute environment released 2025-09) was only reachable via the Cloud Control fallback. CC works but always polls its request token to a terminal state, socdkd deploy --no-waithad no effect on it (the build is a multi-minute async operation, exactly where--no-waitis useful). What shipped: a dedicated SDK provider on thelambda-microvmsservice (new dep@aws-sdk/client-lambda-microvms, a SEPARATE service model from@aws-sdk/client-lambda).create()maps the CFn-schema PascalCase template (CodeArtifact: {Uri},Tags: [{Key,Value}],Logging: {Disabled: true},Hooks,CpuConfigurations,Resources, ...) to the SDK camelCase shape (codeArtifact: {uri},tags: Record<string,string>,logging: {disabled: {}}, ...), callsCreateMicrovmImage, then pollsGetMicrovmImageuntil the image reachesCREATED(throws onCREATE_FAILED). The poll is gated onCDKD_NO_WAIT— so--no-waitreturns right afterCreateMicrovmImage(stateCREATING) with the image ARN already resolved.update()routes a build-affecting change throughUpdateMicrovmImage(the async rebuildUPDATING -> UPDATED) and defensively refuses an in-placeNamechange (create-only; cdkd'sgetCreateOnlyPropertyPathsalready routes it to replacement).Tagsare reconciled out-of-band viaTagResource/UntagResource(UpdateMicrovmImagehas notagsfield), so a tags-only change updates the tags WITHOUT an image rebuild.delete()callsDeleteMicrovmImageand polls until the image 404s (asyncDELETING -> DELETED) so destroy leaves no orphan. Physical id is the image ARN (primaryIdentifier);getAttributeexposesImageArn/State/LatestActiveImageVersion/LatestFailedImageVersion/CreatedAt/UpdatedAt.getMinResourceTimeoutMs()lifts the deploy engine's per-resource deadline to the poll cap (default 30 min) so a slow build is not truncated. Tests: 14 unit tests (CFn->SDK shape translation for every optional field incl. theLogging.Disabledtagged-union +Tags/EnvironmentVariableslist->record mappings, async CREATING->CREATED poll, CREATE_FAILED/DELETE_FAILED,--no-waitskips the poll, create-onlyNameguard, idempotent NotFound delete). Real-AWS integtests/integration/lambda-microvm-image/uploads a Dockerfile+app.js code artifact to S3, deploys (real Firecracker snapshot build toCREATED), asserts the ARN physicalId +MicrovmImageArnoutput parity +GetMicrovmImagestate, then destroys clean (image 404s, state removed). aws-cdk-lib has noCfnMicrovmImageyet, so the fixture (and real users) declare the resource via theCfnResourceescape hatch. No CLI flag or state-schema change. - Selection-aware macro expansion + SDK-default region fallback + EarlyValidation retry (issues #1149 / #1150 / #1151) —
src/synthesis/synthesizer.ts,src/synthesis/macro-expander.ts,src/cli/commands/{deploy,diff,destroy,list}.ts,tests/unit/synthesis/{synthesizer-macro-integration,macro-expander}.test.ts. Why: /hunt-bugs sweep 18 hit three macro pre-pass bugs live: (#1150)Synthesizer.synthesize()expanded macros for EVERY assembly stack, socdkd deploy/diffof a macro-FREE stack failed (or paid CFn round-trips) whenever a sibling carried aTransform, andcdkd listof a macro app required an AWS region + CFn access for names that come from the manifest; (#1149) the expansion region chain checked only--region/env/stack-env and hard-errored for users whose region lives in~/.aws/config, even though the STS hop in the same function resolves that profile region fine; (#1151) AWS'sAWS::EarlyValidation::ResourceExistenceCheckhook rejected the transient changeset intermittently (2 consecutive diff failures, then a clean pass minutes later with the same resources), failing whole runs with no retry. What shipped: (1)SynthesisOptions.deferMacroExpansionskips the pre-pass insidesynthesize();expandMacrosForStacksis now PUBLIC anddeploy/diffcall it after stack selection with exactly the final target set (deploy: incl. auto-included dependency stacks), whilelistanddestroydefer and never expand (destroyworks off cdkd state and its cross-stack ordering scan reads the raw templates' literalFn::ImportValue/Fn::GetStackOutputmarkers, so a macro stack stays destroyable even when expansion would fail);listStacks()defers too. Whole-app consumers (synth,import,export,publish-assets,local) keep the expand-everything default. The STS hop for the default state bucket moved insideexpandMacrosForStacksand runs only when a selected stack actually carries a macro (a plain-a cdk.outread no longer pays STS). (2) The macro region chain falls back toresolveSdkDefaultRegion(profile)— a throwaway STS client'sconfig.region()provider, i.e. the exact chain every provisioning client uses — before hard-erroring; the synth-branchCDK_DEFAULT_REGIONresolution gains the same fallback (matches the CDK CLI, which passes the profile-resolved region to the app subprocess). (3)expandMacrosretries a changeset FAILED whoseStatusReasonnamesAWS::EarlyValidation::*up to 3 attempts (fresh transient stack name each attempt, 2s/4s backoff via theretryDelays.sleeptest seam); non-EarlyValidation failures still surface immediately. One deliberate edge: with expansion now selection-scoped, a macro-generatedOutputs[*].Export.Nameon an UNSELECTED sibling is no longer visible to deploy's cross-stack auto-include inference (it reads the raw template); a rawFn.importValueof a transform-generated export withoutaddDependencywould need the producer deployed explicitly. Tests: 8 new unit tests — SDK-chain region fallback, defer skips expansion, macro sibling outside the selection expands nothing and pays no STS, public post-selection expansion expands only the selected macro stack and resolves the default bucket itself,listStacksneeds no region, EarlyValidation retry succeeds with a fresh stack name + per-attempt cleanup, gives up after 3 attempts with 2s/4s backoff, non-EarlyValidation failure not retried. Live-verified against real AWS with the sweep-18 repro app (4 stacks, one LanguageExtensions). No new dependency, CLI flag, or state-schema change.
Recently Implemented (2026-07-21):
- Property-driven replacement honors
UpdateReplacePolicy: Retainin the stateful guard —src/deployment/deploy-engine.ts,tests/unit/deployment/resource-replacement.test.ts. Why: theupdate-policy-mutationsinteg FAILed at DEPLOY on 2026-07-21 (surfaced by the coverage sweep). A template immutable-property change (e.g. an S3BucketNamesuffix flip) drives a replacement, and cdkd's property-driven replacement path applies a stateful guard that refuses to DELETE+CREATE a stateful resource without--force-stateful-recreation(data-loss confirmation). But the guard calledisStatefulRecreateTargetForReplace— which conservatively treats ANY S3 bucket as stateful — BEFORE reading the resource'sUpdateReplacePolicy. When the policy isRetain, the old physical resource is ORPHANED (kept, not deleted) on replacement, so there is NO data loss and the guard's "confirm the data loss" rationale does not apply; the guard fired anyway, throwingSTATEFUL_REPLACE_BLOCKEDand blocking the documented orphan-on-replace path (Retaining old ... - UpdateReplacePolicy: Retain). What shipped: theupdateReplacePolicyread is hoisted above the guard and the guard is skipped when it equals'Retain'(the same value the create-first replacement path already checks to retain vs delete the old resource, so the two decisions can no longer disagree).'Snapshot'is deliberately NOT exempted — the property-driven path does not implement snapshot-on-replace and falls through to the DELETE branch, so its data really would be lost. The parallel--replaceopt-in guard is unchanged: its fallback unconditionally deletes the old resource first regardless ofUpdateReplacePolicy, so its data-loss guard is correct there. Tests: a new unit test asserts a Retain-policy S3 bucket replacement proceeds without the flag (create called, delete NOT called); live-verified via theupdate-policy-mutationsinteg (deploy proceeds + destroy clean, 0 orphans). No new dependency, CLI flag, or state-schema change.
Recently Implemented (2026-07-20):
- Per-type Cloud Control operation-timeout floor for slow CREATE/DELETE (OpenSearch domain destroy) —
src/provisioning/slow-cc-operation-timeouts.ts(new),src/provisioning/cloud-control-provider.ts,src/cli/commands/destroy-runner.ts,src/deployment/deploy-engine.ts,tests/unit/provisioning/slow-cc-operation-timeouts.test.ts(new),tests/unit/provisioning/cloud-control-provider.test.ts. Why: theopensearch-domain-getattinteg FAILed at DESTROY on 2026-07-20 — anAWS::OpenSearchService::Domaindeletion routinely runs 15-30 min, but theCloudControlProvider's internal poll cap (MAX_WAIT_TIME_MS) is a flat 15 min, sowaitForOperationthrewDELETE timeout ... after 900swhile AWS was stillIN_PROGRESS, leaving a partially-destroyed stack (the fixture trap cleaned up, but the run could not go green). The 15-min CC inner cap is ALSO shorter than the 30-min outer per-resource deadline (DEFAULT_RESOURCE_TIMEOUT_MS), so every Cloud-Control-routed slow resource silently got HALF the advertised budget. What shipped: a single sharedslowCcOperationTimeoutMs(resourceType, operation)floor table (OpenSearch / Elasticsearch domains at 60 min for CREATE/UPDATE/DELETE; Redshift / ElastiCache ReplicationGroup+CacheCluster / RDS DBInstance+DBCluster at 60 min for CREATE/DELETE) consulted in THREE places so the inner and outer budgets can never drift apart again: (1)waitForOperationnow takesresourceTypeand caps atMath.max(MAX_WAIT_TIME_MS, floor)— a normal type keeps the flat 15 min, a slow type grows to its floor; (2) the destroy-runner outer deadline resolution folds the DELETE floor into itsMath.max(providerMinTimeoutMs, floor, globalTimeoutMs); (3) the deploy-engine outer deadline does the same with the CREATE/UPDATE floor. A user-supplied--resource-timeout <TYPE>=<DURATION>per-type override still wins at both outer sites (explicit escape hatch); the defaultcdkd destroynow simply waits long enough for a slow delete instead of aborting, matching CloudFormation's own synchronous-wait semantics and cdkd's "destroy complete = resource actually gone" contract. RDS / ElastiCache carry SDK providers (only CC-routed via #614) but the outer-deadline sites are provider-agnostic, so the floor also lifts their SDK-path deletes, which are the same slow class. Tests: a pure-helper suite (per-type CREATE/UPDATE/DELETE lookups, the cluster-types-have-no-UPDATE-floor distinction,Math.max-safety) plus two fake-timerCloudControlProviderDELETE tests — a normal type aborts at 900s, an OpenSearch domain is still polling at 20 min and aborts at the lifted 3600s. No new dependency, CLI flag, or state-schema change. - Import tag-walk migration batch 3 + a
retry.sleeptest seam (issue #1091) —src/provisioning/import-tag-walk.ts, 12src/provisioning/providers/*.ts, ~26 unit-test files. Why: after batch 2 (PR #1119), ~25 providers still hand-rolled the N+1List*+ per-candidate tag-readaws:cdk:pathwalk inimport(), so a single throttled read aborted the wholecdkd importrun; separately, every batch-1/2 wiring test that exercised the throttle path paid a REAL 0.5s backoff sleep because providerimport()signatures expose no retry options to injectretry.sleepthrough. What shipped: 12 more providers migrated onto the sharedimportTagWalkhelper, chosen by throttle exposure starting from the picks named on the issue —stepfunctions-provider.ts(lowercase{key, value}tags re-shaped to{Key, Value}insidetagsOf; the now-dead privatetagsMatchCdkPathdeleted),cognito-provider.ts(map-shaped tags viaObject.entries; theProperties.UserPoolNamename-match rides the walk as a synthetic tag exactly like SNS Topic's batch-2 shape, so a name-only template still resolves with zero per-candidate API calls, and each tagged candidate costs two reads — DescribeUserPool for the ARN, then ListTagsForResource — both inside the retrieddescribecallback),cloudfront-distribution-provider.ts(IsTruncated/NextMarker fold, NoSuchDistribution skip),acm-certificate-provider.ts,cloudtrail-provider.ts(ListTags(ResourceIdList: [arn])single-ARN batch read stays per-candidate),dynamodb-globaltable-provider.ts(same two-read shape as batch-2's DynamoDB Table),eventbridge-bus-provider.ts,eventbridge-rule-provider.ts(templateEventBusNamescopes every ListRules page; tag-read errors still surface un-skipped, as before),firehose-provider.ts(ExclusiveStartDeliveryStreamName/HasMoreDeliveryStreamspagination fold, mirroring Kinesis),wafv2-provider.ts(templateScopeforwarded on every page),s3-directory-bucket-provider.ts(NoSuchTagSet / AccessDenied candidates still skip; deliberately NO 301 skip — directory buckets are zonal), andbudgets-budget-provider.ts(AccountId on every page, constructed-ARN tag reads). Behavior is preserved mechanically: explicit-override / name-fallback branches untouched, not-found skips becamedescribereturningundefined, physicalId/attributes shapes unchanged; the walk adds only the shared throttle backoff, 10-min wall-clock budget, and 1,000-page ceiling. The test-debt item from the issue also ships:importTagWalkTestHooks— a module-level{sleep?}seam inimport-tag-walk.ts(a per-callretry.sleepstill wins) — and a sweep of 14 existing batch-1/2 wiring-test files onto it, cutting the real backoff waits (e.g. the SSM parameter suite dropped from ~510ms to ~5ms of test time; the two EMR instance-fleet/group throttle tests were inspected and left alone — their retries go through the update-path settle polling, notimportTagWalk). Tests: per-provider wiring tests following the batch-2 pattern — a throttled tag read mid-walk is retried and still finds the match, a non-throttling error surfaces with no retry — plus pagination-fold asserts (Firehose exclusive-start name, GlobalTableExclusiveStartTableName, CloudFront marker, EventBridge Rule bus-scoped pages, WAFv2 Scope forwarding), Cognito name-match-short-circuit + map-tag happy path, and an S3 directory-bucket AccessDenied-skip-continues case. NOT live-verified: the tag-match happy path itself is unreachable on cdkd-deployed resources (aws:is an AWS-reserved tag prefix — see the issue's status note); the pagination/tag wire contracts were verified live in PR #1093/#1101. Remaining: ~13 hand-rolled walkers (multi-sub-type providers plus the excluded elbv2/codebuild batched-describe, KMS alias-walk, and FSx parallel-lane shapes), tracked on #1091. - Integ verify.sh capture-form + function-wrapper gone-probe lint, tree-wide sweep (issue #1120, follow-up to #1097 pattern 2 / PR #1110) —
scripts/check-integ-probe-not-found.ts,tests/unit/scripts/integ-verify-probe-not-found.test.ts, 55tests/integration/*/verify.sh,docs/testing.md,.claude/rules/testing.md,.claude/skills/new-integ/SKILL.md. Why: the #1110 classifier only scanned condition / list-operator positions, so the same silent-pass defect survived in two spellings it never saw: a read-verb command substitution with an error-swallowing fallback (N=$(aws ... --output text 2>/dev/null || echo 0)reads a throttle as "0 remaining"; the tree had ~130 such sites, including post-destroy leak asserts whose fallback literally spelledGONE) and silenced probe wrappers (fn() { aws ... >/dev/null 2>&1; }/ value wrappers with|| truetails — the shape #1110 fixed by hand six times but nothing prevented reintroducing). What shipped: two new classifier categories.blindCaptureProbesflags a$(aws <read-verb> ...)substitution whose failure cannot fail loudly (swallow fallback inside or right after the capture, without stderr routed INTO the capture); plain silenced captures with no fallback stay legal (set -ehard-fails them, only the diagnostic is lost) and the strict stderr-capture idiom ($(cmd 2>&1 >/dev/null || true)) is recognized as strict.silencedFunctionProbesflags bare-statement silenced read probes in function bodies when fully silenced (exit-status wrapper) or swallow-tailed (value wrapper); tail-less value wrappers stay legal. Best-effort cleanup is exempt structurally:set +e[u]..set -e[u]spans, bounded at the enclosing function's close so an unrestoredset +ein an exiting cleanup handler cannot leak over live-phase code; the canonical helper block's lines are skipped by byte-range. The 130-site sweep: mechanical fallback strips onto plain strict captures for live-phase reads,gone_probe-branch rewrites for the 16 sites where not-found is a legitimate outcome (async DynamoDB/RDS/SFN deletes, recovery-window secrets, provisioned-concurrency settle, EC2 terminated-record aging;remove-protectiongained the canonical helper block for this), strict value-wrapper rewrites (find_fixture_*,api_id,find_evaluator_id, ESM list wrappers),gone_probe-backed expected-missing wrappers (queue_url/ssm_value), andset +eubest-effort markers on the seven cleanup helpers that predated the convention. Zero violations tree-wide with NO allowlist entries. Tests: 34 new table tests (positives incl. nested-substitution masking + span bounding; negatives incl. mutation captures,s3 cp, strict idioms), two tree-wide zero-violation tests, and a bash behavioral test proving the strict form propagates values / hard-fails a stubbed throttle while the banned fallback form silently returns the fallback literal. Review hardening (3-axis): the review empirically falsified the "a probe error aborts the caller's$( )capture under set -e" rationale: errexit is CLEARED inside command substitutions (noinherit_errexit), so a multi-statement value wrapper whose probe is not the LAST command silently yielded empty on a probe error. Every intermediateout="$(aws ...)"capture in the tree's wrappers now carries|| return 1(16 sites across 13 fixtures, incl.find_evaluator_id, the 5deletion-ordering-complexfind_*helpers with their innertags=captures, andlist_esms_for_function), enforced by a third new categoryunpropagatedWrapperCaptures(skips last-statement / condition-context /$?-consuming captures; flagslocal V=$(...)outright sincelocalmasks the status). Further: swallow-tail detection extended to|| V=""assignment fallbacks and compact single-line function bodies (both with table tests + tree re-sweep, which surfaced and fixed 15 more sites);set +eucleanup helpers converted to subshell bodies (fn() { ( set +eu; ... ) }) so calling one from aset +eucleanup trap can never re-arm strict mode mid-sweep; everygone_probe-then-requery site gained a TOCTOU guard (canonical not-found on the requery counts as gone, anything else hard-fails); theeventbridge-pipespost-deploy poll andpropagation-races-2post-destroy instance probe gained the gone_probe "not yet"/"swept" branches their siblings had;local V=$(...)/ condition-context bare-silenced captures are documented as a known detector limitation (zero instances today). - FSx
readCurrentState§3b always-emit placeholders + Class 1 discriminator carve-out (issue #1096 item 2) —src/provisioning/providers/fsx-filesystem-provider.ts,tests/unit/provisioning/providers/fsx-filesystem-provider.test.ts,docs/provider-development.md. Why: the FSx provider had never carried §3b's mandatoryit('emits placeholders for every user-controllable top-level key on AWS minimum response')block (it predates #1089; the provider landed in #1042). #1095 shipped the twodrift --revertround-trip guards but deferred this one, because satisfying it changes WHICH top-level keysreadCurrentStateemits — a behavior change, not a test addition. The deferral grew as the read surface grew (#1095 roughly doubled it with three new variant blocks), so on an AWS-minimumDescribeFileSystemsresponse a file system deployed without e.g.NetworkTypenever carried the key inobservedProperties, and the comparator's state-keys-only walk skipped a console-side ADD forever. What shipped: the four top-level propertiesupdate()can actually mutate are now emitted unconditionally with placeholders —StorageCapacity ?? 0,StorageType ?? 'SSD',FileSystemTypeVersion ?? '',NetworkType ?? 'IPV4'(the two enums use the AWS-documented defaults) — joiningTags, which already always-emitted.FileSystemType/SubnetIds/KmsKeyIddeliberately KEEP theirputIfDefinedguard per §3b's "immutable on create" carve-out (all registry-createOnly;update()rejects any change, so a placeholder buys no drift coverage and anundefined -> [] / ''transition could trip theTOP_LEVEL_IMMUTABLE_PROPSguard on a later deploy);SecurityGroupIds/BackupIdare createOnly AND never returned, so they stay ingetDriftUnknownPaths(). The four<Variant>Configurationblocks take §3b's Class 1 type-discriminator carve-out: at most one is legal for a givenFileSystemType, so emitting all four as{}would makedrift --revertpush a shape AWS rejects.readCurrentStatenow emits EXACTLY the one blockFileSystemTypeselects — unconditionally ({}included, so the always-emit contract holds for the legal block) — and never the other three. This changes one prior behavior: a variant block AWS returns empty is now emitted as{}rather than omitted. Two companion guards ship with it. (1) §3b Class 2 wire-layer sanitize: theStorageCapacity ?? 0placeholder is reachable on a real AWS shape — FSx Lustre Intelligent-Tiering (DataReadCacheConfiguration) provisions no capacity, soDescribeFileSystemsreturns none, which is whyStorageCapacityis neither createOnly nor required in the registry schema.update()now suppresses a non-positiveStorageCapacityinstead of shipping it (folded into thechanged()flag so the no-op early-return sees it too, rather than issuing an emptyUpdateFileSystem); suppressing rather than throwing keeps an otherwise-valid update in the same call working. (2) Unrecognized-discriminator warn: pre-carve-out, an unknownFileSystemTypestill produced a partial snapshot (whatever block AWS returned was emitted regardless of type); post-carve-out it produces none, which reads downstream as "no drift" on a resource cdkd never looked at. The no-block behavior stays (guessing is not better) but now emits alogger.warnnaming the type and stating that its configuration is absent from the drift snapshot. The inline Lustre mapping was extracted to areadLustreConfigurationhelper so all four blocks route through symmetric reverse-mappers. NogetDriftUnknownPaths()contradiction was found — every declared entry is a keyreadCurrentStatestill never emits. §3b gained one clarifying paragraph on the N-way (vs boolean) discriminator shape. Tests: 6 new — the mandatory §3b key-set assertion written once perFileSystemType(LUSTRE / WINDOWS / ONTAP / OPENZFS), each asserting the COMPLETE key list, spot-checking every placeholder value, and asserting the three foreign variant blocks are ABSENT; a Class 1 round-trip guard proving a discriminator-false snapshot replayed throughupdate()ships no foreign variant block (the regression would otherwise pickLustreConfigurationfor a WINDOWS file system viadetectVariantConfigKey's declaration-order walk); an unsupported-FileSystemTypecase emitting no variant block. The pre-existing "omits a variant block entirely when AWS returns it empty" test was inverted to match the new contract. No new resource type, dependency, CLI flag, or state-schema change. -
importTagWalkbatch-2 migration: 12 high-throttle-exposure providers (issue #1091 follow-up) —src/provisioning/providers/{lambda-function,cloudwatch-alarm,logs-loggroup,sqs-queue,sns-topic,iam-role,iam-managed-policy,dynamodb-table,kinesis,s3-bucket,ecr,ssm-parameter}-provider.ts, their unit tests,docs/provider-development.md,.claude/rules/code-layout.md. Why: the shared throttle-tolerantaws:cdk:pathwalk shipped in PR #1093 with two reference callers (EMR Cluster, DocDB); the other ~40 providers still hand-rolled the N+1List*+ per-candidate tag read with NO backoff, so on accounts with many resources of a type (Lambda functions, alarms, log groups, queues, topics, roles, tables, buckets — exactly the types real accounts hold in bulk) a single rate-limited call aborted the wholecdkd importrun. What shipped: behavior-preserving migration of the 12 highest-exposure walkers ontoimportTagWalk. Per-provider semantics preserved inside the callbacks: map-shaped tag responses (LambdaListTags, Logs, SQS) re-shaped to{Key, Value}entries intagsOf; not-found-during-walk candidates still skip (each provider's original skip class:ResourceNotFoundException/NoSuchEntityException/QueueDoesNotExist/RepositoryNotFoundException/ParameterNotFound, and S3's NoSuchTagSet / AccessDenied / cross-region-301 trio); IAM'sIsTruncated-gatedMarkerand ManagedPolicy'sScope: 'Local'AWS-managed exclusion kept; Kinesis'sExclusiveStartStreamName+HasMoreStreamspagination (including the stop-on-empty-page guard) folded intonextMarker; DynamoDB's two-reads-per-candidate (DescribeTable for the ARN, then ListTagsOfResource) both live inside the retrieddescribecallback; S3's single unpaginatedListBucketsbecomes a one-page walk. SNS is the one non-mechanical case: its walk matchesProperties.TopicNameagainst the ARN tail per candidate BEFORE the tag read (and works with no CDK path at all), so the name match is expressed to the walk as a syntheticaws:cdk:pathtag carrying the walk's own lookup key (input.cdkPath, or acdkd:sns-topic-name:sentinel when only a name is present) — a name-only template still resolves with ZERO tag API calls, and a name match still short-circuits the tag read when both are present. One deliberate delta: with neither a cdkPath nor a TopicName, SNS previously paginated all topics matching nothing; it now short-circuits tonullwith no API call (same result, fewer calls). Tests: per provider, two wiring tests following the EMR/DocDB pattern — a throttled per-candidate tag read (HTTP 400 +ThrottlingException) is retried and the match still found; a non-throttling error (AccessDeniedException-shaped) is NOT retried and surfaces with exactly one tag-read call — plus SNS name-path tests (name-only zero-tag-read resolution; name short-circuit with cdkPath present) and an S3 NoSuchTagSet-skip test. Stash-check: the throttle-retry test verified to FAIL against the pre-migration code for the lambda-function / iam-role / s3-bucket samples (the non-retry test passes in both worlds — surfacing unretried errors was already the old behavior). Test-infra gotcha captured in the new blocks:vi.clearAllMocks()does NOT drop unconsumedmockResolvedValueOncequeue entries leaked by earlier tests in the same file, so every added blockmockReset()s the send mock first. Remaining hand-rolled walkers (~25, incl. ACM, CloudFront Distribution, CloudTrail, CodeBuild/CodeCommit, Cognito, EventBridge bus/rule, Firehose, DynamoDB GlobalTable, IAM InstanceProfile, RDS DBProxy family, S3 directory/vectors buckets, Step Functions, WAFv2, Budgets, DLM) stay for later batches;fsx-filesystem-provider.tsis owned by the in-flight #1114 lane and untouched. No new resource type, dependency, CLI flag, or state-schema change. -
Fn::GetAttguard extensions: guarded per-type defaults +--strict-getatt+ deploy-summary fallback count (issue #1111) —src/deployment/intrinsic-function-resolver.ts,src/deployment/deploy-engine.ts,src/cli/options.ts,src/cli/commands/deploy.ts,tests/unit/deployment/intrinsic-getatt-fallback-guard.test.ts(extended),tests/unit/deployment/deploy-engine-strict-getatt.test.ts(new),tests/integration/getatt-fallback-guard/(extended). Why: PR #1108 (issue #1106) guarded onlyconstructAttribute's FINAL unknown-type fallback; the 36 per-type handlers ending indefault: return physicalIdstill shipped a NAME for an unrecognized*Arnattribute, an Output-only bogus GetAtt still exited 0, and the per-resolution warns scrolled away on green deploys. What shipped: (1) the shape guard is extracted into a sharedguardedPhysicalIdFallback(logicalId, attributeName, resourceType, physicalId)helper and EVERY per-type unknown-attributedefault:branch routes through it (same rules:*Arn/*Urlshape mismatch hard-fails, other suffixes warn-and-return); known attributes whose correct value IS the physicalId got explicit cases so they never hit the guard (KMS KeyId,SNS TopicName[new],Cognito UserPoolId[new],EC2 InstanceId[new],LaunchTemplate LaunchTemplateId[new],ECS ServiceArn[new; the provider stores the service ARN as physicalId, so strict mode must not reject the correct fallback on attribute-less imported/legacy state], plus the pre-existingSubnetId/FileSystemId/ApiId/Id/Name/QueueUrl/GroupId/VpcIdcases). (2) New deploy-only flag--strict-getatt: promotes EVERY unknown-attribute physicalId fallback (any suffix, even ARN-shaped) to a hard error AND makes an Output resolution failure fail the deploy (default stays warn-and-skip); the strict output failure fires AFTER provisioning succeeded, so the engine persists the provisioning result FIRST (persistStateAfterOutputFailure: this run's resources +imports/outputReadswith the previously persisted outputs) and THEN rethrows — without this, a FIRST deploy (no incremental ETag saves) would leave every created resource in AWS with zero state written (invisible orphans). Threaded viaDeployEngineOptions.strictGetAtt→ the engine'sIntrinsicFunctionResolverconstructor options; nested-stack child engines inherit it through the options spread. (3) The resolver counts warn-path fallbacks per instance (getPhysicalIdFallbackCount/resetPhysicalIdFallbackCount, reset at eachdeploy()start AND again after the diff phase on the change path, so the count is per-run, never leaks across stacks/tests, and never double-counts a site at diff time and provisioning time), surfaced asDeployResult.attributeFallbackCount, andcdkd deployprintsN attribute resolution(s) fell back to the physical ID (potentially wrong values); re-run with --strict-getatt to fail on theseafter the deployment summary when N > 0. Tests: 22 new/extended unit tests (per-type default hard-fail + known-case exemptions + strict promotion + output strict/default split + counter count/reset) and two new integ phases ingetatt-fallback-guard(strict-mode failure on aBogusNameattribute that default mode warn-passes; default-mode success asserting the summary line). Docs:docs/cli-reference.md--strict-getattsection. - Generic sparse-ResourceModel GetResource read-back in
CloudControlProvider.create/update(issue #1105 option 1) —src/provisioning/cloud-control-provider.ts,tests/unit/provisioning/cloud-control-provider-sparse-readback.test.ts(new). Why: the "CC-routed type with a sparse CREATEProgressEvent.ResourceModel→ empty stateattributes→Fn::GetAttsilently resolves to the bare physicalId" class had been fixed per-type three times (#984 Backup, #1103 Pipes / S3 AccessPoint / ResourceGroups), and the shape that keeps producing it (a pure-CC daily type nobody has enriched yet) is structurally invisible to thegen-enrichment-coveragecritic. What shipped: a newmergeSparseModelReadbackpass in bothcreateandupdate, running BEFORE the per-typeenrichResourceAttributesswitch so existingif (!enriched['X'])gating composes (one GetResource total, never two). The sparseness predicate is conservative and per-type by construction: the parsed map is sparse only when empty or when every value is a string echo of the physicalId / a segment of a compound|-joined identifier —AWS::ApiGatewayV2::Api's CREATE model (carriesApiEndpoint) never triggers a read-back, while the Pipes / AccessPoint / ResourceGroups / Backup shapes do. On sparse, ONE best-effortGetResourceread-back (reusesreadCcResourceModel, same never-throw contract) merges the AWS-current model over the attributes; on UPDATE this also refreshes attributes a sparse ProgressEvent would otherwise leave stale from CREATE time. All per-type enrichment cases untouched (they still normalize flat keys / compound-id splits / SDK Describe fallbacks). Tests: 8 unit tests — sparse create merges via exactly one GetResource; identifier-echo-only model (incl. compound-id segments) counts as sparse; ApiEndpoint-shaped model spends zero extra calls; read-back throwing still succeeds the create with unmerged attributes; sparse update refreshes; Pipes composition proves the one-call gating; S3 Bucket per-type Arn fallback still applies after the generic pass; a non-string model value counts as real information. No new resource type, dependency, CLI flag, or state-schema change. - Hard-fail on knowably-wrong
Fn::GetAttphysicalId fallbacks (issue #1106, option 1) —src/deployment/intrinsic-function-resolver.ts,tests/unit/deployment/intrinsic-getatt-fallback-guard.test.ts(new). Why:constructAttribute's final unknown-attribute branch warned ("Unknown attribute X for resource type Y, returning physical ID") and returned the physicalId; in the #1103 incident that shipped four resource NAMES into stack Outputs where ARNs were requested, with a green deploy. What shipped: in that final fallback branch ONLY (after known per-type mappings and enriched attributes are exhausted), an attribute name ending inArnwhose physicalId does not start witharn:, or ending inUrlwhose physicalId does not match^https?://, now throws an actionable error (resource type, logical id, requested attribute, the physicalId that would have been returned, plus an enrichment hint and issue pointer) instead of warning. A physicalId that already IS an ARN / URL passes the shape check and remains a valid fallback (unchanged behavior);Alias/Endpointand every other suffix keep warn-and-return (shape-indistinguishable from a plain name, so false positives are unacceptable).cdkd diffis unaffected: its best-effort resolution catches the throw and renders the raw intrinsic. Tests: 10 unit tests; the 4 throw-asserting ones fail without the fix. - CC GetResource read-back enrichment for Pipes / S3 AccessPoint / ResourceGroups
Fn::GetAtt(issue #1103) —src/provisioning/cloud-control-provider.ts,tests/unit/provisioning/cloud-control-provider.test.ts,tests/integration/cc-getatt-readback/(new). Why: three more live-confirmed instances of the #984 class, found by /hunt-bugs sweep 16:AWS::Pipes::Pipe,AWS::S3::AccessPoint, andAWS::ResourceGroups::Grouphave no SDK provider (pure Cloud Control) and their async CREATEProgressEvent.ResourceModelis sparse, so stateattributesended up{}andFn::GetAttfell through the resolver'sconstructAttributedefault to the bare physicalId — the resource NAME.GetAtt(Pipe, 'Arn')returnedmy-pipeinstead ofarn:aws:pipes:...:pipe/my-pipe,GetAtt(Ap, 'Arn'/'Alias')returned the access point name instead of the ARN /...-s3aliasalias,GetAtt(Rg, 'Arn')returned the group name. Deploy stayed green (warn-only), silently poisoning Outputs and downstream consumers (IAM policies, alarm actions, S3 clients handed a non-existent alias).AWS::ApiGatewayV2::ApiresolvedApiEndpointcorrectly in the same probe stack — ResourceModel sparseness is per-type, which is why the class keeps resurfacing type by type. What shipped: the Backup-scopedreadBackupResourceModelhelper (PR #992) is generalized toreadCcResourceModel(same CCGetResource+ parse + best-effort-never-throws contract, JSDoc updated) and three newenrichResourceAttributescases overlay the missing readOnly attributes from the read-back, gated on the key attribute being absent so an already-rich ResourceModel costs no extra API call: Pipe (Arn, plusCurrentState/StateReason/CreationTime/LastModifiedTime— the documented GetAtt set), AccessPoint (Arn/Alias/NetworkOrigin), Group (Arn). Per-key literalenriched['...']assignments keep thegen-enrichment-coverageTS-AST parser seeing each case's keys; all three types joinenrichedWithoutCachedSchema(informational — deliberately NO cfn-schemas fixture, per the "no stale fixture files for unregistered types" test). Tests: a new unit describe block mirroring the Backup one — overlay per type, skip-read-back-when-present, only-Alias-missing preserves existing Arn, best-effort failed-read leaves attributes unchanged; 5 tests fail without the fix. Integ: newtests/integration/cc-getatt-readback/— deploys bucket + access point, SQS→SQS pipe (+role), tag-based resource group, then asserts each GetAtt-backed stack output EQUALS the value read back from the service API (equality, not anarn:prefix check) and that the state pipe attribute carries the real ARN; destroy + gone assertions. No new resource type, dependency, CLI flag, or state-schema change. -
cdkd importpersists provider-returned attributes (issue #1098) —src/cli/commands/import.ts,tests/unit/cli/import.test.ts,tests/integration/import-attributes/(new),docs/state-management.md. Why:buildStackStatehardcodedattributes: {}when constructing eachResourceStaterow, so every provider'simport()return value — e.g.EMRClusterProvider'sbuildAttributes(cluster)— was computed and then dropped on the floor.attributesis what backsFn::GetAttresolution against state, so a resource adopted viacdkd importstarted with an empty attribute map while the same resource deployed bycdkd deployhad it populated. Pre-existing and provider-agnostic; surfaced while building the EMR import round-trip integ for #1090, which had to assertobservedPropertiesinstead. The open question, settled: the issue asked whether persisting is right or whether the empty map is deliberate (attributes re-derived on demand rather than trusted from an import-time snapshot). Persisting wins — a deploy-created resource already stores a create-time snapshot, so persisting the import-time one makes an adopted resource state-shape-identical to a deployed one rather than introducing a new staleness class. What shipped:ImportRowcarriesattributes, populated fromResourceImportResultat theprovider.import()call site on theimportedoutcome;buildStackStateuses it. One fix covers every mode — the recursive--migrate-from-cloudformationchild walk calls the sameimportOne/buildStackState, so auto / selective / hybrid / nested cannot diverge. The synthesizedAWS::CloudFormation::Stackrow (cdkd-local ARN, no provider call) correctly keeps{}. Second-order effect, measured rather than assumed:resolveImportedPropertiespassesstackState.resourcesas resolver context, and the resolver readsattributesfor bothFn::GetAttandRef, so populating attributes changes the PERSISTEDpropertiesof imported resources. Pre-fix this did NOT fail —constructAttribute's terminal branch isdefault: return physicalId, so it silently substituted the physical id. Probed against an importedAWS::CodeCommit::Repository(physicalId = repo NAME, noconstructAttributebranch): a consumer{"Fn::GetAtt": ["MyRepo", "Arn"]}persisted as"my-repo"pre-fix and"arn:aws:codecommit:us-east-1:...:my-repo"post-fix. So the change CORRECTS a silently-wrong persisted property and converges import's stored shape onto whatcdkd deploywrites. (The effect only appears for types with noconstructAttributebranch — an SQS Queue probe showed no divergence, which is why the empirical check mattered.) Attribute carry-over guard (found by review): a re-imported row REPLACES the wholeResourceState, so a resource that already had a populated map would have it wiped when the provider returns none. It now falls back to the stored map — but ONLY when the physical id is unchanged, since carrying attributes across a--resource X=<other> --forcere-import would resurrect stale facts about the OLD resource and hand them toFn::GetAtt. Tests: three genuinely fail-to-pass unit tests (attributes persisted; same-physical-id carry-over; downstreamFn::GetAttresolving into persisted properties) plus guard tests for the no-attributes, different-physical-id, and unlisted-resource paths. Integ: newtests/integration/import-attributes/—AWS::IAM::ManagedPolicy(free, instant, and itsimport()returns a non-empty{PolicyArn}) deploy →state orphan→ re-import → assert the persistedattributesmap is non-empty ANDPolicyArnmatches → destroy + GONE assertion.import-nested-stackcannot substitute: its only leaf type isAWS::SSM::Parameter, whoseimport()returnsattributes: {}on both branches, so it passes identically with and without the fix. No new resource type, dependency, CLI flag, or state-schema change. - Provider-declared unordered plain-string sets in the drift normalizer (issue #1096 item 1) —
src/analyzer/drift-normalize.ts,src/analyzer/drift-calculator.ts,src/cli/commands/drift.ts,src/types/resource.ts,src/provisioning/providers/fsx-filesystem-provider.ts,docs/provider-development.md,.claude/rules/code-layout.md. Why:drift-normalize.tscanonicalized exactly two shapes before the drift comparison — tag lists and arrays whose every element is an AWS id / ARN. Plain-string arrays were deliberately left untouched because a scalar list can be order-significant, but several CFn inputs are semantically UNORDERED sets of plain strings (e.g. FSxWindowsConfiguration.Aliases), so an AWS-side reorder surfaced as phantom drift on a resource nobody touched. Why the obvious local fix is wrong: sorting inside the provider'sreadCurrentStatereverse-mapper looks like a one-liner but breaks theproperties-fallback baseline —runDriftForStackusesobservedPropertieswhen present and falls back to the templatepropertiesotherwise, so for a resource deployed before observed-capture the baseline would be the user's TEMPLATE order while the read side is sorted, MANUFACTURING drift instead of removing it. What shipped: a new optionalResourceProvider.getDriftUnorderedPaths(resourceType): string[](mirroring the existinggetDriftUnknownPathsseam) threaded bydrift.tsintocalculateResourceDrift'soptions.unorderedPathsand applied bycanonicalizeUnorderedArraysAtPathsto BOTH comparison sides — which is exactly the property the provider-local sort loses. Only arrays whose every element is a plain string are sorted, only at declared paths, and a nested array inside a declared path is left alone, so a mis-declared path can never reorder object / mixed-type / array-valued elements. Both provider-declared path lists now share ONE matcher, the exportedmatchesPathPrefix(path, entries)thatdrift-calculator.ts'sisIgnoredPathis a thin alias over, so the two conventions cannot silently drift apart; every entry is a SUBTREE declaration (there is no leaf-only form). One required divergence is documented:isIgnoredPathnever sees a path crossing an array (the comparator compares arrays wholesale viadeepEqual) whereas the unordered walk descends into array elements and gives them the parent's path, so'Items.Aliases'is meaningful forgetDriftUnorderedPathsbut inert as an ignore-path — strictly more permissive.FSxFileSystemProviderdeclares onlyWindowsConfiguration.Aliases.SelfManagedActiveDirectoryConfiguration.DnsIps— which the issue listed as a second confirmed instance — is deliberately NOT declared: the FSx API reference describes it only as "A list of IP addresses of DNS servers or domain controllers", with no statement that order is insignificant, and DNS resolver lists are conventionally preference-ordered. The two failure modes are not symmetric — leaving a genuinely-unordered set undeclared produces a VISIBLE false positive a user can reason about, while declaring an order-SIGNIFICANT list makes real drift SILENTLY INVISIBLE, the worse failure for a drift tool. Same axis that excludes ElastiCachePreferredAvailabilityZones(which AWS does document as positionally node-indexed). The provider docstring carries the citation and the FSx test pinsDnsIpsin AWS order so a future re-add must be deliberate. Tests: command-path tests intests/unit/cli/drift.test.tsdriving the REALobservedPropertiesandproperties-fallback branches (not a proxy flag) plus a negative control proving the clean result comes from the declaration being threaded — all verified to FAIL when thedrift.tswiring is removed;drift-normalize.test.tscases for declared leaf, undeclared path, subtree-prefix entry, non-string / mixed-type / empty / array-of-arrays arrays, and array-element path inheritance; amatchesPathPrefixblock pinning the shared rule; and an FSx test that routes realreadCurrentStateoutput through the real canonicalizer so a path-spelling mistake fails loudly instead of silently no-opping. Known gap: the FSx-declared path itself is unit-tested only — the live integ (drift-revert-arrays) exercises the normalizer and the provider-declares-nothing non-regression path, but a real FSx file system was out of scope. Cross-provider audit (reported, not implemented — each needs per-type verification against the same "is it documented as unordered?" bar): the strongest candidate isAWS::Cognito::UserPool, wherecognito-provider.tsalready carries a comment self-flagging this exact false-positive class onEnabledMfasand naming "a future order-insensitive array compare in drift-calculator" as the fix — that seam now exists. Also ACMSubjectAlternativeNames, CloudFrontAliases/TrustedKeyGroups, Route53ResourceRecords, IAM ManagedPolicyGroups/Roles/Users, WAFv2TokenDomains, ASGAvailabilityZones. Item 2 of #1096 (the FSx §3b placeholder test) is untouched and the issue stays open for it. No new resource type, dependency, CLI flag, or state-schema change. - Shared throttle-tolerant tag walk for provider
import()(issue #1091) + EMR fleetSUSPENDEDfail-fast (issue #1092 item 2) — newsrc/provisioning/import-tag-walk.ts,tests/unit/provisioning/import-tag-walk.test.ts,src/provisioning/providers/emr-cluster-provider.ts,src/provisioning/providers/docdb-provider.ts,src/provisioning/providers/emr-instance-fleet-config-provider.ts,src/provisioning/import-helpers.ts,docs/provider-development.md. Why: every provider that supports tag-basedimport()auto-lookup does an inherent N+1 read —List*to enumerate candidates, then oneDescribe*/ListTagsForResourceper candidate to read tags, because list summaries do not carry tags. Each provider hand-rolled that loop with NO throttle handling, so on an account with many resources of a type a single rate-limited call aborted the wholecdkd importrun. Separately,EMRInstanceFleetConfigProvider'sFAILED_STATEScontained onlyTERMINATED, so a fleet that enteredSUSPENDED(a resize that could not complete — existing instances keep running but AWS can add/remove none) polled to the fullmaxWaitMstimeout instead of failing fast with the service's own state-change reason. What shipped:importTagWalk({cdkPath, listPage, describe, tagsOf, logicalId?, retry?})centralises the pagination + per-candidate describe +aws:cdk:pathmatch and wraps BOTH callbacks in the deploy engine'swithRetry(0.5s → 1s → 2s → 4s → 5s, 5 retries), returning{summary, detail}for the first match ornull(short-circuiting with zero API calls on an emptycdkPath, replacing theif (!input.cdkPath) return nullguard each provider carried inline). Its classifierisThrottlingLikeError(also exported for providers whose tag API does not fit the callback shape) DELEGATES the error +.causetraversal toretryable-errors.ts's newly-exportedisThrottlingErrorand only adds theRate exceededmessage backstop — an earlier revision of this PR re-implemented the walk while sharing only the tables, and the two copies had ALREADY drifted (the helper checked$metadataat every cause depth, the write path only at depths 0-1), so the status check was folded into the one shared walk;isRetryableTransientErrorconsequently also now catches a retryable HTTP status nested deeper than one cause link. The classifier's POLICY split stays deliberate: the write path's eventual-consistency phrasings (does not exist,not authorized to perform) are terminal on a read-only walk and now surface immediately instead of burning the full backoff budget per candidate. BecausewithRetry's backoff is PER CALL, the walk carries its own limits, surfaced asImportTagWalkLimitErrorwith a--resource <logicalId>=<physicalId>escape-hatch pointer:retry.maxWalkMs(default 10 min — a sustained throttle would otherwise degrade into(pages + candidates) x ~12.5s, ~42 min of near-silent retrying for 200 candidates) andretry.maxPages(default 1,000 — a service returning a non-advancing pagination token would spin forever).retry.isInterrupted/onInterruptedforward towithRetryso Ctrl-C during a throttled sleep is honored as on the deploy path, andretry.loggerdefaults to the process logger so throttled retries AND every skipped candidate are visible under--verbose(a provider whosedescribemaps a broad error class toundefinedwould otherwise silently turn a genuine failure into "no match", and cdkd would CREATE a duplicate resource instead of adopting). Migrated callers:EMRClusterProvider.import(ListClusters+DescribeCluster) andDocDBProvider's three sub-types (DescribeDBInstances/DescribeDBClusters/DescribeDBSubnetGroups+ a new sharedlistTagsForResourcehelper). Batched tag reads are deliberately NOT modelled — neither migrated service offers one; a service that does (e.g. CodeCommitBatchGetRepositories) can satisfy several candidates inside its owndescribecallback. Separately, BOTH halves of the symmetric EMRSUSPENDEDdefect are fixed:SUSPENDEDmeans a resize could not complete (existing instances keep running, AWS can add or remove none), so the wait polled to the fullmaxWaitMstimeout instead of failing fast with the service's own state-change reason.emr-instance-fleet-config-provider.ts'sFAILED_STATESgoes{TERMINATED}->{SUSPENDED, TERMINATED}andemr-instance-group-config-provider.ts's goes{ARRESTED, TERMINATED, ENDED}->{ARRESTED, ENDED, SUSPENDED, TERMINATED}. Issue #1092 item 2 names only the fleet, butSUSPENDEDis a validInstanceGroupStatetoo and the group's resize wait carries the identical defect — shipping one half while declaring the other correct would have been worse than fixing neither. The two sets legitimately differ:ARRESTED/ENDEDareInstanceGroupStatemembers that do NOT exist for fleets, soTERMINATEDis the only state the two enums share (the fleet set is the ANALOGUE of the group set, not a copy — an earlier revision's comment claiming they match was false and is corrected). The fail-fast is scoped to the CREATE path plus post-transition resizes, NOT the first poll of a resize:SUSPENDEDis precisely the state a user re-runscdkd deployto RECOVER from, and the pre-resize read lag both providers already document (right afterModify*the group/fleet still reports its PRE-modify state) means poll #1 of the recovery resize still readsSUSPENDED. Enforcing the failed-state check there would abort the very resize that fixes the resource, on every attempt, leaving it permanently unrecoverable through cdkd — a strictly worse outcome than the slow-timeout bug being fixed. SowaitForGroupReady/waitForFleetReadytake atoleratesStaleFailedStateflag (true only at the resize call sites) that suppresses the check until the resource is observed LEAVING its initial state; from then on it is enforced normally, so a resize that transitionsRUNNING -> RESIZING -> SUSPENDEDstill fails fast. A resource that never leaves its initial state falls through to themaxWaitMstimeout — the pre-fix behavior. Note the asymmetry that makes this specific to failed states: a stale pre-resizeRUNNINGis harmless (the instance-count / capacity check keeps the loop polling), but a staleSUSPENDEDis terminal. Tests: newimport-tag-walk.test.ts(classifier: throttling names, nested cause chain, HTTP 429/503,Rate exceededbackstop, write-path messages NOT matched, cyclic cause chain; walk: first-match, no-match, empty-cdkPathzero-call short-circuit, pagination, describe-undefined skip, throttled-describe retry, throttled-list retry, budget exhaustion, non-throttling immediate rethrow) plus provider-level wiring tests onEMRClusterProvider(retries a throttled DescribeCluster mid-walk and still finds the match/does not retry a non-throttling DescribeCluster error during the walk) andDocDBProvider(a newimport tag walkblock: per-type match, Marker pagination, no-match null,retries a throttled ListTagsForResource instead of failing the walk,does NOT retry a non-throttling error from the walk), andfails fast when the fleet enters SUSPENDED (resize could not complete)on the fleet provider. Every one was verified to FAIL against the pre-fix code. Deferred: migrating the ~40 other providers that hand-roll anaws:cdk:pathwalk — tracked as a follow-up on #1091 so this PR stays reviewable;docs/provider-development.mdnow documentsimportTagWalkas the recommended shape for new providers. No new resource type, dependency, CLI flag, or state-schema change.
Recently Implemented (2026-07-19):
-
AWS::FSx::FileSystemvariant drift read-back +apply*UpdateFielddefault guard (issue #1089 + the FSx-scoped items of #1092, follow-up to #1068) —src/provisioning/providers/fsx-filesystem-provider.ts,tests/unit/provisioning/providers/fsx-filesystem-provider.test.ts,docs/supported-resources.md. Why: #1068 shipped create/update for all four variants butreadCurrentStatestill mapped only the Lustre block, sogetDriftUnknownPathshad to mark the wholeWindowsConfiguration/OntapConfiguration/OpenZFSConfigurationsubtrees drift-unknown —cdkd driftreported "unknown" for every non-Lustre variant's config rather than comparing it. Separately, the fourapply*UpdateFieldswitches had nodefault:arm, so a sub-property added to a*_MUTABLE_SUBPROPSset without a matching case would pass the mutability check, map to nothing, and issue a no-opUpdateFileSystemwithhasMutableDifftrue — silently dropping the user's change. What shipped: threeread*Configurationreverse-mappers turn each variant'sDescribeFileSystemsblock back into the flat CFn*Configurationinput shape (mirroring the LustreDataRepositoryConfiguration→LustreConfigurationmapping), including the WindowsAliasesshape change (API returns{Name, Lifecycle}[], CFn takes a plain name list) and the sharedDiskIopsConfiguration/AuditLogConfiguration/FsrmConfiguration/ReadCacheConfigurationsub-blocks. Read-only API fields that are not CFn inputs (Endpoints,RootVolumeId,EndpointIpAddress,RemoteAdministrationEndpoint,PreferredFileServerIp,MaintenanceOperationsInProgress) are deliberately NOT mapped so they cannot surface as phantom drift.getDriftUnknownPathsdrops the three whole-block entries and keeps only the leaves AWS genuinely never returns: the two write-only credentials (WindowsConfiguration.SelfManagedActiveDirectoryConfiguration.Password—SelfManagedActiveDirectoryAttributeshas noPasswordfield at all;OntapConfiguration.FsxAdminPassword) andOpenZFSConfiguration.RootVolumeConfiguration(configured on the root volume —DescribeFileSystemsreturns onlyRootVolumeId).EndpointIpv6AddressRangeon ONTAP / OpenZFS is reverse-mapped like any other field: it IS present on both read-side interfaces, and since it is also create-mapped and declared mutable, declaring it unknown would have left a user-settable, AWS-mutable, AWS-readable field permanently invisible to drift — the exact gap #1089 exists to close. Theapply*UpdateFieldswitches gain adefault:arm routing to a newunmappedMutableSubprop()that throws a reportableProvisioningErrornaming the exact<ConfigKey>.<Subprop>with no mapping; the variant identity is threaded in via a newVariantFieldContext.VARIANT_MUTABLE_SUBPROPSis now exported so the invariant is testable. A sharedputIfDefinedhelper replaces the repeatedif (x !== undefined)assignments across the whole ofreadCurrentState(behavior-identical; keeps the Lustre block consistent with the three new ones). Tests: 33 generated cases — one per declared-mutable sub-property across all four variants — asserting each maps to a non-emptyUpdateFileSystemfield, which is what makes a future set-addition-without-a-case fail loudly instead of silently; a guard test that temporarily injects an unmapped sub-property and asserts theProvisioningError+ zero AWS calls; per-variantreadCurrentStatereverse-map tests (incl.Aliasesflattening, read-only-field exclusion, write-onlyPasswordabsence, empty-block omission); the two mandatorydrift --revertround-trip guards fromdocs/provider-development.md§3b (unchanged snapshot replays as a no-op; a mutable-only drift replays as a real scopedUpdateFileSystem); and dedicated nested-sub-block update-arm coverage (WindowsAuditLogConfigurationreconciliation, stringly-typedIopscoercion, ONTAPDiskIopsConfigurationindependent ofRouteTableIds, sub-block removal →undefined). Known gaps (neither introduced here): (1) the FSx test file still lacks theemits placeholders for every user-controllable top-level keyblock thatdocs/provider-development.md§3b marks mandatory — retrofitting it would change which top-level keysreadCurrentStateemits on an AWS-minimum response and is deliberately left to a focused follow-up. (2)— RESOLVED forWindowsConfiguration.AliasesandSelfManagedActiveDirectoryConfiguration.DnsIpsare plain-string arrays, whichsrc/analyzer/drift-normalize.tsdoes not canonicalizeAliasesby issue #1096 item 1 (see the entry below), which extended the shared normalizer exactly as this note predicted was the correct fix.DnsIpsis deliberately left undeclared: AWS documents no set semantics for it and DNS resolver lists are conventionally preference-ordered, so declaring it could silently HIDE real drift — the worse failure mode. (3) Windows and ONTAP reverse-mappers are unit-tested only — the live integ covers OpenZFS (issue #1088 tracks per-variant live coverage). -
AWS::FSx::FileSystemWindows / ONTAP / OpenZFS variants (issue #1068, follow-up to #1042) —src/provisioning/providers/fsx-filesystem-provider.ts,tests/unit/provisioning/providers/fsx-filesystem-provider.test.ts,tests/unit/provisioning/provider-registry-cc-routing.test.ts,src/provisioning/property-coverage.generated.ts,src/provisioning/register-providers.ts,docs/supported-resources.md,tests/integration/fsx-openzfs/. Why: the initial FSx provider (#1042) shipped the Lustre variant only;WindowsConfiguration/OntapConfiguration/OpenZFSConfigurationwereunhandledByDesignand pre-flight REJECTED, so a Windows / ONTAP / OpenZFS (or the CDK L2aws-fsx.OpenZfsFileSystem) template failed fast rather than deploying. What shipped: all three config trees move tohandledProperties(FSx'sunhandledByDesignmap is now empty).createmaps each variant's full CFn block to the SDKCreateFileSystem<Variant>Configurationshape (nestedSelfManagedActiveDirectoryConfiguration/AuditLogConfiguration/FsrmConfiguration/DiskIopsConfiguration/RootVolumeConfigurationincl.NfsExports+UserAndGroupQuotas/ReadCacheConfiguration), defensively coercing string-typed numeric/boolean template values — exactly one variant block is non-undefined per file system, so the SDK ignores the rest.updategeneralizes the Lustre sub-property differ into a per-variantcomputeVariantConfigDiff: the mutable subset matching eachUpdateFileSystem<Variant>Configurationis applied,RouteTableIdschanges on ONTAP / OpenZFS are translated intoAddRouteTableIds/RemoveRouteTableIdsset deltas (a pure reorder is a no-op), and any other changed sub-field (createOnly in practice but NOT registry-createOnly — e.g.DeploymentType,OpenZFSConfiguration.RootVolumeConfiguration) is rejected with a--replacepointer. The shared create-poll-to-AVAILABLE/ delete-poll-to-gone /ClientRequestToken/getMinResourceTimeoutMs/ async-FILE_SYSTEM_UPDATE-action machinery is reused unchanged.getAttribute/buildAttributesadd the OpenZFS-onlyRootVolumeId;getDriftUnknownPathsmarks the three variant blocks drift-unknown (their read-back into CFn shape is a follow-up — only the Lustre block is mapped byreadCurrentStatetoday, so listing them avoids a guaranteed phantom drift).disableCcApiFallbackstaystrue(FSx isNON_PROVISIONABLE, so any future unhandled property must never CC-route). Tests: new WINDOWS / ONTAP / OPENZFS describe blocks (variant create mapping + coercion, mutable-update surface incl. SelfManagedAD updates / ReadCache / RouteTableIds Add-Remove + reorder no-op, immutable-sub-prop--replacerejection,RootVolumeIdattribute); the CC-routing guard test was re-pointed (FSx no longer has silent-drop entries, so its variant templates SDK-route cleanly — thedisableCcApiFallback+ silent-drop guard is still covered generically viapickSilentDropFixture). Integ: newtests/integration/fsx-openzfs/— the cheapest variant (OpenZFSSINGLE_AZ_1, no Active Directory) deploy → in-place update → destroy with a GONE-from-AWS assertion (describe-file-systemsempty for the fixture id). Windows / ONTAP are unit-tested and share the OpenZFS-integ-verified create-poll / delete-poll path; a per-variant Windows-AD / multi-AZ-ONTAP integ is an optional cost-gated follow-up. No new resource type / dependency (FSx was already Tier 1); property-coverage regenerated. - SDK Providers for
AWS::EMR::InstanceGroupConfig/AWS::EMR::InstanceFleetConfig(issue #1070, follow-up to #1064/#1043) —src/provisioning/providers/emr-instance-group-config-provider.ts+emr-instance-fleet-config-provider.ts(new),src/provisioning/register-providers.ts,tests/fixtures/cfn-schemas/AWS-EMR-InstanceGroupConfig.json+AWS-EMR-InstanceFleetConfig.json(new fixtures),.claude/integ-coverage-allowlist.json,docs/supported-resources.md+docs/import.md. Why: both types areProvisioningType: NON_PROVISIONABLE(re-verified live viaDescribeType), so the Cloud Control fallback cannot handle them and pre-flight rejected any stack adding a standalone instance group / fleet to an existing cluster. They are the standalone siblings of the inlineAWS::EMR::Cluster.Instancesgroups/fleets — a template attaches them to an EXISTING cluster referenced byJobFlowId(group) /ClusterId(fleet). Lifecycle:create→AddInstanceGroups/AddInstanceFleet+ pollListInstanceGroups/ListInstanceFleetsuntil the new group/fleet isRUNNING(a failed terminal —ARRESTED/TERMINATED/ENDEDfor groups,TERMINATEDfor fleets — is a hard error);update→ the AWS-mutable surface only — for a groupInstanceCount(ModifyInstanceGroupsresize, polled until the group'sRunningInstanceCountreaches the new target — NOT just groupState: RUNNING, which right afterModifyInstanceGroupsis still the STALE pre-resizeRUNNINGand would letupdate()return before the resize even starts; the fleet resize waits on provisioned capacity meeting the target the same way, direction-aware — scale-UP waits forprovisioned >= target, scale-DOWN forprovisioned <= target(an>=-only wait would return instantly on a scale-down because the stale pre-resize capacity is still above the new lower target)) +AutoScalingPolicy(PutAutoScalingPolicy/RemoveAutoScalingPolicy); for a fleetTargetOnDemandCapacity/TargetSpotCapacity/ResizeSpecifications/InstanceTypeConfigs(ModifyInstanceFleet, polled); every other property is registry-createOnly → replacement, and a createOnly change reachingupdate()is refused with a--replacepointer;getAttribute('Id')/Ref→ the group/fleet id (ig-XXXX/if-XXXX). Delete semantics (a real design question, resolved honestly): EMR has NO standalone "delete instance group / fleet" API — a group/fleet's only lifecycle end is cluster termination.delete()is therefore effectively a no-op that drops cdkd state; in the normalcdkd destroythe parentAWS::EMR::Clusteris terminated in the same run (the group/fleet depends on it viaJobFlowId/ClusterId, so it is deleted first, thenTerminateJobFlowsreleases every group/fleet + its EC2 instances → zero orphans). As a best effort for the "delete only the group/fleet, keep the cluster" case, aTASKgroup/fleet is scaled to 0 (ModifyInstanceGroups InstanceCount: 0/ModifyInstanceFleettarget capacities0) to release its instances;MASTER/COREcannot be scaled to 0 and are a pure no-op. Delete never blocks the destroy (warn-and-continue); anInvalidRequestException(cluster/group gone) is region-guarded viaassertRegionMatch. Note scaling to 0 does NOT remove the group/fleet record (it stays with 0 instances) — documented as the honest limitation. Both setdisableCcApiFallback = true(NON_PROVISIONABLE — no CC handler) andgetMinResourceTimeoutMs()(1h poll ceiling, mirrorsEMRClusterProvider). Every settable schema property is inhandledProperties(zero silent-drop).import()is not wired (both are cluster sub-resources without a per-resourceaws:cdk:pathtag — noted indocs/import.mdalongside the ASG entry; a future override-onlyimport()on the group/fleet id is the natural follow-up). Coverage regen: both types leave the Tier-3 (NON_PROVISIONABLE) set and join Tier-1 inprovider-coverage.json/unsupported-types.generated.ts/property-coverage.generated.ts. Tests:tests/unit/provisioning/providers/emr-instance-group-config-provider.test.ts+emr-instance-fleet-config-provider.test.ts(create poll sequences + terminal-state hard error, resize + auto-scaling / modify-fleet update matrix, immutable-change rejection, TASK scale-to-0 delete + region-guard idempotency + warn-and-continue, getAttribute). Integ: newtests/integration/emr-instance-configs/— a group-based cluster (1xm5.xlargemaster + 1xm5.xlargecore; the core node is required because EMR rejectsAddInstanceGroupson a master-only job flow) + a standaloneTASKInstanceGroupConfig(1xm5.xlarge), deploy → in-place resize (1 -> 2,ModifyInstanceGroups) → destroy (group released with the cluster,TERMINATED+ zero-orphan assertion). A cluster's collection type is fixed at create (groups XOR fleets) so ONE cost-bounded cluster covers onlyInstanceGroupConfig; the structurally identicalInstanceFleetConfigis allow-listed in.claude/integ-coverage-allowlist.json(unit-tested, same create-poll / no-standalone-delete / scale-to-0-TASK design).covers: AWS::EMR::InstanceGroupConfig. Engine fix surfaced by the first live integ run: the fixture's EC2 role + instance profile are created ~1s before the cluster'sRunJobFlow, and EMR rejected the create withFailed to authorize instance profile <arn>— the SAME just-created-instance-profile propagation race #1064 already handles asInvalid InstanceProfile, but with a different EMR sentence the retryable set did not cover. AddedFailed to authorize instance profiletosrc/deployment/retryable-errors.ts(+ unit test) so the deploy engine'swithRetryabsorbs the propagation window; CloudFormation tolerates this via deployment latency, cdkd retries. -
Codeseed-content +Triggerssupport forAWS::CodeCommit::Repository(issue #1066, follow-up to #1059/#1045) —src/provisioning/providers/codecommit-repository-provider.ts,tests/unit/provisioning/providers/codecommit-repository-provider.test.ts,docs/supported-resources.md,tests/integration/codecommit/+ newadm-zipdependency. Why: the two properties wereunhandledByDesignand pre-flight REJECTED (opt-in via--allow-unsupported-properties), so a template carrying either failed rather than silently dropping — but they were unsupported. What shipped:Code(create-only) reproduces CloudFormation's seed orchestration —GetObjectthe S3 ZIP (Code.S3.{Bucket,Key,ObjectVersion}), unpack every file entry withadm-zip(directory entries skipped — CodeCommit has no empty-dir concept), and issue a singleCreateCommitcarrying all files asputFilesonCode.BranchName(defaultmain); an empty ZIP is a warn-and-skip (CodeCommit rejects an emptyputFiles).Triggers(mutable) maps the CFn[{Name, DestinationArn, CustomData, Branches, Events}]list to the SDK's camelCaseRepositoryTrigger[]and reconciles viaPutRepositoryTriggerson create AND update —PutRepositoryTriggersREPLACES the full set, so a dropped entry or a fully-removedTriggersproperty is handled by putting the new set (empty array = clear all), and an unchanged block (compared as canonical JSON) skips the call. Both moved fromunhandledByDesigntohandledProperties(the provider now declares NO by-design-unhandled property → property-coverage regen). Create rollback safety: aCode/Triggersfailure AFTERCreateRepositoryself-cleans (best-effortDeleteRepository) before re-throwing —create()throwing before returning a physicalId means the deploy engine's rollback cannot delete the just-created repo, so the provider deletes it itself, mirroring CFn's rollback-deletes-the-repo behavior.Codeis create-only (CFn ignores it on update, so doesupdate()).getDriftUnknownPathsstill returns['Code', 'Triggers']:Codeis git history (never a comparable attribute), andTriggersis now wired on the write side but not yet read back byreadCurrentState(a follow-up can addGetRepositoryTriggersread-back and dropTriggersfrom the list). Tests: newcreate with Code seed(default-branch seed, explicit BranchName + ObjectVersion, empty-zip skip, missing-Bucket/Key rollback, S3-download-failure rollback),create with Triggers(PascalCase→camelCase mapping, optional-field omission, empty-array skip, put-failure rollback), andupdatetrigger cases (full-replace on change, empty-array clear on removal, no-op on unchanged) — S3 /adm-zipmocked. Integ:tests/integration/codecommit/extended with a repo carrying aCodeseed + an SNSTriggersentry, assertingGetFile(seed committed) +GetRepositoryTriggers(trigger applied) read-back, then a clean destroy. -
import()forAWS::EMR::Cluster(issue #1069, follow-up to #1064) —src/provisioning/providers/emr-cluster-provider.ts,tests/unit/provisioning/providers/emr-cluster-provider.test.ts,docs/import.md. Why: the initial EMR SDK provider (#1064) shipped create/update/delete but deferredimport(), socdkd importreported every EMR cluster asunsupported. What shipped:import(input)resolves the cluster either by an explicit id override (--resource <id>=j-XXXX→ verified viaDescribeCluster; there is no template name property equal to the physical id, so no name fallback) or by theaws:cdk:pathtag walk —ListClustersfiltered to the non-terminated states (aTERMINATED*cluster is never adopted) then aDescribeClusterper candidate to readTags(list summaries carry none), matched viamatchesCdkPath. Returns physicalId +{ Id, MasterPublicDNS }attributes (ornullfor not-found / already-terminated, so the import command marks it skipped). Also addsreadCurrentState+getDriftUnknownPaths: the reverse ofcreate()'sInstancesflatten —DescribeCluster(+ListInstanceGroups/ListInstanceFleetschosen byInstanceCollectionType) is re-bucketed back into the role-keyed CFnMasterInstanceGroup/CoreInstanceGroup/TaskInstanceGroups(+*InstanceFleet(s)) shape with the flatEc2InstanceAttributes(subnet / key name / security groups) folded in, plusTags(normalizeAwsTagsToCfn,aws:*stripped) and the top-level scalar fields — so a freshly-imported cluster gets a realobservedPropertiesbaseline (viacaptureObservedForImportedResources) and drift works. Lossy create-only sub-config AWS does not read back faithfully (ManagedScalingPolicy/AutoTerminationPolicy/BootstrapActions/Steps/KerberosAttributes/Configurations/AdditionalInfo/PlacementGroupConfigs/ApplicationsVersion) is declared ingetDriftUnknownPathsso theproperties-fallback path never phantom-drifts.docs/import.mdmovesAWS::EMR::Clusterfrom the unsupported list into the auto-lookup section. No new resource type / registration / coverage regen (EMR was already Tier 1). - Drift support for
AWS::CodeCommit::Repository(issue #1065, follow-up to #1059/#1045) —src/provisioning/providers/codecommit-repository-provider.ts,tests/unit/provisioning/providers/codecommit-repository-provider.test.ts,tests/integration/codecommit/verify.sh+README.md. Why: the initial CodeCommit SDK provider did not implementreadCurrentState, socdkd driftreported "unknown" for every repository. What shipped:readCurrentState(physicalId, logicalId, resourceType)issuesGetRepository+ListTagsForResourceand maps the read side back to the flat CFn inputs cdkd stores in state —repositoryDescription→RepositoryDescription,kmsKeyId→KmsKeyId, the tag map →Tags(CFn[{Key, Value}]list,aws:*tags dropped bynormalizeAwsTagsToCfnso a CDK-deployed repo'saws:cdk:pathtag is not phantom drift),repositoryName→RepositoryName. Every user-controllable top-level keyupdate()can mutate is emitted ALWAYS with a?? ''/?? []placeholder when AWS returns it undefined/empty (docs/provider-rules.md#readcurrentstate-for-drift-detection) — so a repo deployed WITHOUT a description still carriesRepositoryDescriptioninobservedPropertiesand a console-side ADD is visible to drift. The drift comparator (DiffCalculator) canonicalizes tag lists order-independently (drift-normalize.ts) so a tag reorder is never phantom drift. Returnsundefinedwhen the repository is gone (RepositoryDoesNotExistException) so the caller reports drift-unknown rather than throwing (mirrors the optionalimportopt-in shape) — including a repo deleted BETWEEN theGetRepositoryandListTagsForResourcereads (a race with a concurrent destroy), which is caught rather than aborting the wholecdkd driftrun (matches the ECR provider's guard).getDriftUnknownPathsreturns['Code', 'Triggers']— both areunhandledByDesign(create-only seed content / repository triggers), never read back byreadCurrentState, and normally rejected pre-flight, but a state written under--allow-unsupported-propertiescould carry them, so they are excluded defensively. No new resource type, no coverage regen, noregister-providerschange. Tests: newreadCurrentState (drift)describe block in the provider unit test (flat-input mapping, drift detected on a changed description, zero drift on a no-op, tag-reorder not phantom drift,Codeignored viagetDriftUnknownPaths, a genuine tag-value drift, the mandatory always-emit-placeholder key-set assertion on the AWS-minimum response,Tags: []even without an ARN, repo-gone / no-metadata / repo-deleted-mid-read →undefined, non-NotFound propagation) exercising the realcalculateResourceDrift; the integ fixture gained a Phase 1b drift round-trip (clean after deploy → out-of-bandUpdateRepositoryDescriptiondetected as drift exit 1 → revert → clean).
Recently Implemented (2026-07-17):
SDK Provider for
AWS::EMR::Cluster— EMR on EC2 (issue #1043) —src/provisioning/providers/emr-cluster-provider.ts(new),src/provisioning/register-providers.ts,@aws-sdk/client-emr(new dependency),tests/fixtures/cfn-schemas/AWS-EMR-Cluster.json(new fixture). Why: the type isProvisioningType: NON_PROVISIONABLE(re-verified live at implementation time viaDescribeType), so the Cloud Control fallback cannot handle it and pre-flight rejected any stack carrying an EMR cluster. Lifecycle:create→RunJobFlow+ pollDescribeClusteruntilWAITING/RUNNING; aTERMINATED*terminal during create is a hard error and the partially-created cluster is best-effortTerminateJobFlows'd so it does not bill;update→ the LIMITED mutable surface only —SetTerminationProtection(Instances.TerminationProtected),SetVisibleToAllUsers,ModifyCluster(StepConcurrencyLevel),PutManagedScalingPolicy/RemoveManagedScalingPolicy,PutAutoTerminationPolicy/RemoveAutoTerminationPolicy, andAddTags/RemoveTags(full-tag-removal untags explicitly — the #981 ECR regression class);delete→TerminateJobFlows+ poll untilTERMINATEDwith a hard error on timeout (a live cluster bills per instance-hour), idempotent on an already-TERMINATED/ aged-out cluster behindassertRegionMatch, and under--remove-protectionit flipsSetTerminationProtection(false)first (mirroring the EC2/ASG pattern insrc/provisioning/ec2-termination-protection.ts);getAttribute→Id(physical id) /MasterPublicDNS(viaDescribeCluster). Property classification (from the registry schema, not the issue's guesses): everything exceptTags/VisibleToAllUsers/StepConcurrencyLevel/ManagedScalingPolicy/AutoTerminationPolicyandInstances.TerminationProtectedis createOnly → replacement (Instancestopology,Applications,ReleaseLabel, roles, EBS root volume, etc.); an immutable change that reachesupdate()is refused with a--replacepointer. The bulk of the mapping work is the CFnInstancesblock: role-keyedMasterInstanceGroup/CoreInstanceGroup/TaskInstanceGroups(and the*InstanceFleet(s)siblings) are flattened into the SDK'sInstanceGroups/InstanceFleetsarrays with an explicitInstanceRole/InstanceFleetTypediscriminator per entry.getMinResourceTimeoutMs()self-reports a 1-hour ceiling (mirrorsCustomResourceProvider/FSxFileSystemProvider) so slow creates/terminates fit the per-resource deadline without--resource-timeout.disableCcApiFallback = trueso the #614 silent-drop auto-route cannot misroute an unhandled-property EMR template to CC.import()is not wired in this initial provider (tracked as a follow-up overListClusters+DescribeCluster). Unit tests:tests/unit/provisioning/providers/emr-cluster-provider.test.ts(29 tests — create poll sequences + instance-group/fleet flattening, terminal-during-create rollback, mutable-update matrix, immutable-change rejection, delete idempotency + region guard +--remove-protectionflip ordering + timeout hard-error, getAttribute). Integ:tests/integration/emr-cluster/(smallest single-node cluster — 1 masterm5.xlarge,emr-7.x,AutoTerminationPolicyidle-timeout to bound cost; deploy → in-place update → destroy with aTERMINATED/no-orphan assertion).covers: AWS::EMR::Cluster. Engine fix surfaced by the first live integ run:RunJobFlowvalidates the cluster'sJobFlowRoleinstance profile at create time, but cdkd's fast SDK path issuesRunJobFlowonly ~1s after theAWS::IAM::InstanceProfileCREATE — before IAM propagates the profile to EMR's validation layer — so EMR rejected the create withInvalid InstanceProfile: <name>.(the one-word "InstanceProfile", distinct from EC2'sInvalid IAM Instance Profilephrasing already handled). AddedInvalid InstanceProfiletosrc/deployment/retryable-errors.tsso the deploy engine'swithRetryabsorbs the propagation window (same just-created-IAM-dependency class as the Glue / Firehose / Step Functions / EC2 patterns); CloudFormation tolerates this via deployment latency, cdkd retries.SDK Provider for
AWS::FSx::FileSystem— Lustre variant (issue #1042) —src/provisioning/providers/fsx-filesystem-provider.ts(new),src/provisioning/register-providers.ts,src/provisioning/stateful-types.ts(FSx added to the stateful-recreation guard),@aws-sdk/client-fsx(new dependency),tests/fixtures/cfn-schemas/AWS-FSx-FileSystem.json(new fixture). Why: the type isProvisioningType: NON_PROVISIONABLE(re-verified live at implementation time), so the Cloud Control fallback cannot handle it and pre-flight rejected any stack carrying it — including the CDK L2aws-fsx.LustreFileSystem. Scope: the Lustre variant only;WindowsConfiguration/OntapConfiguration/OpenZFSConfigurationareunhandledByDesign(each non-LustreFileSystemTypeREQUIRES its variant block, so every non-Lustre template is caught by the property-coverage pre-flight — no silent drop). Lifecycle:create→CreateFileSystem(orCreateFileSystemFromBackupwhenBackupIdis set — a different API whereFileSystemTypeis not a valid parameter) with a stable immutable-input-hashedClientRequestToken+ pollDescribeFileSystemstoAVAILABLE, best-effort rollback delete on a failed/timed-out create (an orphaned FSx file system bills per hour);update→UpdateFileSystemfor the mutable surface (StorageCapacity, StorageType, FileSystemTypeVersion, NetworkType, and the mutable Lustre sub-props) +TagResource/UntagResourceforTags, with provider-level immutability rejection for Lustre sub-props the registry schema does NOT mark createOnly (DeploymentType/ImportPath/ExportPath/ etc. — the schema-based replacement fallback never fires for them);delete→DeleteFileSystem+ poll until GONE with a hard error on timeout (never warn-and-continue), idempotent NotFound behindassertRegionMatch;getAttribute→DNSName/LustreMountName/ResourceARN/RootVolumeId;readCurrentStatemaps the read-sideDataRepositoryConfigurationback to the flat CFnLustreConfigurationinputs;importverifies an explicit id or walksDescribeFileSystemsfor theaws:cdk:pathtag.getMinResourceTimeoutMs()self-reports a 1-hour ceiling (mirrorsCustomResourceProvider) so slow creates fit the per-resource deadline without--resource-timeout. Unit tests:tests/unit/provisioning/providers/fsx-filesystem-provider.test.ts(30 tests — poll sequences, token stability, replacement classification, tag diff, delete idempotency + region guard). Integ:tests/integration/fsx-lustre/(SCRATCH_2 1.2 TiB, deploy → in-place update (LZ4 compression + tag removal) → destroy with a GONE-from-AWS assertion by id and by tag). Two engine-level bugs surfaced by the first live integ run and fixed in the same PR: (1)UpdateFileSystemapplies changes via an ASYNCFILE_SYSTEM_UPDATEadministrative action — returning onLifecycle: AVAILABLEalone let the next read observe the OLD value (live: LZ4 compression read back as NONE seconds after the update);update()now waits for the action to complete (ignoring the hours-longSTORAGE_OPTIMIZATIONactions, matching CloudFormation) and hard-fails on a FAILED action. (2) The deploy engine's in-place UPDATE state write dropped the resource's storedattributeswhenever the provider's update result carried none — degrading every laterFn::GetAttto the physical-id fallback (live: the stack'sLustreMountNameoutput regressed to the file-system id); the engine now carries the previous attributes forward on in-place updates (replacements still never inherit), and the FSx provider additionally re-derives a fresh attribute set on every update. Plus a CC auto-route viability guard: the #614 silent-drop auto-route now REJECTS pre-flight (clear per-property error + escape hatch) when the route target cannot manage the type —NON_PROVISIONABLEor providerdisableCcApiFallback(first-time consultation of that flag;NestedStackProvider's misroute-to-real-CFn hazard is closed by the same guard). FSx polling loops absorb up to 5 consecutive transient Describe errors (throttle / 5xx) instead of failing a 10-minute create over one throttle.SDK Providers for the Bedrock AgentCore tool types —
AWS::BedrockAgentCore::Browser/AWS::BedrockAgentCore::CodeInterpreter/AWS::BedrockAgentCore::Evaluator(issues #1038 / #1039 / #1058) —src/provisioning/providers/agentcore-browser-provider.ts/agentcore-code-interpreter-provider.ts/agentcore-evaluator-provider.ts(new),agentcore-case-convert.ts(new shared Pascal↔camel helper extracted fromagentcore-runtime-provider.ts, now with apreserveKeysescape hatch for free-form JSON documents),src/provisioning/register-providers.ts,@aws-sdk/client-bedrock-agentcore-controlbumped^3.1017.0→^3.1089.0(the pinned 3.1018.0 predated theEvaluatorConfig.CodeBasedunion member). Why: Browser / CodeInterpreter areProvisioningType: NON_PROVISIONABLE, so the Cloud Control fallback cannot handle them and pre-flight rejected any stack carrying them; Evaluator is the newest piece of the AgentCore story cdkd already invests in (cdkd local invoke-agentcore/start-agentcore). Premise corrections vs the issues (verified against the registry schemas + SDK typings): (1) Browser / CodeInterpreter are READ-ONLY singleton types representing the AWS-managed defaults (aws.browser.v1/aws.codeinterpreter.v1) — every schema property is read-only, the only registry handlers are read/list, and the id patterns are hard-locked to the defaults; the issues' proposedcreate → CreateBrowser/CreateCodeInterpreterwould have created CUSTOM tools, which belong to the DIFFERENTAWS::BedrockAgentCore::BrowserCustom/CodeInterpreterCustomtypes (FULLY_MUTABLE, Cloud Control-served). The providers are therefore ADOPT-ONLY:createverifies the default exists viaGetBrowser/GetCodeInterpreterand records its ARN (the primaryIdentifier, CFnRefparity) as physicalId;update/deleteare no-ops (destroy must never touch the AWS-owned defaults);getAttributeserves the read-only attributes live;importauto-adopts with no--resourceoverride. (2) Evaluator is meanwhile FULLY_MUTABLE (the issue's "entered the Tier 3 set" premise was stale — AWS shipped full handlers after it was filed), but the SDK provider still wins on speed and keeps the AgentCore family consistent. Evaluator provider:create→CreateEvaluator(physicalId = evaluator ARN; SDK ops take the id = the ARN'sevaluator/<id>segment viaevaluatorIdFromArn),update→UpdateEvaluator(Description / EvaluatorConfig / Level / KmsKeyArn) +TagResource/UntagResourcetag reconciliation (the update op has no tags parameter; full-removal untags explicitly — the #981 ECR regression class),delete→DeleteEvaluator(NotFound-idempotent,assertRegionMatch-guarded),getAttribute→ EvaluatorArn/EvaluatorId locally + Status/CreatedAt/UpdatedAt viaGetEvaluator,readCurrentState→ CFn-shaped drift read incl. tags (best-effort),import→ explicit ARN verbatim or bare id resolved viaGetEvaluator.EvaluatorNameis the only createOnly property (the issue guessed "possiblyLevel" — Level is updatable per both schema and SDK) → replacement via the registry-schema createOnly fallback, no hand rule needed. The fullEvaluatorConfigunion (LlmAsAJudgeincl. RatingScale/ModelConfig/InferenceConfig,CodeBased→LambdaConfig) plusKmsKeyArn/Tagsmap 1:1 through the shared case converter — withAdditionalModelRequestFields(free-form model fields liketop_k) passed VERBATIM both directions viapreserveKeys. All non-read-only schema properties of all three types are wired (handledProperties, zero silent-drop; Browser / CodeInterpreter declare the explicit empty set — every property is read-only). Tests: 51 new unit tests acrosstests/unit/provisioning/providers/agentcore-{browser,code-interpreter,evaluator,case-convert}.test.ts(adopt/no-op semantics, union case-conversion + preservation, tag reconcile matrix, delete idempotency + region-mismatch refusal, import paths). Integ: newtests/integration/agentcore-tools/fixture — all three types as rawcdk.CfnResources (aws-cdk-lib ships no L1 for the singletons andCfnEvaluatorpredatesCodeBased), a code-based evaluator backed by a fixture Lambda (no Bedrock model-access dependency): deploy (defaults adopted into state +GetAttoutputs, evaluator at TRACE) → UPDATE (Description / Level TRACE→SESSION / added tag, in-place: same evaluator id) → destroy (evaluator gone, AWS-managed defaults still READY, state removed).covers: AWS::BedrockAgentCore::Browser/AWS::BedrockAgentCore::CodeInterpreter/AWS::BedrockAgentCore::Evaluator.SDK Providers for
AWS::ServiceDiscovery::HttpNamespace/AWS::ServiceDiscovery::PublicDnsNamespace(issue #1044) —src/provisioning/providers/servicediscovery-provider.ts,src/provisioning/register-providers.ts. Why: both types areProvisioningType: NON_PROVISIONABLE, so the Cloud Control fallback cannot handle them and pre-flight rejected them — while theirPrivateDnsNamespacetwin deployed fine via the existing SDK provider, an inconsistency CDKaws-servicediscoveryL2 users hit directly. What shipped: the existingServiceDiscoveryProvidergained both kinds, sharing its operation-polling plumbing (pollOperation: OperationId →GetOperationuntilSUCCESS, namespace id fromTargets.NAMESPACE) — create →CreateHttpNamespace/CreatePublicDnsNamespace(PublicDns passesProperties.DnsProperties.SOA.TTLthrough and surfaces theHostedZoneIdattribute read back viaGetNamespace; the shared SOA-TTL extraction is now theextractSoaTtlPropertieshelper reused by the PrivateDns paths); update →UpdateHttpNamespace(Description is the sole mutable field) /UpdatePublicDnsNamespace(Description + SOA TTL) plus explicitTagResource/UntagResourcediffs via the sharedsyncNamespaceTagshelper (partial AND fullTagsremoval untag explicitly — the ECR #981 regression class; tag failures throw, never warn-swallow; also wired into the pre-existingPrivateDnsNamespaceupdate path, whose Tags-only changes previously silently no-op'd);Nameis createOnly per the registry schema → replacement handled upstream; delete → the now kind-agnosticdeleteNamespace(DeleteNamespace+ operation poll,NamespaceNotFound-idempotent behindassertRegionMatch);getAttribute→GetNamespace(Id/Arn/HostedZoneId) with aGetService-backed Service branch;readCurrentState(drift) reuses the namespace reader with thePropertiesbag omitted for HttpNamespace (no CFnPropertiesthere);importadopts by explicit id /Properties.Name/aws:cdk:pathtag via the sharedListNamespaceswalk, now filtered by the Cloud MapNamespace.Type(HTTP/DNS_PUBLIC/DNS_PRIVATE) so a same-named namespace of a different kind is never adopted (Cloud Map names are not unique across kinds). The deploy-timeFn::GetAttfallback insrc/deployment/intrinsic-function-resolver.tscovers all three namespace kinds (constructedArn;HostedZoneIdfetched live viaGetNamespaceand resolving toundefined— never the namespace id — on a miss; previously the two new kinds AND PrivateDns'sHostedZoneIdfell through to the bare physicalId). All writable schema properties for both types are wired (handledProperties, zero silent-drop; no new dependency —@aws-sdk/client-servicediscoverywas already in). Tests:tests/unit/provisioning/providers/servicediscovery-namespace-kinds.test.ts(GetOperation polling PENDING→SUCCESS with fake timers and FAIL propagation on create AND delete, SOA-TTL passthrough, HostedZoneId attribute, tag add/change/remove incl. full removal and throw-on-failure, delete idempotency, getAttribute matrix, readCurrentState shape split). Integ: newtests/integration/servicediscovery-namespaces/fixture — oneCfnHttpNamespace+ oneCfnPublicDnsNamespace(SOA TTL 90 + tags); verify.sh asserts both namespaces exist, the public one's Route 53 hosted zone exists and the TTL reached AWS, theHostedZoneIdFn::GetAttresolved into stack outputs, then destroys and asserts zero orphans including the hosted zone.covers: AWS::ServiceDiscovery::HttpNamespace, AWS::ServiceDiscovery::PublicDnsNamespace.SDK Provider for
AWS::CodeCommit::Repository(issue #1045) —src/provisioning/providers/codecommit-repository-provider.ts(new),src/provisioning/register-providers.ts,src/analyzer/replacement-rules.ts,src/deployment/intrinsic-function-resolver.ts. Why: the type isProvisioningType: NON_PROVISIONABLE, so the Cloud Control fallback cannot handle it and pre-flight rejected it outright; CodeCommit returned to full General Availability on 2025-11-24 (new sign-ups open again), making a provider worth having. What shipped: create →CreateRepository(RepositoryName— generated defensively when absent,RepositoryDescription,KmsKeyId,Tagswith CFnTag[]→ CodeCommitRecord<string, string>map conversion); update →UpdateRepositoryName(see below) /UpdateRepositoryDescription(empty string clears on property removal) /UpdateRepositoryEncryptionKey(property removal reverts to the AWS-managedalias/aws/codecommitkey) /TagResource+UntagResource(partial AND full tag removal untag explicitly — the ECR #981 regression class); delete →DeleteRepository(idempotent — AWS signals "already deleted" via a nullrepositoryId, NOT an exception, so both the null-id shape and the defensiveRepositoryDoesNotExistExceptionrunassertRegionMatchbefore counting as success); rename is retry-safe (awithRetryre-entry whoseUpdateRepositoryNamehits NotFound probes the new name and treats an already-applied rename as success); getAttribute →GetRepository(Arn/CloneUrlHttp/CloneUrlSsh/Name/KmsKeyId); import → explicitRepositoryNameverify viaGetRepository, else paginatedListRepositories+ListTagsForResourceaws:cdk:pathtag-map lookup. Rename is IN-PLACE, not replacement: issue #1045 proposed classifying aRepositoryNamechange as replacement "for CFn parity", but the CFn docs mark the property "Update requires: No interruption" and the registry schema'screateOnlyPropertiesis empty — real CloudFormation renames viaUpdateRepositoryName, preserving the repository's entire git history (replacement would destroy it), so the provider does the same and returns the new name asphysicalIdwithwasReplaced: false(a hand-writtenReplacementRulesRegistryrule pins all four handled properties as updateable). Ref parity: CFn'sRefreturns the repository ID (a GUID) while every CodeCommit API is name-based (physical id = repository name), socreate()/import()store aRepositoryIdattribute andcfnRefValueFromPhysicalIdrecovers it viastateLookup(falls back to the name on torn state).Code(CFn-only seed-content orchestration) andTriggersareunhandledByDesign— pre-flight rejects templates carrying them (nothing silently dropped) pending a follow-up (CreateCommit/PutFile,PutRepositoryTriggers). New dep@aws-sdk/client-codecommit. Tests:tests/unit/provisioning/providers/codecommit-repository-provider.test.ts(create/update/rename/tag-removal/delete-idempotency/getAttribute/import matrix) + 2Refparity cases intests/unit/deployment/intrinsic-functions.test.ts. Integ: newtests/integration/codecommit/fixture — deploy (desc + 2 tags +Ref-returns-GUID output pin), UPDATE with in-place rename (repository ID must survive) + description change +envtag change +teamtag removal, destroy clean.AWS::Budgets::BudgetSDK provider (issue #1041) —src/provisioning/providers/budgets-budget-provider.ts(new),src/provisioning/register-providers.ts,src/analyzer/replacement-rules.ts,@aws-sdk/client-budgetsdependency. Why: the type isProvisioningType: NON_PROVISIONABLE, so the Cloud Control fallback cannot manage it and pre-flight rejected any stack carrying a cost budget — a popular guardrail resource in baseline stacks. What shipped: full SDK provider —create→CreateBudget(one call carries the budget,NotificationsWithSubscribers, andResourceTags; physicalId is the budget NAME, CFnRefparity — a user-suppliedBudget.BudgetNamepasses through VERBATIM, only the logical-id fallback goes through the name generator),update→UpdateBudgetplus in-place notification/subscriber reconciliation (CreateNotification/DeleteNotification/CreateSubscriber/DeleteSubscriber— notifications are addressed by value, subscriber additions are issued BEFORE deletions because a notification must keep >= 1 subscriber, and every reconciler step is idempotent: NotFound on delete / DuplicateRecord on create count as success so a partial failure can be retried forward or rolled back) andTagResource/UntagResourcetag diffs,delete→DeleteBudget(NotFound-idempotent,assertRegionMatch-guarded),getAttribute→Arn(computedarn:aws:budgets::{account}:budget/{name}, existence-verified viaDescribeBudget). Global-endpoint semantics: the Budgets API is a global per-account service served fromus-east-1; the SDK endpoint ruleset routes EVERY aws-partition region tobudgets.amazonaws.com(SigV4 scopeus-east-1), so the client is created with the deploy region like regional providers and the standard region guard stays uniform.AccountId(required on every call) is resolved once per provider instance via STSGetCallerIdentityas a cached single-flight promise (failures not cached).Budget.BudgetNameis createOnly — a rename classifies as REPLACEMENT via a conditional rule on the nested name (budgetNameChangedinreplacement-rules.ts);NotificationsWithSubscribersis createOnly for CloudFormation (whole-budget replacement) but updateable under cdkd thanks to the reconciler. All 3 writable schema properties are wired (handledProperties, zero silent-drop).importadopts by explicit--resourceid /Properties.Budget.BudgetName, or byaws:cdk:pathtag viaDescribeBudgets+ListTagsForResource. Tests: 45 unit tests (create field/amount/date/PlannedBudgetLimits mapping, STS caching + no-failure-caching, notification/subscriber reconcile ordering, tag diff, delete idempotency + region-mismatch refusal, getAttribute, import explicit/tag/miss, replacement-rule matrix). New real-AWSbudgetsinteg fixture: deploy (1 USD monthly cost budget + 80% ACTUAL email notification, CLI assertions) → in-place UPDATE (limit 1→2 USD, threshold 80→90 reconcile, +1 subscriber; provisionedBy=sdk) → destroy (budget gone, state removed).covers: AWS::Budgets::Budget.Fix:
cdkd state infono longer dies withPermanentRedirectwhen the ambient region differs from the state bucket's region (issue #1054) —src/cli/commands/state.ts. Bug: thestate infoflow called its four raw S3 helpers —detectBucketRegion,listStateFileKeys,readSchemaVersion,listAssetStorageMarkers— with the raw ambient-regionawsClients.s3client, soListObjectsV2/GetObjectagainst a bucket in another region 301'd withPermanentRedirect(repro:AWS_REGION=us-west-2 cdkd state info --state-bucket <us-east-1 bucket>; surfaced by the hardened asset-bootstrap fixture, issue #1052). Every OTHER state-bucket consumer already region-corrects via the sharedrebuildClientForBucketRegionhelper (S3StateBackend/LockManager/ExportIndexStore, issue #827) — these direct reads were the one un-corrected path. Fix:stateInfoCommandbuilds one region-corrected S3 client afterverifyBucketExists()(mirroringExportIndexStore's option choices:reuseClientCredentials+tolerateNonStandardClient, never destroying the shared original; only the replacement is destroyed in the command'sfinally), and the four helpers now take the S3 client directly (s3: S3Client) instead ofAwsClients. Side benefit:detectBucketRegionpreviously try/caught the cross-region failure intoRegion: unknown; with the corrected client it now reports the bucket's actual region. Tests: newcross-region state bucket (issue #1054)describe block intests/unit/cli/state-info.test.ts—rebuildClientForBucketRegionmocked to return a sentinel client, asserting ALLGetBucketLocation/ListObjectsV2/GetObjecttraffic goes to the sentinel (not the original), the helper receives the ExportIndexStore-style options, and only the replacement client is destroyed; plus a same-region case (helper returnsnull) asserting the original client carries the traffic.cdkd local run-task --from-staterecognizes custom-named cdkd container-asset repos via the bootstrap marker (issue #1025) —src/local/ecs-task-resolver.ts,src/cli/commands/local-state-loader.ts,src/cli/commands/local-run-task.ts. Why: the run-task image classifier recognized cdkd-owned container-asset images by the literalcdkd-container-assets-prefix (CDK_ASSET_IMAGE_REPO_RE). Sincecdkd bootstrap --container-repo <name>(issue #1011) a region's container repo can carry ANY name — the per-region bootstrap marker is the source of truth — so images in a custom-named repo were not classified as cdk-asset images and lost the localcdk.out-build fast path (falling back to an ECR pull: correct, but slower). What shipped:isCdkAssetImageUritakes an optionalcdkAssetContainerReponame and additionally accepts any ECR-hosted URI (host contains.dkr.ecr., tolerating both concrete and${AWS::...}placeholder hosts) whose repository path component (new helperextractEcrRepoComponent, digest/tag-stripped) equals it; the name is threaded through a new optionalEcsImageResolutionContext.cdkAssetContainerRepofield into all three classification sites (flat/Fn::Sub,Fn::GetAtt,Fn::Join).detectEcsImageResolutionNeedsgainsneedsAssetRepoMarker(any container Image is a flat-extractable ECR-hosted URI NOT matching the conventional shapes), which gates a new best-effort helperloadBootstrapContainerRepoinlocal-state-loader.ts(readscdkd-bootstrap/{region}.jsonfrom the state bucket viaS3StateBackend.getRawObject+parseBootstrapMarker; every miss — no bucket, no marker, malformed marker, AWS error — logs at debug and returnsundefined, never failing the run). The marker read fires only under--from-state(--from-cfn-stackstacks were deployed via CloudFormation → conventional names → the regex suffices); the conventional-prefix regex remains the no-marker/no-AWS fallback. Largely defensive today: the local resolvers classify the SYNTH template's image and synth stays unrewritten by design (asset redirect happens at deploy), so the custom-name branch is reachable mainly when a template/flat string carries an explicit custom-repo ECR URI. Tests: newecs-task-resolverdescribe block (custom-repoFn::Subplaceholder-host + concrete-literal URIs →cdk-assetwith hash; no-context / different-name →ecr;docker.iohost guard →public; digest-suffixed match; conventional shapes still classify;needsAssetRepoMarkertrue/false matrix) + newtests/unit/cli/local-state-loader-bootstrap-repo.test.ts(marker present → name; null body / bucket-resolution failure / malformed JSON / missing fields / S3 read failure →undefined; bucket-root-relative marker key; region-chain fallback; globalClients reset).AWS::DLM::LifecyclePolicySDK provider (issue #1040) —src/provisioning/providers/dlm-lifecycle-policy-provider.ts(new),src/provisioning/register-providers.ts,@aws-sdk/client-dlmdependency. Why: the type isProvisioningType: NON_PROVISIONABLE, so the Cloud Control fallback cannot manage it and pre-flight rejected any stack carrying a Data Lifecycle Manager policy (EBS snapshot / AMI lifecycle — a common production backup-automation resource). What shipped: full SDK provider —create→CreateLifecyclePolicy(+ follow-upGetLifecyclePolicyfor theArnattribute; physicalId is the service-generated policy id, CFnRefparity),update→UpdateLifecyclePolicyfor policy fields plus explicitTagResource/UntagResourcefor tag diffs including FULLTagsremoval (the #981 ECR regression class — the update API has no Tags parameter),delete→DeleteLifecyclePolicy(NotFound-idempotent,assertRegionMatch-guarded),getAttribute→ArnviaGetLifecyclePolicy. ADefaultPolicychange throws the typedResourceUpdateNotSupportedError(create-only at the API level;--replacerecreates). The CFn property shape maps 1:1 onto the SDK's PascalCase inputs; the only conversion is CFnTagslist ↔ DLM tag map — all 12 writable schema properties are wired (handledProperties, zero silent-drop).readCurrentState(drift) surfacesDescription/State/ExecutionRoleArn/Tags;PolicyDetails+ the default-policy shorthand fields are declared drift-unknown (the service normalizes stored details with filled defaults — positional array comparison would fire phantom drift).importadopts by explicit--resourceid or byaws:cdk:pathtag via the single-callGetLifecyclePoliciessummary tag map. Tests: 22 unit tests (create field mapping + tag-map conversion; update tag add/change/remove incl. full-removal; DefaultPolicy-change rejection; delete idempotency + region-mismatch refusal; getAttribute; drift read-back; import explicit/tag/miss paths). New real-AWSdlm-lifecycle-policyinteg fixture: deploy (ENABLED policy + execution role, get-lifecycle-policy assertions, provisionedBy=sdk) → in-place UPDATE (Description + State ENABLED→DISABLED + tag value change AND tag removal; PolicyId unchanged) → destroy (policy + role gone, state removed).covers: AWS::DLM::LifecyclePolicy.Fix: template Parameter names no longer persisted into state
dependencies(issue #1032) —src/deployment/deploy-engine.ts(extractAllDependencies),src/cli/commands/import.ts(buildStackState). Bug:TemplateParser.extractDependenciescaptures everyRef— including Refs to CFn Parameters — and both state-write sites persisted the set verbatim, so every parameter-referencing resource carried the parameter NAME in itsdependencies. Oncdkd destroythe graph is rebuilt from a state-derived pseudo-template with noParameterssection, soDagBuilder.buildGraphwarnedResource <X> depends on <Param>, but <Param> not found in templatefor every such resource on every destroy (cosmetic — ordering was unaffected — but it reads like a real problem, and any raw-CFn / CfnInclude / migrated stack with parameters triggers it). Found while running the #1027 fixture's destroy. Fix: both write sites filter names declared in the template'sParameterssection before persisting (a parameter is not a provisioning-order edge; the deploy-side DAG already skipped them silently). Legacy state entries self-heal when a resource is next created/updated; until then the destroy warn can still fire for stacks deployed by older binaries. Tests:tests/unit/deployment/deploy-engine-param-deps-filtered.test.ts(CREATE persists only resource edges; empty deps key omitted) + adependencies persisted to state (#1032)case intests/unit/cli/import.test.ts— both fail without the fix.Fix: stacks carrying
AWS::CloudFormation::WaitConditionHandleare now deployable — new no-op SDK provider —src/provisioning/providers/wait-condition-handle-provider.ts(new),src/provisioning/register-providers.ts(issue #1020, user report). Bug: the type is NON_PROVISIONABLE (Cloud Control cannot manage it) and had no SDK provider, so cdkd's pre-flight rejected the whole stack. It is emitted bycdk-multi-region-stackas an empty-template placeholder (CloudFormation rejects zero-resource templates), so any app using that construct was undeployable. Fix: a no-op provider — in CloudFormation the handle's physical id is a pre-signed S3 signal URL backed by CloudFormation's internal bucket, which cannot exist outside a CFn deployment, so cdkd synthesizes an opaquecdkd-wait-condition-handle-<logicalId>-<uuid>placeholder (deliberately NOT URL-shaped so a straycfn-signalfails loudly), never calls AWS on create/update/delete, resolvesRefto the placeholder, throws onFn::GetAtt(the type has none), reports empty drift state, and imports verbatim by explicit id (CFn's pre-signed-URL id during--migrate-from-cloudformation) or synthesizes otherwise.AWS::CloudFormation::WaitCondition(the blocking signal-wait) remains unsupported — that semantic requires CloudFormation itself. New real-AWSwait-condition-handleinteg fixture pins the #1020 shape: bare handle + SSM sibling deploys (the old pre-flight failure),Refresolves into the stack output, sibling UPDATE keeps the handle's physical id stable, destroy leaves nothing.covers: AWS::CloudFormation::WaitConditionHandle.cdkd diffbinds template Parameters + evaluates Conditions like deploy; condition-false Outputs skipped (issues #1027, #1028) —src/cli/commands/diff-recursive.ts,src/deployment/deploy-engine.ts,src/types/resource.ts. Why: raw CloudFormation templates ingested via CDK'sCfnInclude(the common CFn → CDK migration shape) carryParameterswith defaults,Mappings, andConditions— the deploy engine resolved all of them (steps 2.5–2.7) but the standalonecdkd diffdid not, so a no-op diff on a freshly deployed stack reported a phantom[requires replacement]on an unchanged create-only property (the unresolvedFn::Join/Fn::FindInMapnew side vs the resolved old side), a phantomto createfor a condition-false resource, spurious value changes forRef/Fn::Subover parameters, and aFn::Sub variable ... not foundwarn; separately,resolveOutputsiterated everyOutputsentry unconditionally, so an output whoseConditionis false warnedFailed to resolve output ...on EVERY deploy (CFn silently omits such outputs), and one whose value happened to resolve would even be wrongly published as an output/export. Found by the 2026-07-17 bug-hunt round (deploy-first:CfnIncludefixture on real AWS). What shipped:computeStackDiffnow mirrors the deploy engine's preprocessing best-effort — binds template parameter defaults viaresolveParameters(nested-stack input parameters act as user values), evaluatesConditions, prunes condition-false resources viafilterResourcesByCondition, and threadsparameters+conditionsinto the resolver context soFn::Ifresolves too; binding/evaluation failures degrade to the previous raw-template diff.resolveOutputsskips an output whoseConditionevaluated false — no resolution attempt, no warn, not persisted to state, not exported (unknown condition names are kept, matchingfilterResourcesByConditionsemantics);TemplateOutputgains theCondition?: stringfield. Tests: 6 newcomputeStackDiffcases (param-default NO_CHANGE, condition-false prune, condition-false-in-state DELETE,Fn::If, nested-input-satisfies-required-param, graceful fallback) intests/unit/cli/diff-recursive.test.ts+tests/unit/deployment/deploy-engine-condition-false-outputs.test.ts(skip/keep/unknown-condition matrix). Integ: newtests/integration/raw-cfn-conditions-params/fixture (scenario tagraw-cfn-template-diff-parity) — deploy (defaults +Fn::Subover param+GetAtt + condition-false resource/output absent, no warn) → no-opdiff --failexits 0 → inlined-parameter UPDATE diffs as exactly0 to create, 1 to update, 0 to deletewith no replacement and updates in place (same QueueUrl) → destroy clean.cdkd gc— garbage-collect unreferenced objects/images from cdkd-owned asset storage (issue #1012) —src/cli/commands/gc.ts(new),src/cli/commands/state-file-keys.ts(new, extracted frombootstrap-destroy.ts),src/cli/index.ts. Why: cdkd-owned asset storage (issue #1002) is content-addressed and deliberately never deleted oncdkd destroy(another stack or a future rollback may reference the same hash), socdkd-assets-*/cdkd-container-assets-*grow without bound — andcdk gccannot reach them by design. What shipped: top-levelcdkd gc [--region <r>] [--older-than <dur>] [--dry-run] [-y](upstreamcdk gcparity naming; also--state-bucket/--profile/--role-arn/--verbose). Scope is ONE region's storage per invocation; bucket/repo names are read from the region's bootstrap marker (never recomputed — #1011 custom-name compatible); no marker → friendly no-op; CDK bootstrap storage is never touched. References are collected by scanning EVERY state file in the WHOLE state bucket (any--state-prefix; helper extracted into the sharedstate-file-keys.tsso gc andbootstrap --destroycannot drift), deep-walking each document for{S3Bucket, S3Key}pairs,s3://URIs, virtual-hosted / path-stylehttpsURLs (query strings stripped), and ECR image URIs by tag and/or digest. Deletion set = unreferenced AND older than--older-than(default 30d;<n>d/<n>h; missing timestamps treated as new = kept). Guards: anylock.jsonin the bucket aborts (in-flight deploy may have published assets whose state write has not landed); a state file that fails to JSON-parse aborts the whole run; every S3 call pinsExpectedBucketOwnerwith a 403 = foreign-bucket refusal. Reporting prints the per-item reclaim plan + byte totals;--dry-runexits without prompting or deleting; otherwiseContinue? (y/N)default-No confirm (--yesskips; non-TTY without--yeshard-errors); zero candidates → info + exit 0 with no prompt. Deletes via chunkedDeleteObjects(1,000/batch) andBatchDeleteImage(100/batch, by digest), surfacing per-item failures as hard errors. Tests:tests/unit/cli/gc.test.ts(21 cases: every reference-extraction shape incl. custom-prefix state files and outputs; referenced/old vs unreferenced/new keep-delete matrix; lock + malformed-state aborts; no-marker and missing-bucket no-ops; dry-run zero mutations; decline/empty-answer/non-TTY prompt paths;--older-thanparse rejects + cutoff honoring; ListObjectsV2 / DescribeImages pagination; 1,000/100 chunking; DeleteObjects / BatchDeleteImage failure surfacing; ExpectedBucketOwner on every call; marker-sourced names).cdkd bootstrap --asset-bucket <name>/--container-repo <name>— custom asset storage names (issue #1011) —src/cli/commands/bootstrap.ts,src/assets/asset-storage.ts. Why: the cdkd-owned asset storage names were fixed by convention (cdkd-assets-{accountId}-{region}/cdkd-container-assets-{accountId}-{region}); since S3 bucket names are global, a squatted conventional name left--use-cdk-bootstrap-assetsas the region's only remaining option, and org-wide bucket-naming policies could not be met at all. What shipped: two new create-side flags oncdkd bootstrap, validated BEFORE any AWS call (S3: 3-63 lowercase chars/digits/dots/hyphens with letter-or-digit ends; ECR: 2-256 lowercase chars/digits with single._-/separators;validateAssetBucketName/validateContainerRepoNameinasset-storage.ts) and threaded intoensureAssetStorageas optionalassetBucketName/containerRepoNamefields — overriding the conventional names for the existence probe, the create calls, AND the values written into the bootstrap marker. The marker stays the single source of truth: every consumer (deploy redirect/rewrite viaAssetModeResolver, publish,verifyAssetStorageExists,state info, and the #1010 teardown) already reads the marker, so none needed changes.ensureAssetStoragenow reads any existing marker first: a plain re-bootstrap REUSES the marker's names (a custom-named region never grows a second, conventional set), same-names re-runs stay the idempotent verify path, and a requested name that DIFFERS from the marker's hard-errorsASSET_STORAGE_NAME_CONFLICTpointing atcdkd bootstrap --destroy --region <r>(changing names would strand the existing storage + its published assets); a malformed marker is rewritten (re-running bootstrap is its documented fix) while a newerassetSupportVersionstill hard-errors. Flag combos rejected: with--no-assets(contradictory — the flags name storage that--no-assetsskips) and with--destroy(teardown reads names from the marker). The #1007 deploy-time auto-create keeps using conventional names (custom names require the explicit bootstrap), and custom bucket names get the identical squatting defense (ExpectedBucketOwnerprobe, owned-elsewhere hard refusal). Tests: custom names in probe/create/marker, conventional defaults unchanged, differing-names conflict (no AWS calls made), same-names idempotence, plain-re-bootstrap name reuse, custom-name squatting refusal, corrupt/newer marker handling, name-validation accept/reject matrices, and command-level flag threading +--no-assets/--destroy/ invalid-name rejections before any AWS call (tests/unit/assets/asset-storage.test.ts,tests/unit/cli/bootstrap.test.ts).cdkd bootstrap --destroy— teardown of cdkd-created account resources (issue #1010) —src/cli/commands/bootstrap-destroy.ts(new),src/cli/commands/bootstrap.ts. Why:cdkd bootstrapcreates up to four kinds of account resources (state bucket; per-region asset bucket + container-asset ECR repo + bootstrap marker) but there was no reverse command — the CDK CLI equivalent is deleting theCDKToolkitstack, while cdkd users had to hand-delete each piece (aws s3 rb --force,aws ecr delete-repository --force, marker delete). What shipped:--destroyon the existingcdkd bootstrapcommand (honoring--region) tears down ONE region's asset storage: empty (all versions + delete markers) + delete the asset bucket → force-delete the ECR repo → delete the per-region bootstrap marker LAST (mirror of the create side's marker-written-last ordering — a crash mid-teardown leaves the region consistently opted in, deploys hard-error with a re-bootstrap hint, never a silent legacy fallback). Bucket / repo names are read from the marker, never recomputed from the naming convention (compatible with the anticipated custom-name support, issue #1011). Before deleting, every state file in the state bucket is string-scanned for references to the region's asset bucket / repo (the listing spans the WHOLE bucket, so stacks deployed under a custom--state-prefixare covered); any deployed stack still referencing them refuses the teardown with a per-stack listing (--forceoverrides — under--destroythe flag means "skip the reference scan", not the create side's "reconfigure"). The state bucket is kept by default;--include-state-bucketopts it in and is refused (no--forceoverride) while ANY stack state exists (under any--state-prefix— the guard lists the whole bucket) or any OTHER region still holds a bootstrap marker in the bucket. Interactivey/N(default No) confirmation prints the full deletion plan;--yesskips; non-TTY stdin without--yesis a hard error (recreate-confirm-prompt convention). Idempotent: missing pieces are skipped with info lines (mirror ofensureAssetStorage), and every S3 call passesExpectedBucketOwner(a foreign bucket squatting the predictable name is refused, never deleted).--no-assets+--destroyand--include-state-bucketwithout--destroyare rejected as contradictory. Tests:tests/unit/cli/bootstrap-destroy.test.ts(21 tests — teardown order incl. marker-deleted-last, marker-driven custom names,ExpectedBucketOwneron every call, versioned emptying, reference-scan refusal +--forceoverride, no-marker no-op, idempotent skips, foreign-bucket 403 refusal, declined / empty-answer / non-TTY confirmation,--include-state-bucketrefusals + happy path + marker-less state-bucket-only path, flag validation). Docs:docs/cli-reference.mdbootstrap Teardown section,docs/state-management.mdmarker paragraph.
Recently Implemented (2026-07-15):
-
cdkd local run-taskrecognizes cdkd-owned + custom-qualifier container-assets image URIs as CDK assets (PR 3 of 3 of issue #1002) —src/local/ecs-task-resolver.ts. Why: the two ECS container-image classification sites matched only the hardcoded literalcdk-hnb659fds-container-assets-. After PR 2,cdkd deploypublishes container assets into the cdkd-owned repocdkd-container-assets-{acct}-{region}and rewrites templates to point there, so a migrated stack's template (and its--from-statestate) carries the cdkd repo shape — which the local run-task resolver did NOT recognize, falling through to a plain ECR pull instead of resolving the image back to the on-diskcdk.outbuild. The same sites also missed anycdk bootstrap --qualifier <custom>account (a pre-existing gap — only thehnb659fdsdefault matched). What shipped (PR 3 of 3): both classification sites (the flat /Fn::Subpath and the sharedclassifyResolvedImageused by theFn::GetAtt/Fn::JoinfromEcrRepositorypaths) now go through one helperisCdkAssetImageUri, whose regex(?:cdk-[a-z0-9]+|cdkd)-container-assets-matches BOTH the generalized CDK-bootstrap repo (cdk-<qualifier>-container-assets-, any qualifier) and the cdkd-owned repo (cdkd-container-assets-). A recognized URI classifies askind: 'cdk-asset'(resolve viacdk.out) instead ofkind: 'ecr'(pull from ECR). No cdk-local upstream change is needed: cdkd keeps its OWN still-localecs-task-resolver.ts/lambda-resolver.ts(they are not shimmed from cdk-local), and cdk-local'sintrinsic-imagehelpers only do placeholder substitution +Fn::Join→URI resolution — they never classify the container-assets prefix, so all cdkd-side classification lives in this file. The Lambda local-invoke path is unaffected (it resolves container images by assetsourceHash+ ECR-pull fallback, not by repo-prefix classification). Tests: 3 newecs-task-resolverunit tests —cdkd-container-assets-Fn::Sub→cdk-assetwith the hash extracted; custom-qualifiercdk-myqual123-container-assets-Fn::Sub→cdk-asset; aFn::JoinfromEcrRepositorywhose state-resolved repo name iscdkd-container-assets-...→cdk-asset(exercising the sharedclassifyResolvedImagesite). - Asset publishing redirects to cdkd-owned storage + template references rewrite once a region is opted in (PR 2 of issue #1002) —
src/assets/asset-redirect.ts(new),src/assets/asset-publisher.ts,src/assets/asset-storage.ts,src/deployment/deploy-engine.ts,src/provisioning/providers/nested-stack-provider.ts+nested-stack-context.ts,src/cli/commands/{deploy,diff,diff-recursive,import,publish-assets}.ts,src/cli/{options,config-loader}.ts. Why: PR 1 shipped detection-only; assets still published to the CDK bootstrap storage thatcdk gcgarbage-collects. What shipped (PR 2 of 3): incdkd-assetsmode (bootstrap marker present), (1) a destination-driven §6 mapping table is built per stack from*.assets.json— only default-bootstrap-shaped destinations (cdk-<qualifier>-(container-)?assets-{acct}-{region}, exactly the gc-exposed population) map to the marker's bucket/repo; customfileAssetsBucketName/ staging buckets / cross-region destinations stay verbatim (§8), andobjectKey/imageTagflow through unchanged; (2) the publishers redirect through that table (AssetPublisher.addAssetsToGraph({redirect})— deploy ANDpublish-assets, which now reads the marker via the standard state-bucket chain and falls back to legacy with an info line when none resolves); (3) the §7 template rewrite replaces every boundary-matched source name (plain strings,Fn::Subtemplate strings, folded pseudo-parameter-onlyFn::Joinruns; lookalike names like<bucket>-backupare never corrupted) — applied bydeploy(top-level + every nested child template viaNestedStackProviderContext.assetRedirect),diff(incl.--recursivechildren, so the plan previews the one-time migration diff), andimport(top-level + the recursive CFn-migration child walk, so imported state matches the next deploy);synth/exportstay unrewritten by design (§7.1); (4) a post-resolution audit in the deploy engine (DeployEngineOptions.assetRedirect) fails any resource whose resolved properties still name a mapped source — a missed template shape becomes a loud pre-provisioning error, never a split-brain deploy; (5)--use-cdk-bootstrap-assets(deploy / diff / import / publish-assets) +cdk.json context.cdkd.useCdkBootstrapAssetspin legacy destinations per invocation / per app (skips the marker read and the gc notice) for CFn-co-deployed apps in a migration window. The first deploy after opt-in shows a one-time all-assets UPDATE repointingCode/ImageUri/ asset URLs in place (no replacement, no state schema change); an opt-out deploy repoints back — flip-flop is churn, never breakage (both storages hold the content-addressed objects). Tests:tests/unit/assets/asset-redirect.test.ts(every §8 destination-shape row, Fn::Sub / Fn::Join fold / boundary cases, audit, publish-time redirect, lazy STS gating),deploy-engine-asset-audit.test.ts, nested-provider + diff-recursive rewrite tests,resolveUseCdkBootstrapAssetsconfig tests; integtests/integration/asset-migration/(legacy deploy → bootstrap → migration diff → in-place repoint incl. nested child + env-var asset URL read back fromGetFunctionConfiguration→ opt-out round-trip → Docker/ECR leg → clean destroy). -
cdkd bootstrapnow creates cdkd-owned asset storage + per-region marker; deploy detects the asset mode (PR 1 of issue #1002) —src/assets/asset-storage.ts(new),src/cli/commands/bootstrap.ts,src/cli/commands/deploy.ts,src/cli/commands/state.ts. Why:cdk gcdecides "in use" by scanning CloudFormation stack templates in the environment; cdkd-deployed stacks have no CFn stack, so every cdkd-published asset in the CDK bootstrap bucket/repo looks isolated to gc and gets deleted (ECS task launches fail to pull, runtimes3.Assetreaders break). Storage not referenced by the CDK bootstrap stack is structurally out of gc's reach — hence cdkd-owned asset storage (design atdocs/design/1002-cdkd-asset-storage.md, moved onto main by this PR). What shipped (PR 1 of 3): (1)cdkd bootstrapadditionally creates the asset bucketcdkd-assets-{accountId}-{region}(AES-256 + BucketKey, deny-external-account policy, deliberately NO versioning — immutable content-addressed blobs) and the container-asset ECR repocdkd-container-assets-{accountId}-{region}(IMMUTABLE tags), then writes the markers3://{stateBucket}/cdkd-bootstrap/{region}.jsonLAST (crash mid-bootstrap leaves no marker → deploys stay legacy).--no-assetsopts out. Bucket-squatting defense: refuses owned-elsewhere buckets (HeadBucket+ExpectedBucketOwner,BucketAlreadyExistshard error); the marker-verification path also passesExpectedBucketOwner. Re-running bootstrap on an existing account no longer early-returns — the state bucket is left as-is (no--forceneeded) and the asset storage + marker are added, which is the documented upgrade path. (2) Deploy-sideAssetModeResolverreads the marker once per (account, region) per invocation (cached, single-flight): absent → legacy mode, byte-identical publish behavior plus ONE info line about the gc hazard (only when the deploy actually publishes assets); present →cdkd-assetsmode with existence verification of bucket + repo (missing → hard error naming the resource +cdkd bootstrap --region <r>fix — never a silent fallback that would flip-flop properties); malformed marker → hard error. The resolved mode is detection-only in this PR — the publish redirection + template rewrite + post-resolution audit land in PR 2 of the phasing;cdkd localmatching lands in PR 3. (3)cdkd state infolists opted-in regions (Asset storage:line;assetStorage: [{region, assetBucket, containerRepo, createdAt}]in--json, empty array = legacy everywhere; malformed markers are skipped with a warning there since it is a cosmetic command). No state schema bump (v8 stays current); old-binary rollback is safe (old binaries ignore the marker). The asset bucket also gets a public-access block, every configuration PUT is owner-pinned viaExpectedBucketOwner, a marker withassetSupportVersionabove this binary's is hard-rejected ("upgrade cdkd"), and--profileis threaded into the verification clients (without it, the bucket's own deny-external policy turns the HeadBucket probe into a misleading foreign-bucket 403). Unit tests: asset-storage module (naming/marker parse+version guard/resolver legacy+cdkd-assets+missing-resources+cache/ensureAssetStorage idempotency+squatting+marker-last ordering), bootstrap command (asset creation,--no-assets, existing-bucket continue-past, custom-bucket marker targeting), state info (assetStoragein text + JSON). New real-AWS integ fixturetests/integration/asset-bootstrap/(verify.sh): legacy gc-notice exactly once -> bootstrap creates bucket/repo/marker with the exact hardening -> idempotent re-run -> cdkd-assets-mode deploy with no notice -> deleted-repo deploy hard error naming the repo + fix -> clean destroy + full storage cleanup. Docs: README prerequisites + quick start,docs/cli-reference.mdnewcdkd bootstrapsection,docs/state-management.mdmarker key layout.
Recently Implemented (2026-07-03):
Fix: an in-place UPDATE that changes a
Fn::GetAtt-consumed derived attribute now propagates to aNO_CHANGEdependent in the SAME deploy —src/analyzer/diff-calculator.ts(issue #985, the in-place sibling of #807). Bug: when an in-place update bumps a computed read-only attribute a dependent consumes viaFn::GetAtt, the dependent stayed one deploy behind. Concrete case: anec2.LaunchTemplate+autoscaling.AutoScalingGroupwhere the ASG'sLaunchTemplate.VersionisFn::GetAtt [Lt, LatestVersionNumber]. Changing the LaunchTemplate'sinstanceTypebumpsLatestVersionNumber1 → 2, but the ASG was classifiedNO_CHANGE(its raw template did not change and diff-time resolution saw the pre-update version "1"), so it stayed pinned at version "1" — running the same deploy AGAIN updated it to "2", proving the value was merely one deploy behind. Unlike #807 (a replacement moves the physical id, so EVERY reference is affected), here there is NO replacement — an in-place update side-effects a derived attribute value. Root cause: the existing in-place propagation pass (promoteInPlaceAttributeDependents) only promotes a dependent when theFn::GetAttattribute NAME equals a template property that CHANGED on the upstream.LatestVersionNumberis a computed AWS-derived attribute (resolved live via theDescribeLaunchTemplatesspecial case inintrinsic-function-resolver.ts), never a template property, so that arm never matched. Fix: a second, allow-list-driven arm promotes aNO_CHANGEdependent when it reads — viaFn::GetAttorFn::Sub${Up.Attr}— a DERIVED read-only attribute (per-typeIN_PLACE_UPDATE_DERIVED_ATTRS; seeded withAWS::EC2::LaunchTemplate→LatestVersionNumber/DefaultVersionNumber) of an upstream that had an in-place (non-replacement) UPDATE — regardless of WHICH property changed, since anyLaunchTemplateDataedit bumps the version. Replaced upstreams are excluded from the new arm (they are already handled transitively bypromoteReplacementDependents, so #807 is neither regressed nor double-promoted); the fix deliberately does NOT subsume #807 — the two passes stay separate and complementary. Promotion is safe when speculative: the deploy engine re-resolves the promoted dependent against the fresh LIVE value and skips the provider call if it did not move, and the allow list keeps unrelated computed attributes (e.g. a LambdaArn) from ever triggering a spurious dependent update. 5 new unit tests (ASG promoted via GetAtt + viaFn::Sub; the synthetic change is an in-place UPDATE not a replacement; unchanged-LT staysNO_CHANGE; a non-allow-listed computed attr of an updated LT staysNO_CHANGE). New real-AWSlaunchtemplate-asg-inplaceinteg fixture (VPC + LaunchTemplate + ASG,desiredCapacity: 0): Phase 1 asserts ASGLaunchTemplate.Version == "1", the UPDATE phase changes onlyinstanceTypeand asserts the ASG re-points to"2"in the same deploy, then a clean destroy.covers: AWS::EC2::LaunchTemplate, AWS::AutoScaling::AutoScalingGroup.Fix:
Fn::GetAtton CC-routedAWS::Backup::*resources returns the real ARN / VersionId / SelectionId instead of falling back to the physical id —src/provisioning/cloud-control-provider.ts(issue #984). Bug:AWS::Backup::*types have NO SDK provider (pure Cloud Control), and the CC CREATEResourceModelis sparse for Backup, soFn::GetAtt(<Vault>, 'BackupVaultArn')(the canonical CDK shape, emitted byvault.backupVaultArn) fell through cdkd's intrinsic-resolverconstructAttributedefault to the physicalId — which for aBackupVaultis the vault NAME, not the ARN — with only awarn. Deploy stayed green (a silent GetAtt divergence); any downstream consumer that needs the real ARN (an IAM policy, a cross-stack reference, aCfnOutput) got the bare name. Same systemic enrichment-gap bug class as #844 / #864 / #865 / #866 / the Events::Connection Arn fix. Fix: addAWS::Backup::BackupVault(BackupVaultArn/EncryptionKeyArn/BackupVaultName),AWS::Backup::BackupPlan(BackupPlanArn/VersionId/BackupPlanId), andAWS::Backup::BackupSelection(SelectionId/BackupPlanId, extracted from the compound<SelectionId>|<BackupPlanId>CC primaryIdentifier) cases toenrichResourceAttributes. Each overlays the readOnly attributes from a generic Cloud ControlGetResourceread-back on the physicalId (the cleanest source for a type with no SDK provider) via a new best-effortreadBackupResourceModelhelper — a failed read leaves the CC attribute shape unchanged and never fails the deploy, with physicalId-derived fallbacks (BackupVaultName/BackupPlanIdfrom the physicalId,SelectionId/BackupPlanIdfrom the compound-id split) covering the read-fails path. All three types are pure-CC (no SDK provider, so NO cfn-schemas fixture per the established pattern — theno stale fixture files for unregistered typestest would hard-fail otherwise); they joindocs/_generated/enrichment-coverage.json's auto-generatedenrichedWithoutCachedSchemalist. 6 new unit tests (Vault ARN/EncryptionKeyArn overlay + best-effort failure with the name fallback still landing; Plan ARN/VersionId overlay + best-effort failure with the BackupPlanId fallback; Selection compound-id extraction + read-fails compound-split fallback). New real-AWSbackupinteg fixture deploys aBackupVault(removalPolicy DESTROY) +BackupPlanreferencing the vault + a tag-basedBackupSelection, exposesFn::GetAtt(Vault, 'BackupVaultArn')as aCfnOutput('VaultArn'), and asserts the resolved output STARTS WITHarn:aws:backup:(not the bare vault name) — then destroys clean (the empty vault deletes cleanly, no recovery points created).covers: AWS::Backup::BackupVault, AWS::Backup::BackupPlan, AWS::Backup::BackupSelection.Fix: inline
AWS::SNS::TopicSubscriptionlist is now created AND updated (was silently dropped end-to-end) —src/provisioning/providers/sns-topic-provider.ts(issue #980). Bug: the SNS Topic provider declaredSubscriptioninhandledProperties(keeping it on the SDK fast path) butcreate()deliberately skipped it,update()had no branch, andgetDriftUnknownPaths()excluded it — so any Topic carrying an inlineSubscriptionlist got NO subscriptions on AWS while cdkd reported success, and drift was blind to it. The old comment assumed CDK always manages subscriptions as separateAWS::SNS::Subscriptionresources — true for L2topic.addSubscription(), but NOT for L1CfnTopicwithsubscription: [...]or migrated CloudFormation templates, which declare them inline on the Topic (exactly as CloudFormation creates and updates them). Fix:create()issues oneSubscribeper entry (requiredProtocol+Endpoint, plus documented optional attributes —RawMessageDelivery/FilterPolicy/FilterPolicyScope/RedrivePolicy/DeliveryPolicy/ReplayPolicy/SubscriptionRoleArn— passed through the SubscribeAttributesmap, object-valued policies JSON-stringified via the exportedbuildSubscriptionAttributeshelper);update()diffs old vs new lists on(Protocol, Endpoint)identity — added entriesSubscribe, removed entries resolve their liveSubscriptionArnviaListSubscriptionsByTopic(paginated,PendingConfirmationskipped) thenUnsubscribe.getDriftUnknownPaths()now returns[];readCurrentState()reverse-maps the live(Protocol, Endpoint)pairs viaListSubscriptionsByTopic— but ONLY when state actually recorded an inlineSubscriptionlist, so a Topic whose subscriptions are separate resources (L2) does not surface them as phantom drift. 7 new provider unit tests (create Subscribes per entry + attribute JSON-stringify + no-op when absent; update add / remove-with-ARN-resolution / PendingConfirmation-skip / unchanged-no-op) + 2 readCurrentState tests (reverse-map when state carries the list / omit otherwise) + the drift-unknown assertion flipped to[]. Newsns-inline-subscriptioninteg fixture (L1CfnTopicinline-subscribed toCfnQueueA, UPDATE phase switches the endpoint to queue B and asserts B subscribed + A unsubscribed, clean destroy).covers: AWS::SNS::Topic.Fix: removing
LoggingConfiguration/TracingConfiguration/EncryptionConfigurationfrom a StepFunctions StateMachine is now actually applied on UPDATE —src/provisioning/providers/stepfunctions-provider.ts(issue #978). Bug:UpdateStateMachineis patch-style — a field omitted from the request keeps its current AWS value. The provider mapped all three configs from the NEW properties only, so a config REMOVED from the template mapped toundefined, was omitted fromUpdateStateMachineCommand, and AWS silently kept the old config (the removal never reached AWS; cdkd state said "gone" while AWS kept logging / X-Ray tracing / a customer-managed KMS key). Fix: the same removal-needs-clear-sentinel pattern as Lambda ESM'sclearOnUpdateRemoval/ SQS'sSQS_ATTRIBUTE_REMOVAL_RESET— when a config is present+configured inpreviousPropertiesbut absent (or an emptyreadCurrentStateplaceholder) in the newproperties,update()sends the explicit disable payload instead ofundefined: tracing →{ enabled: false }, logging →{ level: OFF, includeExecutionData: false, destinations: [] }(SDKLogLevel.OFF; destinations only required when level is not OFF, so the empty list is accepted), encryption →{ type: AWS_OWNED_KEY }(SDKEncryptionType.AWS_OWNED_KEY, the AWS default — resets a customer-managed CMK config). AwasMeaningfullyConfiguredguard (discriminatorLevelfor logging,Typefor encryption,Enabled === truefor tracing) keeps thereadCurrentStateempty-placeholder round-trip ({}/{ Enabled: false }) a no-op socdkd drift --revertdoes not spuriously reaffirm the AWS default. 8 new unit tests (each config's disable shape; all three at once; never-configured-before → no sentinel; still-present → mapped through unchanged; placeholder round-trip → no-op). Thestepfunctions-logginginteg's UPDATE phase now REMOVES both logging and tracing (definition unchanged) and asserts AWS reports logging level OFF, 0 destinations, and tracing disabled — the removed logs + tracing also naturally shrink the role's default policy in the same deploy.covers: AWS::StepFunctions::StateMachine.Fix: DynamoDB
StreamSpecificationchanges now apply on UPDATE — enabling / disabling / changing a stream is no longer silently dropped —src/provisioning/providers/dynamodb-table-provider.ts(issue #977). Bug:StreamSpecificationis classified updateable and stays on the SDK path (it is inhandledProperties), butupdate()had NO StreamSpecification branch — enabling a stream, changing theStreamViewType, and removing a stream were ALL silently dropped:cdkd deployreported success, state.json recorded the new spec, but AWS never got anUpdateTablewith the stream change, so the next diff saw no change and it could never self-heal (create() and readCurrentState() DID handle it, so a fresh-create table with a stream worked — only the update path was broken). Fix: a StreamSpecification branch mirroring the SSESpecification pattern (its ownUpdateTable, fired only whenJSON.stringify(new) !== JSON.stringify(old)), with three transitions keyed off whether the spec is present on each side: enable (StreamEnabled: true+ the newStreamViewType, wait ACTIVE, captureLatestStreamArn); disable (new absent, previous present →StreamEnabled: false, clear the stream ARN); and view-type change (both present, differentStreamViewType) — AWS REJECTS a direct StreamViewType switch on an enabled stream, so it is applied as disable → wait ACTIVE → re-enable with the new view type → wait ACTIVE (the wait between the two calls is load-bearing: DynamoDB also rejects a rapid disable-then-enable against a still-UPDATING table). The freshly-materializedLatestStreamArnis enriched back into the returnedStreamArnattribute (from the enableUpdateTable'sTableDescription, falling back to aDescribeTable) soFn::GetAtt [Table, StreamArn]resolves after an update-time enable rather than returning the pre-update value. 5 new unit tests (enable pinsStreamEnabled: true+ the fresh StreamArn; DescribeTable fallback when UpdateTable omits the ARN; removal pinsStreamEnabled: false+ cleared StreamArn; view-type change pins the disable → wait → re-enable sequence; unchanged → no UpdateTable). Thedynamodb-streamsinteg gained an enable-on-UPDATE phase (CDKD_TEST_UPDATE=true): Phase 1 deploys a stream-LESS table, the UPDATE phase addsstream: NEW_AND_OLD_IMAGES(+ a Lambda / EventSourceMapping consumer, which can only exist once a stream does) and assertsStreamSpecification.StreamEnabled == true+LatestStreamArnnon-null + the cdkdStreamArnoutput equals AWS's LatestStreamArn.covers: AWS::DynamoDB::Table.Feature: ApiGateway Stage
MethodSettingsare now wired into the SDK provider — throttling/logging/metrics/caching stages stay on the SDK fast path —src/provisioning/providers/apigateway-provider.ts(issue #966, the defense-in-depth half of #963). Background:MethodSettings(CDK's everydaydeployOptions.throttlingRateLimit/metricsEnabled/loggingLevel/caching*) was a silent-drop property, so any stage carrying it CC-routed via #614 — paying CC polling latency on one of the most common resource shapes, and (pre-#963) breakingRef. Implementation: CreateStage does not accept method settings, socreate()issues one post-create UpdateStage withreplaceops per specified field under/{method_setting_key}/...(the key is{resource_path}/{http_method}with the leading slash stripped — CDK's stage-level options key as the star-slash-star wildcard; CFnResourcePathis already~1-escaped so no re-encoding).update()diffs old vs new entries on the same UpdateStage call: changed/new fields →replace; a whole dropped entry →remove /{key}(clears every override for that method path, CFn absent-entry semantics); a field dropped from a KEPT entry → reset-and-rebuild (remove /{key}+replaceof every remaining field) because API Gateway rejects field-level removes ("Cannot remove method setting ... because there is no method setting for this method") while whole-key remove followed by field replaces in the same call applies sequentially — both verified by live UpdateStage probes (2026-07-03). The root resource path (bare/) keys as~1(~1/GET, also live-verified) — plain slash-stripping would build the malformed//GET/...patch path. A post-create UpdateStage failure best-effort-deletes the just-created stage before rethrowing, so the corpse cannot hold the stage name and kill every retry with ConflictException (the PR #957 class). All 10 CFn MethodSetting fields are mapped (throttling rate/burst, metrics, logging level/dataTrace, caching enabled/ttl/encrypted/requireAuth/unauthorizedStrategy).readCurrentStateStage(drift) rebuilds the CFn list from the get-stagemethodSettingsmap, iterating the STATE's entries so list order matches the baseline and emitting only state-declared fields (get-stage fills every default, which would otherwise phantom-drift); entries still emit when AWS returns no methodSettings map at all, so out-of-band removal of every override surfaces as drift instead of dropping out of the comparison. 9 new unit tests (create post-patch; changed-field-only update; entry-drop + reset-and-rebuild; all-10-fields patch-path table incl. false/0 values + the root~1key; MethodSettings riding the same UpdateStage as other stage ops; post-create-failure stage cleanup; unchanged → no call; drift readback order/field filtering + missing-map emission). Theapigatewayinteg gaineddeployOptions.throttling*+ an assertion that the throttling reached AWS ANDprovisionedBy == sdk(the stage no longer CC-routes). Theapigw-stage-throttlingfixture (the #963 CC-route regression) swapped its trigger to the still-unwiredAccessLogSettingso it keeps exercising the CC path — its own guard message predicted exactly this swap. Both integs verified end-to-end against real AWS (0 orphans).covers: AWS::ApiGateway::Stage.Fix:
Refon CC-routed ECS::Service / S3Tables Namespace + Table returns the CFnRefcomponent; WAFv2::WebACLRefreturns the CFnname|id|scopecompound on the SDK path —src/deployment/intrinsic-function-resolver.ts,src/provisioning/providers/wafv2-provider.ts(cross-family close-out of the #963 routing-triggered compound-Refclass). Audit: enumerated every SDK-registered type (116) and cross-checked each against its CC registryprimaryIdentifier; 16 are compound. The ApiGateway / ApiGatewayV2 families were fixed in PR #967 / #969; this PR closes out the remaining 7 after verifying each type's AWS-docs "Return values / Ref": (1)AWS::ECS::Service[ServiceArn, Cluster]—Refis the service ARN (FIRST segment), so a #614-routed service would have leakedarn:...|<cluster>into every ARN consumer; joins the before-first-pipe Set (the SDK path stores the bare ARN — no-op). (2)AWS::S3Tables::Namespace/::Table—Refis the namespace / table NAME, but their SDK provider ITSELF stores the compound (<bucketArn>|<ns>/<bucketArn>|<ns>|<table>), so the after-pipe extraction is load-bearing on the SDK path for both types (these were broken on every deploy thatRefs them). On the CC path it is load-bearing for Namespace only (compound[TableBucketARN, Namespace]primaryIdentifier); Table's CC primaryIdentifier is the bare single-segmentTableARN(describe-type-verified), so a #614-routed Table stores a pipe-free ARN whoseRefstill returns the ARN instead of the documented table name — a KNOWN RESIDUAL pinned by a pass-through unit test and the Set's maintenance note (not fixable from the physical id alone: Table ARNs end in a UUID, so a fix needs the storedTableNameattribute). (3)AWS::WAFv2::WebACLis the INVERSE divergence: CFn'sRefIS the pipe-joinedname|id|scopecompound (docs-explicit) — matching the CC identifier — while the SDK provider stores the ARN; a new special case reconstructs the compound from the ARN via the provider's now-exportedparseWebACLArn(global-scope ARNs map to theCLOUDFRONTscope word). Excluded with documented rationale (synthetic / undocumentedRef, no consuming API): EC2::Route, EC2::VPCGatewayAttachment, Lambda::Permission, Lambda::EventInvokeConfig; ApiGateway::Method / ::DocumentationVersion were already excluded. 9 new unit tests (ECS compound + ARN pass-through; S3Tables 2- and 3-segment + CC-routed-Table bare-ARN pass-through pinning the residual; WAFv2 ARN→compound regional/global + CC pass-through + malformed-ARN pass-through — 5 fail without the fix). Broadmulti-resourceinteg re-ran clean for the resolver change.Fix:
Refon a Cloud-Control-routed ApiGatewayV2 Stage / Route / Integration / Model / Deployment / RouteResponse / IntegrationResponse / Authorizer / ApiMapping returns the CFnRefcomponent —src/deployment/intrinsic-function-resolver.ts(issue #963 follow-up family audit; the V2 sibling of the entry below). Background: the prior compound-id audit (the CognitoUserPoolUserentry, 2026-06-28) cleared the ApiGatewayV2 family under the "pure-CC types only" criterion — but #963 established the ROUTING-TRIGGERED variant: the V2 family has an SDK provider (pipe-free physical ids), yet any instance carrying a property the provider does not wire (e.g. a Stage withAccessLogSettings, an Integration withResponseParameters/TlsConfig) routes through Cloud Control via #614 and stores the compound primaryIdentifier — and every V2 compound type was missing from the extraction Sets, soRefleaked the compound id to consumers like a Route'sTarget: integrations/{Ref <Integration>}/AuthorizerId: {Ref: <Authorizer>}and an ApiMapping'sStage: {Ref: <Stage>}. Fix (all 9 types docs-verified +describe-type-verified):Stage/Route/Integration/Model/Deployment(2-segment[ApiId, <ref>]) andRouteResponse/IntegrationResponse(3-segment, Ref = trailing) join the after-pipe Set;Authorizer([AuthorizerId, ApiId]) andApiMapping([ApiMappingId, DomainName]) are REVERSED and join the before-first-pipe Set — which now exists (from the #963 fix), so the prior audit's "ApiMapping is a trap, adding it would create the inverse bug" carve-out is finally resolvable. The V1/V2 families are CROSS-WIRED (V1 Authorizer trailing / V2 Authorizer FIRST; V1 Deployment FIRST / V2 Deployment trailing) — a new WARNING block in the Set comment pins this so nobody pattern-matches one family from the other; the unit tests pin both directions.Api/VpcLink/DomainNamehave simple pipe-free primaryIdentifiers (no change). 13 new unit tests (each type CC-compound, per-branch SDK pass-through, and the daily Route-wiringFn::Join integrations/{Ref}+AuthorizerIdshapes — 10 fail without the fix). No new integ fixture: the extraction mechanism is byte-identical to the #963 fix, which was live-verified end-to-end (apigw-stage-throttling); the broadmulti-resourceinteg re-ran clean for the resolver change.Fix:
Refon a Cloud-Control-routed ApiGateway Stage / Resource / Authorizer / Deployment / DocumentationPart returns the CFnRefcomponent, not the compound physical id —src/deployment/intrinsic-function-resolver.ts(issue #963, found by/hunt-bugssweep 11). Bug: a REST API whose Stage carriesMethodSettings(synthesized by CDK's everydaydeployOptions.throttlingRateLimit/metricsEnabled/loggingLevel) routes the Stage through Cloud Control via the #614 silent-drop routing, which stores the compound<restApiId>|<stageName>primaryIdentifier as the physical id.AWS::ApiGateway::Stagewas missing fromREF_RETURNS_SEGMENT_AFTER_PIPE, so the CDK-generated Lambda PermissionSourceArn(anFn::Joinover{Ref: <Stage>}) resolved toarn:...:execute-api:...:<apiId>/<apiId>|test/GET/hello— API Gateway could not invoke the Lambda and the deployed API returned 500 on every request while the deploy reported success. Causation proven live: a manually corrected SourceArn immediately returned 200. Fix + family audit (per the Set's maintenance note):Stage/Resource/Authorizerjoin the after-pipe Set (docs-verified:Refreturns the trailing stage name / resource id / authorizer id);Deployment/DocumentationParthave REVERSED primaryIdentifier order ([<refId>, <restApiId>]— theRefcomponent comes FIRST), so a new sibling SetREF_RETURNS_SEGMENT_BEFORE_FIRST_PIPEextracts the segment before the first pipe (Deployment matters in every CDK template: the Stage'sDeploymentIdis{Ref: <Deployment>}).Method/DocumentationVersionare excluded — their AWS-docs pages document noRefreturn value. SDK-provisioned instances of all five types store pipe-free ids, so the extraction is a no-op there. 12 new unit tests (each type CC-compound + SDK-pass-through + the exact Lambda-PermissionFn::Joinshape — 6 fail without the fix). New real-AWSapigw-stage-throttlinginteg fixture pins the full chain: Stage really takes the CC route (provisionedBy == cc-apiguard), the Lambda resource policy SourceArn carries the bare stage name, GET /hello functionally serves, and the UPDATE phase adds a route (hash-suffixed replacement Deployment swaps in, old one deleted). Verified end-to-end against real AWS (0 orphans).covers: AWS::ApiGateway::Stage.Fix: replacing a custom-named resource now fails with an actionable error — or succeeds via
--replacedelete-first — instead of a rawAlreadyExists—src/deployment/deploy-engine.ts(issue #960 follow-up, surfaced by/hunt-bugssweep 10). Bug: a property-driven replacement uses CloudFormation's safe order (create the new resource BEFORE deleting the old), but a resource with a user-supplied physical name cannot reuse the occupied name — the create-first attempt always died with a confusingAlreadyExistsand no remediation. CloudFormation refuses the same shape with "cannot update a stack when a custom-named resource requires replacing" and offers only renaming. Fix: the create-first attempt catches an already-exists-shaped failure and (1) without--replace, throwsNAMED_REPLACEMENT_COLLISIONexplaining BOTH remediations — rename (CFn parity) or re-run withcdkd deploy --replacefor a delete-first recreation under the same name (a working one-command path CloudFormation lacks; brief unavailability); (2) WITH--replace, falls back to delete-first automatically (the property-driven stateful guard has already run at this point, so stateful types still require--force-stateful-recreation); (3) underUpdateReplacePolicy: Retain, always refuses with a Retain-specific message — the retained old resource pins the name, so renaming is the only option. Non-collision create failures pass through unchanged. 4 new unit tests (actionable error + old resource untouched; delete-between-the-two-creates ordering under --replace; Retain refusal; non-collision passthrough — 3 of 4 fail without the fix). Theeventbridge-pipesinteg gained two phases: aSourceswitch (length-1 createOnly) on the named pipe FAILS without--replacewith the actionable message AND leaves AWS unchanged, then succeeds WITH--replaceunder the same pipe name — verified end-to-end against real AWS (0 orphans).covers: AWS::Pipes::Pipe.
Recently Implemented (2026-07-02, second batch):
Fix: nested
createOnlyPropertiesno longer over-trigger replacement — the schema fallback now compares at path granularity —src/provisioning/create-only-properties.ts,src/analyzer/diff-calculator.ts(issue #960, found by/hunt-bugssweep 10). Bug: the CFn-schema createOnly fallback reduced nested registry paths to their top-level containing property, so for types whose createOnly entries are ONLY nested sub-paths the whole top-level property became immutable.AWS::Pipes::Pipe:SourceParametersitself is mutable and only stream-source sub-paths under it (.../KinesisStreamParameters/StartingPositionetc.) are createOnly — an SQS pipe changingSourceParameters.SqsQueueParameters.BatchSize(CFn: "Update requires: No interruption") was classified as a REPLACEMENT, and because the pipe carried a user-suppliedNamethe create-first replacement immediately died withAlreadyExists, hard-failing the deploy. Fix:getCreateOnlyPropertyPathsnow returns the FULL segment paths (RFC 6901 pointers after/properties/), and the new purecreateOnlyChangeRequiresReplacement(paths, key, oldValue, newValue, valuesEqual)decides per changed top-level property: a length-1 path replaces on any change (unchanged behavior); a NESTED path replaces ONLY when the value AT that path differs between old and new — absent containers on both sides resolve equal (the SQS-pipe case), while unresolvable shapes (array/scalar mid-path,*wildcard segments) stay conservative (replacement, the pre-fix behavior). The diff calculator injects its ownvaluesEqualso nested comparisons use the same equality as the top-level diff. 15 new/updated unit tests (path extraction incl. RFC 6901 unescape; the pure comparator's 8 branches; diff-level Pipes BatchSize-stays-UPDATE + StartingPosition-replaces — 17 of 21 fail without the fix). Theeventbridge-pipesinteg gained an UPDATE phase (BatchSize 1 -> 2) that asserts the deploy log contains NOReplacing Pipeline AND the new BatchSize reached AWS in place — verified end-to-end against real AWS (0 orphans).covers: AWS::Pipes::Pipe.Fix: schedules in a custom EventBridge Scheduler group are now manageable — new
AWS::Scheduler::ScheduleSDK provider —src/provisioning/providers/scheduler-schedule-provider.ts(new),src/provisioning/register-providers.ts(issue #961, found by/hunt-bugssweep 10). Bug: the type's registryprimaryIdentifieris/properties/Nameonly, but the AWS read/update/delete handlers resolve a bare Name against the DEFAULT schedule group — a schedule created with a customGroupNamewas unaddressable via Cloud Control by ANY identifier form (bare name → NotFound;grp|name→ ValidationException; ARN → name-pattern rejection; no additionalIdentifiers). Empirically: UPDATE failedNotFound, and DELETE landed FAILED/NotFound which the CC delete path swallowed as idempotent success — silently orphaning a LIVE schedule that keeps firing its target while cdkd state forgot it (a whole-stack destroy was masked by the group-delete cascade; a schedule-only removal orphaned for real). CloudFormation is unaffected (its handler invocations carry the full previous model incl. GroupName). Fix: a dedicated SDK provider (@aws-sdk/client-scheduler) threadsGroupNamefrom the resource properties through Create/Update/Delete/GetSchedule. physicalId stays the schedule NAME (CFnRefparity + zero-migration from CC-provisioned state).UpdateSchedulesends the full desired configuration (it is a full-replace API). AGroupNamechange throws the typedResourceUpdateNotSupportedError(GroupName ADDRESSES the schedule; the--replacefallback recreates it in the new group).readCurrentState(drift) +import(explicit id / templateName, no tag lookup — schedules are not taggable) included. CFnStartDate/EndDateISO strings are converted to SDKDates. 21 new unit tests (GroupName threading on all CRUD; typed GroupName-change rejection incl. custom→default; NotFound-idempotent delete with region check; drift read-back incl. default-group normalization; import paths). New real-AWSscheduler-custom-groupinteg fixture pins the two bug paths: in-place UPDATE of a custom-group schedule reaches AWS, and a schedule-only removal (group kept) actually deletes the schedule from AWS instead of silently orphaning it. The existing default-groupeventbridge-schedulerinteg re-run as the routing regression.covers: AWS::Scheduler::Schedule.Fix: changing a log group's
KmsKeyIdon redeploy is no longer silently dropped —src/provisioning/providers/logs-loggroup-provider.ts. Bug (issue #958 item 1, found by the #954 review):ReplacementRulesRegistryclassifiesKmsKeyIdas updateable forAWS::Logs::LogGroup, butupdate()had NO KmsKeyId branch — associating (or removing) a KMS key on an existing log group reported deploy success while AWS kept the group unencrypted (or encrypted), and state recorded the new value so the next diff saw no change and it could never self-heal. CloudFormation applies it in place ("Update requires: No interruption") viaAssociateKmsKey/DisassociateKmsKey. Fix:update()now associates on set/change and disassociates on removal, placed after the LogGroupClass guard and before the retention branch. 4 new unit tests (add / change / remove / unchanged-no-call with unrelated updates proceeding). New real-AWSloggroup-kms-associateinteg fixture proves BOTH directions reach AWS (phase 2 associate, phase 3 disassociate) — note the fixture must grant the CloudWatch Logs service principal in the KEY POLICY itself (the logs L2 does NOT wire this automatically; without itAssociateKmsKeyfails with "The specified KMS key does not exist or is not allowed to be used", matching the CFn docs' requirement). Destroy leaves the KMS key in its AWS-mandated 7-day PendingDeletion window (not an orphan). Verified end-to-end against real AWS (0 orphans).covers: AWS::Logs::LogGroup, AWS::KMS::Key.
Recently Implemented (2026-07-02):
Fix: standalone delete of a custom-bus EventBridge rule no longer no-ops against the default bus —
src/provisioning/providers/eventbridge-rule-provider.ts(issue #955, found by the PR #949 code review's family audit). Bug:delete()never passedEventBusNametoListTargetsByRule/RemoveTargets/DeleteRule— the Name-only form targets the DEFAULT bus, so a diff-driven deletion of just the rule (bus kept in the template) reportedResourceNotFound, logged "does not exist, skipping deletion", and SILENTLY ORPHANED the rule on AWS. Masked until now because whole-stack destroy also deletes the bus, whose provider sweeps its remaining rules. Live-reproduced with the released 0.230.21 binary: the rule-drop update deployed "successfully" whilelist-rules --event-bus-namestill showed the rule attached. A second defect inextractBusNameFromArnassumed exactlyrule/<bus>/<rule>(3 slash-parts) and returneddefaultfor partner-bus ARNs (rule/aws.partner/foo.com/123/<rule>, 5 parts) — wrong bus for drift readback too. Fix: the bus name is derived from the stored rule ARN as everything between the first and last slash (rule names cannot contain/; same last-slash rule ascfnRefValueFromPhysicalId), anddelete()+getAttribute()now threadEventBusNamethrough every call (omitted for default-bus rules, preserving the previous wire shape there). 4 new unit tests (custom-bus delete passesEventBusNameon all three calls / partner-bus name extraction / default-bus omission pin /getAttributecustom-bus addressing — 3 of 4 fail without the fix). Theeventbridgeinteg fixture gained a Phase 1.5 UPDATE that drops the rule while KEEPING the bus and assertslist-rules --event-bus-namereturns 0 rules (the exact orphan the bug produced) with the bus still present.covers: AWS::Events::Rule.Fix: a failed async Cloud Control CREATE no longer strands a name-holding remnant that kills every retry with
AlreadyExists—src/provisioning/cloud-control-provider.ts. Bug (found by/hunt-bugssweep 10, 2026-07-02): some CC create handlers materialize the resource FIRST and stabilize it afterwards —AWS::Synthetics::Canarycreates the canary entity, then builds its backing Lambda; cdkd's fast path creates the execution role only ~1s earlier, so the IAM-propagation race routinely fails stabilization (The role defined for the function cannot be assumed by Lambda.) and the canary lands in ERROR state still occupying its name. The FAILED progress event's message matched the transient-retry patterns (correctly), but the deploy engine's outerwithRetryre-issuedCreateResourceagainst the occupied name, so every retry died withAlreadyExistsinstead of recovering — and the ERROR remnant was ALSO invisible to rollback (the create never returned, so it was never in state), leaving an orphan CloudFormation would have deleted on rollback. Live repro pinned both attempts viacloudcontrol list-resource-requests: attempt 1 FAILEDGeneralServiceExceptionwithIdentifier: cdkd-bh-syn, attempt 2 FAILEDAlreadyExists. Fix:waitForOperation's FAILED branch now throws the newCloudControlOperationFailedError(aProvisioningErrorsubclass carrying the progress event'sErrorCode+ the operation kind), andcreate()runscleanupFailedCreateRemnantbefore rethrowing: when a CREATE failed with anIdentifierandErrorCode !== 'AlreadyExists', the materialized remnant is best-effort deleted (reusing the provider's own NotFound-idempotentdelete()), so the outer retry starts with a free name and a final failure leaves nothing behind. Safety: theAlreadyExistsguard is load-bearing — anAlreadyExistsFAILED event ALSO carries the identifier, but it names a resource that pre-dates the create (deleting it would destroy a user's pre-existing resource); and handlers may stuff a SPECULATIVE identifier into a FAILED event without materializing anything (observed onAWS::CodeDeploy::DeploymentGroup) — the NotFound-idempotent delete absorbs that as a no-op. UPDATE/DELETE failures never trigger cleanup (ccOperationgate). 7 new unit tests (remnant deleted + original retry-classifiable error rethrown; AlreadyExists NEVER deletes; no-Identifier no-op; NotFound-on-cleanup silent; cleanup-failure warns but rethrows; FAILED UPDATE untouched — 4 of 7 fail without the fix). New real-AWSsynthetics-canaryinteg fixture (deploy → assert canary READY → in-place schedule UPDATE → destroy → cwsyn-* backing-Lambda / log-group / bucket orphan sweep). Verified live: the race fired on the fixed binary's deploy, the remnant was cleaned, and the retry re-created the canary successfully ~14s later (READY, clean UPDATE + destroy, 0 orphans).covers: AWS::Synthetics::Canary.Fix:
RefonAWS::Events::Rule/AWS::CloudTrail::Trailreturns the resource name, not the ARN —src/deployment/intrinsic-function-resolver.ts,src/analyzer/orphan-rewriter.ts. Bug (found by/hunt-bugs, 2026-07-02): both SDK providers store the resource ARN as the physicalId (their delete / update paths need it), andresolveRefValuereturned the physicalId verbatim — but CloudFormation'sReffor these types returns the resource name (AWS::Events::Rule→ the rule name, or<busName>|<ruleName>for a custom-bus rule — thebus|nameform verified against real CloudFormation;AWS::CloudTrail::Trail→ the trail name, AWS docs explicit for both). Live repro: aCfnOutputofrule.ruleName(synthesizes to{Ref: <Rule>}) printed the full rule ARN. Any consumer composing the name into another string or callingevents:*/cloudtrail:*APIs by name got the ARN instead. Fix (three parts): (1) newREF_RETURNS_NAME_FROM_ARNmap in the resolver — the ARN-stored sibling ofREF_RETURNS_SEGMENT_AFTER_PIPE— extracts the CFnRefvalue from the stored ARN (custom-bus rule ARNsrule/<bus>/<name>map to CFn's<bus>|<name>physical id); (2) the constructed-attribute fallback for both types assumed a bare-name physicalId and would have produced a corrupted double ARN forFn::GetAtt Arn— it now returns an ARN-shaped physicalId verbatim; (3) the wholeRef-value derivation is extracted into the exported purecfnRefValueFromPhysicalId(resourceType, physicalId)and thecdkd orphanrewriter's{Ref: <orphan>}substitution now uses it too — previously the rewriter substituted the RAW physicalId, silently baking the ARN (or a Cognito-style<parent>|<child>compound id) into surviving siblings' state. Family audit (per the ARN-stored-physicalId class): every other provider storing an ARN physicalId (ACM::Certificate,ELBv2::*,IAM::ManagedPolicy,SNS::Topic/Subscription,SecretsManager::Secret,StepFunctions::StateMachine,Lambda::LayerVersion,Kinesis::StreamConsumer,ECS::Service/TaskDefinition,S3Tables,RDS::DBProxyTargetGroup) is correct — CFn'sReffor those types IS the ARN;ECS::Clusterstores the name (also correct). 7 new resolver unit tests (default-bus name, custom-busbus|name, trail name, double-ARN guards ×2 — all failing pre-fix) + 2 orphan-rewriter tests (Events::Rule + Cognito compound). Theeventbridgeinteg fixture now storesrule.ruleName(→{Ref}) in an SSM parameter and verify.sh asserts the value equals<busName>|<actual rule name>fromlist-rules. Verified live: pre-fix output printed the ARN, fixed binary prints the rule name; thebus|namecustom-bus form pinned by deploying a real CloudFormation stack and reading itsRefoutputs.covers: AWS::Events::Rule, AWS::CloudTrail::Trail.Fix: Step Functions same-stack assume-role IAM-propagation race is now retried —
src/deployment/retryable-errors.ts. Bug (found by/hunt-bugs): a canonical Express state machine withLoggingConfiguration(StateMachine + fresh Role + DefaultPolicy + LogGroup) failed to deploy 100% of the time: cdkd's fast SDK path issuesCreateStateMachine~1s after the role's CREATE, before IAM propagates the trust policy, and AWS rejects it with "Neither the global service principal states.amazonaws.com, nor the regional one is authorized to assume the provided role." — a phrasing no retryable-error pattern matched ('not authorized to perform' is a different sentence; 'is unable to assume provided role' is Glue's wording), so the whole deploy hard-failed and rolled back. CloudFormation tolerates the race via its deployment latency. Fix: add the'authorized to assume the provided role'pattern (same class as the Glue / Firehose / CodeDeploy / RDS ENHANCED_MONITORING phrasings), anchored on the SFN-specific tail so a genuinely broken trust policy only burns the bounded retries before surfacing. 1 new unit test pins the exact wire message as retryable. New real-AWSstepfunctions-logginginteg fixture — first integ coverage for SFNLoggingConfiguration(CREATE under the race, in-place log-level ALL -> ERROR UPDATE, functionalstart-sync-execution, destroy). Verified end-to-end against real AWS: the retry fired 3x (1s/2s/4s) then the create succeeded; 0 orphans.covers: AWS::StepFunctions::StateMachine.Fix: switching a DynamoDB table's
TableClasson redeploy is no longer silently dropped —src/provisioning/providers/dynamodb-table-provider.ts. Bug (found by/hunt-bugs): changing a table'stableClass(STANDARD <-> STANDARD_INFREQUENT_ACCESS, a common cost-optimization redeploy) reportedUpdated: 1success while AWS kept the OLD class —update()had no TableClass branch at all. Because the silently-dropped change was still written to cdkd state, the nextcdkd diffsaw no difference and the switch could never self-heal. CloudFormation applies it in place viaUpdateTable("Update requires: No interruption"). Fix: TableClass now rides the existing BillingMode/ProvisionedThroughputUpdateTablebranch, with the throughput/billing fields gated on their own change detection so a class-only change does not re-assert unchanged throughput (AWS rejects anUpdateTablewhose requested throughput equals the current value); a removed property reverts to the STANDARD default (CFn absent-property semantics). 4 new unit tests (class-only change sends TableClass without throughput; removal reverts to STANDARD; unchanged class issues no UpdateTable; combined class+billing switch rides one UpdateTable). New real-AWSdynamodb-tableclass-switchinteg fixture (fresh auto-named table per run; exactly one switch per run, inside AWS's two-per-30-days-per-table limit). Verified end-to-end against real AWS (the previously-dropped switch now reaches AWS; 0 orphans).covers: AWS::DynamoDB::Table.Fix: switching a Lambda function's architecture (x86_64 <-> arm64) on redeploy is no longer silently dropped —
src/provisioning/providers/lambda-function-provider.ts. Bug (found by/hunt-bugs): changing a function'sarchitecturewith unchanged code reportedUpdated: 1success while AWS kept the OLD architecture.Architecturesrides onUpdateFunctionCode(the Lambda API ties the instruction set to a code deployment), butupdate()only firedUpdateFunctionCodewhen theCodeproperty changed — and never passedArchitecturesat all. Because the silently-dropped change was still written to cdkd state, the nextcdkd diffsaw no difference and the switch could never self-heal. CloudFormation applies it in place ("Update requires: No interruption"). Fix:update()now firesUpdateFunctionCodewhen the architecture changed even if the code is byte-identical, passingArchitectureson an architecture change; a removed property reverts to thex86_64default (CFn absent-property semantics), and unchanged-architecture code updates omit the field so existing behavior is untouched. 3 new unit tests (arch-only switch fires UpdateFunctionCode with Architectures + the same code; code-only change omits Architectures; removal reverts to x86_64). New real-AWSlambda-arch-switchinteg fixture asserts BOTH the function config AND the actual runtime (process.archvia a live invoke) after the switch, and sweeps the invoke-created log group on cleanup. Verified end-to-end against real AWS (config reports arm64 AND a live invoke returnsarm64; 0 orphans).covers: AWS::Lambda::Function.
Recently Implemented (2026-06-29):
Fix: changing a log group's
LogGroupClasson redeploy now fails actionably instead of being silently dropped —src/provisioning/providers/logs-loggroup-provider.ts. Bug (found by/hunt-bugs): switching alogs.LogGroup'slogGroupClass(STANDARD <-> INFREQUENT_ACCESS, a common cost-optimization redeploy) reportedUpdated: 1success while AWS kept the OLD class. CloudFormation documents the property as "Update requires: Updates are not supported" — there is no CloudWatch Logs API to change a log group's class after creation, and a CFn stack update carrying the change FAILS — but cdkd'supdate()ignored the property entirely. Because the silently-dropped change was still written to cdkd state, the nextcdkd diffsaw no difference and it could never self-heal. Fix:update()now throws the typedResourceUpdateNotSupportedErrorBEFORE any other mutation when the class changed, with the remediation spelled out (--replacerecreates the group under the new class; the existing stateful guard additionally requires--force-stateful-recreationsince a log group retains data). An absent property is normalized to the STANDARD default, so an explicit-STANDARD <-> absent transition is NOT treated as a change. 5 new unit tests (class change throws before any mutation; absent==STANDARD normalization both directions; unrelated updates proceed when the class is unchanged; the message carries--replace). New real-AWSloggroup-class-guardinteg fixture: deploys STANDARD, re-deploys as INFREQUENT_ACCESS WITHOUT--replace(asserts the actionable failure AND that AWS is unchanged), re-deploys WITH--replace --force-stateful-recreation(asserts the group is recreated as INFREQUENT_ACCESS), then destroys clean. Verified end-to-end against real AWS (0 orphans).covers: AWS::Logs::LogGroup.Fix: immutable (createOnly) property changes now correctly drive a replacement, and stateful replacements are guarded against silent data loss —
src/analyzer/diff-calculator.ts,src/analyzer/replacement-rules.ts,src/provisioning/create-only-properties.ts(new),src/deployment/deploy-engine.ts,src/provisioning/providers/efs-provider.ts. Bug (found by the immutable-type follow-up probe): cdkd's diff classifier (ReplacementRulesRegistry) only knew the ~25 types with a hand-authored rule, and it consulted ONLY that registry — never the CFn registry schema'screateOnlyProperties. So an immutable-property change on any OTHER type (e.g.AWS::EFS::FileSystem.PerformanceMode) was mis-classified as an in-place UPDATE (cdkd diffshowed "1 to update"); the provider'supdate()then either rejected it with a typed error (best case) or silently dropped it. A SECOND, deeper issue surfaced in the same area: the property-driven replacement path had NO stateful guard — an immutable change on a stateful type (RDS / EFS / Secret / SSM Parameter / Kinesis / S3-with-data / etc.) was DELETE+CREATEd without confirmation, silently destroying the data (the--replaceand--recreate-via-*paths already required--force-stateful-recreation, but the property-driven path did not). Fix (three parts): (1) the diff now resolves each type'screateOnlyPropertiesfrom the CFn registry schema viacloudformation:DescribeType(newgetTopLevelCreateOnlyProperties, cached + graceful-degradation, mirroringwrite-only-properties.ts) and uses it as a replacement fallback for any property the registry does not explicitly classify (ReplacementRulesRegistry.isClassifiedgates the fallback so a deliberateupdateableProperties/ conditional classification is never overridden) — so a createOnly change on ANY type correctly drives a replacement andcdkd diffreports it honestly; (2) the property-driven replacement path in the deploy engine now applies the same stateful guard as--replace(isStatefulRecreateTargetForReplace+--force-stateful-recreation), throwingSTATEFUL_REPLACE_BLOCKEDrather than silently destroying a stateful resource's data; (3)EFSProvider.createderives itsCreationTokenfrom a content hash of the create properties (cdkd-<logicalId>-<hash>) instead of a barecdkd-<logicalId>— a property-driven replacement creates the new FS while the old one still holds the deterministic token, so the bare token collided ("already exists with creation token"); the content hash makes a replacement's new FS use a different token while a retry of the same create stays idempotent. Behavior change: an immutable-property change on a stateful type now requires--force-stateful-recreation(it previously auto-replaced for registered-rule types, losing data without confirmation; thereplacement-immutable-nameinteg's rename phase was updated to pass the flag for its Kinesis / Secret / SSM resources). 12 new unit tests (createOnly resolver caching / degradation; diff fallback incl. explicit-updateable-not-overridden; stateful-replace guard fires for S3 but not for a non-stateful Lambda; EFS content-hash token stable-vs-replacement). New real-AWSefs-immutable-replacementinteg: deploy EFSmaxIO→cdkd diffreports a replacement → deploy WITHOUT the flag is blocked → deploy WITH--force-stateful-recreationperforms the DELETE+CREATE (new FileSystemId) → destroy clean. Verified end-to-end against real AWS (0 orphans), plus the updatedreplacement-immutable-nameand the broadbench-cdk-sampleregression.covers: AWS::EFS::FileSystem.Fix: S3 replication combined
Andfilter (prefix + tags) no longer silently broadens to replicate-all —src/provisioning/providers/s3-bucket-provider.ts. Bug (found by the S3 replication probe in the 2026-06-29 follow-up sweep): CloudFormation / CDK express a combined prefix+tag replication filter ONLY via theFilter.Andoperator (Filter: { And: { Prefix, TagFilters[] } }— CDK's L2 emits this whenever a rule sets bothprefixandtags).applyReplicationConfigurationread only the top-levelFilter.Prefix/Filter.TagFilterand neverFilter.And, so a combined filter fell through to the empty-filter branch and producedFilter: {}— replicating EVERY object instead of the intended prefix+tag subset (a silent scope-broadening divergence; same class as the lifecycle V1/V2 bug). The symmetric readback (readReplication) had the mirror defect: it collapsed an AWSAnd { Prefix, Tags[] }to a non-canonical top-level{ Prefix, TagFilter }and dropped every tag past the first, which would surface as phantom drift against the template'sFilter.And.TagFilters. Fix: both sides now handle the canonicalAndshape — the write path translatesFilter.And { Prefix?, TagFilters[] }→ SDKAnd { Prefix?, Tags[] }, and the readback round-trips AWSAnd { Prefix, Tags[] }→ CFnAnd { Prefix, TagFilters[] }preserving all tags (the top-levelprefix/tagFilterbranches were also tightened from truthy to!== undefinedso an empty-string prefix round-trips instead of dropping to replicate-all). 4 new unit tests (write: combined And prefix+tags →And.Tagswith every tag; And tags-only; singleTagFilter→Tag; readback: AWSAnd→ CFnAnd.TagFilters). New real-AWSs3-replication-and-filterinteg fixture: deploys versioned source+dest buckets + a replication role with a rule usingFilter.And { Prefix: logs/, TagFilters: [replicate=yes] }, assertsGetBucketReplicationreturns the And filter verbatim (NOT replicate-all), re-deploys changing the And prefixlogs/→data/in place (no bucket replacement), then destroys clean. Verified end-to-end against real AWS (0 orphans).covers: AWS::S3::Bucket, AWS::IAM::Role.
Recently Implemented (2026-06-28):
Fix: Cognito
UserPoolUserno longer leaks its Cloud Control compound id throughRef—src/deployment/intrinsic-function-resolver.ts. Bug (found by the compound-id-Reffamily audit — the missed Cognito sibling of the resource-server / group / IdP / domain fix below):AWS::Cognito::UserPoolUserhas no SDK provider, so it routes through Cloud Control, whose primaryIdentifier is the compound<userPoolId>|<username>; cdkd stored that compound as the physicalId. CloudFormation'sReffor the type returns ONLY the trailing<username>(AWS docs are explicit), but the type was missing fromREF_RETURNS_SEGMENT_AFTER_PIPE. Because there is no L2 forUserPoolUserandCfnUserPoolUserexposes noAttr*getter, the natural CDK patternnew CfnUserPoolUserToGroupAttachment(this, 'x', { username: user.ref, ... })fed the resolver{Ref: User}, which leaked<userPoolId>|admin; the attachment'sAdminAddUserToGroupcall then failed withUser does not exist(real-AWS repro confirmed: 3 of 4 resources created,AttachCREATE failed, full rollback). Fix: addAWS::Cognito::UserPoolUser(→ username) toREF_RETURNS_SEGMENT_AFTER_PIPE. 1 new parameterized unit test (Refreturns the bareadmin, not<poolId>|admin). New real-AWScognito-userpool-user-refinteg fixture deploys a UserPool + Group + UserPoolUser + aCfnUserPoolUserToGroupAttachmentwhoseusernameconsumesuser.ref, asserts the user lands in the group (admin-list-groups-for-user— only possible if the Ref resolved to the bare username), then destroys clean. Verified end-to-end: repro FAILED on the pre-fix binary (User does not exist), PASSED on the fixed binary (0 orphans). Same compound-id-Refbug class as the::UserPoolResourceServerfamily +AWS::ApiGateway::Model/::RequestValidator. The audit also cleared ApiGatewayV2::ApiMapping / ApiGateway::DocumentationPart as TRAPS (child-id is FIRST, not last — adding them would create the inverse bug) and found no other LIKELY candidates across the ApiGateway / ApiGatewayV2 / AppSync / IoT / ECS / ServiceCatalog families.DX: actionable error when a DynamoDB
TimeToLiveSpecificationAttributeName changes between two enabled specs —src/provisioning/providers/dynamodb-table-provider.ts. Context (surfaced by/hunt-bugs, confirmed NON-bug — matches CloudFormation): changing a table's TTL attribute (e.g.ttlA→ttlB) in one deploy cannot work — AWS allows TTL on only one attribute and rejects enabling it on a new attribute while TTL is still active on the old one (TimeToLive is active on a different AttributeName), and DynamoDB rate-limitsUpdateTimeToLiveto one change per table per ~1 hour, so disable-then-re-enable in a single deploy is impossible too (CloudFormation hits the same wall and rolls back). Previously cdkd let the opaque raw AWS error bubble up. Change:update()now pre-emptively throws a clearProvisioningErrorBEFORE the doomedUpdateTimeToLivecall when both the old and new specs are enabled with a differentAttributeName, spelling out the two-deploy remediation (deploy once removing/disabling TTL on the old attribute, then after the ~1h disable settles deploy again enabling it on the new attribute). Only this exact case throws — enabling-from-absent, enabling-from-disabled (even on a new attribute), disabling, and same-attributeEnabledtoggles all pass through unchanged. Theupdate()catch now also passes aProvisioningErrorstraight through (mirroringcreate()) so the actionable message is not double-wrapped behind a generic "Failed to update" prefix (also de-double-wraps the pre-existing ResourcePolicy-ARN guard).applyTimeToLive+ the new guard share a singlereadTtlSpecnormalizer so theEnabled-absent-means-true default cannot drift between them. 5 new unit tests (attribute-name change throws + noUpdateTimeToLiveattempted; error not double-wrapped; enable-from-absent / enable-from-disabled / disable all pass through). New real-AWSdynamodb-ttl-attr-changeinteg fixture: deploys a PAY_PER_REQUEST table with TTL onttlA, re-deploys (CDKD_TEST_UPDATE=true) requesting TTL onttlB, asserts the deploy FAILS with the actionable message AND that AWS TTL is still onttlA(the guard fired before anyUpdateTimeToLivecall), then destroys clean. Verified end-to-end against real AWS (0 orphans).Fix: Cognito UserPool-child resources (resource server / group / IdP / domain) no longer leak their Cloud Control compound id through
Ref—src/deployment/intrinsic-function-resolver.ts. Bug (found by/hunt-bugs): a standardcognito.UserPool+ResourceServer+ aUserPoolClientrequesting the resource-server scope (OAuthScope.resourceServer(rs, scope)) failed at client CREATE withInvalid scope requested: us-east-1_xxx|api/read.AWS::Cognito::UserPoolResourceServerhas no SDK provider, so it routes through Cloud Control, whose primaryIdentifier is the compound<userPoolId>|<identifier>; cdkd stores that compound as the physicalId. CloudFormation'sReffor the type returns ONLY the trailing<identifier>segment (per the AWS docs), but the type was missing fromREF_RETURNS_SEGMENT_AFTER_PIPE, so cdkd's resolver returned the whole compound. The CDK synth shapeAllowedOAuthScopes: [{Fn::Join: ["", [{Ref: ResourceServer}, "/read"]]}]then produced<userPoolId>|api/readinstead ofapi/read, which Cognito rejects. Fix: add the whole Cognito UserPool-child family whose CC primaryIdentifier is<userPoolId>|<child>and whose CFnRefreturns the trailing<child>—AWS::Cognito::UserPoolResourceServer(→ identifier),::UserPoolGroup(→ group name),::UserPoolIdentityProvider(→ provider name),::UserPoolDomain(→ domain) — toREF_RETURNS_SEGMENT_AFTER_PIPE(joining the already-present::UserPoolClient). The two attachment types (::UserPoolRiskConfigurationAttachment/::UserPoolUICustomizationAttachment) are intentionally NOT added — theirRefreturns a synthetic<TypeName>-<UserPoolId>-<ClientId>string, not the after-pipe segment, and neither is referenced in practice. 5 new unit tests (a parameterized case per added type assertingRefreturns the bare trailing segment, plus the end-to-endFn::Joinscope case{Ref: ResourceServer}/read→api/read). New real-AWScognito-resource-serverinteg fixture deploys a UserPool + ResourceServer + a client whoseAllowedOAuthScopesreferences the resource-server scope, plus a UserPoolGroup and a UserPoolDomain, asserts the client'sAllowedOAuthScopesreaches AWS as exactly["api/read"](a compound there would mean the Ref leaked the CC id), then destroys clean. Verified end-to-end against real AWS (the previously-undeployable client now deploys;AllowedOAuthScopes == ["api/read"]; 0 orphans). Same compound-id-Refbug class as theAWS::ApiGateway::Model/::RequestValidatorfixes.
Recently Implemented (2026-06-22):
Follow-up: correct the Cognito immutable-Schema error message + two hardenings —
src/provisioning/providers/cognito-provider.ts. Follow-up to the add-custom-attribute fix below. (1) Accuracy: theResourceUpdateNotSupportedErrorthrown when an existing Schema attribute is removed / modified previously suggestedcdkd deploy --replacealone — butAWS::Cognito::UserPoolIS in cdkd'sSTATEFUL_TYPESguard, so a bare--replaceis refused withSTATEFUL_REPLACE_BLOCKED. The message now correctly sayscdkd deploy --replace --force-stateful-recreation. (2) Hardening (reviewer nit): a Schema entry with noNamewas silently skipped by the diff (neither added nor rejected); it now throws a clearProvisioningError(CDK synth always emitsName; this only fires on a malformed hand-written L1 template). (3) Hardening (reviewer nit): a comment onSTANDARD_USER_POOL_ATTRIBUTESdocuments that the OIDC-claim snapshot may lag AWS and that a future standard attribute would fail LOUDLY at AWS (never a silent drop). Theupdate()catch now also passes aProvisioningErrorstraight through (mirroringcreate()) so the malformed-Schema guard is not double-wrapped. 2 new unit tests (the rejection message names--force-stateful-recreation; a no-Name Schema entry throws).Fix: adding a custom attribute to a Cognito User Pool on redeploy is no longer silently dropped —
src/provisioning/providers/cognito-provider.ts. Bug (found by/hunt-bugs): adding a custom attribute (e.g.customAttributes: { region: new cognito.StringAttribute() }) to an existingcognito.UserPooland re-deploying reportedUpdated: 1success while AWS kept the OLD schema — the new attribute never appeared.UpdateUserPooldoes not acceptSchema, andcognito-provider.update()ignored the property entirely (itspreviousPropertiesarg was unused), so the change reached neither AWS nor any error. AWS does support adding a custom attribute in place via the separateAddCustomAttributesAPI (CloudFormation uses it; adding a Schema attribute is "Update requires: No interruption"). Because the silent-dropped change was still written to cdkd state, the nextcdkd diffsaw no difference and the add could never self-heal. Fix: afterUpdateUserPool,update()now diffs the new vs previousSchemaby attribute name and callsAddCustomAttributesfor every NEWLY-added custom attribute. Removing or modifying an existing attribute, or adding a standard (OIDC) attribute, is not an in-place operation — those are rejected withResourceUpdateNotSupportedError(pointing atcdkd deploy --replace --force-stateful-recreation, which recreates the pool and deletes all users —UserPoolis a stateful-recreate-guarded type) rather than silently dropped; the typed error is re-thrown past the provider's genericProvisioningErrorwrapper so the deploy engine's--replacefallback can catch it. A byte-identical Schema is a no-op (noAddCustomAttributescall, so a no-drift redeploy never errors with "attribute already exists"). Custom vs standard attributes are told apart by aSTANDARD_USER_POOL_ATTRIBUTESset (the OIDC claim names);readCurrentStatealready emitsSchema, socdkd driftwas already correct. 4 new unit tests (add a new custom attribute -> AddCustomAttributes with only the added one; unchanged Schema -> no AddCustomAttributes; removing an existing attribute -> ResourceUpdateNotSupportedError; modifying an existing attribute -> ResourceUpdateNotSupportedError). New real-AWScognito-custom-attribute-addinteg fixture deploys a pool withtenantId+level(asserts AWS reports exactly 2 custom attributes), re-deploys addingregion(asserts AWS now reports all 3 — the add reached AWS, not just cdkd state), then destroys clean. Verified end-to-end against real AWS (the previously-dropped attribute add now reaches AWS; 0 orphans).Fix: switching a Kinesis stream's capacity mode (PROVISIONED <-> ON_DEMAND) on redeploy is no longer silently dropped —
src/provisioning/providers/kinesis-provider.ts. Bug (found by/hunt-bugs): changing akinesis.Stream'sstreamMode(e.g. PROVISIONED -> ON_DEMAND, a common cost-optimization redeploy) and re-deploying reportedUpdated: 1success while AWS kept the OLD mode.kinesis-provider.update()had noUpdateStreamModecall — it only reconciledShardCount(and only when the NEW mode was PROVISIONED), so aStreamModeDetails.StreamModechange reached neither AWS nor any error. Worse, the change WAS written to cdkd state (properties.StreamModeDetails), so the nextcdkd diffsaw no difference (state == template) and the switch could never self-heal;cdkd driftwas also blind to it (itsobservedPropertiesbaseline captured the real AWS mode, which matched the AWS-current read). In CloudFormationStreamModeDetailsis "Update requires: No interruption", applied viaUpdateStreamMode. Fix:update()now switches the mode FIRST viaUpdateStreamMode(resolving the requiredStreamARNviaDescribeStreamSummary) whenoldMode !== newMode, then waits for ACTIVE. Ordering matters: PROVISIONED -> ON_DEMAND switches mode and skips the (invalid-on-on-demand)UpdateShardCountpath; ON_DEMAND -> PROVISIONED switches mode first, then reconciles the shard count against the LIVE open-shard count (DescribeStreamSummary.OpenShardCount) rather than the absent previous-properties value (AWS assigns its own shard count on the switch, so the previous-properties default of 1 would have skipped the reconcile and left the wrong count). 4 new unit tests (PROVISIONED -> ON_DEMAND calls UpdateStreamMode + skips UpdateShardCount; ON_DEMAND -> PROVISIONED calls UpdateStreamMode + reconciles shards from the live count; no UpdateStreamMode when the mode is unchanged; the existing shard-count update path unchanged). New real-AWSkinesis-stream-mode-switchinteg fixture deploys a PROVISIONED (1-shard) stream (asserts AWS reports PROVISIONED), re-deploys as ON_DEMAND (asserts AWS now reports ON_DEMAND — the switch reached AWS, not just cdkd state), then destroys clean (the assertion tolerates Kinesis's asyncDeleteStreamDELETING window). Verified end-to-end against real AWS (the previously-dropped mode switch now reaches AWS; 0 orphans).Fix:
cdkd driftno longer reports phantom drift on an IAM Role right after deploy (siblingDefault Policyrace) —src/deployment/deploy-engine.ts. Bug (deferred from/hunt-bugs): runningcdkd driftimmediately aftercdkd deploycould report a false positive on anAWS::IAM::Role—- Policies:[{...DefaultPolicy...}] / + Policies:[]— for essentially every Lambda / L2 construct whose grant emits aDefault Policy, one of the most common CDK patterns. CDK emits a construct's grants as a SEPARATEAWS::IAM::Policyresource attached to the role viaRoles: [role], which AWS implements viaiam:PutRolePolicy, so the inline policy appears inListRolePolicies. Thecdkd driftAWS-current read already filters these sibling-managed inline policies (issue #323'scollectInlinePolicyNamesManagedBySiblings, fed bybuildReadCurrentStateContextfrom full state). But the deploy-timeobservedPropertiescapture passed no sibling context, so the same filter no-op'd — and the role'sListRolePoliciescapture RACED the sibling policy'sPutRolePolicy. When the read landed after the write, the sibling-managedDefaultPolicy*leaked intoobservedProperties.Policies; a later drift (correctly filtering it from the AWS-current side) then surfaced the baseline-vs-current mismatch as phantom drift. Timing-dependent, so it surfaced intermittently (it bit while adding acdkd driftassertion to thelambda-event-invoke-config-updatefixture). Fix: the deploy-time capture for an IAM principal (AWS::IAM::Role/::User/::Group) now builds a sibling context from the template (buildObservedCaptureSiblings) — deploy-order-independent, immune to the race — and passes it throughkickOffObservedCaptureat both the post-CREATE and post-UPDATE sites. EachAWS::IAM::PolicywhoseRoles/Users/Groupsreferences the captured principal (via{Ref: <logicalId>}or the literal physical name) is synthesized into the resolved-property shapecollectInlinePolicyNamesManagedBySiblingsconsumes, so the SAME sibling-policy filter that runs at drift time now runs at capture time. A role's declared inlinePolicies(non-sibling) are kept; only sibling-managed names are excluded. Non-IAM-principal captures are unaffected (the helper returns no context, leaving the pre-fix behavior). 4 new deploy-engine unit tests (Role + siblingDefault Policy→ capture context carries the resolved sibling; Role with no sibling → no context;Usersattachment field for anAWS::IAM::User; non-principal S3 bucket → no context). New real-AWSiam-role-policies-drift-cleaninteg fixture deploys a Lambda-with-grant (service-roleDefault Policysibling) plus a standalone role carrying BOTH a declared inline policy AND anaddToPolicy()Default Policysibling, runscdkd drifttwice and asserts NO drift on anyAWS::IAM::Role, then destroys clean. Verified end-to-end against real AWS (no phantom drift on either role; destroy 6/0 errors, 0 orphans), plus the broadlambdainteg for the cross-cuttingdeploy-engine.tschange.Fix: EventBridge
ApiDestination(the daily webhook pattern) is no longer undeployable —AWS::Events::ConnectionArn GetAtt enrichment gap —src/provisioning/cloud-control-provider.ts. Bug (found by/hunt-bugs): a standardevents.Connection+events.ApiDestinationpair failed at ApiDestination CREATE withModel validation failed (#/ConnectionArn: failed validation constraint for keyword [pattern]).AWS::Events::Connectionis CC-API-provisioned (no SDK provider) and its primaryIdentifier isName, so the cdkd physicalId is the connection NAME, not the ARN. Its readOnlyArnattribute was NOT inenrichResourceAttributes, soAWS::Events::ApiDestination'sConnectionArn—Fn::GetAtt(Connection, 'Arn'), the canonical CDK shape — fell through the resolver'sconstructAttributeto the physicalId (the bare name) and AWS rejected the invalid ConnectionArn. The full connection ARN carries a random unique suffix (.../connection/<name>/<uuid>) so it cannot be string-constructed from account + region + name. Same systemic enrichment-gap bug class as #844 / #864 / #865 / #866. Fix: addAWS::Events::Connection(Arn/SecretArnviaDescribeConnection;ArnForPolicyderived by stripping the ARN's trailing unique segment) andAWS::Events::ApiDestination(ArnviaDescribeApiDestination;ArnForPolicyderived likewise) cases toenrichResourceAttributes, best-effort (a failed Describe leaves the CC attribute shape unchanged and never fails the deploy). 6 new unit tests (Connection Arn/SecretArn/ArnForPolicy overlay + the DescribeConnectionNameinput + already-present-attrs short-circuit + per-field-independence partial overlay + best-effort failure; ApiDestination Arn/ArnForPolicy overlay + best-effort failure). New real-AWSeventbridge-api-destinationinteg fixture deploys Connection + ApiDestination + a Rule whose target is the ApiDestination, asserts the resolvedConnectionArnAND the Rule's target Arn reaching AWS are real ARNs (not bare names), then destroys clean. Both types are pure-CC (no SDK provider, no cached CFn schema fixture per the established pattern that keeps fixtures 1:1 with registered SDK providers), so they joindocs/_generated/enrichment-coverage.json'senrichedWithoutCachedSchemalist alongside the other enriched pure-CC types (ElastiCache::ReplicationGroup / Redshift::Cluster / OpenSearchService::Domain). Verified end-to-end against real AWS (the previously-undeployable ApiDestination now deploys; 0 orphans).Fix: updating an async Lambda's EventInvokeConfig (maxEventAge / retryAttempts) no longer hard-fails — new
src/provisioning/providers/lambda-event-invoke-config-provider.ts+ registered insrc/provisioning/register-providers.ts+src/analyzer/replacement-rules.ts. Bug (found by/hunt-bugs): an async Lambda configured with anonFailuredestination plusmaxEventAge/retryAttemptssynthesizes anAWS::Lambda::EventInvokeConfig. That type had no SDK provider, so it routed through Cloud Control. Cloud Control's UPDATE applies a JSON-patch read-modify-write, and Lambda's EventInvokeConfig read handler returns an AWS-injected emptyDestinationConfig.OnSuccess: {}even when onlyOnFailurewas configured — so every UPDATE that changedmaxEventAgeorretryAttemptshard-failed model validation withModel validation failed (#/DestinationConfig/OnSuccess: required key [Destination] not found)and rolled back. CREATE worked (the template carried noOnSuccess), so the break only surfaced on the very common second deploy that tweaks an async Lambda's retry / age settings. Fix: a dedicated SDK provider whosecreate()andupdate()both callPutFunctionEventInvokeConfig— a synchronous full-replace write, exactly what CloudFormation uses for this type — sending only the configuredOnFailureand never an emptyOnSuccess, sidestepping the CC read-modify-write merge entirely. The physical id keeps the Cloud Control primaryIdentifier shape<FunctionName>|<Qualifier>so import / migration stay consistent;delete()parses it back toDeleteFunctionEventInvokeConfig(ResourceNotFound = idempotent success with region check);readCurrentState()surfaces the mutable props forcdkd drift, dropping the AWS-injected emptyOnSuccessand always emittingQualifier(CDK synthesizes$LATESTinto state, so omitting it would phantom-drift a base async Lambda);update()is a logical no-op when nothing changed (drift--revertround-trip).replacement-rules.tsadds a rule marking the two CREATE-ONLY props (FunctionName/Qualifier) as replacement-triggers while keepingMaximumEventAgeInSeconds/MaximumRetryAttempts/DestinationConfigin-place-updateable. 20 new unit tests (create incl. OnSuccess destination + numeric coercion / the full-replace update regression / no-op update / delete id-parse + region-match + region-mismatch idempotency / readCurrentState OnSuccess-drop + Qualifier-always-emitted / import + the replacement-rule classification). New real-AWSlambda-event-invoke-config-updateinteg fixture deploys (maxEventAge 2 min / retryAttempts 1 / onFailure DLQ), asserts the EventInvokeConfig is drift-clean, redeploys with (5 min / 2) and asserts the UPDATE — undeployable pre-fix — reaches AWS, then destroys clean. Note: like every backfilled SDK provider, thecc-api→sdkrouting is sticky per the existingprovisionedByrule, so an EventInvokeConfig already created by a pre-fix cdkd binary stays on the (broken) CC update path; a fresh create (or destroy + redeploy) picks up the SDK provider. Verified end-to-end against real AWS (fresh deploy → the previously-failing update now succeeds with MaxAge 300 / Retries 2 on AWS; 0 orphans).Fix: ECR
imageScanOnPush(and KMSKmsKey) silently dropped — CFn PascalCase not mapped to SDK camelCase —src/provisioning/providers/ecr-provider.ts. Bug (found by/hunt-bugs): an ECR repository created withimageScanOnPush: truedeployed "successfully" but AWS reportedscanOnPush: false. The ECR CFn properties are PascalCase (ImageScanningConfiguration: { ScanOnPush: true },EncryptionConfiguration: { EncryptionType, KmsKey }) but the AWS SDK input is camelCase ({ scanOnPush },{ encryptionType, kmsKey }).ECRProvider.create()forwarded the CFn-cased object toCreateRepositoryverbatim (castas ImageScanningConfiguration— compile-only, a runtime lie), so the SDK ignored the unknownScanOnPushkey and silently resetscanOnPushto false. The same casing trap silently dropped a KMS repo'sKmsKey(masked in most tests because AES256 is the SDK default). Theupdate()path'sPutImageScanningConfigurationhad the identical bug. Scan-on-push is a common security default, so cdkd silently shipped repos without image scanning. Fix: map CFn PascalCase → SDK camelCase viatoSdkScanningConfig({ScanOnPush}→{scanOnPush}) andtoSdkEncryptionConfig({EncryptionType, KmsKey}→{encryptionType, kmsKey}, kmsKey only onEncryptionType: KMS) in bothcreate()andupdate(). 4 new unit tests assert the create + update inputs carry the camelCase keys (and never forward the PascalCase key). New real-AWSecr-scanninginteg fixture deploys withimageScanOnPush: true(asserts AWS reportsscanOnPush=true), re-deploys with it false (asserts the update reaches AWS), then destroys clean. Verified end-to-end against real AWS (0 orphans). The existingecrfixture didn't catch it — it never set or assertedscanOnPush.Feature:
cdkd deploy --replace(implements the flag ~20 error messages + docs already referenced) + Fix: Glue SecurityConfiguration silently dropped its S3 encryption —src/cli/options.ts+src/cli/commands/deploy.ts+src/deployment/deploy-engine.ts+src/provisioning/providers/glue-provider.ts. Background: a follow-up to the LayerVersion fix below found thatcdkd deploy --replacewas referenced as the escape hatch by ~20 provider error messages (Glue / EFS / ECS / ApiGatewayV2 / Kinesis / EC2 / Firehose / DynamoDB GlobalTable immutable-update rejections),src/utils/error-handler.ts,src/cli/commands/drift.ts, anddocs/cli-reference.md— but the flag did not exist (cdkd deploy --replace→ "unknown option"), so users hitting any immutable-property change on a type without a built-in replacement rule were stuck. Feature:--replaceis now a real deploy flag. The deploy engine's normal-update catch block already falls back to DELETE + CREATE for the Cloud ControlUnsupportedActionException/ "does not support UPDATE" signals (unconditional, pre-existing);--replaceextends that fallback to the typedResourceUpdateNotSupportedErroran SDK provider throws when an immutable property changed and AWS exposes no in-place update API. Without the flag the typed rejection propagates (the deploy fails, as before); with it the resource is replaced. Stateful guard: the replacement is a data-losing DELETE + CREATE, so a stateful target (RDS / DynamoDB / EFS / S3-with-data / Logs-with-retention / etc. — the sameisStatefulRecreateTargetSyncguard list--recreate-via-cc-apiuses) requires--force-stateful-recreationto be ALSO set; the guard is checked at the moment the rejection is caught (mid-deploy) and the error names the resource + the data-loss reason. Non-stateful immutable types (LayerVersion / Glue SecurityConfiguration / ECS TaskDefinition / ApiGatewayV2 sub-resources) replace with--replacealone. Threaded asDeployEngineOptions.replace+forceStatefulRecreation. 4 new deploy-engine unit tests (non-stateful replace = update→delete→create order; no-flag = the rejection propagates; stateful blocked without the force flag; stateful replaced with it). The integ fixture surfaced a second, latent bug it then also fixes:tests/integration/glue-securityconfig-replace(the--replacereal-AWS probe — an immutableAWS::Glue::SecurityConfigurationEncryptionConfiguration change) deployed an EMPTY EncryptionConfiguration on Phase 1 becauseglue-provider.ts'sbuildEncryptionConfigurationread the CFn propertyS3Encryption(singular) while CDK / CloudFormation emitS3Encryptions(plural —CloudWatchEncryption/JobBookmarksEncryptionARE singular in both CFn and the SDK, but the CFnS3Encryptionslist maps to the SDKS3Encryptionfield). So S3 encryption was silently dropped on every Glue SecurityConfiguration create; the existing unit test hid it by feeding the same wrong singular key the provider read (the classic "unit test used the wrong shape, only a real synth emits the right one" trap). Fix: readS3Encryptions(plural) on create, emitS3Encryptionson thereadCurrentStatereadback, fix the misleading "CFn names match the SDK verbatim" comment, and correct the unit test to the real plural CFn shape (it now fails against the old code, passes against the fix). Theglue-securityconfig-replaceverify.shruns all four phases: deploy SSE-S3 (asserts the mode actually reached AWS — the Glue fix), re-deploy the immutable DISABLED change WITHOUT--replace(asserts the deploy FAILS and the live config is unchanged — the flag is load-bearing), re-deploy WITH--replace(asserts the live config is now DISABLED — replacement applied), destroy clean. Verified end-to-end against real AWS (0 orphans), plus the broadlambdainteg for the cross-cuttingdeploy-engine.ts/deploy.tschange. The ~20 pre-existingcdkd deploy --replacereferences are now all accurate;docs/cli-reference.mdgains a## --replace (deploy)section. —src/cli/options.ts+src/cli/commands/deploy.ts+src/deployment/deploy-engine.ts+src/provisioning/providers/glue-provider.ts. Background: a follow-up to the LayerVersion fix below found thatcdkd deploy --replacewas referenced as the escape hatch by ~20 provider error messages (Glue / EFS / ECS / ApiGatewayV2 / Kinesis / EC2 / Firehose / DynamoDB GlobalTable immutable-update rejections),src/utils/error-handler.ts,src/cli/commands/drift.ts, anddocs/cli-reference.md— but the flag did not exist (cdkd deploy --replace→ "unknown option"), so users hitting any immutable-property change on a type without a built-in replacement rule were stuck. Feature:--replaceis now a real deploy flag. The deploy engine's normal-update catch block already falls back to DELETE + CREATE for the Cloud ControlUnsupportedActionException/ "does not support UPDATE" signals (unconditional, pre-existing);--replaceextends that fallback to the typedResourceUpdateNotSupportedErroran SDK provider throws when an immutable property changed and AWS exposes no in-place update API. Without the flag the typed rejection propagates (the deploy fails, as before); with it the resource is replaced. Stateful guard: the replacement is a data-losing DELETE + CREATE, so a stateful target (RDS / DynamoDB / EFS / S3-with-data / Logs-with-retention / etc. — the sameisStatefulRecreateTargetSyncguard list--recreate-via-cc-apiuses) requires--force-stateful-recreationto be ALSO set; the guard is checked at the moment the rejection is caught (mid-deploy) and the error names the resource + the data-loss reason. Non-stateful immutable types (LayerVersion / Glue SecurityConfiguration / ECS TaskDefinition / ApiGatewayV2 sub-resources) replace with--replacealone. Threaded asDeployEngineOptions.replace+forceStatefulRecreation. 4 new deploy-engine unit tests (non-stateful replace = update→delete→create order; no-flag = the rejection propagates; stateful blocked without the force flag; stateful replaced with it). The integ fixture surfaced a second, latent bug it then also fixes:tests/integration/glue-securityconfig-replace(the--replacereal-AWS probe — an immutableAWS::Glue::SecurityConfigurationEncryptionConfiguration change) deployed an EMPTY EncryptionConfiguration on Phase 1 becauseglue-provider.ts'sbuildEncryptionConfigurationread the CFn propertyS3Encryption(singular) while CDK / CloudFormation emitS3Encryptions(plural —CloudWatchEncryption/JobBookmarksEncryptionARE singular in both CFn and the SDK, but the CFnS3Encryptionslist maps to the SDKS3Encryptionfield). So S3 encryption was silently dropped on every Glue SecurityConfiguration create; the existing unit test hid it by feeding the same wrong singular key the provider read (the classic "unit test used the wrong shape, only a real synth emits the right one" trap). Fix: readS3Encryptions(plural) on create, emitS3Encryptionson thereadCurrentStatereadback, fix the misleading "CFn names match the SDK verbatim" comment, and correct the unit test to the real plural CFn shape (it now fails against the old code, passes against the fix). Theglue-securityconfig-replaceverify.shruns all four phases: deploy SSE-S3 (asserts the mode actually reached AWS — the Glue fix), re-deploy the immutable DISABLED change WITHOUT--replace(asserts the deploy FAILS and the live config is unchanged — the flag is load-bearing), re-deploy WITH--replace(asserts the live config is now DISABLED — replacement applied), destroy clean. Verified end-to-end against real AWS (0 orphans), plus the broadlambdainteg for the cross-cuttingdeploy-engine.ts/deploy.tschange. The ~20 pre-existingcdkd deploy --replacereferences are now all accurate;docs/cli-reference.mdgains a## --replace (deploy)section.Fix: changing a Lambda LayerVersion's content is now a transparent replacement (was an undeployable error) —
src/analyzer/replacement-rules.ts+src/provisioning/providers/lambda-layer-provider.ts. Bug: editing a Lambda layer's content (e.g. bumping the code in alambda.LayerVersion) and redeploying the SAME logical id was misclassified as an in-place UPDATE.AWS::Lambda::LayerVersionis fully immutable on AWS — there is noUpdateLayerVersionAPI; every change requires a freshPublishLayerVersion— so the provider'supdate()hard-failed with an immutable-resource error that pointed users at acdkd deploy --replaceflag that does not exist, leaving a layer-content change completely undeployable. In CloudFormation everyAWS::Lambda::LayerVersionproperty is "Update requires: Replacement", socdk deploytransparently publishes a new version and re-points the consuming function — cdkd diverged from this on a daily-use path (any shared layer whose content changes between deploys). The misclassification stemmed fromReplacementRulesRegistryhaving no rule forAWS::Lambda::LayerVersion, sorequiresReplacementdefaulted to "updateable". Fix:replacement-rules.tsadds a replacement rule marking everyAWS::Lambda::LayerVersionproperty (Content/LayerName/Description/CompatibleRuntimes/CompatibleArchitectures/LicenseInfo) as a replacement-trigger, plus a sibling rule for the equally-immutableAWS::Lambda::Version. A content change now drives a DELETE + CREATE, and the existingpromoteReplacementDependents(issue #807) cascade re-points the consuming function at the new layer version ARN — matchingcdk deployexactly, with no manual flag. The provider'supdate()becomes a defensive fallback whose error message no longer references the non-existent--replaceflag. New unit tests cover the LayerVersion / Version replacement-rule classification and a regression guard that the provider message never reintroduces--replace. New real-AWSlambda-layer-version-updateinteg fixture deploys a layer (contentv1) + a consuming function, redeploys withv2content, and asserts a new layer version:2is published AND the function follows to:2(replacement propagated) before destroying clean. Verified end-to-end against real AWS (the content change auto-replaces; the function is re-pointed; 0 orphans). Note: the non-existentcdkd deploy --replaceflag this entry called out (referenced by ~20 provider messages +docs/cli-reference.mdas the escape hatch for other immutable-update /cdkd drift --revertcases) was implemented as a real flag in the follow-up entry above — those references are now all accurate.
Recently Implemented (2026-06-21):
Fix:
cdkd deploy <consumer>no longer drags a weakly-referenced (Fn::GetStackOutput) producer into the deploy —src/analyzer/cross-stack-deps.ts+src/cli/commands/deploy.ts. Bug (regression from #751): #751 added cross-stack ordering inference (inferCrossStackStackDeps) and wired it into BOTH of deploy.ts's--allsites — the auto-include-dependency CLOSURE walk (which EXPANDS the deploy set) and the inter-stack DAG ORDERING edges. But it treatedFn::ImportValue(a strong reference — the export must exist, so the producer is a genuine deploy dependency) andFn::GetStackOutput(cdkd's weak reference — it reads the producer's output opportunistically from cdkd state) identically. Socdkd deploy <consumer>auto-included and silently RE-DEPLOYED any producer the consumer merely read viaFn::GetStackOutput, violating the documented weak-reference contract (see Cross-Stack References: GetStackOutput producer must be "deletable independently of consumers"). The visible symptom: a consumer-only redeploy mutated the producer'sstate.json(e.g. transparently upgrading its schema version), and the producer's resources were needlessly re-driven. Caught by theschema-v7-to-v8-migrationinteg (FAIL: producer state.version drifted ... during consumer-only Phase 3 deploy) during the 2026-06-21 staleness sweep. Fix:inferCrossStackStackDeps(stacks, opts?)gains anopts.kindsfilter (CrossStackRefKind = 'ImportValue' | 'GetStackOutput', default both). deploy.ts now computes TWO maps: a STRONG-only map (['ImportValue']) drives the closure walk (effectiveStrongStackDeps→addDependencies), so a weak GetStackOutput edge never expands the deploy set; the full (strong + weak) map still drives the DAG ORDERING edges (effectiveStackDeps), so when a producer IS already in the deploy set (--all, or explicitly named) it is still correctly ordered before its GetStackOutput consumer. CDK manifestaddDependencydeps remain in the closure (always strong).destroy.tsis unaffected — its use ofinferCrossStackStackDepsonly REORDERS the already-selected destroy set (it never expands it), so keeping both kinds there is correct. New unit tests cover thekindsfilter (default = both;['ImportValue']= strong only excludes the GetStackOutput producer;['GetStackOutput']= weak only). Verified end-to-end against real AWS:schema-v7-to-v8-migrationnow PASSES (Phase 3 deploys ONLY the consumer; producer state stays at its prior version) and the broadmulti-stack-depsinteg confirms cross-stack deploy ORDERING is non-regressed (producer-first deploy, consumer-first destroy, 0 orphans).Feature:
cdkd destroy --purge-events(issue #885 follow-up) —src/cli/commands/destroy.ts. The #885 store-bounding work (auto-prune +cdkd events prune) left the issue's third proposed option unimplemented: a destroy-time flag to delete event history.cdkd destroy <stack> --purge-eventsadds it. By defaultcdkd destroykeeps thedeployments/event store as post-mortem context (so the bucket never returns fully empty after teardown);--purge-eventsopts into deleting that history immediately after a CLEAN, non-interrupted destroy of each target stack, so the bucket returns empty in one command. The gating is the exported, unit-testable helperpurgeEventsAfterDestroy(reader, stack, region, {purgeEvents, runResult, interrupted}, logger)— it purges (viaDeploymentEventsReader.pruneRuns(..., {all:true})) ONLY whenpurgeEvents===true && runResult==='SUCCEEDED' && !interrupted, and is deliberately skipped on a failed/interrupted destroy (those events are exactly the post-mortem the user wants on the retry). It is called AFTER the run'seventRecorder.finalize()so this run's own events are included in the purge (purging first would just be re-created by finalize). Best-effort: a purge failure warns but never fails the already-successful destroy (the resources are gone regardless).cdkd state destroydoes NOT take the flag —cdkd events prune <stack> --allis the CDK-app-free / already-destroyed equivalent. The flag is registered directly oncreateDestroyCommand(not the shareddestroyOptionsarray) so onlycdkd destroyadvertises it. 6 new unit tests cover the helper's gating matrix (clean purge / flag-off no-op / FAILED skip / interrupted skip / no-log-when-nothing-deleted / warn-not-throw on purge failure). Verified end-to-end against real AWS via a broad integ (deploy →destroy --purge-events→ assert state bucket fully empty incl.deployments/).Fix: adding a Global Secondary Index to a DynamoDB table is now an in-place UPDATE (was a failed replacement) —
src/analyzer/replacement-rules.ts+src/provisioning/providers/dynamodb-table-provider.ts. Bug: adding a GSI to an existingAWS::DynamoDB::TablegrowsAttributeDefinitions(the new index's key attribute), and the replacement rule listedAttributeDefinitionsas a blanket replacement-trigger — socdkd deploytried to REPLACE the table (DELETE + CREATE on the same name), which failed at CREATE withTable already existsand rolled back. This diverged from CloudFormation, where adding a GSI is an in-placeUpdateTable(update behavior "No interruption"). Adding/removing a GSI is one of the most common DynamoDB redeploy operations, so this hit a daily-use path. Fix (two layers): (1)replacement-rules.tsremovesAttributeDefinitionsfrom the DynamoDBreplacementPropertiesset and adds aconditionalReplacementspredicate (attributeTypeChangedForSharedAttribute) that requires replacement ONLY when an attribute present on BOTH sides changes itsAttributeType(the one case DynamoDB rejects in place); a key-attribute NAME change still surfaces as aKeySchemadiff, which remains a blanket replacement. (2)dynamodb-table-provider.ts'supdate()now actually applies GSI add / remove / per-index throughput changes viaUpdateTable'sGlobalSecondaryIndexUpdates(previously a silent gap — the change would have been recorded in state as applied while never reaching AWS): deletes/creates/updates are serialized one op perUpdateTable(an AWS constraint), a Create carries the full desiredAttributeDefinitions, and a newwaitForTableAndIndexesActivewaits for both the table AND every GSI to beACTIVEbetween ops (a fresh index keeps BACKFILLING after the table returns to ACTIVE). New unit tests cover the conditional replacement predicate (add-only / remove-only / shared-type-change / KeySchema) and the provider GSI add / remove / throughput-update / no-op paths. New real-AWSdynamodb-gsi-updateinteg fixture deploys apk-only table, redeploys adding a GSI, and asserts the table'sCreationDateTimeis unchanged (no replacement) + the GSI reachesACTIVEbefore destroying clean. Verified end-to-end against real AWS (the GSI is added in place; the table keeps its identity).Feature: bound + purge the deployment-events store (issue #885) —
src/state/deployment-events-store.ts+src/cli/commands/events.ts+src/state/s3-state-backend.ts. Bug: the structured deployment-events store (cdkd/{stack}/{region}/deployments/, issue #808) had no lifecycle management —index.jsonkept only the last 20 runs but the underlying{runId}.jsonlobjects were never deleted, so after 20 runs every older run's stream lingered in S3 forever, and there was nocdkdcommand to purge them (socdkd destroy, which deliberately keeps event history as post-mortem context, never returned the state bucket to empty — tripping the project's "state bucket empty after teardown" orphan-verification convention). Fix (two mechanisms): (1) Self-bounding at write time —DeploymentEventsStore.finalize()now calls a newpruneSupersededRunFiles(keptRunIds)after the index write, deleting{runId}.jsonlstreams that fell out of the 20-run (DEPLOYMENT_EVENTS_MAX_INDEX_RUNS) index window so the per-run files stay bounded to the same window as the index. It runs inside the same best-effort write-chain link (a failure warns once, never blocks the run) and is concurrency-tolerant: the cutoff is the OLDEST retained run id and only streams strictly below it are deleted — run ids are time-sortable, so a concurrent newer run's id sorts above every retained id and is never touched (an extreme earlier-started-still-writing edge self-heals on its next full-stream re-PUT). (2) Explicit purge — newcdkd events prune <stack>subcommand (createEventsPruneCommand/eventsPruneCommand) routed throughDeploymentEventsReader.pruneRuns:--allpurges every run + the index (mutually exclusive with the other retention flags),--keep <N>retains the newest N,--older-than <dur>deletes runs older than the cutoff (parsed viarunIdTimestampMsoff the run-id's compact-ISO prefix; an unparseable id is kept — the safe direction), both together delete only runs that are BOTH beyond newest-N AND older than the cutoff, and no flag defaults to keeping the newest 20. Prompts for confirmation unless-y/--yes(and refuses rather than hangs on a non-interactive terminal without--yes);--stack-regiondisambiguates a multi-region stack.pruneRunsdeletes the matching streams then rewritesindex.jsonto drop the pruned runs (or removes it entirely when none remain), soprune --allreturns the stack'sdeployments/prefix to empty. Both paths batch-delete through the newS3StateBackend.deleteRawObjects(keys)(chunked to the 1,000-keyDeleteObjectsceiling, idempotent, aggregating per-keyDeleteObjectserrors and throwing on the explicit-purge path). The default "events survive destroy" behavior is unchanged — this only adds a way to reclaim the space and bounds unbounded growth. Thecdkd events prunesubcommand reads its inherited options viacommand.optsWithGlobals()because the parenteventsalready declares the shared flags and a duplicate child declaration would mis-route post-subcommand flags to the parent. Unit tests cover the runId timestamp parser, the auto-prune (window edge + below-window no-op),pruneRuns(--all/--keep/--keep 0/--older-than/ keep+older conservative-AND / default-keep / no-match / index rewrite + delete-when-empty / delete-failure surfacing), and theevents prunecommand (purge / retention /--all+--keeprejection / non-TTY refusal / index-only removal). State-driven (no synth, no lock); not in any destroy / deploy integ-gate scope (the events store is a separatesrc/state/key family, never touches resource deletion). Verified end-to-end against real S3.
Recently Implemented (2026-06-15):
Backfill:
AWS::StepFunctions::StateMachineDefinitionS3Location(silent-drop → handled, #609) —src/provisioning/providers/stepfunctions-provider.ts.DefinitionS3Location({ Bucket, Key, Version? }) has NO field on the SDKCreateStateMachineinput — CloudFormation reads the S3 object (the Amazon States Language definition) and inlines its contents as the state-machineDefinition, so cdkd was silently dropping it (a state machine deployed with an S3-sourced definition got an empty{}definition). This is a feasible deferred backfill rather thanunhandledByDesign: cdkd already does S3-fetch-and-inline forAWS::AppSync::GraphQLSchema.DefinitionS3Location. Fix:DefinitionS3Locationmoves from the StateMachinesilentDropset intohandledProperties;buildDefinitionString()becomes async — when no inlineDefinitionString/Definitionis present, it fetches the S3 object (GetObject, honoring an optionalVersion→VersionId) and uses the body as the definition. It then appliesDefinitionSubstitutions(the${name}token replacement CloudFormation does) to the fetched body, because the intrinsic resolver cannot reach into S3 content — unlike the inline path, where CDK folds substitutions intoFn::Suband cdkd's resolver pre-resolves them before the provider sees them. InlineDefinitionString/Definitionstill take precedence overDefinitionS3Location(CloudFormation parity); an empty S3 body throws beforeCreateStateMachineruns (no half-created state machine). Substitution values are coerced fromstring/number/booleanonly. New unit tests cover S3 fetch /VersionIdpassthrough / substitution application / inline-precedence (no S3 fetch) / empty-body throw. New real-AWSstepfunctions-s3-definitioninteg fixture (L1CfnStateMachine+ ans3_assets.AssetASL file + a${Greeting}substitution) asserts (viadescribe-state-machine) that the definition was sourced from S3 AND the substitution was applied on a real deploy before destroying clean. Also fixes a latent asset-layer bug the integ surfaced (src/assets/asset-publisher.ts+src/assets/asset-manifest-loader.ts): BOTH file-asset-selection sites —AssetPublisher.addAssetsToGraph(deploy /publish-assets) ANDAssetManifestLoader.getFileAssets(cdkd local invoke-agentcore) — excluded EVERY.jsonfile asset (endsWith('.json')) to skip the CFn template, which wrongly dropped legitimate user.jsonfile assets — so the ASL document (asset.<hash>.json) was never published to the bootstrap bucket and the deploy failed withThe specified key does not exist. Both now exclude ONLY.template.json(top-level +.nested.template.json) via a new sharedisCfnTemplateAssetPath()predicate so the two sites cannot drift. The S3-fetch path also now throws on a zero-byte object (a presentBodywhosetransformToString()is'') beforeCreateStateMachine, rather than sending an empty definition. Unit tests cover the predicate, both selection sites (non-template.jsonpublished, top-level + nested templates skipped), and the empty / no-body throws.Cleanup: reclassify
AWS::CloudWatch::AlarmEvaluationCriteria/EvaluationIntervalasunhandledByDesign(#609) —src/provisioning/providers/cloudwatch-alarm-provider.ts. Both properties exist in the CloudFormationAWS::CloudWatch::Alarmregistry schema but are absent from BOTH the AWS SDKPutMetricAlarminput AND the aws-cdk-libCfnAlarmL1 (a newer CFn-schema surface ahead of SDK / CDK support), so per the #609 backfillability rule they are not backfillable. They previously carried the genericsilentDrop"not yet implemented by cdkd" placeholder (falsely implying pending work); a new providerunhandledByDesignmap now records the real rationale, which the property-coverage codegen surfaces inproperty-coverage.generated.ts. No behavior change (theunhandledByDesignmap is consumed only by the property-coverage check). Sibling reclassification candidates (CognitoWebAuthnFactorConfiguration, ECSInferenceAccelerators) were already classified in prior work.Backfill:
AWS::ServiceDiscovery::ServiceServiceAttributes(silent-drop → handled, #609) —src/provisioning/providers/servicediscovery-provider.ts.CreateServicedoes NOT acceptServiceAttributes(a key→value map); they ride on a separate post-createUpdateServiceAttributescontrol-plane call, so cdkd was silently dropping them. Fix:ServiceAttributesmoves from the ServicesilentDropset intohandledProperties;createService()issues anUpdateServiceAttributes(wrapped in the sharedwithRetrytransient helper) afterCreateServicesucceeds, and on failure issues a best-effortDeleteService+ rethrows (create atomicity — otherwise a half-configured service strands and the next CREATE hits a name collision).updateService()diffs old vs new attributes and submits the delta viaUpdateServiceAttributes(changed/added keys) andDeleteServiceAttributes(keys present only in the previous state), throwing on failure so state is not written as-if-applied; the attribute diff runs even when theServiceChangebody is empty.readCurrentStatefor the Service gains aGetServiceAttributesreadback (emits the full key→value map; best-effort on permission error). Map values are coerced fromstring/number/booleanonly (neverString()an object). New unit tests cover create-apply / create-atomicity / update-upsert / update-remove / no-op / update-throw and readback emit/empty. A new real-AWSservicediscoveryinteg fixture (bare VPC +PrivateDnsNamespace+ L1CfnServicewithserviceAttributes) asserts (viaget-service-attributes) the attributes reached AWS on a real deploy before destroying clean.Backfill:
AWS::ApiGatewayV2::AuthorizerAuthorizerCredentialsArn(silent-drop → handled, #609) —src/provisioning/providers/apigatewayv2-provider.ts.AuthorizerCredentialsArnis the REQUEST-authorizer-only IAM role ARN API Gateway assumes to invoke the REQUEST Lambda authorizer; it is a simple create-time scalar onCreateAuthorizer/UpdateAuthorizer/GetAuthorizer(no post-create control-plane call, no atomicity handling). Fix:AuthorizerCredentialsArnmoves from the AuthorizersilentDropset intohandledProperties;createAuthorizer()passes it through onCreateAuthorizerCommand(ungated — AWS rejects it on JWT authorizers and CDK only emits it for REQUEST, so passingundefinedwhen absent is a no-op);updateAuthorizer()adds it to the diffedUpdateAuthorizerCommandinput alongside the other REQUEST-only fields;readAuthorizer()emits it (gated!== undefined) inside the existingAuthorizerType === 'REQUEST'discriminator branch so a JWT authorizer never surfaces it. New unit tests cover create-send / update-diff / unchanged-no-op / readback emit-omit-and-JWT-guard. Theserverless-apiinteg fixture gains aniam.Role(assumed byapigateway.amazonaws.com) and setsauthorizerCredentialsArnon its standalone REQUEST authorizer; itsverify.shAssertion 5 now also asserts (viaget-authorizers) thatAuthorizerCredentialsArnis an IAM role ARN that reached AWS on a real deploy before destroying clean.Backfill:
AWS::ElasticLoadBalancingV2::ListenerListenerAttributes(silent-drop → handled, #609) —src/provisioning/providers/elbv2-provider.ts.CreateListenerdoes NOT acceptListenerAttributes(e.g.routing.http.response.server.enabled,tcp.idle_timeout.seconds) — they ride on a separate post-createModifyListenerAttributescontrol-plane call, so cdkd was silently dropping them. Fix:ListenerAttributesmoves from the ListenersilentDropset intohandledProperties;create()issues aModifyListenerAttributes(wrapped in the sharedwithRetrytransient/throttle helper) afterCreateListenersucceeds, and on failure issues a best-effortDeleteListener+ rethrows (create atomicity — otherwise a half-configured listener strands and the next CREATE hitsDuplicateListener).update()diffs old vs new attributes and submits only the delta (keys removed from the template are pushed back as the empty-string default to clear the override), throwing on a Modify failure so state is not written as-if-applied. The existing ListenerreadCurrentStategains aDescribeListenerAttributesreadback (sorted byKeyfor stable positional drift compare). Attribute Values are coerced fromstring/number/booleanonly (neverString()an object). New unit tests cover create-apply / update-diff / clear-on-remove / no-op / create-atomicity / string-passthrough / readback. Thealbinteg fixture setsrouting.http.response.server.enabled=falsevia the L1CfnListener.listenerAttributesescape hatch, and its newverify.shasserts (viadescribe-listener-attributes) the value reached AWS on a real deploy before destroying clean.Polish: call-site warn when a no-change-path Outputs resolution fails (follow-up to #875) —
src/deployment/deploy-engine.ts. The #875 no-change Outputs-persist path keeps the previously-persisted outputs (rather than overwriting with a partial map) whenresolveOutputscould not resolve every output.resolveOutputsalready logs a per-outputwarnon each unresolved value, but the no-change path now ALSO emits a one-line call-site summary when the resolved outputs DID differ yet a resolution failure suppressed the persist — so the "deploy reportsNo changes detectedwhile a newly-added export silently failed to land" path is explicit in the log instead of leaving a later downstreamFn::ImportValuefailure with no obvious link back. Also adds an explanatory comment on why resolving againsteffectiveTemplatevs the rawtemplateis equivalent in the no-change path (condition pruning only touchesResources; outputs resolve againstcurrentState.resources). No behavior change beyond the added log line; theresolutionFailed-guard branch it sits in is already covered by the #875 unit test.Fix: persist an Outputs-only change on a no-resource-diff deploy (closes the cross-stack
Fn::ImportValuebreak, issue #875) —src/deployment/deploy-engine.ts. Bug:DiffCalculator.calculateDiffcompares onlyResources, so when a downstream stack newly references an already-deployed producer, CDK synth adds a newOutput(withExport.Name) to the producer's template WITHOUT changing any of its resources — and the diff yieldshasChanges = false. The deploy engine's no-change early-return then never calledresolveOutputs()/exportIndexStore.updateForStack(), so the new export was never written to the producer's state or the exports index, and the consumer's subsequentFn::ImportValuefailed withexport '...' not found in any stack. (Deploying both stacks together for the FIRST time worked, because the producer's resources are allCREATEand the normal path persists outputs; the bug only fired when the producer was already deployed and the reference was added afterwards.) This is a behavior divergence from the CDK CLI / CloudFormation, where an Outputs-only change IS detected as a changeset diff and the stack is updated. Fix: the no-change path now (skipped under--dry-run) resolves the template outputs against current state and, when they differ fromstate.outputs, persists the new outputs (outputs-only state save) and callsexportIndexStore.updateForStack()— alongside the existing observed-properties refresh, in a single state save. A resolution that produces anyundefinedvalue (a resolver failure — should not happen in the no-change path since every resource is already in state) keeps the existing good outputs rather than overwriting them with a partial map;imports[]/outputReads[]are preserved as before. Output-map equality is a new module-private deep compare (outputMapsEqual). 5 unit tests (new export persisted + index updated; unchanged outputs → no save / no index touch; output removal persisted + dropped from index;--dry-rundoes nothing;imports[]/outputReads[]preserved). Newoutputs-only-exportinteg fixture reproduces the exact chain end-to-end against real AWS: deploy producer alone (no export) → consumer now exists so the producer redeploy is a no-op resource diff that still persists the export to state + index (bucket NOT recreated) → consumer deployed with--exclusively(producer NOT redeployed) resolves itsFn::ImportValueto the producer bucket ARN. New canonical scenariomulti-stack-outputs-only-export.Fix: stop unwrapping
AWS::CloudFront::Distribution.OriginGroupsin drift readback (closes the OriginGroups phantom drift, issue #873) —src/provisioning/providers/cloudfront-distribution-provider.ts. Bug:OriginGroupswas listed inQUANTITY_ITEM_FIELDS(the top-level fields that are a bare array in CFn and a{ Quantity, Items }wrapper in the SDK, so cdkd wraps them on write and unwraps them on read). But unlikeOrigins/CacheBehaviors/Aliases/CustomErrorResponses(genuinely bare lists in CFn), the CFnDistributionConfig.OriginGroupsproperty is ITSELF a{ Quantity, Items }object — confirmed by synthesizing a distribution with an origin group (Quantity+Items[], each item carrying inner{ Quantity, Items }MembersandFailoverCriteria.StatusCodes). SoconvertToCfnFormatwas unwrapping the top-level OriginGroups to a bare array, which no longer matched the template's{ Quantity, Items }, firing phantom drift on everycdkd driftfor any distribution with an origin group. (#871 had suppressed OriginGroups viagetDriftUnknownPathsas a stopgap, under the mistaken assumption — from that PR's review — that the inner Members/StatusCodes were bare arrays needing arevertOriginGroup; the synth evidence shows the whole subtree is{ Quantity, Items }, so the real fix is the opposite: don't touch it at all.) Fix: removeOriginGroupsfromQUANTITY_ITEM_FIELDSso it passes through UNTOUCHED in both directions (create still works —wrapWithQuantitywas a no-op on the already-{ Quantity, Items }value), and removeDistributionConfig.OriginGroupsfromgetDriftUnknownPathsso origin-group drift is now actually reported. 1 new unit test (OriginGroups{ Quantity, Items }with inner Members/StatusCodes passes through byte-equal, not unwrapped);getDriftUnknownPathstest updated. Validated against real AWS: thes3-cloudfrontfixture gains anOriginGroup(primary S3-OAI origin + HTTP fallback + failover status codes), andverify.shasserts the distribution carries exactly 1 OriginGroup AND that the whole distribution (OriginGroups now included) reports clean on a fresh deploy.Fix: canonicalize CloudFront OAI grant principals in
S3BucketPolicyProvider.readCurrentState(closes the phantom drift on an S3 BucketPolicy granting an OAI, issue #872) —src/provisioning/providers/s3-bucket-policy-provider.ts. Bug: a bucket policy statement that grants a CloudFront Origin Access Identity (OAI) read access stores its principal as the OAI's S3 canonical user id ({ CanonicalUser: <64-hex> }, what CDK'sFn::GetAtt [<OAI>, S3CanonicalUserId]resolves to). Buts3:GetBucketPolicyreturns that same principal in TWO other unstable forms over the policy's lifetime: a transient{ AWS: <IAM-unique-id> }(e.g.AIDA…) right afterPutBucketPolicy, then the settled{ AWS: arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity <id> }. The drift comparator saw these three equivalent representations as different and fired a guaranteed false positive on everycdkd driftrun for any stack with an OAI + S3 bucket policy (the deploy-timeobservedPropertiescapture caught the transient unique-id form; the later drift read got the ARN form — neither matched the template's canonical user id). Fix:readCurrentStatenow canonicalizes every recognizable OAI principal back to{ CanonicalUser: <id> }. For the settledarn:…:cloudfront:user/…<oaiId>form it maps the OAI id to itsS3CanonicalUserId, preferring the same-stack sibling OAI resource's already-read state attribute (zero AWS call / nocloudfront:GetCloudFrontOriginAccessIdentityIAM grant — the OAI'sS3CanonicalUserIdis a readOnly attribute cdkd already resolved at deploy time, threaded into the drift read via the newReadCurrentStateContext.siblings[].attributes+.physicalIdfields) and falling back toGetCloudFrontOriginAccessIdentity(<oaiId>)only when the OAI is NOT a same-stack sibling (an imported / external OAI). The transient bare-IAM-unique-id form (which carries no recoverable link to the OAI) is canonicalized only by matching the corresponding template statement (same Effect / Action / Resource) carrying a{ CanonicalUser }principal — safe because a user cannot author a bare IAM unique id as a bucket-policy principal, so it only ever fires for AWS's transient rendering. Both the deploy-time capture and the drift read run through the same normalization, so both converge to the canonical user id and compare equal; a genuinely-different OAI resolves to a different canonical id so real drift is still detected (strict reconcile — never a blanket suppress). Non-OAI principals (*, service principals, normal role ARNs) are untouched. TheReadCurrentStateContext.siblingsshape gained optionalphysicalId+attributes(populated bybuildReadCurrentStateContextinsrc/cli/commands/drift.ts) so any provider can reconcile a sibling's computed identity from already-read state without an extra AWS call. 13 unit tests (ARN→CanonicalUser via sibling state attribute with NO CloudFront call; strict-reconcile: a DIFFERENT OAI resolves to a DIFFERENT canonical id so real drift is NOT suppressed; AWS-array principal + ambiguous-template-match left unchanged; ARN→CanonicalUser viaGetCloudFrontOriginAccessIdentityfallback when not a sibling; bare-unique-id→template-match; no-template-match left unchanged; best-effort ARN-lookup-failure left unchanged; non-OAI principals untouched; one CloudFront call cached across statements). Validated against real AWS by extending thes3-cloudfrontinteg'sverify.shto assert theAWS::S3::BucketPolicyreports clean (in thecleandrift bucket, notdrifted) on a fresh deploy — the distribution-scoping caveat the CloudFront PR (#871) added is removed now that the bucket policy no longer phantom-drifts. (Approach mirrors the sibling cdk-real-drift project's fix per the #872 cross-note: read the OAI'sS3CanonicalUserIdfrom already-read state rather than re-fetching it.)
Recently Implemented (2026-06-14):
Fix:
AWS::DynamoDB::Tablein-placeBillingMode/ProvisionedThroughputUPDATE (was a silent drop) —src/provisioning/providers/dynamodb-table-provider.ts. Bug:update()handled OnDemandThroughput / WarmThroughput / PITR / TTL / ResourcePolicy / Kinesis / ContributorInsights / Tags but issued NOUpdateTableforBillingModeorProvisionedThroughput, even though both are declared inhandledPropertiesand both are mutable (CFn createOnly = onlyTableName+ImportSourceSpecification). So a pure capacity change (e.g. RCU 5→100, mode unchanged) or a pure billing-mode switch (PROVISIONED↔PAY_PER_REQUEST) was silently dropped —update()returned{ wasReplaced: false }with no AWS call while cdkd recorded the new value into state as if applied, so the next deploy saw no diff and the AWS-side capacity / mode stayed stale forever (the silent-drift failure mode documented infeedback_tags_on_update_must_throw). Fix: a new branch, ordered BEFORE the OnDemand/Warm throughput branches, fires a SINGLEUpdateTablewheneverBillingModeORProvisionedThroughputchanged. It forwardsBillingModewhen present andProvisionedThroughputonly when the (desired) mode is notPAY_PER_REQUEST(AWS rejects caps on on-demand; PROVISIONED requires them), coercing the string-typed capacity values to numbers viaNumber()(matchingcreate(); CFn emits numerics as strings). A combined switch-to-PROVISIONED-with-caps now works because both fields ride one call before the OnDemand branch (closing the pre-existing fail-loud caveat the old comment documented). After the call it waits for ACTIVE via the existingwaitForTableActiveAfterUpdatehelper so later branches don't race a still-UPDATING table. Per-indexGlobalSecondaryIndexesProvisionedThroughput is explicitly NOT handled (a documented deferred gap, not a silent one — needsGlobalSecondaryIndexUpdates). 6 unit tests (pure capacity change PROVISIONED→PROVISIONED; string-numeric coercion; switch to PAY_PER_REQUEST drops caps; PAY_PER_REQUEST switch ignores stale template caps; switch to PROVISIONED sends both in one call; no-change makes no billing UpdateTable). Integ: thedynamodb-ondemandfixture gains a standalone PROVISIONED table whose capacity flips RCU 5→20 / WCU 5→10 underCDKD_TEST_UPDATE=true, andverify.shadds a Phase-1.5 re-deploy +describe-tableassertion that AWS reflects the new ProvisionedThroughput (plus a destroy-cleanup poll for the new table).Feature: CC-API enrichment-coverage completeness matrix + CI critic (makes the enrichment-gap bug class non-regressing) —
scripts/gen-enrichment-coverage.ts+docs/_generated/enrichment-coverage.{json,md}+vp run gen:enrichment-coverage/vp run audit:enrichment-coverage:check+scripts/refresh-cfn-schemas.mjs. Motivation:CloudControlProvider.enrichResourceAttributes(the hand-maintained switch that overlays computedFn::GetAttattributes onto the flat-key shape cdkd's intrinsic resolver expects for CC-routed resources) had no mechanism preventing a new CC-routed type with a computedreadOnlyattribute from silently falling throughconstructAttributeto the physicalId — the exact bug class fixed one-off in #844 / #864 / #865 / #866. Deliverable: a codegen + CI critic modeled on the existinggen-property-coverage/gen-unsupported-typespattern (TS-Compiler-API parse of the source, drift-on-git diffin CI). The generator parses theenrichResourceAttributesswitch via the TypeScript Compiler API (extracting, percase, theenriched['Attr'] = ...keys — flat-keys likeEndpoint.Addressmatched against their nested readOnly propEndpoint), cross-references each type'sreadOnlyPropertiesfrom the cached CFn schema fixtures, and classifies every type into:enriched,no-computed-attr(Ref == physicalId is correct),sdk-fallback-gap(gap on an SDK-backed type — only exposed on the #614 silent-drop CC-fallback path, informational), orunenriched-computed(gap on a pure-CC type with no SDK provider — the real bug class). Theaudit:enrichment-coverage:checkcritic hard-fails ONLY onunenriched-computed. A readOnly prop that IS the type'sprimaryIdentifieris auto-classified not-a-gap (the resolver's physicalId fallback resolves it);refresh-cfn-schemas.mjsis extended to captureprimaryIdentifierinto the fixtures so this is data-driven going forward. SeedENRICHMENT_ALLOW_LISTcarves outAWS::MSK::Cluster(Arn == primaryIdentifier == physicalId, not-a-bug) +AWS::Elasticsearch::Domain(Tier-3 non-provisionable, rejected pre-flight). Current classification (offline, cached fixtures): 114 CC-routable types classified — 3 fully enriched, 13 no-computed-attr, 0 pure-CC latent gaps (every cached schema today is an SDK-backed Tier 1 type — the fixture refresh only fetches registered providers — so the critic passes by construction and becomes load-bearing the moment a pure-CC type's schema is cached, e.g. ElastiCache::ReplicationGroup / Redshift::Cluster / OpenSearchService::Domain). 98 SDK-fallback gaps are reported informationally (computed attrs only exposed on the #614 path; the SDK provider populates them on the primary path) for a future #614-hardening pass. 8 enrichment cases (ApiGateway::RestApi, CloudFront::OriginAccessControl, EC2::EIP, ElastiCache::ReplicationGroup, Lambda::Version, OpenSearchService::Domain, Redshift::Cluster, Route53::HealthCheck) lack a cached schema and are listed as needing a fixture refresh. 20 unit tests (tests/unit/scripts/gen-enrichment-coverage.test.ts) covering the switch parser (plain / try-catch / fall-through / empty-body / non-enrichedassignment), the classifier (every bucket, flat-key-to-nested matching, primaryIdentifier auto-allow-list, enrichment-precedence, partial coverage), andbuildReport(bucketing + summary + cache-less-enrichment-case reporting). No AWS integ (pure static analysis / codegen); only/check+/check-docsgates apply.Fix: Glue Job/Crawler/Trigger/Workflow update + delete hardening (4 bugs) + new
glue-update-hardeninginteg —src/provisioning/providers/glue-provider.ts+tests/integration/glue-update-hardening/. Four confirmed bugs in the Glue providers, all in one file:- Glue Job stringly-typed numeric coercion.
buildJobCommonFields(shared by create + update) copiedMaxRetries/AllocatedCapacity/Timeout/MaxCapacity/NumberOfWorkersverbatim, plus nestedExecutionProperty.MaxConcurrentRuns/NotificationProperty.NotifyDelayAfter. CFn can deliver these as STRINGS ("60") while the Glue SDK types them int/double;as numberis compile-only and does not coerce at runtime, so a string would reach the SDK for a number-typed field (thefeedback_cfn_stringly_typed_numerics_need_coercepattern). Fix: newcoerceNumber()helper wraps each numeric field at the wire boundary (already-numeric values pass through; non-finite / unparseable inputs are left unchanged so AWS surfaces a clear validation error rather thanNaN). - Glue Crawler
CrawlerRunningExceptionon delete/update.delete()/update()only caughtEntityNotFoundException; a mid-run crawler throwsCrawlerRunningExceptionon DeleteCrawler / UpdateCrawler → uncaught → destroy/update failed. Fix: onCrawlerRunningException, the newstopCrawlerAndWait()helper issuesStopCrawlerand pollsGetCrawleruntil the crawler leaves RUNNING/STOPPING (tolerates the already-stopping race), then the delete/update is retried. - Glue Trigger update state-machine + delete.
update()did StopTrigger → UpdateTrigger → StartTrigger but (a) did not wait for the trigger to leave ACTIVATED after the async StopTrigger before UpdateTrigger, and (b) skipped StartTrigger if UpdateTrigger threw — leaving a previously-ACTIVATED trigger stuck DEACTIVATED.delete()did not stop an ACTIVATED trigger first. Fix: newwaitForTriggerDeactivated()polls GetTrigger after StopTrigger; the StartTrigger re-activation always runs even when UpdateTrigger throws, but is now sequenced via capture-and-rethrow (NOT a barefinally) so that if BOTH the update and the re-activation fail, the root-cause UpdateTrigger error wins instead of being masked by a secondary "failed to re-activate" error (a re-activation failure on an otherwise-successful update still surfaces);delete()stops + waits for an ACTIVATED trigger before DeleteTrigger. - Glue Workflow Tags silently dropped for the MAP shape.
workflowTagsForCreate()only acceptedArray.isArray(tags)and returnedundefinedfor the tag-map shape CDK'sCfnWorkflow.tagssynths — silently dropping every tag. Fix: Workflow create now uses the map-tolerantcfnTagsToMap()(the helper every other Glue type already uses); the deadworkflowTagsForCreatehelper is removed. WorkflowMaxConcurrentRuns(create + update) is also now run throughcoerceNumber(). - Glue Crawler create raced IAM trust-policy propagation. The first real-AWS run of the integ surfaced a 5th bug NOT in
glue-provider.ts: cdkd's fast SDK path creates the Crawler's IAM role and the Crawler ~1s apart, before the role's trust policy propagates to Glue's assume layer, soCreateCrawlerwas rejected with "Service is unable to assume provided role. Please verify role's TrustPolicy." and the deploy failed at CREATE. CloudFormation never hits this (its deployment latency lets IAM settle) but cdkd does — the same just-created-role propagation race the RDS Enhanced Monitoring (#794) / ECS CapacityProvider (#805) / SNS+SQS resource-policy (#839) fixes handle for other services. Fix: add'is unable to assume provided role'toRETRYABLE_ERROR_MESSAGE_PATTERNSinsrc/deployment/retryable-errors.ts(anchored on the Glue-specific phrase — the existing lower-case'trust policy'pattern does NOT match Glue's"TrustPolicy"), so cdkd's outerwithRetry(1s/2s/4s/8s backoff) absorbs the propagation window the way CloudFormation's deployment latency does. 1 unit test added. Unit tests cover: string→number coercion on Job create + update + already-numeric pass-through + non-finite/unparseable left-unchanged (noNaN); Workflow MAP + list tag shapes + no-tags elision; Crawler stop-and-retry on CrawlerRunningException for both delete + update + the already-stopping-race StopCrawler-rejection tolerance; Trigger restore-on-UpdateTrigger-failure, wait-for-DEACTIVATED ordering, no stop/restart when already DEACTIVATED, stop-before-delete, no-stop-when-not-ACTIVATED; Glue assume-role propagation message is retryable AND a non-matching Glue error is not. Newglue-update-hardeninginteg fixture (L1CfnJobwith numeric props + idleCfnCrawler+ ON_DEMANDCfnTriggerrunning the Job +CfnWorkflowwith MAP-shape tags);verify.shasserts the Job numeric props reach AWS as JSON numbers (aws glue get-job), the Workflow MAP-shape tags reach AWS (aws glue get-tags), the crawler + trigger exist, and all four are gone after a clean destroy (0 orphans);CDKD_TEST_UPDATE=truere-deploys to exercise the update paths. (The string-INPUT coercion path is unit-tested rather than integ-tested because CDK's L1 validator rejects stringly-typed numerics at synth time.)
- Glue Job stringly-typed numeric coercion.
Feature:
CloudFrontDistributionProvider.readCurrentState+getDriftUnknownPaths(closes theAWS::CloudFront::Distributiondrift blind spot) —src/provisioning/providers/cloudfront-distribution-provider.ts. Gap: the provider was 1 of only 3 (the other two — custom-resource / nested-stack — are by-design N/A) lackingreadCurrentState, socdkd driftfell back to the generic CC-APIGetResourcepath. CloudFront's deeply-nestedDistributionConfigcarries{ Quantity, Items }wrappers + AWS-injected defaults whose shape diverges from the CFn shape cdkd state stores, so the generic strip pass surfaced phantom drift on a no-change distribution (and could miss real drift). CloudFront is SDK-provisioned, config-heavy, and frequently console-edited (cache behaviors, TTLs, origins, aliases, certs) — the highest-value drift gap. Fix:readCurrentStatecalls the read-onlyGetDistributionConfigCommandand inverts the provider's existingconvertToSdkFormatvia a newconvertToCfnFormat(unwraps every top-level + nested-cache-behavior + origin{ Quantity, Items }wrapper back to bare arrays, hoists the AWS-nestedCachedMethodsout ofAllowedMethodsto the CFn sibling shape, and drops theCallerReferenceidempotency token), then overlays AWS-current tags (GetDistributionARN →ListTagsForResource, filteringaws:cdk:*auto-tags; best-effort — a tag read failure still returns the config).getDriftUnknownPathsdeclaresDistributionConfig.CallerReference(cdkd-generated, never templated) +DistributionConfig.Logging.Bucket(AWS normalizes to a regional domain on read) +DistributionConfig.OriginGroups(the per-origin-group inner{ Quantity, Items }wrappers —Members/FailoverCriteria.StatusCodes— are not yet unwrapped byconvertToCfnFormat, andconvertToSdkFormatis symmetrically unaware of them, so OriginGroups is suppressed to avoid aproperties-fallback-baseline false positive until a dedicatedrevertOriginGroup+ a real OriginGroups integ fixture lands; PR #871 review) as drift-unknown. Because the deploy-time observed baseline is produced by the SAMEreadCurrentState, a fresh distribution compares byte-equal against itself → no false positive. 9 unit tests (non-CloudFront type returns undefined; NoSuchDistribution → undefined; full Quantity-unwrap + CachedMethods hoist + CallerReference drop + tag surface incl. aws:cdk filter; no Tags key when no user tags; config still returned when tag read fails; byte-stable across two reads; getDriftUnknownPaths). Integ:tests/integration/s3-cloudfront/verify.shextended to assert (viacdkd drift --json+ jq, scoped to theAWS::CloudFront::Distributionresource) that the distribution is in thecleanbucket and NOT indriftedon a freshly-deployed stack, then mutatesCommentout-of-band viaaws cloudfront update-distributionand asserts the distribution now appears indriftedwith aCommentchange, then reverts before the clean destroy. The assertion is distribution-scoped rather than whole-stack-clean because the fixture's S3 BucketPolicy granting the OAI carries a SEPARATE, pre-existing latent false-positive (cdkd stores the OAI principal as the S3 canonical user id at deploy time butGetBucketPolicyreturns it asarn:aws:iam::cloudfront:user/CloudFront Origin Access Identity <id>, so the two equivalent principal forms compare unequal) — an S3 BucketPolicy provider gap unrelated to this PR, tracked as a follow-up. Lossiness note:convertToCfnFormatis not perfectly lossless (SDK-required defaultsconvertToSdkFormatinjects —Comment: '',Logging.*,CustomOriginConfig.HTTP{,S}Port— are preserved on the inverted side), but this is correct for the comparator: the observed baseline carries the same defaults, and theproperties-fallback baseline only walks state keys, so the extra defaults can never fire false drift. NOTE:drift.ts/drift-calculator.tsare unchanged (not ininteg-destroyscope), but the provider file IS, so the merge still requires a real-AWS deploy+destroy (/run-integ s3-cloudfront).Fix:
AWS::S3Vectors::VectorBucketin-place Tags UPDATE (was a silent no-op) +S3VectorsProvider.update()immutable-property guard —src/provisioning/providers/s3-vectors-provider.ts. Bug:update()was a silent no-op (return { wasReplaced: false }with no AWS call), so aTagschange was dropped while cdkd recorded the new Tags into state as if applied — the next deploy then saw no diff and the AWS-side tags stayed stale forever (the silent-drift failure mode documented infeedback_tags_on_update_must_throw).Tagsis the ONLY in-place-updatable property of a VectorBucket (the CFn registry schema marksVectorBucketName+EncryptionConfigurationcreate-only, so a change to either drives a replacement, not an update). Fix:updateVectorBucket()diffs old vs new tags and applies the delta viaTagResource(added / changed keys) +UntagResource(removed keys), resolving the bucket ARN viaGetVectorBucketfirst (theupdate()contract only hands the physicalId). A tag-API failure THROWS (state is NOT written → next deploy retries) rather than being swallowed. A create-only property change that somehow reachesupdate()is surfaced asResourceUpdateNotSupportedError(cdkd deploy --replace) instead of silently leaving AWS unchanged. 5 unit tests (no-delta no-op; TagResource add/change; UntagResource remove; tag-API failure throws; immutable-property change rejects before any AWS call). Validated against real AWS (/run-integ s3-vectors— the fixture now re-deploys underCDKD_TEST_UPDATE=trueto changeenv, addowner, removeteam;verify.shasserts the AWS-side tags reflect all three viaListTagsForResource; deploy + update + destroy clean, 0 orphans).Fix: enrich
AWS::OpenSearchService::Domainendpoint/ARN attributes on the Cloud Control path + newopensearch-domain-getattinteg —src/provisioning/cloud-control-provider.ts+tests/integration/opensearch-domain-getatt/. Bug: OpenSearch Service Domain has no SDK provider, so it always routes through Cloud Control, and the CC-API GetResource model does not surface the search endpoint / ARN in the flat-key shape cdkd's intrinsic resolver expects.Fn::GetAtt(<Domain>, 'DomainEndpoint')(thehttps://search-...es.amazonaws.comURL clients connect to) andFn::GetAtt(<Domain>, 'Arn')/DomainArntherefore fell through the resolver'sconstructAttributeto the physicalId (the domain NAME, NOT the endpoint hostname / ARN) — so a connection string or IAM resource statement built from it pointed at garbage with a silent deploy success. Same systemic CC-enrichment GetAtt gap as the Redshift (#865) / ElastiCache RG (#864) / RDS DBInstance (#844) fixes. Fix: add anAWS::OpenSearchService::Domaincase toenrichResourceAttributesthat callsDescribeDomainand overlays the flat-keyDomainEndpoint(fromDomainStatus.Endpoint, falling back to thevpcentry ofDomainStatus.Endpointsfor a VPC domain) +Arn/DomainArn(fromDomainStatus.ARN) +Id(fromDomainStatus.DomainId). Best-effort: a failed Describe leaves the CC-API attribute shape unchanged and never fails the deploy. Adds@aws-sdk/client-opensearchas a dependency. 4 unit tests (happy-path public-domain DomainEndpoint/Arn/DomainArn/Id; VPC-domain Endpoints.vpc fallback; best-effort failure; no-Endpoint-yet tolerance). Validated against real AWS (/run-integ opensearch-domain-getatt— the smallest public single-nodet3.small.searchdomain (10 GiB gp3, no VPC);Fn::GetAtt(DomainEndpoint)/Arnstored into SSM Parameters resolved to the real*.es.amazonaws.comhostname /arn:aws:es:...:domain/...ARN, not the domain name; deploy + destroy clean, 0 orphans). New scenario tagcc-api-getatt-enrichment-opensearch-domain. Third fix of the CC-API enrichment-gap batch (remaining: MSKBootstrapBrokers). NOTE: the legacyAWS::Elasticsearch::Domainis a Tier-3 NON_PROVISIONABLE type (cdkd rejects it pre-flight), so it needs no enrichment — the modernAWS::OpenSearchService::Domainis the only provisionable variant.Fix: enrich
AWS::Redshift::Clusterendpoint attributes on the Cloud Control path + newredshift-cluster-getattinteg —src/provisioning/cloud-control-provider.ts+tests/integration/redshift-cluster-getatt/. Bug: Redshift Cluster has no SDK provider, so it always routes through Cloud Control, and the CC-API GetResource model does not reliably surface the cluster endpoint.Fn::GetAtt(<Cluster>, 'Endpoint.Address')/Endpoint.Port(the JDBC/ODBC connection coordinates) therefore fell through the resolver'sconstructAttributeto the physicalId (the cluster identifier, NOT the endpoint hostname). Same systemic CC-enrichment GetAtt gap as the ElastiCache RG (#864) / RDS DBInstance (#844) fixes. Fix: add anAWS::Redshift::Clustercase toenrichResourceAttributesthat callsDescribeClustersand overlays the flat-keyEndpoint.Address/Endpoint.Port(the SDKCluster.Endpointobject uses the SAME names as the CFn return values — no casing quirk, unlike ElastiCache). Best-effort: a failed Describe leaves the CC-API attribute shape unchanged and never fails the deploy. Adds@aws-sdk/client-redshiftas a dependency. 3 unit tests (happy-path Endpoint.Address/Port string-coercion; best-effort failure; no-Endpoint-yet tolerance). Validated against real AWS (/run-integ redshift-cluster-getatt— a single-nodera3.largecluster (the smallest orderable node type — the legacydc2.largeis no longer orderable) with AWS-managed master password;Fn::GetAtt(Endpoint.Address)stored into an SSM Parameter resolved to the real*.redshift.amazonaws.comhostname, not the cluster id; deploy + destroy clean, 0 orphans). New scenario tagcc-api-getatt-enrichment-redshift-cluster. Second fix of the CC-API enrichment-gap batch (remaining: OpenSearch / ElasticsearchDomainEndpoint, MSKBootstrapBrokers).Fix: enrich
AWS::ElastiCache::ReplicationGroupendpoint attributes on the Cloud Control path + newelasticache-replicationgroup-getattinteg —src/provisioning/cloud-control-provider.ts+tests/integration/elasticache-replicationgroup-getatt/. Bug: ElastiCache ReplicationGroup has no SDK provider, so it always routes through Cloud Control, and the CC-API GetResource model does not surface the connection endpoints in the flat-key shape cdkd's intrinsic resolver expects.Fn::GetAtt(<RG>, 'PrimaryEndPoint.Address')(and the Reader / Configuration variants) therefore fell through the resolver'sconstructAttributeto the physicalId (the replication-group id, NOT the Redis hostname) — so a SecurityGroup rule or client connection string built from the endpoint pointed at garbage with a silent deploy success. This is the same systemic CC-enrichment GetAtt gap as the RDS DBInstance (#844) / CompositeAlarm / EC2 PrivateIp fixes, surfaced by the 2026-06-14 enrichment-coverage audit. Fix: add anAWS::ElastiCache::ReplicationGroupcase toenrichResourceAttributesthat callsDescribeReplicationGroupsand overlays the flat-key endpoint attributes with the CFn casing (PrimaryEndPoint.Address/PrimaryEndPoint.Port/ReaderEndPoint.*/ConfigurationEndPoint.*for cluster-mode + the comma-delimitedReadEndPoint.Addresses/ReadEndPoint.Portslist across node groups) — note the SDK fields areEndpoint(lower p) while the CFn GetAtt names areEndPoint(capital P). Best-effort: a failed Describe leaves the CC-API attribute shape unchanged and never fails the deploy (matches the RDS DBCluster/DBInstance enrichment branches). 9 unit tests intests/unit/provisioning/cloud-control-provider.test.ts(cluster-mode disabled → Primary/Reader; cluster-mode enabled → Configuration + multi-shard ReadEndPoint list; best-effort failure). Validated against real AWS (/run-integ elasticache-replicationgroup-getatt— a cluster-mode-disabled Redis RG;Fn::GetAtt(PrimaryEndPoint.Address)stored into an SSM Parameter resolved to the real*.cache.amazonaws.comhostname, not the RG id; deploy + destroy clean, 0 orphans). New scenario tagcc-api-getatt-enrichment-elasticache-replicationgroup. First fix of the CC-API enrichment-gap batch (remaining latent gaps the audit found: OpenSearch / ElasticsearchDomainEndpoint, MSKBootstrapBrokers, RedshiftEndpoint).New
deletion-ordering-complexfailure-seeking integ stresses cdkd's ELBv2 destroy-ordering web (test-only — nosrc/change) —tests/integration/deletion-ordering-complex/**. Extends destroy-ordering coverage beyond the SG/IGW/NAT cases (vpc-nat-gateway, issue #817) into the richer ElasticLoadBalancingV2 dependency web, which has noimplicit-delete-depsedge today. TheCdkdDeletionOrderingComplexExamplestack is a minimal VPC (natGateways:0, 2 public subnets) with anApplicationLoadBalancer+TargetGroup(TargetType: IP) +Listener(:80 forward) + a non-defaultListenerRule(/app/*) + at3.nanoEC2 instance registered as the TG's IP target. It exercises five AWS-enforced delete-ordering constraints that are not trivially visible as forwardRef/DependsOn: (1) Listener / ListenerRule before TargetGroup (elseDeleteTargetGroup→ResourceInUse); (2) ListenerRule before Listener; (3) TargetGroup + Listener before the LoadBalancer (the ListenerRefs the LB so reverse-DAG handles it, but the TG does NOTRefthe LB — TG-vs-LB rides on the Listener-vs-TG edge alone); (4) the high-risk one — the LB's hyperplane ENIs must be released before the Subnet / SecurityGroup delete (elseDependencyViolation), butELBv2Provider.deleteLoadBalancerfiresDeleteLoadBalancerCommandand returns immediately (nowaitUntilLoadBalancersDeleted), so the reverse-DAG can race the async ENI teardown; (5) the EC2 target's ENI must release before the Subnet / SG delete.verify.shdeploys, asserts the ALB + TargetGroup + Listener + non-default ListenerRule exist, then runs the KEY test —cdkd destroy --forceMUST exit 0 (a wrong delete order surfaces as a non-zero exit, and the script prints the exact AWSDependencyViolation/ResourceInUseerror + full destroy log for triage) — and finally asserts 0 orphans (LB / TG / SG / subnets / IGW / VPC / EC2 instance all gone, located by the fixture's owncdkd:integ-fixture=deletion-ordering-complextag since AWS reserves theaws:prefix so cdkd cannot setaws:cdk:path; state-empty alone is not trusted per the #796 lesson). On any failure exit theEXITtrap tears the leftovers down in AWS-safe order (listener → TG → LB + wait → EC2 instance + wait → leftover ENIs → SG → subnets → IGW → VPC) so a failing run never orphans the cost-bearing ALB / EC2 / VPC. The script is BSD/macOS-portable (nogrep -P/date -d), captures real exit codes, and prints[verify] PASSonly on full success. New canonical scenario tagelbv2-listener-tg-lb-deletion-orderinscripts/build-scenario-coverage-matrix.ts; integ-coverage + scenario-coverage matrices regenerated (the fixture register-coversAWS::ElasticLoadBalancingV2::{LoadBalancer,TargetGroup,Listener}+AWS::EC2::{VPC,Subnet,SecurityGroup,Instance,InternetGateway}). Validated green against real AWS (/run-integ deletion-ordering-complex— deploy + destroy both exit 0, 0 orphans). The constraint-#4 race (LB hyperplane ENIs not yet released when the Subnet / SecurityGroup delete fires, sinceELBv2Provider.deleteLoadBalancerdoes notwaitUntilLoadBalancersDeleted) was predicted to fail but does NOT on current cdkd: the would-beDependencyViolationis absorbed by cdkd's existing transient-error retry ('DependencyViolation'/'has dependencies and cannot be deleted'are retryable patterns insrc/deployment/retryable-errors.ts), so the Subnet / SG delete simply retries on the 1s/2s/4s/8s backoff until the async ENI teardown completes — the destroy converges WITHOUT an explicitimplicit-delete-depsELBv2→Subnet edge (the retry is the safety net). The deploy side needs PR #851's EC2PrivateIpGetAtt fix to register the EC2 instance as the TargetGroup IP target, which is why this fixture could only run once #851 landed on main.New
multi-assetinteg stresses the asset-publishing layer when MANY assets of TWO kinds publish concurrently in one deploy —tests/integration/multi-asset/**. The existingdocker-image-assetands3-asset-deployfixtures each exercise ONE publisher in isolation (ECR build+push vs a single S3 zip upload), leaving the CONCURRENT multi-asset case —FileAssetPublisher+DockerAssetPublisherinterleaved, ECR + S3 in one run, and several distinct S3 uploads in flight together — without a dedicated regression. The newCdkdMultiAssetExamplestack (no VPC) publishes 1 ECR image + 4 S3 objects in a single deploy: alambda.DockerImageFunction(DockerImageCode.fromImageAsset(docker/, { platform: LINUX_ARM64 })+architecture: ARM_64, matched, to avoid the cross-archRuntime.InvalidEntrypoint: ProcessSpawnFailedtrap on Apple-Silicon hosts) goes throughDockerAssetPublisher; threelambda.Functions (AlphaHandler/BetaHandler/GammaHandler, Python 3.12) eachCode.fromAsset('<distinct multi-file dir>')produce three DISTINCTFileAssetPublisherS3 uploads (distinct content -> distinct content-addressed asset hash, confirmed at synth: 5 file assets + 1 docker image in the manifest); and a generics3_assets.Asset(asset-data/) is a 4th S3 upload whose resolveds3BucketName/s3ObjectKeyare threaded into the alpha Lambda viaCONFIG_BUCKET/CONFIG_KEYenv (asset-ref intrinsic resolution). The load-bearing correctness proof is that each Lambda returns its OWN distinct marker (...-docker/...-alpha/...-beta/...-gamma): a cross-wired asset (e.g. the beta ZIP uploaded but cdkd pointed the alpha Lambda'sCode.S3Bucket/S3Keyat it) would return the WRONG marker and FAIL — so the markers prove not just that all assets uploaded but that each Lambda was wired to the RIGHT one.verify.sh(BSD/macOS-portable, real-rc capture + explicit[verify] PASS) gracefully SKIPs (exit 0) whendocker infofails so it is robust on a Docker-less box but runs in a Docker env; it then deploys (printing which asset/resource failed for triage on a deploy error), asserts the Docker Lambda isPackageType=Imagewith OUR pushed image present in ECR by content-tag (parsed fromCode.ImageUri), asserts each zip Lambda'sCodeSize > 500bytes (ran from an uploaded ZIP, not inline), invokes all 4 Lambdas and asserts each distinct marker, asserts the alpha Lambda's generic-asset read-back (configBytes > 0), then destroys and asserts clean (all 4 Lambdas gone, OUR pushed ECR image by tag gone via a sweep fallback, state file gone). The shared bootstrap container-assets ECR repo + the bootstrap asset bucket OBJECTS persist by design (cdkd does not own CDK bootstrap infra) and are NOT treated as orphans; the EXIT trap sweeps leftover state, the deployment-events sidecar, and (by tag) the pushed image. New scenario tagmulti-assetin the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs/run-integ multi-asset(in a Docker env) before merge.)New
sg-circular-dependencyinteg surfaces create/destroy DAG-ordering bugs with a circular Security Group reference (test-only — nosrc/change) —tests/integration/sg-circular-dependency/**. Models the classic CloudFormation cycle the CFn-safe way: SG-A allows ingress from SG-B AND SG-B allows ingress from SG-A, where each rule is a STANDALONEAWS::EC2::SecurityGroupIngressresource (not inline) so the two SGs can exist before the cross-references are added. In CDK,sgA.addIngressRule(sgB, ...)+sgB.addIngressRule(sgA, ...)against two distinct SG constructs makes CDK emit standalone ingress resources (eachFn::GetAttsGroupIdon the SG it attaches to andSourceSecurityGroupIdon the OTHER SG), breaking what would otherwise be a genuine SG-to-SG cycle. The stack is anatGateways: 0single-AZ VPC + SG-A + SG-B + the two cross-referencing ingress resources (no EC2 instances).verify.sh(BSD/macOS-portable, captures real exit codes, prints an explicit=== PASS ===only on full success): Phase 0 runscdkd synthand asserts >= 2 standaloneAWS::EC2::SecurityGroupIngressresources each carrying aSourceSecurityGroupId(true SG-to-SG cross-ref) so the cycle-breaking shape is confirmed before any AWS call; Phase 1 deploys (the DAG builder insrc/analyzer/dag-builder.tsmust NOT raise a falseDependencyError— the standalone ingress resources break the would-be cycle) and asserts both SGs exist with the live cross-reference (SG-A'sIpPermissions[].UserIdGroupPairs[].GroupIdcontains SG-B and vice versa); Phase 2 (the key test) destroys and asserts 0 errors — if cdkd deletes an SG while its cross-referencing ingress rule is still live, AWS rejectsDeleteSecurityGroupwithDependencyViolation: resource sg-xxx has a dependent object, so a wrong delete order fails / orphans here. The post-destroy assertions confirm both SGs + the VPC + the state file are gone. Resources are located by thecdkd:integ-fixture=sg-circular-dependencytag (NOTaws:cdk:path, which AWS reserves), and the EXIT-trap cleanup revokes-then-deletes both SGs directly (the SAME ordering cdkd must perform) so a destroy-ordering bug never leaks billing resources. The existingAWS::EC2::SecurityGroup -> AWS::EC2::SecurityGroupIngressimplicit-delete-dep edge insrc/analyzer/implicit-delete-deps.tsis what this fixture exercises end-to-end on real AWS. New scenario tagsg-circular-dependencyin the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); integ-coverage + scenario-coverage matrices regenerated. One fix surfaced while authoring: AWS rejects non-ASCII characters (em-dash U+2014) in a SecurityGroupGroupDescription, so the twoGroupDescriptionstrings are ASCII-only (hyphen, not em-dash). Validated green against real AWS (deploy + destroy clean, 0 orphans).Retry SNS/SQS resource-policy create on fresh-role IAM propagation, plus a new
iam-propagation-stressinteg that is a race detector for IAM-propagation bugs on cdkd's fast SDK path (issue #839) —src/deployment/retryable-errors.ts+tests/integration/iam-propagation-stress/**. cdkd creates an IAM role and has a service assume it within ~1s, before IAM finishes propagating the role / its trust policy; CloudFormation never hits this (deployment latency lets IAM settle) but cdkd does, so every "role created -> assumed within ~1s" edge is a potential failure. The race is handled NARROWLY today for RDS Enhanced Monitoring (#794), ECS CapacityProvider (#805), and Custom Resource (#756) — but MANY other consumers are unprotected, and no integ deliberately stresses the breadth of fresh-role edges. The newCdkdIamPropagationStressExamplestack creates FOUR brand-new IAM roles, each consumed IMMEDIATELY by a DIFFERENT service in ONE deploy so the DAG carries many independent race edges at once: (1) a fresh Lambda exec role ->AWS::Lambda::Function(CreateFunctionvalidateslambda.amazonaws.comcan assume it); (2) a fresh SFN role ->AWS::StepFunctions::StateMachine(CreateStateMachinevalidates the role — the SFN provider has NO propagation retry of its own); (3) a fresh EventBridge target role ->AWS::Events::Rulewith an SFN target (PutTargetsvalidates the rule can assume the role toStartExecution); (4) a fresh principal role ->AWS::SQS::QueuePolicy+AWS::SNS::TopicPolicy(the resource-policy PUT validates the principal). Synth confirms each edge references its role viaFn::GetAtt <Role>.Arn. Everything is cheap (no VPC, no NAT, no long-lived compute); the EventBridge rule isenabled: falseon a 365-day schedule so it never fires (no cost) butPutTargetsstill validates the fresh role at create time. The pass condition is: deploy SUCCEEDS — a deploy failure is a real cdkd finding (an unprotected consumer racing IAM propagation), soverify.shprints WHICH resource failed plus the error (deploy-log tail +cdkd eventsper-resourceRESOURCE_FAILEDlines + the partial state'slogicalId -> typemap) for trivial triage, then still attempts destroy / cleanup. On success it also asserts each role consumer works (invoke the Lambda + assert its marker;start-execution-> polldescribe-executiontoSUCCEEDED, proving both the fresh SFN role AND the SFN->Lambda invoke grant;list-targets-by-ruleshows the SFN target bound to a role; the SQS queue + SNS topic each carry a non-empty resource policy), then destroys and asserts the Lambda / state machine / rule / queue / topic / state file are each GONE from AWS (perfeedback_protection_integ_must_instantiate_resource— state-empty alone can miss an orphan carrying no stack name).verify.shis BSD/macOS-portable (nogrep -P/date -d), recovers the real deploy exit code fromPIPESTATUSso atee'd non-zero is not masked, traps aggressively (state + lock + deployment-events sidecar), and prints[verify] PASSonly on full success. New scenario tagiam-fresh-role-immediate-assumein the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. The first real-AWS run of this fixture surfaced exactly the race it was built to find: edge (4) — the SQSQueuePolicy+ SNSTopicPolicyPUT against a brand-new principal role — failed because cdkd's fast SDK path issued the resource-policy PUT before IAM finished propagating the just-created role. CloudFormation never hits this (its deployment latency lets IAM settle), but cdkd does. The fix adds two narrowly-anchored retry patterns toRETRYABLE_ERROR_MESSAGE_PATTERNSinsrc/deployment/retryable-errors.ts:Policy Error: PrincipalNotFound(the SNSSetTopicAttributesrejection wording) andInvalid value for the parameter Policy(the SQSSetQueueAttributesrejection — the SQS QueuePolicy in the fixture is byte-for-byte the same fresh-principal document, so the less-specific SQS phrasing is the SAME propagation race, not a malformed document). Both are anchored on the full vendor phrase so a genuinely malformed / non-existent principal still burns only the bounded retries before surfacing — they do not false-positive unrelated SNS/SQS parameter errors. Twoverify.shassertion fixes landed alongside: the post-destroy StepFunctions check now polls forDeleteStateMachine's async deletion (the API returns before the state machine is gone), and the edge-2 SFN execution assertion retriesstart-execution->describe-executionacross the IAM-propagation window (the fresh SFN role can take a moment to be assumable for the SFN->Lambda invoke). Validated GREEN end-to-end against real AWS (/run-integ iam-propagation-stress): deploy clean across all four race edges, all four post-deploy consumer assertions OK, destroy reported 13 deleted / 0 errors, and the post-destroy orphan sweep was empty (0 orphans).New
rds-full-stackinteg stresses a realistic single-instance RDS deployment +Fn::GetAttof a computed DB endpoint, plus a realsrc/fix enrichingAWS::RDS::DBInstanceEndpoint attributes on the Cloud Control path —tests/integration/rds-full-stack/**. The two existing RDS fixtures both use L1 (CfnDBInstance/CfnDBCluster) and target #609 silent-drop closure + theprovisionedBy=sdkrouting guard; neither uses an explicit DBSubnetGroup + DBParameterGroup pair on an L2rds.DatabaseInstance, and neither consumes a DBInstance's COMPUTED endpoint via a downstream reference. This fixture (CdkdRdsFullStackExample, 15 synthesized resources) closes that gap: a VPC (natGateways: 0, isolated subnets) + explicitrds.SubnetGroup+ explicitrds.ParameterGroup(Postgres 17.4, non-defaultapplication_name = cdkd-rds-full-stack) + explicit SecurityGroup + a smallrds.DatabaseInstance(db.t3.micro, single-AZ, 20 GiB gp2, CDK-managed Secrets Manager credentials,deletionProtection: false,RemovalPolicy.DESTROY, no final snapshot) + anssm.StringParameter(/cdkd/rds-full-stack/db-endpoint) whose value isFn::GetAtt(<Database>, Endpoint.Address)(synth-confirmed). The angle being stressed is cdkd's event-driven DAG + intrinsic resolution under a slow-create resource: cdkd must create the sub-groups + SG before the instance (Ref edges), wait ~5-10 min for the instance to become available, read its computedEndpoint.Addressattribute back, and only THEN create the SSM Parameter with the resolved value.verify.sh(BSD/macOS-portable — nogrep -P/date -d, real-rc capture, explicit[verify] PASS) deploys (dumping state on a deploy failure for triage), asserts the instance uses OUR custom subnet group + parameter group (DescribeDBInstancesDBSubnetGroup.DBSubnetGroupName/DBParameterGroups[]) and the group carries the non-defaultapplication_name(DescribeDBParameters), asserts the SSM parameter value equals the LIVEDescribeDBInstancesendpoint address (proving the computedFn::GetAttresolved post-create — an empty value would mean cdkd read the attribute before the instance was available), then destroys and asserts the instance, subnet group, parameter group, SSM parameter, and state file are all gone with 0 orphans. The cleanup trap deletes in the RDS-safe order (instance first +aws rds wait db-instance-deleted, then subnet / param groups, then SG / VPC viastate destroy). Physical ids are resolved from cdkd state (the auto-named groups) + the explicit SSM name. This integ is SLOW by RDS nature (~10-20 min end-to-end) — acceptable + expected. New scenario tagrds-full-stackin the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. It also carries a real cdkdsrc/fix:src/provisioning/cloud-control-provider.tsenrichResourceAttributeshad noAWS::RDS::DBInstancecase, so a CC-API-routed DBInstance (routed via a #614 silent-drop property) never had its computedEndpoint.Address/Endpoint.Port/Endpoint.HostedZoneId/Arnattributes enriched after create — a downstreamFn::GetAtt(<db>, 'Endpoint.Address')resolved to the raw physicalId instead of the real endpoint. OnlyAWS::RDS::DBClusterwas enriched (per #381). The fix adds a parallel DBInstance case (DescribeDBInstances-> flat keysEndpoint.Address,Endpoint.Port,Endpoint.HostedZoneId,Arn; the DBInstanceEndpointis a nested{Address, Port, HostedZoneId}object, distinct from the DBCluster's flatEndpoint/ReaderEndpointscalars). Unit tests intests/unit/provisioning/cloud-control-provider.test.tscover the new case. Validated GREEN end-to-end against real AWS (/run-integ rds-full-stack): deploy clean, the computed-endpointFn::GetAttresolved to the live endpoint address, destroy reported 15 deleted / 0 errors, and the post-destroy orphan sweep was empty (0 orphans).Fn::Joinover a list-returning intrinsic +AWS::NotificationARNspseudo-parameter resolves to empty string inFn::Sub/Ref(issue #838), plus a newintrinsics-tortureinteg that stress-tests cdkd's hand-rolled intrinsic-function resolver against the less-common + deeply-nested intrinsics —src/deployment/intrinsic-function-resolver.ts+tests/integration/intrinsics-torture/**. cdkd resolves EVERY CloudFormation intrinsic itself insrc/deployment/intrinsic-function-resolver.ts(unlike the CDK CLI, which defers them to CloudFormation), so the less-common intrinsics + deep nesting are exactly where cdkd is most likely to diverge — and the existingintrinsic-functionsfixture only exercised Ref / Fn::GetAtt / Fn::Join / Fn::Sub. The new failure-seekingintrinsics-tortureinteg surfaced two real bugs that the fix in this PR closes: (a) issue #838 —Fn::Joinover a list-returning intrinsic (e.g.Fn::Join['', Fn::Cidr[...]]): the resolver mapped each element of theFn::Joinsecond argument expecting it to already be an array, but when the second argument is itself a list-returning intrinsic (Fn::Cidr/Fn::GetAZs/Fn::Split) it had not yet been resolved to an array, so the.map()ran over the unresolved intrinsic object. The fix resolves the second argument FIRST and only then maps over the resulting list. (b)AWS::NotificationARNspseudo-parameter now resolves to an empty string inFn::Sub/Refinstead of leaving the literal${AWS::NotificationARNs}placeholder in the output — cdkd carries no CloudFormation notification-ARN list, and CloudFormation itself resolves this pseudo-parameter to an empty list (joined to an empty string in aFn::Subcontext), so emitting an empty string matches CloudFormation's own behavior rather than leaking an unresolved placeholder downstream. The newCdkdIntrinsicsTortureExamplestack is cheap (an SNS topic + SQS queue + tenAWS::SSM::Parameters; no VPC / NAT / Lambda) and computes each SSM parameter'sValuevia a harder intrinsic, built with the raw CFn escape hatch (new ssm.CfnParameter+addPropertyOverride('Value', <intrinsic>)) so the synth template carries the EXACT intrinsic shape under test. Coverage that goes BEYONDintrinsic-functions:Fn::Cidr(['10.0.0.0/16', 8, 8]carved into eight /24 blocks — bothFn::Select[3]=10.0.3.0/24and the fullFn::Joined list asserted — the list-returning-Fn::Joincase that exercises fix (a)),Fn::FindInMap(aMappingssection with a{Ref: AWS::Region}top-level key + a region-independentDEFAULTrow),Fn::GetAZs+Fn::Select[0](first AZ, computed the same way cdkd sorts the list),Fn::Base64, nestedFn::Split+Fn::Select+Fn::Join(expecta|c|e), deeply-nested two-argFn::Sub(literal-map var via a nestedFn::Join+${AWS::Region}+ a${<Queue>.Arn}GetAtt), and ALL pseudo-parameters (AWS::AccountId/AWS::Region/AWS::Partition/AWS::StackName/AWS::URLSuffix/AWS::NotificationARNs).verify.sh(BSD/macOS-portable — nogrep -P/date -d, real deploy-rc capture, explicit[verify] PASS) deploys, reads each parameter back viaaws ssm get-parameter, and asserts it equals an expected value computed independently in the script from the account / region — so a wrong resolution produces a wrong parameter value that pinpoints which intrinsic cdkd got wrong; a failed deploy prints the failing resource + error for triage. It then destroys and asserts clean (state.json gone + zero orphan SSM parameters). Thepseudoassertion pins the new behavior thatAWS::NotificationARNsresolves to an empty string insideFn::Sub(fix (b)), so a regression that changed it would flip the assertion. Unit tests intests/unit/deployment/intrinsic-functions.test.tscover both fixes (Fn::JoinoverFn::Cidr/Fn::GetAZs/Fn::Split, andAWS::NotificationARNs-> empty string inFn::Sub+Ref). New scenario tagintrinsics-torturein the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated; testing-guide entry added to Testing. Validated GREEN end-to-end against real AWS (/run-integ intrinsics-torture) and thebench-cdk-samplebroad integ passed clean in the same worktree.An SQS Queue attribute that resolves away on an in-place UPDATE (e.g.
Fn::If→AWS::NoValue) is now CLEARED on AWS instead of leaving the stale value, plus the newconditions-update-2integ that surfaced it —src/provisioning/providers/sqs-queue-provider.ts+tests/integration/conditions-update-2/**. The siblingconditions-and-ifinteg surfaced bug #840 (a resource whoseCondition:flippedtrue → falseon redeploy was never deleted), and the fix (TemplateParser.filterResourcesByCondition) prunes condition-false resources from the effective template before the diff. But that narrow flip leaves the surrounding condition-on-UPDATE semantics — each a distinct way the prune step can still be wrong — without an end-to-end backstop. The newCdkdConditionsUpdate2Examplestack (cheap: 4xAWS::SSM::Parameter+ 2xAWS::SQS::Queue, no VPC / NAT) redeploys the SAME stack in place across a CDK-context phase flip (-c phase=a|b, the same param-flip mechanismconditions-and-ifuses since cdkd has no deploy-time--parameterflag) and asserts five cases against real AWS: (1) a resource that MOVES gating conditions —MoverParam(Condition: IsPhaseA, present in phase a) goes condition-false in phase b and must be DELETED (the #840 case re-asserted), plus its reverseAppearParam(Condition: IsPhaseB, ABSENT in phase a) is CREATED on the phase-b redeploy (absent → present); (2)Fn::If→AWS::NoValueremoving a NESTED property block on an in-place UPDATE — the case that surfaced THIS PR'ssrc/bug —WorkQueue.RedrivePolicy(a DLQ-target +maxReceiveCountJSON block) is SET in phase a and GONE in phase b on the SAME physical queue (QueueNameunchanged). cdkd's diff layer correctly classified the removed property as a change (itscomparePropertiesunions current + desired keys, so a key present in state but absent from the resolved desired template is detected), but the SQS provider'supdate()loop only acted on keys PRESENT in the new properties — so the staleRedrivePolicywas never cleared on AWS (the classic "providers only act on keys present in newProperties" gap fromfeedback_internal_contract_audit_first). The fix adds a removal branch toSQSQueueProvider.update(): a CDK-managed attribute present inpreviousPropertiesbut absent from the resolved desired properties is reset to its default viaSetQueueAttributes(a newSQS_ATTRIBUTE_REMOVAL_RESETmap — the JSON policy attributesRedrivePolicy/RedriveAllowPolicy+KmsMasterKeyIdclear to the empty string SQS documents for removal; numeric attributes reset to their documentedSetQueueAttributesdefaults), mirroring CloudFormation's reset-to-default-on-removal behavior. The branch is gated on the attribute being present inpreviousPropertiesAND in the reset map, so it never spuriously clears an attribute that was never set (e.g. a tag-only update issues noSetQueueAttributes), and immutable / FIFO-discriminated attributes (FifoQueue/DeduplicationScope/FifoThroughputLimit) are deliberately excluded. The fix is confined to the SQS provider — no shared / cross-provider code path changed, so other providers' update semantics are untouched. Unit tests intests/unit/provisioning/sqs-queue-provider-update.test.tscover the clear-on-removal (RedrivePolicy →""), the numeric-reset-on-removal (VisibilityTimeout →30), and the no-over-clear guard (attribute absent on both sides → noSetQueueAttributes). This exercises theprovider.update()drop-a-block path, not the create-time omissionconditions-and-if's SNSDisplayNamecovered; (3) a condition-gated OUTPUT — theMoverParamNameoutput (condition: IsPhaseA) is present in the cdkd stateoutputsmap in phase a and absent in phase b (asserted by reading the state file directly); (4) aDependsOnto a condition-EXCLUDED resource —KeeperParam(always present)DependsOn MoverParam, which is pruned in phase b, so cdkd must DROP the danglingDependsOn(like CloudFormation) and still deploy/updateKeeperParamin place (itsFn::Ifvalue flipskeeper-phase-a→keeper-phase-b); (5) aRefto a condition-excluded resource INSIDE a condition-false resource —RefHolderParam(Condition: IsPhaseA,ValueRefsMoverParamwhich is alsoIsPhaseA-gated) is pruned together withMoverParamin phase b, so the surviving template carries no danglingRefand the deploy must not crash.verify.sh(BSD/macOS-portable — nogrep -P/date -d, real exit codes, explicitAll N passedpass line) runs three phases (Phase A-c phase=apresence/SET/PRESENT, Phase B-c phase=bDELETE/CREATE/REMOVED/ABSENT, Phase C destroy + assert every named SSM parameter and SQS queue is NOT-FOUND on AWS and the state file is gone). New scenario tagconditions-update-semanticsinKNOWN_SCENARIOS(scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs/run-integ conditions-update-2before merge.)A resource whose
Condition:flipstrue → falseon a redeploy is now DELETED (issue #840) —src/analyzer/template-parser.ts(newfilterResourcesByCondition) +src/deployment/deploy-engine.ts. The newconditions-and-ifinteg surfaced a real cdkd gap: CloudFormation does not strip condition-gated resources at synth time — CDK emits a resource carrying aCondition:key intoResourcesregardless of the condition's value (verified by synthing the fixture both ways:PremiumOnlyParamis present inResourceswithCondition: IsPremiumin BOTH-c tier=premiumand-c tier=basic, differing only in theTierparameter default that flips the condition). cdkd evaluated theConditionssection forFn::Ifresolution but NEVER consulted the resource-levelCondition:key — so a condition-false resource was created on first deploy AND never deleted when its condition flipped (it stayed in the desired set and diffed asNO_CHANGE). The deploy engine now prunes every resource whoseCondition:resolved tofalse(viaTemplateParser.filterResourcesByCondition) immediately after evaluating theConditionssection, so the whole downstream pipeline (type/property validation, DAG build, diff, provisioning) operates on the CFn-effective resource set: a condition-false resource is never created, and one present in prior state but condition-excluded from the effective template falls through the diff's existing "present in state, absent from desired → DELETE" path exactly as CloudFormation removes it. A resource whoseCondition:names an unevaluated/unknown condition is kept (treated as present, not silently dropped). Tests: 5 newfilterResourcesByConditionunit tests (tests/unit/analyzer/template-parser.test.ts— false→removed / true→kept / no-Condition→kept / unknown-condition→kept / preserves Conditions+Outputs and does not mutate input) + 1 new diff-calculator test (tests/unit/analyzer/diff-calculator.test.ts— resource in state but absent from the pruned template →DELETE). Closes the FAIL theconditions-and-ifinteg's Phase 2 asserts (PremiumOnlyParamABSENT after the basic redeploy). (NOTE: needs/run-integ conditions-and-ifagainst real AWS before merge.)New
conditions-and-ifinteg stresses cdkd's own CloudFormation Conditions +Fn::Ifevaluation (test-only — nosrc/change) —tests/integration/conditions-and-if/**. cdkd evaluates theConditionssection + the resource-levelCondition:key +Fn::If/Fn::Equals/Fn::And/Fn::Or/Fn::NotITSELF (no CloudFormation engine underneath), but the existingconditionsfixture has noverify.shand only exercised a singleFn::And+ one conditionally-created S3 bucket + anFn::Ifbucket name — leaving condition-gated resource CREATION,Fn::If->AWS::NoValueproperty OMISSION, andFn::Or/Fn::Notwithout an end-to-end real-AWS backstop. The newCdkdConditionsIfExamplestack (cheap: 3xAWS::SSM::Parameter+ 1xAWS::SNS::Topic, no VPC / NAT) closes the gap. ItsConditionssection combinesFn::Equalson aCfnParameterwithFn::And(IsPremiumPrimary),Fn::Or(IsPremiumOrSecondary), andFn::Not(IsSecondaryRegion). It carries TWO resources with aCondition:key (PremiumOnlyParamon the bareFn::Equalscondition,PremiumPrimaryParamon theFn::Andcondition), an always-created parameter whoseValueis anFn::Ifbranch (TierLabelParam), and an SNS topic whoseDisplayNameisFn::If(IsPremium, 'Premium Notifications', AWS::NoValue)plus two tag values driven byFn::If. TheTierCfnParameterdefault is read from CDK context (-c tier=premium|basic) at synth — cdkd has no deploy-time--parameterflag, so flipping the context is the param-flip mechanism.verify.sh(BSD/macOS-portable — nogrep -P/date -d, real exit codes, explicit pass line) runs three phases against real AWS: Phase 1 (-c tier=premium) — asserts theFn::Ifproperty branch reached AWS (TierLabelParamValue ==tier-is-premium), both condition-gated parameters are PRESENT (aws ssm get-parameter), the SNSDisplayNameis SET toPremium Notifications, and theFn::Iftag values /Fn::Ortag value reached AWS; Phase 2 (-c tier=basic, redeploy in place) — asserts theFn::Ifbranch FLIPPED on AWS (tier-is-basic), both condition-gated parameters are now ABSENT (resource removed because itsCondition:went false), the SNSDisplayNameis genuinely OMITTED on AWS (Fn::If->AWS::NoValue), and the tag values flipped; Phase 3 — destroy + assert every named resource (each SSM parameter, the SNS topic) is NOT-FOUND on AWS and the state file is gone. 14 assertions total. New scenario tagconditions-and-ifinKNOWN_SCENARIOS(scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs/run-integ conditions-and-ifbefore merge.)cdkd diff --recursiveno longer reports spurious changes on a freshly-deployed nested-stack tree, plus a newnested-stack-3levelinteg that surfaced it —src/cli/commands/diff-recursive.ts+tests/integration/nested-stack-3level/**. The new deep+bidirectional fixture surfaced a real recursive-diff bug: the walker built each nested child's intrinsic-resolver context from the child's deployedresourcesbut omitted the resolved inputParametersthat the deploy engine forwards to the child (NestedStackProvider.extractParameters->DeployEngineOptions.parameters). So a child property whose value derives from a DOWN-passed nested-stackParameter(CDK's synthesizedreferenceto<Parent>…input — e.g. the great-grandchild'sValue: Fn::Join['', ['…:', {Ref: referenceto…RootTopicName}]]) kept its raw intrinsic at diff time (theRefresolved to neither a resource nor a parameter), while cdkd state held the resolved string; the diff calculator'svaluesEqualthen saw "intrinsic vs concrete" and reported a phantomUPDATEon every freshly-deployed tree. The fix threads aparametersmap throughcomputeStackDiff(into the resolverResolverContext.parameters) and addsresolveChildStackParameters, which resolves each childAWS::CloudFormation::Stackrow'sProperties.Parametersagainst the PARENT's deployed state + already-resolved parameters (best-effort: an unresolvable value is omitted so the existing intrinsic-vs-resolved fallback still applies), then forwards the resolved scalar map as the child node'sparameters— exactly mirroring the deploy engine's parent->child forwarding so the recursive diff matches what deploy wrote to state. The bottom-upFn::GetAttoutput chain was already correct (it resolves againstattributes['Outputs.<key>'], which the diff path already supplied). Tests: 4 new unit tests intests/unit/cli/diff-recursive.test.ts(a freshly-deployed down-passed-parameter child diffs NO_CHANGE; a genuinely-changed resolved value still diffs UPDATE;computeStackDiffwith the parameter -> NO_CHANGE; without it -> the pre-fix spurious UPDATE, proving the resolution is load-bearing). The existing nested-stack fixtures stop short of the depth + breadth + reference-direction combinations where cdkd'sNestedStackProviderrecursion, the v6<parent>~<childLogicalId>state-key derivation, anddiff --recursive/state list --treeare most likely to drift:nested-stackis 1 level, andnested-stack-deepis 3 levels with exactly one SSM Parameter per level and bottom-upFn::GetAttoutputs only. This fixture (CdkdNestedStack3LevelExample) is a strict superset along three axes at once: (1) deeper — a 4-level tree (root →Child→Grandchild→GreatGrandchild, depth 3, one deeper thannested-stack-deep), so the${parentStackName}~${nestedLogicalId}join is exercised at theCdkdNestedStack3LevelExample~Child~Grandchild~GreatGrandchildkey; (2) wider — the grandchild is a BRANCHING node owning two own resources (anAWS::SNS::TopicAND anAWS::SSM::Parameter) alongside its nested-stack child, so the per-level DAG must order a sibling resource next to the nested-stack node, and a second resource TYPE participates; (3) bidirectional cross-level references — in addition to the bottom-upFn::GetAttoutput chain (great-grandchild param name bubbles up through grandchild → child → root'sRootRef), the rootRootTopicname is threaded DOWN all three boundaries as a synthesized nested-stackParameter(verified in synth: eachAWS::CloudFormation::Stackcarries areferenceto…RootTopic…TopicNameentry and the great-grandchild template declares the matching inputParameter), exercising cdkd'sNestedStackProviderParametersextraction +DeployEngineOptions.parametersforwarding — a pathnested-stack-deepnever touches.verify.sh(BSD/macOS-portable — nogrep -P/date -d, real exit codes captured to variables, explicitPASSonly on full success) asserts what the existing deep fixture does NOT: it reads each of the four level state files directly from S3 and asserts the v6parentStack/parentLogicalIdpoint one level up (root has neither), collects every SSM Parameter name + SNS Topic ARN from state and asserts each REAL AWS resource exists post-deploy (and that the downward root-topic name actually reached the great-grandchild's parameter value), runscdkd diff --recursiveclean (plus a changed great-grandchild value surfacing a[~]UPDATE under theNested stack: …~GreatGrandchildheader), assertscdkd state list --treerenders the 4-level hierarchy with box-drawing branches (great-grandchild nested under a branch, not a root row), then destroys and asserts the full cascade — every level's SSM Parameter / SNS Topic gone on AWS and every level's state file removed. New scenario tagnested-stack-deep-deploy-cascadein the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs/run-integ nested-stack-3levelbefore merge.)Fn::FindInMapnow supports the optional 4th-argument{ DefaultValue: <x> }(returns the default when the requested key is absent instead of throwing), plus the newintrinsics-torture-2integ that targets the HARDER intrinsic-resolution arg-shapes (sibling ofintrinsics-torture, which found bug #838) —src/deployment/intrinsic-function-resolver.ts+tests/integration/intrinsics-torture-2/. The first torture fixture surfaced thatFn::Joinover a list-returning intrinsic crashed (#838); this second fixture goes after the next tier of less-common / harder arg shapes insrc/deployment/intrinsic-function-resolver.ts, each feeding a REALAWS::SSM::Parameter.Value(written via the raw L1ssm.CfnParameter+addPropertyOverride('Value', <intrinsic>)escape hatch so the synthesized template carries the LITERAL intrinsic, not a CDK-pre-folded value), so a wrong / failed resolution is caught by reading the parameter back. Thesrc/fix it surfaced:resolveFindInMapdestructured only the 3 classic args ([MapName, TopLevelKey, SecondLevelKey]) and threwFn::FindInMap: top-level key '...' not found in mapping '...'whenever a requested key was absent — but CloudFormation's modern grammar accepts an optional 4th element{ "DefaultValue": <value> }and returns that default when the top-level OR second-level key is missing (validated against real AWS via theFindInMapDefaultresource in this fixture, which uses a deliberately-missing top-level key). The handler now accepts the 4th arg, resolves theDefaultValue(which may itself be an intrinsic, e.g.{Ref: AWS::Region}) lazily only when a key is missing, and returns it for every not-found case (no Mappings section / mapping / top-level key / second-level key absent); when noDefaultValueis supplied the existing throws are preserved byte-for-byte (backward compatible), and the present-key 3-arg path is unchanged. TheCdkdIntrinsicsTorture2Examplestack (1 SNS Topic + 10 SSM String parameters, no VPC / Lambda) exercises:Fn::Select[1, Fn::GetAZs('')]andFn::Select[0, Fn::Split(',', {Ref})](the Select analogue of the #838 resolveJoin-over-list-intrinsic bug class);Fn::FindInMapwith the enhanced 4th-arg{DefaultValue}AND a{Ref: AWS::Region}-driven top-level key (the default-value path this fix adds);Fn::GetAtt: [Topic, {Ref: AttrNameParam}]with aRef-valued attribute name (CFn allows it;resolveGetAtttreatsgetAtt[1]as a literal string); theFn::Sub${!Literal}escape (renders a literal${Literal});Fn::Base64of an intrinsic ({Ref}→ string → base64); a triple-nestedFn::If-in-Fn::Sub-in-Fn::Join; andFn::CidrIPv6 (2001:db8::/56→Select[0]=2001:db8:0:0:0:0:0:0/64, the resolver's uncompressed-group form) plus a 2nd IPv4 edge with a differentcidrBits(10.0.0.0/24, 4, 4→Select[2]=10.0.0.32/28).verify.sh(BSD/macOS-portable — nogrep -P/date -d, captures real exit codes, prints an explicitAll N checks passed) computes each expected concrete value in-script from account / region (AZ list viadescribe-availability-zones, base64 viabase64, topic ARN read from deploy outputs), deploys, asserts every SSM parameter equals its expected value FAILing by intrinsic name on mismatch, then destroys and asserts cdkd state + all SSM parameters are gone (no orphans); on a deploy failure (the likely outcome when a real resolver bug is hit) it prints triage context (cdkd state + the synth template's intrinsic blocks) before exiting non-zero. Skipped on purpose:Fn::ImportValuewith anFn::Subexport name (would require a sibling producer stack / export, making this a multi-stack fixture — out of scope for this single-stack torture fixture; the strong-ref import path is covered byimport-value-strong-ref);Fn::CidrIPv6 is INCLUDED (the resolver supports IPv6 viaresolveCidr'sisIpv6branch), so the IPv4-only-fallback alternative was unnecessary. Unit tests intests/unit/deployment/intrinsic-functions.test.tscover the 4th-arg fix (present key ignores DefaultValue / top-level-key-absent returns default / second-level-key-absent returns default / intrinsic-valued DefaultValue resolves / 3-arg key-absent still throws). New scenario tagintrinsic-hard-arg-shapesinKNOWN_SCENARIOS(scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs/run-integ intrinsics-torture-2.)Retry KMS Key / key-policy create on fresh-principal IAM propagation, surfaced by the new
propagation-races-2integ —src/deployment/retryable-errors.ts+tests/integration/propagation-races-2/. cdkd's fast SDK path creates an IAM role and then, within ~1s, issues aKMS:CreateKeywhose key policy references that brand-new role principal; KMS rejects it withMalformedPolicyDocumentException: Policy contains a statement with one or more invalid principalsbecause IAM has not finished propagating the principal yet (CloudFormation never hits this — its deployment latency lets IAM settle, but cdkd does). Issue #839 added retry patterns for the SNS/SQS resource-policy consumers, but KMSCreateKey/PutKeyPolicyis a DIFFERENT consumer those patterns do not cover, so the KMS create failed hard instead of retrying through the propagation window. The fix adds the KMS invalid-principal phrase (Policy contains a statement with one or more invalid principals) toRETRYABLE_ERROR_MESSAGE_PATTERNS— the same array #839 extended. The KMS Key provider is a normal SDK provider whosecreate()runs under the deploy engine'swithRetry(it does NOT opt out viadisableOuterRetrylike the Custom Resource provider), so the message pattern alone is sufficient —withRetrycatches the classified-retryable error and retries the create through the propagation window. The phrase is anchored on the full KMS/IAM policy-document wording so it does not false-positive on unrelated KMS errors (a genuinely malformed key policy still burns only the bounded retries before surfacing). Unit tests intests/unit/deployment/retryable-errors.test.tscover the new pattern (the KMS invalid-principal message classifies retryable; a clearly-different KMS error stays non-retryable). Thepropagation-races-2fixture is the race detector that surfaced this (deploy SUCCESS is the pass condition). (NOTE: needs/run-integ propagation-races-2against real AWS before merge.)New
propagation-races-2integ surfaces more fresh-principal / propagation-race edges (sibling to the original IAM-propagation stress integ that found #839) —tests/integration/propagation-races-2/. The original IAM-propagation stress integ exercised Lambda exec role / SFN role / EventBridge target / SQS+SNS resource policy and surfaced #839 (an SNS/SQS policy PUT not retried on a fresh-rolePrincipalNotFound); many sibling AWS APIs share the same race. This fixture (CdkdPropagationRaces2Example, 20 synthesized resources) probes four DISTINCT race edges the prior integ did not cover, each a NEW consumer of a resource created moments earlier in the SAME deploy: (1) IAM InstanceProfile -> EC2 Instance —RunInstancesvalidates the instance profile at launch (instance-profile propagation is the slowest IAM surface, often 5-10s+, so this is the highest-probability edge); the instance is a RAWec2.CfnInstance(L1) emitting only cdkd-handled top-level props (ImageId/InstanceType/SubnetId/SecurityGroupIds/IamInstanceProfile/Tags) so it stays on the SDK provider path (an L2 instance emitsAvailabilityZone, a silent-drop that flips it onto Cloud Control) in a minimal single-AZ no-NAT VPC; (2)AWS::Lambda::Permissiongranting a fresh S3 bucket source —AddPermissionvalidates the just-createdSourceArn+ function in one call; (3)AWS::S3::BucketPolicyreferencing a fresh IAM role principal —PutBucketPolicyvalidates the role principal (the classicInvalid principal in policyS3 race); (4)AWS::KMS::Keykey policy referencing a fresh IAM role principal —CreateKeyvalidates every principal in the key policy (this edge surfaced the KMS retry fix insrc/deployment/retryable-errors.tsabove). The PASS CONDITION is thatcdkd deploySUCCEEDS — the fixture is a RACE DETECTOR: if cdkd does not retry the fresh-principal propagation error for one of these edges, the deploy fails andverify.shprints which resource failed + the AWS error + thecdkd events --format jsonRESOURCE_FAILEDlines for triage. On success it asserts each resource actually works (instance running with the profile attached, Lambda invokable + resource policy grants the S3 source, bucket policy present + references the fresh role, KMS key Enabled + usable + policy references the fresh role), then destroys and asserts every NAMED resource is gone (EC2 instance terminated, instance profile / Lambda / both S3 buckets gone, KMS key PendingDeletion) by the fixture-ownedcdkd:integ-fixture=propagation-races-2tag / state-resolved physical ids — NOT the AWS-reservedaws:cdk:pathtag — plus a tag-scoped orphan-instance sweep that catches an orphan a state-resolved-id check would miss. The cleanup trap deletes in EC2-before-VPC order (terminate-instances+instance-terminatedwait so a lingering ENI does not block the SG/subnet) and is BSD/macOS-portable (nogrep -P/date -d, real exit codes captured, explicit=== PASS ===only on full success). New scenario tagfresh-principal-consumer-raceinKNOWN_SCENARIOS(scripts/build-scenario-coverage-matrix.ts); the.scenarios.jsonalso tags the existingiam-policy-propagation-retry; coverage matrices regenerated. (NOTE: needs/run-integ propagation-races-2against real AWS before merge; the deploy step is the bug-finder.)Fn::GetAttof an unenriched computed attribute resolved to the raw physicalId instead of the real attribute, plus a new failure-seekingdeep-getatt-chainsinteg for LONGFn::GetAttchains —src/deployment/intrinsic-function-resolver.ts+tests/integration/deep-getatt-chains/**. The fixture surfaced a real cdkd bug shared by two resource classes:intrinsic-function-resolver.ts'sconstructAttribute(the per-type fallback that synthesizes an attribute when the provider never populated it — e.g. on the Cloud-Control-API path, where attributes are not always captured) had no case forAWS::CloudWatch::CompositeAlarm(soFn::GetAtt(<CompositeAlarm>, 'Arn')fell through to the physicalId default and resolved to the alarm NAME, not its ARN) and no case forAWS::EC2::Instance(soFn::GetAtt(<Instance>, 'PrivateIp')resolved to the instance id, not the IP — breaking an ELBv2 IP-target registration withnot a valid IPv4 address). The fix adds both: CompositeAlarmArnis constructed deterministically from the physicalId (arn:<partition>:cloudwatch:<region>:<accountId>:alarm:<AlarmName>— the same:alarm:ARN shape as a metric alarm, so no AWS call is needed); EC2 InstancePrivateIp/PublicIp/PrivateDnsName/PublicDnsName/AvailabilityZonerequire a LIVEDescribeInstanceslookup (the IP is not derivable from the id), cached per (physicalId, attribute) like the file's other live-lookup cases. The resolver-level fix works regardless of SDK-vs-CC-API routing. The newCdkdDeepGetAttChainsExamplestack is a 5-deep chain (cheap; SNS / CloudWatch / SSM / IAM / Lambda only, no VPC, inline LambdaZipFile):A AWS::SNS::Topic(SDK)--TopicArn-->B AWS::CloudWatch::Alarm(SDK,AlarmActions[0])--AlarmName (Ref)-->C AWS::CloudWatch::CompositeAlarm(CC-API, no SDK provider registered;AlarmRule)--Arn-->D AWS::SSM::Parameter(SDK,Value=Fn::Subjoining C.Arn + B.Arn)--Name (Ref)-->E AWS::Lambda::Function(SDK, terminal multi-attributeFn::Subenv pulling A.TopicArn + C.Arn + D(Ref) at once). The critical link is C:AWS::CloudWatch::CompositeAlarmis unregistered, so cdkd routes it via Cloud Control API and itsArnnow comes from the newconstructAttributecase rather than falling through to the physicalId default.verify.sh(BSD/macOS-portable — nogrep -P/date -d— real rc capture viaPIPESTATUS, explicit[verify] PASS) deploys, reads each upstream resource's REAL attribute back from AWS as ground truth, and asserts per-link: alarm B'sAlarmActions[0]== A's realTopicArn; composite C'sAlarmRulereferences B's real name; SSM param D'sValue==composite=<C.Arn>;alarm=<B.Arn>built from the REAL ARNs (the CC-API → SDK hop); and Lambda E's env resolvesUPSTREAM_TOPIC_ARN/UPSTREAM_COMPOSITE_ARN/UPSTREAM_PARAM_NAME/UPSTREAM_JOINEDto the real upstream attributes — a mismatch fails with a message naming the broken link. It then destroys with--forceand asserts every named resource is gone (each carries an OWNcdkd:integ-fixturetag; assertions resolve by name / state — neveraws:cdk:path) plus the S3 state file removed. The EC2 Instance live-lookup path is integ-covered by the siblingdeletion-ordering-complexfixture (an ELBv2 IP-target group registered with a Lambda-less EC2 instance'sPrivateIp). Unit tests intests/unit/deployment/intrinsic-functions.test.tscover the CompositeAlarm ARN construction and the EC2PrivateIplive-lookup (mockedDescribeInstances). New scenario tagdeep-getatt-chain-resolutioninKNOWN_SCENARIOS(scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated (the fixture also newly registersAWS::CloudWatch::CompositeAlarmin the integ-coverage matrix). (NOTE: needs/run-integ deep-getatt-chainsagainst real AWS before merge.)New
sdk-ccapi-crossrefinteg surfaces bugs at the SDK-Provider <-> Cloud Control API cross-reference boundary (test-only — nosrc/change) —tests/integration/sdk-ccapi-crossref/. cdkd's #614 routing flips an SDK-registered resource to the generic Cloud Control API path the moment its template sets a top-level property the SDK Provider would silently drop, which bypasses the SDK Provider'screate()/delete()entirely — typed attribute writes never happen and the physical id becomes whatever CC API returns.Fn::GetAtt/Refreferences crossing that SDK <-> CC seam (and CC API's physical-id shapes) are a documented fragile area (memoryfeedback_silent_drop_forces_cc_api_routing+feedback_cc_api_routing_bypasses_sdk_delete_logic), but no integ exercised it directly. The newCdkdSdkCcApiCrossrefExamplestack forces a heterogeneous routing mix in ONE stack (no VPC / NAT):KinesisStream(AWS::Kinesis::Stream, silent-dropDesiredShardLevelMetrics) andCcLambda(AWS::Lambda::Function, silent-dropRuntimeManagementConfig) auto-route via CC API, whileExecRole(AWS::IAM::Role) andStreamArnParam(AWS::SSM::Parameter) stay on the SDK path. The routing was confirmed against this cdkd version with thefindActionableSilentDropsregistry helper on the synthesized template before finalizing. It crosses the boundary withFn::GetAttin both directions: (A) SDK -> CC —StreamArnParam.Value = Fn::GetAtt(KinesisStream, 'Arn')(an SDK-routed consumer reading a CC-routed producer's attribute, whose CC physical id is the stream NAME not the Arn); (B) CC -> SDK —CcLambda.Role = Fn::GetAtt(ExecRole, 'Arn')(a CC-routed consumer reading an SDK-routed producer's attribute).verify.sh(BSD/macOS-portable, real-rc capture + explicit[verify] PASS) deploys, asserts from state that the two silent-drop resources areprovisionedBy: 'cc-api'and the other two are'sdk'(proves the mixed routing), asserts cross-ref A (the SSM parameter's value on AWS equals the real Kinesis stream ARN), asserts cross-ref B (the Lambda's configured role on AWS equals the real IAM role ARN), asserts the silent-dropRuntimeManagementConfig.UpdateRuntimeOnreached AWS (FunctionUpdate— the CC route forwarded the full property map), thencdkd destroy --force(which exercises the CC delete path that bypasses the SDK providerdelete()for the stream + Lambda) and asserts every named resource (stream / function / role / parameter) and the state file are gone. New scenario tagsdk-ccapi-crossref-boundaryin the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs/run-integ sdk-ccapi-crossrefbefore merge.)New
custom-resource-getatt-datafailure-seeking integ for the Custom Resource response-Dataattribute path consumed viaFn::GetAttinto a dependent resource (refs #756 / #804, test-only — nosrc/change) —tests/integration/custom-resource-getatt-data/**. The existingcustom-resource-providerfixture exercises the CDK Provider framework's asyncisCompleteHandlerpolling path but only asserts the CR's own output; it does NOT prove that a CR's responseDataflows THROUGH cdkd's intrinsic resolver INTO another resource's property — the fragile path (#756 / #804: CRDataattributes only exist after the CR's backing Lambda runs and returns SUCCESS, so a resolver / DAG-ordering bug there is silent unless a dependent's value is read back). The newCdkdCrGetAttDataExamplestack closes that gap with the cheapest shape: an inline NodeJS Lambda-backedAWS::CloudFormation::CustomResourcewhose handler returnsData: { ComputedValue: 'computed-integ', Another: 'another-<region>', NumericValue: '42' }directly in its response payload (the simple synchronous path, no Provider framework / VPC), consumed by THREEAWS::SSM::Parameterdependents whoseValueisFn::GetAtt(MyCustomResource, '<key>')— one per Data key (multiple keys catch a resolver that only wires the first attribute; the stringified-number key catches non-text-Data mishandling), with an explicitaddDependency(cr)on one parameter to exercise the DAG ordering (CR must complete + haveattributespopulated before the dependent provisions).verify.sh(BSD/macOS-portable, real-rc capture + explicit=== PASS ===) deploys, then reads each SSM parameter back from AWS (aws ssm get-parameter) and asserts itsValueequals the value the CR handler returned — the load-bearing check that the CRData.<key>attribute resolved into the dependent property (a blank/wrong value would otherwise pass unnoticed). It also cross-checks the resolvedComputedValueinstate.outputs, then destroys and asserts the state file, all three SSM parameters, and the backing Lambda (resolved by physical id from state) are gone. New scenario tagcustom-resource-getatt-datainKNOWN_SCENARIOS(scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs/run-integ custom-resource-getatt-databefore merge.)Fix: retry AWS throttling rejections that surface as HTTP 400 (not 429) by error NAME + new
throttle-wide-dagfailure-seeking integ —src/deployment/retryable-errors.ts+tests/integration/throttle-wide-dag/. Bug: cdkd's transient-error classifier (isRetryableTransientError) only treated HTTP 429 / 503 + a fixed message-pattern table as retryable. Most AWS throttles, however, surface as HTTP 400 with the throttling signal carried only in the error code / name (e.g. SSMPutParameterrejects a burst withThrottlingException/Rate exceeded. Ensure you have the high-throughput setting enabled ...at status 400), so cdkd'swithRetrynever retried them and the deploy failed. Because cdkd's event-driven DAG dispatches with no level barrier, a wide stack at a high--concurrencyfires a large create burst that trips a per-service rate limit — a NON-DETERMINISTIC subset of resources fails each run (the AWS SDK's own 3 fast internal retries are not enough to drain the burst). Fix: add name-based throttle detection — a newTHROTTLING_ERROR_NAMESset (mirrors the AWS SDK v3@aws-sdk/service-error-classificationTHROTTLING_ERROR_CODES) checked against the error AND its wrapped.causechain (the original SDK error is one cause-link deep under cdkd'sProvisioningError), plus aRate exceededmessage-pattern backstop. cdkd's outerwithRetry(1s/2s/4s/8s backoff, deeper than the SDK's) then spreads the remaining creates out until the rate window clears. The newthrottle-wide-dagfixture deploys a ~100-resource stack (80 SSM Parameters + a 10-deepFn::Subchain + 10 IAM Roles + 10 SNS Topics) at--concurrency 40to force the burst, and asserts the deploy SUCCEEDS (throttles retried, not fatal), all 100 resources reach AWS, the chained parameters are created in DAG order, and destroy is clean with 0 orphans. The fixture's own post-deploy AWS assertion calls runAWS_RETRY_MODE=adaptive/AWS_MAX_ATTEMPTS=10so they survive the same SSM rate window the test deliberately induces. 6 new unit tests intests/unit/deployment/retryable-errors.test.ts(name on the error; name one cause-link deep;Rate exceededmessage backstop; other canonical names; non-throttling 400 not retried; cyclic-cause-chain safety). Validated against real AWS (/run-integ throttle-wide-dag— deploy + destroy clean, 0 orphans; the pre-fix binary failed the same deploy non-deterministically). Surfaced by the 2026-06-13/14 failure-seeking integ campaign.New
tags-propagationfailure-seeking integ verifies STACK-LEVEL tag propagation across many taggable types on BOTH the SDK-provider and Cloud Control API paths (test-only — nosrc/change) —tests/integration/tags-propagation/**.cdk.Tags.of(app).add(k, v)injects the same tags into the CFnTagsproperty of every taggable resource, and cdkd must forward them to AWS correctly per type — but each AWS type accepts tags in a DIFFERENT wire shape ({Key,Value}[]list vs{ k: v }map vs the CC-API forwarder), and the per-type tag handling lives in each provider independently, so a bug in one type's tag handling is invisible to the others. NotablyAWS::SSM::Parameter.Tagsis a CFn MAP (not the list almost every other type uses) — the historicalproperties.Tags.map()deploy-crash type recorded infeedback_ssm_parameter_tags_is_a_map, which hid because the unit tests + the only SSM-tag fixtures used the wrong (list) shape. The newCdkdTagsPropagationExamplestack applies 3 stack-level tags (CdkdTagOwner/CdkdTagEnv/CdkdTagCostCenter, set viacdk.Tags.of(app)inbin/app.ts) to 9 taggable resources spanning both provisioning paths: SDK path — S3 Bucket, SNS Topic, SQS Queue, SSM Parameter (the MAP type), IAM Role, Logs LogGroup, Lambda Function, DynamoDB Table; Cloud Control API path — Athena WorkGroup (no SDK provider, so cdkd routes it through CC, which forwards the full CFnTagsproperty). No VPC / NAT / instances — every resource is control-plane-only and cheap.verify.sh(BSD/macOS-portable — nogrep -P/date -d, realcdkdexit codes captured,jqfield extraction,[verify] PASSonly on full success): (1) deploys (a wrong-Tags-shape crash on any type fails here with specifics); (2) readsstate.jsonand asserts the routing split — Athena WorkGroupprovisionedBy == 'cc-api', the other 8'sdk'; (3) for EACH of the 9 types reads the live AWS-side tags via that type's type-specific list/describe API (s3api get-bucket-tagging/sns list-tags-for-resource/sqs list-queue-tags/ssm list-tags-for-resource --resource-type Parameter/iam list-role-tags/logs list-tags-for-resource/lambda list-tags/dynamodb list-tags-of-resource/athena list-tags-for-resource) and asserts ALL 3 stack-level tags landed with the correct value — a type missing a tag (a dropped tag) FAILs the run NAMING the type; (4)cdkd driftimmediately after deploy must report exit 0 — a tag-list reorder from AWS must not show as a false-positive drift (issue #802canonicalizeTagListsDeep), nor a map-vs-list readback-shape mismatch; (5)cdkd destroy --forcecleans up. New scenario tagstack-level-tag-propagation-multitypein the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs/run-integ tags-propagationbefore merge.)New
getstackoutput-crossregioninteg exercises cdkd's UNIQUE same-account / CROSS-REGIONFn::GetStackOutputintrinsic (test-only — nosrc/change) —tests/integration/getstackoutput-crossregion/**. cdkd'sFn::GetStackOutputcan read a producer stack's output from a DIFFERENT region than the consumer's deploy region (the architecture doc claims this works out of the box because the cdkd state bucket is account-scoped, not region-scoped — the resolver readscdkd/{Producer}/{producerRegion}/state.jsonfrom the same bucket the consumer state lives in), but that path was under-tested: the existingcross-stack-references/schema-v7-to-v8-migrationfixtures both resolveFn::GetStackOutputonly WITHIN one region (noRegionargument). The new fixture is a two-stack, one-app pair pinned to different regions viaenv.region: a PRODUCER (CdkdGsoProducer,us-west-2) SSM parameter whose ARN is exported viaCfnOutputProducerArn, and a CONSUMER (CdkdGsoConsumer,us-east-1) SSM parameter whoseValueis{Fn::GetStackOutput: {StackName, OutputName: ProducerArn, Region: us-west-2}}(injected viaaddPropertyOverride—aws-cdk-libships no typed helper). The producer/consumer are SEPARATEcdkd deploycalls with different--region.verify.sh(BSD/macOS-portable — nogrep -P/date -d, captures real exit codes, prints an explicit final PASS line): (1) deploys the producer inus-west-2, asserts its state lands at theus-west-2region-prefixed key and itsProducerArnoutput ARN carries the:us-west-2:segment; (2) deploys the consumer inus-east-1, asserts its state lands at theus-east-1key (a failed cross-region read fails the deploy here with "stack not found in region 'us-west-2'"); (3) the LOAD-BEARING assertion — the consumer's SSM parameter on AWS (inus-east-1) must equal the producer's REAL output value (fromus-west-2), proving the cross-region read worked AND resolved the CORRECT value, plus a belt-and-suspenders check that the resolved value names the PRODUCER's region (guards against a silent same-region fallback); (4) destroys consumer-first then producer (Fn::GetStackOutputis a weak reference, so order is not strictly required — mirrors recommended order), asserting both AWS resources AND both region-prefixedstate.jsonfiles are gone. The cleanup trap drops both regions' SSM parameters + both region-prefixedstate.json/lock.jsonsidecars from the account-scoped bucket, so a failed run leaves nothing behind in either region. New scenario taggetstackoutput-cross-regionin the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); the fixture's.scenarios.jsonalso tags the existingmulti-stack-getstackoutput; coverage matrices regenerated. (NOTE: not yet run against real AWS — needs/run-integ getstackoutput-crossregionbefore merge.)New
eventsourcemapping-racefailure-seeking integ forAWS::Lambda::EventSourceMappingcreated against a FRESH source + role in the same deploy (test-only — nosrc/change) —tests/integration/eventsourcemapping-race/**. cdkd's event-driven DAG dispatches each resource the instant its own deps complete with NO level barrier, so anAWS::Lambda::EventSourceMappingthat references a queue, function, and execution-role all created in the SAMEcdkd deploycan fireCreateEventSourceMappingbefore AWS has propagated them — surfacing asInvalidParameterValueException("Cannot access queue" / "provided role ... does not have permissions" / "Function not found"). No existing fixture exercised an ESM against a fresh SQS source on the create-race-prone path with a delivery assertion + a redeploy-orphan guard. The newCdkdEsmRaceExamplestack is cheap (onesqs.Queue+ one inline-Pythonlambda.Function+ the synthesized ESM wiring them viaSqsEventSource; no VPC, no KMS; synth confirms exactly oneAWS::Lambda::EventSourceMappingwithEventSourceArn: Fn::GetAtt[queue, Arn],FunctionName: Ref[fn],Enabled: true).verify.sh(BSD/macOS-portable — nogrep -P, nodate -d; captures the real deploy exit code and prints an explicitPASSEDline) runs: (1) a PRE-FLIGHT orphan scan per therun-integskill —list-event-source-mappingsfiltered by the stack name aborts with cleanup commands if a prior killed run left an out-of-state ESM that would collide on the next CREATE (ResourceConflictException), the orphan-ESM-on-redeploy class; (2) deploy succeeds (no fresh-source/role race) — on failure it prints the deploy output and greps the ESM-specific error lines (InvalidParameterValue/does not have permissions/Cannot access/Function not found/ResourceConflict); (3) the ESM exists + reachesState=Enabled(get-event-source-mappingpoll + a cross-check thatlist-event-source-mappings --event-source-arn <queue>returns the UUID); (4) the wiring actually delivers — sends a probe message to the queue and polls the Lambda's CloudWatch logs for the handler'sCDKD_ESM_PROCESSED <body>marker (proves queue -> ESM -> Lambda delivery, not just that the mapping was created); (5) destroy is clean — NO orphan ESM survives (list-event-source-mappingsby function is empty), the queue is gone, and the state file is gone. An EXIT trap deletes any leftover ESM + queue + state on every exit path. New scenario tageventsourcemapping-fresh-source-raceinKNOWN_SCENARIOS(scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs/run-integ eventsourcemapping-racebefore merge.)New
update-policy-mutationsinteg surfaces UPDATE-time handling ofDeletionPolicy/UpdateReplacePolicy/DependsOnchanges + metadata-only no-ops + orphan-on-replace (test-only — nosrc/change) —tests/integration/update-policy-mutations/**. The existing fixtures cover property mutations (update-replace) and a single-deploy Retain skip (deletion-policy-retain), but NONE exercise a template-level ATTRIBUTE that changes across two deploys — a likely-under-tested cluster indiff-calculator.ts'scompareAttributes+deploy-engine.ts's Retain-on-replace / DeletionPolicy destroy-skip paths. The newCdkdUpdatePolicyMutationsExamplestack drives two deploys via a CDK context flip (-c phase=athen-c phase=b, read at synth time) and asserts four edge cases against real AWS: (1)UpdateReplacePolicy: Retainorphan-on-replace —RetainReplaceBucket(AWS::S3::Bucket,RemovalPolicy.RETAIN) changesBucketName(in the replacement-rules registry) so phase-b forces a REPLACEMENT; verify.sh asserts the OLD physical bucket is RETAINED on AWS while the new one is created; (2)DeletionPolicyflip —PolicyFlipParam(AWS::SSM::Parameter) flipsRemovalPolicy.DESTROY->RETAINbetween phases (value unchanged, so the only diff is the attribute); the FINAL destroy must honor the current Retain policy and leave it on AWS; (3)DependsOnadd/remove — two SNS-topic pairs (DependsOnAdd{A,B}gains a DependsOn in phase b;DependsOnRemove{A,B}loses one) must update successfully with both topics keeping their physical id (ARN) — a metadata-only change must NOT trigger replacement; (4) no-op — an identical redeploy must reportNo changes detected. The test INTENTIONALLY creates orphans (the Retain-replaced old bucket + the phase-b Retain bucket + the phase-b Retain SSM param survive destroy); the verify.sh trap deletes EVERY captured / deterministic physical id (both bucket phases + both SSM params) and the final step asserts 0 leftovers via direct AWS API checks. BSD/macOS-portable (nogrep -P/date -d), captures real rc, printsAll update-policy-mutations checks passedonly on full success. SNS ARNs are captured fromcdkd state show --json. New scenario tagupdate-policy-mutationsin the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs/run-integ update-policy-mutationsbefore merge.)New
importvalue-chainfailure-seeking integ for TRANSITIVE 3-stackFn::ImportValuechains (test-only — nosrc/change) —tests/integration/importvalue-chain/**. Goes deeper than the two existing cross-stack fixtures:cross-stack-references(1 producer + 1 consumer;Fn::ImportValuevsFn::GetStackOutputside by side) andimport-value-strong-ref(1 producer + 1 consumer; strong-ref refusal + schema v3→v4 migration story) both stop at a single producer→consumer edge. This fixture is a 3-stack chain A→B→C where the middle stack BOTH imports AND re-exports: Stack A (CdkdImportChainA) is an SNS Topic exportingChainTopicArn; Stack B (CdkdImportChainB) importsChainTopicArn(stored in an SSM Parameter) AND derives a value from it viaFn::Sub(derived::<topicArn>::from-b) which it re-exports asChainDerivedValue; Stack C (CdkdImportChainC) importsChainDerivedValue. So C's value transitively depends on A's export through B — the link neither existing fixture exercises. Resources are cheap (SNS + SSM only, no VPC).verify.sh(BSD/macOS-portable, real exit codes captured per step, explicitAll importvalue-chain smoke tests passed): (1)deploy --all(DAG must order A→B→C); asserts B's SSM Parameter holds A's REAL SNS topic ARN (exact match when the topic is enumerable, ARN-shape otherwise), C's SSM Parameter holds B's derived value with the embedded ARN equal to what B imported (full transitive chain), and the exports index carries BOTHChainTopicArn+ChainDerivedValue(withChainTopicArn's indexed value matching B's import). (2) Error path — deploying C with--exclusivelyagainst a throwaway--state-prefix(no producers) must fail with a clearnot found/exporterror namingChainDerivedValue(cdkd does not silently resolve a dangling token).--exclusivelyis load-bearing: likecdk deploy, a barecdkd deploy <stack>deploys the stack's DEPENDENCY CLOSURE, so without it cdkd would deploy A→B→C on the fresh prefix (producing the import) and B's account-global SSM Parameter name would collide with the main chain's still-live B parameter — masking the missing-export path. With--exclusivelyonly C is deployed, so itsFn::ImportValuegenuinely has no producer and fails at resolution before any resource is created. (3) Chained strong-ref protection — destroying B while C imports it is refused (Cannot destroy stack, namesCdkdImportChainC+ChainDerivedValue); destroying A while B imports it is refused (namesCdkdImportChainB+ChainTopicArn). (4) Ordered teardown — destroy C→B→A; each succeeds once its consumer is gone; asserts state gone for all 3, the named SSM Parameters + the SNS topic NOT-FOUND in AWS (state-empty can miss an orphan carrying no stack name — assert the real resources directly, perfeedback_protection_integ_must_instantiate_resource), and the exports index purged of both exports. An EXIT trap tears down both the main and fresh prefixes consumer-first and sweeps the SSM params. Reuses the existingmulti-stack-importvalue-strong-refscenario tag (the chain exercises the sameFn::ImportValuestrong-ref + exports-index machinery transitively, so no newKNOWN_SCENARIOSentry); coverage matrices regenerated. Validated against real AWS (/run-integ importvalue-chain— full chain + error-path + strong-ref + ordered teardown clean, 0 orphans).Hardened post-destroy assertions across three new behavior-class integ fixtures so a silently-skipped delete cannot pass as clean (issue #831, test-only — no
src/change) —tests/integration/{update-replace,destroy-interrupt,deployment-events}/verify.sh. Code review of the newupdate-replaceinteg (PR #830) surfaced a hardening gap shared by several of the new fixtures: their post-destroy assertions checked only thatstate.jsonwas gone (and S3 buckets), but did NOT explicitly assert that the named NON-bucket resources (Lambda / IAM Role / SecurityGroup / SNS Topic) were actually deleted from AWS. Per thefeedback_protection_integ_must_instantiate_resourcerule ("state-empty misses an orphan carrying no stack name"), astate destroythat silently skips a resource (e.g. an SG delete blocked by a lingering ENI, or an IAM role that is not VPC-bound and orphans independently) would leave a real orphan while the test still passed. The fixtures now assert each named resource is NOT-FOUND in AWS after destroy:update-replace— the Lambda (WorkerFn,aws lambda get-function-configuration), IAM Role (WorkerRole,aws iam get-role), and SecurityGroup (WorkerSg,aws ec2 describe-security-groups) physical ids captured from state in Phase 1 must each error post-destroy, on top of the two existing bucket checks; plus a belt-and-suspendersaws s3 rb --forceof the predictablecdkd-update-replace-{account}-{region}-v1/-v2bucket names in the cleanup trap so a re-run is not blocked after a mid-replacement crash that left a v1/v2 bucket behind.destroy-interrupt— already resolved the backing Lambda + VPC (covering subnets/SG/ENI implicitly) + each SSM parameter id from state; added EXPLICIT not-found assertions for the CR handler's IAM Role (aws iam get-role— NOT VPC-bound, so the VPC-gone assert did NOT cover it) and the Lambda SecurityGroup (aws ec2 describe-security-groups— belt-and-suspenders on top of the VPC-gone implication).deployment-events— already asserted the SSM parameter gone; added an SNS Topic not-found assertion (aws sns get-topic-attributesagainst the deterministic${STACK}-topicARN resolved viasts get-caller-identity) plus a matching topic delete in the failure-cleanup path. All three scripts stay BSD/macOS-portable (nogrep -P/date -d), usejq has()for boolean probes, capture real exit codes, and print[verify] PASSonly on full success — following the own-cdkd:integ-fixture-tag pattern already used byrollback-failure-injection(these three resolve physical ids from cdkd state instead, which is equally reliable and needs nolib/change). Resource TYPES are unchanged, so the integ-coverage / scenario-coverage matrices regenerated with no diff. (NOTE:update-replaceanddeployment-eventsneed a real-AWS re-run to validate the new assertions;destroy-interruptadds only two cheap not-found checks to an already-passing fixture.)New
replacement-fanoutinteg stresses replacement propagation (issue #807) at FAN-OUT scale —tests/integration/replacement-fanout/(new fixture; nosrc/change). #807 fixed the basic replacement-propagation case (promoteReplacementDependentsinsrc/analyzer/diff-calculator.ts: a dependent whose only "change" is aRef/Fn::GetAtt/Fn::Subto a to-be-REPLACED resource is promotedNO_CHANGE→UPDATEso it re-points at the new physical id), and it was verified end-to-end with a single dependent (the ECSService→ replacedTaskDefinitioncase inecs-fargate). A single-dependent test cannot surface a PARTIAL-propagation gap. This fixture exercises the same propagation with one base resource referenced by many (10) dependents:CdkdReplacementFanoutExampleis anAWS::SNS::Topic(BaseTopic) with an explicit, phase-keyedTopicName(cdkd-replacement-fanout-{region}-a), referenced by 10AWS::SSM::Parameters whoseValueisFn::Sub("arn=<topicArn>|idx=N", { arn: Ref(BaseTopic) })(SNSRefresolves to the topic ARN) plus anAWS::SNS::TopicPolicywhose policyResourceis the topic ARN. A-c phase=a|bflip (read at synth time) renames the topic-a→-bon phase b;TopicNameis in the SNS entry of the replacement-rules registry, so the rename forces delete + recreate → a NEW topic ARN. The 10 parameters are auto-named, so they keep their physical id and take an in-placeValueupdate — only the embedded ARN changes.verify.sh(BSD/macOS-portable — nogrep -P/date -d, real exit codes captured to variables, explicit[verify] PASS): (1) deploy-c phase=a, capture the base ARN + every dependent's AWS-resolvedValue(baseline asserts all embed the phase-a ARN, TopicPolicy references it); (2) redeploy-c phase=b, assert the base ARN CHANGED (old topic gone, new present) AND every dependent re-resolved to the NEW ARN on AWS — a dependent left on the stale phase-a ARN FAILS naming its index (#807 fan-out gap), each parameter keeps its physical id, and the TopicPolicy re-points at the new ARN with the stale one absent; (3) destroy and assert the state file, base topic, and all 10 parameters are gone (per-index orphan attribution). New scenario tagreplacement-fanout-propagationinKNOWN_SCENARIOS(scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs/run-integ replacement-fanoutbefore merge.)Fix: parse the SecretsManager whole-secret dynamic-reference form + new
secrets-dynamic-reffailure-seeking integ for CloudFormation dynamic references ({{resolve:secretsmanager:...}}/{{resolve:ssm:...}}) —src/deployment/intrinsic-function-resolver.ts+tests/integration/secrets-dynamic-ref/. Bug: the whole-secret form{{resolve:secretsmanager:SECRET_ID:SecretString}}(and:SecretBinary) with no trailing json-key/version segments was mis-parsed —resolveSecretsManagerReferenceonly matched the mid-string delimiter:SecretString:/:SecretBinary:(with a trailing colon), so the end-anchored whole-secret form fell through. Fix: add an end-anchoredendsWith(':SecretString')/endsWith(':SecretBinary')fallback that fires only when the mid-string delimiter is absent AND the segment is at the very end, so aSECRET_IDthat merely contains:SecretStringmid-name is not split incorrectly. cdkd resolves dynamic references itself inresolveDynamicReferences(src/deployment/intrinsic-function-resolver.ts) BEFORE the property reaches the provider, and that code path had unit coverage only — no real-AWS integ proved that the deployed resource actually carries the RESOLVED value rather than the literal{{resolve:...}}token (which is how the whole-secret bug stayed latent). The fixtureCdkdSecretsDynamicRefExample(cheap, no VPC) deploys a SecretsManager secret with a KNOWN JSON value ({"username":"cdkd-user","password":"cdkd-known-pw-123"}), an SSMStringparameter with a KNOWN value (cdkd-known-ssm-value), and a consumer inline-codeAWS::Lambda::Functionwhose ENVIRONMENT VARIABLES are literal{{resolve:...}}strings (CDK emits them asFn::Joinarrays interpolatingAWS::AccountId; cdkd'sresolveJoinre-runsresolveDynamicReferenceson the joined result). Forms exercised — and which cdkd supports: secretsmanager JSON-key (:SecretString:password) SUPPORTED, secretsmanager whole-secret (:SecretString) SUPPORTED, secretsmanager version-stage (:SecretString:password:AWSCURRENT) SUPPORTED,ssm:<name>plaintext param SUPPORTED.ssm-secure:<name>is intentionally OUT OF SCOPE / SKIPPED (cdkd's resolver routes onlysecretsmanager+ssm;ssm-securehits the else branch → warn + leave literal → broken deploy), and the secret VERSION-ID slot is not exercised (the version id is not knowable ahead of deploy; the version-stage slot covers the optional-trailing-field grammar).verify.sh(BSD/macOS-portable, real-rc capture, explicit final PASS line) deploys, reads the consumer Lambda's env vars viaGetFunctionConfiguration, and asserts each env var is (a) NOT still a literal{{resolve:...}}token AND (b) equals the known expected value — a wrong-or-literal value FAILS with specifics; then destroys and asserts the Lambda, secret (force-deleted, sodescribe-secretis NotFound), SSM parameter, and state file are all gone. SECURITY: secret-derived values are never printed — assertions mask them (xx***(len=N)); only PASS/FAIL plus a masked snippet appears in the log. New scenario tagdynamic-reference-resolutioninKNOWN_SCENARIOS(scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. Validated against real AWS (/run-integ secrets-dynamic-ref— deploy + destroy clean, 0 orphans) plus abench-cdk-samplebroad integ for the cross-cutting resolver change.New
s3-asset-deployinteg exercises the S3 file/ZIP asset-publishing path during a realcdkd deploy—tests/integration/s3-asset-deploy/**. Most Lambda fixtures use inline code (Code.fromInline) or never assert the asset upload itself, leaving cdkd'sFileAssetPublisher(S3 zip + upload, content-addressed skip-if-exists) without a dedicated end-to-end regression. The newCdkdS3AssetDeployExamplestack closes that gap: a Lambda whose code comes from a local multi-file directory (lambda/— handler +helpers/+vendored/Python sub-packages, so the asset is a genuine multi-file ZIP, not a trivial single file) forces cdkd to zip the directory + upload it to the CDK bootstrap asset bucket and wire the function'sCode.S3Bucket/Code.S3Keyto the uploaded object; AND a generics3_assets.Asset(asset-data/) is uploaded to the same bucket with its resolveds3BucketName/s3ObjectKeythreaded into the Lambda asCONFIG_BUCKET/CONFIG_KEYenv vars (synth confirms both are emitted asFn::Sub-backed bucket + literal key refs, exercising cdkd's intrinsic resolver).verify.sh(BSD/macOS-portable, real-rc + explicit[verify] PASS) deploys, asserts the function'sCodeSize > 500bytes (proves it ran from the uploaded ZIP, not inline), invokes it and asserts the handler marker (cdkd-s3-asset-deploy-marker-v1) plus a non-zeroconfigBytesfrom the generic-asset S3 read-back (proving that upload reached AWS and the bucket/key env wiring resolved), then destroys and asserts the Lambda + state file are gone with 0 errors. The bootstrap-bucket asset OBJECTS persist by design (cdkd does not own / delete the CDK bootstrap bucket) — the script deliberately does NOT assert their absence and notes this. New scenario tags3-asset-deployin the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs/run-integ s3-asset-deploybefore merge.)New
drift-revert-arraysinteg broadens drift coverage to TAG-heavy / ARRAY-heavy resource types (refs issue #802) —tests/integration/drift-revert-arrays/(new fixture; nosrc/change). Issue #802 addedsrc/analyzer/drift-normalize.ts(canonicalizeTagListsDeep+canonicalizeIdArraysDeep) so a benign AWS-side reorder of a tag list ({Key,Value}[]) or a resource-id / ARN array no longer surfaces as phantom drift, but the existingdrift-revert/drift-revert-vpcfixtures carry none of those unordered-set array shapes — the canonicalization path had unit coverage only. This fixture (CdkdDriftArraysExample) deploys an S3 Bucket, SNS Topic, SQS Queue (each with six user tags), an IAM ManagedPolicy with a multi-statement document carrying multipleAction[](plain scalar arrays — intentionally NOT canonicalized) + multipleResource[]ARN arrays (canonicalized) + six tags, and a VPC (natGateways: 0, no NAT cost) + SecurityGroup with four CIDR ingress rules + six tags.verify.shasserts (a) no false positive on a clean deploy (cdkd driftexit 0 even though AWS reorders the tag lists / ARN arrays on readback), (b) no false positive on an induced reorder (inject-drift.ts reorderre-PUTs the same six S3 tags reversed;cdkd driftstill exit 0 — provescanonicalizeTagListsDeep), (c) true drift still detected (inject-drift.ts driftchanges a tag VALUE + adds a managed-policy Action + authorizes a new SG ingress rule out of band;cdkd driftexit 1), thencdkd drift --revert -yreverts and a follow-upcdkd driftis clean, and destroy leaves 0 orphans. The script is BSD/macOS-portable (nogrep -P/date -d), captures eachcdkd driftreal exit code, hard-fails with a canonicalizer-naming message, and prints[verify] PASSonly on full success. Thesubnet-…/sg-…resource-id branch ofcanonicalizeIdArraysDeepstays unit-covered (tests/unit/analyzer/drift-normalize.test.ts); the integ exercises the ARN branch of the same function end-to-end. New scenario tagdrift-revert-array-canonicalization.New
docker-image-assetinteg exercises the deploy-time ECR build + push asset pipeline end-to-end —tests/integration/docker-image-asset/. Until now cdkd's Docker asset path (src/assets/docker-asset-publisher.ts—docker buildof a local Dockerfile, ECR auth,docker push) was only covered by the LOCAL-emulation container fixtures (local-invoke-containeretc.), which build images locally and never touch AWS — the actual deploy-time ECR build+push was untested end-to-end. The new fixture is a tinyCdkdDockerImageAssetExamplestack with a singlelambda.DockerImageFunction(DockerImageCode.fromImageAsset(docker/, { platform: LINUX_ARM64 }), a trivialpublic.ecr.aws/lambda/nodejs:20-based multi-arch image with a one-line handler, no VPC / NAT — the cheapest reliable way to force the build+push). The buildplatformAND the Lambdaarchitectureare BOTH pinned to ARM_64 (matching) on purpose: a defaultDockerImageFunctionsynthesizes NOsource.platformin the asset manifest and NOArchitectureson the template (defaulting the function to x86_64), so cdkd — which correctly honorssource.platformwhen present but builds for the HOST arch when it is absent — produces an arm64 image on an Apple-Silicon host that the x86_64 Lambda rejects at invoke withRuntime.InvalidEntrypoint: ProcessSpawnFailed(the same cross-arch trap CDK CLI users hit on Mac). PinningLINUX_ARM64makes CDK emitsource.platform: "linux/arm64"+Architectures: ["arm64"]so the built image arch always matches the Lambda runtime arch on any host.verify.sh(BSD/macOS-portable, captures the real rc + prints an explicit[verify] PASS) gracefully SKIPs (exit 0) whendocker infofails so it is robust on a Docker-less box but runs in a Docker env; it then: (1) deploys and asserts the Lambda exists withPackageType=Imageand aCode.ImageUripointing at the CDK-managed container-assets ECR repo, that the repo exists, and that OUR exact pushed image — identified by its content-addressed asset-hash TAG parsed from the ImageUri — is present (it deliberately does NOT count images in the shared bootstrap repo, which already holds thousands from other deploys, so a count is not a meaningful signal); (2) invokes the Lambda (aws lambda invoke) and asserts the expected payload (message,deployedByenv var, echoed event) — proving the pushed image actually runs; (3) destroys and asserts clean (Lambda gone, OUR pushed image gone from ECR with 0 orphans — the asset repo is the shared bootstrap-managedcdk-hnb659fds-container-assets-*repo so the assertion is on OUR image's lifecycle by tag, not the shared repo's removal, which is expected to persist), and the state file gone. The EXIT trap aggressively sweeps any leftover state, the deployment-events sidecar, and (by tag) the pushed image so the cost-bearing ECR image never lingers. New scenario tagdocker-image-asset-ecr-publish(KNOWN_SCENARIOS inscripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated; testing-guide entry added to Testing.New
destroy-interruptinteg for the graceful-SIGINT destroy path (issue #816) + the Custom-Resource replay fail-fast (issue #804) —tests/integration/destroy-interrupt/(new fixture +verify.sh). Both behaviors shipped with unit + clean-destroy coverage only; this is their first real-AWS end-to-end verification. The fixture stackCdkdDestroyInterruptExample(16 synthesized resources) is a VPC with two isolated subnets + an S3 gateway VPC endpoint + a VPC-attached Lambda (HandlerFn) backing acdk.CustomResource(CrProbe) + fourAWS::SSM::Parameters — enough resources (VPC + subnets + SG + Lambda hyperplane ENI delete in order) that a destroy spans several seconds, so a mid-destroy SIGINT reliably lands during deletion, and the VPC-attached Lambda backing a CR is exactly the #804 shape (on a re-run the backing Lambda may already be gone).verify.sh(BSD/macOS-portable — nogrep -P, nodate -d, real exit codes captured to variables) runs four phases: (1) deploy clean; (2) first Ctrl-C — launchcdkd destroy --forcein the background, poll its log for delete-loop evidence (bounded ~30s) before sending ONEkill -INT <pid>to cdkd's own SIGINT handler, then on an interrupt that lands mid-destroy assert the #816 contract: the drain notice is logged, the stack lock object is gone (released — pre-fix it stranded for its 30m TTL), and the state file is preserved (trimmed, still listing the not-yet-deleted resources); if the destroy finishes before the interrupt can land (a fast-account race) that is logged and accepted, falling through to the clean-end asserts rather than hard-failing; (3) re-runcdkd destroy --forceto completion — assert it exits 0 (clean resume), finishes in < 180s (the #804 fail-fast: NO ~10-minuteGetFunctionstall against the already-deleted backing Lambda), and the log carries noPending/ long-Lambda-waiter signature; (4) clean end-state — state + lock gone, backing Lambda gone, VPC gone (subnets / SG / ENI implicitly cleared, plus an explicit leftover-ENI scan), no leftover SSM parameters. New scenario tagdestroy-interruptinKNOWN_SCENARIOS(scripts/build-scenario-coverage-matrix.ts); the fixture's.scenarios.jsonalso tagscustom-resource-async-poll+vpc-lambda-eni-release. Nosrc/change — integ-only PR.Multi-resource deploy-engine ROLLBACK regression net (refs #808) — new
tests/integration/rollback-failure-injection/fixture. The only existing real-AWS rollback coverage was the trivialbasicsingle-SQSCDKD_TEST_FAILinjection (one queue, nothing for rollback to delete). This fixture is a RICH interdependent stack (CdkdRollbackFailureExample:AWS::EC2::VPC+ Subnets +AWS::EC2::SecurityGroup+AWS::IAM::Role+AWS::Lambda::Function-in-VPC +AWS::SSM::Parameter) that exercises thesrc/deployment/deploy-engine.tsperformRollbackpath when several siblings have ALREADY completed before the failure fires. The failure is self-contained (does NOT reuse thebasicfixture'sCDKD_TEST_FAILplumbing): gated onROLLBACK_INTEG_FAIL=true, the stack adds anAWS::SQS::Queuewith an out-of-rangemessageRetentionPeriod(9999999; valid range[60, 1209600]) that AWS rejects onCreateQueue. The failing queue is wired to depend on the two fast siblings (IAM Role + SSM Parameter), so cdkd's event-driven DAG guarantees those are created before the queue is attempted — giving rollback already-created siblings to delete (the slow VPC/Lambda branch runs in parallel and is also rolled back).verify.sh(BSD/macOS-portable, real-rc capture + explicit[verify] PASS): (1) deploy with the flag ON exits non-zero; (2) the completed siblings are rolled back — queried directly against AWS, the SSM Parameter / SecurityGroup / VPC are gone, no failing queue lingers, no leftover hyperplane ENIs, and cdkd state reflects rollback (state.jsonremoved or0resources); (3) the #808 events captured the failure —cdkd events --format jsonshows the newest run is aFAILEDdeploywhose per-run stream has aRESOURCE_FAILEDforAWS::SQS::Queue,ROLLBACK_STARTED+ROLLBACK_RESOURCE_SUCCEEDEDevents, andRUN_FINISHED result=FAILED; (4) deploy with the flag OFF succeeds →cdkd destroy --force→ clean (state gone, 0 orphan VPC/SG/SSM); (5) an EXIT trap performs aggressive orphan cleanup on the failure path (SSM Parameter + failing SQS queue + any Lambda / IAM Role tagged with the fixture'saws:cdk:path+ the VPC and its dependents — ENIs first, then NAT GW / SGs / subnets / IGW / route tables / VPC) plus removal of the events sidecar. This closes the failure-path follow-up thedeployment-eventsfixture README explicitly deferred (RESOURCE_FAILED/ROLLBACK_*events). New scenario tagrollback-failure-injection. Test-only PR — nosrc/change.Extracted the triplicated
ensureClientForBucket()state-bucket-region rebuild into one shared helper (issue #827) —src/utils/bucket-region-client.ts(new) +src/state/s3-state-backend.ts+src/state/lock-manager.ts+src/state/export-index-store.ts. Pure refactor, no behavior change. The "resolve the state bucket's region via the cachedGetBucketLocationprobe, short-circuit when it already matches the client's region, else rebuild a region-correctedS3Clientreusing the caller's credentials" pattern had drifted into three near-identical privateensureClientForBucket()copies (the state backend PR #60, the lock manager #803, the exports index store #819). They are now one exported helperrebuildClientForBucketRegion(client, bucket, opts): Promise<S3Client | null>— it returnsnullto mean "no rebuild needed, keep the original client" and a fresh client otherwise. Every load-bearing per-store difference is preserved via options rather than collapsed:destroyOldClient(the state backend OWNS its client and.destroy()s the replaced one; the lock manager + exports store shareAwsClients.s3and must NOT),profile+ staticcredentials(the state backend threads its constructorclientOptsinto both the probe and the rebuild) vsreuseClientCredentials(the other two authenticate the probe viaclient.config.credentials()best-effort and reuse the original client'sconfig.credentialsPROVIDER REFERENCE — not a resolved snapshot — for the rebuild), andtolerateNonStandardClient(only the exports store gracefully degrades a test double whoseconfig.regionis not a function by returningnull; the other two readconfig.region()directly as before). Each store keeps its own per-instance memoization (clientResolvedflag + single-flightresolveInFlightpromise) and its own debug log wording via anonRebuildcallback. The helper deliberately lives in its OWN module rather than alongsideresolveBucketRegioninaws-region-resolver.ts: the three stores' unit tests mockresolveBucketRegionviavi.mock('aws-region-resolver.js', ...), and a helper co-located in that module would call its sibling through an in-module binding vitest cannot intercept (a module cannot mock itself) — a separate module imports the mocked binding cross-module so the mock still applies. Tests: all 75 existing unit tests acrosstests/unit/state/{s3-state-backend,lock-manager,export-index-store}.test.ts(incl. each store's 301-rebuild / same-region-no-rebuild / resolve-once-cached suites) pass UNCHANGED, plus 7 new focused tests intests/unit/utils/bucket-region-client.test.ts(region-mismatch → rebuilt client with correct region; same-region →null; static-credential carry-over to probe + rebuild; provider-reference reuse;destroyOldClientgating; non-standard-client tolerance →null; no-credentials → omitcredentialsfrom the rebuilt client). No runtime behavior change for any of the three cross-region-state-bucket paths (lock acquisition, state read/write, exports index).--regiondeprecation warning no longer contradicts the actual behavior (issue #818) —src/cli/options.ts.warnIfDeprecatedRegionand the hiddendeprecatedRegionOptionhelp text both claimed--region"has no effect" on non-bootstrap commands, but every non-bootstrap command (deploy,destroy,diff,synth,list,state,force-unlock,publish-assets,import,export,orphan,drift,events,local *, …) actually consumesoptions.regionas the highest-precedence region source:const region = options.region || process.env['AWS_REGION'] || 'us-east-1'feeds the provisioning / state-bucket SDK clients and theapplyRoleArnIfSetSTS hop, anddeploy/destroy/import/export/orphanadditionally inject it intoprocess.env.AWS_REGIONso the CDK synth subprocess inherits it (e.g.deploy.ts~L167/L175/L341). The warning and the code therefore disagreed — a user passing--regionwas told it did nothing while it silently took effect. Investigation determined--regionIS legitimately honored everywhere (option B in the issue), so the fix is purely in the warning + help text — no command implementation (deploy.tsetc.) was touched, keeping the change out of theinteg-broadmerge-gate scope and carrying zero behavior-change risk. The warning now reads "--region is deprecated and will be removed in a future release. It is still honored for now (it overrides AWS_REGION / your AWS profile), but prefer the AWS_REGION environment variable or your AWS profile…" and the option description drops the false "No effect" claim. The recommended mechanism is stillAWS_REGION/ the AWS profile; the flag stays hidden + deprecated, just honestly described. Docs corrected: two "deprecated and ignored" lines in CLI Reference and the--regionbullet in .claude/rules/cli-internals.md. Tests:tests/unit/cli/options.test.ts— the existing message assertion updated, plus new assertions that neither the warning nor the option description contains "no effect" and that both mention the flag is "still honored" (issue #818).destroywaits for NAT Gateway deletion before detaching / deleting the IGW + VPCGatewayAttachment (issue #817) —src/analyzer/implicit-delete-deps.ts. Destroying a VPC + NAT Gateway + IGW stack attempted theVPCGatewayAttachmentdetach while the NAT Gateway's Elastic IP was still mapped to the VPC's public address space, failing withNetwork vpc-xxx has some mapped public address(es), after which the IGW delete hung (~19 min observed). This was the first-run failure split out of the #804 incident as a separate issue. The fix adds two type-based implicit delete-dependency edges so the shared deploy DELETE phase + standalone destroy command order the teardown like CloudFormation does:AWS::EC2::InternetGatewaygainsAWS::EC2::NatGateway(alongside its existingAWS::EC2::VPCGatewayAttachmentdependee) and a newAWS::EC2::VPCGatewayAttachmentkey listsAWS::EC2::NatGateway— both are deleted AFTER the NAT Gateway is gone (NAT deletion releases / decouples the EIP). No type-based rule is needed for the EIP itself: the NAT Ref's its EIP viaAllocationId, so the reversed delete traversal already deletes the NAT before the EIP is released. The injection logic (destroy-runner.ts/deploy-engine.ts) naturally produces no edge when no NatGateway is in state. Tests: 4 unit assertions intests/unit/analyzer/implicit-delete-deps.test.ts(IGW-after-NAT edge, VPCGatewayAttachment-after-NAT edge, no NatGateway / EIP key registered; the existing no-self-cycle guard covers the new entries). Integ: the existingvpc-nat-gatewayfixture (VPC + public/private subnets + IGW + NatGateway + EIP) exercises exactly this teardown end-to-end.Exports index store resolves the state bucket's region before its write/remove (issue #819) —
src/state/export-index-store.ts. PR #803 fixedLockManagerto resolve a cross-region state bucket's actual region viaGetBucketLocationbefore any S3 op; the automatedcross-region-state-bucketinteg then surfaced that the exports index store (Fn::ImportValuecross-stack reference tracking, writess3://{bucket}/{prefix}/_index/{region}/exports.json) still had the SAME unfixed bug. Its S3 client was pinned to the CLI base region, so against a state bucket in another region every index write (after a deploy save) and remove (after a destroy) hit S3's 301 PermanentRedirect, logged asExports index remove failed (non-retryable): The bucket you are attempting to access must be addressed using the specified endpoint ...; continuing without index update. Non-fatal by design (the canonicalstate.jsonis written through the already-region-correctedS3StateBackendand stays correct; the index is a perf-only derived view that self-heals on the nextlookupmiss-and-patch / rebuild), so the run still passed — but the cross-region exports index was silently never maintained. The fix ports theLockManager.ensureClientForBucket()pattern intoExportIndexStore: before its first S3 read (readIndexRaw) or write (writeIndex) it resolves the bucket's region (cached process-wide viaresolveBucketRegion, so when the state backend / lock manager already resolved the same bucket there's no extraGetBucketLocationcall) and, if it differs from the supplied client's region, builds a private replacementS3Clientfor that region — reusing the caller's resolved credentials (so--profile/ static creds carry over without threading client options through the four store call sites) and NOT destroying the sharedAwsClients.s3instance other components still hold. The resolution is memoized + single-flight (clientResolved/resolveInFlight), and degrades gracefully for a test double whose client lacks the SDKconfig.region()shape (skips resolution, uses the client unchanged) — so the store stays contained, with no ripple todeploy.ts/destroy.ts/state.ts/local-state-loader.ts. Tests: 4 new unit tests intests/unit/state/export-index-store.test.ts(removeStack + updateForStack succeed through a region-corrected client when the bucket region differs — pre-fix 301; no client rebuild when the resolved region matches; the bucket region is resolved exactly once across multiple index ops). Integ: thecross-region-state-bucketfixture stack now publishes a CloudFormation Output with anExport.Name(an export-less stack short-circuits the index write entirely), andverify.shgreps thecdkd deploy+cdkd destroy --verboseoutput to assert the exports-index 301 warning is GONE on both paths AND that_index/{region}/exports.jsonwas actually written to the cross-region bucket on deploy. New scenario tagexports-index-region-resolve.destroyhandles the first Ctrl-C gracefully — flushes state + releases the lock instead of stranding it (issue #816) —src/cli/commands/destroy-runner.ts+src/cli/commands/destroy.ts+src/cli/commands/state.ts. This is the deferred "optional fix 3" from #804 (the incremental-state-persistence + CR fail-fast work shipped in PR #814). Before:cdkd destroy/cdkd state destroyhad NO SIGINT handler, so a first Ctrl-C killed the process mid-destroy — thefinallythat releases the stack lock never ran, leaving the lock stranded for its full TTL, and any in-flight provider delete was severed abruptly. After (Terraform parity): the runner registers a per-call SIGINT handler that on the FIRST Ctrl-C sets adrainingflag — the reverse-DAG delete loop checks it before scheduling each subsequent LEVEL (and, defense-in-depth, before dispatching each resource), so NO new delete is started; the deletes already in flight in the current level are awaited to completion (NOT cancelled). Control then falls through to the existingfinally, which flushes the incremental save-chain from #804 (so the preservedstate.jsonlists only the resources that still exist), stops the live renderer, and releases the lock. A SECOND Ctrl-C bypasses graceful shutdown (process.exit(130)). On a graceful interrupt the runner PRESERVES state (it does NOTdeleteState, even thougherrorCount === 0, because resources remain) and surfaces the outcome via a newDestroyRunnerResult.interruptedflag; bothdestroy.tsandstate.tsstop their multi-stack loop on the first interrupted stack and throwPartialFailureError(exit code 2) so scripts / CI see the destroy did not complete. The handler reads/writes only its own call's closure state and is removed viaprocess.removeListener('SIGINT', ...)in thefinally, so no listener leaks — important for nested-stack recursion, whereNestedStackProvider.deleterecurses intorunDestroyForStackand registers one handler per level (Node delivers SIGINT to every listener, so the first Ctrl-C drains the parent AND every in-flight child). A re-run ofcdkd destroyafter a graceful interrupt resumes cleanly with no replay (the #814 incremental state already trimmed the deleted resources) and no wait for the lock TTL. Tests: 5 unit tests intests/unit/cli/destroy-runner-sigint.test.ts(the SIGINT handler is captured by spying onprocess.on('SIGINT', ...)and invoked directly — no real OS signal is sent): first Ctrl-C finishes the in-flight delete + schedules no new deletes + preserves the trimmed state + releases the lock + marksinterrupted; the level-boundary gate stops all subsequent levels; a second Ctrl-C force-quits viaprocess.exit(130); a normal completion leavesinterrupted: falseand removes the listener;process.removeListeneris invoked in thefinally. Happy-path (uninterrupted) destroy is unchanged. Docs: destroy-interruption subsection in State Management + the stale-lock note in Troubleshooting.deployretries the ECS CapacityProvider same-stack infrastructure-role IAM-propagation race (issue #805) —src/deployment/retryable-errors.ts. cdkd's event-driven DAG dispatches the Cloud ControlCreateResourcefor anAWS::ECS::CapacityProvider(Managed Instances) as soon as its same-stack infrastructure role finishes creating, and cdkd's fast SDK path creates the IAM role without waiting for propagation — so ECS tried to assume the just-createdInfrastructureRoleArnbefore IAM had propagated it and rejected the create withCaught ServiceAccessDeniedException for ECSInfrastructureRole[arn:...]. The CC API handler classifies this as a terminalInvalidRequest(no internal retry,SDK Attempt Count: 1), and none of the existing message patterns matched it, so the deploy failed fast on a transient error. The fix adds'Caught ServiceAccessDeniedException'toRETRYABLE_ERROR_MESSAGE_PATTERNS— mirroring theENHANCED_MONITORINGpattern added for #794 — so the deploy engine's existingwithRetry(8 attempts, ~47s cumulative) absorbs the propagation window; the phrase is anchored on the CC-API/ECS handler wording so a genuine, permanent role misconfiguration only burns the bounded retries before surfacing. Generic by design: any Cloud-Control-provisioned type that validates a same-stack IAM role at create time and surfacesServiceAccessDeniedExceptionis covered. Tests: the exact wire message from the issue classifies retryable + a plainAccessDeniedException(without the handler's "Caught" anchor) stays non-retryable inretryable-errors.test.ts. Verified by the issue reporter against the real-world 33-resource stack that surfaced the bug (the capacity provider create retried through the window and completed).AWS::ECS::TaskDefinitionVolumes[].ConfiguredAtLaunchno longer silently dropped (issue #806) —src/provisioning/providers/ecs-provider.ts.ECSProvider.convertVolumesmapped onlyName/Host/EFSVolumeConfigurationwhen converting CFnVolumesto theRegisterTaskDefinitionwire shape;ConfiguredAtLaunchwas dropped, so the registered task definition had noconfiguredAtLaunchvolume and a same-stackAWS::ECS::ServicecarryingVolumeConfigurations(a managed EBS volume — CDK'sServiceManagedVolume) failed to create with "Volume configuration provided but no matching configuredAtLaunch volume found in task definition". The pre-flight property-coverage gate could not catch this class: it works at top-level property granularity andVolumesIS inhandledProperties— the gap was one level down, inside the handled property.convertVolumesnow forwardsconfiguredAtLaunchvia acoerceBoolhelper (same pattern asEC2Provider's) that normalizes CFn boolean-ish values (true/"true"/false/"false") at the wire boundary and returnsundefinedfor absent props so the field is omitted from the SDK input (AWS keeps its default). No parallelupdate()change is needed — ECS TaskDefinitions are immutable revisioned resources; property changes route through Replace (CREATE then DELETE). Tests: 4 unit tests (present-true forwarded, string"true"/"false"coerced, absent omitted, explicitfalsepreserved as distinct from omit). Theecs-fargateinteg fixture gains aServiceManagedVolume(1 GiB gp3, XFS) mounted into the container and attached to the Service viaservice.addVolume()— synthesizing exactly theConfiguredAtLaunch+VolumeConfigurationspairing the bug broke (withdesiredCount: 0no task launches, so no EBS volume is actually created);verify.shasserts the registered task definition'sebs-datavolume hasconfiguredAtLaunch == true(probed via jqhas()— the//operator would map an explicitfalseto the fallback) and thatDescribeServicesshows the deployment carrying theebs-datavolume configuration. RemainingconvertVolumessub-property gaps of the same class (DockerVolumeConfiguration/FSxWindowsFileServerVolumeConfigurationunmapped;Host/EFSVolumeConfigurationcast without PascalCase-to-camelCase conversion) are tracked separately per the issue.AWS::ECS::TaskDefinitionVolumes[]sub-configurations fully PascalCase-to-camelCase converted (issue #815) —src/provisioning/providers/ecs-provider.ts. The remainingconvertVolumessub-property gaps deferred from #806 are now closed. Before:DockerVolumeConfigurationandFSxWindowsFileServerVolumeConfigurationwere not mapped at all (silently dropped fromRegisterTaskDefinition), andHost/EFSVolumeConfigurationwere cast through raw — so their nested CFn-PascalCase keys (Host.SourcePath,EFSVolumeConfiguration.{FilesystemId, RootDirectory, TransitEncryption, TransitEncryptionPort, AuthorizationConfig.{AccessPointId, IAM}}) reached the ECS SDK still PascalCase and AWS dropped them. This is the same PascalCase-to-camelCase trap already fixed for theContainerDefinitionssub-arrays (convertEnvironment/convertSecrets/convertMountPointsetc.). The property-coverage gate could not catch it —VolumesIS inhandledProperties, so the gap was one level down inside the handled property. After:convertVolumesruns each volume sub-block through a dedicated explicit converter (convertVolumeHost/convertDockerVolumeConfiguration/convertEFSVolumeConfiguration+convertEFSAuthorizationConfig/convertFSxWindowsVolumeConfiguration+convertFSxWindowsAuthorizationConfig), matching the provider's existing per-type converter style. The case mapping is NOT a simple first-letter flip in two spots, verified against the CDK L1*ToCloudFormationmappings: EFS usesFilesystemId(lowercases) while FSx usesFileSystemId(capitalS), and EFSAuthorizationConfigusesIAM(all caps), notIam.Autoprovision(Docker) andTransitEncryptionPort(EFS) are coerced at the wire boundary (coerceBool/Number(...)) since CFn can carry them stringly-typed.readCurrentStateTaskDefinitionnow also normalizes the camelCase SDKvolumesshape back to PascalCase via the newvolumesToCfnSDK-to-CFn converter, so thereadCurrentStatedrift snapshot matches the deploy-time template form (forward-looking — TaskDefinitions are immutable replace-only today, so no UPDATE path consumes it yet). Tests: 8 new unit tests (EFS full-shape conversion + stringly-typedTransitEncryptionPortcoercion +Dockerfull-shape + stringly-typedAutoprovisioncoercion +FSxfull-shape +Host.SourcePath+ omit-when-absent for every sub-block) plus areadCurrentStatenormalization test asserting all four sub-block types round-trip back to PascalCase; the two pre-existing #806 volume tests were updated for the new omit-when-absent key set and the corrected PascalCaseHost.SourcePathinput. Integ: theecs-fargatefixture gains anefs.FileSystem+efs.AccessPoint(public subnets,RemovalPolicy.DESTROY) and anefsVolumeConfigurationvolume on the task definition;verify.shassertsdescribe-task-definitionshows theefs-datavolume'sefsVolumeConfigurationreached AWS with camelCasefileSystemId/transitEncryption: ENABLED/authorizationConfig.{accessPointId, iam: ENABLED}. EFS is the integ-verified path;DockerVolumeConfiguration(Docker-daemon-scoped, unsupported on Fargate) andFSxWindowsFileServerVolumeConfiguration(Windows / FSx-specific) are hard to integ on Fargate and are covered by the unit tests only.Cloud Control UPDATE re-includes write-only properties in every patch document (issue #809) —
src/provisioning/cloud-control-provider.ts+ newsrc/provisioning/write-only-properties.ts. Cloud Control applies UPDATE patches read-modify-write: the type's read handler returns the current model, the patch is applied on top, and the result becomes the desired state — but read handlers cannot return write-only properties, so any write-only property absent from cdkd's minimal previous-vs-desired patch silently vanished from the desired state on every CC-routed UPDATE.AWS::ECS::Service(writeOnlyProperties:ServiceConnectConfiguration/VolumeConfigurations/ForceNewDeployment) hard-failed: a task-definition-only change on a service with a managed EBS volume produced a patch withoutVolumeConfigurations, andUpdateServicerejected with "Task definition has configuredAtLaunch volume but no volume configuration provided at runtime", wedging the stack (state still recorded the old properties, so every subsequent deploy retried the same failing patch). Types whose handler accepts the write-only-less state lost the configuration silently instead. The fix mirrorsterraform-provider-awscc:CloudControlProvider.updatenow resolves the type'swriteOnlyPropertiesfrom the registry schema viacloudformation:DescribeType(reduced to the top-level containing property — a nested path like/properties/Foo/Barstrips toFoo), removes those properties from the PREVIOUS side, and regenerates the patch — the generator then naturally emitsaddops for every write-only property present in the desired properties, which is exactly what the CC read-modify-write contract requires. Only write-only properties are force-included (blanket-upserting all desired properties would risk false replacement signals oncreateOnlyPropertieswhose read-back form differs from the stored form). Only SUCCESSFUL DescribeType results are cached per resource type for the deploy lifetime in a module-level map, so repeated updates of the same type pay one throttled-API call; a DescribeType failure (missing IAM permission, transient throttle / 5xx) is NOT cached — it warns and falls back to the pre-#809 minimal patch for that update, and a later update of the same type retries DescribeType. Caching failures would let one transient throttle on the first CC-routed UPDATE silently disable write-only re-inclusion for every CC-routed type for the rest of the deploy, reintroducing the exact hard-fail this fixes. No regression for callers permanently without the newcloudformation:DescribeTypepermission — each update simply re-warns and re-falls-back. A DescribeType response without aSchema(e.g. a still-registering type) is treated as "no write-only properties" — a successful, cacheable, warning-free lookup. Removal-only write-only diffs skip the update entirely (CC cannot remove what its read handler never returns — pre-fix aremoveop against a path absent from the current model would have failed), and the no-change fast path still skips without any DescribeType call. Tests: 11 unit tests intests/unit/provisioning/cloud-control-provider.test.ts(unchanged write-only prop rides along asadd; changed write-only prop not duplicated; nested-path top-level strip; no-write-only type keeps the minimal patch; DescribeType failure warning + fallback; per-type caching of successful lookups; failures NOT cached so a later update retries; retry-after-failure uses the populated set on success; Schema-less response = no write-only props + no warning; no-change skip; removal-only skip). Integ: theecs-fargatefixture'sServiceManagedVolume+CDKD_TEST_UPDATEpass (issues #806/#807) exercises this exact path end-to-end once both land.Replacement of a referenced resource now propagates to dependents diffed as
NO_CHANGE(issue #807) —src/analyzer/diff-calculator.ts+src/analyzer/template-parser.ts. Diff-time intrinsic resolution runs against CURRENT state, so a dependent whose only "change" was aRef/Fn::GetAttto a resource that gets a NEW physical ID on replacement (e.g.AWS::ECS::TaskDefinition— every revision is a new ARN) compared equal and landed onNO_CHANGE; the deploy engine excluded it from the execution DAG and never re-pointed it at the new physical resource. For ECS this meant a task definition change registered a new revision butUpdateServicewas never issued — the service kept running tasks on the old, now-deregistered revision. CloudFormation propagates the new physical ID to dependents; cdkd now mirrors that: after per-resource diffs are computed,DiffCalculator.promoteReplacementDependentswalks reverse reference edges (built from the desired template's per-propertyRef/Fn::GetAtt/Fn::Sub/ nested-intrinsic references via the new publicTemplateParser.extractReferences;DependsOnis excluded — pure ordering carries no value to propagate) from every replacement-triggeringUPDATEand promotesNO_CHANGEdependents toUPDATEwith syntheticPropertyChangeentries for the referencing top-level properties. Each synthetic change is re-evaluated againstReplacementRulesRegistrywithundefinedold/new values — the referencing property's template value did not actually change (only its resolved physical ID / ARN will), so unconditionalreplacementPropertiesstill fire on the property name whileconditionalReplacementsare NOT fed a phantom resolved-string → unresolved-intrinsic delta that would falsely report "changed" and spuriously enqueue the dependent's grandchildren (review Fix 1). A promoted dependent whose referencing property is itself immutable (e.g. a TaskDefinition whoseContainerDefinitionsreference a replaced resource) becomes a replacement seed for its dependents — the walk is transitive, and theenqueuedguard makes it terminate even on a reference cycle (A→B→A). Dependents that already had their own property changes stayUPDATEand gain the referencing-property entry (no duplicates);CREATE/DELETEdependents are untouched. Over-promotion is harmless by construction: the deploy engine's UPDATE path re-resolves desired properties against the in-flight state map (which by DAG order already carries the replaced dependency's new physical ID) and skips the provider call when the resolved properties are unchanged. Each syntheticPropertyChangecarriesreplacementPropagated: true(a new optional field on the sharedPropertyChangetype) socdkd diffannotates the property line[replacement propagated]— the apparent old-value →{Ref}delta reads as a propagated replacement rather than a literal value edit (review Fix 2). Tests: 8 unit tests (Ref promotion + unrelated NO_CHANGE stays, GetAtt promotion, transitive A→B→C with replacement re-evaluation, already-UPDATE dependent append-once, in-place update does NOT promote,replacementPropagatedmarker present, conditionalReplacement does NOT spuriously promote grandchildren, reference-cycle termination); theecs-fargateinteg fixture gains aCDKD_TEST_UPDATE=truePhase 1b (container command change → TaskDefinition replacement) whose verify.sh asserts the Service'staskDefinitiontracks the new ACTIVE revision carrying the updated command.Interrupted / partially-failed destroy no longer replays Custom Resource deletes against an already-deleted backing Lambda (issue #804) —
src/provisioning/providers/custom-resource-provider.ts+src/cli/commands/destroy-runner.ts. Two layered fixes:- CR provider delete fail-fast. Before: re-running a destroy whose first run had already deleted a Custom Resource AND its backing Lambda stalled ~10 minutes per CR — the delete entered
waitForBackingLambdaReady, whose SDK v3 waiters classifyResourceNotFoundExceptionas RETRY (no error acceptor) and pollGetFunctionfor the fullmaxWaitTime: 600, until the lenient delete catch swallowed the timeout. After:delete()issues ONEGetFunctionpre-check before preparing the invocation; a definitiveResourceNotFoundExceptionlogs a warning and treats the Custom Resource as already deleted (warn-and-continue is the provider's existing delete policy), restoring re-run idempotency parity with every other resource type. Inconclusive pre-check errors (throttle, IAM) fall through to the normal invoke path; SNS-backed tokens skip the pre-check; create / update are unchanged (they must keep failing loudly against a missing function). - Incremental state persistence on destroy (Terraform parity, root fix). Before: destroy state handling was all-or-nothing —
deleteStateon full success, untouched full state on any failure / interrupt, so a preserved state still listed every already-deleted resource and the next run replayed them all. After:runDestroyForStackmirrors deploy'ssaveStateAfterResource— each successfully deleted resource (including the idempotent "not found → already deleted" path) is removed from a working copy ofstate.resourcesand the trimmed state is persisted to S3 per resource, serialized through a save chain under the already-held stack lock. Retained resources (DeletionPolicy: Retain) stay in every snapshot (their record is only dropped by the wholesale state-file delete at the end of a clean destroy, as before). Persist failures are warn-and-continue and never fail the destroy; the final write remains authoritative (deleteStateonerrorCount === 0, a final preserve-write of the remaining resources onerrorCount > 0); the save chain is flushed beforedeleteState(no resurrection race) and before lock release. Nested stacks inherit the behavior automatically —NestedStackProvider.deleteroutes child destroys through the samerunDestroyForStack.cdkd destroyandcdkd state destroyshare the runner, so both get it. - Persisted destroy snapshots clear
outputs/ dropimports/outputReads(phantom-export fix). Both the incremental writes and the final partial-failure preserve-write now writeoutputs: {}and omitimports/outputReads.outputsis keyed by output NAME (not logical id) so it cannot be pruned per-resource as backing resources are deleted; a partially/fully destroyed stack has no meaningful outputs, and leaving them in the preserved state would advertise an export whose backing resource is gone — a phantom export the exports index or another producer'sscanActiveConsumersstrong-ref scan could pick up. The destroy's OWN strong-ref check is unaffected: it reads the in-memorystate.outputsBEFORE the delete loop, and the in-memorystateobject is never mutated (only the persisted snapshot copies are cleared). On a clean destroy the exports-index entry is removed viaexportIndexStore.removeStack; on a partial destroy the index may briefly carry stale entries (a perf-only derived view that self-heals), but the canonicalstate.jsonno longer carries the phantom outputs. - Issue's optional fix 3 (graceful SIGINT handling for destroy: stop scheduling, persist, release the lock) was deferred here and later shipped as issue #816 (see the dedicated entry above). The first-run IGW/NAT/EIP implicit delete-dependency gap mentioned in the issue is a separate issue.
- Tests: 5 CR fail-fast unit tests (
custom-resource-provider.test.ts— Lambda gone → 1 GetFunction, no waiter polls / no invoke / no S3, warn logged; Lambda present → unchanged 4-call path; inconclusive pre-check on Throttling / AccessDenied / generic 5xx → falls through to the normal invoke; SNS ServiceToken → no GetFunction pre-check issued) + 11 destroy-runner unit tests (destroy-runner-incremental-state.test.ts— per-resource trimmed persists thendeleteState, partial-failure state contains only failed/remaining resources, "not found" removal, incremental + final persist failures are non-fatal, retained resources survive snapshots, persisted snapshots clear outputs/imports/outputReads while the in-memory state is preserved, mid-chain incremental persist failure doesn't poison later links, 3-concurrent-sibling snapshots shrink monotonically, nested-stack child drives its own state key and flushes before the parent deleteState).
- CR provider delete fail-fast. Before: re-running a destroy whose first run had already deleted a Custom Resource AND its backing Lambda stalled ~10 minutes per CR — the delete entered
Structured deployment events +
cdkd eventscommand (issue #808) —src/types/deployment-events.ts,src/state/deployment-events-store.ts,src/cli/commands/events.ts, plus event-emission seams insrc/deployment/deploy-engine.ts,src/cli/commands/deploy.ts,src/cli/commands/destroy-runner.ts,src/cli/commands/destroy.ts. cdkd now records a CloudFormationDescribeStackEvents-equivalent stream of structured deployment events to S3 for everycdkd deploy/cdkd destroyrun, readable back with the newcdkd events <stack>command. Pre-PR the only durable artifact of a failed run was the (partial)state.json; per-resource lifecycle detail (which op failed, why, in what order, with what AWS error) existed only as transient stdout/stderr log output — making post-hoc troubleshooting (especially handing context to an AI agent on another machine / session) impossible.- Event types:
RUN_STARTED/RUN_FINISHED(command, region, cdkd version, terminal result, per-op counts);RESOURCE_STARTED/RESOURCE_SUCCEEDED/RESOURCE_FAILED(logicalId, resourceType,provisionedBy, physicalId on success, durationMs, error metadata on failure);RESOURCE_RETAINED(destroy-sideDeletionPolicy: Retainskip);ROLLBACK_STARTED/ROLLBACK_RESOURCE_SUCCEEDED/ROLLBACK_RESOURCE_FAILED/ROLLBACK_FINISHED. Failure events carry{ name, message, awsErrorCode?, requestId? }extracted from the innermost AWS-SDK-shaped error in the thrown error's.causechain. - Emitter seam: the
DeployEngineemits per-resource + rollback events through an optionalDeploymentEventRecorderinjected viaDeployEngineOptions.eventRecorder(around the existingprovisionResource/performRollbackpaths — no logging rewrite); the destroy runner emits per-resource DELETE events throughDestroyRunnerContext.eventRecorder; the deploy / destroy CLIs own the run-levelRUN_STARTED/RUN_FINISHEDevents (they know the command / version / result) andfinalize()the recorder in afinally. - S3 layout (no state schema bump): JSONL at
s3://{bucket}/{prefix}/{stackName}/{region}/deployments/{runId}.jsonl+ a smalldeployments/index.json(last 20 runs, newest first). Deliberately a separate key family fromstate.json— state stays at its current version (nointeg-schema-migrationgate), fully backward compatible. Event files survivecdkd destroy(state deletion does not touchdeployments/), so a destroyed stack's failure history stays readable. - Best-effort, never blocking:
record()is synchronous + buffers in memory; flushes are async (debounced timer + size threshold) serialized on a write chain; a failed S3 write warns at most once then degrades to debug — it can NEVER fail or block the deploy / destroy. No locking (per-run unique.jsonlkeys;index.jsonis last-writer-wins — a derived view, the.jsonlfiles are the source of truth). No resource properties in events (secrets) — errors + metadata only; properties already live in state.json. cdkd events <stack> [--run <id>] [--format json] [--stack-region <r>]: lists runs newest-first (from the index, falling back to{runId}.jsonlkey enumeration);--runreads one run's ordered stream (skipping torn / malformed lines from an interrupted flush);--format json(alias--json) emits raw JSON for tooling / AI-agent hand-off. State-driven (no synth, no lock); region auto-discovered from thedeployments/key listing so it works for destroyed stacks. Registered insrc/cli/index.ts.- Tests:
tests/unit/state/deployment-events-store.test.ts(JSONL shape, no-properties-leaked, error-metadata capture, best-effort no-throw + one-shot warn, empty-run no-artifact, index newest-first + truncation-to-N, corrupt-index rebuild, reader listing / index-fallback / torn-line skip / region discovery),tests/unit/deployment/deployment-events-emission.test.ts(ordered emission, no-properties, AWS-error-metadata + rollback events on failure, no-recorder back-compat, throwing-recorder never breaks deploy),tests/unit/cli/commands/events.test.ts(list / read-one /--format json/ not-found / no-history / multi-region ambiguity /--stack-region). Docs: new Deployment Events + acdkd eventssection in CLI Reference. Out of scope (per the issue, deferred):cdkd doctor --bundlediagnostic bundle + MCP server exposure. - Follow-up (review fixes, same PR):
cdkd eventsno longer mislabels a successful run asFAILEDin the index-fallback (user-visible correctness fix). Whendeployments/index.jsonis missing / corrupt,DeploymentEventsReader.listRunsrebuilds the run listing by enumerating the{runId}.jsonlkeys. It previously stamped every fallback rowresult: 'FAILED'— so a run that genuinely SUCCEEDED but whoseindex.jsonwrite lost the last-writer-wins race showed asFAILED. The fallback now reads each run's JSONL and derives the true terminal result (command / cdkd version / timestamps / event count too) from the run's lastRUN_FINISHEDevent; a run with no terminalRUN_FINISHED(interrupted, or index write lost) reports the newresult: 'UNKNOWN'(added to aDeploymentRunSummaryResult = DeploymentRunResult | 'UNKNOWN'type used only on the summary; the run-level emitters still only ever produceSUCCEEDED/FAILED) and is colored neutrally in the run listing rather than red.- Run-level bracket extracted to
src/cli/commands/deployment-events-run.ts(startRunRecorder/recordRunSucceeded/recordRunFailed) so theRUN_STARTED/RUN_FINISHED+--dry-run-skips-recorder +extractDeploymentEventError-on-failure contract is directly unit-testable and shared by bothdeploy.tsanddestroy.ts(behavior identical to the prior inline code). - Added tests:
tests/unit/types/deployment-events.test.ts(extractDeploymentEventErrordeepest-AWS-shaped-error extraction, bounded-depth-10 + cyclic-chain guard, non-Error inputs),tests/unit/cli/destroy-runner-events.test.ts(destroy-runnerRESOURCE_STARTED/SUCCEEDED/FAILED+RESOURCE_RETAINEDfor aDeletionPolicy: Retainskip + no-recorder back-compat),tests/unit/cli/deployment-events-run.test.ts(run-level bracket: dry-run = no recorder,RUN_STARTEDat create, successRUN_FINISHEDwith counts, failureRUN_FINISHEDwithresult: 'FAILED'+ error metadata, no-properties-leak), aROLLBACK_RESOURCE_FAILEDcase indeployment-events-emission.test.ts, the no-FAILED-fabrication +UNKNOWN-on-torn cases indeployment-events-store.test.ts, and alistRawKeysmulti-pageContinuationTokenpagination case intests/unit/state/s3-state-backend.test.ts.
- Integration coverage (follow-up PR, refs #808): new
tests/integration/deployment-events/fixture — a tinyCdkdDeploymentEventsExamplestack (anAWS::SNS::Topic+ anAWS::SSM::Parameter, no VPC / NAT, deploy+destroy in well under a minute) with averify.shthat exercises the feature end-to-end against real AWS: deploy writes thedeployments/{runId}.jsonl+index.jsonsidecar;cdkd events <stack> --stack-region <r>lists adeployrun asSUCCEEDED;--format jsonis valid JSON carryingRUN_STARTED/RUN_FINISHED+ at least oneRESOURCE_*event for the topic / parameter; the SSM parameter's marker value (events-integ-secret-value) does NOT appear anywhere in the events JSON (the no-secrets guarantee); aftercdkd destroy --force,state.jsonis gone but thedeployments/sidecar survives (now carrying the destroy run too) andcdkd eventslists BOTH a deploy and a destroy run; the fixture removes the events sidecar at the end (and on the failure path via an EXIT trap). New scenario tagdeployment-events.
- Event types:
LockManagerresolves the state bucket's actual region before lock operations (issue #803) —src/state/lock-manager.ts. PR #60 taughtS3StateBackendto resolve a cross-region state bucket's real region viaGetBucketLocationand rebuild its S3 client, butLockManagerwas left out: it kept using the raw client pinned to the CLI's base region (AWS_REGION/ fallbackus-east-1), so against a bucket in another region every state read/write succeeded while every lock acquisition failed with S3's 301 PermanentRedirect ("must be addressed using the specified endpoint") — contradicting the documented "the state bucket can live in any AWS region" guarantee.LockManagernow has its ownensureClientForBucket()(awaited at the top ofacquireLock/getLockInfo/releaseLock/deleteLock) mirroring the state backend's pattern with two deliberate differences: the replacement client reuses the original client's resolved credentials provider (so--profile/ static credentials carry over without threading client options through the 8new LockManager(...)call sites), and the original client is NOT destroyed (it is the sharedAwsClients.s3instance other components still hold).resolveBucketRegioncaches per bucket name, so when the state backend already resolved the same bucket the lock path adds no extraGetBucketLocationcall. The fix is contained entirely insideLockManager— none of the 8 call sites changed. Unit tests: region-mismatch rebuild (pre-fix 301 path — the PutObject goes through the rebuilt client), same-region no-rebuild, single resolution across multiple lock ops, and resolver receives the caller's credentials + fallback region. Thecross-region-state-bucketinteg fixture is now AUTOMATED: its newverify.shcreates a temporary uniquely-named state bucket inus-west-2, runs deploy / state ls / destroy withAWS_REGION=us-east-1, asserts state.json written + lock.json released in the cross-region bucket, and deletes the bucket at the end (EXIT trap covers failure paths) — previously the fixture was manual-only and its 2026-06-02 ledger PASS ran against the default same-region bucket, never exercising the scenario it is named after. Docs:state-management.md(State Bucket Region + Lock Mechanism),troubleshooting.md(lock-path 301 symptom + fix note).
Recently Implemented (2026-06-10):
-
AWS::EC2::Instancesecurity-prop backfill: DisableApiTermination / MetadataOptions / Monitoring / EbsOptimized / CreditSpecification (issue #609) —src/provisioning/providers/ec2-provider.ts. Five security-focused properties that were silent-dropped pre-PR are now wired throughEC2Provider'screate()+update()+readCurrentState()and added tohandledPropertiesforAWS::EC2::Instance(the type stays open for the remaining ~26 props intests/fixtures/cfn-schemas/_todo-backfill.json). All five are mutable in-place, so each has anupdate()path diffed againstpreviousProperties(thecdkd drift --revertno-op round-trip stays free of mutating SDK calls):- DisableApiTermination — termination protection (pre-PR a silent-drop let a user believe the instance was protected when it was not). Rides on
RunInstancesat create;ModifyInstanceAttributeon update; readback via the existingDescribeInstanceAttribute(disableApiTermination)call. The destroy-side flip-off already lived inec2-termination-protection.ts. - MetadataOptions — IMDSv2 enforcement (
HttpTokens=required) mitigates SSRF credential theft.RunInstancesat create;ModifyInstanceMetadataOptionson update; reverse-mapped fromDescribeInstances .MetadataOptionson readback, excluding the AWS-managedStatefield to avoid false-positive drift. - Monitoring — detailed CloudWatch monitoring.
RunInstances{ Enabled }at create;MonitorInstances/UnmonitorInstanceson update; readback already mapped.Monitoring.Stateto a boolean. - EbsOptimized — dedicated EBS throughput.
RunInstancesat create;ModifyInstanceAttributeon update; readback emit-when-present. - CreditSpecification — T-family burstable CPU credit mode.
RunInstancesat create;ModifyInstanceCreditSpecificationon update; readback viaDescribeInstanceCreditSpecifications(best-effort: non-burstable families error and fall back to omitting the key). Accepts the canonical CFnCPUCreditskey and the SDK-styleCpuCreditskey. - CFn boolean-ish (
true/"true") and numeric (HttpPutResponseHopLimit) values are coerced at the wire boundary. Theec2-instanceinteg fixture is rewritten to author the instance as a raw L1ec2.CfnInstance: the L2ec2.Instanceconstruct always emits anAvailabilityZoneproperty (a cdkd silent-drop) which under the #614 routing rule flips the whole resource onto the Cloud Control path, bypassing the SDK backfill this slice verifies. The newverify.shasserts each prop reached AWS post-deploy and thatprovisionedBystayedsdk, then exercises the destroy path with--remove-protection(the instance is termination-protected). Tests: 14 create/update unit tests + 5 readback unit tests.
- DisableApiTermination — termination protection (pre-PR a silent-drop let a user believe the instance was protected when it was not). Rides on
Recently Implemented (2026-06-09):
Property-coverage backfill (issue #609): wired 6 top-level properties on
AWS::EFS::FileSystemin one bundle —AvailabilityZoneName,LifecyclePolicies,BackupPolicy,FileSystemPolicy,BypassPolicyLockoutSafetyCheck, andFileSystemProtection— all previously silent-dropped byEFSProvider. One prop is deferred asunhandledByDesign:ReplicationConfiguration(cross-region EFS replication provisions a separate destination file system in another region with its own lifecycle / KMS key / AZ placement — a multi-resource, cross-region orchestration out of scope for the single-resource SDK provider; tracked as a #609 follow-up). With this slice,AWS::EFS::FileSystem's remainingsilentDropset is exactly{ ReplicationConfiguration }.AvailabilityZoneName(One Zone EFS) rides DIRECTLY onCreateFileSystemand is immutable —create()forwards it; a later change is routed through DELETE+CREATE by the replacement-detection layer (it is inupdateFileSystem's immutable-key reject guard alongsideEncrypted/KmsKeyId/PerformanceMode).readCurrentStatesurfaces it fromDescribeFileSystems.LifecyclePolicies/BackupPolicy/FileSystemPolicy(+BypassPolicyLockoutSafetyCheck) /FileSystemProtectioneach ride on a separate post-create control-plane API (PutLifecycleConfiguration/PutBackupPolicy/PutFileSystemPolicy/UpdateFileSystemProtection) — AWS rejects all four against a still-creating file system, so they run AFTER the create-timeavailablewait. They are wrapped in a newretryOnTransientControlPlanehelper (modeled on the DynamoDB provider's PITR/TTL retry) because back-to-back EFS control-plane ops collide withIncorrectFileSystemLifeCycleState/ConflictException/ "in progress".create()is atomic: a post-ACTIVE step failure best-effortDeleteFileSystems the just-created file system (modeled onDynamoDBTableProvider.create'stableCreatedrollback) so a half-built file system does not orphan + block the next deploy'sCreationToken.FileSystemPolicycasing/shape: the CFn property is a JSON policy object but the SDK'sPutFileSystemPolicy.Policyfield is a JSON string, so the providerJSON.stringifys an object value;readCurrentStateJSON.parses theDescribeFileSystemPolicy.Policystring back to an object so the drift comparator compares object-to-object.BypassPolicyLockoutSafetyCheckis a field ONPutFileSystemPolicy(not a standalone resource on AWS), so it wires together withFileSystemPolicy.update()applies each control-plane prop only onJSON.stringify-deep diff; aLifecyclePoliciesremoval clears all policies viaPutLifecycleConfiguration([]);BackupPolicy/FileSystemPolicy/FileSystemProtectionhave no clean CFn "drop" mapping so a pure removal is a deliberate no-op.readCurrentStateis emit-when-present for every prop (a phantom default would force guaranteed drift on the typical un-configured file system).- The 6 props move from
silentDroptohandledinproperty-coverage.generated.ts(regenerated viavp run gen:property-coverage). 15 new unit tests intests/unit/provisioning/providers/efs-provider.test.tscover the create-input ride (AvailabilityZoneName), each post-ACTIVE Put*/Update* apply, theJSON.stringifypolicy + Bypass forwarding, the transient-control-plane retry, the post-ACTIVE-failure rollback, update diffs (BackupPolicy apply, LifecyclePolicies removal-clears, no-op), and readback (all props surfaced + FileSystemPolicy JSON round-trip + PolicyNotFound omission). Real-AWS verified via the existingtests/integration/efs-standalone/fixture — its L2efs.FileSystemgainslifecyclePolicy/enableAutomaticBackups/replicationOverwriteProtection/fileSystemPolicy, and a NEWverify.shdeploys, asserts all four reached AWS (describe-backup-policy,describe-lifecycle-configuration,describe-file-systemsforFileSystemProtection,describe-file-system-policy), then destroys clean.
cdkd local invoke/run-taskreach a server on the host viahost.docker.internal+ start-service/start-alb WARN dedup follows (issues #784 / #785 / #786 / #787) — bumpscdk-local^0.142.0->^0.147.0. The bump auto-inherits the start-service/start-alb fixes; #784 needed cdkd source work because cdkd keeps its OWNinvoke/run-taskcommand paths (it does NOT embed cdk-local'sinvoke/run-taskfactories).- #784 (cdk-local #483) —
host.docker.internalreachability oninvoke/run-task— REQUIRED cdkd code. A Lambda / ECS task container can now reach a server bound on the host loopback (anAWS_ENDPOINT_URL_*local endpoint, or a tunneled VPC resource) viahost.docker.internal. Docker Desktop resolves it natively; Linux native dockerd needs the explicit--add-host host.docker.internal:host-gateway(Docker 20.10+), silently skipped on an older / unavailable daemon (never throws). cdkd adopts cdk-local'sresolveHostGatewayExtraHosts()(re-exported viasrc/local/docker-version.tsalongsideHOST_DOCKER_INTERNAL_GATEWAY) intocdkd local invoke(threaded intorunDetached'sextraHosts) andcdkd local run-task(set onRunEcsTaskOptions.hostGatewayExtraHosts, merged with the Cloud Map peer-discovery--add-hostflags by the new puremergeHostGatewayAddHostFlagshelper inecs-task-runner.ts).start-service/start-albinherit the same reachability automatically from cdk-local's bundled ECS service emulator engine (cdkd'secs-service-emulator.tsis a re-export shim — no local resolve site). Tests: amergeHostGatewayAddHostFlagsunit suite + a source-level binding test (tests/unit/cli/host-gateway-extra-hosts-binding.test.ts) pinning the resolve + thread at each cdkd-owned run site — the reachability only differs on Linux, so a Docker-Desktop integ cannot catch a dropped wiring (per memoryfeedback_site_level_binding_test.md). Docs:local-emulation.md"Reaching a server on the host" note. - #785 (cdk-local #485) — start-service/start-alb same-stack-ECR boot WARN fires once, not twice — AUTO-inherited. cdkd's
start-service/start-albconsume cdk-local's ECS service emulator engine, so the dedup lands with the bump; no cdkd source change. - #786 (cdk-local #488) — start-service/start-alb listener
WARN: WARN:doubled-prefix collapses to one — AUTO-inherited. Same engine-inheritance path as #785; no cdkd source change. - #787 (cdk-local #490) — studio
pinUnresolvedbrowser hint — N/A for cdkd. cdkd does not embed cdk-local'sstudiocommand, so thiscreateLocalStudioCommand-only change has no cdkd surface. - The remaining cdk-local 0.143.x-0.147.0 commits (logger
WARN:/ERROR:prefix [#478] surfaces only through inherited-command warn output; studio readability passes; test-infra reverts) carry no cdkd-owned behavior change. Verified end-to-end via/run-integ local-invoke+/run-integ local-run-taskagainst real Docker (the host-gateway mapping is added on a host-gateway-capable daemon and the containers run cleanly).
- #784 (cdk-local #483) —
cdkd local start-cloudfrontWARNs when--cache-originis set without--from-cfn-stack(issue #782) — bumpscdk-local^0.140.0->^0.142.0.cdkd local start-cloudfrontis a THIN pass-through to cdk-local'screateLocalStartCloudFrontCommandfactory, so cdk-local's #476 is inherited with no cdkd source-logic change — only the dep bump + thelocal-emulation.md--cache-origindoc line were updated. Behavior delta (cat 4 in #782):start-cloudfront ... --cache-originwith no--from-cfn-stackwas previously a fully silent no-op (--cache-originonly feeds the deployed-S3 read-through reader, which is built solely under--from-cfn-stack); it now logs one boot-time WARN (--cache-origin has no effect without --from-cfn-stack: ...). Non-fatal — no error, no exit-code change. cdkd does not post-process / match on start-cloudfront's stderr (thelocal-start-cloudfrontinteg verify.sh greps only the boot banner + specific GET response bodies/headers, and uses neither--cache-originnor--from-cfn-stack), so the new WARN never fires in the fixture and no test change was needed. The cdk-local 0.141.0 studio change (#472, auto-render editable controls) is irrelevant to cdkd (cdkd does not embed cdk-local'sstudiocommand); the rest of the 0.141.0 / 0.142.0 commits are test / docs / chore.cdkd local start-cloudfront --kvs-fileaccepts a construct path / bare construct id (issue #780) — bumpscdk-local^0.139.0->^0.140.0.cdkd local start-cloudfrontis a THIN pass-through to cdk-local'screateLocalStartCloudFrontCommandfactory, so cdk-local's #467 is inherited with no cdkd source-logic change — only the dep bump + thelocal-emulation.md--kvs-filedoc line were updated. Behavior delta (cat 4 in #780): the<key>left-hand side of--kvs-file <key>=<file.json>previously HAD to be the hash-suffixedAWS::CloudFront::KeyValueStoreresource logical id, and an unrecognized key was silently ignored (the store stayed unbound and thecf.kvs()read failed at runtime). It now accepts the logical id, the construct path (MyStack/RoutesKvs), or the bare construct id (RoutesKvs) — normalized to the logical id before binding — and an unrecognized key (or an ambiguous bare id) now fails FAST with an error listing the distribution's KeyValueStore candidates. cdk-local also exportsnormalizeKvsFileKeysfromcdk-local/internal(cat 3 in #780) for a host building its own--kvs-fileflow; cdkd just wraps the command, so it does NOT consume it. The studio fixes in cdk-local 0.139.1 / 0.139.2 / 0.139.3 are irrelevant to cdkd (cdkd does not embed cdk-local'sstudiocommand). cdkd's unit test asserts only that--kvs-fileis a registered option (no assertion on the old silent-ignore behavior), so no test change was needed; thelocal-start-cloudfrontinteg fixture does not exercise--kvs-file(no KeyValueStore in the distribution).cdkd local start-agentcorefollows cdk-local #454 (warm serve generalization) + #455 (CodeConfiguration build no-install) — issues #774 / #775 / #776 / #777 / #778 — bumpscdk-local^0.128.0->^0.139.0.cdkd local start-agentcoreis a THIN pass-through to cdk-local'screateLocalStartAgentCoreCommandfactory and everysrc/local/agentcore-*.tsmodule is a re-export shim overcdk-local/internal, so the entire serve generalization is inherited with no cdkd source-logic change — only the command's doc comment + the user docs (local-emulation.md/README.md) and thelocal-start-agentcoreinteg verify.sh were updated. Behavior deltas inherited (verified end-to-end via/run-integ local-start-agentcoreagainst real Docker):- #775 (slice 1, cdk-local#458) — warm HTTP serve. The container boots once and stays warm; HTTP / AGUI runtimes now serve
POST /invocations+GET /ping(proxied to the warm container, session-id / boot-resolvedAuthorizationinjected, request/response incl. SSE streamed) alongside the/wsbridge, both on the same host port. A newHTTP contract served on http://...ready line is printed; the existingServer listening on ws://...line is kept verbatim. (Was:/ws-only.) - #776 (slice 2, cdk-local#459) — MCP + A2A warm serve. MCP runtimes serve
POST /mcp(container port 8000), A2A servePOST /(port 9000), with no/wsbridge. (Was: rejected up front withLOCAL_START_AGENTCORE_PROTOCOL_UNSUPPORTED.) cdkd never special-cased the old rejection, so nothing to drop. - #777 (slice 4a, cdk-local#461) — per-request inbound JWT +
--sigv4. AcustomJwtAuthorizerruntime now boots without a token and verifies each contract request'sAuthorizationper request (401 missing / 403 invalid / forwarded on pass;GET /pingunauthenticated);--bearer-tokenis the default-when-missing fallback.--sigv4(new flag, auto-inherited viaaddStartAgentCoreSpecificOptions) signs each forwarded request with AWS SigV4 (servicebedrock-agentcore) when nocustomJwtAuthorizeris declared; mutually exclusive with--bearer-token. (Was: boot-time--bearer-tokenvalidation, rejected at boot if missing.) - #778 (slice 4b, cdk-local#462) —
--watch. New flag (auto-inherited) re-synths + reloads the warm container in place on a CDK source change, keeping the host serve up (per-firing rebuild / soft-reload classifier, the same machinery asinvoke-agentcore --ws --watch). (Was: ran until^Cwith no reload.) - #774 (cat 4, cdk-local#455 / cdk-local#456) —
CodeConfigurationbuilds no longer install deps. ThefromCodeAsset/fromS3source build (buildAgentCoreCodeImage, shared by bothinvoke-agentcoreandstart-agentcore) now runs the bundle as-is — nopip install/npm install— matching the AWS managed runtime, which resolves deps vendored into the bundle at deploy time. A bundle declaring a dependency manifest without vendored deps now fails locally withModuleNotFoundErrorthe same way it fails deployed (instead of passing locally only because of the local install), and cdkd emits a warning with the vendoring recipe. Container artifacts (fromContainerAsset/fromEcr) are unaffected. - Tests:
tests/integration/local-start-agentcore/verify.shextended to probe the new warm HTTP contract (theHTTP contract served on http://...ready line,GET /ping-> 200,POST /invocationsecho round-trip with the bridge-injected session-id) and a second--sigv4boot asserting the forwarded request carries anAWS4-HMAC-SHA256Authorizationheader — on top of the existing header-less/wsbridge probe. No unit-test change (all behavior is upstream-owned + upstream-tested; cdkd's surface is the unchanged factory pass-through).tests/unit/cli/local-start-agentcore.test.tscontinues to assert the cdkd--from-state/--state-bucket/--state-prefixflags + the inherited option block.
- #775 (slice 1, cdk-local#458) — warm HTTP serve. The container boots once and stays warm; HTTP / AGUI runtimes now serve
Recently Implemented (2026-06-05):
cdkd local invoke/cdkd local start-apipin a ZIP Lambda's--platformto its declaredArchitectures(issue #768) — follows cdk-local's #428 for cdkd's OWN local-execution paths. Before: cdkd's ZIP container run never set--platform(only the IMAGE path did —lambda-resolver.tscapturedArchitecturesonly on the IMAGE variant), so a ZIP container ran at the host's native arch; aprovided.*custom-runtimebootstrapcompiled for the other architecture failed withfork/exec /var/runtime/bootstrap: exec format error/Runtime.InvalidEntrypointon an arch-mismatched host. After:ResolvedZipLambda/ResolvedStartApiZipLambdacarryarchitecture(parsed by a sharedextractArchitecture/extractStartApiArchitecturehelper, defaultx86_64,arm64honored, unsupported values rejected), and both thecdkd local invokeZIP plan (resolveZipImagePlan) and thecdkd local start-apiwarm-container spec threadarchitectureToPlatform(architecture)todocker run --platform, so Docker emulates the function's declared arch. This was NOT auto-inherited from the cdk-local bump — cdkd does not embed cdk-local'sinvoke/start-apifactories; those paths are cdkd-local code (lambda-resolver.ts/local-invoke.ts/local-start-api.ts). (cdkd local start-alb/start-cloudfrontuse cdk-local's engine / factory and already inherited #428 via the pinned cdk-local 0.126.6.) Tests: ZIP-arch capture (lambda-resolver.test.tsarm64 / default-x86_64 / reject;local-start-api-container.test.tssame for the start-api resolver) + the ZIP plan--platformthreading (local-invoke-zip-platform.test.ts). Verified end-to-end via/run-integ local-invoke-provided.cdkd local start-cloudfront --from-state— closes the start-cloudfront half of issue #766 — bumpscdk-local^0.126.6->^0.128.0and threads cdkd's S3-backed--from-statefactory into thestart-cloudfrontpass-through, mirroringstart-agentcore/start-alb/start-service. cdk-local 0.128.0 (go-to-k/cdk-local#426 / #436) added theextraStateProvidersseam toCreateLocalStartCloudFrontCommandOptions(the factory now passes it through to its two internalcreateLocalStateProvidercalls — the KVS resolver + the S3-origin/Function-URL resolver), whichstart-cloudfrontpreviously lacked (the reason it shipped--from-state-exempt in the start-agentcore PR #767).src/cli/commands/local-start-cloudfront.tsnow passes{ embedConfig, extraStateProviders: cdkdExtraStateProviders }to the factory and adds the cdkd-specific--from-state/--state-bucket/--state-prefixflags on top of cdk-local's inherited--from-cfn-stack/--stack-region/--assume-role. So a CloudFront distribution's Lambda Function URL origin (backing Lambda) and deployed-S3 origin (bucket name) can be bound to cdkd-managed state after acdkd deploy, not only to a CloudFormation stack. The two state sources stay mutually exclusive (enforced by cdk-local'screateLocalStateProvider). Tests:tests/unit/cli/local-start-cloudfront.test.tsflips its "exempt from #766" assertions to assert the three cdkd flags are present + defaulted (--from-statefalse,--state-prefixcdkd); the dispatcher-wiring comment now groupsstart-cloudfrontwith the other three factory-seam pass-throughs. Docs (local-emulation.md/README.md/.claude/rules/code-layout.md) drop the--from-state-exemption language. Verified via thelocal-start-cloudfrontinteg (the command boots + serves cleanly on cdk-local 0.128.0; the--from-statesubstitution path is the shared cdk-local mechanism already real-AWS-verified bylocal-start-alb-from-state). No cdkd source change beyond the wrapper + the dep bump.Bump
cdk-local^0.126.0->^0.126.6so the factory pass-throughlocalcommands read aws-cdk-lib 2.258.0 (cloud-assembly schema v54) — aws-cdk-lib2.258.0(released 2026-06-04) bumped the cloud-assembly schema to v54. The cdk-local-factory-basedlocalcommands (start-agentcore/start-cloudfront/start-alb/start-service) synth through cdk-local's toolkit-lib-basedSynthesizer, which (via@aws-cdk/toolkit-lib@1.26.2->cloud-assembly-schema@53.27.0, max v53) rejected v54 withAssemblyVersionMismatch: Maximum schema version supported is 53.x.x, but found 54.0.0. cdkd's owndeploy/synthand the cdkd-implementedlocalcommands (invoke/start-api/run-task/invoke-agentcore) were never affected — cdkd's coreSynthesizeris self-implemented (readsmanifest.jsondirectly with no schema validation) and tolerates v54 (verified by synthesizing a 2.258.0 app throughcdkd synth). The fix is upstream in cdk-local (go-to-k/cdk-local#430 / #431, released as cdk-local 0.126.6): bump@aws-cdk/toolkit-libto^1.28.0(cloud-assembly-schema >=54.2.0) + align@aws-cdk/cloud-assembly-apito^2.2.5. cdkd inherits it by bumping thecdk-localfloor to^0.126.6(brings@aws-cdk/toolkit-lib@1.28.0into cdkd's tree; cdkd'smanifest.json-direct reader needs no cloud-assembly-api dedup of its own). Thetests/integration/local-start-agentcore/fixture's interimaws-cdk-libpin (~2.257.0, added in the start-agentcore PR to dodge the v54 break) is relaxed back to^2.257.0so it floats to current aws-cdk-lib — verified end-to-end: the fixture now resolves aws-cdk-lib 2.258.0 andcdkd local start-agentcoreserves/wsthrough the bridge cleanly (/run-integ local-start-agentcore, 0 container leaks). No cdkd source change — a dependency bump + fixture unpin.cdkd local start-agentcore+--from-statefor the factory pass-throughs (issues #765 / #766) — bumpscdk-local^0.106.0->^0.126.0and adds the long-running serve counterpart ofcdkd local invoke-agentcore.cdkd local start-agentcore [target]boots the Bedrock AgentCore Runtime container (same image / env / credential resolution asinvoke-agentcore) and fronts its bidirectional/wsWebSocket endpoint with a host WebSocket bridge that injects the AgentCore session-id (and, under acustomJwtAuthorizer, theAuthorizationheader) on the container upgrade — so a header-less client (e.g. a browserWebSocket, which cannot set custom upgrade headers) can hold an interactive multi-frame session. HTTP / AGUI protocols only (MCP / A2A runtimes have no/ws). Newsrc/cli/commands/local-start-agentcore.tsis a THIN pass-through to cdk-local'screateLocalStartAgentCoreCommandfactory (cdk-local#420, released in cdk-local 0.125.0); cdkd re-hands the active embed config (so branding stays cdkd) and — UNLIKEstart-cloudfront— threads its S3-backed--from-statefactory through the factory'sextraStateProvidersseam, layering the cdkd-specific--from-state/--state-bucket/--state-prefixflags on top of cdk-local's inherited--from-cfn-stack/--stack-region. Registered increateLocalCommand()betweeninvoke-agentcoreandstart-alb. The agentcore-specific option block (--port/--host/--session-id/--bearer-token/--no-verify-auth/--env-vars/--platform/--no-pull/--no-build/--container-host/--timeout/--assume-role/--ecr-role-arn) auto-inherits from cdk-local'saddStartAgentCoreSpecificOptions. The studioagentcore-wsserve kind (cdk-local 0.126.0 / cdk-local#422) spawnscdkl start-agentcore, but cdkd does NOT embed cdk-local'sstudiocommand, so that surface is not exposed by the cdkd CLI (no cdkd-side wiring needed). Tests:tests/unit/cli/local-start-agentcore.test.ts(subcommand name, optional single positional target, inherited agentcore + CFn state-source flags, the cdkd--from-state/--state-bucket/--state-prefixdeclarations + defaults, flag parsing). New integ fixturetests/integration/local-start-agentcore/(adapted from cdk-local's): builds the EchoAgent container from a local Dockerfile, bootscdkd local start-agentcore --port 0, connects a header-less Node global-WebSocketprobe (browser path), asserts the bridge injects a session-id + a second frame round-trips through the bridge (loop-echo:<text>), then SIGTERMs and asserts nocdkd-local-agentcore-*container leaks. Verified end-to-end via/run-integ local-start-agentcore.cdkd local start-cloudfrontgains Lambda Function URL + deployed-S3 origins (inherited from the cdk-local bump, cdk-local#380);--from-statestays exempt (#766) — the^0.106.0->^0.126.0cdk-local bump changes the thin-pass-throughstart-cloudfront's surface: it now serves a distribution's Lambda Function URL origins (the backing Lambda runs locally via the RIE container, so Docker is required for that case) and its deployed-S3 origins, and so inherits cdk-local's--from-cfn-stack/--stack-region/--assume-rolestate-source flags +--kvs-file/--cache-origin/--no-pull. It is no longer "pure-local, no AWS call" — a CloudFront-Functions + S3-origin-only distribution still serves fully in-process (no Docker), but a Function-URL-fronted distribution does not. cdkd does NOT wire its S3-backed--from-stateintostart-cloudfront: cdk-local'sCreateLocalStartCloudFrontCommandOptionsaccepts onlyembedConfig, not theextraStateProvidersseam, sostart-cloudfrontstays exempt from #766 until cdk-local exposes that seam (decided with the user; thestart-agentcore/start-alb/start-servicepass-throughs DO thread--from-state). The command's doc comment +tests/unit/cli/local-start-cloudfront.test.tswere updated to the new contract (asserts the inherited CFn flags are present AND cdkd's--from-state/--state-bucket/--state-prefixare absent), and thelocal-emulation.md/README.md/cli-reference.md"no Docker / no state binding" claims were corrected. No other cdkd-owned surface broke on the 20-version bump (typecheck + full unit suite clean).
Recently Implemented (2026-06-02):
destroy --remove-protectionclears EC2DisableApiTerminationon BOTH the SDK and Cloud Control delete paths, retrying through the flip-off propagation race — fixes a realdestroy --remove-protectionfailure surfaced by theremove-protectioninteg. cdkd flipsDisableApiTerminationoff (ModifyInstanceAttribute) and then deletes the instance, but AWS's modify WRITE lags the delete READ, so the delete 400s withThe instance ... may not be terminated. Modify its 'disableApiTermination' instance attribute and try again.even though cdkd just cleared it (empirically: a manualmodify-instance-attribute --no-disable-api-terminationreports success anddescribe-instance-attributereadstruefor ~25s, yet aterminate-instancesimmediately after succeeds — the attribute READ is eventually consistent). cdkd's fast SDK path outruns the propagation window, exactly like the IAM / Route53 races elsewhere. Crucially, anAWS::EC2::Instanceis frequently routed through Cloud Control (its template trips the #614 silent-drop routing — confirmed viaprovisionedBy: cc-apiin the integ's state), andCloudControlProvider.deletehad NODisableApiTerminationhandling at all — so the original SDK-onlyEC2Provider.deleteInstanceflip-off never ran for the integ's instance. The fix adds a sharedsrc/provisioning/ec2-termination-protection.tshelper (disableInstanceApiTermination+isTerminationProtectionPropagationError+TERMINATION_PROTECTION_MAX_ATTEMPTS) used by BOTHEC2Provider.deleteInstance(SDK path) andCloudControlProvider.delete(CC-API path): whencontext.removeProtection === trueand the type isAWS::EC2::Instance, flip the attribute off, then retry the delete up to 5 times with increasing backoff (re-flipping each attempt) to close the propagation window. The 400 is deliberately NOT in the generic retryable set — a protected instance destroyed WITHOUT--remove-protectionmust fail fast so the user is told to pass the flag — so the retry is gated onremoveProtection === true. Without this, the un-terminable instance blocked the entire VPC teardown (the IGW / VPCGatewayAttachment then hit their own 6m/30m delete timeouts). Tests:tests/unit/provisioning/ec2-provider-instance-protection-retry.test.ts(SDK path: retry-then-succeed re-flips each attempt; no---remove-protectionfails fast with no flip-off; non-protection error fails fast; gives up after the 5-attempt budget) +tests/unit/provisioning/ec2-termination-protection.test.ts(the shared helper's flip-off send + error-classification). Verified end-to-end via theremove-protectioninteg (whose instance is CC-API-routed).CLI exits cleanly when a downstream consumer closes stdout/stderr early (EPIPE) — piping any cdkd command into a reader that stops reading (
cdkd state list | grep -q foo,... | head,... | lessthenq) closes the pipe while cdkd is still writing; Node then emitted an unhandled'error'(EPIPE) on the stream and the process crashed with a stack trace + non-zero exit. That is normal Unix behavior for the consumer to stop reading, so the CLI must treat it as success. NewinstallPipeCloseHandler()(src/cli/pipe-close-handler.ts, called once at the top ofmain()insrc/cli/index.ts) attaches an'error'listener toprocess.stdout/process.stderrthatprocess.exit(0)s on EPIPE and re-throws every other (real) stream error unchanged. Surfaced by theremove-protectioninteg, whosecdkd state list | grep -q <stack>assertion crashed cdkd on EPIPE and the test misread the non-zero exit as a "state stripped despite destroy failing" failure (the state was actually preserved correctly —grep -qclosing the pipe after its first match was the real cause). Tests:tests/unit/cli/pipe-close-handler.test.ts(EPIPE → exit 0; non-EPIPE → re-throw; handler installed on every supplied stream). Verified end-to-end via theremove-protectioninteg.Route53::HostedZonedestroy waits through the*_HOSTED_ZONE_LOCKEDaccelerated-recovery transients instead of bailing — fixes a destroy failure + orphan surfaced by the 2026-06-02 regression sweep (fixtureroute53). A hosted zone deployed withHostedZoneFeatures.AcceleratedRecoveryStatus: 'ENABLED'must have the feature disabled beforeDeleteHostedZoneis accepted; the pre-delete guardensureAcceleratedRecoveryDisabledForDelete(insrc/provisioning/providers/route53-provider.ts) issuesUpdateHostedZoneFeatures(false)and pollsGetHostedZoneuntil the status settles toDISABLED. The bug: the enable/disable transition briefly surfacesENABLING_HOSTED_ZONE_LOCKED/DISABLING_HOSTED_ZONE_LOCKED(AWS momentarily locks the zone mid-transition), and these were lumped into theTERMINAL_FAILEDset alongside the genuinely-failedENABLE_FAILED/DISABLE_FAILED— so the destroy bailed withoperator must resolvethe moment it observed a lock transient, even though the zone settles toDISABLEDon its own within seconds (confirmed via manual cleanup: the real zone transitionedDISABLING → DISABLING_HOSTED_ZONE_LOCKED → DISABLED). Fix:TERMINAL_FAILEDnow contains ONLYENABLE_FAILED/DISABLE_FAILED; the*_HOSTED_ZONE_LOCKEDstates are treated as in-flight sub-states — the Phase-1 enabling-settle wait fires onENABLINGORENABLING_HOSTED_ZONE_LOCKED, the Phase-2 already-disabling skip fires onDISABLINGORDISABLING_HOSTED_ZONE_LOCKED, and thewaitForpoll loop polls through any lock transient like any other non-target status until it reachesENABLED/DISABLED(or the existing env-overridable timeout). Genuinely-failed states still hard-fail immediately with the manual-recovery pointer. Tests: two new cases intests/unit/provisioning/route53-provider.test.ts(waits throughDISABLING_HOSTED_ZONE_LOCKED→DISABLEDbeforeDeleteHostedZone; waits through an initialENABLING_HOSTED_ZONE_LOCKED→ENABLED→ disable →DISABLED). Real-AWS verified via theroute53integ: deploy enables accelerated recovery, destroy now disables + waits through the lock transients + deletes clean (was a hard FAIL + manual cleanup before).AWS::SSM::Parameterdeploy no longer crashes onTags(CFn SSM Tags is a key->value MAP, not a list) — fixes a hard deploy failure surfaced by the 2026-06-02 regression sweep (fixturescontext-testANDinfra-security, both never-run integs until this session). Any SSM Parameter with tags failed to create withFailed to create SSM parameter <id>: properties.Tags.map is not a function. Root cause: unlike almost every other CFn resource (whoseTagsis a[{Key,Value}]list),AWS::SSM::Parameter.Tagsis a key->value map ({ "Env": "prod" }) — CDK synthesizes the map form, andSSMParameterProvider.create()/update()didproperties['Tags'].map(...), which throws because an object has no.map. The bug was never caught because the provider's unit tests + the only SSM-with-tags integ fixtures used the (wrong) list shape, andcontext-test/infra-securityhad never been run as integs. Fix: acfnTagsToSdkTags()helper normalizes the CFn value into the SDK{Key,Value}[]shape, accepting BOTH the map (canonical) and the list (defensive), coercing non-string values to strings (SSM tag values must be strings), and droppingaws:-prefixed reserved keys;create()/update()route through it.readCurrentState()now ALSO emitsTagsas the map shape (matching the template shape cdkd stores in state) instead of the{Key,Value}[]list — an array readback would false-positivecdkd drifton every clean run for a tagged parameter (state map vs observed list never compare equal). Tests: newtests/unit/provisioning/ssm-parameter-provider-tags-map.test.ts(create accepts the map shape + applies it as SDKTag[]; defensive list-shape still works;aws:*keys dropped; empty map fires noAddTags; non-string values coerced; update diffs map shapes for add/remove; unchanged map is a no-op) + updatedssm-parameter-provider-readcurrentstate.test.tsassertions to the map shape. Real-AWS verified via thecontext-testinteg (deploy+destroy clean). The list-shape create input the existing partial-create-cleanup / roundtrip tests use is still accepted, so the (incorrect-but-tolerated) list form does not regress.Custom Resources retry on transient IAM-authorization failures (CR-internal retry + exec-env recycle) — fixes a hard deploy failure surfaced by the 2026-06-02 regression sweep (fixture
custom-resource-provider). A CDKcr.Provider-framework custom resource failed to create with403 lambda:GetFunction ... no identity-based policy allowseven though the framework role's inline policy (which DOES grant it — present since aws-cdkv2.178.1/ aws-cdk#26838) was deployed byte-correct. Root cause is NOT a missing permission or a statement-drop: cdkd's fast SDK path attaches the role's inline policy and creates+invokes the backing Lambda ~0.7s later, so the function cold-starts before IAM propagates the policy to its assumed-role session and caches stale, policy-less credentials for the warm container's whole life — the framework's first invoke /waitUntilFunctionActivethen 403s. CloudFormation never hits this because its deployment latency lets IAM settle (confirmed: theoutbound.jsframework runtime is byte-identical across 2.250.0 -> 2.257.0, so a version bump does NOT fix it;SimulatePrincipalPolicyis NOT a valid signal either — it reportsallowedwhile the live assumed-role session still 403s, because IAM's policy-evaluation store and STS credential vending propagate independently). Fix:invokeCustomResourceWithRetry()insrc/provisioning/providers/custom-resource-provider.tsre-invokes (default 2 retries;CDKD_CR_AUTHZ_MAX_RETRIES, 0 disables) when the FAILED reason matches a NARROW IAM-authz signal set (CR_TRANSIENT_AUTHZ_SIGNALS:not authorized to perform/no identity-based policy allows/not in the state functionActive/cannot be assumed/is unable to assume— generic timeouts and handler bugs are deliberately NOT retried, so genuine failures still surface fast). Each retry derives a fresh pre-signed URL/RequestId (preserving thedisableOuterRetryinvariant that guards against stranding a response at an unpolled S3 key) AND recycles the backing function's execution environment via a no-opUpdateFunctionConfigurationso the next cold start re-assumes the role with the now-propagated policy (a plain re-invoke would reuse the same stale warm container). This is the CR-path analogue of the IAM-propagation retry cdkd'swithRetryalready applies to every other resource — the CR path opts out ofwithRetry(disableOuterRetry) so it retries internally instead. Tests:tests/unit/provisioning/custom-resource-provider-authz-retry.test.ts(retry-then-succeed, no-retry-on-generic-FAILED, give-up-after-max,=0disables, narrow classifier). Real-AWS verified via thecustom-resource-providerinteg: deploy 37s (attempt 1 -> 403 -> recycle -> attempt 2 created), destroy 17 deleted / 0 errors / 0 orphans (was a hard FAIL before).destroyretries the transient Lambda EventSourceMapping "in use" delete error — fixes a partial-destroy + orphan surfaced by the 2026-06-02 regression sweep (fixturemulti-resource). Deleting an SQS EventSourceMapping on destroy could fail withCannot delete the event source mapping because it is in use.— a transient AWS state-lifecycle lock during teardown that clears on its own (a manualcdkd destroyre-run succeeded). Root cause:runDestroyForStackinsrc/cli/commands/destroy-runner.tscarried its OWN inline 4-pattern retryable list (Too Many Requests/has dependencies/can't be deleted since/DependencyViolation) and did NOT use the sharedisRetryableTransientErrorclassifier, so the ESM in-use error matched nothing and failed fast. Fix routes the destroy retry decision throughisRetryableTransientError(plus an explicitToo Many Requestskeep, since 429$metadatacan be lost across theProvisioningErrorwrap) and adds thebecause it is in usemessage pattern tosrc/deployment/retryable-errors.ts— matched on the message substring (narrow to the transient delete case) rather than the bareResourceInUseExceptionname, which the SDK also throws for non-transient create conflicts. The provider'sdelete()is already wrapped in a retry loop on both destroy paths (deploy-engine + destroy-runner), so no provider source changed. Tests: a retryable ESM-in-use case + a NOT-retryableResourceNotFoundguard (no over-broadening) inretryable-errors.test.ts, and alambda-eventsource-providerdelete case (throw-in-use → wrappedProvisioningError→ classified retryable). Real-AWS verified:multi-resourcenow destroys clean in a singledestroyrun (previously needed a manual re-run).deploy --all/destroy --allorder stacks by cross-stack references (Fn::ImportValue/Fn::GetStackOutput), not just manifestaddDependency— fixes a real failure surfaced by the 2026-06-02 regression sweep. Previously--allordered stacks ONLY by the cloud-assembly manifest's declared dependencies (CDKaddDependency). A stack linked to another ONLY via a RAWcdk.Fn.importValue('<name>')/Fn::GetStackOutput(noaddDependency) created no manifest dependency, so under the default--stack-concurrency 4the consumer deployed before the producer and failed:deploy --allerroredFn::ImportValue: export 'X' not found/Fn::GetStackOutput: stack 'Y' not found, anddestroy --alldestroyed the producer before the consumer (StackHasActiveImportsError-> partial destroy + orphan). Newsrc/analyzer/cross-stack-deps.tsinferCrossStackStackDeps(stacks)derives consumer->producer edges from the synthesized templates (mapexportName -> producerStackfrom every stack'sOutputs[*].Export.Name; match literalFn::ImportValueexport names + read eachFn::GetStackOutput{StackName}target; edges only between stacks both in the set; non-literal / external / self refs ignored, so resolution of already-deployed external exports via the runtime index is unchanged).deploy.tsunions these withstack.dependencyNamesat both--allsites (auto-include walk + inter-stack DAG edges, in-set guard preserved);destroy.tsreverse-sorts (consumer before producer, exportedorderConsumersBeforeProducers, guarded to the synth path so the state-only fallback keeps original order). ManifestaddDependencybehavior is byte-unchanged when there are no raw cross-stack refs; runtime intrinsic resolution untouched. Unit tests:tests/unit/analyzer/cross-stack-deps.test.ts+tests/unit/cli/destroy-order-consumers.test.ts. Real-AWS verified:multi-stack-deps(Fn::ImportValue) +cross-stack-references(Fn::GetStackOutput) now deploy/destroy clean via--all(producer-first deploy, consumer-first destroy, 0 orphans) — both FAILED before. Known minor: a pathological mutual raw-import (A<->B, unbuildable on AWS) now surfaces as the WorkGraph generic "Deadlock detected" rather than a cross-stack-cycle-specific message.Integ-run ledger (
docs/_generated/integ-last-run.tsv) +/pick-integskill — a committed (NOT gitignored), update-type ledger records, one row per integration test, when it last ran (last_run_iso), itsresult(PASS/FAIL),duration_s,flow(verify.sh / standard), and a shortnote./run-integnow has a MANDATORY step 13 that writes the ledger on EVERY run (pass or fail) using a portable awk update (drop the test's old row + append the new one — NOTgrep -P, which is unavailable on macOS BSD grep, a trap hit while building this). Superseded 2026-07-20 by issue #1112: the drop-and-append shape duplicated rows under rebase (the replayed append landed on a base that already carried the row), so the recipe is now append-then-vp run integ-ledger-normalize, with CI enforcing the normalized shape. The ledger answers "has this integ run recently? / it hasn't run in months, it's risky to trust" without trawling CI history, and is the input to the new/pick-integskill: it ranks tests by staleness (older than the 14-day integ-gate TTL window), last result (FAIL / never-run), and the code areas a recent diff touches (a path→test heuristic table — cross-cutting deploy/destroy → BROAD set,src/provisioning/providers/<Svc>*→ that service's integ,src/local/**→local-*,src/state/**→ schema-migration + cross-stack, etc.), then prints a prioritized/run-integplan (P0 changed+stale, P1 changed+green, P2 hygiene). No new markgate gate was added — the mandatory/run-integstep plus the committed file (a PR that ran integ but skipped the ledger is visible in review) plus/pick-integtreating absent/old rows as stale make enforcement unnecessary. The ledger is seeded with a 2026-06-02 broad regression sweep of 35 tests (29 PASS) — see the sweep findings below.cdkd local invoke-agentcore --watch— re-synth + reload the agent container on CDK source edits, following cdk-local#270. cdk-local'srunAgentCoreWatchLoophard-couples to cdk-local's OWNSynthesizer/LocalInvokeAgentCoreOptionstypes (it lives inside cdk-local's own command), so it cannot be shimmed; instead cdkd owns a watch loop (src/local/invoke-agentcore-watch-loop.ts) built on cdk-local's already-exported watch primitives (createFileWatcher/createWatchPredicates/resolveWatchConfig/classifySourceChange+ theReloadVerdict/ReloadAssetContexttypes) — the SAME patterncdkd local start-api --watchuses. A per-firing classifier picks the reload primitive: an interpreted-language source edit inside aCodeConfigurationsource tree takes a soft-reload FAST PATH (docker cpthe freshly-synthed source into the running container's WORKDIR +docker restart, no rebuild, container ID + host port preserved), while a Dockerfile / compiled-source / asset-hash-changed / ambiguous edit (or afromS3/ non-CDK-asset runtime, or any classifier-context failure) forces a full rebuild (SIGTERM +docker rm -f+ re-resolve the image + freshdocker run).--watchapplies to BOTH the--wssession path (the active socket is closed cleanly on each reload via the abort signal, then re-opened against the new container) AND the default one-shotPOST /invocations(the reload re-runs the single shot — cdkd extends the loop here; cdk-local treats single-shot HTTP as a no-op WARN). For MCP / A2A runtimes--watchis a no-op WARN and the single shot proceeds (those protocols run once and exit with no reconnect surface). Reloads are chain-serialized (no parallel reloads); a reload-callback failure exits the loop cleanly instead of blocking on a stale port. Plumbing: the cold-boot container sequence (image resolution → env build →runDetached→ log stream) is hoisted into the exportedbootAgentCoreContainer(...)so the rebuild callback re-runs it against a fresh synth; the existing one-shot--ws//invocationsbehavior is byte-for-byte unchanged (the watch path is purely additive).loadAgentCoreAssetContext+deriveOldAssetHashare NOT exported fromcdk-local/internalso they are copied into the cdkd module (verified againstnode_modules/cdk-local/dist/internal.d.ts). New--watchOption (default false) registered near--ws;watch?: booleanadded toLocalInvokeAgentCoreOptions. Unit tests:tests/unit/local/invoke-agentcore-watch-loop.test.ts(14 cases — classifier soft-reload vs rebuild dispatch, classifier-failure rebuild fallback, reload-chain serialization, clean WS abort on reload, rebuild-failure loop exit, benign-close exit, thesoftReloadAgentContainerdocker-cp + restart wiring, theisAgentCoreWatchEligibleMCP/A2A no-op predicate, and flag registration). Thelocal-invoke-agentcoreintegverify.shgains a--watchscenario (Test 21): open a long-lived--ws --watchsession against the EchoAgent in loop mode, edit the agent source to inject a unique marker, and assert the watcher logs a reload verdict + the re-opened session surfaces the new marker.BREAKING (
cdkd local invoke-agentcore): the--ws-interactiveflag is removed;--wsnow auto-detects a TTY and enables the interactive REPL automatically, following cdk-local (cdk-local#274 / cdk-local#278). When stdin is a TTY, lines typed after the initial--eventframe are sent as follow-up text frames (one per line, blank lines skipped), and each received frame is printed with a trailing newline + a'> 'prompt — a multi-turn REPL on the same/wsconnection. When stdin is piped / redirected (CI), only the initial frame is sent and output stays wire-faithful (no extra newlines / prompts) — the one-shot behavior. Migration: users who passed--ws-interactivemust drop it (REPL is now implicit in a TTY); to force one-shot inside a TTY, redirect stdin from/dev/null(cdkd local invoke-agentcore <t> --ws </dev/null). Plumbing: dropped thewsInteractiveoption field, the--ws-interactiveOption registration, and thewsInteractive && !wswarn guard; the--wsbranch computesconst interactive = process.stdin.isTTY === trueand threads it through frameSource creation + the new exportedwrapWsOnMessage(sink, interactive)helper (+WS_REPL_PROMPT = '> ');readStdinLines()now skips strictly-empty lines.--watchon/ws(cdk-local#270) was deferred from this PR (cdk-local does not export itsrunAgentCoreWatchLoop, which lives inside cdk-local's own command tightly coupled to its synth / image-build / container-lifecycle internals) — it shipped in the follow-upcdkd local invoke-agentcore --watchentry above (a cdkd-owned watch loop on top of cdk-local's exported watch primitives, not a shim ofrunAgentCoreWatchLoop). Unit tests: 7 new cases forwrapWsOnMessage(interactive newline+prompt / non-interactive identity / no double-newline) andreadStdinLines(skips empty, keeps whitespace-only) intests/unit/cli/local-invoke-agentcore-pure-helpers.test.ts. Thelocal-invoke-agentcoreintegverify.shTest 18 was rewritten to drop--ws-interactive(piped stdin = non-TTY = one-shot: asserts the initial ack is present and the piped lines do NOT become follow-up frames).docs/local-emulation.mdupdated.cdkd local start-api --assume-role-auto— ports cdk-local'sstart-api --assume-role-autoflag (cdk-local#271, issue #256 Option 1) into cdkd's OWN start-api command (cdkd does not use cdk-local's start-api command, so the flag is not auto-inherited by the bump). The bare boolean auto-resolves EACH routed Lambda's own execution role per-Lambda instead of a single global default: per-Lambda boot tries the synthesized template's literal-ARNProperties.Rolefirst, then falls back to a deployed-state lookup (resolveExecutionRoleArnFromState, reused fromlocal-invoke.ts), and warns-and-passes-through to the developer's shell credentials when neither recovers the ARN. Precedence: per-Lambda override (--assume-role <LogicalId>=<arn>) > (--assume-role-autoOR global default--assume-role <arn>) > unset.--assume-role-autois mutually exclusive with the global-default--assume-role <arn>form (errors at boot via the newnormalizeStartApiAssumeRoleguard insrc/cli/options.ts) but compatible with per-Lambda--assume-role <LogicalId>=<arn>overrides (the map wins for named Lambdas, auto-resolve handles the rest). Plumbing:AssumeRoleOptiongainsbareAutoResolve?: boolean;LocalStartApiOptionsgainsassumeRoleAuto?: boolean; new exportedresolveStartApiAssumeRoleArn(...)replaces the bareeffectiveAssumeRoleArn(...)call inbuildContainerSpec.assumeLambdaExecutionRoleis unchanged (region-only). New unit testtests/unit/cli/local-start-api-assume-role-auto.test.ts(15 cases) covers the normalization guard, the full resolver precedence, the literal-ARN + state-lookup + miss paths, and flag registration. Slower boot (one STS call per Lambda) but the right shape when each Lambda's deployed role differs.cdk-localbumped 0.69.0 → 0.77.1 — cdkd follows the upstream local-emulation engine forward. The bulk of the delta is auto-inherited through thecdk-local/internalleaf-module shims and the ECS service-emulator option helpers (addStartServiceSpecificOptions/addAlbSpecificOptions) thatcdkd local start-service/start-albalready call, so the new behavior lands with no cdkd.addOption(...)duplication. Newly inherited oncdkd local start-service/cdkd local start-alb: the--image-overridefamily (--image-override <target>=<imageRef|dir|Dockerfile>plus per-service--image-build-arg/--image-build-secret/--image-targetvariants — pin or locally build a replica's image instead of the deployed registry tag; cdk-local#241 / #244),--shadow-ready-timeout <ms>(per-invocation override of the shadow-replica TCP-ready probe budget, default raised to 60s; cdk-local#266), live streaming of each replica's container stdout / stderr to the host terminal (cdk-local#231), and the no-rule-matched 404 now explaining which listener fields were evaluated onstart-alb(cdk-local#229). Also inherited across the shimmed modules: an interactive spinner during longdocker build/docker pull(cdk-local#269), the source-change classifier now defaulting TypeScript edits to a rebuild (precompiled setups were left stale by a soft-reload; cdk-local#236),--profilenow fully threaded across every STSClient site (cdk-local#254), and auth / watcher / HTTPv2 / classifier rejection reasons now surfaced instead of swallowed into debug-log fallbacks (cdk-local#253). Required cdkd call-site adaptation (cdk-local #252/#253 type change): cdk-local switched its pass-through JWKS / discovery warn-dedup state from aSet<string>(warn once ever per URL) to aWarnedAt=Map<string, number>(warn once per time-window).src/cli/commands/local-start-api.tsrenames its localjwksWarnedUrls = new Set<string>()tojwksWarnedAt = new Map<string, number>()(passed tostartApiServer's renamedjwksWarnedAtoption at both the HTTP-API and WebSocket server-construction sites), andsrc/cli/commands/local-invoke-agentcore.ts'sverifyJwtViaDiscoverycall passes{ warnedAt: new Map<string, number>() }instead of{ warned: new Set() }.sigV4WarnedForeignIdsis unchanged (still aSet<string>). No user-facing behavior change from the rename — the dedup window is engine-managed; the type swap was required purely to typecheck against the newcdk-local/internalsignatures. The agentcore--wsREPL UX polish (cdk-local#278) and the--wsauto-TTY-detection /--ws-interactive-drop (cdk-local#274) +start-api --assume-role-auto(cdk-local#271) land in cdkd through follow-up PRs (cdkd owns those command surfaces, so they are NOT auto-inherited by the bump). All 5405 unit tests pass against 0.77.1 with only the three call-site renames above.
Recently Implemented (2026-05-31):
cdkd local start-service --watch/cdkd local start-alb --watch— sub-second reload for interpreted-handler source edits (Phase 4 of cdk-local#214; cdk-local bumped 0.64.0 → 0.69.0). Each watcher firing now runs a per-target classifier: source-only edits on interpreted-language handlers (Node / Python / Ruby / shell) inside a CDK image asset take a bind-mount FAST PATH (docker cpthe new source into each replica +docker restart, nodocker build, no shadow boot, typical end-to-end latency well under a second; classifier logsverdict=soft-reloadand the runner emitsSoft-reloaded replica … restart + TCP-ready probe complete). Dockerfile / dependency manifest / compiled-language source / asset-hash-unchanged / ambiguous edits keep running the Phase 1-3 rebuild rolling primitive verbatim (shadow boot under a bumped generation suffix + TCP-ready probe + atomic Service-Connect / Cloud Map / front-door pool swap; classifier logsverdict=rebuild (…)and the runner emitsRolling replica … swap complete). Either path rolls one replica at a time, so the multi-replica zero-connection-refusal guarantee is preserved.cdkd local start-servicepreviously did NOT expose--watchat all because cdkd was not calling cdk-local'saddStartServiceSpecificOptionshelper — this PR re-exports the helper fromsrc/cli/commands/ecs-service-emulator.tsand wires it intocreateLocalStartServiceCommand, so--host-port(cdk-local 0.62+) AND--watch(cdk-local 0.69+) now land incdkd local start-service --helpand any future start-service-only flag the helper adds inherits automatically.cdkd local start-alb --watch(already wired viaaddAlbSpecificOptions) inherits Phase 4 wording with no code change. No state-source / behavior change for users who do not pass--watch— the classifier only fires on a watcher reload. New integ fixturetests/integration/local-start-service-watch-fast/(modeled on cdk-local'stests/integration/local-start-service-watch-fast/): single-replica Node-22 ECS service with awebapp/server.cjsinterpreted handler (the.cjsextension keeps the committed source out oftests/integration/.gitignore's*.jssweep);verify.shbootscdkd local start-service --watch, rewritesserver.cjsv1 → v2 and assertsverdict=soft-reload+Soft-reloaded replica … complete+ the v1 → v2 transition oncurl /(with zero rebuild verdicts post-edit), then rewrites the Dockerfile and assertsverdict=rebuild (Dockerfile edit …)+Rolling replica … (swap|single-replica reload) complete+ the v2 → v3 transition + clean SIGTERM teardown. Unit testtests/unit/cli/local-start-service.test.tsextended with assertions that--host-port/--watchare declared and that--watchdefaults tofalse. Verified end-to-end via/run-integ local-start-service-watch-fast(Docker integ — no AWS deploy). Closes #743.BREAKING (
cdkd local start-alb): HTTPS listener default flipped from auto-TLS-terminate to plain HTTP, matching cdk-local 0.64.0'scdkl start-alb(cdk-local#203). A cloud-HTTPS listener is now served over plain HTTP locally —X-Forwarded-Proto: httpsis preserved so the upstream app still sees the deployed listener protocol. Users who relied on the prior default to terminate TLS locally (auto-generating a self-signed cert) MUST add--tlsto restore TLS termination. New--tlsopt-in flag is auto-implied by--tls-cert/--tls-key. Refactor follow-up to PR #725 / PR #731: dropped cdkd's local definitions ofparseLbPortOverrides/resolveAlbTarget/albStrategy/pickStack/notFoundand the 5.addOption(...)blocks for ALB-specific flags (--lb-port/--tls-cert/--tls-key/--no-verify-auth/--bearer-token) — these moved to cdk-local 0.64.0's bundledaddAlbSpecificOptions+ ALB strategy/helper exports (cdk-local#203). cdkd'ssrc/cli/commands/local-start-alb.tscollapses from 421 LOC to ~110 LOC;src/cli/commands/ecs-service-emulator.tsre-exports the new ALB symbols fromcdk-local/internal.LocalStartAlbOptionsgainstls?: boolean. Net change: cdkd'sstart-albautomatically inherits any future ALB-only flag the upstreamcdkl start-albadds without manual.addOption(...)duplication. Unit testtests/unit/cli/local-start-alb.test.tstrimmed to cover only the cdkd-specific--from-state/--state-bucket/--state-prefixwiring + thecdkdExtraStateProviderssingleton-identity check (theparseLbPortOverrides/resolveAlbTarget/albStrategy.resolveBootsblocks moved to cdk-local's own test). Docslocal-emulation.mdupdated with the new--tlsrow + the listener-protocols section's default flip. Verified end-to-end via/run-integ local-start-alb-from-state(Docker integ — real AWS deploy +cdkd local start-alb --from-stateboot + plain-HTTP front-door curl +--from-statesubstitution + clean SIGTERM teardown).BREAKING (
cdkd local start-api): SigV4 default flipped from fail-closed to warn-and-pass, matching cdk-local'scdkl start-api. The CLI flag is renamed:--allow-unverified-sigv4(opt OUT of fail-closed) is removed and replaced by--strict-sigv4(opt IN to fail-closed). Users who relied on the prior default to deny unverifiable AWS_IAM SigV4 requests against REST v1AuthorizationType: 'AWS_IAM'/ Function URLAuthType: 'AWS_IAM'MUST add--strict-sigv4to theircdkd local start-apiinvocation. The previous cdkd-divergent default (security review #484) drove embedConfig branching + per-flag plumbing that compounded drift across every shim slice; following cdk-local removes that maintenance cost. Plumbing changes:src/cli/commands/local-invoke.ts'sCDKD_EMBED_CONFIGflipssigV4StrictByDefault: true → falseandsigV4OptFlag: '--allow-unverified-sigv4' → '--strict-sigv4';LocalStartApiOptions.allowUnverifiedSigv4?: booleanrenames tostrictSigv4?: boolean; the twosigV4Strict: options.allowUnverifiedSigv4 !== truetranslation sites inlocal-start-api.tsflip tosigV4Strict: options.strictSigv4 === true; the.addOption(new Option('--allow-unverified-sigv4', ...))block becomes--strict-sigv4with the inverted help text; shim header comments insrc/local/http-server.ts/src/local/sigv4-verify.tsupdated;tests/unit/cli/local-embed-config.test.tsassertion updated to the new values. The memory rulefeedback_shim_blocked_by_unadopted_semantic_divergence.mdrecords the case-A → case-B retrofit pattern: even a deliberate documented divergence is worth re-examining when its maintenance cost compounds. Verified end-to-end via/run-integ local-start-api(Docker integ).Property-coverage backfill (issue #609): wired 7 props on
AWS::Lambda::EventSourceMappingin one bundle —KmsKeyArn,LoggingConfig,MetricsConfig,ProvisionedPollerConfig,Queues,Topics,StartingPositionTimestamp— all previously silent-dropped byLambdaEventSourceMappingProvider. Per the AWS SDK shape audit (@aws-sdk/client-lambda3.xCreateEventSourceMappingRequestvsUpdateEventSourceMappingRequest), 4 of the 7 ride BOTH create + update (KmsKeyArn/LoggingConfig/MetricsConfig/ProvisionedPollerConfig) and 3 are create-only (Queues/Topics/StartingPositionTimestampare absent fromUpdateEventSourceMappingRequest— AWS rejects mutation, CFn replaces the resource on a template change, which cdkd's diff layer schedules independently). Wire-format casing flip: CFn schema spells the encryption key asKmsKeyArn(lower-casems), but the SDK field isKMSKeyArn(upper-caseMS); bothcreate()andupdate()do the flip andreadCurrentState()flips back so cdkd state stores the CFn-shaped key.StartingPositionTimestampcoercion: CFn supplies a Number (epoch seconds per the AWS::Lambda::EventSourceMapping schema), the SDK wants aDate;create()coerces (number/ISO-string/Date all accepted), andreadCurrentStateconverts back to epoch seconds so the drift comparator sees the same shape on both sides (a missed conversion would surface phantom drift on every clean run). Update gated onprev !== next: the 4 mutable props use!== undefined(not truthy) so explicit''reaches AWS as the documentedKMSKeyArnclear-back-to-AWS-owned-key sentinel rather than being silently dropped.readCurrentStateis emit-when-present for all 7 — AWS returns these only when the user set them, so a phantomKmsKeyArn: ''/LoggingConfig: { ...defaults }placeholder would force guaranteed drift on every clean run for the typical un-configured ESM. With this slice theAWS::Lambda::EventSourceMappingtype is now COMPLETE (itssilentDropset is empty and the whole key is dropped fromtests/fixtures/cfn-schemas/_todo-backfill.json). 18 new unit tests across 3 files: 8 inlambda-eventsource-provider.test.ts(create command branches — KmsKeyArn casing flip, LoggingConfig/MetricsConfig/ProvisionedPollerConfig forwarding, Queues, Topics, StartingPositionTimestamp number / ISO-string / Date coercion, all-7-omit-when-absent), 4 inlambda-eventsource-provider-roundtrip.test.ts(KmsKeyArn casing flip mirrors create, empty-string KmsKeyArn clear-sentinel, LoggingConfig/MetricsConfig/ProvisionedPollerConfig update forwarding, 3-create-only-silent-omission from UpdateInput), 6 inlambda-eventsource-provider-readcurrentstate.test.ts(KMSKeyArn casing flip-back emit-when-present, KmsKeyArn omit-when-absent guard against false-positive drift, LoggingConfig/MetricsConfig/ProvisionedPollerConfig together, Queues/Topics array-clone, StartingPositionTimestamp Date→epoch-seconds conversion, all-7-omit-when-absent). Real-AWS verified via the existingtests/integration/dynamodb-streams/fixture — theDynamoEventSourceL2 gains a smallFilterCriteria(so AWS actually persistsKmsKeyArn— without filter criteria the key is a no-op and AWS silently doesn't surface it onget-event-source-mapping), a newkms.Keyfor the filter-criteria encryption with a Lambda-servicegrantEncryptDecrypt(so AWS authorizes the encryption op), andaddPropertyOverrideforKmsKeyArn+MetricsConfigon the synthesized L1 (the L2 doesn't surface these top-level props). The verify.sh extension asserts viaaws lambda get-event-source-mappingthat both props reached AWS. The other 5 props (LoggingConfig/ProvisionedPollerConfig/Queues/Topics/StartingPositionTimestamp) are source-kind-discriminated (Kafka / SQS / Kinesis-AT_TIMESTAMP-only) and don't apply to DynamoDB Streams; they are unit-test-covered.Broader real-AWS integ fixture for
cdkd local start-alb --from-state+.claude/rules/code-layout.mdrestructure (follow-up to PR #731 Part B). New fixturetests/integration/local-start-alb-from-state/: one stack with VPC (2 AZs, public-only, no NAT) + ALB + 2 ApplicationListenerRules (default + path/orders/*) + 2 TargetGroups + 2 ECS Fargate services (Web + Orders,desiredCount: 0to avoid container compute cost) + IAM execution role + LogGroup + 2 SecurityGroups. Each service's TaskDefinition carries anALB_DNS_NAMEenv var withFn::GetAtt: [Alb, DNSName]so the engine's state-source dispatcher MUST substitute the resolved DNS name from cdkd's S3 state whencdkd local start-alb --from-stateboots the containers locally.verify.shdoes pre-flight Docker orphan sweep, deploys the stack via cdkd, validates ALB viaaws elbv2 describe-load-balancers, bootscdkd local start-alb '<stack>/Alb' --from-state --lb-port 80=8080in background, asserts the boot banner + theALB front-door: ...:8080listener banner (proves--lb-portoverride), curlshttp://127.0.0.1:8080/and asserts the response body containsservice=web alb=<deployed-alb-dns>(proves default-action routing +--from-statesubstitution reached the Web container), curlshttp://127.0.0.1:8080/orders/and assertsservice=orders alb=<deployed-alb-dns>(proves ListenerRule path routing + multi-target boot ordering +--from-statesubstitution reached the Orders container), SIGTERMs cdkd, asserts zero leftovercdkd-local-*containers + networks, runscdkd destroy, and verifies the cdkd S3 state for the stack is empty. Closes the gap memory rulefeedback_never_defer_integ_from_originating_pr.mdrecords: the engine's host-side wiring (serviceStrategyfactory +cdkdExtraStateProvidersmap +LocalStartAlbOptionsindex-signature extension) is uniquely exercised end-to-end here; the pure-local siblingtests/integration/local-start-alb/fixture cannot test substitution because there is no deployed state to read, and the upstream cdk-local engine's integ tests its own surface, not cdkd's shim. The fixture also drove discovery + fix of a verify.shset -o pipefailbug (aws s3 lsreturns exit 1 when the prefix has zero objects, which would have terminated the post-destroy state-verification step before printing the success banner)..claude/rules/code-layout.md's giantsrc/local/**bullet had its Service Connect / Cloud Map /ecs-service-runner.ts/ecs-service-resolver.ts/cloud-map-registry.ts/cloud-map-resolver.ts/createSharedSvcNetwork+SHARED_SVC_SUBNET_OCTETparagraph (originally added by issues #466 / #460 to describe the pre-refactor topology) replaced with a single sentence pointing forward to the PR #731 Part B changelog entry — the modules described there were deleted in #731, so the prose was stale at the head + the trailing "Part B annotation" sentence the PR #731 review flagged as suboptimal placement is no longer needed.Property-coverage backfill (issue #609): wired
TagsonAWS::CloudFront::Distribution, whichCloudFrontDistributionProviderpreviously silent-dropped on write.Tagsis a standard CFn[{ Key, Value }]array; CloudFront's SDK gates tag-on-create behind a separate command class —CreateDistributionWithTagsCommand({ DistributionConfigWithTags: { DistributionConfig, Tags: { Items: Tag[] } } })— so the provider'screate()switches command class based on whetherproperties['Tags']is non-empty (an emptyTags: []from CFn is treated as "no tags" and routes through the plainCreateDistributionCommandto avoid hitting the tags-enabled control plane for nothing).update()gains a tag diff after the existingUpdateDistributionCommand: removals →UntagResourceCommand({ Resource: <ARN>, TagKeys: { Items: [...] } }), additions + value rewrites →TagResourceCommand({ Resource: <ARN>, Tags: { Items: [...] } })(TagResource overwrites a key's value on re-tag, so a same-key value rewrite is in the upsert set alone). The removal pass runs BEFORE the upsert pass so a renamed key (value-only edit on key K) is not accidentally cleared by a stale UntagResource.readCurrentStateis intentionally NOT added in this PR — CloudFront has noreadCurrentStatetoday (drift falls back to the CC-API path), and a partial implementation that reads onlyTagswhile ignoringDistributionConfigwould surface less drift than CC-API would; full readback is deferred to a separate PR. With this slice, theAWS::CloudFront::Distributiontype is now COMPLETE (itssilentDropset is empty and the whole key is dropped fromtests/fixtures/cfn-schemas/_todo-backfill.json—Tagswas the only outstanding entry).Tagsmoves fromsilentDroptohandledinproperty-coverage.generated.ts(regenerated viavp run gen:property-coverage— the raw codegen formatting artifact is normalized byvp check --fix). 8 new unit tests incloudfront-distribution-provider.test.tscover the create command-class switch (with Tags →CreateDistributionWithTagsCommand, without and withTags: []→ plainCreateDistributionCommand), the update tag-diff (add-only → TagResource, removal-only → UntagResource, value-rewrite on same key → TagResource only, unchanged → neither, mixed adds + removes → Untag then Tag in that order). Real-AWS verified via the existingtests/integration/s3-cloudfront/fixture — the L2cloudfront.Distributiongains twocdk.Tags.of(distribution).add(...)calls; a NEWverify.shdeploys, resolves the distribution ARN viaaws cloudfront get-distribution, asserts both tags viaaws cloudfront list-tags-for-resource, then destroys clean.Property-coverage backfill (issue #609): wired
ReservedConcurrentExecutionsonAWS::Lambda::Function, whichLambdaFunctionProviderpreviously silent-dropped on write. Real safety concern before this PR: a CDK template settingreservedConcurrentExecutions: 100to cap a function's concurrency would silently drop the cap on deploy via cdkd — production stacks could exceed their intended concurrency limits. Post-create control-plane API pattern (matches PR #719 RecursiveLoop):CreateFunctiondoes NOT accept the field; it requires a separatePutFunctionConcurrencyCommand({ FunctionName, ReservedConcurrentExecutions: number })call after function creation.create()issues the post-create call when the value is set (!== undefined, NOT a truthy gate — 0 is a meaningful value that throttles the function to zero concurrency); on failure issuesDeleteFunctionCommandasdelete-on-post-create-failureatomicity rollback (mirrors RecursiveLoop's pattern exactly).update()gates onprev !== nextstrict-compare; removal (prev: number, next: undefined) maps toDeleteFunctionConcurrencyCommand— unlike RecursiveLoop which has no clear API and just leaves the last-set value pinned, AWS provides a dedicatedDeleteFunctionConcurrencyso a user dropping the template prop actually un-throttles the function instead of silently leaving the limit in place.readCurrentStateadds a separateGetFunctionConcurrencyCommandcall after the primaryGetFunction, emit-when-present (the AWS response carriesReservedConcurrentExecutionsonly when the limit is set, so a typical un-throttled function correctly maps to omit-from-readback — no phantom drift). 9 new unit tests inlambda-function-provider.test.tscover create-send (with 50, with explicit 0, absent), atomicity rollback via DeleteFunction, update set / update clear via DeleteFunctionConcurrency / update no-diff, readback emit / omit. Real-AWS verified via/run-integ lambda(broad-set): the fixture'slambda.FunctiongainsreservedConcurrentExecutions: 5; verify.sh extends the existing RecursiveLoop assertion withaws lambda get-function-concurrency --query ReservedConcurrentExecutionsreturning5. 9 resources deployed clean, all 3 assertions pass (provisionedBy='sdk' + RecursiveLoop='Allow' + ReservedConcurrentExecutions=5), 9 destroyed with 0 errors / 0 orphans.cdkd local start-service <targets...>refactor onto the shared ECS service emulator engine (follow-up to PR #725'sstart-albshim work, completes the symmetry the ALB PR explicitly deferred). The old 944-linesrc/cli/commands/local-start-service.ts— owning the per-replica boot loop + shared docker network + Cloud Map registry + per-targetcreateLocalStateProvider+ manual env-substitution + SIGINT single-flight cleanup — collapses to a ~120-line shim mirroringlocal-start-alb.ts: aLocalStartServiceOptionsinterface extending the engine'sEcsServiceEmulatorOptionswith cdkd's--from-state/--state-bucket/--state-prefix, a smallserviceStrategy(options): EmulatorStrategy(picker vialistTargets(stacks).ecsServices, picker text "Select one or more ECS services to run", trivialresolveBootsmapping each chosen target to{ target }since the engine'sbootOneTargetcallsresolveEcsServiceTargetinternally,lbPortOverrides: {}since services have no listener ports), and acreateLocalStartServiceCommand()that wires the sharedrunEcsServiceEmulator(targets, options, serviceStrategy(options), cdkdExtraStateProviders)engine entry. The shared engine + Cloud Map + sidecar machinery has lived in cdk-local since 0.62.0 (PR #725's pre-work) — Part B just adopts it for the second consumer. With this refactor, every per-replica boot orchestration / shared-network / sidecar-credentials / Cloud Map registry / state-provider-per-target / cross-stack-resolver / assume-task-role / profile-credentials-file / SIGINT-cleanup mechanic is owned by cdk-local for BOTHstart-serviceANDstart-alb— adding a feature now means changing one upstream module instead of two byte-identical command files. Now-dead code DELETED from cdkd's tree:src/local/ecs-service-runner.ts(959 lines — the entire per-replica orchestrator + Cloud Map publish + subnet allocator),src/local/ecs-service-resolver.ts(596 lines — service-discovery resolver, now in cdk-local's bundled engine),src/local/cloud-map-registry.ts(11-line shim no longer imported by anyone),src/local/cloud-map-resolver.ts(13-line shim no longer imported by anyone),tests/unit/local/ecs-service-runner.test.ts(1934 lines — every test is now exercised by cdk-local's own bundled test),tests/unit/local/ecs-service-resolver.test.ts(379 lines — same), andtests/unit/cli/local-start-service-profile-creds.test.ts(resolveSharedSidecarCredentialsis now sourced from cdk-local via theecs-service-emulator.tsshim — testing it from cdkd was dead-coverage).src/local/ecs-network.tskeeps itscreateTaskNetwork/destroyTaskNetwork/buildMetadataEnv/buildEndpointSubnetexports (used by the still-localecs-task-runner.tsforcdkd local run-task) but dropscreateSharedSvcNetwork+SHARED_SVC_SUBNET_OCTET(the start-service-specific shared-network factory) since the engine creates its own shared network from cdk-local's bundled equivalent. Net diff: -3500 LOC in cdkd's tree with zero behavior change for the user-facingcdkd local start-servicecommand — every flag (--cluster/--env-vars/--container-host/--assume-task-role/--no-pull/--ecr-role-arn/--platform/--max-tasks/--restart-policy/--from-state/--from-cfn-stack/--state-bucket/--state-prefix/--stack-region) and every behavior (replica boot / Cloud Map peer discovery / Service Connect aliasing / shared sidecar/role/<arn>credentials / profile-credentials-file bind-mount / cross-stackFn::ImportValuesubstitution / ^C teardown) renders identically post-refactor. Thetests/unit/cli/local-commands-dispatcher-wiring.test.ts(issue #611 dispatcher-wiring scan) now tracks only the three direct-dispatch commands (local-invoke/local-start-api/local-run-task);local-start-servicejoinslocal-start-albin the engine-wired category where the dispatcher invocation lives inside cdk-local'srunEcsServiceEmulatorand reaches cdkd's S3-backed--from-statefactory transparently via the sharedcdkdExtraStateProvidersmap. The pre-PRMAX_TASKS_SUBNET_RANGE_CAPexport fromlocal-start-service.tsis dropped (the engine's bundledparseMaxTasksenforces the same cap with the same error message). Real-AWS verified via the existingtests/integration/local-start-service/fixture (single-service replica boot + Cloud Map peer registration + ^C cleanup, against real Docker; AWS deploy is N/A for this pure-local fixture). A broader multi-service + ALB integ exercising--from-statesubstitution against deployed cdkd state is deferred to a follow-up PR — Part B's risk surface is concentrated in the smallserviceStrategy()factory + the dispatcher-wiring test update (both unit-tested), and the shared engine itself was already verified end-to-end against real AWS by PR #725 Part A.Property-coverage backfill (issue #609): wired
TagsonAWS::S3Vectors::VectorBucket, whichS3VectorsProviderpreviously silent-dropped on write.Tagsis a standard CFn[{ Key, Value }]array. The AWS SDKCreateVectorBucketInput.tagsaccepts a flatRecord<string, string>shape;createVectorBucket()converts the CFn array → SDK map and passes it onCreateVectorBucketCommand(omit-when-absent — an emptyTags: []array sends notagsfield so no spurious CloudTrail event fires). VectorBucket has NOUpdateVectorBucketAPI (the provider'supdate()is already a no-op), so update-side wiring is intentionally not added — a tag change requires a destroy+recreate via cdkd's existing replacement path.readCurrentStateadds a second AWS call (ListTagsForResource(resourceArn=vectorBucketArn)) after the primaryGetVectorBucket, converts the SDKRecord<string, string>back to CFn[{ Key, Value }]shape, and emitsTags: []when AWS returns no tags or whenListTagsForResourceitself fails (best-effort; the drift comparator stays happy). With this slice, theAWS::S3Vectors::VectorBuckettype is now COMPLETE (itssilentDropset is empty and the whole key is dropped fromtests/fixtures/cfn-schemas/_todo-backfill.json).Tagsmoves fromsilentDroptohandledinproperty-coverage.generated.ts(962 handled, 409 silent-drop). New unit tests intests/unit/provisioning/providers/s3-vectors-provider.test.ts(Tags forwarded as the SDK Record<string,string> shape; absent and empty-array variants both omittagsfrom the SDK input) andtests/unit/provisioning/s3-vectors-provider-readcurrentstate.test.ts(readback surfaces Tags via the new ListTagsForResource hop, reshapes SDK map to CFn[{Key, Value}], falls back toTags: []when ListTagsForResource fails). The two pre-existing roundtrip cases (Class 1 — readCurrentState does not emit KMSKeyArn on an AES256 bucket+readCurrentState emits both SSEType and KMSKeyArn on aws:kms) were updated to mock the new ListTagsForResource call alongside their existing GetVectorBucket mock and to expectTags: []in the result. Real-AWS verified by extending the existingtests/integration/s3-vectors/fixture — theCfnVectorBucketL1 gainstags: [{ key: 'env', value: 'cdkd-integ' }, { key: 'team', value: 'platform' }], and a NEWverify.shdeploys, resolves the bucket ARN viaaws s3vectors get-vector-bucket --query vectorBucket.vectorBucketArn, asserts both tags viaaws s3vectors list-tags-for-resource, then destroys clean (1 deployed, 1 destroyed, 0 errors, 0 orphans).Property-coverage bookkeeping fix (issue #609): retired the stale
AWS::ECS::TaskDefinition:InferenceAcceleratorsentry fromtests/fixtures/cfn-schemas/_todo-backfill.json. The property was ALREADY declared inECSProvider.unhandledByDesign(with rationale "AWS Elastic Inference end-of-life 2024-04; use AWS Inferentia / Trainium accelerator instance families instead") but the backfill todo file still listed it, so the property-coverage strict-mode test was the only thing keeping the entry technically "tracked". Pure bookkeeping cleanup — zero src wire changes, no integ needed.property-coverage.generated.tsregenerated to reflect the move fromsilentDropto the implicit-handled set (961 handled, 410 silent-drop, down from 411).New
cdkd local start-alb <targets...>command (issue #86): run an Application Load Balancer locally — name one or moreAWS::ElasticLoadBalancingV2::LoadBalancerresources, discover the ECS / Lambda targets behind each listener'sforwardaction, boot every backing ECS service via the shared enginelocal start-serviceuses, and stand up a per-listenernode:http(s)front-door that round-robins inbound requests across the running replicas and applies the listener rules (path / host / header / method / query-string / source-IP). The symmetric counterpart oflocal start-apifor ALB-fronted workloads. Models cdk-local'scdkl start-alb, ported into cdkd's command tree as a 2-shim + 1-command trio:src/local/elb-front-door-resolver.ts(re-exportsresolveAlbFrontDoor+isApplicationLoadBalancer+ the front-door type set fromcdk-local),src/cli/commands/ecs-service-emulator.ts(re-exports the sharedrunEcsServiceEmulatorengine +addCommonEcsServiceOptions+ theEcsServiceEmulatorOptions/EmulatorStrategy/Planned*types fromcdk-local/internal), and the 421-linesrc/cli/commands/local-start-alb.tscommand file (createLocalStartAlbCommand+ exportedparseLbPortOverrides/resolveAlbTarget/albStrategyhelpers). The cdk-local-side engine + resolver were released in coordinated upstream PR cdk-local#190 as cdk-local 0.62.0 so cdkd's shim consumer pattern works without inlining the ~1000-line front-door + per-replica boot orchestrator. Listener / action support: HTTP and HTTPS listeners (TLS terminated locally via--tls-cert/--tls-key, auto-generated self-signed cert as fallback cached under$XDG_CACHE_HOME/cdk-local/alb-https/); forward (single target group AND weighted forward across multiple target groups), redirect (301 / 302 with protocol / host / port / path / query overrides), fixed-response (configurable status code / content-type / body); all six rule condition fields (path-pattern, host-header, http-header, http-request-method, query-string, source-ip); ECS targets (viaAWS::ECS::Service.LoadBalancers[]binding the TG to a container + port) AND Lambda targets (viaTG.Targets[].Id = {Fn::GetAtt: [<FnLogicalId>, "Arn"]}); authenticate-cognito + authenticate-oidc actions enforce a local Bearer-JWT check (orAWSELBAuthSessionCookiepass-through) against the same JWKS / OIDC discovery URL the deployed ALB would (signature + iss + aud + exp). Per-listener host-port remap via--lb-port <listenerPort>=<hostPort>(repeatable) for macOS where privileged listener port < 1024 cannot bind without root (default: host port == listener port). State-source flags (--from-state/--from-cfn-stack/--state-bucket/--state-prefix/--stack-regionmirroringlocal start-service) ride through to the backing services via the shared engine — the engine internally callscreateLocalStateProvider(options, ..., extraStateProviders)per backing-service boot, and cdkd's S3-backed--from-statefactory is wired via the new exportcdkdExtraStateProviders({ fromState: fromStateFactory }) insrc/cli/commands/local-state-source.ts. The newLocalStartAlbOptionsinterface extendsEcsServiceEmulatorOptionswith cdkd-specificfromState/stateBucket/statePrefixfields (carried through cdk-local's[key: string]: unknownindex signature). Auth-guard opt-outs:--no-verify-authdisables the JWT check entirely;--bearer-token <jwt>injects a default Authorization header when the inbound request has none. New unit tests intests/unit/cli/local-start-alb.test.ts(30 cases:parseLbPortOverridesvalid / invalid / range / multi-entry semantics,resolveAlbTargetstack-prefix / multi-stack / non-ALB / missing-resource error paths, and the option-builder smoke test asserting the cdkd-specific--from-state/--state-bucket/--state-prefixflags are wired alongside the engine-inherited--from-cfn-stack/--stack-region/--lb-port/--max-tasks/--restart-policy/ etc.). Real-AWS verified via NEWtests/integration/local-start-alb/pure-local fixture (no AWS deploy): VPC-freeCfn*topology with one ALB + one HTTP:80 listener + one TargetGroup + one EC2-launchType ECS Service running busybox httpd on container port 80;verify.shbootscdkd local start-albwith--lb-port 80=8080, asserts the boot banner + the front-door listening banner, hitshttp://127.0.0.1:8080/and asserts the busybox container's fixed banner ("OK from cdkd-local-start-alb-fixture") routes correctly, then SIGTERMs and asserts clean teardown (zero leftovercdkd-local-*containers / networks). Thelocal-start-servicerefactor to also delegate to the shared engine (instead of its current per-replica boot loop) is deferred to a follow-up PR per scope.Property-coverage backfill (issue #609): wired
HostedZoneFeaturesonAWS::Route53::HostedZone, whichRoute53Providerpreviously silent-dropped on write.HostedZoneFeaturesis{ AcceleratedRecoveryStatus: 'ENABLED' | 'DISABLED' }— the AcceleratedRecovery feature targets a 60-minute Recovery Time Objective (RTO) for DNS operations during us-east-1 service disruptions (per the AWS launch blog); the feature itself is free (no premium-tier billing — verified against Route 53 pricing and the launch blog's "There is no additional cost for using accelerated recovery" statement). Unlike the direct-on-create backfills this session, this rides on a separate post-create control-plane API —CreateHostedZonedoes NOT accept the feature; it requires a follow-upUpdateHostedZoneFeaturesCommand({ HostedZoneId, EnableAcceleratedRecovery: boolean }). The backfill follows the post-create control-plane pattern established in PR #719 (Lambda::Function:RecursiveLoop):create()issuesUpdateHostedZoneFeaturesAFTERCreateHostedZonesucceeds when the template requested'ENABLED'(calling withfalseis skipped — AWS default is DISABLED, so the explicit-toggle hop is unnecessary); on failure issuesDeleteHostedZoneasdelete-on-post-create-failureatomicity rollback before throwing (the next deploy retry sees no orphan zone).update()is extended with the missingpreviousPropertiesparameter and gatesUpdateHostedZoneFeaturesonprev !== next— a removal (prev: ENABLED, next: undefined) is treated asDISABLED(the AWS default state, matching CFn's omit-default convention).delete()gains a pre-delete guard — AWS rejectsDeleteHostedZonewhile AcceleratedRecovery is anything other thanDISABLED(Cannot delete a hosted zone with accelerated recovery enabled. Please disable first.), sodeleteHostedZoneprobes the current status, issuesUpdateHostedZoneFeatures(false)if needed, and polls until the AWS-side state settles toDISABLED(default 10-min timeout / 15s interval; env-overridable viaCDKD_R53_ACCEL_RECOVERY_POLL_TIMEOUT_MS/CDKD_R53_ACCEL_RECOVERY_POLL_INTERVAL_MS). Without this guard, ANY zone deployed withHostedZoneFeatures.AcceleratedRecoveryStatus: 'ENABLED'would be physically un-destroyable via cdkd (the create path opts in, the destroy path'sDeleteHostedZoneis then rejected indefinitely until manualaws route53 update-hosted-zone-features --no-enable-accelerated-recoveryrecovery). Genuinely failed statuses (ENABLE_FAILED/DISABLE_FAILED) hard-fail the delete with an actionable error pointing the operator at the manual recovery command; the*_HOSTED_ZONE_LOCKEDtransients are waited through (see the 2026-06-02 fix below).readHostedZonesurfaces it back fromGetHostedZone.HostedZone.Features.AcceleratedRecoveryStatusemit-when-present (gated on!== undefined, NOT a default-when-absent placeholder — zones older than the 2025 feature launch return undefined indefinitely, so a phantom{ AcceleratedRecoveryStatus: 'DISABLED' }would force guaranteed drift on every clean run for the typical zone that never opted in). With this slice, theAWS::Route53::HostedZonetype is now COMPLETE (itssilentDropset is empty and the whole key is dropped fromtests/fixtures/cfn-schemas/_todo-backfill.json). New unit tests inroute53-provider.test.ts(create with ENABLED triggers post-createUpdateHostedZoneFeatures(true); absent omits; explicit DISABLED also omits — AWS default; failed UHF rolls back viaDeleteHostedZone+ProvisioningError; update prev=DISABLED→next=ENABLED fires UHF(true); update prev=ENABLED→next=undefined fires UHF(false) as the implicit-DISABLED transition; update with unchanged status does NOT fire UHF) androute53-provider-readcurrentstate.test.ts(readback emits whenGetHostedZone.HostedZone.Features.AcceleratedRecoveryStatusis present; omits when AWS returns noFeaturesblock). Real-AWS verified via the existingtests/integration/route53/fixture — theroute53.HostedZoneL2 gainsaddPropertyOverride('HostedZoneFeatures.AcceleratedRecoveryStatus', 'ENABLED')since CDK L2 does not expose the property;verify.shis extended (same style as the existing GeoProximityLocation / CidrRoutingConfig assertions) withaws route53 get-hosted-zone --query 'HostedZone.Features.AcceleratedRecoveryStatus'asserting'ENABLED'reached AWS, then destroys clean.Property-coverage backfill (issue #609): wired
ServiceConnectDefaultsonAWS::ECS::Cluster, whichECSProviderpreviously silent-dropped on write.ServiceConnectDefaultsis the cluster-wide default{ Namespace }ARN that new ECS services use when they enable Service Connect without specifying their own namespace; pre-PR the property's existing comment inupdateClusterexplicitly deferred this slice ("ServiceConnectDefaultsis also accepted by UpdateClusterCommand but is intentionally NOT applied here — create() and readCurrentState() do not surface it either"). It rides DIRECTLY onCreateCluster/UpdateCluster(the single SDK calls the provider already makes forAWS::ECS::Cluster) — there is NO separate control-plane API. CFn{ Namespace }maps 1:1 to the SDK'sserviceConnectDefaults: { namespace }(casing flip only).createClusterforwardsproperties['ServiceConnectDefaults']when present (omit-when-absent).updateClusteradds it to the existingsettingsChanged || configChangedJSON-stringify diff gate alongside ClusterSettings / Configuration so aServiceConnectDefaults-only change triggers a singleUpdateClusterCommand; the removal case sends the AWS-documentednamespace: ''sentinel (perClusterServiceConnectDefaultsRequest.namespacedocs — "If you update the cluster with an empty string""for the namespace name, the cluster configuration for Service Connect is removed") so a user dropping the property from their template actually clears the AWS-side default instead of being silently treated as no-op.readCurrentStateClusterreads it back fromDescribeClusters.serviceConnectDefaults.namespaceemit-when-present (gated on!== undefined, NOT a default-when-absent placeholder — a cluster that never set a default Service Connect namespace returns noserviceConnectDefaultsfrom AWS, so a phantom{ Namespace: '' }would force guaranteed drift on every clean run). With this slice, theAWS::ECS::Clustertype is now COMPLETE (itssilentDropset is empty and the whole key is dropped fromtests/fixtures/cfn-schemas/_todo-backfill.json—ServiceConnectDefaultswas the only outstanding entry).ServiceConnectDefaultsmoves fromsilentDroptohandledinproperty-coverage.generated.ts(regenerated viavp run gen:property-coverage— the raw codegen formatting artifact is normalized byvp check --fix). New unit tests inecs-provider.test.ts(create forwardsServiceConnectDefaults: { Namespace: '<arn>' }intoCreateClusterCommand; omit-when-absent),ecs-provider-roundtrip.test.ts(update emitsUpdateClusterCommandwithserviceConnectDefaults: { namespace: '<arn>' }on add; emits{ namespace: '' }clear-sentinel on removal; not present in input when only an unrelated field — ClusterSettings — changed), andecs-provider-readcurrentstate.test.ts(readback emitsServiceConnectDefaultswhen AWS returns it; omits for the typical cluster that did not configure a default namespace). Real-AWS verified by extendingtests/integration/ecs-fargate/verify.sh— the existingnew ecs.Cluster({ defaultCloudMapNamespace: { name: 'cdkd-test.local' } })synthesizes anAWS::ECS::ClusterwhoseServiceConnectDefaults.Namespacecarries the auto-createdAWS::ServiceDiscovery::PrivateDnsNamespace's Arn; the verify.sh extension asserts viaaws ecs describe-clusters --query 'clusters[0].serviceConnectDefaults.namespace'that the namespace ARN reached AWS (with a sanity check on thearn:*:servicediscovery:*:namespace/*shape), then destroys clean.Property-coverage backfill (issue #609): wired
TypeonAWS::SecretsManager::Secret, whichSecretsManagerSecretProviderpreviously silent-dropped on write.Typeis a single optional string scalar — the partner identifier for AWS Secrets Manager managed external secrets (third-party-managed secrets registered through partners like Snowflake / Datadog / MongoDB; see the AWS docs reference in the SDK comments onCreateSecretRequest.Type). It rides DIRECTLY onCreateSecret/UpdateSecret(the single SDK calls the provider already makes) — there is NO separate control-plane API. The SDK field name (Type) and casing already match CFn, so the backfill is a straight field-forward:create()passesproperties['Type']tocreateParams.Typetruthy-gated (omit-when-absent — empty string is a no-op on AWS, so the truthy gate matches the field's semantics);update()adds it to the existing update input builder with the same truthy gate (an explicit clear is also a no-op on AWS, no client-side sanitize required).readCurrentStatereads it back fromDescribeSecret'sTypeemit-when-present (gated on!== undefined, NOT a default-when-absent placeholder — the typical secret is non-partner-managed and AWS returns noType, so an''placeholder would force guaranteed drift on every clean run). With this slice, theAWS::SecretsManager::Secrettype is now COMPLETE (itssilentDropset is empty and the whole key is dropped fromtests/fixtures/cfn-schemas/_todo-backfill.json—Typewas the only outstanding entry).Typemoves fromsilentDroptohandledinproperty-coverage.generated.ts(regenerated viavp run gen:property-coverage— the raw codegen formatting artifact is normalized byvp check --fix). New unit tests extendsecretsmanager-secret-provider-roundtrip.test.ts(create sendsType: 'urn:partner:example'intoCreateSecretCommand; omit-when-absent; update sendsType: 'urn:partner:v2'intoUpdateSecretCommandon diff; omit-when-absent on update) andsecretsmanager-secret-provider-readcurrentstate.test.ts(readback emitsTypewhen AWS returns a partner identifier; omits for the typical non-partner-managed secret). Integ verified via/run-integ composite-stack(the existingAWS::SecretsManager::Secretrow in the fixture — noTypeset, so the integ exercises the omit-when-absent path end-to-end and confirms thehandledPropertiesaddition does not regress the existing secret deploy). TheTypewire itself is fully covered at unit level because the AWS-side validation of partner identifier strings is opaque (the field accepts only AWS-recognized partner IDs and would reject an arbitrary test value), so live verification of a real partner identifier is out of scope for this slice.New
cdkd local invoke-agentcore <target>command: run a Bedrock AgentCore Runtime container locally and invoke it once over the AgentCore protocol declared by the target (HTTP/invocations/ MCP streamable-HTTP / A2A JSON-RPC / AGUI streaming / bidirectional WebSocket via--ws). Models cdk-local'scdkl invoke-agentcore, ported into cdkd's command tree as 8 new shim files undersrc/local/agentcore-*.ts(agentcore-resolver/agentcore-code-build/agentcore-s3-bundle/agentcore-sigv4-sign/agentcore-client/agentcore-mcp-client/agentcore-a2a-client/agentcore-ws-client) + 1 newsrc/local/target-picker.tsshim + an expandedsrc/local/cognito-jwt.tsshim (addsverifyJwtViaDiscoveryto the re-export list for inbound JWT auth) + the ~1650-linesrc/cli/commands/local-invoke-agentcore.tscommand file. The command supports the container artifact (fromContainerAsset/fromEcr) and theCodeConfigurationmanaged-runtime artifact (fromCodeAsset, built from source) on all 4 protocols, plus inbound JWT auth verification against the runtime's OIDC discovery URL (customJwtAuthorizer), outbound SigV4 signing of/invocations(--sigv4), per-request timeout (--timeout, default 120s), session-id header binding (--session-id), platform override (--platform, defaultlinux/arm64per AgentCore's required arch), state-source flags (--from-state/--from-cfn-stackmirroringcdkd local invoke), role-assumption flags (--assume-roleauto-resolves the runtime'sRoleArnfrom cdkd state when bare), and ECR cross-account image pulls (--ecr-role-arn). The shim pattern follows the established 33-file precedent from #713: every shim is a small re-export from'cdk-local/internal'so the actual implementation lives in cdk-local and cdkd consumes it verbatim. Two cdk-local-side exports were added in a coordinated upstream PR (cdk-local#177, released as cdk-local 0.61.0) —pickAgentCoreCandidateStack(image-uri candidate stack picker) andresolveSingleTarget(interactive picker for omitted target) — so cdkd's shim consumer pattern works without inlining 250+ lines of helpers.resolveExecutionRoleArnFromStateinsrc/cli/commands/local-invoke.tswas extended with an optionalrolePropertyparameter (defaulting to'Role') so the agentcore command can reuse it with'RoleArn'(the field name onAWS::BedrockAgentCore::Runtime). The cdkdlocal-state-source.tsshim addsresolveCfnFallbackRegionandExtraStateProvidersto its re-export list. Cross-cuttingsrc/local/docker-runner.tsextension for the new command's protocol diversity + secret-handling needs: adds optionalcontainerPort?: number(defaults to 8080 so the existing RIE Lambda local-invoke path is unchanged; MCP runtimes pass 8000, A2A runtimes pass 9000 so the docker-pflag publishes the right port) and optionalsensitiveEnvKeys?: ReadonlySet<string>(always unioned with the newSENSITIVE_ENV_KEYSconstant covering the AWS credential set, so decrypted SecureString SSM values + AWS creds are routed through docker's value-from-process-env form-e KEYrather than-e KEY=value— the values never appear on thedocker runargv /ps//proc/<pid>/cmdline/ verbose debug logs). New unit test intests/unit/cli/local-invoke-auto-assume-role.test.tscovers the 3rd-argrolePropertyextension's'RoleArn'case. Integ fixturetests/integration/local-invoke-agentcore/mirrors cdk-local's: EchoAgent (HTTP), ProtectedAgent (JWT auth), McpAgent (MCP), CodeAgent (CodeConfiguration source-build), A2aAgent (A2A), AguiAgent (AGUI) — verify.sh exercises 20 end-to-end scenarios against Docker. Out of scope (carried over from cdk-local): real Bedrock AgentCore SDK invocation against the cloud (cdkd local *is local-only by definition). The command does NOT replace the existingcdkddeploy path forAWS::BedrockAgentCore::Runtime(that usessrc/provisioning/providers/agentcore-runtime-provider.ts); they are distinct paths — the provider deploys agentcore to AWS, the new command runs an agentcore container locally for debugging.Property-coverage backfill (issue #609): wired
LogConfigonAWS::Events::EventBus, whichEventBridgeBusProviderpreviously silent-dropped on write.LogConfigis a nested object{ IncludeDetail?: 'NONE' | 'FULL', Level?: 'OFF' | 'ERROR' | 'INFO' | 'TRACE' }that controls EventBridge's per-bus log emission to CloudWatch Logs / S3 / Firehose (separateAWS::Events::LogStreamresources route the output). It rides DIRECTLY onCreateEventBus/UpdateEventBus(the single SDK calls the provider already makes) — NO separate control-plane API.create()forwardsproperties['LogConfig']to the SDK input when present (omit-when-absent);update()adds it to the existing JSON-stringify diff gate alongsideDescription/KmsKeyIdentifier/DeadLetterConfig(so aLogConfig-only change triggers a singleUpdateEventBus);readCurrentStatesurfaces it back fromDescribeEventBus.LogConfigemit-when-present (NOT the always-emit-placeholder pattern that the siblingDeadLetterConfiguses — AWS only returnsLogConfigwhen set, so a phantom{ Level: 'OFF', IncludeDetail: 'NONE' }placeholder would round-trip into spurious drift on buses that never configured logging). Each sub-field is gated on!== undefinedindividually, so partial AWS responses surface only the user-controllable fields.LogConfigmoves fromsilentDroptohandledinproperty-coverage.generated.ts(regenerated viavp run gen:property-coverage— the raw codegen formatting artifact is normalized byvp check --fix);AWS::Events::EventBus'ssilentDropbecomes EMPTY (only entry wasLogConfig) and the whole key is dropped fromtests/fixtures/cfn-schemas/_todo-backfill.json. New unit tests ineventbridge-bus-provider-roundtrip.test.ts(create forwardsLogConfig: { Level: 'INFO', IncludeDetail: 'FULL' }intoCreateEventBusCommand; create omits when absent; update emits a singleUpdateEventBusCommandon diff; update-no-op produces zeroUpdateEventBuscalls) andeventbridge-bus-provider-readcurrentstate.test.ts(readback emits LogConfig when AWS returns it; omits when undefined). Real-AWS verified via a NEWtests/integration/eventbridge/verify.shthat deploys the existingEventBridgeStack(now withlogConfig: { level: events.Level.INFO, includeDetail: events.IncludeDetail.FULL }on the L2events.EventBus), asserts viaaws events describe-event-busthat both sub-fields reached AWS, then destroys clean.NestedStackProvider.deletemarks its two UNHEALABLE deterministic refusals non-retryable, so a user-controlled child stack name can no longer make them look transient (issue #1849, thesrc/provisioning/**sibling of #1838). Scope deliberately stated as the two marked arms rather than as the whole message: the third arm — a plain, non-interrupted child failure — is left UNMARKED because a whole-child re-destroy can genuinely heal, and an unmarked error is classified by MESSAGE, so that arm remains name-dependent. There is nomarkRetryableto assert the healable reading with, and marking it would make the healable case terminal for everyone, so the residual is accepted rather than fixed.nestedStackChildFailureMessageinterpolates<parent>~<childLogicalId>into its text andretryable-errors.tsclassifies by SUBSTRING, so a nested stack named e.g.MyDependencyViolationSubmatchedDependencyViolation— the only whitespace-free entry inRETRYABLE_ERROR_MESSAGE_PATTERNS. The fix ismarkNonRetryableat the THROW SITE (not in the constructor and not innested-stack-messages.ts, which is required to stay import-free), applied per arm rather than to the class: theerrorCount > 0throw is marked ONLY when the child destroy was also interrupted — there a retry does not heal anything, it re-enters a destroy the user just aborted, becausedrainingindestroy-runner.tsis a per-invocation local with a per-invocation SIGINT listener. A plain (non-interrupted) child failure is left UNMARKED, because a whole-child re-destroy runs against the child's preserved state and a resource still draining on attempt 1 may genuinely be gone on attempt 2. Left unmarked its classification stays MESSAGE-driven, so — measured —Parent~MyDependencyViolationSubclassifies retryable there whileParent~PlainSubdoes not: that arm keeps the name-dependence, which is the residual described above rather than a second fix. The provider's other deterministic refusal — thewithNestedStackContextwiring check — is marked too, since AsyncLocalStorage is read inside the same async context on every attempt. Both marks are LATENT today (disableOuterRetry = truemakesdestroy-runner.tscomputemaxAttempts = 0, short-circuitsDeployEngine.withRetry, androllback-executor.tsdoes not wrap deletes at all) and are landed as the declaration that survives any of those opting back in — the same reasoning #1778 used for its six latent sites. Unit tests intests/unit/provisioning/nested-stack-provider.test.tspin the MARKER rather than the wording: each of the two MARKED arms is paired with a CONTROL built from the byte-identical message, so a future reword cannot satisfy the assertion while the mark is gone. The unmarked arm gets the opposite treatment — a row pinning BOTH halves of its name-dependence (poisoned id retryable, ordinary id not), so a later blanket-mark breaks loudly and the residual stays visible instead of being described only in prose.Region canonicalization for every synthesized ARN / URI (issue #1850) —
AppSyncProvider.buildAppSyncArn, the fourCloudControlProvider.enrichResourceAttributessites (KMS KeyArn, ECR RepositoryArn, ECRRepositoryUri, Kinesis StreamArn), the fivesrc/utils/s3-endpoints.tshelpers, the syntheticAWS::StackId, and — widest — ONE fold atIntrinsicFunctionResolver.constructAttribute's destructure, which feeds every ARN / URI the resolver builds (DynamoDB, SNS, Logs, ECR, ECS, ...). All of them interpolated the region VERBATIM, socdkd deploy --region US-EAST-1recordedarn:aws:appsync:US-EAST-1:...as the resource'sFn::GetAttanswer. IAM policy matching IS case-sensitive, so such a value matches no policy and is rejected by every SDK call that takes it, and it is persisted intostate.json, so it outlives the deploy and is what every laterFn::GetAtt/cdkd driftreads. Each site folds throughcanonicalizeRegion(src/utils/aws-partition.ts), mirroring the fix issue #1824 applied toSSMParameterProvider. The PARTITION segment was already correct —derivePartitionAndUrlSuffixcanonicalizes its own input since issue #1795 — and double-folding is a no-op, which is what makes the two safe side by side. The S3 fold lives INSIDEs3-endpoints.ts, not at its callers, which was a review finding rather than the first cut: that module has TWO callers —S3BucketProvider.buildAttributes(what lands in state) and the resolver — so folding at the resolver alone made theFn::GetAttanswer disagree withreadCurrentState, the exact phantom drift the module exists to prevent (issue #1745). ForWebsiteURLit is not even a spelling difference: the separator comes from a case-SENSITIVE Set lookup, so an upper-cased region MISSES the legacy-dash set and takes the wrong separator. TheRepositoryUrihost matters for its own reason — the recorded URI is handed todockerand parsed back byparseEcrRegistryHost(src/utils/ecr-uri.ts), whose canonical-segment guards are exactly what an upper-cased label has to get past. Upgrade consequence, stated because it is user-visible: a stack already deployed with a non-canonical--regionkeeps its old value in state (constructAttributeruns only for UNCACHED attributes), so the next diff of a resource whose attribute is re-resolved sees a property change — and where that property is create-only (e.g.AWS::SNS::Subscription.TopicArn) that classifies as a REPLACEMENT. Deliberate: the recorded value is unusable, so converging it is the point. Two sibling classes are deliberately NOT covered and are filed rather than left silent: six SDK providers build ARNs fromclient.config.region()instead ofaccountInfo.region(issue #1881), and theAWS::Regionpseudo-parameter still returned the raw spelling so a user's ownFn::SubARN inherited it (issue #1882, CLOSED 2026-08-25 by a fold at its source). Two claims in this entry were corrected on 2026-08-25 and the correction is recorded here rather than by silent edit, because three follow-up issues inherited them verbatim: this entry said an upper-cased--regionis reachable "since DNS is case-insensitive and the SDK endpoint still resolves, so the deploy SUCCEEDS", and it is not — SigV4 scopes a credential to the region STRING and the service compares it case-sensitively, so every call is refused withSignatureDoesNotMatch: Credential should be scoped to a valid region(measured 2026-08-25 against this repo's vendored SDK on STS and CloudFormation, and the same day'scdkd-side measurement in issue #2065 had already recorded the S3 preflight dying the same way). The values this fold corrects are therefore reached from region sources OTHER than a raw--region— an explicitoverrideRegion, a library caller buildingAwsClientsdirectly — rather than from the flag; the fold is right either way, but the reachability story it shipped with was wrong. Unit tests pin BOTH polarities at every site (an upper-cased region folds; an already-canonical one comes out byte-identical), acn-north-1row per file so the partition and the region segment are proven to land together, one fully literalarn:aws-cn:expectation so a wrong-but-consistent partition cannot move with the bug, and theWebsiteURLlegacy-dash branch flip in both directions.cdkd exportbackfills a pre-#1761AWS::EC2::SecurityGroupIngressrule id by live-reading AWS (issue #1791) —src/cli/commands/export.ts,tests/unit/cli/export-sg-ingress-rule-id-backfill.test.ts,docs/cli-reference.md,docs/state-management.md. A row written before #1761 hasattributes: {}, and export is all-or-nothing, so ONE such row made a whole stack un-exportable with no workaround (AWS returns thesgr-…id only fromAuthorizeSecurityGroupIngress, so a no-op re-deploy heals nothing).CompositePhysicalIdIdentifiergains an optional asyncbackfillreached ONLY after the state-onlyresolvethrew — so a row that already records theIdissues no AWS call — and theAWS::EC2::SecurityGroupIngressentry supplies one: a paginatedDescribeSecurityGroupRulesfiltered by the composite'sgroupId, matched on the composite's(ipProtocol, fromPort, toPort)tuple with the protocol canonicalized on BOTH sides (the #1643 fold, so a record packed fromIpProtocol: 6matches AWS'stcp) and egress rules excluded. EXACTLY ONE match is adopted; zero, more than one, an unparseable composite, a failed lookup and an exhausted page ceiling each REFUSE with a message naming the row and what was ambiguous — the same discipline #1761 applies on its already-exists arm, because two rules sharing that tuple are two rules cdkd's own physical id cannot tell apart either. The MATCHES are counted before any candidate is set aside for carrying an unusable id, so a genuine 2-match whose other candidate AWS reported without ansgr-…id refuses instead of adopting the survivor (and the zero case can no longer claim it "found NO ingress rule matching" a tuple that rules did match); a BLANK port segment is refused beforeNumber('')can read it as port0and look up a real tuple (EC2Provider.cfnIngressPortValuereads the same blank as-1, so the two layers did not agree on what it meant); the lookup is wrapped in the standard throttle-onlywithRetry, so oneRequestLimitExceededno longer aborts an all-or-nothing export and a persistent throttle is reported as a throttle rather than as a missingec2:DescribeSecurityGroupRules; the walk is memoized per GROUP for the lifetime of ONEbuildImportPlancall (never module-global, which would be shared across the stacks a concurrent run exports), caching the OUTCOME rather than anErrorso each row's refusal still names ITSELF; and the ambiguity refusal names the SECOND cause too — two DISTINCTAWS::EC2::SecurityGroupIngressresources differing only by source have byte-identical composite ids, so "split it per source" is not their remedy (repairattributes.Id, or drop the row) — and that two-cause remedy is appended to the unusable-id refusal as well whenever more than one rule matched, since such a row is ambiguous however readable its ids are and would otherwise be told only to "open a cdkd issue".Fix: a WebAuthn-only User Pool no longer gets an invented
MfaConfiguration: OPTIONAL—src/provisioning/providers/cognito-provider.ts. Bug (issue #1920): the MFA-config family (EnabledMfas/EmailAuthentication*/WebAuthn*) is applied by a post-createSetUserPoolMfaConfigcall, and because that API is a full-replace — an omittedMfaConfigurationresets the pool to OFF and drops the factor blocks being enabled in the same call — cdkd defaulted the field toOPTIONALwhenever the template omitted it. That reasoning only holds when the call actually enables an MFA FACTOR. WebAuthn is not one, so a passkey-first pool (passkeyRelyingPartyIdwith nomfa, whose CloudFormation default forMfaConfigurationisOFF) had AWS reject the call withInvalid MFA Configuration given. SMS MFA, Email MFA, or Software Token MFA must be enabled.; the post-create atomicity path then deleted the freshly created pool, socdkd deployfailed on a template the CDK CLI deploys fine. Fix: when the template omitsMfaConfiguration, default toOPTIONALonly if the call enables a factor, else toOFF— CloudFormation's own default. An explicit template value still wins in every case. The factor test is deliberately TWO-part (EnabledMfasnon-empty OR a factor sub-block emitted), and both halves are load-bearing. The requested-set half keeps a MISSPELLED entry loud:EnabledMfas: ['SOFTWARE_TOKEN']emits no factor block, so a recognized-set-only test would have sentOFF, which AWS ACCEPTS — the pool would have shipped with MFA silently disabled and the declared factor dropped, turning a loud pre-existing failure into a silent one. The same hole exists via the property's SHAPE rather than its spelling: a hand-written YAML scalar (EnabledMfas: SOFTWARE_TOKEN_MFA) or a!Refto aStringparameter failsArray.isArray, so it too read as "no factor declared". A present-but-non-list value is therefore treated as factor intent as well (and warned about) — inhasMfaConfigPropstoo, so the call is not skipped outright and the factor dropped one layer earlier. Keying on what the template DECLARED rather than on what cdkd RECOGNIZES also keeps this forward-compatible with any factor AWS adds later, which is why an unrecognized entry WARNS rather than throwing. The warning is emitted AFTER the default resolves and reports the value actually being sent, because a warning written to PREDICT its outcome states the opposite in two branches: an explicit templateMfaConfiguration: OFFreally does deploy MFA-disabled, and a dropped entry riding alongside a recognized factor is not what fails the call. Both are named explicitly now, and the second arm says a factor block IS SENT rather than that a factor IS ENABLED, because that is all the code knows; predicting a rejection on the silent-drop path is the worst of the three things the message could say. Warn coverage is widened separately in issue #1932. The sub-block half deliberately leaves the email-OTP message/subject shape on its existingOPTIONALdefault:EmailMfaConfigurationis emitted for a bareEmailAuthenticationMessage/Subjectcustomization too, and whether AWS accepts that block underOFFis unverified — the integ cannot reach it (email-OTP needs a verified SES sender), so it was not flipped on an untested wire assumption (issue #1923). Applies to create and update alike (both route throughapplyMfaConfig). 23 new unit tests pin both polarities of the default (WebAuthn-only and an emptyEnabledMfas->OFF, on create AND update; a real factor, alone or alongside WebAuthn, and the SMS arm ->OPTIONAL), both polarities of the override (an explicitOFFbeats theOPTIONALdefault, an explicitONbeats theOFFone), the misspelled-entry guard, the non-list shape (its default, its warn, that it still issues the call at all, and that it keeps an explicit value offCreateUserPool),null/''as absence rather than intent, and both polarities of the warn; the WebAuthn-only test now pins the WHOLE request viatoEqual, since the original blind spot was precisely a field nobody asserted. Thecognitointeg fixture gained both arms: aPasskeyOnlyUserPool(WebAuthn, noMfaConfiguration) assertingOFFwith its WebAuthn config intact, and aFactorDefaultUserPool(a factor, noMfaConfiguration) assertingOPTIONAL— the inverse regression, where the fix would silently disable MFA on a pool that asked for it.BackfillUserPoolmoved fromOPTIONALto an explicitONand its assertion was pinned exactly, becauseOPTIONALis what the default produces anyway and so could not distinguish a threaded explicit value from a fired default.update()can now report a PARTIAL outcome, and the ACM replacement orphan stops being invisible —src/types/resource.ts,src/deployment/update-outcome.ts,src/deployment/deploy-engine.ts,src/cli/commands/{deploy,drift}.ts,src/deployment/rollback-executor.ts, the ACM, two IAM and API Gateway providers. Gap (issue #1819):deletegained a skip channel in #1752;updatehad none, so an update that discovered it could not finish had two options — return normally (a lie) or throw (fail the resource). Four providers implement a REPLACEMENT insideupdate()by pairing create and delete, and by the time the inner delete runs the NEW resource already exists, so aborting would strand an untracked resource. The three create-then-delete providers therefore emitted alogger.warnand the deploy exited 0 with the old resource alive and no longer in cdkd state — discoverable only by reading the log. Live half (issue #1922): ACM refuses to delete a certificate a consumer still references (typically a CloudFront distribution in another stack not yet updated), so the replacement's delete throwsResourceInUseExceptionand lands in exactly that swallowing catch. Fix:ResourceUpdateResultis now a base intersected with a discriminated outcome union — omitted /'updated', or'partial'with a REQUIRED reason, mirroringResourceDeleteResultso the requirement is compiler-enforced rather than asserted in a doc comment. Omission still means clean, so the ~80 providers returning a bare{ physicalId, wasReplaced }are untouched. Deliberately no'skipped'member: an update that cannot touch the resource at all throws, so that value would be an unreachable enum member. The naming is load-bearing.'partial', not'skipped':RESOURCE_SKIPPED's documented invariant is "the resource this row names was not destroyed", and a partial update DID update its row's resource. #1922 proposed emittingRESOURCE_SKIPPEDfor the row, which would have put the events store at odds with its own contract. The engine instead emitsRESOURCE_SUCCEEDEDfor the updated row AND aRESOURCE_SKIPPEDnaming the SURVIVOR — for which the invariant is exactly true, and which carries the physical id a cleanup pass needs, since state now points at the new resource. No new event type was required. Consumption followsdelete-outcome.tsexactly: a leafupdate-outcome.ts(no imports beyond the type, because the engine,drift --revertand the rollback executor all consume it and already sit on a dense import ring), a counter kept apart from bothupdatedanddeleteSkipped, apartial (<reason>)status line at warn level, a summary row shown only when non-zero, and the shared run-levelskippedcountercdkd eventsrenders as⚠N. All FOURupdate()call sites consume it — the deploy engine,drift --revert, and BOTH rollback-executor arms (replayRollbackand the--revert-failedreplayFailedOperations, the latter found only in review) — so no entry point silently drops what another reports.sns-subscriptionis NOT wired: it is delete-then-create and aborts on a SKIPPED delete, but a THROWN one is still caught and it creates anyway, leaving two live subscriptions delivering every message twice. That is a behavior change (a currently-succeeding deploy would start failing) and is filed as #1967 rather than bundled here. Not included: the exit-code half of #1922 step 3. Deploy does not exit non-zero for its own skipped-DELETE case either, so making a partial UPDATE do so would be arbitrary, and making both do so is a behavior change to every deploy that skips a delete — filed separately. 14 new unit tests (helper, ACM producer, engine wiring), each carrying its clean-update control so none passes vacuously, and each mutation-proven: reverting the ACM producer to warn-only fails 2, always-partial fails 1, disabling the in-use classifier fails 1, counting a partial as clean fails 1, dropping the survivor event fails 1, dropping the status line fails 1.